Skip to content

feat: prune old blocks and states on finalization#170

Merged
pablodeymo merged 3 commits intomainfrom
prune-states
Mar 3, 2026
Merged

feat: prune old blocks and states on finalization#170
pablodeymo merged 3 commits intomainfrom
prune-states

Conversation

@MegaRedHand
Copy link
Collaborator

@MegaRedHand MegaRedHand commented Mar 2, 2026

Summary

  • Add retention-based pruning for BlockHeaders, BlockBodies, BlockSignatures, and States tables
  • Keep the most recent ~1 day of blocks (BLOCKS_TO_KEEP = 21,600) and ~1 hour of states (STATES_TO_KEEP = 900)
  • Always preserve finalized and justified blocks/states as a safety guarantee

Motivation

Previously only the LiveChain index and signature/attestation tables were pruned on finalization. The four main data tables grew indefinitely, causing unbounded disk growth.

Design

Two new methods on Store:

  • prune_old_states(protected_roots): scans BlockHeaders for slot info, keeps newest STATES_TO_KEEP + protected roots, deletes from States
  • prune_old_blocks(protected_roots): same pattern with BLOCKS_TO_KEEP, deletes from BlockHeaders/BlockBodies/BlockSignatures

Called in update_checkpoints() after existing pruning, states before blocks (state pruning uses headers for slot lookup).

Protected roots (finalized + justified) are always retained regardless of the retention window, preventing panics in code paths that expect these blocks/states to exist.

@MegaRedHand MegaRedHand changed the title Prune old blocks and states on finalization feat: prune old blocks and states on finalization Mar 2, 2026
@github-actions
Copy link

github-actions bot commented Mar 2, 2026

🤖 Kimi Code Review

Review Summary

The PR introduces state and block pruning functionality to manage storage growth. While the implementation is generally correct, there are several critical issues that need addressing:

Critical Issues

  1. Incorrect key usage in state pruning (lines 604-620, 641-647)

    • The code uses block header keys to identify states for deletion, but states are stored under their state root, not block root. This will incorrectly delete states and potentially leave orphaned state data.
    • Fix: States should be keyed by their state root (state_root field from the header), not the block root.
  2. Race condition in pruning (lines 392-425)

    • The comment states "Prune old states before blocks" but the actual execution order is:
      1. prune_old_states()
      2. prune_old_blocks()
    • However, both functions independently read the current block headers, so there's no actual dependency. The comment is misleading.
  3. Memory usage concerns (lines 604-620, 648-664)

    • Both pruning functions load all block headers into memory before processing. With 21,600+ blocks, this could consume significant memory.
    • Suggestion: Use a streaming approach or limit the query to only necessary data.

Security & Correctness Issues

  1. Unbounded memory growth in tests (test functions)

    • Tests create large numbers of entries (21,600+ blocks) which could cause OOM in CI environments.
    • Fix: Use smaller test constants or mock the retention limits.
  2. Missing error handling (multiple locations)

    • Functions use .expect() for database operations which will panic on errors. In a consensus client, this could lead to node crashes.
    • Critical locations: lines 610, 615, 642, 647, 668, 673, 678, 683, 688, 693

Performance Issues

  1. Inefficient sorting (lines 627, 670)

    • Sorting all entries by slot is O(n log n) when we only need the top N entries.
    • Suggestion: Use a min-heap of size STATES_TO_KEEP/BLOCKS_TO_KEEP to find the cutoff more efficiently.
  2. Redundant data collection (lines 604-620, 648-664)

    • Both functions duplicate the same logic for collecting block headers. Consider extracting a helper.

Minor Issues

  1. Magic numbers in tests (test functions)

    • Tests use hardcoded values like + 10 and + 5 which aren't related to the retention constants.
    • Fix: Use calculated values based on the constants.
  2. Test naming inconsistency (test functions)

    • Some tests use "exceeding_retention" while others use "within_retention" - consider consistent naming.
  3. Missing edge case tests

    • No tests for empty database
    • No tests for when all entries are protected
    • No tests for when protected roots don't exist

Recommendations

  1. Immediate fix required: Correct the state pruning key usage
  2. High priority: Add proper error handling instead of .expect()
  3. Medium priority: Optimize memory usage for large datasets
  4. Low priority: Extract common code and improve test coverage

