Skip to content

Close the active segment before compaction removes source segments - #6

Merged
attermann merged 1 commit into
attermann:masterfrom
dobrevit:fix/close-active-segment-before-compaction
Nov 10, 2026
Merged

attermann merged 1 commit into
attermann:masterfrom
dobrevit:fix/close-active-segment-before-compaction

Conversation

@mclueppers

Copy link
Copy Markdown
Contributor

compact() deletes every source segment, including the one currently open for appends. LittleFS and FatFS refuse to unlink an open file, so on those filesystems the active segment survived compaction ("Failed to unlink ... Has open FD") and on POSIX-like filesystems the handle silently kept writing into an unlinked inode.

Close the active file before phase 1 and reopen a segment on both exit paths: seg0 after a successful compaction (the internal callers then move on to seg1 as before), current_segment after a failed one, so a direct compact() call never leaves the store without a writable segment.

Adds test_file_store_compact_keeps_store_writable, which fails on master (the record written after compact() is lost on reload) and passes here.

compact() deletes every source segment, including the one currently open
for appends. LittleFS and FatFS refuse to unlink an open file, so on those
filesystems the active segment survived compaction ("Failed to unlink ...
Has open FD") and on POSIX-like filesystems the handle silently kept
writing into an unlinked inode.

Close the active file before phase 1 and reopen a segment on both exit
paths: seg0 after a successful compaction (the internal callers then move
on to seg1 as before), current_segment after a failed one, so a direct
compact() call never leaves the store without a writable segment.

Adds test_file_store_compact_keeps_store_writable, which fails on master
(the record written after compact() is lost on reload) and passes here.
mclueppers added a commit to dobrevit/RetiMesh_Node that referenced this pull request Aug 26, 2026
The upstream fixes are now PRs (attermann/microReticulum#82 and #85,
attermann/microStore#6) and live on our forks, so the default build pulls
dobrevit/microReticulum#retimesh/combined and
dobrevit/microStore#fix/close-active-segment-before-compaction from git
and relies on the new APIs unconditionally:

- Reticulum::jobs_interval(1.0f) replaces the manual Transport::jobs()
  call in the RNS task,
- the packet-carrying AnnounceHandler callback replaces the firmware's
  own announce parser/verifier (RetiTransportServer::noteAnnounce), which
  is removed; the SD-card announce log line moves to the handler.

RETIMESH_UPSTREAM_FIXES and [env:t3s3-upstream] are gone; [env:t3s3-local]
builds against sibling checkouts for library development.
@attermann

Copy link
Copy Markdown
Owner

Thanks for tracking this down and for adding a regression test. Losing writes after a direct compact() is a real bug, and closing the active segment before the sources are removed is the right fix.

Two things before this can go in:

  1. Failure-path reopen can create a stray segment. rotate_segment_if_needed() increments current_segment to _segment_count before it calls compact(). So when compaction fails from that path, open_segment(current_segment) opens (and creates) segment_<segment_count>.dat, which is outside the range. The caller then moves to the last segment, but that empty file is never cleaned up by finalize_compaction() or clear(), and this happens on the storage-full path. Could you clamp it, e.g. reopen min(current_segment, _segment_count - 1), or otherwise make sure only a valid segment is reopened? The same applies to the success path if the rename in finalize_compaction() fails and returns before resetting current_segment.
  2. Power-loss note. The contribution policy asks changes to compaction to explain what happens on a power loss at each step (section 5.5). A couple of lines in the description is enough: for example, that closing the handle happens before the COMPACTING journal is written and that nothing is written between the reopen and clear_journal().

Since maintainer edits aren't enabled on the branch, please push these yourself. Reviews may take a while on my end, but this one is close.

@attermann

Copy link
Copy Markdown
Owner

Thanks again for finding this and for the regression test. Closing the active segment inside compact() is the right fix, because it covers direct calls as well as the internal callers.

I'm merging this as-is and will handle the remaining issues myself in a follow-up commit, so you don't need to make any changes. The follow-up will:

  • flush the write buffer before closing the active segment;
  • reopen a segment on every exit path, including the early return when compact.tmp can't be opened, which currently leaves the store with no writable segment;
  • clamp the reopened segment to segment_count - 1, so a failed rotation-triggered compaction (or a failed rename in finalize_compaction()) can no longer create a stray seg<segment_count>.dat;
  • write up the power-loss behaviour for each step, as the contribution policy asks for compaction changes.

This also covers #11, which fixed the threshold-triggered path on its own.

@attermann
attermann merged commit 2e3bef1 into attermann:master Nov 10, 2026
attermann added a commit that referenced this pull request Nov 10, 2026
Follow-up to #6. compact() now flushes and closes the active segment
itself, runs the compaction body (moved to compact_segments()), and
reopens min(current_segment, segment_count - 1) on every exit path.

Fixes three issues in the #6 approach:
- a failed rotation-triggered compaction created a stray
  seg<segment_count>.dat that clear() and finalize never remove
- the early return when compact.tmp cannot be opened left the store
  with no active segment, so threshold-triggered and direct callers
  failed every later write until reboot
- a failed rename in finalize_compaction() could reopen an
  out-of-range segment

This also covers the threshold-path fix proposed in #11.

Power loss: the flush and close happen before the COMPACTING journal is
written, and the reopen happens after the journal is cleared (or
deliberately retained for boot-time recovery). No segment writes occur
in between, so crash recovery is unchanged.

Adds RAM-fs open/write failure injection and tests for direct,
threshold and rotation compaction failures.
@mclueppers
mclueppers deleted the fix/close-active-segment-before-compaction branch November 10, 2026 20:00
@mclueppers

Copy link
Copy Markdown
Contributor Author

Thanks for taking this over the line, and for the follow-up. I've read 84e09a6, bed1195 and d5282e6 against what you listed. The flush before the close, the reopen on every exit path, the clamp to segment_count - 1 and the power-loss notes are all there, and the journal-pending guard in compact() closes the truncation case as well. We've rebuilt our firmware on master with them, and the suite passes on our branches (113/113).

While checking the clamp I found an older edge case in the same area, so I've filed it separately as #17 with a fix in #18. With segment_count == 1, a successful compaction sends new writes to seg1, outside the scanned range, and those records are lost on reload. It isn't caused by #6 or your follow-up (0.1.7 does the same), and the defaults never hit it.

On your question in #11: a T-Beam (ESP32, LittleFS) has run on master + microReticulum for 30 minutes with no "Has open FD", but path-store compaction hadn't triggered in that window yet. I'll confirm there once a node has logged a compaction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants