Skip to content

fix(bulk): bill the committed-slot read, bound BulkAppend estimates, validate chunk header (#822 follow-up) - #826

Merged
QuantumExplorer merged 2 commits into
developfrom
claude/grovedb-issue-822-followup
Aug 22, 2026
Merged

fix(bulk): bill the committed-slot read, bound BulkAppend estimates, validate chunk header (#822 follow-up)#826
QuantumExplorer merged 2 commits into
developfrom
claude/grovedb-issue-822-followup

Conversation

@QuantumExplorer

Copy link
Copy Markdown
Member

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 in BulkAppendTree::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 takes SlotWriteAccounting::Overwrite { previous_value_len }. The Result-returning appends carry the read alongside the prepaid share in one AppendResult/AppendNoStateRootResult::storage_accounting_cost: OperationCost (caller bills, like hash_count; append_deferred_roots includes it in its cost and mirrors it); CommitmentTree::append_raw/append_many_raw and 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_bytes and the slot-read storage_loaded_bytes saturate at u32::MAX (chunk entry lengths are u32; neither the op nor the worst-case layer info bounds earlier entries), and added_bytes is 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 declared TreeType::BulkAppendTree(chunk_power) (the estimator now matches BulkAppend ops against that declaration, like CT/PDS) it charges the compaction as epoch × value_size replaced, 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 test bulk_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_bytes validates the fixed-format header (count × entry_size == payload) before reporting prepaid bytes; malformed blobs prepay nothing. Test added.
  • The legacy-accounting tests cover GROVE_V2 alongside V1/V3 (CT frontier cost and bulk slot/blob puts).
  • "Propagate committed-slot read cost" and "restore an upper bound for variable-size BulkAppend compaction" — addressed above.
  • "Track the frontier length after each successful save" — deliberately not: persisted_frontier_len is the size loaded at open, and save must keep comparing against it because a StorageBatch keeps 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 with new() 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

QuantumExplorer and others added 2 commits August 22, 2026 09:29
…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>
@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@QuantumExplorer, you've reached your PR review limit, so we couldn't start this review.

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 @coderabbitai review or push new commits to the PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5aaa4334-5b3b-4da3-91af-30d346a8dd06

📥 Commits

Reviewing files that changed from the base of the PR and between 4eb6562 and 4f2d29b.

📒 Files selected for processing (17)
  • docs/crates/costs.md
  • grovedb-bulk-append-tree/src/cost/mod.rs
  • grovedb-bulk-append-tree/src/cost/v0.rs
  • grovedb-bulk-append-tree/src/cost/v1.rs
  • grovedb-bulk-append-tree/src/tree/append.rs
  • grovedb-bulk-append-tree/src/tree/mod.rs
  • grovedb-bulk-append-tree/src/tree/storage_accounting_tests.rs
  • grovedb-commitment-tree/src/commitment_tree/cost/mod.rs
  • grovedb-commitment-tree/src/commitment_tree/mod.rs
  • grovedb-dense-fixed-sized-merkle-tree/src/test_utils.rs
  • grovedb-dense-fixed-sized-merkle-tree/src/tests.rs
  • grovedb-dense-fixed-sized-merkle-tree/src/tree.rs
  • grovedb/src/batch/estimated_costs/average_case_costs.rs
  • grovedb/src/batch/estimated_costs/mod.rs
  • grovedb/src/batch/estimated_costs/worst_case_costs.rs
  • grovedb/src/operations/bulk_append_tree.rs
  • grovedb/src/tests/append_storage_accounting_tests.rs

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.37%. Comparing base (4eb6562) to head (4f2d29b).

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           
Components Coverage Δ
grovedb-core 90.52% <100.00%> (+<0.01%) ⬆️
merk 93.27% <ø> (ø)
storage 87.08% <ø> (ø)
commitment-tree 96.29% <100.00%> (-0.02%) ⬇️
mmr 96.49% <ø> (ø)
bulk-append-tree 92.27% <100.00%> (+0.31%) ⬆️
element 97.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@QuantumExplorer
QuantumExplorer merged commit 2f59a90 into develop Aug 22, 2026
12 checks passed
@QuantumExplorer
QuantumExplorer deleted the claude/grovedb-issue-822-followup branch August 22, 2026 07:25
QuantumExplorer added a commit that referenced this pull request Aug 22, 2026
…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>
QuantumExplorer added a commit that referenced this pull request Aug 22, 2026
…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>
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.

1 participant