Automated review by Kimi (Moonshot AI) · custom prompt

@github-actions
Copy link

github-actions bot commented Mar 2, 2026

🤖 Codex Code Review

  1. High: state-pruning correctness can break fork-choice/block processing under fork or pending-block pressure
    At store.rs#L596, prune_old_states derives retention candidates from BlockHeaders, not States.
    This means retention is effectively “900 block roots”, not “~900 slots of states” as documented, and includes pending/fork headers that may not have states. In adversarial/fork-heavy conditions, valid canonical states can be pruned too aggressively (except finalized/justified), causing get_state(parent_root) / attestation target lookups to fail later in consensus flow.
    Suggested change: drive pruning from Table::States (or a dedicated state-slot index) and retain by slot/window semantics, not raw header count.

  2. Medium: panic-on-decode in pruning path is a node-crash risk
    At store.rs#L602 and store.rs#L652, malformed header bytes trigger expect("valid header").
    A single corrupted DB record can panic during finalization-time pruning, taking the client down.
    Suggested change: replace expect with fallible handling (if let Ok(...)) + warn! (and optionally quarantine/delete bad entries).

  3. Medium: pruning cost is high and repeated on every finalization advance
    At store.rs#L597 and store.rs#L647, both state and block pruning do full-table scans + deserialize + sort (O(n log n)) and allocate large vectors.
    For large histories, this can create latency spikes and memory pressure on checkpoint updates.
    Suggested change: avoid full sorts/scans each time (maintain slot-indexed keys, prune by cutoff slot, or use selection/streaming strategies and batch limits).

I could not run tests in this environment because cargo needs network access to fetch the toolchain/index.


Automated review by OpenAI Codex · custom prompt

@github-actions
Copy link

github-actions bot commented Mar 2, 2026

🤖 Claude Code Review

Now I have a full picture of the PR. Let me write up the review.


PR #170 Review: Prune old blocks and states on finalization

Overall: The design is sound — states before blocks, protected roots, compile-time invariant assertion, correct ordering of operations. A few issues worth fixing before merge.


Medium: pruned_states count is inflated in steady state

File: crates/storage/src/store.rs, prune_old_states (lines 608, 626)

The early-return guard checks the number of block headers, not the number of states:

if entries.len() <= STATES_TO_KEEP {
    return 0;
}

Once the system reaches steady state, BlockHeaders stabilises at ~BLOCKS_TO_KEEP (21,600) entries after block pruning, while States has only ~STATES_TO_KEEP (900) entries from previous runs. On every subsequent finalization:

  • entries.len() = 21,600 → threshold passed
  • keys_to_delete = ~20,700 block root keys
  • Most of those roots no longer have entries in States (already pruned) → silent no-op deletes
  • count returned = 20,700, logged as pruned_states=20700

The logged count is inflated by up to ~24×. The fix is to query States directly for which roots actually exist, or to count against States table entries rather than BlockHeaders. At minimum the doc comment "Returns the number of states pruned" is incorrect since it counts candidates, not actual deletions.


Medium: Duplicate full scan of BlockHeaders per finalization

File: lines 597–605 and 647–655

Both prune_old_states and prune_old_blocks independently iterate and SSZ-deserialize the entire BlockHeaders table. At BLOCKS_TO_KEEP = 21,600 entries (~100 B each) this means ~2 MB read + 43,200 from_ssz_bytes calls on every finalization event, even when there is nothing to prune.

Extracting a shared helper collect_header_entries() -> Vec<(Vec<u8>, u64)> and calling it once, passing the result to both pruning functions, would halve the I/O cost and remove the duplicated deserialization logic.


Low: Non-atomic commit across the two new pruning calls

prune_old_states commits its write batch, then prune_old_blocks commits a separate batch. A crash in between leaves states pruned but headers intact (or vice versa on the next call). Both functions are idempotent so this self-heals, but it is worth a comment. The three-table deletion inside prune_old_blocks IS correctly atomic (single batch), which is good.


Low: Panic on corrupt header bytes

Both functions use:

let header = BlockHeader::from_ssz_bytes(&value).expect("valid header");

Consistent with existing prune_* methods in the file, but a corrupted header entry would crash the node process during pruning rather than logging and skipping. Consider .ok() / filter_map for the same resilience pattern used in prune_aggregated_payload_table.


