Size a segment's head reserve without holding its records - #5
Conversation
Every per-file setting -- inline runs, compression, spread syncs, the head reserve -- has to be applied before the first key, and each is silent when forgotten: a plain segment where a compressed one was wanted, or a reserve of zero that costs the sparse reader a round trip and raises nothing. Four setters a caller has to remember is the wrong shape for that, and it is the shape that let `write_sorted` ship without compression in the first place. `create_with(path, opts, write, reserve)` takes them all at construction. It destructures `SegmentWrite` rather than reading its fields, so a setter added to the writer without a field beside it here stops the build instead of quietly defaulting. `SegmentWrite` is now exactly one field per setter, which is what makes that invariant true. The choice of whether the reserve holds a copy of the hash directory left it: `create_with` is handed the reserve as a number, so a policy field there would be a field that call does not read, and that is the failure this all started with. `write_sorted` computes the reserve with the directory in it and says in its own docs how to trade it away -- `for_lengths(..).without_directory()` through `create_with`. `write_sorted` is now that constructor plus the loop, so the batch path and the streaming path cannot apply settings differently. A test holds `create_with` to the four setters called by hand, at both reserves, and holds `write_sorted` to `create_with`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2wxgRU31dUrqaY8KwfsA2
`create_with` lets a caller stream, but it did not finish the job: to set the reserve they still had to call `for_lengths`, which takes a slice of every key's lengths -- sixteen bytes a key -- and, worse, allocated placeholder arenas as large as all the keys and all the inline runs put together. A caller avoiding a gigabyte of records was handed a function that allocates about as much. The estimate never needed the input, only aggregates: the key count, the record bytes, how the runs cut into blocks, and the lengths of the keys the fence samples. `Planner` accumulates those one key at a time. A first pass over lengths sizes the reserve, a second streams the records through `create_with`, and neither holds a value. What it keeps is one u32 per sixteenth key. The fence samples every stride-th key and the stride is not known until the count is, but every stride the format can choose is a power of two at or above the smallest, so every sampled key is a multiple of that smallest stride. At ten million keys that is 2.5 MB against 160 MB for a slice of lengths. The layout arithmetic still has one definition. `plan_inline` had it inline after its per-key loop, and it only ever depended on those four aggregates, so it moved to `flatindex::section_layout` and both callers use it. `for_lengths` is now the planner over a slice, so the arenas are gone from that path too, and `from_totals` no longer materialises a vector of every key. Tests: the planner and the slice agree over key counts either side of the sampling stride; what it retains is a sixteenth of a slice of lengths and a hundredth of the records; a key too long to frame is refused rather than sized; and a twenty-thousand-key segment written without holding a record opens from its own probe, reads back, and is byte-identical to the same segment written by the batch writer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2wxgRU31dUrqaY8KwfsA2
The module note still described planning the key section through `plan_inline` over placeholder keys, which is what the planner replaced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2wxgRU31dUrqaY8KwfsA2
There was a problem hiding this comment.
🟡 Changes recommended
reserve::Planner::push uses unchecked arithmetic for rec_bytes, which can overflow (notably on 32-bit/wasm32) and yield an incorrect reserve instead of failing.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Refactors segment head-reserve sizing so it can be computed from streamed length data (without holding records), and makes segment construction apply all per-file settings plus the reserve at creation time.
Changes:
- Add
reserve::Plannerfor one-pass aggregate sizing, and reworkreserve::for_lengths/from_totalsto use it. - Introduce
SegmentWriter::create_with(...)and routewrite_sortedthrough it to ensure all per-file settings are applied up-front. - Add/adjust tests to validate streaming write shape, reserve correctness, and constructor-vs-setters equivalence; update README description.
File summaries
| File | Description |
|---|---|
| tests/segwriter.rs | Adds coverage for create_with behavior and a two-pass streaming writer workflow that never retains full records. |
| src/reserve.rs | Implements reserve::Planner and rewires reserve sizing helpers to avoid allocating input-shaped buffers. |
| src/flatindex.rs | Factors layout arithmetic into section_layout and exposes helpers used by the new reserve planner. |
| src/db.rs | Adds SegmentWriter::create_with and simplifies write_sorted to size reserve and construct writer in one consistent path. |
| README.md | Updates the segment reserve narrative to mention the planner + streaming writer workflow. |
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Copilot's finding on the pull request, and it is right: `Planner::push` accumulated the record bytes with `+=` where `plan_inline` uses `checked_add`. Where a usize is 32 bits -- wasm32 -- that wraps, and `section_layout`'s guard against a section past what the index can address cannot catch it, because there `MAX_RECS` is `usize::MAX` and nothing is ever above it. The result would be a reserve computed for the wrapped total: a plausible number for a file nobody can write. Reading the surrounding lines for the same shape found two more. The run length was unbounded. The writer refuses a run it cannot address with a u32 extent; the planner now refuses it too, with a little headroom below that limit for the record framing's own rounding, which is what overflows first where the limit is `usize::MAX`. And `cut` compared `staged + n` against the block size, forming the sum before the comparison -- so the sum is what overflows. It asks whether `n` is past what is left of the block instead, which cannot. This one is reachable at any pointer width, through `from_totals` with whatever `max_run_len` its caller passes, so it is not only a 32-bit concern. Each has a test that fails against the code it replaces. The one for `cut` uses a length that wraps at any width rather than only at 32 bits, because a test that takes the path only on a target this repository does not run tests on would prove nothing here: wrapped, no block closes and the run is left staged, and the counts are what say which happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2wxgRU31dUrqaY8KwfsA2
There was a problem hiding this comment.
🟡 Changes recommended
SegmentWriter::create_with currently moves *write out of a shared reference (non-Copy), which should not compile and needs a small fix before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
src/reserve.rs:320
- The doc comment claims “a slice of lengths is sixteen bytes a key”, but
[(usize, usize)]is 16 bytes/key only on 64-bit; on 32-bit targets it’s 8 bytes/key. Consider expressing this in terms ofsize_of::<(usize, usize)>()(or qualify “on 64-bit”) to keep the docs correct across targets.
This issue also appears on line 453 of the same file.
src/reserve.rs:458
- This test hard-codes 16 bytes/key for a
Vec<(usize, usize)>slice (1_000_000 * 16), which is only true on 64-bit. Usingsize_of::<(usize, usize)>()keeps the assertion accurate if the test suite is ever run on 32-bit targets (e.g. wasm32).
assert_eq!(p.keys(), 1_000_000);
assert_eq!(p.retained_bytes(), 1_000_000usize.div_ceil(16) * 4);
// Against sixteen bytes a key for a slice of the same lengths, and
// the 116 bytes a key the records themselves are.
assert!(p.retained_bytes() * 60 < 1_000_000 * 16);
assert!(p.finish().is_some());
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
Copilot's suppressed comments on the pull request, and both are right. The planner's note priced a slice of lengths at sixteen bytes a key, which is a pair of usizes and so eight where a pointer is four -- and this crate builds for wasm32, where it is. A claim about memory that is only true on the target I happen to be on is the kind of imprecision this file spends its comments on elsewhere. The note says which width it is quoting, and the test asks `size_of::<(usize, usize)>()` instead of writing sixteen. Its margin drops from sixty to thirty, which is what still holds when the slice it is compared against halves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2wxgRU31dUrqaY8KwfsA2
There was a problem hiding this comment.
🔵 Needs a closer look
There is a confirmed overflow bug in flatindex::section_layout (keys * 2) and a README example that won’t compile as written due to missing references.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
src/flatindex.rs:739
section_layoutcomputes the hash capacity growth loop condition withkeys * 2, which can overflowusize(notably on 32-bit targets), potentially producing an incorrect capacity or an infinite/short loop. Use checked multiplication for thekeys*2target so overflow returnsNonelike the other sizing paths.
README.md:96- In the README inline call to
SegmentWriter::create_with, the arguments are shown by value (opts,write), but the actual signature takes&SegmentOptionsand&SegmentWrite. As written, this example won’t compile if a user copies it into code.
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Lite
Follow-up to #4, from two pieces of feedback:
write_sortednever set compression, and its slice of records is the wrong shape for a caller who will not hold them.The shape now
With the whole input in hand,
SegmentWriter::write_sorted(path, opts, write, generation, items)is those two steps in one call.SegmentWriter::create_with(path, opts, write, reserve)SegmentWriteinline_max,compress,sync_every— and nothing elsereserve::Plannerreserve::for_lengthsreserve::from_totalsreserve::ReserveWhy a constructor rather than setters
Every one of those settings must be applied before the first key, and each is silent when forgotten: a plain segment where a compressed one was wanted, or a zero reserve that costs the sparse reader a round trip and raises nothing. Four setters a caller has to remember is the wrong shape for that, and it is the shape that let the first
write_sortedship without compression at all — the same failureSegmentOptionsrefuses a compression field to avoid, reproduced one layer up.create_withdestructuresSegmentWriterather than reading its fields, so a setter added to the writer without a field beside it stops the build. That invariant is why the choice of what the reserve holds is not in that struct:create_withis handed the reserve as a number, so a policy field there would be one the call does not read.Why the planner
for_lengthstakes a slice of every key's lengths — sixteen bytes a key — and allocated placeholder arenas as large as all the keys and all the inline runs put together. A caller avoiding a gigabyte of records was handed a sizing function that allocates about as much. That was a defect in #4, not a gap in the request.The estimate never needed the input, only four aggregates: the key count, the record bytes, how the runs cut into blocks, and the lengths of the keys the fence samples. Only the last is awkward, because the fence's stride is not known until the count is — but every stride the format can choose is a power of two at or above the smallest, so keeping one
u32per sixteenth key covers any of them. At ten million keys that is 2.5 MB against 160 MB for a slice of lengths.Planner::retained_bytes()reports it, so the claim is checkable rather than trusted.The layout arithmetic still has one definition
It was inline in
plan_inlineafter its per-key loop and only ever depended on those aggregates, so it moved toflatindex::section_layout, whichplan_inlinecalls after counting and the planner calls after accumulating. Record bytes come fromrecord_len_tail, the block table's size fromblock_table_len(whichencode_blocksallocates by), the fence's stride fromfence_stride. What is left inreserveis the cut into blocks, four lines of the writer's own rule. A second copy of any of it would be a second definition of the format.Verified
sh scripts/check.sh— 98 tests, clippy-D warnings, formatted, wasm links.Vecfirst would pass every assertion and prove nothing, which is why the generator shape is the test.create_withis held to the four setters called by hand, at both reserves;write_sortedis held tocreate_with.One thing worth knowing
Byte-for-byte comparisons of segments normalise four words away. A segment's superblock records
SystemTime::now()in seconds and an FNV over the fields covers it, in each of the two slots, so two writes of identical input differ whenever they straddle a tick — which is a test that passes on a fast machine and fails on a slow one, and did, on macOS. A consequence beyond the tests: two builds of the same input are not the same bytes, so a segment cannot be content-addressed or cache-validated across builds. That is a property of the format, untouched here.Not done
This needs a source that can be traversed twice. For a single pass over something unrewindable,
from_totalsgives a bound, or write to a temp name and rename — happy to build for whichever they actually have.🤖 Generated with Claude Code
https://claude.ai/code/session_01F2wxgRU31dUrqaY8KwfsA2
Generated by Claude Code