fix(storage): prune the beacon LiveChain below the finalized block - #627
MegaRedHand wants to merge 8 commits into
Conversation
Table::LiveChain, the slot-ordered block index fork choice reads through Store::block_index(), was never pruned on a beacon directory: update_checkpoints gated the whole prune step to Chain::Lean because pruning to the stored finalized checkpoint's slot is unsafe there. A beacon checkpoint's slot is its epoch's start slot, not a real block's own slot, and when that boundary slot was skipped the finalized block itself sits below it. Pruning to it would delete the finalized block's own row, and every fork-choice walk that asks get_checkpoint_block for the ancestor at that slot (filter_block_tree, on_block, the gossip finalized-ancestry check) would fall through the gap into a hard SpecAssert instead of finding it. Left unfixed, the table also just grows forever on a beacon follower, and block_index() is an uncached full scan run on every get_head and import, so a long-running node gets slower over time. Prune to the finalized block's own slot instead, read from BlockHeaders (never pruned) rather than LiveChain (the table being pruned): prune_live_chain(h) keeps every row with slot >= h, so the finalized block's row always survives, and any walk from one of its descendants down to a slot at or above it stops at or above the finalized block rather than falling through. Hooked into Store::update_checkpoints itself, alongside the existing lean branch: it is already the one call site both chains advance finality through, already compares old and new finalized slots to fire only on an actual advance, and its own doc comment already promised this. Skip pruning (and warn) if the finalized root has no block entry, rather than guessing a horizon. Audited every other beacon reader of LiveChain/block_index (filter_block_tree, get_head, compute_weights, invalidate_subtree, the attestation target checks, the gossip finalized-ancestry walk, the pending-block cascade, req/resp range and by-root serving, the Beacon API): none needs a row below the finalized block, and several read a different, unpruned table (BlockHeaders/BlockRoots) for exactly that reason. Also updates three stale doc comments naming a Store::promote_beacon_anchor that does not exist on this branch, and the lean-only framing of the pruning docs.
…block
Since LiveChain is pruned below the finalized block's own slot on beacon,
the gossip finalized-ancestry walk has a second way to fail. A block on a
fork that split off below the finalized block walks into a pruned row, and
finalized_ancestry read every failed walk as Unknown, so a block the spec
REJECTs ("the current finalized checkpoint is an ancestor of the block") was
only IGNOREd. Before the pruning the same walk found the fork's block and
answered Conflicts.
A failed walk is now classified by the slot of the block it could not look
up, read from BlockHeaders (never pruned) and measured against the finalized
block's own slot. Every descendant of the finalized block has a strictly
higher slot, so a different block at or below that slot cannot be on the
finalized chain, whatever removed its row: Conflicts. Lighthouse's
is_finalized_checkpoint_or_descendant treats a walk off its pruned tree the
same way.
Above that slot pruning never reaches, so a missing row there is still the
late invalidate_subtree case, which stays Unknown (IGNORE) so peers that
forwarded a child before the invalidation are not penalized. So is a walk
whose missing block, or the finalized root, has no BlockHeaders row.
get_ancestor keeps its spec signature and now wraps get_ancestor_or_missing,
which returns the root the walk could not find.
Two comments in the beacon fork choice still justified building block_index once per call by saying beacon LiveChain is never pruned. Since the beacon arm of Store::update_checkpoints prunes it below the finalized block's own slot on every finalization advance, the full scan covers only the unfinalized window. The reasoning still holds, since that window grows with every block while finality stalls, so only the description of the table changes.
… preset
The test hardcoded mainnet's epoch length (blocks at 31 and 33, epoch 1
starting at 32). Under preset-minimal an epoch is 8 slots, so the finalized
block sat epochs past its own checkpoint and the walk to epoch 1's start
slot ran into the pruned anchor, failing both minimal-preset CI jobs with
SpecAssert("root in store.blocks"). Deriving the slots from
compute_start_slot_at_epoch(1) keeps the same scenario on both presets.
…ix/beacon-prune-live-chain
🤖 Claude Code ReviewReview of PR 627: prune
|
🤖 Codex Code ReviewLooks good overall. The PR fixes a real beacon fork-choice/storage bug around pruning
One small concern to consider:
No obvious security or memory-safety issues were introduced, and performance is fine: the change replaces a potentially wrong prune horizon with one Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Kimi Code ReviewI'll review this PR focusing on the beacon chain fork choice pruning logic and its interaction with empty epoch boundaries. This is a consensus-critical change. Overall AssessmentThis PR fixes a consensus-critical bug where beacon chain finalization pruning could delete the finalized block's own Detailed Findings1. Critical Bug Fix: Beacon Pruning Horizon (Correct)
The core fix is correct. The beacon chain stores checkpoint slots as epoch start slots ( // Line ~1830: Correctly uses block_slot from BlockHeaders, not finalized.slot
match self.block_entry(&finalized.root) {
Some((block_slot, _)) => {
let pruned_chain = self.prune_live_chain(block_slot).expect("prune live chain");Verification: 2.
|
| Aspect | Assessment |
|---|---|
| Correctness | Correct — fixes the pruning horizon bug properly |
| Security | No vulnerabilities introduced — conservative handling of missing data |
| Performance | Neutral/Minor improvement — pruning reduces LiveChain size |
| Rust idioms | Acceptable — Result<Root, Root> is slightly unidiomatic but documented |
| Test coverage | Good — covers main scenarios; minor gap at exact boundary equality |
| Documentation | Excellent — extensive comments explain the "why" |
Recommended Changes
-
Line
crates/blockchain/state_transition/src/beacon/gossip/mod.rs:433: Add test formissing_slot == finalized_slot && missing != finalized_rootboundary case. -
Line
crates/storage/src/store.rs:1838: Consider whether thewarn!on missing finalized block entry needs rate limiting or if invariants prevent repeated hits. -
Optional: Consider
block_slotlightweight accessor ifblock_entrydeserialization proves costly inmissing_row_ancestry.
The PR is ready to merge with or without these minor adjustments.
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
Why
On a beacon directory,
Table::LiveChainwas never pruned.Store::update_checkpointsonly ran the prune step whenself.chain == Chain::Lean, because pruning to a beacon checkpoint's stored slot is unsafe. That slot is the finalized epoch's start slot. When the start slot is empty, the finalized block sits strictly below it, andget_checkpoint_block→get_ancestorwalks down to that block. If its row has been pruned, the result is a hardSpecAssert("root in store.blocks").Two problems follow from leaving the table unpruned:
block_index()is an uncached full scan of the table, run on everyget_headand every import, so a long-running follower gets slower over time.What
update_checkpointsnow matches on the chain:BlockHeaders(a table that is never pruned).prune_live_chain(h)keeps every row withslot >= h, so the finalized block always survives, and every ancestor walk from one of its descendants stops at or above it.warn!and skips pruning instead of guessing a horizon.Store::promote_beacon_anchor, a function that no longer exists on this branch; they now point at the new beacon arm. The "lean-only" wording in theLiveChaincomments and indocs/data_storage.mdis updated too.Gossip verdict for forks from before finality (
fc8e78fa)Pruning adds a second reason the finalized-ancestry walk in
beacon/gossip/mod.rscan fail. Before, a walk failed only wheninvalidate_subtreehad deleted a row above finality, which must be IGNORE. Now a walk also fails on a fork that split off below the finalized block, because the block where the fork splits off has been pruned. The spec'sbeacon_blockrule and Prysm REJECT that case.finalized_ancestrynow tells the two apart by where the walk stopped:Conflicts, which isReject(FinalizedNotAncestor). Every descendant of the finalized block has a higher slot, so such a block can't be on the finalized chain. Lighthouse'sis_finalized_checkpoint_or_descendanttreats a walk that runs out of parents in its pruned tree the same way.BlockHeadersrow:Unknown, which isQueue(ParentNotReady), as before. This keeps the late-invalidation case at IGNORE.get_ancestorkeeps its signature. It now wraps a newget_ancestor_or_missing, which on failure returns the root it couldn't look up.717af0fefixes two more comments infork_choice.rsthat still said the beaconLiveChainis never pruned.Readers audited
filter_block_tree,get_headon_blockcheckpoint-descendant checkcompute_weightsinvalidate_subtreeBlockHeaders/BlockRootsTests
beacon_finalization_prunes_live_chain_to_the_finalized_blocks_own_slot(storage): epoch 1's start slot (32) is empty and the finalized block is at 31. After pruning, row 31 survives, row 0 is gone and row 33 remains.beacon_finalization_skips_pruning_when_the_finalized_root_has_no_block_entry(storage): the defensive no-op path.get_checkpoint_block_succeeds_after_pruning_past_an_empty_epoch_boundary(fork choice): the exactSpecAssertcase the old comment warned about, run through a realupdate_checkpointscall.Gossip (
fc8e78fa): a block on a pruned pre-finality fork is rejected; a parent above the cutoff goes fromAccepttoQueue(ParentNotReady)after a realinvalidate_subtree; three unit tests of the classification itself; one test forget_ancestor_or_missing. The two REJECT tests fail without the fix.Ran
cargo test --profile release-fastonethlambda-storage --lib,ethlambda-state-transition --lib beacon::fork_choiceandethlambda-blockchain --lib, plus clippy with-D warningson all three crates. Everything passes. For the follow-up commits:beacon::gossip(40 passed),beacon::fork_choice(29 passed), and clippy onethlambda-state-transition.Deploying
No
DB_VERSIONchange. On an existing follower, the first finalization advance after the upgrade deletes every accumulated row below the finalized block in one batch.Moved
beacon-chain-integrationlives on this repo.beacon-chain-integration@c79fabd5, merged into the branch.