Low: Invariant comment could be more explicit

The compile-time assert at line 87:

const _: () = assert!(BLOCKS_TO_KEEP >= STATES_TO_KEEP, "BLOCKS_TO_KEEP must be >= STATES_TO_KEEP");

The assert is correct and the PR body explains the reasoning, but the code comment only states the rule without the why. Adding a sentence noting that this ensures all block headers within the state retention window are still present when prune_old_states runs (before prune_old_blocks removes any of them) would help future maintainers understand the ordering dependency.


Confirmed Correct

  • Key design: States is keyed by block root (confirmed at line 786–789), so using block root keys from BlockHeaders to delete from States is correct.
  • Ordering: states pruned before blocks (prune_old_statesprune_old_blocks) so state pruning can use header slot data before any headers are removed. Correct.
  • Protected roots: finalized + latest justified are consistently protected in both functions. Reading self.latest_justified() after the checkpoint metadata write (line 396) correctly picks up any newly-justified checkpoint.
  • Genesis/anchor blocks: deleting from BlockBodies and BlockSignatures for blocks with no entries (genesis) is a safe no-op.
  • LiveChain consistency: prune_live_chain(finalized.slot) runs before the new pruning; blocks old enough to be deleted by prune_old_blocks (>1 day) are almost certainly already finalized and thus absent from LiveChain.
  • Tests: cover within-retention no-op, over-retention deletion, and protected-root preservation. The test helper insert_header correctly populates all three tables.

Automated review by Claude (Anthropic) · custom prompt

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 2, 2026

Greptile Summary

This PR introduces retention-based pruning for BlockHeaders, BlockBodies, BlockSignatures, and States tables, keeping ~1 day of block history and ~1 hour of state history while always preserving finalized and justified roots. The pruning is triggered inside update_checkpoints() whenever finalization advances, and the six new unit tests cover the within-retention, exceeding-retention, and protected-root scenarios correctly.

Issues found:

  • prune_old_states returns keys_to_delete.len() (the number of block-header roots eligible for deletion) rather than the number of states actually removed from the States table. delete_batch silently ignores missing keys, so in steady state the pruned_states log field will report ~BLOCKS_TO_KEEP − STATES_TO_KEEP = 20 700 on every finalization even though only one new state is being pruned per slot.
  • States whose roots have no corresponding entry in BlockHeaders (e.g., genesis/anchor state, or states written independently) are invisible to prune_old_states and will grow indefinitely. Additionally, calling prune_old_blocks before prune_old_states (both are pub) causes states for the newly-deleted headers to become permanently orphaned.
  • Both prune_old_states and prune_old_blocks independently perform a full scan, SSZ-parse, and sort of the entire BlockHeaders table on every finalization event, doubling the I/O for the most common code path.

Confidence Score: 3/5

  • The pruning logic is functionally correct for the common case, but the misleading count return value and the orphaned-state edge case should be addressed before merging.
  • Core retention and protected-root logic is sound, tests pass the described scenarios, and the ordering in update_checkpoints is correct. However, the inflated pruned_states count is a real observable bug in every production log after the first pruning round, and the pub API footgun (wrong call order → permanent orphaned states) is an architectural concern that lowers confidence.
  • crates/storage/src/store.rs — specifically prune_old_states (count accuracy and orphaned-state discovery) and the pub visibility of both pruning methods.

Important Files Changed

