feat(replication)!: complete V1 state sync and bounded-memory restore - #840
Conversation
Library code must not write to stderr; the error is already propagated to the caller. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Thread the version supplied to start_snapshot_syncing through start_syncing_session and MultiStateSyncSession::new instead of hardcoding CURRENT_STATE_SYNC_VERSION, and replace the three exact-equality version checks (fetch_chunk, start_snapshot_syncing, apply_chunk) with membership in the new SUPPORTED_STATE_SYNC_VERSIONS set. This is the groundwork for serving multiple protocol versions side by side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
apply_chunk no longer flushes completed subtree batches through set_new_transaction/commit_transaction. All restored subtrees now stay inside the single session transaction until commit_session verifies the final root hash, so a failed or abandoned sync rolls the destination back to its pre-sync state instead of leaving partial subtrees on disk (issue dashpay#775). subtrees_batch_size is kept purely as discovery pacing. Re-derives 526dd9d on top of the non-Merk entry-replay work; NonMerkRestorer writes already go through the same session transaction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the non-Merk entry-replay path (issue dashpay#785) to PrivateDocumentStore (issues dashpay#783 / dashpay#784): the source serves pages straight from the underlying BulkAppendTree, and the target replays them through PrivateDocumentStore::append_many so the committed entry_size from its hash-verified element is enforced on every wire entry. Finalize recomputes the config-binding pds_state root strictly (new compute_non_merk_state_root arm) and checks it against the parent binding. The populated-PDS NotSupported rejects on both sides are gone; a cursor-less request from a pre-dashpay#785 peer still gets the descriptive missing-page-cursor error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Indexed subtrees (ProvableCountIndexedTree / ProvableSumIndexedTree / ProvableCountProvableSumIndexedTree) are now transferable at state sync protocol version 2. The target requests an indexed primary with a header request carrying the axis tags and secondary root keys from its hash-verified element; the source answers with an indexed header (primary root hash plus per-axis secondary root hashes, which the element does not carry) bundled with the primary's root chunk, then serves further primary chunks and the per-axis secondaries as ordinary Merk chunks addressed by derived prefix. The header is a hint only: each group finishes with an unconditional joint verification recomputing combine_hash_three / axes_digest over the ACTUAL restored root hashes against the parent-bound element value hash. Version 1 sessions keep the up-front NotSupported reject on both sides. Also fixes a pre-existing Merk restore defect this surfaced: chunk proof nodes of the Provable* families embed subtree AGGREGATES, which write_chunk persisted as node OWN values (and as_link mirrored into link aggregates), so any multi-node provable-aggregate tree restored with wrong aggregates and a wrong recomputed root. Restorer::finalize now runs a rewrite_aggregates pass (the aggregate counterpart of rewrite_heights) re-deriving own feature values from element bytes exactly as the write path does and recomputing link aggregates bottom-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ersion serving CURRENT_STATE_SYNC_VERSION moves to 2 (indexed-tree transfer); version 1 remains fully served for older peers via SUPPORTED_STATE_SYNC_VERSIONS. Version 1 requests keep the descriptive indexed-tree NotSupported rejects on both sides, and cursor-less append-only requests keep the pre-dashpay#785 missing-page-cursor error. Adds a v1-client-vs-v2-source compatibility round trip (including non-Merk entry-replay subtrees), pins the indexed reject tests to version 1, and updates the indexed-tree book chapter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds state-sync round trips for SumTree, BigSumTree, CountTree, CountSumTree, ProvableSumTree, ProvableCountTree, ProvableCountSumTree and ProvableCountProvableSumTree (each with post-sync write parity and a clean verify_grovedb, pinning the restorer's finalize-time aggregate rewrite), plus a 6-level mixed-type hierarchy synced with subtrees_batch_size 2 to cross discovery batch boundaries repeatedly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion finalize() historically skipped Merk::verify's aggregate checks for every aggregate-bearing tree type because restored aggregates were not authoritative. With the finalize-time rewrite_aggregates pass they now are, so run verify with skip_sum_checks disabled as the defense-in-depth backstop against a chunk producer lying about aggregate contributions. Flagged by review of the state sync v2 work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Warning Review limit reachedNext included review available in 28 minutes. View limit detailsLimit 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. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (21)
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❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## develop #840 +/- ##
===========================================
+ Coverage 92.53% 92.56% +0.02%
===========================================
Files 292 307 +15
Lines 90501 95705 +5204
===========================================
+ Hits 83744 88585 +4841
- Misses 6757 7120 +363
🚀 New features to boost your workflow:
|
…indexed header decode Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The restore side of state sync holds every write of the whole sync in one OptimisticTransactionDB transaction until commit_session verifies the root hash. That write batch lives in RocksDB's C++ heap, so its size had never been measured — the atomicity guarantee was adopted without a number attached to it. Adds an #[ignore]d, four-tier harness that builds a Platform-shaped synthetic grove (identity-like items, document-like items across nested per-contract subtrees, a sum tree, a commitment tree, an MMR, and two indexed trees with populated axes), checkpoints it, restores it into a fresh directory, and reports peak process RSS over the restore window alongside wall-clock, chunk round trips, wire bytes, and on-disk sizes. RSS is read from the OS (proc_pidinfo / procfs) because a Rust allocator hook cannot see the C++ write batch, and the value filler is a xorshift stream so RocksDB compression does not shrink the on-disk baseline every ratio is derived from. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…aces Every decoder reached by fetch_chunk / apply_chunk parses bytes a remote peer chose, before anything has been verified. Adds proptest coverage for all of them — unpack_nested_bytes, decode_global_chunk_id, IndexedHeader::decode, IndexedHeaderRequest::decode, NonMerkChunkId::decode and decode_non_merk_page — asserting three properties each: no panic on any input, payload bytes bounded by input length (so a small message is never an allocation bomb), and canonical framing, i.e. encode(decode(x)) == x whenever decode succeeds. Each surface is fuzzed twice: with unstructured bytes, and with valid encodings put through single-byte splice/truncate/flip mutations, since random bytes almost never reach a structured decoder's interior branches. Adds direct per-branch unit tests for IndexedHeader::decode and verify_indexed_binding that assert the diagnosis and not just is_err(), covering the indexed_sync error paths the indexed-tree patch coverage was missing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The suite covered the v1-client-against-v2-source direction only. The reverse is the direction a rollout actually takes — new nodes come up first and fetch from peers that have not upgraded — and none of its outcomes were pinned. Adds a simulated peer with a serving policy (v1-only, or refusing everything) and pins: a grove without indexed trees round-trips from a v1 peer into a v2 session, because the two versions produce the same bytes for every non-indexed transfer mode; a grove with an indexed tree fails with a descriptive NotSupported naming indexed trees and protocol version 2, not a root-hash mismatch several layers later; and every failure path leaves the destination's root hash unchanged and verify_grovedb clean, which is the atomic-restore invariant applied to a sync that never commits. Also pins the session-version guards on apply_chunk (mismatched version, unsupported version) on both sides of the wire. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… sync At this layer a snapshot is a plain RocksDB checkpoint directory with no pin, lease, or refcount tying it to the sessions reading it, so nothing stops a retention policy above grovedb from deleting one while a slow consumer is still fetching. The behaviour was assumed, never asserted. Pins all three halves of it. Deleting the directory between fetch_chunk calls does not disturb an open source: POSIX unlink semantics keep the SSTs readable through the descriptors the source already holds, so the sync completes with the correct root hash — which means pruning is NOT a way to cut off a slow consumer or reclaim the space. Reopening the pruned path, however, does not fail closed: GroveDb::open runs with create_if_missing, so it silently creates an empty grove there, and only the client's app-hash check stops that empty grove from being synced. And a session abandoned mid-sync leaves the destination's root hash unchanged and verify_grovedb clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…te batch from the flush Sampling resident size understated the largest tier by nearly half: at 4 GB of state macOS was compressing the process's anonymous pages, which drops them out of RSS while the process still owns them, so a restore that was swamping the machine measured as CHEAPER than one that fit. The 4 GB tier reads 14.3 GiB by RSS and 26.9 GiB by phys_footprint — the latter is the real number. Switch the macOS reading to proc_pid_rusage/ri_phys_footprint; Linux keeps /proc/self/statm, which has no such failure mode. Also samples the footprint immediately before commit_session, when the whole sync write set is in the WriteBatchWithIndex and nothing has been flushed. That separates the two costs — the batch itself is about half the peak at every tier, the commit-time flush the other half — which is what decides whether the remedy is a staged/scratch restore or a RocksDB write-buffer tuning. Documents the source/target co-location confound that makes the increment an upper bound, gives RssSampler a Drop so a panicking measurement cannot leak the poll thread, and relabels the partial 'logical payload' figure as what it is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every property in the file is written as an if-let over a successful decode, which asserts nothing when the decoder always rejects — and that is the normal case for unstructured bytes, since these formats need an exact length and an exact count byte to parse at all. The canonical-framing assertions were therefore carried entirely by the mutated-encoding strategies, with nothing checking that those still produce parseable inputs. Adds a deterministic per-surface corpus of genuine single-byte edits (splice, truncate, flip) and requires both arms to be reached: at least one edited input decodes, so the re-encode assertions actually run, and at least one is rejected, so the corpus is not just the identity. The seed itself is excluded from the corpus and checked separately, so the accept arm cannot be satisfied by the unmutated input. All six decode surfaces pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
QA: scale/memory measurement + adversarial input testing (test commits Memory ceiling of the atomic restore — measured for the first time, and it's a real finding:
Scaling is linear at ~5× the source's on-disk size (the single sync-wide Adversarial input testing — no bugs found: all six untrusted decode surfaces (nested-bytes framing, global chunk IDs, indexed header + header request, non-Merk chunk IDs and pages) property-tested for no-panic, input-bounded allocation, and canonical round-tripping, against both unstructured bytes and mutated valid encodings, with a non-vacuity guard proving both accept and reject paths are exercised. Per-branch tests added for every Compatibility pinned in the rollout direction: a v2 client syncing from a v1-only source round-trips non-indexed groves byte-identically, and fails indexed groves with a descriptive Two operational sharp edges documented (not corruption risks): deleting a checkpoint directory mid-sync does not stop an already-open source (pruning can't cut off a slow consumer or reclaim disk), and 🤖 Posted autonomously by Claude on behalf of pasta. |
…col version The pre-existing state sync wire protocol never shipped, so there is no compatibility story to keep: version 1 simply becomes the updated protocol (atomic restore, indexed-tree transfer, PrivateDocumentStore replay, aggregate-restore fix). Remove the dual-version machinery: SUPPORTED_STATE_SYNC_VERSIONS, is_supported_state_sync_version, INDEXED_SYNC_MIN_VERSION, and the version-gated indexed-tree serving/discovery rejects. Every entry point now compares the requested version against CURRENT_STATE_SYNC_VERSION (back to 1) and rejects anything else with an error naming both versions; the version parameter threading and the session-consistency check stay as the hook for a future bump. Replace the cross-version compat tests with replication_version_tests.rs pinning what survives: descriptive rejection of unsupported versions on both sides with an untouched destination, the session-version-consistency guard, and a round-trip sanity anchor. Reword the append-only page-cursor check as plain input validation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-wedge guarantee Regression tests for the preceding fix. Deliberately free of any assertion that a restore SUCCEEDS, so they are green at BOTH grovedb pins: Dash Platform state always contains sum trees, so a full successful restore needs dashpay/grovedb#840, but a restore that FAILS exercises the same recovery path either way — at the unpatched revision the sum-tree defect supplies the failure for free. Covers: offer_snapshot records the sentinel before it wipes; a rejected offer records none, so a peer cannot make a healthy node wipe itself on the next restart just by offering a format it cannot speak; a restart mid-restore wipes, comes up empty and passes the info handshake instead of crash-looping; a NORMAL restart keeps its state, which is the regression that matters most if startup recovery ever fires unconditionally; init_chain clears a sentinel left by an abandoned restore, so the block-sync fallback's chain survives the next restart; and end to end, an unusable snapshot offered by a peer leaves the node empty, recoverable and able to sync. The shared chunk-loop driver now reports REJECT_SNAPSHOT as a SnapshotSyncOutcome::Rejected rather than treating it as an unexpected result code, and the two existing tests that relied on the old error-returning refusal assert the rejection plus the new wipe-back-to-clean behaviour. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The atomic restore holds every write of the whole sync in one RocksDB transaction until commit_session has verified the root hash. That is correct by construction and it is what issue dashpay#775 asked for, but it costs a resident copy of the entire state: measured at 5.2x the source grove's on-disk size, growing linearly, which puts a spec-compliant 8 GB evonode over its RAM budget at well under a gigabyte of grove. RestoreCommitMode::Incremental commits at boundaries the session proves are safe -- current_prefixes drained, so no restorer holds a storage context on the transaction, and no indexed group open, so a group's joint verification can never straddle a commit. The payload budget drives the existing discovery pacing, which matters because a subtree *count* boundary is unreachable on Platform-shaped state (a few fat subtrees, never 64 of them). Peak memory then tracks the budget, not the state. The default stays Atomic and is untouched: discovery_batch_full returns false for the budget in that mode, so no atomic session can take an intermediate commit. What Incremental gives up is only the rollback -- the final root hash check still gates the last commit. A database left half-restored is stamped in aux storage and readable back via GroveDb::has_incomplete_restore, so a caller can tell a poisoned directory from a plausible one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…the ceiling Adds a GROVEDB_SCALE_RESTORE_BUDGET_MIB knob so the same tiers can be run against RestoreCommitMode::Incremental without editing code, and reports the mode and the intermediate-commit count alongside the memory readings. Adds a value-heavy/key-heavy fixture pair at matched on-disk size and ~40x apart in entry count. It settles where the bytes live: cost per source byte matches within ~15% across the pair while cost per entry differs 60x, so the WriteBatchWithIndex payload is the ceiling and its skiplist index is single-digit percent. That is why RocksDB write-buffer tuning cannot move the number -- a transaction commit is one WriteBatch, and RocksDB switches memtables between write groups, never inside one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nothing A commit is only safe once every in-flight restorer has released the transaction, so the budget can only be spent at a moment when current_prefixes is empty. Nothing was bounding how many subtrees got there at once: a parent's whole fan-out is activated in a single prepare_sync_state_sessions call, and a Platform-shaped root fans out to a handful of very large subtrees. The drained moment therefore did not arrive until nearly the whole state was already in the write batch. Measured on the medium tier (1.31 GiB source): uncapped, a 64 MiB budget took exactly ONE intermediate commit and moved peak footprint from 7879 to 7355 MiB -- 7%, i.e. nothing. With max_subtrees_in_flight=1 the same budget takes 9 commits and peak drops to 3314 MiB, a 67% cut in the restore's memory increment, at no wall-clock cost (48.2s vs 52.3s) and with the restored directory nearly halved (1512 vs 3282 MiB) because the WAL and L0 no longer hold the entire sync. Also replaces the drained-boundary predicate with the condition it actually wants -- current_prefixes empty and discovered work pending. Re-testing discovery_batch_full there would have stalled the deferred set the moment the in-flight cap stopped being the reason the batch was closed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ter a failed commit RestoreCommitMode::Incremental's fields are public, and max_subtrees_in_flight: 0 was not a slow restore but a silent hang: every discovered subtree is deferred, apply_chunk returns no next chunk ids, the caller's queue drains, and is_sync_completed() stays false forever with nothing to report. Clamped to one at the point of use, with a test that drives a real sync at zero and fails if the clamp goes away. Also marks a session dead when an intermediate commit fails. Tx::commit consumes the transaction, so the swap has to precede the commit, which means a failed commit leaves the session holding a fresh transaction with the failed batch's writes gone -- an unrecoverable hole mid-restore. The final root hash check would very likely catch it, but a caller that ignored the error should not get to find out; apply_chunk and commit now refuse outright. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…urve Peak memory is not a clean multiple of the budget -- a commit can only land on a subtree boundary, so the effective granularity is max(budget, largest subtree) and below that the budget buys nothing. Records the measured medium-tier curve (atomic 7046 MiB, 128 MiB budget 2666, 64 MiB 2357, 16 MiB 1428) and defaults to 16 MiB, the smallest budget measured to hold a gigabyte-scale restore near the size of the source rather than a multiple of it. Also states the residual O(budget + largest subtree) term on the Incremental variant instead of claiming a flat O(budget) bound it does not deliver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Follow-up on the memory finding: root-caused, and fixed with an opt-in bounded-memory restore (commits Where the 5.24× peak actually goes (measured, The fix — Measured result (medium tier): peak increment 7,046 → 1,428 MiB (5.24× → 1.06× of source size) at the 16 MiB default budget; restored directory also drops 3,282 → 1,407 MiB. Honest residual: a subtree's restorer pins its storage context for the subtree's life, so peak is O(budget + largest subtree), not O(budget) — documented on the type. What incremental mode gives up is the rollback, not the check: the final root-hash gate still refuses a mismatch, and a half-restored database is stamped in aux ( Exposure, for calibration: mainnet's grove is on the order of 50–150 MB today (derived independently from fee-metered storage bytes and from object counts against the fixtures' measured ~199 B/entry), so this was a headroom problem rather than a live wall — an 8 GB evonode tops out around a 0.86 GB grove under atomic restore, roughly 4–9 years out at the current ~42 MB/yr billed growth, but only ~8 months after a 10× adoption step. Worth noting separately: 🤖 Posted autonomously by Claude on behalf of pasta. |
|
Live end-to-end validation is green. With this PR's fixes plus the companion changes, a fresh node joining a running local network completes a genuine state sync — 14 passing / 0 failing across join, proof-verified state serving, all-validators churn mid-join, re-sync after a platform-data wipe, and correct block-sync fallback when no snapshots are served. Both previously-fatal live bugs (chunk verification failure; the 🤖 Posted autonomously by Claude on behalf of pasta. |
|
Real-data validation: state sync restored actual mainnet state, correctly, in 14.9 seconds. A mainnet Evolution fullnode was synced to the live tip (height 424,653; Core IBD 2h58m, platform replay of 424k blocks 5h29m, zero ABCI errors) and its real grove used as a state-sync source in-process:
Side-findings landing elsewhere: all five hardcoded dashmate mainnet tenderdash seeds fail the p2p handshake (they still accept TCP, so port checks miss it), and dashmate cannot start Platform on a fullnode configured outside the setup wizard (tenderdash node key never generated; the template writes the literal string 🤖 Posted autonomously by Claude on behalf of pasta. |
rewrite_aggregates re-derives each node's own aggregate contribution from its element bytes, and kept the stored feature value for anything that does not parse as a GroveDB Element. For a raw merk -- values that are not Elements, which is what the exported ChunkProducer / Restorer API exists for -- that value is the node's SUBTREE total, not its own contribution: the producer falls back to Node::KVValueHashFeatureType, and to_kv_value_hash_feature_type_node fills it from aggregate_data() because the verifier hashes the aggregate. Keeping it as the own contribution and then re-attaching the recomputed child aggregates counted every descendant twice, so finalize()'s verify rejected a perfectly valid multi-node raw ProvableCount / ProvableSum / ProvableCountProvableSum restore with 'restored tree invalid'. Subtract the recomputed child aggregates back out instead. Only the four Provable* feature variants are touched, which are exactly the ones the producer substitutes an aggregate for; the non-provable SumTree / CountTree / CountSumTree / BigSumTree family carries own values on the wire and is returned unchanged. This path is unreachable through GroveDB state sync, where every value is an Element. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All three findings confirmed — with the first reproduced as a working exploit before fixing — and addressed in
Gates: 2,986 grovedb tests + 724 merk tests passing, workspace clippy 🤖 Posted autonomously by Claude on behalf of pasta. |
thepastaclaw
left a comment
There was a problem hiding this comment.
Final validation — Sol-only technical fallback
All three prior findings are fixed at the exact head: empty subtree payloads are commitment-checked, indexed secondaries are activated before descendants, and raw provable aggregates are decomposed into own contributions. Two reproducible edge cases remain in the new aggregate rewrite: Element-shaped opaque values are misclassified, and valid signed cancellation can overflow during inverse decomposition; both reject valid raw Merk restores, so this review is COMMENT.
Source: reviewer 1: gpt-5.6-sol (agent: sol-fallback-reviewer, role: general); reviewer 2: gpt-5.6-sol (agent: sol-fallback-reviewer, role: rust-quality); final verifier: gpt-5.6-sol (agent: sol-verifier, role: final-verifier)
One or more required Phase-1 GLM Flash lanes remained technically unusable after the bounded exact-model retry. Their evidence was discarded as authoritative, and the complete selected role cohort was rerun fresh on exact gpt-5.6-sol before this fresh Sol verifier produced the final decision. No additional Phase-2 reviewer pass ran.
Review provenance
- Phase 1 GLM evidence: technically unusable after bounded retry; discarded from the decision
- GLM failure attempts:
codex-general-26dd3322f0a34cb786d21dcd0db83d53(failed),codex-general-800977ebd56e444d99fc12d770f4cf6b(failed),codex-rust-quality-d0a8832ca43a47d885497e03a739d69e(failed),codex-rust-quality-56f0001a13084bfa8903c39dd2fb3f73(failed) - Sol-only fallback reasons:
launch_transport_or_nonzero_exit,launch_transport_or_nonzero_exit - Sol-only fallback reviewers:
gpt-5.6-sol— general (completed); agentsol-fallback-reviewer,gpt-5.6-sol— rust-quality (completed); agentsol-fallback-reviewer - Fresh verifier (Sol):
gpt-5.6-sol— final-verifier; agentsol-verifier - Additional Phase 2 pass: not run; the Sol-only fallback is final
🟡 2 suggestion(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `merk/src/merk/restore.rs`:
- [SUGGESTION] merk/src/merk/restore.rs:722-735: Element-shaped raw Merk values are rewritten as GroveDB elements
Merk values are opaque `Vec<u8>` values, so successful `Element::deserialize` does not establish that the caller intended GroveDB element semantics. This branch nevertheless replaces the proof-carried feature with `Element::get_feature_type`. A multi-node raw `ProvableCountTree` whose values happen to be serialized `Element::Item` bytes and whose own count is 7 constructs and produces valid chunks, but `finalize` returns `restored tree invalid` because every count is rewritten to the Item-derived value 1. For provable hosts, recover the own contribution from the proof-carried subtree total regardless of value encoding, or carry an explicit ownership-domain discriminator; add a round-trip test using Element-shaped opaque bytes with deliberately different feature values.
- [SUGGESTION] merk/src/merk/restore.rs:1039-1046: Raw provable sums can overflow during a valid decomposition
`TreeNode::aggregate_data` computes signed sums as `(own + left) + right`, but this inverse computes `(total - left) - right`. Those checked operations have different intermediate overflow behavior. For example, a raw three-node `ProvableSumTree` with root own value `i64::MAX`, left aggregate `i64::MIN`, and right aggregate `1` is valid because `(MAX + MIN) + 1 == 0`; restoration nevertheless rejects it because `0.checked_sub(i64::MIN)` overflows before the right contribution can cancel it. Undo the source operations in reverse order—subtract right, then left—or calculate in `i128` and range-check the final own value. The same defect affects the sum component of both count-and-sum provable variants.
| let derived_from_element = | ||
| match Element::deserialize(cloned_node.value_as_slice(), grove_version) { | ||
| Ok(element) => { | ||
| let derived_feature_type = | ||
| element.get_feature_type(tree_type).map_err(|_| { | ||
| Error::CorruptedState( | ||
| "cannot derive feature type during aggregate rewrite", | ||
| ) | ||
| })?; | ||
| cloned_node.set_feature_type(derived_feature_type); | ||
| true | ||
| } | ||
| Err(_) => false, | ||
| }; |
There was a problem hiding this comment.
🟡 Suggestion: Element-shaped raw Merk values are rewritten as GroveDB elements
Merk values are opaque Vec<u8> values, so successful Element::deserialize does not establish that the caller intended GroveDB element semantics. This branch nevertheless replaces the proof-carried feature with Element::get_feature_type. A multi-node raw ProvableCountTree whose values happen to be serialized Element::Item bytes and whose own count is 7 constructs and produces valid chunks, but finalize returns restored tree invalid because every count is rewritten to the Item-derived value 1. For provable hosts, recover the own contribution from the proof-carried subtree total regardless of value encoding, or carry an explicit ownership-domain discriminator; add a round-trip test using Element-shaped opaque bytes with deliberately different feature values.
source: ['claude']
| let own_sum = |total: i64| -> Result<i64, Error> { | ||
| total | ||
| .checked_sub(provable_sum_of(left)) | ||
| .and_then(|rest| rest.checked_sub(provable_sum_of(right))) | ||
| .ok_or(Error::CorruptedState( | ||
| "chunk-carried subtree sum does not decompose against its children's sums", | ||
| )) | ||
| }; |
There was a problem hiding this comment.
🟡 Suggestion: Raw provable sums can overflow during a valid decomposition
TreeNode::aggregate_data computes signed sums as (own + left) + right, but this inverse computes (total - left) - right. Those checked operations have different intermediate overflow behavior. For example, a raw three-node ProvableSumTree with root own value i64::MAX, left aggregate i64::MIN, and right aggregate 1 is valid because (MAX + MIN) + 1 == 0; restoration nevertheless rejects it because 0.checked_sub(i64::MIN) overflows before the right contribution can cancel it. Undo the source operations in reverse order—subtract right, then left—or calculate in i128 and range-check the final own value. The same defect affects the sum component of both count-and-sum provable variants.
| let own_sum = |total: i64| -> Result<i64, Error> { | |
| total | |
| .checked_sub(provable_sum_of(left)) | |
| .and_then(|rest| rest.checked_sub(provable_sum_of(right))) | |
| .ok_or(Error::CorruptedState( | |
| "chunk-carried subtree sum does not decompose against its children's sums", | |
| )) | |
| }; | |
| let own_sum = |total: i64| -> Result<i64, Error> { | |
| total | |
| .checked_sub(provable_sum_of(right)) | |
| .and_then(|rest| rest.checked_sub(provable_sum_of(left))) | |
| .ok_or(Error::CorruptedState( | |
| "chunk-carried subtree sum does not decompose against its children's sums", | |
| )) | |
| }; |
source: ['claude']
|
This is Claude, posting an automated review on behalf of the maintainer. Fixes for everything marked fixed below follow in a commit pushed to this branch; the numbers are the pre-fix reproductions. VerdictThe design is sound and the three earlier blockers are genuinely closed (re-verified adversarially, see the end). But one new blocking issue survives all of the PR's checks, and I reproduced it end-to-end through Findings1. 🔴 Blocking — forged element bytes under an honest value hash restore cleanly and corrupt non-provable aggregates (reproduced, fixed)
Reproduction (new test Fix: element-mode finalization requires 2. 🟠 Should-fix — feature-type family is never tied to the tree type; the new root recheck turns one case into a panic (reproduced, fixed)
3. 🟠 Should-fix — source-side response amplification in
|
- Bind element bytes in element-mode aggregate restore: a KVValueHash-family chunk node's value bytes are not covered by the chunk hash, so require value_hash(bytes) to equal the hash-bound value hash for simple-valued elements before they decide a node's aggregate contribution (reproduced: a forged SumItem restored and committed under an honest value hash). - Refuse chunk nodes whose feature-type family is foreign to the tree type at write time (KV into a SumTree; ProvableCountedSummed into a ProvableCountTree, which panicked at the root recheck). - Bound the request shape fetch_chunk serves: at most CONST_GROUP_PACKING_SIZE global ids, the same per-subtree local ids, one page cursor per append-only subtree. - Refuse to start any session into a database marked with an incomplete restore. - Track every discovered prefix and require all of them to be processed before the session reports completion. - Validate the protocol version in start_syncing_session*; make the raw session constructor and add_subtree_sync_info crate-private. - Return an error instead of panicking on a Hash-only non-root chunk. - Scope the conjured transaction reference to the chunk loop; correct the aggregate-verification doc comments. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
This is Claude. Fixes for every item marked fixed in the review above are pushed to this branch as 44f1eba ( What the commit adds, with a regression test per item that was reproduced first against d637371:
API note for drive-abci: Local results on 44f1eba: |
… error arms The scoped codecov/patch/indexed-tree status failed because both rejection arms of the extracted `indexed_element_axes` helper were unexercised. Cover them directly, and add margin to the overall patch status with tests for: - a header page with the wrong section count and a header whose axis tags differ from the element's axes (target-side registration errors), - header requests that are not alone in their global chunk, carry an unknown axis tag, or name a primary root that has no node (source side), - the commit-mode predicate and the replication feature-version gate on the session constructors, - the `SubtreesMetadata` Debug rendering for all three variants, - `expects_an_empty_tree` before and after the root chunk is processed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Conflict resolution notes: - get/mod.rs: `follow_reference_with_max_hop` keeps develop's `underlying()`-based hop detection (#858) and adds the bidirectional edge as a hop that also caps the remaining budget with its declared `max_hop`; the terminal is returned wrapper-intact but with its referrer list stripped (the logical form forward references commit to). - lib.rs `verify_grovedb`: a bidirectional reference's self hash is `combine(inner, backrefs)`; develop's `reference_terminal_as_committed` selection is applied to that self hash and the terminal's LOGICAL hash is what the chain commits to. - proof/generate.rs: every V1 dereferencing site threads the bidi `max_hop` and self-hash override into develop's committed-terminal selection; the backward-references item arm charges rows through the per-instance limit state (#844/#845). - insert/add_element_on_transaction/v2.rs (#858): carries the same backward-references arms as v1 (family items store like their plain twins, bidirectional references fail closed outside the MerkCache flow, references commit to the terminal's logical hash). - grovedb-version v4.rs: `add_element_on_transaction: 2` from develop; the `*_without_transaction` slots removed by this branch stay removed. - sum-budget row binding (#870): `KVBackwardsReferencesValueHash` rows are reported by the merk verifier as hash-bound (their combined hash is recomputed from the presented bytes into the root), so the window verifier accepts backward-references sum items. - get/mod.rs (#923): develop's seeded-`visited` walk (`follow_reference_as_stored_visiting`, used by `follow_reference_as_stored_for_write` to refuse a chain running back through the written position) and this branch's per-edge hop budget now share one walk, `follow_reference_with_max_hop_visiting`; the two public entry points are thin wrappers over it. - merk/src/tree/mod.rs (#918): `put_value` / `put_value_and_reference_value_hash` moved to the versioned `put_value/` module on develop; the provided-hash put (with its post-JIT rehash) stays in `tree/mod.rs`. - merk/src/merk/restore.rs (#840): develop computes the parent-link values inside `rewrite_parent_link` and refuses a bare-`Hash` chunk root there; this branch captures them in `process_chunk` (before the write, applied only after it succeeds), so the descriptive refusal moved to that capture and the rewrite keeps its parameters. `NULL_HASH` import taken from develop. - replication/verify.rs (#840): the post-restore value-hash binding pass learns the backward-references family — a bidirectional reference binds `combine(combine(inner, referrer list), end hash)` with the end hash being the terminal's logical hash (bidirectional edges are hopped like any other), the item variants bind `combine(inner, referrer list)`. - replication_session_tests.rs (#840): both sides appended tests at the same spot; the bidirectional-reference round trip and develop's indexed / incremental / version tests are all kept. - V1 prover stack frame: both sides had grown `prove_subqueries_v1`'s debug frame to the edge of the 2 MiB test-thread stack (94 KiB before the merge, 100 KiB after — `proof_generation_succeeds_at_reasonable_depth` overflowed at depth 20). The self-contained sections — the count-offset short-circuit, the non-Merk lower-layer descents and the reference-row rewrites — now live in `#[inline(never)]` helpers so their locals stop being reserved in every recursion level. V0 prover untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Conflict resolution notes: - get/mod.rs: `follow_reference_with_max_hop` keeps develop's `underlying()`-based hop detection (#858) and adds the bidirectional edge as a hop that also caps the remaining budget with its declared `max_hop`; the terminal is returned wrapper-intact but with its referrer list stripped (the logical form forward references commit to). - lib.rs `verify_grovedb`: a bidirectional reference's self hash is `combine(inner, backrefs)`; develop's `reference_terminal_as_committed` selection is applied to that self hash and the terminal's LOGICAL hash is what the chain commits to. - proof/generate.rs: every V1 dereferencing site threads the bidi `max_hop` and self-hash override into develop's committed-terminal selection; the backward-references item arm charges rows through the per-instance limit state (#844/#845). - insert/add_element_on_transaction/v2.rs (#858): carries the same backward-references arms as v1 (family items store like their plain twins, bidirectional references fail closed outside the MerkCache flow, references commit to the terminal's logical hash). - grovedb-version v4.rs: `add_element_on_transaction: 2` from develop; the `*_without_transaction` slots removed by this branch stay removed. - sum-budget row binding (#870): `KVBackwardsReferencesValueHash` rows are reported by the merk verifier as hash-bound (their combined hash is recomputed from the presented bytes into the root), so the window verifier accepts backward-references sum items. - get/mod.rs (#923): develop's seeded-`visited` walk (`follow_reference_as_stored_visiting`, used by `follow_reference_as_stored_for_write` to refuse a chain running back through the written position) and this branch's per-edge hop budget now share one walk, `follow_reference_with_max_hop_visiting`; the two public entry points are thin wrappers over it. - merk/src/tree/mod.rs (#918): `put_value` / `put_value_and_reference_value_hash` moved to the versioned `put_value/` module on develop; the provided-hash put (with its post-JIT rehash) stays in `tree/mod.rs`. - merk/src/merk/restore.rs (#840): develop computes the parent-link values inside `rewrite_parent_link` and refuses a bare-`Hash` chunk root there; this branch captures them in `process_chunk` (before the write, applied only after it succeeds), so the descriptive refusal moved to that capture and the rewrite keeps its parameters. `NULL_HASH` import taken from develop. - replication/verify.rs (#840): the post-restore value-hash binding pass learns the backward-references family — a bidirectional reference binds `combine(combine(inner, referrer list), end hash)` with the end hash being the terminal's logical hash (bidirectional edges are hopped like any other), the item variants bind `combine(inner, referrer list)`. - replication_session_tests.rs (#840): both sides appended tests at the same spot; the bidirectional-reference round trip and develop's indexed / incremental / version tests are all kept. - V1 prover stack frame: both sides had grown `prove_subqueries_v1`'s debug frame to the edge of the 2 MiB test-thread stack (94 KiB before the merge, 100 KiB after — `proof_generation_succeeds_at_reasonable_depth` overflowed at depth 20). The self-contained sections — the count-offset short-circuit, the non-Merk lower-layer descents and the reference-row rewrites — now live in `#[inline(never)]` helpers so their locals stop being reserved in every recursion level. V0 prover untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-wedge guarantee Regression tests for the preceding fix. Deliberately free of any assertion that a restore SUCCEEDS, so they are green at BOTH grovedb pins: Dash Platform state always contains sum trees, so a full successful restore needs dashpay/grovedb#840, but a restore that FAILS exercises the same recovery path either way — at the unpatched revision the sum-tree defect supplies the failure for free. Covers: offer_snapshot records the sentinel before it wipes; a rejected offer records none, so a peer cannot make a healthy node wipe itself on the next restart just by offering a format it cannot speak; a restart mid-restore wipes, comes up empty and passes the info handshake instead of crash-looping; a NORMAL restart keeps its state, which is the regression that matters most if startup recovery ever fires unconditionally; init_chain clears a sentinel left by an abandoned restore, so the block-sync fallback's chain survives the next restart; and end to end, an unusable snapshot offered by a peer leaves the node empty, recoverable and able to sync. The shared chunk-loop driver now reports REJECT_SNAPSHOT as a SnapshotSyncOutcome::Rejected rather than treating it as an unexpected result code, and the two existing tests that relied on the old error-returning refusal assert the rejection plus the new wipe-back-to-clean behaviour. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… un-ignore the state sync round trip dashpay/grovedb#840 merged into develop as 6ec8feec; every grovedb crate is re-pinned to that exact merge commit (Cargo.lock changes only the grovedb source revisions). With the fix in place the full two-instance round trip run_state_sync_between_two_platforms is un-ignored and both tripwires that pinned the old defect (tests/sum_tree_sync_probe.rs and state_sync_transfer_detects_sum_tree_restore_defect) are deleted. The new grovedb also invalidates a restore session permanently once a chunk fails to apply, so apply_snapshot_chunk no longer answers a bad chunk with RETRY + refetch (the session could never accept the refetched chunk); it bans the sender and asks Tenderdash for RETRY_SNAPSHOT, which offer_snapshot answers by wiping and opening a fresh session. The handler unit test and the integration driver's tampered-chunk path are updated to the new ladder. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Summary
Completes the replication support needed for Dash Platform state sync. The unshipped protocol is updated in place under V1:
CURRENT_STATE_SYNC_VERSIONremains 1.RestoreCommitMode::Incrementalbounds pending work with a payload budget and an in-flight subtree cap. Intermediate commits wait for active restorers to drain and indexed groups to verify. An incomplete-restore marker survives failures, and constructors reject marked destinations.Compatibility
State sync has never shipped, so these wire changes belong to V1. The version parameter remains available for future incompatible protocols.
start_syncing_sessionandstart_syncing_session_with_modetake both a protocol version and&GroveVersion, returnResult, and initialize the root before returning. All public constructors validate the version, discovery batch size, and destination marker.start_snapshot_syncing*retain their signatures. Raw session construction and subtree activation are crate-private.Atomic mode provides rollback of the entire restore. Incremental mode trades that rollback for lower memory use; its bound still depends on the largest active subtree or indexed group. Final value verification streams stored nodes with uncached reference lookups and retains only the subtree frontier.
Validation