PR #488 follow-up: deferred review items
Tracks all items deferred from a multi-pass review of #488 (fix(merge): close merge-stale-diff bug class). Items addressed during the review are not listed here — they landed in commits 35b918e4, 837fd392, b79bd5a3, e77a70da, 19b0297f, 3a6baf15, dc217d5f, a1cd5233, and e1c1bd1d.
This issue groups items by severity. Test coverage gaps are in a follow-up comment to keep this main body focused on code-level items.
Medium severity
1. Consolidate KeyValueRejectedDeployBuffer and KeyValueDeployStorage
The new buffer module is ~85% byte-identical to existing KeyValueDeployStorage:
| Method |
KeyValueDeployStorage |
KeyValueRejectedDeployBuffer |
Differs? |
| Struct field |
pub store: KeyValueTypedStoreImpl<ByteString, Signed<DeployData>> |
identical |
no |
new(kvm) |
opens "deploy_storage" |
opens "rejected_deploy_buffer" |
name only |
add(deploys) |
put, key by sig |
identical |
no |
remove(deploys) |
delete, key by sig |
identical |
no |
remove_by_sig(sig) |
exists-check + delete |
identical |
no |
read_all() |
to_map().map(...) |
identical |
no |
non_empty() |
store.non_empty() |
identical |
no |
any(pred) |
present |
absent |
only KeyValueDeployStorage |
contains_sig(sig) |
absent |
present |
only buffer |
get_by_sig(sig) |
absent |
present |
only buffer |
The buffer's own header comment is honest: // Mirrors KeyValueDeployStorage in shape and storage backing.
Cost of leaving as-is: every future change to the deploy-sig storage API lands in two files; helpers drift between the two over time.
Recommended fix: parameterize KeyValueDeployStorage::new on store name, add the missing helpers (contains_sig, get_by_sig, any) once, delete the buffer module. ~30-line consolidation removes 91 lines. Update ~7 import sites across engine/casper_launch.rs, engine/engine.rs, engine/genesis_validator.rs, engine/genesis_ceremony_master.rs, engine/initializing.rs, node/src/rust/runtime/setup.rs, and tests.
Bundle: make pub store: field pub(crate). The whole point of the wrapper is to expose only sig-keyed methods. pub store: lets any caller bypass the wrapper and call store.put(...) / store.delete(...) directly with arbitrary keys. Verified no Rust call site outside the module accesses .store directly today, but pub invites future drift. Same smell exists on KeyValueDeployStorage.store (existing); the consolidated module should make it private.
Files: block-storage/src/rust/deploy/{key_value_deploy_storage,key_value_rejected_deploy_buffer}.rs.
2. repeat_deploy should use resolve_batch instead of per-sig resolve
validate.rs::repeat_deploy (line 353-415) calls resolve_finalization_status(...) once per sig that's in rejected_in_scope. Each call runs a fresh BFS over deploy_lifespan blocks.
let deploy_key_set: HashSet<Vec<u8>> = block.body.deploys
.iter()
.filter(|pd| {
if !s.rejected_in_scope.contains(&pd.deploy.sig) {
return true;
}
match resolve_finalization_status( // ← per-sig BFS over deploy_lifespan
&s.dag, block_store, expiration_threshold as i64, &pd.deploy.sig,
) { ... }
})
...
For a block with M deploys whose sigs are in rejected_in_scope:
- Each
resolve_finalization_status runs a fresh BFS bounded by deploy_lifespan blocks
- Each block visited triggers an LMDB read + protobuf deserialization
- Total cost per block validation: M × deploy_lifespan LMDB reads
Concrete numbers:
deploy_lifespan = 50 (test default), M = 10 → 500 reads per block validation
deploy_lifespan = 200 (typical production), M = 20 → 4 000 reads per block validation
The resolve_batch API exists in deploy_finalization_status.rs:497 (added in this PR) and amortizes the BFS across all sigs in one walk. Cost goes from O(M × lifespan) to O(M + lifespan) — for M = 20, ~220 reads instead of 4 000, an ~18× reduction for the validation hot path.
Recommended fix: swap the per-sig resolve for one resolve_batch call upstream of the filter, then look up per-sig results from the resulting HashMap:
let rejected_in_scope_sigs: HashSet<Bytes> = block.body.deploys
.iter()
.filter_map(|pd| s.rejected_in_scope.contains(&pd.deploy.sig).then(|| pd.deploy.sig.clone()))
.collect();
let statuses = if rejected_in_scope_sigs.is_empty() {
HashMap::new()
} else {
resolve_batch(&s.dag, block_store, expiration_threshold as i64, &rejected_in_scope_sigs)?
};
// per-deploy filter then looks up statuses[sig] instead of calling resolve()
File: casper/src/rust/validate.rs:353-415.
Low / Medium — non-deterministic block bytes
These are pre-existing patterns the PR inherits but doesn't introduce. Two-line fixes each. They don't break cross-validator consensus (each validator signs their own block) but they break crash-recovery idempotency, test reproducibility, and any byte-keyed cache.
3. Sort justifications Vec before signing
justifications is collected as DashSet<Justification> in multi_parent_casper_impl.rs:331-342:
let justifications = {
let bonded_validators = &on_chain_state.bonds_map;
latest_msgs_hashes
.iter()
.filter(|(validator, _)| bonded_validators.contains_key(*validator))
.map(|(validator, block_hash)| Justification {
validator: validator.clone(),
latest_block_hash: block_hash.clone(),
})
.collect::<dashmap::DashSet<_>>()
};
Then iterated into a Vec in block_creator.rs:723 and serialized into the signed block:
justifications.iter().map(|j| j.clone()).collect()
Two layers of non-determinism: imbl::HashMap source iteration and DashSet collection. Same proposer, same DAG state, two creation attempts → different signed bytes. PR #488 widens this (Section 3 puts equivocators in the set, growing the variance from non-deterministic ordering).
Fix: sort the Vec by validator pk before passing to package_block:
let mut justifications_vec: Vec<Justification> = justifications.iter().map(|j| j.clone()).collect();
justifications_vec.sort_by(|a, b| a.validator.cmp(&b.validator));
The sort key is unique per justification by construction (one entry per validator).
4. Sort all_deploys Vec before passing to runtime
all_deploys: HashSet<Signed<DeployData>> is collected via into_iter().collect() in block_creator.rs:540-545, 669 and passed to the runtime, which processes deploys in input order and emits processed_deploys in the same order — into the signed block body.
The deploy-cap path (lines 247-298) DOES sort the deploys deterministically before selection, but only when valid_unique.len() > max_user_deploys. Under the cap (the common case), the HashSet is returned as-is.
PR #488 amplifies this: the recovery buffer adds entries to the merged HashSet, growing the set and making cap-hit more common.
Fix: apply the existing cap-path sort unconditionally, not just when over the cap:
let mut deploys_vec: Vec<Signed<DeployData>> = all_deploys.into_iter().collect();
deploys_vec.sort_by(|a, b| a.sig.cmp(&b.sig));
Low / Verify
5. Convert active_validators: Vec<Validator> to a HashSet
filter_slashable_invalid_messages (block_creator.rs:359-366) calls active_validators.contains(validator) — O(N) slice scan in a filter loop:
fn filter_slashable_invalid_messages(
invalid_latest_messages: HashMap<Validator, BlockHash>,
bonds_map: &HashMap<Validator, i64>,
active_validators: &[Validator], // slice
) -> Vec<(Validator, BlockHash)> {
invalid_latest_messages
.into_iter()
.filter(|(validator, _)| {
bonds_map.get(validator).copied().unwrap_or(0) > 0
&& active_validators.contains(validator) // O(N)
})
.collect()
}
bonds_map is already a HashMap right next to it. Two different data structures for the same "is this validator in this set?" check. Negligible at typical sizes but inconsistent.
Fix: either build a HashSet<Validator> at the call site, or change OnChainCasperState::active_validators: Vec<Validator> (casper.rs:272) to a HashSet upstream.
6. Recovered-slash log: original_issuer is misleading after dedup
block_creator.rs:637-641:
tracing::info!(
"Recovering merge-rejected slash: invalid_block={}, original_issuer={}",
pretty_printer::PrettyPrinter::build_string_bytes(&rs.invalid_block_hash),
hex::encode(&rs.issuer_public_key.bytes)
);
After b79bd5a3's dedup-by-invalid_block_hash collapses multiple rejected slashes for the same equivocator to one survivor, the survivor's issuer_public_key is whichever happened to be first in sorted order — not necessarily informative.
Fix: drop the field from the log, rename to one_of_original_issuers=, or extend RejectedSlash with Vec<PublicKey> to log all of them.
7. Verify two compute_parents_post_state calls hit the cache
block_creator::create calls compute_parents_post_state once at line 593 (to discover rejected slashes), then compute_deploys_checkpoint calls it again internally at line 666. The PR claims caching prevents recomputation. The cache key (per slash_recovery_spec.rs:331-338):
ParentsPostStateCacheKey {
sorted_parent_hashes,
snapshot_lfb_hash,
disable_late_block_filtering,
}
None of these mutate during a single create() invocation, so the cache should hit. But it's load-bearing for perf and unverified.
Fix: add a tracing-target counter that increments on cache miss vs hit during compute_deploys_checkpoint calls inside create(). CI assertion that miss-count = 0 in steady state.
8. Sort rejected_slashes Vec at construction site
compute_parents_post_state (interpreter_util.rs:1230-1281) builds rejected_slashes: Vec<RejectedSlash> by iterating a HashMap<BlockHash, Vec<Bytes>>:
let mut by_block: HashMap<BlockHash, Vec<Bytes>> = HashMap::new();
for (sig, src_block) in &rejected_slash_pairs {
by_block.entry(src_block.clone()).or_default().push(sig.clone());
}
let mut out = Vec::new();
for (src_block, _sigs) in by_block { // ← non-deterministic iteration
...
out.push(RejectedSlash { ... });
}
out
HashMap iteration is non-deterministic (per-process random hasher seed). The output Vec therefore has non-deterministic order. Currently saved by downstream filter_recoverable which sorts on invalid_block_hash — but the cache stores the unsorted Vec, so any future consumer of the cached value that doesn't re-sort inherits the non-determinism. Fragile coupling.
Fix: sort out before returning (or sort the unique source blocks before iteration).
9. Resolver bfs_finalized_window is DFS, not BFS (in two places)
Both bfs_finalized_window in deploy_finalization_status.rs:198-217 and descendants_within_scope in dag_merger.rs:39-68 use Vec::pop() which is LIFO/DFS:
let mut frontier: Vec<BlockHash> = vec![lfb_hash.clone()];
while let Some(candidate_hash) = frontier.pop() { // pop() = LIFO = DFS
...
for parent in &candidate_block.header.parents_hash_list {
if !visited.contains(parent) {
frontier.push(parent.clone());
}
}
}
Function names and comments say BFS. Two validators with the same DAG state visit blocks in the same DFS order, so today's behavior is consistent across validators.
The risk: a future reader who notices the doc/code mismatch and "fixes" it by switching to frontier.remove(0) or VecDeque::pop_front would silently change which block wins the latest_block_hash tiebreak in the resolver, and the traversal order in the merge — both observable.
Fix: rename to dfs_finalized_window / descendants_within_scope_dfs and document the determinism guarantee, OR convert to true BFS via VecDeque::pop_front. Pick one and lock it in.
10. Drop deploy_storage lock earlier in prepare_user_deploys
block_creator.rs:67-231:
async fn prepare_user_deploys(...) -> Result<...> {
let mut deploy_storage_guard = deploy_storage.lock() // ① held entire function (~lines 67-305)
.map_err(|e| CasperError::LockError(e.to_string()))?;
// ... read deploy_storage ...
let recovered: HashSet<Signed<DeployData>> = { // ② brief buffer acquisition
let buffer_guard = rejected_deploy_buffer.lock()
.map_err(|e| CasperError::LockError(e.to_string()))?;
buffer_guard.read_all()?
}; // buffer guard dropped here
// ... ~130 lines of categorize/filter/sort work, deploy_storage_guard still held ...
if !all_expired.is_empty() {
let expired_list = ...;
deploy_storage_guard.remove(expired_list.clone())?;
let mut buffer_guard = rejected_deploy_buffer.lock() // ③ buffer re-acquisition
.map_err(|e| CasperError::LockError(e.to_string()))?;
buffer_guard.remove(expired_list)?;
}
// ... rest of function, deploy_storage_guard still held ...
}
Issues:
deploy_storage_guard is held for the entire function (~50-200 ms).
- Buffer mutex acquired twice in one function (read in ②, write in ③). Between them, a validator-thread replay can mutate the buffer. Today benign because ③ removes only expired sigs which the validator-thread replay won't add — undocumented invariant.
Under contention: validator thread blocks on buffer; any other deploy-storage-touching code blocks on deploy_storage_guard for the full ~200 ms.
Fix: drop deploy_storage_guard after the read-and-classify phase, before deploy-cap selection. Combine buffer read+write into a single critical section, or document the "buffer can grow but not shrink for these sigs" invariant.
11. Buffer has no admission size cap
KeyValueRejectedDeployBuffer::add has no upper bound on entries. The expiry-purge fix (837fd392) closes the realistic unbounded-growth path (sigs that age out without finalization). With expiry purge in place, the remaining unbounded-growth scenario is pathological (many deploys rejected that never expire and never re-finalize).
Fix: defensive cap with oldest-first eviction or simple max-entries reject. Belt-and-suspenders, not a known bug.
Open questions to investigate
12. Buffer recovery when LFB advances past the source block
The catchup gate (Section 5) resolves to Finalized | Failed | Expired to skip re-proposing. But it doesn't explicitly handle the case where the source block of a buffered deploy has been pruned from history (pre-LFB). If this fires, the resolver might return pending_unknown and the deploy stays in the buffer indefinitely.
Action: trace the lookup_by_deploy_id path for the pruned-source case, document the behavior, add a test.
13. Buffer behavior on reorg
If sig X is rejected in branch B1 (orphaned) and finalized in branch B2 (canonical), record_directly_finalized purges based on B2's body.deploys and body.rejected_deploys. But if the rejection only happened on B1, the validator's local buffer may have admitted X on B1's merge and never see X cleaned up via B2's finalization.
Action: trace this case, verify the catchup gate handles it on the next propose attempt.
14. LMM-advance non-determinism for same-seq equivocations (Section 3)
When two equivocation blocks B1 and B2 (same sender, same seq num, different content) arrive in different orders on different nodes, the LMM advance rule (block.seq_num >= metadata.sequence_number) means whichever arrives second wins. Two nodes can have different invalid-latest entries for the same equivocator.
Verified safe in the review: all downstream uses are either set-based (justification_follows) or filter-out-invalid (fork choice, Estimator, slashing only checks "latest is invalid", not which specific block).
Action: add a comment to Section 3's safety argument explicitly noting the same-seq case so future readers don't assume LMM-content equality across validators is required.
15. compute_parents_post_state cache coherence vs. concurrent finalization
If finalization advances LFB between block_creator's first compute_parents_post_state call and the second one inside compute_deploys_checkpoint, the cache key includes snapshot_lfb_hash which is captured at snapshot time — so the lookup uses the snapshot's LFB, not a freshly-fetched one. Need to verify this end-to-end.
Action: trace the cache lookup site to confirm snapshot_lfb_hash is used (not dag.last_finalized_block()).
Style / cleanup
16. Stale references in committed comments
Four PR-introduced comments anchor on transient patch context. Mechanical cleanup.
16a. block-storage/src/rust/dag/block_dag_key_value_storage.rs:645-679 — Scala source-of-truth + pre-fix narration in safety-argument comment:
"This matches the Scala source-of-truth (BlockDagKeyValueStorage.scala, where newLatestMessages and shouldAddAsLatest never reference invalid)."
"The pre-fix if invalid { return empty } guard had no Scala counterpart and silently disabled the slashing pipeline (no slashes ever issued, equivocators never punished)."
The substantive safety argument (fork choice / slashing / justification_follows downstream consumers) should stay. Drop the Scala anchors and the pre-fix narration.
16b. casper/tests/batch2/validate_test.rs:974-981 — "Pre-fix / Post-fix" doc block:
"Pre-fix: the rejected_in_scope filter in repeat_deploy exempts the sig unconditionally → returns Valid → double-execution slips through. This test fails."
"Post-fix: the filter is gated on status != Finalized. ... This test passes."
Once merged, "pre-fix" describes a state that no longer exists, and "this test fails" becomes literally untrue. The four lines above this block already describe the test's intent. Delete the pre/post block.
16c. casper/tests/api/deploy_finalization_status_test.rs:493-500 — "With the buggy implementation / with the fix" framing:
"Tests the canonical-descendant invalidation gap: with the buggy implementation, is_in_main_chain(A, S) = true causes invalidation of the clean inclusion at A even though S itself is non-canonical. With the fix that additionally requires the rejection block to be on LFB's main-parent chain, this rejection is correctly ignored and the sig stays Finalized."
Rewrite to describe the rule (both is_in_main_chain(A, S) AND is_in_main_chain(S, C) must hold) rather than the patch history.
16d. Scala-style camelCase identifiers in comments. The Rust code uses snake_case; readers grep for deploy_lifespan and find nothing because the comment used deployLifespan.
| File:line |
Stale form |
Rust form |
casper/src/rust/api/deploy_finalization_status.rs:36 |
deployLifespan |
deploy_lifespan |
casper/src/rust/api/deploy_finalization_status.rs:395 |
deployLifespan |
deploy_lifespan |
casper/src/rust/storage/rnode_key_value_store_manager.rs:121 |
deployLifespan, currentBlock, maxBlockNum |
snake_case |
casper/src/rust/storage/rnode_key_value_store_manager.rs:122 |
earliestBlockNumber |
earliest_block_number |
casper/tests/blocks/block_creator_spec.rs:184 |
maxBlockNum |
max_block_num |
casper/tests/blocks/block_creator_spec.rs:296 |
maxBlockNum |
max_block_num |
casper/tests/blocks/block_creator_spec.rs:341 |
maxBlockNum, deployLifespan, earliestBlockNumber |
snake_case |
Note: pre-existing // See casper/src/main/scala/coop/rchain/... file headers at the top of files like block_creator.rs are pre-existing and out of scope here — that's a wider codebase pattern. The four items above are PR-#488-introduced.
17. Test setup boilerplate duplication across two new specs
dedup_orphan_recovery_spec.rs:62-117 and multi_validator_recovery_spec.rs:60-111 share ~50 lines of setup boilerplate (genesis, kvm, runtime_manager, block_store, dag_storage, buffer init, init_logger, now_millis closure, mk_snapshot closure). The differences between the two are minor (number of validators captured, one extra explanatory comment).
Fix: extract into casper/tests/util/recovery_test_setup.rs returning a builder:
pub struct RecoveryTestSetup {
pub kvm: Box<dyn KeyValueStoreManager>,
pub block_store: KeyValueBlockStore,
pub dag_storage: BlockDagKeyValueStorage,
pub runtime_manager: RuntimeManager,
pub rejected_deploy_buffer: Arc<Mutex<KeyValueRejectedDeployBuffer>>,
pub genesis_context: GenesisContext,
pub now_millis: fn() -> i64,
}
pub async fn setup_recovery_test() -> RecoveryTestSetup { ... }
Each spec then opens with two lines instead of fifty.
18. MutexExt trait or From<PoisonError> for lock-error idiom
.map_err(|e| CasperError::LockError(e.to_string()))? repeats 4× in PR-added lines of block_creator.rs (more in surrounding existing code).
Fix: add a tiny helper or From<PoisonError<...>> for CasperError so ? works directly:
trait MutexExt<T> {
fn lock_for_casper(&self) -> Result<MutexGuard<'_, T>, CasperError>;
}
impl<T> MutexExt<T> for Mutex<T> { ... }
19. as_ref().map(...).unwrap_or(true) → map_or(true, ...)
4× in resolver body-scan loop, e.g.:
state.clean_finalized_event
.as_ref()
.map(|(h, _)| height > *h)
.unwrap_or(true)
Option::map_or covers exactly this case:
state.clean_finalized_event
.map_or(true, |(h, _)| height > h)
File: casper/src/rust/api/deploy_finalization_status.rs.
20. Replace active_sigs HashSet with direct per_sig.get_mut
deploy_finalization_status.rs:206-208:
let active_sigs: HashSet<Bytes> = per_sig.keys().cloned().collect();
per_sig is a HashMap<Bytes, ResolverState> — per_sig.contains_key(sig) is already O(1) without the duplicate set. The whole active_sigs allocation can go away — replace if active_sigs.contains(&pd.deploy.sig) with a direct if let Some(state) = per_sig.get_mut(&pd.deploy.sig) { ... }, which combines the membership check and the mutable lookup the next line is doing anyway.
This also removes 3 of the 4 .expect("active_sigs and per_sig must agree on key set") calls.
21. Convert dag_merger.rs cache .unwrap() to .expect("just-inserted")
Three cache.get(&addr).unwrap() calls in dag_merger.rs:361, 398, 399 on or_insert_with lookups. Safe today (entry exists by construction), but a bare .unwrap() panics with a generic "called Option::unwrap on a None value".
Fix: convert to .expect("just-inserted in get_chain_derived") to make the implicit invariant visible if a future refactor breaks it.
22. Add descendants_within_scope size metric
dag_merger.rs:39-68 walks DAG descendants restricted to scope. Worth surfacing scope size via tracing::debug!(scope_size=...) to make catchup or attack scenarios visible.
23. Buffer LMDB env shares 1 GB sizing with deploy_storage
rnode_key_value_store_manager.rs:121-127 registers the new buffer LMDB env using deploy_storage_env_config() (1 GB reservation). Practical disk usage will be much smaller (the buffer holds at most O(rejected_per_merge × deploy_lifespan) entries), but total LMDB reservation grows by 1 GB on disk on first launch.
Action: worth a note in operator-facing release notes if disk planning is tight. No code fix needed.
24. PR description vs. reality drift
PR description still says "Two deferred code-level tests (concurrent_registry_inserts_both_eventually_included, phase_c_dedup_orphans_loser_block_registry_writes) — rewritten but pending cleanup before commit." Neither test name appears in the codebase. Strategy actually changed: commit 88595e74 removed the existing ignored concurrent_registry_inserts_should_not_conflict test, and 3 commits (cf5d013d, bedaa83f, 88595e74) cover the review gaps under different names.
Action: update PR description to remove the deferred-tests line and describe the actual coverage delta.
25. thread_rng() non-determinism in RSpace::shuffle_with_index
rspace++/src/rspace/rspace.rs:1124-1133. Called from fetch_channel_to_index_data:654, extract_produce_candidate:727, fetch_matching_data:743. The PR description flagged this as the suspected cause of test_contract_lifecycle.py non-determinism on exploratory queries against recovery-merged state. Local fix exists (remove thread_rng()) but parity with Scala's SpaceMatcher.fetchChannelToIndexedData not yet verified.
Why it may be invisible in normal block flow: block replay rigging (Blake2b512Random + produce_counter + event-log replay) constrains matches by event log, masking shuffle non-determinism. Exploratory deploy is read-only and bypasses replay rigging.
Action: verify Scala parity before deciding fix direction. Tracked separately from this issue if it's part of a wider rspace investigation; included here for completeness.
26. Commit-message PR references
Two commits in this PR's history reference review item numbers in their commit messages (282f31fa: "Address PR #488 review request to document why..."; 55cbbb06: "Address remaining concern from PR #488 review #5..."). Code comments were cleaned up via 35b918e4 and 19b0297f; commit-message references don't survive squash-merge so they're harmless if the PR is squashed. Worth a one-line note if the PR is not squashed.
Minor recommendations from per-section review
27. Comment clarity on dag_merger.rs scope: None vs scope: Some(...)
dag_merger.rs lines 92-107 and 112-123 distinguish "legacy no-scope path (non-deterministic)" from "production multi-parent merge path (deterministic, scope-bounded)". The comment "non_finalized_blocks (non-deterministic, but no scope means this is not a multi-parent merge validation)" is alarming on first read — should clarify who actually calls without scope (proposer-only? legacy compat?).
28. disable_late_block_filtering flag interaction in dag_merger.rs
When scope.is_some(), late blocks are forced empty regardless of the flag. The flag's role only applies to the legacy scope: None path. Add a code comment noting this asymmetry.
29. Variable name first_seen_block_hash in resolver is misleading
lookup_by_deploy_id returns the last-inserted block from the deploy_index, not the first-seen. Doesn't affect correctness because valid_after_block_number is identical across blocks containing the same sig (it's part of the signed payload). Rename to last_indexed_block_hash or similar.
30. latest_block_hash resolver doc says "canonical block" but BFS walks all parents
The doc comment claims "highest-block-number canonical block" but bfs_finalized_window enqueues every parent in parents_hash_list, so the returned hash can be on a non-main-parent ancestor. Either tighten the BFS to main-parent-only or update the doc to say "in the finalized-ancestor closure of LFB."
31. Inconsistent definitions of "canonical" within the resolver
Canonical-descendant invalidation uses main-parent-chain checks (is_in_main_chain). latest_block_hash uses any-parent BFS. Two definitions, depending on what's being checked. Worth a comment explaining why each definition is the right one for its context.
32. Failed sigs are exempted from repeat_deploy (design choice — confirm)
The state machine in repeat_deploy treats Failed the same as Pending/Expired for exemption. Failed sigs are unlikely to succeed on re-execution (failures are usually deterministic — out of phlo, contract error). Allowing re-proposal is wasted work but not unsafe. Confirm this is the intended behavior; could be tightened to "exempt only Pending/Expired" if Failed re-execution is undesired.
33. Block-creator vs. validator asymmetry on Finalized gate (defense-in-depth — comment)
Block creator's collect_self_chain_deploy_sigs filter exempts rejected_in_scope unconditionally; validator's repeat_deploy exempts only non-Finalized. The asymmetry is defense-in-depth (proposer permissive, validator strict), but a buggy buffer admission could produce a block the validator will reject. Worth a code comment at the block creator filter explaining the deliberate asymmetry.
34. runtime_manager_test.rs size
File grew to ~2700+ lines and now contains a mix of original test scaffolding, general-purpose regression tests, and PR-#488-specific bug-demo tests. The author moved recovery-cycle / dedup-orphan / multi-validator / slash tests into casper/tests/batch2/*_spec.rs (good pattern) but left stale_diff_application_corrupts_merged_state and bridge_query_survives_multi_parent_merge in runtime_manager_test.rs.
Action: extract these into casper/tests/batch2/merge_corruption_spec.rs or similar.
35. compute_state_then_compute_bonds_should_be_replayable_after_all is #[ignore]
Pre-existing flakiness (added 2025-05-20, before PR #488). But this PR's changes affect deploy execution and replay, so flakiness may be related — author should run the ignored test with --include-ignored to see if PR #488 fixes or worsens it.
36. Cache invalidation question for slash recovery
block_creator::create calls compute_parents_post_state once before system-deploy construction, then compute_deploys_checkpoint later. If the cache is invalidated between those calls (DAG generation advances during block creation), the merge runs twice and the rejected_slashes from the first call may not match what the second call sees. Cache key includes (sorted_parent_hashes, snapshot_lfb_hash, disable_late_block_filtering) — none of which include DAG generation, so concurrent finalization shouldn't invalidate. Worth confirming end-to-end.
Summary
Code-level items: 36 total.
| Category |
Count |
| Medium severity |
2 |
| Low/Medium (block-byte determinism) |
2 |
| Low / Verify |
7 |
| Open questions |
4 |
| Style / cleanup |
11 |
| Minor recommendations |
10 |
None merge-blocking on their own. Items 1 and 2 are the highest-value follow-ups (real maintenance / perf cost). Items 3, 4 close a coherent determinism story. Items 12-15 are open questions that warrant a trace-level investigation before they bite.
Test coverage gaps are tracked in a follow-up comment.
Co-Authored-By: Claude noreply@anthropic.com
PR #488 follow-up: deferred review items
Tracks all items deferred from a multi-pass review of #488 (
fix(merge): close merge-stale-diff bug class). Items addressed during the review are not listed here — they landed in commits35b918e4,837fd392,b79bd5a3,e77a70da,19b0297f,3a6baf15,dc217d5f,a1cd5233, ande1c1bd1d.This issue groups items by severity. Test coverage gaps are in a follow-up comment to keep this main body focused on code-level items.
Medium severity
1. Consolidate
KeyValueRejectedDeployBufferandKeyValueDeployStorageThe new buffer module is ~85% byte-identical to existing
KeyValueDeployStorage:KeyValueDeployStorageKeyValueRejectedDeployBufferpub store: KeyValueTypedStoreImpl<ByteString, Signed<DeployData>>new(kvm)"deploy_storage""rejected_deploy_buffer"add(deploys)remove(deploys)remove_by_sig(sig)read_all()to_map().map(...)non_empty()store.non_empty()any(pred)KeyValueDeployStoragecontains_sig(sig)get_by_sig(sig)The buffer's own header comment is honest:
// Mirrors KeyValueDeployStorage in shape and storage backing.Cost of leaving as-is: every future change to the deploy-sig storage API lands in two files; helpers drift between the two over time.
Recommended fix: parameterize
KeyValueDeployStorage::newon store name, add the missing helpers (contains_sig,get_by_sig,any) once, delete the buffer module. ~30-line consolidation removes 91 lines. Update ~7 import sites acrossengine/casper_launch.rs,engine/engine.rs,engine/genesis_validator.rs,engine/genesis_ceremony_master.rs,engine/initializing.rs,node/src/rust/runtime/setup.rs, and tests.Bundle: make
pub store:fieldpub(crate). The whole point of the wrapper is to expose only sig-keyed methods.pub store:lets any caller bypass the wrapper and callstore.put(...)/store.delete(...)directly with arbitrary keys. Verified no Rust call site outside the module accesses.storedirectly today, butpubinvites future drift. Same smell exists onKeyValueDeployStorage.store(existing); the consolidated module should make it private.Files:
block-storage/src/rust/deploy/{key_value_deploy_storage,key_value_rejected_deploy_buffer}.rs.2.
repeat_deployshould useresolve_batchinstead of per-sigresolvevalidate.rs::repeat_deploy(line 353-415) callsresolve_finalization_status(...)once per sig that's inrejected_in_scope. Each call runs a fresh BFS overdeploy_lifespanblocks.For a block with M deploys whose sigs are in
rejected_in_scope:resolve_finalization_statusruns a fresh BFS bounded bydeploy_lifespanblocksConcrete numbers:
deploy_lifespan = 50(test default), M = 10 → 500 reads per block validationdeploy_lifespan = 200(typical production), M = 20 → 4 000 reads per block validationThe
resolve_batchAPI exists indeploy_finalization_status.rs:497(added in this PR) and amortizes the BFS across all sigs in one walk. Cost goes from O(M × lifespan) to O(M + lifespan) — for M = 20, ~220 reads instead of 4 000, an ~18× reduction for the validation hot path.Recommended fix: swap the per-sig
resolvefor oneresolve_batchcall upstream of the filter, then look up per-sig results from the resulting HashMap:File:
casper/src/rust/validate.rs:353-415.Low / Medium — non-deterministic block bytes
These are pre-existing patterns the PR inherits but doesn't introduce. Two-line fixes each. They don't break cross-validator consensus (each validator signs their own block) but they break crash-recovery idempotency, test reproducibility, and any byte-keyed cache.
3. Sort
justificationsVec before signingjustificationsis collected asDashSet<Justification>inmulti_parent_casper_impl.rs:331-342:Then iterated into a Vec in
block_creator.rs:723and serialized into the signed block:Two layers of non-determinism:
imbl::HashMapsource iteration andDashSetcollection. Same proposer, same DAG state, two creation attempts → different signed bytes. PR #488 widens this (Section 3 puts equivocators in the set, growing the variance from non-deterministic ordering).Fix: sort the Vec by
validatorpk before passing topackage_block:The sort key is unique per justification by construction (one entry per validator).
4. Sort
all_deploysVec before passing to runtimeall_deploys: HashSet<Signed<DeployData>>is collected viainto_iter().collect()inblock_creator.rs:540-545, 669and passed to the runtime, which processes deploys in input order and emitsprocessed_deploysin the same order — into the signed block body.The deploy-cap path (lines 247-298) DOES sort the deploys deterministically before selection, but only when
valid_unique.len() > max_user_deploys. Under the cap (the common case), the HashSet is returned as-is.PR #488 amplifies this: the recovery buffer adds entries to the merged HashSet, growing the set and making cap-hit more common.
Fix: apply the existing cap-path sort unconditionally, not just when over the cap:
Low / Verify
5. Convert
active_validators: Vec<Validator>to a HashSetfilter_slashable_invalid_messages(block_creator.rs:359-366) callsactive_validators.contains(validator)— O(N) slice scan in a filter loop:bonds_mapis already a HashMap right next to it. Two different data structures for the same "is this validator in this set?" check. Negligible at typical sizes but inconsistent.Fix: either build a
HashSet<Validator>at the call site, or changeOnChainCasperState::active_validators: Vec<Validator>(casper.rs:272) to a HashSet upstream.6. Recovered-slash log:
original_issueris misleading after dedupblock_creator.rs:637-641:After
b79bd5a3's dedup-by-invalid_block_hashcollapses multiple rejected slashes for the same equivocator to one survivor, the survivor'sissuer_public_keyis whichever happened to be first in sorted order — not necessarily informative.Fix: drop the field from the log, rename to
one_of_original_issuers=, or extendRejectedSlashwithVec<PublicKey>to log all of them.7. Verify two
compute_parents_post_statecalls hit the cacheblock_creator::createcallscompute_parents_post_stateonce at line 593 (to discover rejected slashes), thencompute_deploys_checkpointcalls it again internally at line 666. The PR claims caching prevents recomputation. The cache key (perslash_recovery_spec.rs:331-338):None of these mutate during a single
create()invocation, so the cache should hit. But it's load-bearing for perf and unverified.Fix: add a tracing-target counter that increments on cache miss vs hit during
compute_deploys_checkpointcalls insidecreate(). CI assertion that miss-count = 0 in steady state.8. Sort
rejected_slashesVec at construction sitecompute_parents_post_state(interpreter_util.rs:1230-1281) buildsrejected_slashes: Vec<RejectedSlash>by iterating aHashMap<BlockHash, Vec<Bytes>>:HashMap iteration is non-deterministic (per-process random hasher seed). The output Vec therefore has non-deterministic order. Currently saved by downstream
filter_recoverablewhich sorts oninvalid_block_hash— but the cache stores the unsorted Vec, so any future consumer of the cached value that doesn't re-sort inherits the non-determinism. Fragile coupling.Fix: sort
outbefore returning (or sort the unique source blocks before iteration).9. Resolver
bfs_finalized_windowis DFS, not BFS (in two places)Both
bfs_finalized_windowindeploy_finalization_status.rs:198-217anddescendants_within_scopeindag_merger.rs:39-68useVec::pop()which is LIFO/DFS:Function names and comments say BFS. Two validators with the same DAG state visit blocks in the same DFS order, so today's behavior is consistent across validators.
The risk: a future reader who notices the doc/code mismatch and "fixes" it by switching to
frontier.remove(0)orVecDeque::pop_frontwould silently change which block wins thelatest_block_hashtiebreak in the resolver, and the traversal order in the merge — both observable.Fix: rename to
dfs_finalized_window/descendants_within_scope_dfsand document the determinism guarantee, OR convert to true BFS viaVecDeque::pop_front. Pick one and lock it in.10. Drop
deploy_storagelock earlier inprepare_user_deploysblock_creator.rs:67-231:Issues:
deploy_storage_guardis held for the entire function (~50-200 ms).Under contention: validator thread blocks on buffer; any other deploy-storage-touching code blocks on
deploy_storage_guardfor the full ~200 ms.Fix: drop
deploy_storage_guardafter the read-and-classify phase, before deploy-cap selection. Combine buffer read+write into a single critical section, or document the "buffer can grow but not shrink for these sigs" invariant.11. Buffer has no admission size cap
KeyValueRejectedDeployBuffer::addhas no upper bound on entries. The expiry-purge fix (837fd392) closes the realistic unbounded-growth path (sigs that age out without finalization). With expiry purge in place, the remaining unbounded-growth scenario is pathological (many deploys rejected that never expire and never re-finalize).Fix: defensive cap with oldest-first eviction or simple max-entries reject. Belt-and-suspenders, not a known bug.
Open questions to investigate
12. Buffer recovery when LFB advances past the source block
The catchup gate (Section 5) resolves to
Finalized | Failed | Expiredto skip re-proposing. But it doesn't explicitly handle the case where the source block of a buffered deploy has been pruned from history (pre-LFB). If this fires, the resolver might returnpending_unknownand the deploy stays in the buffer indefinitely.Action: trace the
lookup_by_deploy_idpath for the pruned-source case, document the behavior, add a test.13. Buffer behavior on reorg
If sig X is rejected in branch B1 (orphaned) and finalized in branch B2 (canonical),
record_directly_finalizedpurges based on B2'sbody.deploysandbody.rejected_deploys. But if the rejection only happened on B1, the validator's local buffer may have admitted X on B1's merge and never see X cleaned up via B2's finalization.Action: trace this case, verify the catchup gate handles it on the next propose attempt.
14. LMM-advance non-determinism for same-seq equivocations (Section 3)
When two equivocation blocks
B1andB2(same sender, same seq num, different content) arrive in different orders on different nodes, the LMM advance rule (block.seq_num >= metadata.sequence_number) means whichever arrives second wins. Two nodes can have different invalid-latest entries for the same equivocator.Verified safe in the review: all downstream uses are either set-based (
justification_follows) or filter-out-invalid (fork choice, Estimator, slashing only checks "latest is invalid", not which specific block).Action: add a comment to Section 3's safety argument explicitly noting the same-seq case so future readers don't assume LMM-content equality across validators is required.
15.
compute_parents_post_statecache coherence vs. concurrent finalizationIf finalization advances LFB between block_creator's first
compute_parents_post_statecall and the second one insidecompute_deploys_checkpoint, the cache key includessnapshot_lfb_hashwhich is captured at snapshot time — so the lookup uses the snapshot's LFB, not a freshly-fetched one. Need to verify this end-to-end.Action: trace the cache lookup site to confirm
snapshot_lfb_hashis used (notdag.last_finalized_block()).Style / cleanup
16. Stale references in committed comments
Four PR-introduced comments anchor on transient patch context. Mechanical cleanup.
16a.
block-storage/src/rust/dag/block_dag_key_value_storage.rs:645-679— Scala source-of-truth + pre-fix narration in safety-argument comment:The substantive safety argument (fork choice / slashing / justification_follows downstream consumers) should stay. Drop the Scala anchors and the pre-fix narration.
16b.
casper/tests/batch2/validate_test.rs:974-981— "Pre-fix / Post-fix" doc block:Once merged, "pre-fix" describes a state that no longer exists, and "this test fails" becomes literally untrue. The four lines above this block already describe the test's intent. Delete the pre/post block.
16c.
casper/tests/api/deploy_finalization_status_test.rs:493-500— "With the buggy implementation / with the fix" framing:Rewrite to describe the rule (both
is_in_main_chain(A, S)ANDis_in_main_chain(S, C)must hold) rather than the patch history.16d. Scala-style camelCase identifiers in comments. The Rust code uses snake_case; readers grep for
deploy_lifespanand find nothing because the comment useddeployLifespan.casper/src/rust/api/deploy_finalization_status.rs:36deployLifespandeploy_lifespancasper/src/rust/api/deploy_finalization_status.rs:395deployLifespandeploy_lifespancasper/src/rust/storage/rnode_key_value_store_manager.rs:121deployLifespan,currentBlock,maxBlockNumcasper/src/rust/storage/rnode_key_value_store_manager.rs:122earliestBlockNumberearliest_block_numbercasper/tests/blocks/block_creator_spec.rs:184maxBlockNummax_block_numcasper/tests/blocks/block_creator_spec.rs:296maxBlockNummax_block_numcasper/tests/blocks/block_creator_spec.rs:341maxBlockNum,deployLifespan,earliestBlockNumberNote: pre-existing
// See casper/src/main/scala/coop/rchain/...file headers at the top of files likeblock_creator.rsare pre-existing and out of scope here — that's a wider codebase pattern. The four items above are PR-#488-introduced.17. Test setup boilerplate duplication across two new specs
dedup_orphan_recovery_spec.rs:62-117andmulti_validator_recovery_spec.rs:60-111share ~50 lines of setup boilerplate (genesis, kvm, runtime_manager, block_store, dag_storage, buffer init,init_logger,now_millisclosure,mk_snapshotclosure). The differences between the two are minor (number of validators captured, one extra explanatory comment).Fix: extract into
casper/tests/util/recovery_test_setup.rsreturning a builder:Each spec then opens with two lines instead of fifty.
18.
MutexExttrait orFrom<PoisonError>for lock-error idiom.map_err(|e| CasperError::LockError(e.to_string()))?repeats 4× in PR-added lines ofblock_creator.rs(more in surrounding existing code).Fix: add a tiny helper or
From<PoisonError<...>> for CasperErrorso?works directly:19.
as_ref().map(...).unwrap_or(true)→map_or(true, ...)4× in resolver body-scan loop, e.g.:
Option::map_orcovers exactly this case:File:
casper/src/rust/api/deploy_finalization_status.rs.20. Replace
active_sigsHashSet with directper_sig.get_mutdeploy_finalization_status.rs:206-208:per_sigis aHashMap<Bytes, ResolverState>—per_sig.contains_key(sig)is already O(1) without the duplicate set. The wholeactive_sigsallocation can go away — replaceif active_sigs.contains(&pd.deploy.sig)with a directif let Some(state) = per_sig.get_mut(&pd.deploy.sig) { ... }, which combines the membership check and the mutable lookup the next line is doing anyway.This also removes 3 of the 4
.expect("active_sigs and per_sig must agree on key set")calls.21. Convert
dag_merger.rscache.unwrap()to.expect("just-inserted")Three
cache.get(&addr).unwrap()calls indag_merger.rs:361, 398, 399onor_insert_withlookups. Safe today (entry exists by construction), but a bare.unwrap()panics with a generic "calledOption::unwrapon aNonevalue".Fix: convert to
.expect("just-inserted in get_chain_derived")to make the implicit invariant visible if a future refactor breaks it.22. Add
descendants_within_scopesize metricdag_merger.rs:39-68walks DAG descendants restricted to scope. Worth surfacing scope size viatracing::debug!(scope_size=...)to make catchup or attack scenarios visible.23. Buffer LMDB env shares 1 GB sizing with deploy_storage
rnode_key_value_store_manager.rs:121-127registers the new buffer LMDB env usingdeploy_storage_env_config()(1 GB reservation). Practical disk usage will be much smaller (the buffer holds at mostO(rejected_per_merge × deploy_lifespan)entries), but total LMDB reservation grows by 1 GB on disk on first launch.Action: worth a note in operator-facing release notes if disk planning is tight. No code fix needed.
24. PR description vs. reality drift
PR description still says "Two deferred code-level tests (
concurrent_registry_inserts_both_eventually_included,phase_c_dedup_orphans_loser_block_registry_writes) — rewritten but pending cleanup before commit." Neither test name appears in the codebase. Strategy actually changed: commit88595e74removed the existing ignoredconcurrent_registry_inserts_should_not_conflicttest, and 3 commits (cf5d013d,bedaa83f,88595e74) cover the review gaps under different names.Action: update PR description to remove the deferred-tests line and describe the actual coverage delta.
25.
thread_rng()non-determinism inRSpace::shuffle_with_indexrspace++/src/rspace/rspace.rs:1124-1133. Called fromfetch_channel_to_index_data:654,extract_produce_candidate:727,fetch_matching_data:743. The PR description flagged this as the suspected cause oftest_contract_lifecycle.pynon-determinism on exploratory queries against recovery-merged state. Local fix exists (removethread_rng()) but parity with Scala'sSpaceMatcher.fetchChannelToIndexedDatanot yet verified.Why it may be invisible in normal block flow: block replay rigging (Blake2b512Random +
produce_counter+ event-log replay) constrains matches by event log, masking shuffle non-determinism. Exploratory deploy is read-only and bypasses replay rigging.Action: verify Scala parity before deciding fix direction. Tracked separately from this issue if it's part of a wider rspace investigation; included here for completeness.
26. Commit-message PR references
Two commits in this PR's history reference review item numbers in their commit messages (
282f31fa: "Address PR #488 review request to document why...";55cbbb06: "Address remaining concern from PR #488 review #5..."). Code comments were cleaned up via35b918e4and19b0297f; commit-message references don't survive squash-merge so they're harmless if the PR is squashed. Worth a one-line note if the PR is not squashed.Minor recommendations from per-section review
27. Comment clarity on
dag_merger.rsscope: Nonevsscope: Some(...)dag_merger.rslines 92-107 and 112-123 distinguish "legacy no-scope path (non-deterministic)" from "production multi-parent merge path (deterministic, scope-bounded)". The comment "non_finalized_blocks (non-deterministic, but no scope means this is not a multi-parent merge validation)" is alarming on first read — should clarify who actually calls without scope (proposer-only? legacy compat?).28.
disable_late_block_filteringflag interaction indag_merger.rsWhen
scope.is_some(), late blocks are forced empty regardless of the flag. The flag's role only applies to the legacyscope: Nonepath. Add a code comment noting this asymmetry.29. Variable name
first_seen_block_hashin resolver is misleadinglookup_by_deploy_idreturns the last-inserted block from the deploy_index, not the first-seen. Doesn't affect correctness becausevalid_after_block_numberis identical across blocks containing the same sig (it's part of the signed payload). Rename tolast_indexed_block_hashor similar.30.
latest_block_hashresolver doc says "canonical block" but BFS walks all parentsThe doc comment claims "highest-block-number canonical block" but
bfs_finalized_windowenqueues every parent inparents_hash_list, so the returned hash can be on a non-main-parent ancestor. Either tighten the BFS to main-parent-only or update the doc to say "in the finalized-ancestor closure of LFB."31. Inconsistent definitions of "canonical" within the resolver
Canonical-descendant invalidation uses main-parent-chain checks (
is_in_main_chain).latest_block_hashuses any-parent BFS. Two definitions, depending on what's being checked. Worth a comment explaining why each definition is the right one for its context.32. Failed sigs are exempted from
repeat_deploy(design choice — confirm)The state machine in
repeat_deploytreats Failed the same as Pending/Expired for exemption. Failed sigs are unlikely to succeed on re-execution (failures are usually deterministic — out of phlo, contract error). Allowing re-proposal is wasted work but not unsafe. Confirm this is the intended behavior; could be tightened to "exempt only Pending/Expired" if Failed re-execution is undesired.33. Block-creator vs. validator asymmetry on Finalized gate (defense-in-depth — comment)
Block creator's
collect_self_chain_deploy_sigsfilter exemptsrejected_in_scopeunconditionally; validator'srepeat_deployexempts only non-Finalized. The asymmetry is defense-in-depth (proposer permissive, validator strict), but a buggy buffer admission could produce a block the validator will reject. Worth a code comment at the block creator filter explaining the deliberate asymmetry.34.
runtime_manager_test.rssizeFile grew to ~2700+ lines and now contains a mix of original test scaffolding, general-purpose regression tests, and PR-#488-specific bug-demo tests. The author moved recovery-cycle / dedup-orphan / multi-validator / slash tests into
casper/tests/batch2/*_spec.rs(good pattern) but leftstale_diff_application_corrupts_merged_stateandbridge_query_survives_multi_parent_mergeinruntime_manager_test.rs.Action: extract these into
casper/tests/batch2/merge_corruption_spec.rsor similar.35.
compute_state_then_compute_bonds_should_be_replayable_after_allis#[ignore]Pre-existing flakiness (added 2025-05-20, before PR #488). But this PR's changes affect deploy execution and replay, so flakiness may be related — author should run the ignored test with
--include-ignoredto see if PR #488 fixes or worsens it.36. Cache invalidation question for slash recovery
block_creator::createcallscompute_parents_post_stateonce before system-deploy construction, thencompute_deploys_checkpointlater. If the cache is invalidated between those calls (DAG generation advances during block creation), the merge runs twice and the rejected_slashes from the first call may not match what the second call sees. Cache key includes(sorted_parent_hashes, snapshot_lfb_hash, disable_late_block_filtering)— none of which include DAG generation, so concurrent finalization shouldn't invalidate. Worth confirming end-to-end.Summary
Code-level items: 36 total.
None merge-blocking on their own. Items 1 and 2 are the highest-value follow-ups (real maintenance / perf cost). Items 3, 4 close a coherent determinism story. Items 12-15 are open questions that warrant a trace-level investigation before they bite.
Test coverage gaps are tracked in a follow-up comment.
Co-Authored-By: Claude noreply@anthropic.com