diff --git a/CLAUDE.md b/CLAUDE.md index 8807e15e..cbb590c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -362,7 +362,13 @@ cargo test -p ethlambda-blockchain --test forkchoice_spectests -- --test-threads - Blocks are split into three tables: `BlockHeaders`, `BlockBodies`, `BlockSignatures` - Genesis/anchor blocks have empty bodies (detected via `EMPTY_BODY_ROOT`) — no entry in `BlockBodies` - Genesis block has no signatures — no entry in `BlockSignatures` -- All other blocks must have entries in all three tables +- Non-genesis blocks have a `BlockSignatures` entry until finalized: once below the + finalized boundary, signatures are pruned (`prune_old_block_signatures`) while + headers and bodies are kept forever. `get_signed_block` returns `None` for a + pruned finalized block +- States are stored as parent-linked diffs (`StateDiffs`, never pruned) plus + full-state snapshots (`States`) at anchors/hot states; `get_state` + reconstructs from diffs when no snapshot exists - `LiveChain` table provides fast `(slot||root) → parent_root` index for fork choice - Storage uses trait-based API: `StorageBackend` → `StorageReadView` (reads) + `StorageWriteBatch` (atomic writes) diff --git a/Cargo.lock b/Cargo.lock index 764d89b0..d3c7ccdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2170,6 +2170,7 @@ dependencies = [ "leansig", "libssz", "libssz-derive", + "libssz-types", "rand 0.10.1", "rocksdb", "tempfile", diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 79faa1bd..feeb6e7d 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use ethlambda_state_transition::{is_proposer, slot_is_justifiable_after}; -use ethlambda_storage::{ForkCheckpoints, Store}; +use ethlambda_storage::{DiffBase, ForkCheckpoints, Store}; use ethlambda_types::{ ShortRoot, attestation::{ @@ -556,6 +556,10 @@ fn on_block_core( let block = signed_block.message.clone(); + // Capture the diff base before the parent is consumed into the post-state + // (avoids cloning the multi-MB historical_block_hashes list). + let diff_base = DiffBase::from_state(block.parent_root, &parent_state); + // Execute state transition function to compute post-block state let state_transition_start = std::time::Instant::now(); let mut post_state = parent_state; @@ -576,9 +580,9 @@ fn on_block_core( store.update_checkpoints(ForkCheckpoints::new(store.head(), Some(justified), None)); } - // Store signed block and state + // Store signed block and state (as a parent-linked diff + snapshot) store.insert_signed_block(block_root, signed_block.clone()); - store.insert_state(block_root, post_state); + store.insert_state_with_diff(block_root, diff_base, post_state); for att in block.body.attestations.iter() { // Count each participating validator as a valid attestation. diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index f5b2ca58..3e60a375 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -18,6 +18,7 @@ thiserror.workspace = true libssz.workspace = true libssz-derive.workspace = true +libssz-types.workspace = true [dev-dependencies] tempfile = "3" diff --git a/crates/storage/src/api/tables.rs b/crates/storage/src/api/tables.rs index 5884f1f9..98eddab7 100644 --- a/crates/storage/src/api/tables.rs +++ b/crates/storage/src/api/tables.rs @@ -11,7 +11,21 @@ pub enum Table { /// All other blocks must have an entry in this table. BlockSignatures, /// State storage: H256 -> State + /// + /// Holds full-state snapshots only: anchors, hot states (recent window, + /// finalized/justified/head). Non-snapshot states live in `StateDiffs` and + /// are reconstructed on demand. States, + /// State diffs: H256 -> StateDiff + /// + /// Parent-linked diff written for every non-genesis state. Never pruned, so + /// it preserves full state history. See `get_state` for reconstruction. + StateDiffs, + /// State snapshot anchors: H256 (root) -> u64 (slot) + /// + /// Roots whose snapshots are retained permanently to bound diff-walk depth + /// (one per 1024-slot window). Protected from snapshot eviction. + StateAnchors, /// Metadata: string keys -> various scalar values Metadata, /// Live chain index: (slot || root) -> parent_root @@ -23,11 +37,13 @@ pub enum Table { } /// All table variants. -pub const ALL_TABLES: [Table; 6] = [ +pub const ALL_TABLES: [Table; 8] = [ Table::BlockHeaders, Table::BlockBodies, Table::BlockSignatures, Table::States, + Table::StateDiffs, + Table::StateAnchors, Table::Metadata, Table::LiveChain, ]; @@ -40,6 +56,8 @@ impl Table { Table::BlockBodies => "block_bodies", Table::BlockSignatures => "block_signatures", Table::States => "states", + Table::StateDiffs => "state_diffs", + Table::StateAnchors => "state_anchors", Table::Metadata => "metadata", Table::LiveChain => "live_chain", } diff --git a/crates/storage/src/backend/rocksdb.rs b/crates/storage/src/backend/rocksdb.rs index e278c8fe..58f6119f 100644 --- a/crates/storage/src/backend/rocksdb.rs +++ b/crates/storage/src/backend/rocksdb.rs @@ -11,15 +11,11 @@ use std::path::Path; use std::sync::Arc; /// Returns the column family name for a table. +/// +/// Delegates to [`Table::name`] so the CF name and the metrics label share a +/// single source of truth (and a new table only needs one mapping). fn cf_name(table: Table) -> &'static str { - match table { - Table::BlockHeaders => "block_headers", - Table::BlockBodies => "block_bodies", - Table::BlockSignatures => "block_signatures", - Table::States => "states", - Table::Metadata => "metadata", - Table::LiveChain => "live_chain", - } + table.name() } /// RocksDB storage backend. diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index bb27e949..535226f5 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -1,6 +1,8 @@ mod api; pub mod backend; +mod state_diff; mod store; pub use api::{ALL_TABLES, StorageBackend, StorageReadView, StorageWriteBatch, Table}; +pub use state_diff::DiffBase; pub use store::{ForkCheckpoints, GetForkchoiceStoreError, MAX_RESUMABLE_DB_STATE_AGE, Store}; diff --git a/crates/storage/src/state_diff.rs b/crates/storage/src/state_diff.rs new file mode 100644 index 00000000..d3d67638 --- /dev/null +++ b/crates/storage/src/state_diff.rs @@ -0,0 +1,242 @@ +//! Parent-linked state diffs for diff-layer state storage. +//! +//! A [`StateDiff`] captures the change from a base state (the parent block's +//! post-state) to a target state, storing only what cannot be recovered from a +//! snapshot plus the parent relationship. +//! +//! Field handling: +//! - `config`, `validators`: never change; omitted (taken from the snapshot). +//! - `latest_block_header`: omitted; reconstructed from the `BlockHeaders` table. +//! - `historical_block_hashes`: pure-append in the STF, so only the appended +//! tail (`hbh_appended`) is stored. +//! - everything else: stored verbatim (the justification fields are bounded by +//! the non-finalized window, so they stay small under healthy finality). + +use ethlambda_types::{ + block::BlockHeader, + checkpoint::Checkpoint, + primitives::H256, + state::{ + HISTORICAL_ROOTS_LIMIT, JustificationRoots, JustificationValidators, JustifiedSlots, State, + }, +}; +use libssz_derive::{SszDecode, SszEncode}; +use libssz_types::SszList; + +/// Appended tail of `historical_block_hashes`, bounded by the same limit as the +/// full list. +pub type HistoricalBlockHashesTail = SszList; + +/// Describes the parent state a new state's diff is built against. +/// +/// Captured by the caller before the parent is consumed into the post-state, so +/// the store can build the diff and decide anchoring without re-reading it. +/// Construct via [`DiffBase::from_state`]; fields are crate-internal. +pub struct DiffBase { + /// Block root of the parent state (the diff's `base_root`). + pub(crate) root: H256, + /// Parent state's `historical_block_hashes` length. + pub(crate) hbh_len: usize, + /// Parent state's slot (used for the anchor-boundary check). + pub(crate) slot: u64, +} + +impl DiffBase { + /// Build the diff base from the parent state and its block root. + /// + /// `root` is the parent block root (the child's `parent_root`), passed in + /// since the caller already has it; `hbh_len` and `slot` are read from + /// `state`. Call this before the parent is consumed into the child. + pub fn from_state(root: H256, state: &State) -> Self { + Self { + root, + hbh_len: state.historical_block_hashes.len(), + slot: state.slot, + } + } +} + +/// The change from a base (parent) state to a target state. +/// +/// Reconstruct the target with [`StateDiff`] applied against the nearest +/// ancestor snapshot; see the storage layer's `get_state` for the walk. +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct StateDiff { + /// Block root of the base state this diff is relative to (`block.parent_root`). + pub base_root: H256, + /// Target state's slot. + pub slot: u64, + /// Target state's latest justified checkpoint. + pub latest_justified: Checkpoint, + /// Target state's latest finalized checkpoint. + pub latest_finalized: Checkpoint, + /// Target state's `justified_slots` (stored in full). + pub justified_slots: JustifiedSlots, + /// Target state's `justifications_roots` (stored in full). + pub justifications_roots: JustificationRoots, + /// Target state's `justifications_validators` (stored in full). + pub justifications_validators: JustificationValidators, + /// Elements appended to `historical_block_hashes` relative to the base. + pub hbh_appended: HistoricalBlockHashesTail, +} + +impl StateDiff { + /// Build a diff from a consumed target state against a base identified by its + /// `historical_block_hashes` length. + /// + /// Takes `target` by value so the multi-MB justification fields are moved + /// into the diff rather than cloned. On the block-import path the base state + /// has already been consumed into `target`, so only its length is retained; + /// `base_hbh_len` is that length (`target`'s historical list strictly extends + /// the base's, since the state transition only appends to it). + /// + /// # Panics + /// + /// Panics if `target.historical_block_hashes` is shorter than `base_hbh_len`. + pub fn from_base(base_root: H256, base_hbh_len: usize, target: State) -> Self { + let State { + slot, + latest_justified, + latest_finalized, + historical_block_hashes, + justified_slots, + justifications_roots, + justifications_validators, + .. + } = target; + + let hbh = historical_block_hashes.into_inner(); + assert!( + hbh.len() >= base_hbh_len, + "target historical_block_hashes shorter than base: {} < {base_hbh_len}", + hbh.len() + ); + let hbh_appended = HistoricalBlockHashesTail::try_from(hbh[base_hbh_len..].to_vec()) + .expect("appended tail cannot exceed HISTORICAL_ROOTS_LIMIT"); + + Self { + base_root, + slot, + latest_justified, + latest_finalized, + justified_slots, + justifications_roots, + justifications_validators, + hbh_appended, + } + } +} + +/// Rebuild a state from a base snapshot and the diffs leading to the target. +/// +/// `diffs` are ordered from the snapshot's child up to the target (inclusive, +/// non-empty). `latest_block_header` is the target's header (kept in the +/// `BlockHeaders` table rather than the diff). `config`/`validators` come from +/// `snapshot` (they never change), `historical_block_hashes` is replayed from +/// the appended tails, and the remaining fields come from the last diff. +/// +/// # Panics +/// +/// Panics if `diffs` is empty. +pub(crate) fn reconstruct( + snapshot: State, + diffs: &[StateDiff], + latest_block_header: BlockHeader, +) -> State { + let target = diffs + .last() + .expect("reconstruct requires at least one diff"); + + let mut hbh: Vec = snapshot.historical_block_hashes.to_vec(); + for diff in diffs { + hbh.extend_from_slice(&diff.hbh_appended); + } + let historical_block_hashes = hbh + .try_into() + .expect("reconstructed historical_block_hashes within limit"); + + State { + config: snapshot.config, + slot: target.slot, + latest_block_header, + latest_justified: target.latest_justified, + latest_finalized: target.latest_finalized, + historical_block_hashes, + justified_slots: target.justified_slots.clone(), + validators: snapshot.validators, + justifications_roots: target.justifications_roots.clone(), + justifications_validators: target.justifications_validators.clone(), + } +} + +#[cfg(test)] +mod tests { + use ethlambda_types::state::{State, Validator}; + use libssz::{SszDecode, SszEncode}; + + use super::*; + + fn h256(byte: u8) -> H256 { + H256::from([byte; 32]) + } + + /// A minimal genesis-like base state with two validators. + fn base_state() -> State { + let validators = vec![ + Validator { + attestation_pubkey: [1u8; 52], + proposal_pubkey: [2u8; 52], + index: 0, + }, + Validator { + attestation_pubkey: [3u8; 52], + proposal_pubkey: [4u8; 52], + index: 1, + }, + ]; + State::from_genesis(1_000, validators) + } + + #[test] + fn from_base_captures_appended_tail_and_absolute_fields() { + let base = base_state(); + let base_len = base.historical_block_hashes.len(); + + let mut target = base.clone(); + target.slot = 5; + let expected_justified = Checkpoint { + root: h256(7), + slot: 4, + }; + target.latest_justified = expected_justified; + // Append three roots (one real parent + two zero-filled empty slots). + let mut hbh: Vec = base.historical_block_hashes.to_vec(); + hbh.extend([h256(9), H256::ZERO, H256::ZERO]); + target.historical_block_hashes = hbh.try_into().unwrap(); + + let diff = StateDiff::from_base(h256(1), base_len, target); + + assert_eq!(diff.base_root, h256(1)); + assert_eq!(diff.slot, 5); + assert_eq!(diff.latest_justified, expected_justified); + assert_eq!(diff.hbh_appended.len(), 3); + assert_eq!(diff.hbh_appended[0], h256(9)); + assert_eq!(diff.hbh_appended[1], H256::ZERO); + } + + #[test] + fn ssz_roundtrips() { + let base = base_state(); + let base_len = base.historical_block_hashes.len(); + let mut target = base.clone(); + target.slot = 2; + let mut hbh: Vec = base.historical_block_hashes.to_vec(); + hbh.push(h256(9)); + target.historical_block_hashes = hbh.try_into().unwrap(); + + let diff = StateDiff::from_base(h256(1), base_len, target); + let bytes = diff.to_ssz(); + let decoded = StateDiff::from_ssz_bytes(&bytes).expect("decodes"); + assert_eq!(diff, decoded); + } +} diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index bd36bb59..a026a30a 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -14,6 +14,8 @@ use ethlambda_types::{ state::{ChainConfig, State, anchor_pair_is_consistent}, }; use libssz::{SszDecode, SszEncode}; + +use crate::state_diff::{DiffBase, StateDiff}; use thiserror::Error; use tracing::{info, warn}; @@ -83,20 +85,28 @@ const KEY_LATEST_JUSTIFIED: &[u8] = b"latest_justified"; /// Key for "latest_finalized" field of the Store. Its value has type [`Checkpoint`] and it's SSZ-encoded. const KEY_LATEST_FINALIZED: &[u8] = b"latest_finalized"; -/// ~1 day of block history at 4-second slots (86400 / 4 = 21600). -const BLOCKS_TO_KEEP: usize = 21_600; +/// Retain full-state snapshots for states within this many slots of the tip. +/// +/// Older snapshots are evicted from `States`; their `StateDiffs` entry remains +/// and the state is reconstructed on demand. ~20 minutes at 4-second slots. +const SNAPSHOT_HOT_WINDOW: u64 = 300; + +/// Persist a permanent snapshot whenever a block's slot crosses a multiple of +/// this value (relative to its parent's slot). +/// +/// These anchors are never evicted, bounding state-reconstruction diff walks to +/// at most this many steps. ~68 minutes at 4-second slots. +const SNAPSHOT_ANCHOR_INTERVAL: u64 = 1_024; -/// ~3.3 hours of state history at 4-second slots (12000 / 4 = 3000). -const STATES_TO_KEEP: usize = 3_000; +/// Keep block signatures for at least this many slots below the tip, even once +/// finalized. Signatures older than this window are pruned only when the window +/// lies entirely within finalized history; see [`Store::prune_old_block_signatures`]. +/// ~1 day at 4-second slots. +const SIGNATURE_PRUNING_RANGE: u64 = 21_600; /// ~30 minutes of resume window at 4-second slots (1800 / 4 = 450). pub const MAX_RESUMABLE_DB_STATE_AGE: u64 = 450; -const _: () = assert!( - BLOCKS_TO_KEEP >= STATES_TO_KEEP, - "BLOCKS_TO_KEEP must be >= STATES_TO_KEEP" -); - /// Hard cap for the known aggregated payload buffer (number of distinct attestation messages). /// With 1 attestation/slot, this holds ~500 messages (~33 min at 4s/slot). const AGGREGATED_PAYLOAD_CAP: usize = 512; @@ -639,11 +649,23 @@ impl Store { .expect("put block body"); } - // State + // State. The anchor has no parent in the store, so it cannot be a + // diff; register it as a permanent anchor instead. This keeps the + // invariant that a `States` snapshot is never written alone (it is + // always paired with a `StateDiffs` or `StateAnchors` entry) and + // pins the base of every diff chain so reconstruction never relies + // on a snapshot that can be evicted. let state_entries = vec![(anchor_block_root.to_ssz(), anchor_state.to_ssz())]; batch .put_batch(Table::States, state_entries) .expect("put state"); + let anchor_entries = vec![( + anchor_block_root.to_ssz(), + anchor_state.latest_block_header.slot.to_ssz(), + )]; + batch + .put_batch(Table::StateAnchors, anchor_entries) + .expect("put state anchor"); // Live chain index let index_entries = vec![( @@ -789,11 +811,15 @@ impl Store { } } - /// Prune old states and blocks to keep storage bounded. + /// Bound storage by evicting old state snapshots and finalized signatures. + /// + /// State diffs, block headers, and block bodies are retained for the full + /// history; only full-state snapshots outside the hot window (diffs remain) + /// and signatures of finalized blocks are removed. /// /// This is separated from `update_checkpoints` so callers can defer heavy /// pruning until after a batch of blocks has been fully processed. Running - /// this mid-cascade would delete states that pending children still need, + /// this mid-cascade would delete snapshots that pending children still need, /// causing infinite re-processing loops when fallback pruning is active. pub fn prune_old_data(&mut self) { let protected_roots = [ @@ -801,10 +827,17 @@ impl Store { self.latest_justified().root, self.head(), ]; - let pruned_states = self.prune_old_states(&protected_roots); - let pruned_blocks = self.prune_old_blocks(&protected_roots); - if pruned_states > 0 || pruned_blocks > 0 { - info!(pruned_states, pruned_blocks, "Pruned old states and blocks"); + let finalized_slot = self.latest_finalized().slot; + let tip_slot = self + .get_block_header(&self.head()) + .map_or(finalized_slot, |header| header.slot); + let evicted_snapshots = self.prune_old_states(&protected_roots); + let pruned_signatures = self.prune_old_block_signatures(finalized_slot, tip_slot); + if evicted_snapshots > 0 || pruned_signatures > 0 { + info!( + evicted_snapshots, + pruned_signatures, "Evicted old snapshots and finalized signatures" + ); } } @@ -908,41 +941,49 @@ impl Store { pruned_new + pruned_known } - /// Prune old states beyond the retention window. + /// Evict full-state snapshots outside the hot window. /// - /// Keeps the most recent `STATES_TO_KEEP` states (by slot), plus any - /// states whose roots appear in `protected_roots` (finalized, justified). + /// A snapshot is retained when it is within [`SNAPSHOT_HOT_WINDOW`] slots of + /// the newest snapshot, is a permanent anchor (in `StateAnchors`), or its + /// root is in `protected_roots` (finalized, justified, head). Evicted + /// snapshots are removed from `States` only; their `StateDiffs` entry remains + /// and the state is reconstructed on demand. /// - /// Returns the number of states pruned. + /// Returns the number of snapshots evicted. 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, u64)> = view - .prefix_iterator(Table::BlockHeaders, &[]) + // Collect (root_bytes, slot) for every snapshot currently in `States`. + // Slots come from BlockHeaders (snapshots always have a header). + let snapshots: Vec<(Vec, u64)> = view + .prefix_iterator(Table::States, &[]) .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) + .filter_map(|(key, _)| { + let header_bytes = view.get(Table::BlockHeaders, &key).expect("get")?; + let header = BlockHeader::from_ssz_bytes(&header_bytes).expect("valid header"); + Some((key.to_vec(), header.slot)) }) .collect(); + + // Anchors are protected from eviction. + let mut protected: HashSet> = protected_roots.iter().map(|r| r.to_ssz()).collect(); + protected.extend( + view.prefix_iterator(Table::StateAnchors, &[]) + .expect("iterator") + .filter_map(|res| res.ok()) + .map(|(key, _)| key.to_vec()), + ); drop(view); - if entries.len() <= STATES_TO_KEEP { + let Some(tip_slot) = snapshots.iter().map(|(_, slot)| *slot).max() else { return 0; - } - - // Sort by slot descending (newest first) - entries.sort_unstable_by(|a, b| b.1.cmp(&a.1)); - - let protected: HashSet> = protected_roots.iter().map(|r| r.to_ssz()).collect(); + }; + let cutoff = tip_slot.saturating_sub(SNAPSHOT_HOT_WINDOW); - // Skip the retention window, collect remaining keys for deletion - let keys_to_delete: Vec> = entries + let keys_to_delete: Vec> = snapshots .into_iter() - .skip(STATES_TO_KEEP) - .filter(|(key, _)| !protected.contains(key)) + .filter(|(key, slot)| *slot < cutoff && !protected.contains(key)) .map(|(key, _)| key) .collect(); @@ -951,61 +992,58 @@ impl Store { let mut batch = self.backend.begin_write().expect("write batch"); batch .delete_batch(Table::States, keys_to_delete) - .expect("delete old states"); + .expect("evict old snapshots"); batch.commit().expect("commit"); } count } - /// Prune old blocks beyond the retention window. + /// Prune signatures of old finalized blocks, keeping a recent window. + /// + /// Signatures within [`SIGNATURE_PRUNING_RANGE`] slots of `tip_slot` are + /// always kept, as are all signatures of non-finalized blocks. Concretely, + /// with `cutoff = tip_slot - SIGNATURE_PRUNING_RANGE`: + /// + /// - if `cutoff <= finalized_slot` (healthy finality): delete signatures for + /// `slot < cutoff` (entirely within finalized history); + /// - otherwise (the non-finalized range exceeds the window): prune nothing, + /// since pruning up to `cutoff` would touch non-finalized blocks. /// - /// Keeps the most recent `BLOCKS_TO_KEEP` blocks (by slot), plus any - /// blocks whose roots appear in `protected_roots` (finalized, justified). - /// Deletes from `BlockHeaders`, `BlockBodies`, and `BlockSignatures`. + /// Headers and bodies are always retained. Finalized blocks can never be + /// reverted, so their signatures are not needed for fork choice, re-org + /// safety, or re-aggregation once outside the window. /// - /// Returns the number of blocks pruned. - pub fn prune_old_blocks(&mut self, protected_roots: &[H256]) -> usize { + /// Returns the number of signatures pruned. + pub fn prune_old_block_signatures(&mut self, finalized_slot: u64, tip_slot: u64) -> usize { + let cutoff = tip_slot.saturating_sub(SIGNATURE_PRUNING_RANGE); + // Only prune when the whole window is finalized; never touch + // non-finalized signatures. + if cutoff > finalized_slot { + return 0; + } + let view = self.backend.begin_read().expect("read view"); - let mut entries: Vec<(Vec, u64)> = view - .prefix_iterator(Table::BlockHeaders, &[]) + // Iterate blocks that still have signatures; drop those below the cutoff + // (slot looked up from the retained header). + let keys_to_delete: Vec> = view + .prefix_iterator(Table::BlockSignatures, &[]) .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) + .filter_map(|(key, _)| { + let header_bytes = view.get(Table::BlockHeaders, &key).expect("get")?; + let header = BlockHeader::from_ssz_bytes(&header_bytes).expect("valid header"); + (header.slot < cutoff).then(|| key.to_vec()) }) .collect(); drop(view); - if entries.len() <= BLOCKS_TO_KEEP { - return 0; - } - - // Sort by slot descending (newest first) - entries.sort_unstable_by(|a, b| b.1.cmp(&a.1)); - - let protected: HashSet> = protected_roots.iter().map(|r| r.to_ssz()).collect(); - - let keys_to_delete: Vec> = entries - .into_iter() - .skip(BLOCKS_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::BlockHeaders, keys_to_delete.clone()) - .expect("delete old block headers"); - batch - .delete_batch(Table::BlockBodies, keys_to_delete.clone()) - .expect("delete old block bodies"); batch .delete_batch(Table::BlockSignatures, keys_to_delete) - .expect("delete old block signatures"); + .expect("delete finalized block signatures"); batch.commit().expect("commit"); } count @@ -1013,10 +1051,7 @@ impl Store { /// Get the block header by root. pub fn get_block_header(&self, root: &H256) -> Option { - let view = self.backend.begin_read().expect("read view"); - view.get(Table::BlockHeaders, &root.to_ssz()) - .expect("get") - .map(|bytes| BlockHeader::from_ssz_bytes(&bytes).expect("valid header")) + self.get_ssz(Table::BlockHeaders, root) } // ============ Signed Blocks ============ @@ -1085,12 +1120,13 @@ impl Store { /// or if the signature row is missing for any block other than the /// slot-0 anchor. /// - /// Signatures are absent for genesis-style anchor blocks (no proposer - /// ever signed them). To keep BlocksByRoot symmetric with the - /// fork-choice view for peers, synthesize an empty proof for the slot-0 - /// case only; for any other slot the missing-signature state is treated - /// as storage corruption and surfaces as `None` rather than as a - /// fabricated block. + /// Signatures are absent in two cases: genesis-style anchor blocks (no + /// proposer ever signed them), and finalized blocks whose signatures were + /// pruned by [`prune_old_block_signatures`](Self::prune_old_block_signatures). + /// To keep BlocksByRoot symmetric with the fork-choice view for peers, + /// synthesize an empty proof for the slot-0 anchor only; for any other slot + /// a missing signature surfaces as `None` (a pruned finalized block can no + /// longer be served with its proof) rather than as a fabricated block. pub fn get_signed_block(&self, root: &H256) -> Option { let view = self.backend.begin_read().expect("read view"); let key = root.to_ssz(); @@ -1110,9 +1146,9 @@ impl Store { Some(proof_bytes) => { MultiMessageAggregate::from_ssz_bytes(&proof_bytes).expect("valid block proof") } - // Synthesis only covers the genesis-style anchor (slot 0). Any other - // missing-proof case is a storage corruption that should surface - // as `None` rather than fabricating a block with an empty proof. + // Synthesis only covers the genesis-style anchor (slot 0). For any + // other slot a missing proof (pruned finalized block, or genuine + // corruption) surfaces as `None` rather than a fabricated block. None if header.slot == 0 => MultiMessageAggregate::default(), None => return None, }; @@ -1128,26 +1164,102 @@ impl Store { // ============ States ============ /// Returns the state for the given block root. + /// + /// Fast path: a full snapshot in `States`. Otherwise the state is + /// reconstructed by walking parent-linked `StateDiffs` back to the nearest + /// ancestor snapshot and replaying forward. Returns `None` if the diff chain + /// is broken or the target block header is unavailable. pub fn get_state(&self, root: &H256) -> Option { + // Fast path: a full snapshot in `States`. Otherwise reconstruct from diffs. + if let Some(state) = self.get_ssz::(Table::States, root) { + return Some(state); + } + self.reconstruct_state(root) + } + + /// Read and SSZ-decode a value keyed by block root from `table`. + fn get_ssz(&self, table: Table, root: &H256) -> Option { let view = self.backend.begin_read().expect("read view"); - view.get(Table::States, &root.to_ssz()) + view.get(table, &root.to_ssz()) .expect("get") - .map(|bytes| State::from_ssz_bytes(&bytes).expect("valid state")) + .map(|bytes| T::from_ssz_bytes(&bytes).expect("valid encoding")) + } + + /// Reconstruct a state from diffs and the nearest ancestor snapshot. + /// + /// Walks `base_root` pointers back until a snapshot is found, fetches the + /// target's block header, and delegates the assembly to + /// [`state_diff::reconstruct`](crate::state_diff::reconstruct). + fn reconstruct_state(&self, root: &H256) -> Option { + // Walk back collecting diffs until we reach a snapshot. + let mut diffs: Vec = Vec::new(); + let mut cursor = *root; + let snapshot = loop { + if let Some(snapshot) = self.get_ssz::(Table::States, &cursor) { + break snapshot; + } + let diff = self.get_ssz::(Table::StateDiffs, &cursor)?; + cursor = diff.base_root; + diffs.push(diff); + }; + + // `diffs` runs target -> snapshot child; reverse to snapshot child -> target. + diffs.reverse(); + + // The latest block header lives in BlockHeaders; the stored state caches + // the real state_root there, so it equals the header byte-for-byte. + let latest_block_header = self.get_block_header(root)?; + + Some(crate::state_diff::reconstruct( + snapshot, + &diffs, + latest_block_header, + )) } - /// Returns whether a state exists for the given block root. + /// Returns whether a state is available for the given block root. + /// + /// True if a snapshot exists or the state can be reconstructed from a diff. pub fn has_state(&self, root: &H256) -> bool { let view = self.backend.begin_read().expect("read view"); - view.get(Table::States, &root.to_ssz()) - .expect("get") - .is_some() + let key = root.to_ssz(); + view.get(Table::States, &key).expect("get").is_some() + || view.get(Table::StateDiffs, &key).expect("get").is_some() } - /// Stores a state indexed by block root. - pub fn insert_state(&mut self, root: H256, state: State) { + /// Persist a post-block state as a parent-linked diff, plus a full snapshot. + /// + /// Every non-genesis state gets a `StateDiffs` entry (never pruned, so full + /// history is preserved). A snapshot is also written: the state is at the tip + /// when imported, so it is always hot now; eviction thins non-hot snapshots + /// later. States crossing a [`SNAPSHOT_ANCHOR_INTERVAL`] boundary are + /// recorded as permanent anchors so they survive eviction. + /// + /// `base` describes the parent state the diff is built against (see + /// [`DiffBase`]); its fields are captured before the parent is consumed into + /// `state`. + pub fn insert_state_with_diff(&mut self, root: H256, base: DiffBase, state: State) { + let slot = state.slot; + let is_anchor = slot / SNAPSHOT_ANCHOR_INTERVAL > base.slot / SNAPSHOT_ANCHOR_INTERVAL; + + // Serialize the full snapshot first (borrow), then move `state` into the + // diff so its multi-MB justification fields are not cloned. + let snapshot_bytes = state.to_ssz(); + let diff_bytes = StateDiff::from_base(base.root, base.hbh_len, state).to_ssz(); + + let key = root.to_ssz(); let mut batch = self.backend.begin_write().expect("write batch"); - let entries = vec![(root.to_ssz(), state.to_ssz())]; - batch.put_batch(Table::States, entries).expect("put state"); + batch + .put_batch(Table::StateDiffs, vec![(key.clone(), diff_bytes)]) + .expect("put state diff"); + batch + .put_batch(Table::States, vec![(key.clone(), snapshot_bytes)]) + .expect("put state"); + if is_anchor { + batch + .put_batch(Table::StateAnchors, vec![(key, slot.to_ssz())]) + .expect("put state anchor"); + } batch.commit().expect("commit"); } @@ -1453,6 +1565,15 @@ mod tests { batch.commit().expect("commit"); } + /// Insert a real full-state snapshot for a given root (seeds a diff-chain base). + fn insert_snapshot(backend: &dyn StorageBackend, root: H256, state: &State) { + let mut batch = backend.begin_write().expect("write batch"); + batch + .put_batch(Table::States, vec![(root.to_ssz(), state.to_ssz())]) + .expect("put snapshot"); + batch.commit().expect("commit"); + } + /// Count entries in a table. fn count_entries(backend: &dyn StorageBackend, table: Table) -> usize { let view = backend.begin_read().expect("read view"); @@ -1503,176 +1624,315 @@ mod tests { } } - // ============ Block Pruning Tests ============ + // ============ Block Signature Pruning Tests ============ #[test] - fn prune_old_blocks_within_retention() { + fn prune_signatures_keeps_recent_window_when_finality_healthy() { let backend = Arc::new(InMemoryBackend::new()); let mut store = Store::test_store_with_backend(backend.clone()); - // Insert exactly BLOCKS_TO_KEEP blocks - for i in 0..BLOCKS_TO_KEEP as u64 { + // Blocks at slots 0..12, each with header + body + signature. + for i in 0..13u64 { insert_header(backend.as_ref(), root(i), i); } - assert_eq!( - count_entries(backend.as_ref(), Table::BlockHeaders), - BLOCKS_TO_KEEP - ); - let pruned = store.prune_old_blocks(&[]); - assert_eq!(pruned, 0); - assert_eq!( - count_entries(backend.as_ref(), Table::BlockHeaders), - BLOCKS_TO_KEEP - ); + // Healthy finality: non-finalized gap (5) < SIGNATURE_PRUNING_RANGE. + // tip = range + 10, finalized = range + 5, so cutoff = tip - range = 10. + let tip_slot = SIGNATURE_PRUNING_RANGE + 10; + let finalized_slot = SIGNATURE_PRUNING_RANGE + 5; + let pruned = store.prune_old_block_signatures(finalized_slot, tip_slot); + + // cutoff = 10: slots 0..9 pruned, slots 10..12 kept (within the window). + assert_eq!(pruned, 10); + for i in 0..10u64 { + assert!(!has_key(backend.as_ref(), Table::BlockSignatures, &root(i))); + } + for i in 10..13u64 { + assert!(has_key(backend.as_ref(), Table::BlockSignatures, &root(i))); + } + + // Headers and bodies are always retained for the whole history. + assert_eq!(count_entries(backend.as_ref(), Table::BlockHeaders), 13); + assert_eq!(count_entries(backend.as_ref(), Table::BlockBodies), 13); } #[test] - fn prune_old_blocks_exceeding_retention() { + fn prune_signatures_noop_when_non_finalized_range_exceeds_window() { let backend = Arc::new(InMemoryBackend::new()); let mut store = Store::test_store_with_backend(backend.clone()); - let total = BLOCKS_TO_KEEP + 10; - for i in 0..total as u64 { + for i in 0..10u64 { insert_header(backend.as_ref(), root(i), i); } - assert_eq!(count_entries(backend.as_ref(), Table::BlockHeaders), total); - - let pruned = store.prune_old_blocks(&[]); - assert_eq!(pruned, 10); - assert_eq!( - count_entries(backend.as_ref(), Table::BlockHeaders), - BLOCKS_TO_KEEP - ); - assert_eq!( - count_entries(backend.as_ref(), Table::BlockBodies), - BLOCKS_TO_KEEP - ); - assert_eq!( - count_entries(backend.as_ref(), Table::BlockSignatures), - BLOCKS_TO_KEEP - ); - // Oldest blocks (slots 0..10) should be gone - for i in 0..10u64 { - assert!(!has_key(backend.as_ref(), Table::BlockHeaders, &root(i))); - } - // Newest blocks should still exist - for i in 10..total as u64 { - assert!(has_key(backend.as_ref(), Table::BlockHeaders, &root(i))); - } + // Deep non-finality: gap (tip - finalized) > SIGNATURE_PRUNING_RANGE, so + // cutoff = tip - range > finalized → prune nothing. + let tip_slot = SIGNATURE_PRUNING_RANGE + 100; + let finalized_slot = 5; + let pruned = store.prune_old_block_signatures(finalized_slot, tip_slot); + assert_eq!(pruned, 0); + assert_eq!(count_entries(backend.as_ref(), Table::BlockSignatures), 10); } #[test] - fn prune_old_blocks_preserves_protected() { + fn prune_signatures_noop_when_tip_within_window() { let backend = Arc::new(InMemoryBackend::new()); let mut store = Store::test_store_with_backend(backend.clone()); - let total = BLOCKS_TO_KEEP + 10; - for i in 0..total as u64 { + for i in 0..10u64 { insert_header(backend.as_ref(), root(i), i); } - // Protect the two oldest blocks (slots 0 and 1) - let finalized_root = root(0); - let justified_root = root(1); - let pruned = store.prune_old_blocks(&[finalized_root, justified_root]); - - // 10 would be pruned, but 2 are protected - assert_eq!(pruned, 8); - assert!(has_key( - backend.as_ref(), - Table::BlockHeaders, - &finalized_root - )); - assert!(has_key( - backend.as_ref(), - Table::BlockHeaders, - &justified_root - )); - assert!(has_key( - backend.as_ref(), - Table::BlockBodies, - &finalized_root - )); - assert!(has_key( - backend.as_ref(), - Table::BlockSignatures, - &finalized_root - )); + // Early chain: tip < SIGNATURE_PRUNING_RANGE → cutoff saturates to 0, + // so nothing is old enough to prune even though slots are finalized. + let pruned = store.prune_old_block_signatures(9, 9); + assert_eq!(pruned, 0); + assert_eq!(count_entries(backend.as_ref(), Table::BlockSignatures), 10); } - // ============ State Pruning Tests ============ + // ============ State Snapshot Eviction Tests ============ + + /// Mark a root as a permanent snapshot anchor for a given slot. + fn insert_anchor(backend: &dyn StorageBackend, root: H256, slot: u64) { + let mut batch = backend.begin_write().expect("write batch"); + batch + .put_batch(Table::StateAnchors, vec![(root.to_ssz(), slot.to_ssz())]) + .expect("put anchor"); + batch.commit().expect("commit"); + } #[test] - fn prune_old_states_within_retention() { + fn evict_snapshots_within_window_is_noop() { let backend = Arc::new(InMemoryBackend::new()); let mut store = Store::test_store_with_backend(backend.clone()); - // Insert STATES_TO_KEEP headers + states - for i in 0..STATES_TO_KEEP as u64 { + // All slots are within SNAPSHOT_HOT_WINDOW of the tip. + let total = SNAPSHOT_HOT_WINDOW + 1; // slots 0..=SNAPSHOT_HOT_WINDOW + for i in 0..total { insert_header(backend.as_ref(), root(i), i); insert_state(backend.as_ref(), root(i)); } + + let evicted = store.prune_old_states(&[]); + assert_eq!(evicted, 0); assert_eq!( count_entries(backend.as_ref(), Table::States), - STATES_TO_KEEP + total as usize ); - - let pruned = store.prune_old_states(&[]); - assert_eq!(pruned, 0); } #[test] - fn prune_old_states_exceeding_retention() { + fn evict_snapshots_outside_window() { let backend = Arc::new(InMemoryBackend::new()); let mut store = Store::test_store_with_backend(backend.clone()); - let total = STATES_TO_KEEP + 5; - for i in 0..total as u64 { + // slots 0..=SNAPSHOT_HOT_WINDOW+4. tip = SNAPSHOT_HOT_WINDOW+4, + // cutoff = tip - SNAPSHOT_HOT_WINDOW = 4. Slots 0..4 (4 states) evicted. + let total = SNAPSHOT_HOT_WINDOW + 5; + for i in 0..total { insert_header(backend.as_ref(), root(i), i); insert_state(backend.as_ref(), root(i)); } - assert_eq!(count_entries(backend.as_ref(), Table::States), total); - let pruned = store.prune_old_states(&[]); - assert_eq!(pruned, 5); - assert_eq!( - count_entries(backend.as_ref(), Table::States), - STATES_TO_KEEP - ); + let evicted = store.prune_old_states(&[]); + assert_eq!(evicted, 4); - // Oldest states should be gone - for i in 0..5u64 { + for i in 0..4 { assert!(!has_key(backend.as_ref(), Table::States, &root(i))); } - // Newest states should remain - for i in 5..total as u64 { + for i in 4..total { assert!(has_key(backend.as_ref(), Table::States, &root(i))); } } #[test] - fn prune_old_states_preserves_protected() { + fn evict_snapshots_preserves_protected() { let backend = Arc::new(InMemoryBackend::new()); let mut store = Store::test_store_with_backend(backend.clone()); - let total = STATES_TO_KEEP + 5; - for i in 0..total as u64 { + let total = SNAPSHOT_HOT_WINDOW + 5; // cutoff = 4, slots 0..4 evictable + for i in 0..total { insert_header(backend.as_ref(), root(i), i); insert_state(backend.as_ref(), root(i)); } + // Protect two of the four evictable snapshots. let finalized_root = root(0); let justified_root = root(2); - let pruned = store.prune_old_states(&[finalized_root, justified_root]); + let evicted = store.prune_old_states(&[finalized_root, justified_root]); - // 5 would be pruned, but 2 are protected - assert_eq!(pruned, 3); + assert_eq!(evicted, 2); assert!(has_key(backend.as_ref(), Table::States, &finalized_root)); assert!(has_key(backend.as_ref(), Table::States, &justified_root)); } + #[test] + fn evict_snapshots_preserves_anchors() { + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::test_store_with_backend(backend.clone()); + + let total = SNAPSHOT_HOT_WINDOW + 5; // cutoff = 4, slots 0..4 evictable + for i in 0..total { + insert_header(backend.as_ref(), root(i), i); + insert_state(backend.as_ref(), root(i)); + } + + // Anchor one evictable snapshot; it must survive eviction. + insert_anchor(backend.as_ref(), root(1), 1); + let evicted = store.prune_old_states(&[]); + + assert_eq!(evicted, 3); + assert!(has_key(backend.as_ref(), Table::States, &root(1))); + } + + // ============ State Diff Reconstruction Tests ============ + + use ethlambda_types::state::Validator; + + /// The header `insert_header` writes for a given slot. + fn header_at(slot: u64) -> BlockHeader { + BlockHeader { + slot, + proposer_index: 0, + parent_root: H256::ZERO, + state_root: H256::ZERO, + body_root: H256::ZERO, + } + } + + /// A real `State` at `slot` with the given historical_block_hashes and a + /// `latest_block_header` matching what `insert_header` stores. + fn sample_state(slot: u64, hbh: Vec) -> State { + let validators = vec![Validator { + attestation_pubkey: [7u8; 52], + proposal_pubkey: [9u8; 52], + index: 0, + }]; + let mut state = State::from_genesis(1_000, validators); + state.slot = slot; + state.latest_block_header = header_at(slot); + state.historical_block_hashes = hbh.try_into().unwrap(); + state + } + + /// Delete the full-state snapshot for a root, forcing reconstruction. + fn evict_snapshot(backend: &dyn StorageBackend, root: H256) { + let mut batch = backend.begin_write().expect("write batch"); + batch + .delete_batch(Table::States, vec![root.to_ssz()]) + .expect("evict snapshot"); + batch.commit().expect("commit"); + } + + #[test] + fn get_state_reconstructs_from_diff_after_eviction() { + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::test_store_with_backend(backend.clone()); + + // Genesis snapshot at slot 0. + let s0 = sample_state(0, vec![]); + let r0 = root(0); + insert_header(backend.as_ref(), r0, 0); + insert_snapshot(backend.as_ref(), r0, &s0); + + // Child at slot 1: appends one historical root, sets a checkpoint. + let r1 = root(1); + let mut s1 = sample_state(1, vec![root(42)]); + s1.latest_justified = Checkpoint { + root: root(7), + slot: 0, + }; + insert_header(backend.as_ref(), r1, 1); + let base = DiffBase { + root: r0, + hbh_len: s0.historical_block_hashes.len(), + slot: s0.slot, + }; + store.insert_state_with_diff(r1, base, s1.clone()); + + // Fast path: snapshot present. + assert_eq!(store.get_state(&r1).unwrap().to_ssz(), s1.to_ssz()); + + // Evict the snapshot; reconstruction from the diff must be byte-identical. + evict_snapshot(backend.as_ref(), r1); + assert!(!has_key(backend.as_ref(), Table::States, &r1)); + let reconstructed = store.get_state(&r1).expect("reconstructs from diff"); + assert_eq!(reconstructed.to_ssz(), s1.to_ssz()); + } + + #[test] + fn get_state_reconstructs_across_multiple_diffs() { + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::test_store_with_backend(backend.clone()); + + // Snapshot s0, then two chained diffs s1 -> s2. + let s0 = sample_state(0, vec![]); + let r0 = root(0); + insert_header(backend.as_ref(), r0, 0); + insert_snapshot(backend.as_ref(), r0, &s0); + + let r1 = root(1); + let s1 = sample_state(1, vec![root(42)]); + insert_header(backend.as_ref(), r1, 1); + let base = DiffBase { + root: r0, + hbh_len: s0.historical_block_hashes.len(), + slot: s0.slot, + }; + store.insert_state_with_diff(r1, base, s1.clone()); + + let r2 = root(2); + let s2 = sample_state(2, vec![root(42), root(43)]); + insert_header(backend.as_ref(), r2, 2); + let base = DiffBase { + root: r1, + hbh_len: s1.historical_block_hashes.len(), + slot: s1.slot, + }; + store.insert_state_with_diff(r2, base, s2.clone()); + + // Evict both diffed snapshots; s2 must reconstruct by walking to s0. + evict_snapshot(backend.as_ref(), r1); + evict_snapshot(backend.as_ref(), r2); + let reconstructed = store.get_state(&r2).expect("reconstructs across diffs"); + assert_eq!(reconstructed.to_ssz(), s2.to_ssz()); + } + + #[test] + fn insert_state_with_diff_records_anchor_on_boundary_crossing() { + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::test_store_with_backend(backend.clone()); + + let s0 = sample_state(SNAPSHOT_ANCHOR_INTERVAL - 1, vec![]); + let r0 = root(0); + insert_header(backend.as_ref(), r0, s0.slot); + insert_snapshot(backend.as_ref(), r0, &s0); + + // Crossing the interval boundary records an anchor. + let r1 = root(1); + let s1 = sample_state(SNAPSHOT_ANCHOR_INTERVAL, vec![root(42)]); + insert_header(backend.as_ref(), r1, s1.slot); + let base = DiffBase { + root: r0, + hbh_len: s0.historical_block_hashes.len(), + slot: s0.slot, + }; + store.insert_state_with_diff(r1, base, s1.clone()); + assert!(has_key(backend.as_ref(), Table::StateAnchors, &r1)); + + // A non-crossing child does not. + let r2 = root(2); + let s2 = sample_state(SNAPSHOT_ANCHOR_INTERVAL + 1, vec![root(42), root(43)]); + insert_header(backend.as_ref(), r2, s2.slot); + let base = DiffBase { + root: r1, + hbh_len: s1.historical_block_hashes.len(), + slot: s1.slot, + }; + store.insert_state_with_diff(r2, base, s2.clone()); + assert!(!has_key(backend.as_ref(), Table::StateAnchors, &r2)); + } + // ============ Periodic Pruning Tests ============ /// Set up finalized and justified checkpoints in metadata. @@ -1710,13 +1970,14 @@ mod tests { }, ); - // Insert more than STATES_TO_KEEP headers + states, but fewer than BLOCKS_TO_KEEP - let total_states = STATES_TO_KEEP + 5; - for i in 0..total_states as u64 { + // Insert more states than the hot window so some snapshots get evicted. + let total_states = SNAPSHOT_HOT_WINDOW + 5; // cutoff = 4, slots 0..4 evictable + for i in 0..total_states { insert_header(backend.as_ref(), root(i), i); insert_state(backend.as_ref(), root(i)); } + let total_states = total_states as usize; assert_eq!(count_entries(backend.as_ref(), Table::States), total_states); assert_eq!( count_entries(backend.as_ref(), Table::BlockHeaders), @@ -1734,18 +1995,17 @@ mod tests { store.prune_old_data(); - // 3005 headers total. Top 3000 by slot are kept in the retention window, - // leaving 5 candidates. 2 are protected (finalized + justified), - // so 3 are pruned → 3005 - 3 = 3002 states remaining. + // Slots 0..4 are outside the hot window; finalized(0) + justified(1) are + // protected, so only slots 2 and 3 are evicted → total - 2 remain. assert_eq!( count_entries(backend.as_ref(), Table::States), - STATES_TO_KEEP + 2 + total_states - 2 ); // Finalized and justified states must survive assert!(has_key(backend.as_ref(), Table::States, &finalized_root)); assert!(has_key(backend.as_ref(), Table::States, &justified_root)); - // Blocks: total_states < BLOCKS_TO_KEEP, so no blocks should be pruned + // Block headers are never pruned, so all of them must remain. assert_eq!( count_entries(backend.as_ref(), Table::BlockHeaders), total_states @@ -1769,26 +2029,22 @@ mod tests { }, ); - // Insert exactly STATES_TO_KEEP entries (no excess) - for i in 0..STATES_TO_KEEP as u64 { + // Insert states entirely within the hot window (no excess). + let total = SNAPSHOT_HOT_WINDOW + 1; // slots 0..=SNAPSHOT_HOT_WINDOW + for i in 0..total { insert_header(backend.as_ref(), root(i), i); insert_state(backend.as_ref(), root(i)); } // Use the last inserted root as head - let head_root = root(STATES_TO_KEEP as u64 - 1); + let head_root = root(total - 1); store.update_checkpoints(ForkCheckpoints::head_only(head_root)); store.prune_old_data(); // Nothing should be pruned (within retention window) - assert_eq!( - count_entries(backend.as_ref(), Table::States), - STATES_TO_KEEP - ); - assert_eq!( - count_entries(backend.as_ref(), Table::BlockHeaders), - STATES_TO_KEEP - ); + let total = total as usize; + assert_eq!(count_entries(backend.as_ref(), Table::States), total); + assert_eq!(count_entries(backend.as_ref(), Table::BlockHeaders), total); } // ============ PayloadBuffer Tests ============ @@ -2581,6 +2837,19 @@ mod tests { assert!(store.get_signed_block(&root).is_none()); } + /// The anchor bootstrap must register its snapshot in `StateAnchors` so it is + /// never evicted (the base of every diff chain) and `States` is not written + /// alone. + #[test] + fn from_anchor_state_registers_permanent_anchor() { + let backend: Arc = Arc::new(InMemoryBackend::new()); + let store = Store::from_anchor_state(backend.clone(), State::from_genesis(0, vec![])); + + let anchor_root = store.head(); + assert!(has_key(backend.as_ref(), Table::States, &anchor_root)); + assert!(has_key(backend.as_ref(), Table::StateAnchors, &anchor_root)); + } + // ============ from_db_state Tests ============ #[test]