Summary
The KeyValueRejectedDeployBuffer is only evicted on finalization (in multi_parent_casper_impl.rs::finalizer, ~L1599 — buffer_guard.remove_by_sig(sig)). Between when a recovered deploy is re-proposed and when its containing block finalizes, the deploy's sig is re-included in every subsequent block. Wasteful but sound; surfaces as block-space bloat and redundant deploy execution during the recovery window.
Surfaced while writing the recovery-cycle E2E regression test in PR #488 (Gap 1 from the meta review's test-coverage requests).
Reproduction
In the new recovery_cycle_rejected_deploy_is_buffered_and_re_proposed test (PR #488 follow-up commit), after the recovery block is created, the buffer still contains the recovered sig. Each subsequent propose pulls the same sig and includes it in the new block. The pattern continues until LFB advances past either:
- the original rejection block (so the sig leaves `rejected_in_scope`), or
- the recovery block (so the finalizer evicts the sig from the buffer).
Why it's not unsafe
PR #488's validator-side defense (`fix(validate): gate repeat_deploy recovery exemption on Finalized status` — commit `55cbbb06`) catches re-inclusion attempts once the resolver reports `Finalized`. The merge engine doesn't conflict on multi-block clean inclusion of the same sig. So no double-execution, no fork-divergence — just block-space bloat during the recovery-to-finalization window.
Why it's worth fixing
For workloads that produce frequent rejections (bridge-style contracts, registry-contention patterns), every recovery wastes `window_size` blocks of body-space on redundant entries. Multiplied across recoveries, this is meaningful overhead. Also confusing for clients querying deploy state: a single sig appearing in many blocks' `body.deploys` is hard to interpret.
Design options
Three plausible approaches:
Option 1 — Status quo
Buffer holds until finalization. Wasteful but sound. (Current behavior.)
Option 2 — Evict on pull
`block_creator` removes from buffer at `prepare_user_deploys` time. Risk: if the recovery block gets orphaned (fork-race loser, validation failure, etc.), the sig is lost permanently. Would need orphan-detection logic to re-populate the buffer; not trivial since orphan handling lives in separate code paths.
Option 3 — Skip pull when clean inclusion exists in current view
In `prepare_user_deploys`, when a sig is in both `deploys_in_scope` and `rejected_in_scope`, check whether a clean canonical inclusion exists in the proposer's parents' main-chain ancestry. If yes, recovery already happened — skip. If no (the only clean inclusion is on a sibling fork), re-propose.
Implementation: needs a "is sig in clean canonical inclusion of an ancestor of X?" check, where X is the proposer's parents' main-chain. Could repurpose `deploy_finalization_status::resolve` (currently anchored on LFB) by generalizing it to take an arbitrary anchor block.
My lean: Option 3. Sound and orphan-resilient. The infrastructure (`resolve_batch` plumbing) exists from PR #488; just needs a parameterized anchor.
Tests
The `recovery_cycle_rejected_deploy_is_buffered_and_re_proposed` test (PR #488 follow-up) reproduces the symptom. A future test for the fix would assert:
- After recovery block is in scope, next propose does NOT re-include the recovered sig
- If recovery block is orphaned (constructed via DAG manipulation), next propose DOES re-include the sig (so recovery isn't lost)
Prerequisites
PR #488 (`fix/merge-stale-diff-rejection-expansion`) — introduces the recovery mechanism this issue concerns and the `deploy_finalization_status` infrastructure that Option 3's fix depends on.
Co-Authored-By: Claude noreply@anthropic.com
Summary
The
KeyValueRejectedDeployBufferis only evicted on finalization (inmulti_parent_casper_impl.rs::finalizer, ~L1599 —buffer_guard.remove_by_sig(sig)). Between when a recovered deploy is re-proposed and when its containing block finalizes, the deploy's sig is re-included in every subsequent block. Wasteful but sound; surfaces as block-space bloat and redundant deploy execution during the recovery window.Surfaced while writing the recovery-cycle E2E regression test in PR #488 (Gap 1 from the meta review's test-coverage requests).
Reproduction
In the new
recovery_cycle_rejected_deploy_is_buffered_and_re_proposedtest (PR #488 follow-up commit), after the recovery block is created, the buffer still contains the recovered sig. Each subsequent propose pulls the same sig and includes it in the new block. The pattern continues until LFB advances past either:Why it's not unsafe
PR #488's validator-side defense (`fix(validate): gate repeat_deploy recovery exemption on Finalized status` — commit `55cbbb06`) catches re-inclusion attempts once the resolver reports `Finalized`. The merge engine doesn't conflict on multi-block clean inclusion of the same sig. So no double-execution, no fork-divergence — just block-space bloat during the recovery-to-finalization window.
Why it's worth fixing
For workloads that produce frequent rejections (bridge-style contracts, registry-contention patterns), every recovery wastes `window_size` blocks of body-space on redundant entries. Multiplied across recoveries, this is meaningful overhead. Also confusing for clients querying deploy state: a single sig appearing in many blocks' `body.deploys` is hard to interpret.
Design options
Three plausible approaches:
Option 1 — Status quo
Buffer holds until finalization. Wasteful but sound. (Current behavior.)
Option 2 — Evict on pull
`block_creator` removes from buffer at `prepare_user_deploys` time. Risk: if the recovery block gets orphaned (fork-race loser, validation failure, etc.), the sig is lost permanently. Would need orphan-detection logic to re-populate the buffer; not trivial since orphan handling lives in separate code paths.
Option 3 — Skip pull when clean inclusion exists in current view
In `prepare_user_deploys`, when a sig is in both `deploys_in_scope` and `rejected_in_scope`, check whether a clean canonical inclusion exists in the proposer's parents' main-chain ancestry. If yes, recovery already happened — skip. If no (the only clean inclusion is on a sibling fork), re-propose.
Implementation: needs a "is sig in clean canonical inclusion of an ancestor of X?" check, where X is the proposer's parents' main-chain. Could repurpose `deploy_finalization_status::resolve` (currently anchored on LFB) by generalizing it to take an arbitrary anchor block.
My lean: Option 3. Sound and orphan-resilient. The infrastructure (`resolve_batch` plumbing) exists from PR #488; just needs a parameterized anchor.
Tests
The `recovery_cycle_rejected_deploy_is_buffered_and_re_proposed` test (PR #488 follow-up) reproduces the symptom. A future test for the fix would assert:
Prerequisites
PR #488 (`fix/merge-stale-diff-rejection-expansion`) — introduces the recovery mechanism this issue concerns and the `deploy_finalization_status` infrastructure that Option 3's fix depends on.
Co-Authored-By: Claude noreply@anthropic.com