fix(bulk): bill the committed-slot read, bound BulkAppend estimates, validate chunk header (#822 follow-up) - #826
Conversation
…s (review) Review on #825 found two gaps. The read that sizes a slot rewrite was performed inside the dense tree and discarded by BulkAppendTree::append_no_state_root, so V4 CT/Bulk appends did one unbilled seek per buffered append. The read now lives in BulkAppendTree::slot_write_accounting, which knows from the count at open whether a slot holds a committed value (epoch-1 slots and compacting appends are not read), and its seek and bytes travel with the prepaid share in AppendResult/AppendNoStateRootResult::storage_accounting_cost, which CommitmentTree and the GroveDB bulk op bill; the dense tree takes SlotWriteAccounting::Overwrite { previous_value_len }. The CT estimator gained the read; the GroveDB tests pin the real RocksDB seek/load deltas. The BulkAppend estimators were not upper bounds at compaction for variable-size entries: the worst-case arm now saturates replaced_bytes and the slot-read loaded bytes (earlier entries are unboundable from the op) and bounds added_bytes properly; the average arm is epoch-aware — storage and hashes — when the tree's layer is declared TreeType::BulkAppendTree(chunk_power). New estimate-vs-actual compaction test with variable-size values. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…over V2 CodeRabbit on #825: chunk_blob_entry_bytes trusted the payload length of a fixed-format blob without checking it against the header's count x entry_size, so a malformed blob could have its payload reported as replaced rather than added; it now returns 0 unless the header describes the payload exactly. The legacy-accounting tests now cover GROVE_V2 alongside V1 and V3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 48 minutes Limit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?Wait for the limit to reset, then comment An organization admin can change what happens after included review limits in Billing. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (17)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #826 +/- ##
========================================
Coverage 92.36% 92.37%
========================================
Files 288 288
Lines 87470 87527 +57
========================================
+ Hits 80794 80851 +57
Misses 6676 6676
🚀 New features to boost your workflow:
|
…cost; document range reads get_range hand-rolled its cost-charging dense-tree read because, when it was written, get_buffer_value discarded the read's cost. develop (#826) has since added get_buffer_value_with_cost for exactly that purpose, so the buffer tail of the page now goes through it — same cost accounting, same CorruptedData on a lost value, less duplication. The chunk loop keeps its hoisted single MMR (the per-chunk rebuild get_chunk_value_with_cost would do is what CodeRabbit asked to avoid). Book: add bulk_get_range / commitment_tree_get_range to the operation tables and describe the paginated position-range proof entry points. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e layer (#786) * feat(bulk-append-tree): chunk-aligned paginated range reads with proof helpers Add the position-range read seam at the shared BulkAppendTree layer so every BulkAppendTree-backed element type (CommitmentTree today, the planned PrivateDocumentStore / DataCommitmentTree) inherits it: - BulkAppendTree::get_range(start, limit): fetch entries for [start, start + limit) clamped to total_count, returned as a RangePage (entries + total_count). Chunk-aligned: each completed chunk overlapping the range is read and deserialized exactly once, so a page costs O(chunks touched) blob reads instead of O(entries) random reads. - position_range_query(start, limit): the canonical Query (8-byte big-endian position keys) shared by prover and verifier. - BulkAppendTreeProof::generate_for_range / verify_range: paginated proof round-trip over the existing chunk-MMR + dense-buffer proof, with completeness enforced; absence past the end falls out of the authenticated total_count (position >= count), not per-position absence proofs. - CommitmentTree::get_range pass-through for the shielded-pool scanning path. Part of work-plan item 4 of #784. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(grovedb): chain empty BulkAppendTree V1 lower layers through NULL_HASH A V1 proof descending into an empty BulkAppendTree could never verify: insert commits the element with a NULL_HASH child hash (there is no bulk state until the first append) and verify_grovedb's integrity walk mirrors that, but verify_bulk_append_lower_layer returned the domain-tagged empty state root blake3("bulk_state" || 0*32 || 0*32), so the combine_hash chain check always failed. Return NULL_HASH for a zero-count BulkAppendTree element instead, matching what the writer commits. This is sound because verify_and_compute_root has already rejected any proof carrying chunk or buffer data for a zero-count tree. CommitmentTree keeps the computed value: its insert commits EMPTY_COMMITMENT_TREE_STATE_ROOT, which folds in the computed empty bulk root. Verification-only change on an element type not reachable in any shipped grove version's state; no wire format or state root changes, so no GROVE_V* gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(grovedb): paginated position-range reads and proofs for append-only trees Wire the BulkAppendTree range-read seam through GroveDB, per work-plan item 4 of #784 — clients of append-only stores walk "all entries since my cursor" in pages: - GroveDb::bulk_get_range / GroveDb::commitment_tree_get_range: chunk-aligned page fetch returning a RangePage (entries + total_count), O(chunks touched) instead of O(entries) random reads. - PathQuery::new_bulk_position_range: the canonical query shape for a page — element key plus an 8-byte big-endian position-range subquery — derived identically by prover and verifier from (start, limit). - GroveDb::prove_bulk_position_range: proves one page via the existing V1 proof layering (ProofBytes::BulkAppendTree / CommitmentTree); no wire-format change, so no new GROVE_V* gate. - GroveDb::verify_bulk_position_range_proof: verifies the page entries (ascending, contiguous, complete) and extracts the authenticated total_count from the same proof bytes by subset-verifying the element itself. Absence beyond the end falls out of the provable count (position >= total_count) — no per-position absence proofs. The seam lives at the shared BulkAppendTree layer and dispatches on the element type, so the planned PrivateDocumentStore (#784) and DataCommitmentTree (#783) pick it up by adding their element variants to the prove/verify match arms. Tests cover round-trips across chunk boundaries, empty ranges, ranges past the end, single-entry pages, large multi-chunk pages, empty trees, nested paths, wrong-element errors, narrower-proof rejection, and the cursor-walk scan pattern for both BulkAppendTree and CommitmentTree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(grovedb): add paged-scan benchmark for bulk position-range reads Benchmark the append-only scanning hot path on a 4096-entry BulkAppendTree (chunk size 64, 96-byte entries): single-page reads, per-page proof generation and verification at page sizes 16/256/1024, and a full proved cursor-walk of the tree at page size 256. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: address CodeRabbit review on paginated range reads - Build the chunk MMR once per get_range call instead of once per chunk: going through get_chunk_value re-cloned the MMR overlay for every chunk in the page, exactly the repeated work chunk-alignment is meant to avoid. Reuses the same single-MMR pattern as BulkAppendTreeProof::generate. - Add explicit version gates (prove_bulk_position_range, verify_bulk_position_range_proof) matching the other public proof entry points; 0 across GROVE_V1..V4, so no behavior change — the delegated prove_query/verify_query gates still apply transitively. - Pin the shared-chunk disjoint-range semantics with a test: a proof generated for [0, 2) verifies a request for [2, 4) inside the same chunk, because chunk-aligned proofs carry the whole authenticated blob. Rejection of ranges needing an unproved chunk is unchanged. - Add the CommitmentTree-envelope wrong-range rejection test mirroring the BulkAppendTree one (different proof envelope and child-hash derivation, so the bulk test did not cover it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: cover reachable error paths in paginated range reads codecov/patch flagged the new code at 80.55% against the 90% bar. Cover the arms that are reachable without contrived proofs: - get_range corruption paths: storage claiming chunks / buffered entries it does not hold must error, not silently skip entries. - Version-gate rejections for prove_bulk_position_range and verify_bulk_position_range_proof under an unknown feature version. - verify_bulk_position_range_proof adversarial inputs: a canonical range proof over a plain Tree with 8-byte item keys (element type must be rejected when extracting total_count) and over a nonexistent key (a proof binding no element must not invent a total_count). The remaining uncovered lines are defense-in-depth InvalidProof arms (root-mismatch between sub-proofs of the same bytes, non-8-byte / non-item / non-contiguous rows under a genuine bulk element) that cannot be reached through any proof that survives the lower layer's own checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: cover the MMR storage-error path in get_range MemStorageContext (codecov-ignored test util) grows a fail_gets switch that makes every read error, so the chunk-lookup MmrError arm in get_range is exercised: a broken backing store must surface as an error, not a panic or a silent empty page. The buffer-side ok_or_else arm stays uncovered by design: the dense tree's get() errors (never returns Ok(None)) for a missing value below its count, so that arm is unreachable defensive depth, same as the remaining InvalidProof arms in verify_bulk_position_range_proof. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: charge range-read storage costs and validate completed chunk length Address review on paginated range reads: - get_range now returns a CostResult: the chunk-MMR node reads and each dense-buffer read charge their seeks and loaded bytes, aggregated through CommitmentTree::get_range, bulk_get_range, and commitment_tree_get_range, so cost limits reflect a page's actual work instead of treating up to 65,535 buffer reads as free. The buffer reads go through dense_tree.get directly so their costs are captured. - get_range rejects a completed chunk as corrupted unless it holds exactly epoch_size entries: a short blob would silently omit positions and an oversized one would overlap the next chunk, breaking the contiguous-page contract and stalling cursor scans. Unlike proof verification — where chunk bytes are bound to the state root and a length check is redundant (see the NOTE in proof/mod.rs) — this raw read path has no root comparison backing it. Tests: a tampered-overlay chunk with the wrong entry count (short and oversized) is rejected with CorruptedData, and bulk_get_range reports nonzero seek/loaded-byte costs that grow with page size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: route get_range buffer reads through get_buffer_value_with_cost; document range reads get_range hand-rolled its cost-charging dense-tree read because, when it was written, get_buffer_value discarded the read's cost. develop (#826) has since added get_buffer_value_with_cost for exactly that purpose, so the buffer tail of the page now goes through it — same cost accounting, same CorruptedData on a lost value, less duplication. The chunk loop keeps its hoisted single MMR (the per-chunk rebuild get_chunk_value_with_cost would do is what CodeRabbit asked to avoid). Book: add bulk_get_range / commitment_tree_get_range to the operation tables and describe the paginated position-range proof entry points. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Follow-up to #825 (issue #822), which was merged before its last review fix landed. This carries that fix plus the CodeRabbit findings on #825.
Review on #825 (codex)
The committed-slot read is now billed on every path. The read that sizes a slot rewrite was performed inside the dense tree and discarded by
BulkAppendTree::append_no_state_root, so V4 CT/Bulk appends did one unbilled seek per buffered append. The read now lives inBulkAppendTree::slot_write_accounting, which knows from the count it was opened with whether a slot holds a committed value — epoch-1 slots and compacting appends are not read at all (no phantom seek), a real rewrite costs exactly one seek + the committed bytes — and the dense tree takesSlotWriteAccounting::Overwrite { previous_value_len }. TheResult-returning appends carry the read alongside the prepaid share in oneAppendResult/AppendNoStateRootResult::storage_accounting_cost: OperationCost(caller bills, likehash_count;append_deferred_rootsincludes it in its cost and mirrors it);CommitmentTree::append_raw/append_many_rawand the GroveDB bulk op add it. The CT estimator gained the seek and the entry bytes; the #813 bound tests stay green. GroveDB tests assert the real RocksDB(+1 seek, +committed_len loaded)deltas for CT, Bulk and PDS on buffered appends from epoch 2 on, and(0, 0)in epoch 1, at compaction, and for an epoch boundary inside one batch.BulkAppend estimates at compaction are bounds again. Worst-case arm:
replaced_bytesand the slot-readstorage_loaded_bytessaturate atu32::MAX(chunk entry lengths are u32; neither the op nor the worst-case layer info bounds earlier entries), andadded_bytesis a real V4 bound (slot + share + blob framing at the 2^16 epoch + MMR internal nodes) instead of the old flat 64 KB. Average-case arm: when the tree's own layer is declaredTreeType::BulkAppendTree(chunk_power)(the estimator now matches BulkAppend ops against that declaration, like CT/PDS) it charges the compaction asepoch × value_sizereplaced, the framing, and an epoch-scaled hash bound (the old arm's 1 hash per append already under-estimated buffered appends); undeclared it stays the amortized one-entry average, documented as such. New testbulk_append_estimates_dominate_actual_compaction_with_variable_sizes: fifteen 10 KiB values then a 16-byte overflow (≈150 KB replaced for a 16-byte op) — the worst case dominates; sixteen same-size values — the declared average and the worst case both dominate.CodeRabbit on #825
chunk_blob_entry_bytesvalidates the fixed-format header (count × entry_size == payload) before reporting prepaid bytes; malformed blobs prepay nothing. Test added.GROVE_V2alongside V1/V3 (CT frontier cost and bulk slot/blob puts).persisted_frontier_lenis the size loaded atopen, andsavemust keep comparing against it because aStorageBatchkeeps one put per key — a session that saves twice is charged for the last put only, which has to describe the transition from the committed frontier (documented on the field). GroveDB opens a fresh handle per operation, so no handle ever saves against a stale figure; a tree built withnew()has no committed frontier, so its first save creating the key as new storage is correct.Verified: clippy
-D warnings, all-targets check,verify-feature build, fmt, full workspace tests (4944 passed before the two CodeRabbit quick wins; affected suites re-run after).🤖 Generated with Claude Code