Reuse the segment handle across an iteration - #8
Open
mclueppers wants to merge 1 commit into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 vialfs_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
BasicFileStoreon LittleFS, let it take normal write traffic, then timefor (auto it = store.begin(); it != store.end(); ++it) { auto& e = *it; }. Timing a single*itshows the cost is inopen(), not the read.The solution
Keep the segment handle in the iterator and reuse it, swapping only when the walk crosses a segment boundary:
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().Fileholds ashared_ptr<FileImpl>andFileImplcloses on destruction, so the last copy to go away closes it exactly once. Closing explicitly would shut the file under the copyoperator++(int)returns, which may still be walking. Every record is addressed by an explicitseek(), 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,
BasicFileStoreon LittleFS, 200 records, reached through microReticulum's path table: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:The three failures are pre-existing on master and unrelated —
test_file_store_compact_basic,test_dir_prefix_reload_indexandtest_dir_prefix_compact, all failing on future-timestamp sweeping.test_iteratorandtest_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
FlashFileSystembackends. The change is backend-agnostic — it removesopen()calls and does not alter what is read or written — so a backend with a cheapopen()sees a smaller improvement rather than different behaviour.Closes #7