Filename Overview
crates/storage/src/store.rs Adds prune_old_states and prune_old_blocks with correct retention logic and protected-root guarantees, but the reported prune count is inaccurate in steady state (counts block-header candidates instead of actual deletions), orphaned states are never discovered, both methods redundantly scan BlockHeaders, and the ordering requirement between the two pub methods is only documented, not enforced.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Store
    participant Backend as StorageBackend

    Caller->>Store: update_checkpoints(checkpoints)
    Store->>Backend: begin_write() — commit new head/justified/finalized
    Backend-->>Store: ok

    alt finalized.slot > old_finalized_slot
        Store->>Store: prune_live_chain(finalized.slot)
        Store->>Store: prune_gossip_signatures(finalized.slot)
        Store->>Store: prune_attestation_data_by_root(finalized.slot)
        Store->>Store: prune_aggregated_payload_table(×2)

        Note over Store,Backend: States pruned BEFORE blocks (headers needed for slot lookup)

        Store->>Backend: begin_read() — scan all BlockHeaders
        Backend-->>Store: (root_bytes, slot) entries
        Store->>Store: sort by slot desc, skip STATES_TO_KEEP (900), filter protected
        Store->>Backend: begin_write() — delete_batch(States, keys_to_delete)
        Backend-->>Store: pruned_states (candidate count, NOT actual deletes)

        Store->>Backend: begin_read() — scan all BlockHeaders (again)
        Backend-->>Store: (root_bytes, slot) entries
        Store->>Store: sort by slot desc, skip BLOCKS_TO_KEEP (21600), filter protected
        Store->>Backend: begin_write() — delete_batch(BlockHeaders + BlockBodies + BlockSignatures)
        Backend-->>Store: pruned_blocks

        Store->>Caller: log "Pruned finalized data"
    end
Loading

Last reviewed commit: 9b373c5

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

1 file reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +619 to +634
let keys_to_delete: Vec<Vec<u8>> = entries
.into_iter()
.skip(STATES_TO_KEEP)
.filter(|(key, _)| !protected.contains(key))
.map(|(key, _)| key)
.collect();

let count = keys_to_delete.len();
if count > 0 {
let mut batch = self.backend.begin_write().expect("write batch");
batch
.delete_batch(Table::States, keys_to_delete)
.expect("delete old states");
batch.commit().expect("commit");
}
count
Copy link
Contributor

Choose a reason for hiding this comment

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

pruned_states count is severely inflated in steady state

keys_to_delete.len() counts the number of block header roots that are older than STATES_TO_KEEP, not the number of states that were actually removed from the States table. delete_batch silently skips missing keys, so there is no feedback on what was actually deleted.

In the steady state (running for > 1 day), prune_old_states will see ~BLOCKS_TO_KEEP = 21 600 block header entries on every call. It will compute ~BLOCKS_TO_KEEP − STATES_TO_KEEP = 20 700 "candidate" roots and return that number — even though almost none of those states exist any more (they were deleted in earlier pruning rounds). The pruned_states = 20700 log line will appear on every finalization update despite only one new state actually being pruned.

The same issue exists in prune_old_blocks at line 675, though there it is less misleading because block headers are only deleted once.

A straightforward fix is to count only keys that actually exist before deleting, or to track the real deletion count. For example:

// In prune_old_states, filter to keys that actually exist in States before returning count
let view = self.backend.begin_read().expect("read view");
let keys_to_delete: Vec<Vec<u8>> = keys_to_delete
    .into_iter()
    .filter(|k| view.get(Table::States, k).expect("get").is_some())
    .collect();
drop(view);

let count = keys_to_delete.len();
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/storage/src/store.rs
Line: 619-634

Comment:
**`pruned_states` count is severely inflated in steady state**

`keys_to_delete.len()` counts the number of *block header roots* that are older than `STATES_TO_KEEP`, not the number of states that were actually removed from the `States` table. `delete_batch` silently skips missing keys, so there is no feedback on what was actually deleted.

In the steady state (running for > 1 day), `prune_old_states` will see ~`BLOCKS_TO_KEEP` = 21 600 block header entries on every call. It will compute ~`BLOCKS_TO_KEEP − STATES_TO_KEEP` = 20 700 "candidate" roots and return that number — even though almost none of those states exist any more (they were deleted in earlier pruning rounds). The `pruned_states = 20700` log line will appear on **every** finalization update despite only one new state actually being pruned.

The same issue exists in `prune_old_blocks` at line 675, though there it is less misleading because block headers are only deleted once.

A straightforward fix is to count only keys that actually exist before deleting, or to track the real deletion count. For example:

