fix(btree): scan the complete keyspace during dense repack - #10
Merged
farhan-syah merged 5 commits intoJul 26, 2026
Merged
Conversation
… scans Several call sites still enumerated a tree by scanning against an invented upper bound (`[0xFF; N]`, `u64::MAX`, or a hand-rolled successor key). Keys are arbitrary byte strings with no reserved sentinel and no length ceiling, so any concrete upper bound sits inside the valid key domain and silently drops records at the top of the keyspace. Add `BTree::collect_all` for the "enumerate everything" case (dense repack, commit-history trimming, oldest-commit lookup) and switch the remaining catalog segment-row scans to `scan_prefix`, matching the prefix-based approach already used elsewhere. Covers the exact `[0xFF; 256]` boundary and a key extending past it with new tests in the B+ tree and compaction suites.
Extract the shared runtime/allocator-tracking/park-teardown plumbing out of the segment benchmark into benches/common, then reuse it for a new compaction benchmark that measures the dense repack path: build a store with a large enough free-list to force `compact_now` into the full rebuild, then time only the repack itself.
# Conflicts: # Cargo.lock
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.
Summary
Dense compaction enumerated the B+ tree with a range scan bounded by
[0xFF; 256],treating an all-
0xFFbyte string as outside the valid key domain. It is not.Keys are arbitrary byte strings with no reserved sentinel and no length ceiling,
so the exact 256-byte all-
0xFFkey — and any key extending it — was droppedfrom the rebuilt tree. The repack then published successfully, because the
truncated traversal was internally consistent. Silent, durable data loss rather
than a read error.
The original diagnosis, the
[0xFF; 256]boundary case, and the firstregression test are @presempathy-awb's. During review the fix was widened to
cover the rest of the same class and the benchmark was corrected; that work is
in the two later commits.
Root cause
The code expressed "scan every row" as a range against an invented maximum key.
A sentinel bound is only sound when the data model reserves the sentinel or
imposes a smaller maximum key. pagedb does neither.
d7c4856had already fixed the related catalog-prefix problem by moving thosescans to
scan_prefix, but did not touch the full-tree scan used by denserepack.
What changed
A named operation instead of a bound.
BTree::collect_all()is anexplicitly unbounded leftmost-to-rightmost leaf traversal. Expressing the full
scan as
scan_prefix(&[])would also be correct, but relies on "the emptyprefix matches everything" as an unstated coincidence;
collect_allsays whatit does and documents why it cannot be a range scan. It also drops the
per-record bound comparison entirely.
The whole class, not just the one site:
compaction/helpers.rscollect_range(&[], &[0xFF; 256])collect_all()— the data-loss fixtxn/db/reader.rs,txn/db/catalog.rs×3collect_range(0u64.to_be, u64::MAX.to_be)collect_all()— an exclusive[0xFF; 8]bound hid au64::MAXcommit idrecovery/reconcile.rschecked_addsuccessor + overflow guardscan_prefix(CatalogRowKind::Segment)txn/db/segment.rs,recovery/deep_walk.rs,txn/db/snapshot.rs[0x01]..[0x02]scan_prefix(CatalogRowKind::Segment)The commit-history sites were reachable only at
u64::MAX, and the catalogranges were correct as written. They are included because they were the same
shape — a bound computed by arithmetic — and leaving them is leaving a pattern
for the next person to copy.
Tests
Both were confirmed to fail against the pre-fix behavior and pass after, by
temporarily restoring the bounded scan:
tests/btree_basic.rs—collect_allat the tree level, covering the exactsentinel, a key extending it, and the empty-tree case.
tests/compaction_basic.rs— end-to-end dense repack, asserted after therepack and again after reopening the store, so the claim is about what was
durably published rather than cached state. Guarded by
main_db_pages_reclaimed > 0, which onlyatomic_dense_repackwrites — sothe assertion proves the path under test actually ran instead of returning
early.
The ordinary-survivor assertions are deliberate: they fail an over-broad fix
that only rescues high-byte keys.
The first-draft test
compact_now_preserves_ff_256_user_keywas folded intocompact_now_preserves_top_of_keyspace_keys, which covers the same boundaryplus the extending key and the reopen.
Benchmark
benches/compaction.rsaddscompaction/dense_repack: 1,200 keys inserted,1,100 deleted,
compact_now()timed.benches/common/holds the harnessplumbing both bench targets share.
The first draft measured
Dbteardown as part of the operation.Bencher::iter_with_setupstops its timer after the routine's value isdropped, so the store — its buffer pool and whole in-memory file — was torn
down inside the timed region. Parking it for the next untimed setup phase
removed it:
Roughly half the original figure was teardown, not repack. Treat the mean as
±10% run-to-run: seven samples on a
MemVfsworkload is thin.The workload runs on
MemVfs, so the number is the repack's CPU + AEAD cost —full-tree enumeration, bulk rebuild, header commit — not the cost of writing a
repacked file to real storage. That is noted in the bench module header.
benches/segment.rsalso had no tracking allocator, so its allocation columnsreported zero; the shared harness gives both targets one, and the figures are
now comparable between them.
Verification
On the merged result:
cargo fmt --all --checkclean,cargo clippy --all-targets --all-features -- -D warningsclean,cargo nextest run --all-features409 passed / 4 skipped, both bench targets run to completion.Compatibility
Enumeration-only behavior change. No format, public API, feature flag,
dependency, or VFS contract movement. Keys that were already below the old
bound come back in the same order with the same values; keys at the top of the
keyspace now survive.