Skip to content

Reuse the segment handle across an iteration - #8

Merged
attermann merged 1 commit into
attermann:masterfrom
dobrevit:fix/iterator-reuses-segment-handle
Nov 10, 2026
Merged

attermann merged 1 commit into
attermann:masterfrom
dobrevit:fix/iterator-reuses-segment-handle

Conversation

@mclueppers

@mclueppers mclueppers commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

The problem

BasicFileStore::iterator::load_value() reopens the segment file for every record it reads. operator* calls it at each position, so walking N records performs N opens of the same file.

On LittleFS that is the expensive half. An open() resolves the path from the root each time, and resolving a component replays that directory's metadata log via lfs_dir_fetchmatch — a log that grows with write activity until it is compacted. A store under steady write traffic therefore charges more and more per open while the record read itself stays the same size.

Anyone iterating a store on a device with a watchdog is affected, and the failure only appears once the store has been in service a while — a freshly created one is fast. Measured on a Heltec V4 (ESP32-S3) with 200 records on LittleFS: 162 ms per record, ~32 s for the full walk. The caller fed a 30 s task watchdog once per pass, so the node rebooted every ~90 seconds until this was found. Full report in #7.

To reproduce: put ~200 records in a BasicFileStore on LittleFS, let it take normal write traffic, then time for (auto it = store.begin(); it != store.end(); ++it) { auto& e = *it; }. Timing a single *it shows the cost is in open(), not the read.

The solution

Keep the segment handle in the iterator and reuse it, swapping only when the walk crosses a segment boundary:

if (!seg_file_ || seg_held_ != iv_.segment) {
    char name[USTORE_MAX_FILENAME_LEN];
    store_->segment_name(iv_.segment, name);
    seg_file_ = store_->_filesystem.open(name, File::ModeRead);
    seg_held_ = iv_.segment;
}

Why the iterator and not the store. A handle cached on the store would need invalidating against every write, remove, rename and compaction. Scoped to the iterator there is nothing new to invalidate: begin() already flushes pending writes before iterating, and mutating the store already invalidates the iterator. The change claims validity for exactly the iteration's lifetime and no longer.

Why it is not closed in load_value(). File holds a shared_ptr<FileImpl> and FileImpl closes on destruction, so the last copy to go away closes it exactly once. Closing explicitly would shut the file under the copy operator++(int) returns, which may still be walking. Every record is addressed by an explicit seek(), so a handle shared with a copied iterator cannot be left on a stale position.

Trade-offs and limitations. An iterator now holds one file handle open for its lifetime, where before it held none between dereferences — so a caller that keeps an iterator alive for a long time also keeps a handle open. This is the same handle the previous code opened and closed on every access, so the peak is unchanged; only the duration differs. Backends where open() is cheap simply see a smaller win. Records are still read in index order, which is random access into the segment file; ordering the walk by offset would help further but is a larger change and not attempted here.

Compatibility. No public API, on-disk format or protocol change. Behaviour is identical; only the number of open() calls differs.

Testing

Measured on a Heltec V4 (ESP32-S3, 8 MB flash), Arduino core 3.3.11 / ESP-IDF 5.5.5, BasicFileStore on LittleFS, 200 records, reached through microReticulum's path table:

before after
per record 162 ms 32 ms
64-record pass 10368 ms 2073 ms
first walk after boot 12312 ms 2118 ms
full 200-record walk ~32 s ~6.4 s

Held steady over a 270 s run on a single boot; the watchdog reboots the change was chased for stopped.

Native test suite (pio test -e native), before and after, on the same machine:

before (master 0f28567):  74 test cases: 3 failed, 70 succeeded
after  (this branch):     74 test cases: 3 failed, 70 succeeded

The three failures are pre-existing on master and unrelated — test_file_store_compact_basic, test_dir_prefix_reload_index and test_dir_prefix_compact, all failing on future-timestamp sweeping. test_iterator and test_typed_store, which exercise this code, pass in both runs.

Not tested: nRF52 / RAK4631 and the ttgo-lora32-v21 target (no hardware to hand), and the FAT/SD and FlashFileSystem backends. The change is backend-agnostic — it removes open() calls and does not alter what is read or written — so a backend with a cheap open() sees a smaller improvement rather than different behaviour.

Closes #7

The iterator reopened the segment file for every record. On LittleFS an
open resolves the path afresh, and resolving a component replays that
directory's metadata log, which grows with write activity until it is
compacted — so a store that is appended to constantly charges more and
more per open while the read itself stays the same size.

Measured on a 200-record path store on an ESP32-S3, one record cost
93-162 ms, nearly all of it under lfs_dir_find. The caller walking the
table spent 32 s doing so and its task watchdog fired.

The handle is held for the iteration and swapped when a walk crosses a
segment boundary. begin() already flushes pending writes before
iterating and mutating the store already invalidates the iterator, so
nothing new is claimed about validity. It is deliberately not closed in
load_value(): File holds a shared_ptr and FileImpl closes on
destruction, so the last copy closes it exactly once, whereas an
explicit close would shut the file under the copy operator++(int)
returns.
@attermann

Copy link
Copy Markdown
Owner

Thanks for tracking this down and for the detailed measurements. The per-record open cost on LittleFS is a nasty one to find, and keeping the handle scoped to the iterator is the right place for it.

A couple remarks:
Wording: the description says the handle is swapped "when the walk crosses a segment boundary". But the walk follows std::unordered_map order, which is effectively random, not segment order. With records spread over several segments, it reopens a file on most steps, and the gain shrinks as the segment count grows. The reported 5× speedup probably reflects a store sitting mostly in one segment. The fix is still worthwhile; just keep expectations realistic for fragmented stores.
Missing tests: no test walks across more than one segment, and none uses post-increment while reading values. Small tests for both would cover the reopen path and the shared handle.

Thanks again for the contribution.

@attermann
attermann merged commit fa20cae into attermann:master Nov 10, 2026
attermann added a commit that referenced this pull request Nov 10, 2026
Follow-up to #8. The iterator now holds its segment file open until it
is destroyed, so extend the iterator warning: iterators must go out of
scope (not merely stop being used) before put/remove/clear/compact,
since some backends refuse to remove or rename an open file and each
held handle consumes a filesystem file slot.

Trim the load_value() comment to the essentials and note that the walk
follows hash order, so a store spread over many segments still swaps
handles frequently.

Add iterator tests for single-segment handle reuse (one read open per
walk), value correctness across multiple segments, and post-increment
copies sharing the handle.
@mclueppers
mclueppers deleted the fix/iterator-reuses-segment-handle branch November 10, 2026 20:00
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.

Bug: iterating a FileStore reopens the segment file for every record, costing 162 ms/record on LittleFS

2 participants