The grain is extended and rebased without reading it whole - #189
Merged
Merged
Conversation
extend_grain and rebase_head read the entire .grain on every call just to learn how many records it held and where the last one ended; append_page read and rewrote it per received page. At a few hundred MB that was a transient allocation of the same size on every chunk flush. A .grain.commit beside the grain now records both numbers after each completed write, bound to the grain's inode and the bytes at its two ends, so a grain replaced or rebased by anyone else is walked once instead of trusted. Whatever lies past the committed end is a torn write and is cut before the next append. The commit is renamed into place without an fsync: a lost or older one describes a prefix of the file and costs re-deriving the difference. extend_grain: seek to the committed end and append. rebase_head: walk only the k dropped records, carry the commit through both the COLLAPSE_RANGE and the rewrite path. append_page: append in place. .rings is still read whole by extend_grain, and grain::load still copies every filter; neither is changed here. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
extend_grain parsed all of .rings on every call to learn the chunk count and take the records past what the grain covers. The records are a fixed stride after a declared header, so the count is the file's length and the tail is a single read. read_index_tail returns both. Its header validation is the one parse_index_versioned already did, factored into index_layout so the two cannot disagree about what a rings file is. On a store with an 89 MB grain, appending one chunk peaked at 96 MB before this series and 9 MB after. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This was referenced Oct 2, 2026
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.
Problem
extend_grainandrebase_headread the entire.grainon every call to learn how many records it holds and where the last one ends;append_pageread and rewrote it per received page; andextend_grainalso parsed all of.rings. The.grainstates neither number, so every write rediscovered them.The writer's maintenance tick calls
extend_grainwhenever the flushed-chunk count changes, i.e. on every chunk flush. A store with a ~130 MB grain therefore allocated ~130 MB, transiently, every few seconds (peak RSS ~195 MB), and a host running many such stores sees the sum.Change
.grain.commitbeside the grain recordscountandendafter each completed write. It is bound to the grain's inode, its 16 header bytes and the 16 bytes at its first record, so a grain replaced or head-dropped by anyone else (an older binary during a downgrade, say) no longer matches and is walked once instead of trusted. Anything past the committed end is a torn write and is cut before the next append.The commit is renamed into place without an fsync: a lost or older one describes a prefix of the file, which costs re-deriving the difference and never correctness.
extend_grain: seek to the committed end and append. No commit means one buffered forward walk, then a commit.rebase_head: walk only thekdropped records; carry the commit through both theCOLLAPSE_RANGEand the rewrite path.append_page: append in place.read_index_tail: the rings are a fixed stride after a declared header, so the chunk count is the file length and the tail is one read. Header validation is shared withparse_index_versionedviaindex_layout.No format change: the grain's bytes are identical, so no new magic and older binaries read it as before.
Measured
A store with an 89 MB grain, one chunk appended:
Tests
Extending in steps equals a one-shot build; a torn tail is cut with and without a commit; a commit for another file is not believed; a lagging commit is repaired; the commit survives
rebase_headon both the collapse (ext4) and rewrite (tmpfs) paths; adopted pages build a byte-identical grain; the rings tail equals the full parse from that point, including v1 numbering, a partial trailing record and refused headers. Mutating the code to ignore the commit failsthe_commit_governs_where_extending_resumes.Not in this PR
grain::loadstill reads the whole file and copies every filter, so a--hasquery holds about twice the grain.🤖 Generated with Claude Code