Skip to content

feat(replication): state sync support for the append-only tree family (fixes #785) - #788

Merged
QuantumExplorer merged 7 commits into
developfrom
claude/eager-blackwell-838b49
Aug 22, 2026
Merged

feat(replication): state sync support for the append-only tree family (fixes #785)#788
QuantumExplorer merged 7 commits into
developfrom
claude/eager-blackwell-838b49

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Aug 3, 2026

Copy link
Copy Markdown
Member

Implements both phases proposed in #785: state sync previously could not transfer the non-Merk append-only tree family at all — a single populated CommitmentTree (the live shielded-pool notes tree), MmrTree, BulkAppendTree, or DenseAppendOnlyFixedSizeTree made every snapshot from the holding node unusable, with the source's fetch_chunk dying on an opaque CorruptedData("... cannot create chunk producer for empty Merk").

Commits

  1. Reproduction tests — two-instance sync tests proving the failure mode for all four types (and that empty ones synced fine, demonstrating why a naive skip fix would have silently dropped payload).
  2. Phase 0 guard — descriptive NotSupported on both the target-side discovery and source-side fetch_chunk paths, mirroring the indexed-tree guards from State sync does not support indexed trees (PCIT/PSIT/PCPSIT) #778.
  3. Phase 1: entry-replay transfer — real state-sync support for all four types, superseding the Phase 0 guards.

Phase 1 design: target-driven entry replay

  • Target drives. The target already holds the subtree's element (entry counts + parameters) from the hash-verified parent Merk, so it encodes a (start, state, param) page cursor (17 bytes) into every local chunk id it requests. The source never reconstructs tree geometry from its raw namespace, and the global chunk id format is unchanged.
  • Only leaf entries cross the wire (plus the serialized Sinsemilla frontier on a commitment tree's first page — the frontier is an accumulator and cannot be replayed from entries without redoing every Sinsemilla hash). The source serves pages through the same accessors normal reads use (get_chunk_value/get_buffer_value, MMR element_at_position, dense get), with a 1 MiB / 8192-entry page budget.
  • The target replays each entry through the real append primitives (BulkAppendTree::append, MMR::push, DenseFixedSizedMerkleTree::insert), so every internal node, chunk blob, and cached hash on the target is locally derived from the wire entries — there is no raw copy of internal state that verification could miss.
  • Verification: at subtree completion the target recomputes the type-specific state root from its own storage via the new strict GroveDb::compute_non_merk_state_root (same dispatch verify_grovedb uses, but error-propagating instead of falling back), and requires combine_hash(value_hash(element_bytes), state_root) to equal the element value hash bound into the restored parent Merk (the fix(grovedb): bind terminal non-Merk tree element bytes to the parent value_hash #782 terminal binding). Any tampering with wire bytes — a flipped entry byte, a stripped or altered frontier, a dropped entry — fails the sync before commit.

Protocol compatibility

CURRENT_STATE_SYNC_VERSION stays 1. Mixed old/new peers fail safe with no silent corruption in either direction: an old target requesting an append-only subtree cursor-lessly gets a descriptive NotSupported from a new source; a new target syncing from an old source fails when the old source cannot serve pages. Replication is node-local wire behavior — no committed hashes change — so no GroveVersion gating (consistent with the #778 guards).

Tests

  • Round trips for all four types: multi-epoch commitment tree (compacted chunk + buffer + frontier), multi-chunk bulk tree, MMR, dense, and a >1 MiB multi-page MMR transfer
  • Post-sync usability: appending the same note on source and destination after sync produces identical root hashes
  • Byzantine-source tamper rejection: flipped entry byte, stripped frontier, tampered frontier, dropped entry — each rejected with a specific error
  • Subtree-batch-boundary interleaving (subtrees_batch_size = 1 across CT/MMR/dense)
  • Old-peer cursor-less request rejection; wire codec unit tests
  • Full grovedb suite: 2553 passed, clippy clean

Closes #785. Cross-refs: #778 (indexed trees — same choke point, still guarded), #783 / #784 (future types 16/15 should plug into this same path).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added replication support for append-only tree types, including commitment, MMR, bulk-append, and dense trees.
    • Added multi-page synchronization with cursor-based requests.
    • Added state validation to ensure restored data matches expected tree roots.
  • Bug Fixes

    • Replication now reports errors for missing cursors, malformed pages, unreadable data, and integrity mismatches instead of silently proceeding.
  • Tests

    • Expanded coverage for round-trip synchronization, empty and multi-page trees, tampered data, and invalid replication requests.

QuantumExplorer and others added 3 commits August 3, 2026 06:59
Investigation tests for the state-sync gap in the append-only tree
family (CommitmentTree / MmrTree / BulkAppendTree /
DenseAppendOnlyFixedSizeTree):

- a populated CommitmentTree bricks source-side fetch_chunk with an
  opaque CorruptedData ("cannot create chunk producer for empty
  Merk"): is_empty_tree() raw-iterates the prefix namespace, sees the
  non-Merk payload entries, and the chunk producer then fails on the
  rootless Merk
- the same failure reproduces for populated MmrTree, BulkAppendTree,
  and DenseAppendOnlyFixedSizeTree
- an EMPTY CommitmentTree syncs fine, demonstrating that a naive
  skip/empty-chunk fix would silently commit a destination missing the
  frontier and note payload (restore never recomputes non-Merk state
  roots; the app-hash check passes on the byte-identical parent Merk)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e sync (Phase 0 of #785)

A populated CommitmentTree / MmrTree / BulkAppendTree /
DenseAppendOnlyFixedSizeTree previously made source-side fetch_chunk
fail with an opaque CorruptedData ("cannot create chunk producer for
empty Merk") when a syncing peer requested the subtree's chunk. Reject
instead with a descriptive NotSupported on both sides:

- target-side discovery (discover_new_subtrees_metadata) rejects when
  it encounters a populated non-Merk tree element, mirroring the
  indexed-tree guards from #778
- source-side fetch_chunk rejects when the requested prefix has a
  non-empty namespace under a non-Merk tree type, where the chunk
  producer would otherwise fail on the rootless Merk

Empty append-only trees keep syncing as before (no payload exists; the
element itself is restored via the parent Merk).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… replay (Phase 1 of #785)

Adds state-sync transfer for the non-Merk append-only tree family:
CommitmentTree, MmrTree, BulkAppendTree, and
DenseAppendOnlyFixedSizeTree. Previously a single populated tree of any
of these types made every snapshot from the holding node unusable.

Design — target-driven entry replay:
- The target holds the subtree's element (counts + parameters) from the
  hash-verified parent Merk, and encodes a (start, state, param) page
  cursor into every local chunk id it requests.
- The source serves pages of leaf entries only (plus the serialized
  Sinsemilla frontier on a commitment tree's first page — it is an
  accumulator and cannot be replayed without redoing every Sinsemilla
  hash), read through the same accessors normal reads use.
- The target replays each entry through the real append primitives
  (BulkAppendTree::append / MMR::push / DenseFixedSizedMerkleTree::
  insert), so every internal node, chunk blob, and cached hash on the
  target is locally derived from the wire entries.
- At subtree completion the target recomputes the type-specific state
  root from its own storage (new strict GroveDb::
  compute_non_merk_state_root) and requires combine_hash(
  value_hash(element_bytes), state_root) to equal the parent binding.
  Any tampering with wire bytes — entries, frontier, counts — fails the
  sync instead of committing corrupt state.

Protocol notes:
- CURRENT_STATE_SYNC_VERSION stays 1: mixed old/new peers fail safe
  (a cursor-less request for an append-only subtree gets a descriptive
  NotSupported; an old source cannot serve pages), with no silent
  corruption in either direction.
- Node-local wire behavior only — no committed hashes change, so no
  GroveVersion gating.

Tests: round trips for all four types (multi-epoch commitment tree,
multi-chunk bulk tree, multi-page MMR transfer), byzantine-source
tamper rejection (flipped entry byte, stripped frontier, tampered
frontier, dropped entry), subtree-batch-boundary interleaving, and the
old-peer cursor-less rejection path.

Closes #785

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 5 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: 8887f20b-fe47-475b-99b3-55f5b9816aab

📥 Commits

Reviewing files that changed from the base of the PR and between 72eb0df and ab4e07a.

📒 Files selected for processing (5)
  • grovedb/src/lib.rs
  • grovedb/src/replication.rs
  • grovedb/src/replication/non_merk_sync.rs
  • grovedb/src/replication/state_sync_session.rs
  • grovedb/src/tests/replication_session_tests.rs
📝 Walkthrough

Walkthrough

The replication system now synchronizes non-Merk append-only subtrees through cursor-based pages. It replays commitment, bulk-append, MMR, and dense tree entries, validates reconstructed roots against parent Merk hashes, and covers round trips, tampering, malformed input, and empty trees.

Changes

Non-Merk append-only replication

Layer / File(s) Summary
Replication contracts and state-root reconstruction
grovedb/src/lib.rs
Replication openings retain the parent Element. Non-Merk state roots are reconstructed strictly and report corrupted payloads as CorruptedData.
Cursor-based page transfer
grovedb/src/replication.rs, grovedb/src/replication/non_merk_sync.rs
Append-only trees use encoded cursors and bounded pages. Commitment, bulk-append, MMR, and dense tree storage provide the page data.
Non-Merk page replay and finalization
grovedb/src/replication/non_merk_sync.rs
NonMerkRestorer validates page order, counts, auxiliary data, completion state, and MMR size before replay and parent binding validation.
State-sync session integration and coverage
grovedb/src/replication/state_sync_session.rs, grovedb/src/tests/replication_session_tests.rs
State synchronization selects non-Merk restoration for append-only subtrees. Tests cover round trips, empty trees, multiple pages, tampering, unsupported requests, and malformed input.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StateSyncSession
  participant GroveDb
  participant NonMerkRestorer
  participant AppendOnlyTree
  participant ParentMerk
  StateSyncSession->>GroveDb: request cursor-based page
  GroveDb-->>StateSyncSession: encoded append-only page
  StateSyncSession->>NonMerkRestorer: apply page
  NonMerkRestorer->>AppendOnlyTree: replay entries
  AppendOnlyTree-->>NonMerkRestorer: updated tree state
  StateSyncSession->>NonMerkRestorer: finalize replay
  NonMerkRestorer->>ParentMerk: validate reconstructed state binding
Loading

Possibly related issues

Possibly related PRs

  • dashpay/grovedb#782 — Adds related integrity validation between non-Merk state roots and parent Merk hashes.
  • dashpay/grovedb#786 — Shares paginated BulkAppendTree and CommitmentTree data access with this synchronization flow.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: state synchronization support for append-only tree types.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/eager-blackwell-838b49

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 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.99044% with 134 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.40%. Comparing base (1b18de6) to head (ab4e07a).
⚠️ Report is 1 commits behind head on develop.

Files with missing lines Patch % Lines
grovedb/src/replication/non_merk_sync.rs 87.12% 77 Missing ⚠️
grovedb/src/lib.rs 68.31% 32 Missing ⚠️
grovedb/src/replication/state_sync_session.rs 77.67% 25 Missing ⚠️

❌ Your patch check has failed because the patch coverage (83.99%) is below the target coverage (90.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #788      +/-   ##
===========================================
+ Coverage    92.39%   92.40%   +0.01%     
===========================================
  Files          288      289       +1     
  Lines        87906    88937    +1031     
===========================================
+ Hits         81219    82181     +962     
- Misses        6687     6756      +69     
Components Coverage Δ
grovedb-core 90.62% <83.99%> (+0.04%) ⬆️
merk 93.27% <ø> (ø)
storage 87.08% <ø> (ø)
commitment-tree 96.29% <ø> (ø)
mmr 96.49% <ø> (ø)
bulk-append-tree 92.27% <ø> (ø)
element 97.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…tree round trips

Raises patch coverage on the #785 entry-replay code:
- direct malformed-input coverage for NonMerkRestorer (bad cursor
  length, out-of-order cursor, undecodable page, more-without-entries,
  entry overflow, missing frontier, premature finalize, aux on a
  non-commitment-tree page, page after final)
- empty MmrTree / BulkAppendTree / DenseAppendOnlyFixedSizeTree round
  trip, covering the empty-tree state-root conventions in
  compute_non_merk_state_root

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
grovedb/src/replication.rs (1)

88-96: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the fetch_chunk doc notes for the append-only path.

The Notes section states that the function opens a Merk tree for each chunk and that empty trees return an empty byte vector. Append-only subtrees now bypass both behaviors: they serve cursor-based entry pages and reject requests without a page cursor. Add that case so callers of this public method know the new contract.

📝 Suggested doc addition
     /// - The function opens a `Merk` tree for each chunk and retrieves the
     ///   associated data.
     /// - Empty trees return an empty byte vector.
+    /// - Non-Merk append-only subtrees (`CommitmentTree`, `MmrTree`,
+    ///   `BulkAppendTree`, `DenseAppendOnlyFixedSizeTree`) are served as
+    ///   cursor-based entry pages instead of Merk chunks. A request for one
+    ///   of these subtrees without a page cursor returns
+    ///   `Error::NotSupported`.
+    /// - Indexed-tree requests return `Error::NotSupported`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@grovedb/src/replication.rs` around lines 88 - 96, Update the Notes section of
the public fetch_chunk documentation to describe append-only subtrees: they
serve cursor-based entry pages and reject requests that lack a page cursor,
rather than opening Merk trees or returning empty vectors for empty trees. Keep
the existing notes for non-append-only chunks unchanged.
grovedb/src/replication/state_sync_session.rs (1)

541-550: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Document the set_new_transaction constraint in the SAFETY comment.

apply_chunk differs from the two call sites this comment cites. Unlike add_subtree_sync_info and discover_new_subtrees_metadata, apply_chunk can replace and commit self.transaction itself, at Line 698 via set_new_transaction. After that call, transaction_ref points at a committed and dropped transaction. The current code is sound because the last use of transaction_ref is at Line 644, inside the loop that ends before Line 698. That ordering is not stated anywhere, so a future edit that moves a use below the loop would introduce a use-after-free without any compiler diagnostic.

🛡️ Suggested comment extension
         let db = self.db;
         // SAFETY: the transaction lives as long as the pinned session and is
         // dropped last; the reference is only used within this call while
         // the session is alive. This mirrors the pattern used by
         // `add_subtree_sync_info` and `discover_new_subtrees_metadata`.
+        //
+        // ADDITIONAL INVARIANT for this call site: `set_new_transaction()`
+        // below replaces and commits `self.transaction`, which invalidates
+        // `transaction_ref`. Every use of `transaction_ref` MUST stay inside
+        // the per-chunk loop, above the `set_new_transaction()` call. Do not
+        // use `transaction_ref` after that point.
         let transaction_ref: &'db Transaction<'db> = unsafe {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@grovedb/src/replication/state_sync_session.rs` around lines 541 - 550, Update
the SAFETY comment above transaction_ref in apply_chunk to document that
set_new_transaction may replace and commit/drop self.transaction, and that
transaction_ref must not be used after the loop’s final use before that call.
Preserve the existing lifetime rationale while explicitly requiring all
transaction_ref accesses to remain before set_new_transaction.
grovedb/src/tests/replication_session_tests.rs (2)

1354-1367: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider asserting that the transfer actually used more than one page.

This test depends on MAX_PAGE_BYTES staying at 1 MiB: 4 leaves of 400 KiB total 1.6 MiB. If that constant is later raised above 1.6 MiB, the transfer completes in a single page. The test still passes, but it no longer covers the multi-page path it is named for. Count the applied chunks, or size the payload from the constant, so the coverage cannot silently disappear.

// Sizing the payload from the constant keeps the split guaranteed:
use crate::replication::non_merk_sync::MAX_PAGE_BYTES; // needs pub(crate)
let leaf_size = MAX_PAGE_BYTES / 2 + 1; // any two leaves exceed one page
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@grovedb/src/tests/replication_session_tests.rs` around lines 1354 - 1367,
Update the replication test around the mmr_tree_append loop so it cannot
silently stop exercising multi-page transfers when MAX_PAGE_BYTES changes. Size
the appended payloads from MAX_PAGE_BYTES to guarantee the total exceeds one
page, or count applied chunks and assert that more than one page was used;
expose MAX_PAGE_BYTES as pub(crate) if needed.

1423-1468: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider consolidating the four sync-driver loops.

This file now contains four near-identical fetch/apply loops: sync_source_to_destination (Lines 48-68), try_sync_source_to_destination (Lines 742-756), this helper (Lines 1423-1468), and the inline loop in state_sync_non_merk_trees_with_batch_size_one (Lines 1670-1688). They differ only in batch size, error handling, and the optional page mutation. One driver taking a batch size and an optional mutation hook would cover all four call sites and keep future protocol changes to a single place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@grovedb/src/tests/replication_session_tests.rs` around lines 1423 - 1468,
Consolidate the duplicated chunk fetch/apply logic from
sync_source_to_destination, try_sync_source_to_destination, the current helper,
and state_sync_non_merk_trees_with_batch_size_one into one shared sync driver.
Parameterize it for batch size, error-handling behavior, and an optional
page-mutation hook, preserving each caller’s existing semantics; route all four
call sites through the driver so fetch, apply, and queue-extension behavior has
one implementation.
grovedb/src/lib.rs (1)

2489-2590: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider deriving compute_non_merk_child_hash from this strict variant.

compute_non_merk_state_root and compute_non_merk_child_hash (Lines 2387-2464) now contain the same four per-type reconstruction branches. They differ only in failure policy: the strict variant returns CorruptedData, the lenient one falls back to merk_root_hash. The empty-tree branches also already express the same value two ways (NULL_HASH here, merk_root_hash there), which is the exact kind of drift a shared implementation prevents. Both feed hashes that are compared against consensus-bound parent bindings, so the two must never diverge.

♻️ Suggested direction
 fn compute_non_merk_child_hash<'b, B: AsRef<[u8]>>(
     &self,
     element: &Element,
     subtree_path: SubtreePath<'b, B>,
     transaction: &Transaction,
     merk_root_hash: [u8; 32],
 ) -> [u8; 32] {
-    match element {
-        // ... duplicated per-type reconstruction ...
-    }
+    self.compute_non_merk_state_root(element, subtree_path, transaction)
+        .unwrap_or(merk_root_hash)
 }

Note that this makes the empty BulkAppendTree / MmrTree / DenseAppendOnlyFixedSizeTree cases return NULL_HASH explicitly instead of the passed-in merk_root_hash; confirm those are identical for an empty inner Merk before applying.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@grovedb/src/lib.rs` around lines 2489 - 2590, Refactor
compute_non_merk_child_hash to reuse the per-type reconstruction logic from
compute_non_merk_state_root so both functions produce identical hashes. Preserve
the lenient function’s fallback to merk_root_hash on reconstruction errors while
retaining CorruptedData propagation in the strict function, and confirm empty
inner Merk cases use the same NULL_HASH value before sharing the implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@grovedb/src/tests/replication_session_tests.rs`:
- Around line 1544-1559: In the frontier-tamper case within
try_sync_with_ct_page_mutation, replace the broad "cannot" alternative with
exact, frontier-specific error substrings that represent the valid
tampered-frontier rejection paths. Keep the existing "state root mismatch after
replay" match if applicable, and align the assertion with the exact-substring
style used by the other three cases.

---

Nitpick comments:
In `@grovedb/src/lib.rs`:
- Around line 2489-2590: Refactor compute_non_merk_child_hash to reuse the
per-type reconstruction logic from compute_non_merk_state_root so both functions
produce identical hashes. Preserve the lenient function’s fallback to
merk_root_hash on reconstruction errors while retaining CorruptedData
propagation in the strict function, and confirm empty inner Merk cases use the
same NULL_HASH value before sharing the implementation.

In `@grovedb/src/replication.rs`:
- Around line 88-96: Update the Notes section of the public fetch_chunk
documentation to describe append-only subtrees: they serve cursor-based entry
pages and reject requests that lack a page cursor, rather than opening Merk
trees or returning empty vectors for empty trees. Keep the existing notes for
non-append-only chunks unchanged.

In `@grovedb/src/replication/state_sync_session.rs`:
- Around line 541-550: Update the SAFETY comment above transaction_ref in
apply_chunk to document that set_new_transaction may replace and commit/drop
self.transaction, and that transaction_ref must not be used after the loop’s
final use before that call. Preserve the existing lifetime rationale while
explicitly requiring all transaction_ref accesses to remain before
set_new_transaction.

In `@grovedb/src/tests/replication_session_tests.rs`:
- Around line 1354-1367: Update the replication test around the mmr_tree_append
loop so it cannot silently stop exercising multi-page transfers when
MAX_PAGE_BYTES changes. Size the appended payloads from MAX_PAGE_BYTES to
guarantee the total exceeds one page, or count applied chunks and assert that
more than one page was used; expose MAX_PAGE_BYTES as pub(crate) if needed.
- Around line 1423-1468: Consolidate the duplicated chunk fetch/apply logic from
sync_source_to_destination, try_sync_source_to_destination, the current helper,
and state_sync_non_merk_trees_with_batch_size_one into one shared sync driver.
Parameterize it for batch size, error-handling behavior, and an optional
page-mutation hook, preserving each caller’s existing semantics; route all four
call sites through the driver so fetch, apply, and queue-extension behavior has
one implementation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5c2f0c5f-89dd-418e-bc11-aa3ee8202739

📥 Commits

Reviewing files that changed from the base of the PR and between d473818 and 72eb0df.

📒 Files selected for processing (5)
  • grovedb/src/lib.rs
  • grovedb/src/replication.rs
  • grovedb/src/replication/non_merk_sync.rs
  • grovedb/src/replication/state_sync_session.rs
  • grovedb/src/tests/replication_session_tests.rs

Comment thread grovedb/src/tests/replication_session_tests.rs
- tighten the frontier-tamper assertion to the specific frontier
  rejection errors instead of a broad "cannot" substring
- document the append-only page-serving contract in fetch_chunk's notes
- extend the apply_chunk SAFETY comment with the set_new_transaction
  invariant: transaction_ref must not be used after the per-chunk loop
- size the multi-page MMR test payload from MAX_PAGE_BYTES so the
  multi-page path cannot silently stop being covered if the budget is
  raised
- consolidate the four near-identical sync-driver loops in the tests
  into one run_sync(source, version, batch_size, mutator) driver

Deliberately NOT applied: deriving compute_non_merk_child_hash from the
strict compute_non_merk_state_root. The two differ on empty trees for a
reason — the lenient variant returns the actual (possibly non-null)
inner Merk root so verify_grovedb still flags a corrupt DB where a
count-0 append-only tree has stray Merk nodes; the strict variant's
NULL_HASH would mask exactly that corruption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer

Copy link
Copy Markdown
Member Author

This is Claude. Addressed the CodeRabbit review in df05f98:

  • Applied (5): tightened the frontier-tamper assertion to the specific rejection errors; documented the append-only page-serving contract in fetch_chunk's notes; extended the apply_chunk SAFETY comment with the set_new_transaction invariant (good catch — that ordering constraint was previously unstated); sized the multi-page MMR test payload from MAX_PAGE_BYTES so the multi-page path can't silently lose coverage; consolidated the four sync-driver loops in the tests into one run_sync(source, version, batch_size, mutator) driver.
  • Skipped (1): deriving compute_non_merk_child_hash from the strict compute_non_merk_state_root. The empty-tree branches are intentionally different: the lenient variant returns the actual inner Merk root (possibly non-null on a corrupt DB), so verify_grovedb still flags a count-0 append-only tree that has stray Merk nodes — combine(value_hash, actual_root) != combine(value_hash, NULL) bound at insert. Routing it through the strict variant's NULL_HASH would make that exact corruption verify clean. The caveat noted in the suggestion ("confirm those are identical for an empty inner Merk") is precisely the case where they must be allowed to differ.

@QuantumExplorer QuantumExplorer left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found two resource-safety issues in the new non-Merk state-sync path. The functional round-trip and tamper tests pass, but both peer-facing boundaries need additional validation before this is safe against malformed requests/responses.

)));
}
};
let mut sections = unpack_nested_bytes(packed)?;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Enforce the page-entry cap on receipt

MAX_PAGE_ENTRIES is enforced only by the honest sender loops. This decoder trusts the packed section count and allocates before applying any protocol limit; apply_page then replays every decoded entry. A Byzantine snapshot source can send a few megabytes containing roughly a million zero-length entries, causing large metadata allocations, hashes, and transactional writes before the final root mismatch rejects the subtree. Reject counts above MAX_PAGE_ENTRIES + 1 before unpack_nested_bytes, and enforce a receiver-side byte/work budget.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is Claude. Fixed in ab4e07a. decode_non_merk_page now peeks the packed section count and rejects anything above MAX_PAGE_ENTRIES + 1 before unpack_nested_bytes allocates, and additionally enforces the sender loop's byte invariant on receipt: every entry but the last must fit cumulatively under MAX_PAGE_BYTES (only the final entry may overhang, which the transport cap bounds). Unit tests: non_merk_page_enforces_entry_cap_on_receipt (incl. a lying u32::MAX count with a truncated body) and non_merk_page_enforces_byte_budget_on_receipt.

&& bytes < MAX_PAGE_BYTES
{
let node = store_ref
.element_at_position(leaf_to_pos(leaf))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Validate peer-controlled MMR cursor arithmetic

Both id.state and id.start come from the requesting peer. With state = u64::MAX, mmr_size_to_leaf_count returns 2^63; start = 2^63 - 1 enters this loop and leaf_to_pos panics in debug builds with integer overflow. Release builds wrap to an unrelated position. Validate that the MMR size is canonical and that the leaf index is within the helper's arithmetic range—or derive the metadata from the source's authenticated element—before converting it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is Claude. Fixed in ab4e07a. Added validate_mmr_size, which requires the cursor state to be a canonical MMR size (2L - popcount(L) recomputed with checked arithmetic) and returns the leaf count; the source calls it before any leaf_to_pos, and the target calls it on the element in NonMerkRestorer::new for symmetry. state = u64::MAX, start = 2^63 - 1 is now a bounded CorruptedData("... is not a valid MMR size"). Note 2^63 is canonical (2^62 + 1 leaves) and remains a bounded "missing MMR leaf" read. Tests: validate_mmr_size_accepts_canonical_and_rejects_others (edge table) and fetch_chunk_rejects_non_canonical_mmr_cursor (source side, crafted cursors).

QuantumExplorer and others added 2 commits August 22, 2026 15:02
…788 review)

Addresses the two open review findings on #788 plus three issues found
while re-reviewing after merging develop:

- P1: enforce the page budget on receipt. `decode_non_merk_page` now
  rejects a declared section count above MAX_PAGE_ENTRIES + 1 *before*
  `unpack_nested_bytes` allocates, and requires every entry but the last
  to fit cumulatively under MAX_PAGE_BYTES (the honest sender's loop
  invariant), so a few MiB of tiny entries can no longer drive millions
  of hashes/writes on the target before the final root check.
- P2: validate peer-controlled MMR cursor arithmetic. `validate_mmr_size`
  requires a canonical MMR size (checked arithmetic) on the source before
  any `leaf_to_pos`, and on the target element for symmetry; `state =
  u64::MAX, start = 2^63 - 1` is now a bounded error, not a debug-build
  overflow panic / release-build wrapped position.
- Frontier canonicality: `CommitmentFrontier::deserialize` tolerates
  trailing bytes and the target stored the wire bytes verbatim, so a
  padded frontier passed the state-root check while changing
  `persisted_frontier_len` — the length V4 frontier-save accounting bills
  against — diverging the synced node's costs from the network. The
  frontier must now round-trip byte-for-byte and declare the element's
  tree size. An empty commitment tree must not carry a frontier at all
  (its state root is a constant that would never look at planted bytes,
  yet the next append would load them).
- PrivateDocumentStore routing: develop added PDS to
  `uses_non_merk_data_storage()`, which this PR used as its routing
  predicate — an empty PDS would have broken state sync with an
  InternalError. Route only the four append-only types to entry replay
  (`supports_entry_replay`); a populated PDS gets a descriptive
  NotSupported on both sides, an empty one syncs via the Merk path as on
  develop.
- Thread `grove_version` through `apply_page` / `finalize` /
  `compute_non_merk_state_root` for the #822/#825 `append` / `commit_mmr`
  / `push` / `get_root` signatures (bytes written are version-independent;
  only billing differs, and sync discards costs).

Tests: receiver-side entry cap and byte budget, canonical MMR size table
incl. 2^63-1 / 2^63 / u64::MAX edges, source-side crafted MMR cursors,
padded / undecodable / planted frontiers, empty-PDS round trip,
populated-PDS rejection on both sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer

Copy link
Copy Markdown
Member Author

This is Claude. Re-reviewed #788 after merging develop (clean merge, 4e10d959) and pushed ab4e07a0 addressing the two open review findings plus three issues the re-review surfaced:

Review findings

  • P1 (page-entry cap on receipt) — fixed; see thread.
  • P2 (MMR cursor arithmetic) — fixed; see thread.

New findings, fixed in the same commit

  1. Frontier canonicality (Byzantine source → cost divergence on the synced node). CommitmentFrontier::deserialize tolerates trailing bytes, and the target stored the wire frontier verbatim — so a padded frontier passed the state-root check (same Sinsemilla root) while changing persisted_frontier_len, which is what the GROVE_V4 frontier-save accounting bills against. The target now requires the frontier to round-trip byte-for-byte through the codec and to declare the element's tree size, and an empty commitment tree must not carry a frontier at all (its state root is a constant that never reads the payload, yet the next append would load the planted bytes). The legacy "trailing anchors" encoding predates the crate's first commit, so the strict check cannot reject honest data. Tests: state_sync_commitment_tree_rejects_non_canonical_frontier, state_sync_empty_commitment_tree_rejects_planted_frontier (both fail against the previous head).
  2. PrivateDocumentStore routing regression after the merge. develop added PDS to TreeType::uses_non_merk_data_storage(), which this PR used as its routing predicate — so even an empty PDS would have broken state sync with an InternalError from NonMerkRestorer::new. Entry replay is now gated on a precise supports_entry_replay (the four append-only types); a populated PDS gets a descriptive NotSupported on both sides (discovery and fetch_chunk), an empty one keeps syncing via the Merk path exactly as on develop. Tests: state_sync_empty_private_document_store_round_trip, state_sync_rejects_populated_private_document_store_up_front.
  3. grove_version threading for the Append-only tree family charges write churn as new storage: compaction blob, epoch≥2 buffer writes and frontier rewrites are all reported as added_bytes #822/fix(costs): report append-only write churn as replacement, not new storage (#822) #825 append / commit_mmr / push / get_root signatures (bytes written are version-independent; sync discards costs).

Verification: cargo test -p grovedb 2864 passed / 0 failed; cargo clippy -p grovedb --all-features -- -D warnings and cargo fmt --check clean.

One pre-existing property left as-is (not introduced here): fetch_chunk serves every local chunk id packed into a global id, so a requester can ask for many pages per request; each page is budget-bounded and the Merk path has the same shape, so the absolute cap belongs at the transport.

@QuantumExplorer
QuantumExplorer merged commit a57551d into develop Aug 22, 2026
10 of 11 checks passed
@QuantumExplorer
QuantumExplorer deleted the claude/eager-blackwell-838b49 branch August 22, 2026 08:42
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.

State sync cannot transfer the append-only tree family — a single populated CommitmentTree makes snapshots from that node unusable

1 participant