```rust
// In prune_old_states, filter to keys that actually exist in States before returning count
let view = self.backend.begin_read().expect("read view");
let keys_to_delete: Vec<Vec<u8>> = keys_to_delete
    .into_iter()
    .filter(|k| view.get(Table::States, k).expect("get").is_some())
    .collect();
drop(view);

let count = keys_to_delete.len();
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +593 to +610
pub fn prune_old_states(&mut self, protected_roots: &[H256]) -> usize {
let view = self.backend.begin_read().expect("read view");

// Collect (root_bytes, slot) from BlockHeaders to determine state age.
let mut entries: Vec<(Vec<u8>, u64)> = view
.prefix_iterator(Table::BlockHeaders, &[])
.expect("iterator")
.filter_map(|res| res.ok())
.map(|(key, value)| {
let header = BlockHeader::from_ssz_bytes(&value).expect("valid header");
(key.to_vec(), header.slot)
})
.collect();
drop(view);

if entries.len() <= STATES_TO_KEEP {
return 0;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Orphaned states are never pruned

prune_old_states uses the BlockHeaders table as the source of truth for which state roots exist and how old they are. Any state stored in the States table whose root does not have a corresponding entry in BlockHeaders (e.g., a pre-anchor genesis state, or a state written independently for testing/recovery) will be invisible to this function and will never be pruned, regardless of how old it is.

Additionally, if prune_old_blocks is called before prune_old_states (both methods are pub), block headers for the oldest slots will already have been deleted. prune_old_states will then only see BLOCKS_TO_KEEP headers; the states corresponding to the headers that were just deleted will have no slot information and will be permanently orphaned.

The comment in update_checkpoints correctly documents the required ordering, but the pub visibility of both methods creates an API footgun. Consider making them pub(crate) or pub(super) if they are not intended to be called from outside the storage crate, or combining them into a single prune_old_data method that enforces the correct order internally.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/storage/src/store.rs
Line: 593-610

Comment:
**Orphaned states are never pruned**

`prune_old_states` uses the `BlockHeaders` table as the source of truth for which state roots exist and how old they are. Any state stored in the `States` table whose root does not have a corresponding entry in `BlockHeaders` (e.g., a pre-anchor genesis state, or a state written independently for testing/recovery) will be invisible to this function and will never be pruned, regardless of how old it is.

Additionally, if `prune_old_blocks` is called before `prune_old_states` (both methods are `pub`), block headers for the oldest slots will already have been deleted. `prune_old_states` will then only see `BLOCKS_TO_KEEP` headers; the states corresponding to the headers that were just deleted will have no slot information and will be permanently orphaned.

The comment in `update_checkpoints` correctly documents the required ordering, but the `pub` visibility of both methods creates an API footgun. Consider making them `pub(crate)` or `pub(super)` if they are not intended to be called from outside the storage crate, or combining them into a single `prune_old_data` method that enforces the correct order internally.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +644 to +660
pub fn prune_old_blocks(&mut self, protected_roots: &[H256]) -> usize {
let view = self.backend.begin_read().expect("read view");

let mut entries: Vec<(Vec<u8>, u64)> = view
.prefix_iterator(Table::BlockHeaders, &[])
.expect("iterator")
.filter_map(|res| res.ok())
.map(|(key, value)| {
let header = BlockHeader::from_ssz_bytes(&value).expect("valid header");
(key.to_vec(), header.slot)
})
.collect();
drop(view);

if entries.len() <= BLOCKS_TO_KEEP {
return 0;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Duplicate full scan of BlockHeaders

Both prune_old_states (lines 597-605) and prune_old_blocks (lines 647-655) independently iterate the complete BlockHeaders table, parse every SSZ-encoded header, and build the same (root_bytes, slot) vector. This means every finalization that triggers both functions does two full sequential scans and two full sort passes over the same data.

Since update_checkpoints always calls them back-to-back, the sorted entries could be computed once and passed as an argument, or the two functions could be merged into a single prune_old_data method that shares the scan result.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/storage/src/store.rs
Line: 644-660

Comment:
**Duplicate full scan of `BlockHeaders`**

Both `prune_old_states` (lines 597-605) and `prune_old_blocks` (lines 647-655) independently iterate the complete `BlockHeaders` table, parse every SSZ-encoded header, and build the same `(root_bytes, slot)` vector. This means every finalization that triggers both functions does **two** full sequential scans and two full sort passes over the same data.

Since `update_checkpoints` always calls them back-to-back, the sorted entries could be computed once and passed as an argument, or the two functions could be merged into a single `prune_old_data` method that shares the scan result.

How can I resolve this? If you propose a fix, please make it concise.

@pablodeymo pablodeymo merged commit 5ce2a7c into main Mar 3, 2026
2 checks passed
@pablodeymo pablodeymo deleted the prune-states branch March 3, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants