Skip to content

fix(btree): scan the complete keyspace during dense repack - #10

Merged
farhan-syah merged 5 commits into
NodeDB-Lab:mainfrom
presempathy-awb:codex/upstream/catalog-prefix-hardening
Jul 26, 2026
Merged

fix(btree): scan the complete keyspace during dense repack#10
farhan-syah merged 5 commits into
NodeDB-Lab:mainfrom
presempathy-awb:codex/upstream/catalog-prefix-hardening

Conversation

@presempathy-awb

@presempathy-awb presempathy-awb commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Dense compaction enumerated the B+ tree with a range scan bounded by [0xFF; 256],
treating an all-0xFF byte 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-0xFF key — and any key extending it — was dropped
from 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 first
regression 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.

d7c4856 had already fixed the related catalog-prefix problem by moving those
scans to scan_prefix, but did not touch the full-tree scan used by dense
repack.

What changed

A named operation instead of a bound. BTree::collect_all() is an
explicitly unbounded leftmost-to-rightmost leaf traversal. Expressing the full
scan as scan_prefix(&[]) would also be correct, but relies on "the empty
prefix matches everything" as an unstated coincidence; collect_all says what
it 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:

Site Was Now
compaction/helpers.rs collect_range(&[], &[0xFF; 256]) collect_all() — the data-loss fix
txn/db/reader.rs, txn/db/catalog.rs ×3 collect_range(0u64.to_be, u64::MAX.to_be) collect_all() — an exclusive [0xFF; 8] bound hid a u64::MAX commit id
recovery/reconcile.rs hand-rolled checked_add successor + overflow guard scan_prefix(CatalogRowKind::Segment)
txn/db/segment.rs, recovery/deep_walk.rs, txn/db/snapshot.rs magic [0x01]..[0x02] scan_prefix(CatalogRowKind::Segment)

The commit-history sites were reachable only at u64::MAX, and the catalog
ranges 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:

assertion `left == right` failed: [0xFF; 256] key lost after repack
  left: None
 right: Some("high-key-value")

assertion `left == right` failed
  left: 200
 right: 202
  • tests/btree_basic.rscollect_all at the tree level, covering the exact
    sentinel, a key extending it, and the empty-tree case.
  • tests/compaction_basic.rs — end-to-end dense repack, asserted after the
    repack 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 only atomic_dense_repack writes — so
    the 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_key was folded into
compact_now_preserves_top_of_keyspace_keys, which covers the same boundary
plus the extending key and the reopen.

Benchmark

benches/compaction.rs adds compaction/dense_repack: 1,200 keys inserted,
1,100 deleted, compact_now() timed. benches/common/ holds the harness
plumbing both bench targets share.

The first draft measured Db teardown as part of the operation.
Bencher::iter_with_setup stops its timer after the routine's value is
dropped, 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:

mean
first draft (teardown included) ~269 µs
after parking teardown ~131 µs

Roughly half the original figure was teardown, not repack. Treat the mean as
±10% run-to-run: seven samples on a MemVfs workload 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.rs also had no tracking allocator, so its allocation columns
reported zero; the shared harness gives both targets one, and the figures are
now comparable between them.

Verification

On the merged result: cargo fmt --all --check clean, cargo clippy --all-targets --all-features -- -D warnings clean, cargo nextest run --all-features 409 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.

presempathy-awb and others added 5 commits July 26, 2026 05:14
… 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.
@farhan-syah
farhan-syah merged commit 3d20288 into NodeDB-Lab:main Jul 26, 2026
18 checks passed
@farhan-syah farhan-syah changed the title fix(compaction): scan the complete keyspace during repack fix(btree): scan the complete keyspace during dense repack Jul 26, 2026
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.

2 participants