diff --git a/CLAUDE.md b/CLAUDE.md index 5b10c6dd..73dfcc3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -366,24 +366,33 @@ cargo test -p ethlambda-blockchain --test forkchoice_spectests -- --test-threads 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`) written only at 1024-slot anchors (and the + bootstrap). Neither is ever pruned. `get_state` returns an anchor snapshot or + reconstructs by walking diffs back to the nearest anchor; results are memoized + in an in-memory LRU (`STATE_CACHE_CAPACITY`) so recent reads stay hot - `LiveChain` table provides fast `(slot||root) → parent_root` index for fork choice - Storage uses trait-based API: `StorageBackend` → `StorageReadView` (reads) + `StorageWriteBatch` (atomic writes) -### Storage Tables (10) +### Storage Tables (7) + +These are the variants of the `Table` enum (`crates/storage/src/api/tables.rs`). | Table | Key → Value | Purpose | |-------|-------------|---------| | `BlockHeaders` | H256 → BlockHeader | Block headers by root | | `BlockBodies` | H256 → BlockBody | Block bodies (empty for genesis) | -| `BlockSignatures` | H256 → BlockSignatures | Signatures (absent for genesis) | -| `States` | H256 → State | Beacon states by root | -| `LatestKnownAttestations` | u64 → AttestationData | Fork-choice-active attestations | -| `LatestNewAttestations` | u64 → AttestationData | Pending (pre-promotion) attestations | -| `GossipSignatures` | SignatureKey → ValidatorSignature | Individual validator signatures | -| `AggregatedPayloads` | SignatureKey → Vec\ | Aggregated proofs | +| `BlockSignatures` | (slot\|\|root) → BlockSignatures | Type-2 proof blob; keyed slot\|\|root so pruning scans in slot order and stops early; absent for genesis, pruned below finalized | +| `States` | H256 → State | Full-state snapshots; bootstrap + 1024-slot anchors only; never pruned | +| `StateDiffs` | H256 → StateDiff | Parent-linked state diff per non-genesis state; never pruned | | `Metadata` | string → various | Store state (head, config, checkpoints) | | `LiveChain` | (slot\|\|root) → parent\_root | Fast fork choice traversal index | +Attestations and gossip signatures are **not** persisted tables; they live in +in-memory `Store` buffers (`new_payloads`, `known_payloads`, `gossip_signatures`) +and are consumed during the tick pipeline (promotion at intervals 0/4, +aggregation at interval 2). + ### State Root Computation - Always computed via `tree_hash_root()` after full state transition - Must match proposer's pre-computed `block.state_root` diff --git a/Cargo.lock b/Cargo.lock index 764d89b0..5c0ed65f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2170,6 +2170,8 @@ dependencies = [ "leansig", "libssz", "libssz-derive", + "libssz-types", + "lru", "rand 0.10.1", "rocksdb", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 2b20d590..100b1a4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ vergen-git2 = { version = "9", features = ["rustc"] } rayon = "1.11" rand = "0.10" +lru = "0.16" rocksdb = "0.24" libc = "0.2" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 7de13c27..64db40c9 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -1265,8 +1265,20 @@ mod tests { // Head state justified `a` (slot 1), which lies on the head's chain. let head_justified = Checkpoint { root: a, slot: 1 }; - let mut head_state = State::from_genesis(1000, vec![]); + // Persist `b`'s post-state via the diff API, diffed against the genesis + // anchor. Build it as a valid direct child of genesis (the STF appends the + // parent block root to historical_block_hashes), with the head's justified + // checkpoint set; `insert_state` reads the base from + // `latest_block_header.parent_root`, and `get_state(b)` then returns it + // from the cache. + let genesis_state = store.get_state(&genesis).expect("genesis state"); + let mut head_state = genesis_state.clone(); + head_state.slot = genesis_state.slot + 1; head_state.latest_justified = head_justified; + head_state.latest_block_header.parent_root = genesis; + let mut hbh = genesis_state.historical_block_hashes.to_vec(); + hbh.push(genesis); + head_state.historical_block_hashes = hbh.try_into().expect("within limit"); store .insert_state(b, head_state) .expect("insert head state should succeed"); diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index f5b2ca58..7381a53e 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -18,6 +18,9 @@ thiserror.workspace = true libssz.workspace = true libssz-derive.workspace = true +libssz-types.workspace = true + +lru.workspace = true [dev-dependencies] tempfile = "3" diff --git a/crates/storage/src/api/tables.rs b/crates/storage/src/api/tables.rs index 5884f1f9..dcda1cbf 100644 --- a/crates/storage/src/api/tables.rs +++ b/crates/storage/src/api/tables.rs @@ -11,7 +11,16 @@ pub enum Table { /// All other blocks must have an entry in this table. BlockSignatures, /// State storage: H256 -> State + /// + /// Holds full-state snapshots only: the bootstrap anchor plus one anchor per + /// 1024-slot window. Never pruned. Non-anchor states live in `StateDiffs` and + /// are reconstructed on demand (memoized by an in-memory cache). 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, /// Metadata: string keys -> various scalar values Metadata, /// Live chain index: (slot || root) -> parent_root @@ -23,11 +32,12 @@ pub enum Table { } /// All table variants. -pub const ALL_TABLES: [Table; 6] = [ +pub const ALL_TABLES: [Table; 7] = [ Table::BlockHeaders, Table::BlockBodies, Table::BlockSignatures, Table::States, + Table::StateDiffs, Table::Metadata, Table::LiveChain, ]; @@ -40,6 +50,7 @@ impl Table { Table::BlockBodies => "block_bodies", Table::BlockSignatures => "block_signatures", Table::States => "states", + Table::StateDiffs => "state_diffs", 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 de5f20df..9b21dc85 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -1,6 +1,7 @@ mod api; pub mod backend; mod error; +mod state_diff; mod store; pub use api::{ALL_TABLES, StorageBackend, StorageReadView, StorageWriteBatch, Table}; diff --git a/crates/storage/src/state_diff.rs b/crates/storage/src/state_diff.rs new file mode 100644 index 00000000..1e9e5028 --- /dev/null +++ b/crates/storage/src/state_diff.rs @@ -0,0 +1,375 @@ +//! 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 (the parent block root +//! plus one zero per skipped slot), recoverable from `base_root` and the slot +//! gap, so nothing is stored for it. +//! - 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, HashTreeRoot}, + state::{JustificationRoots, JustificationValidators, JustifiedSlots, State}, +}; +use libssz_derive::{SszDecode, SszEncode}; + +/// 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, +} + +/// Why a post-state could not be reduced to a [`StateDiff`]. +/// +/// Every variant means the state transition mutated `historical_block_hashes` +/// in a way the diff layer does not model: the diff stores no history and +/// [`reconstruct`] regenerates the append from `base_root` plus the slot gap, so +/// a mismatch here would corrupt later reads. These are state-transition +/// invariants, not recoverable conditions, so the production caller `expect`s on +/// them. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum StateDiffError { + /// The post-state's history is shorter than the pre-state's: the STF removed + /// entries instead of appending. + #[error("post-state historical_block_hashes ({actual}) is shorter than pre-state ({base})")] + HistoryShrank { actual: usize, base: usize }, + /// The number of appended entries does not equal the slot gap (one entry per + /// slot from the parent exclusive to the target inclusive). + #[error("appended historical_block_hashes length ({appended}) does not match slot gap ({gap})")] + AppendLengthMismatch { appended: usize, gap: usize }, + /// The first appended entry is not the base (parent) block root. + #[error("first appended historical_block_hash is not the base (parent) root")] + FirstAppendedNotBase, + /// A skipped-slot entry is non-zero (skipped slots must be zero-filled). + #[error("skipped-slot historical_block_hashes are not zero-filled")] + SkippedSlotsNotZero, +} + +impl StateDiff { + /// Build a diff from the pre-state (the parent block's post-state) and the + /// consumed post-state. + /// + /// Takes `post_state` by value so its multi-MB justification fields are moved + /// into the diff rather than cloned; `pre_state` is read to derive `base_root` + /// and to validate the `historical_block_hashes` append (which is not stored, + /// only checked here and regenerated by `reconstruct`). + /// + /// `base_root` is the parent block root, computed as the `hash_tree_root` of + /// the pre-state's `latest_block_header`. A `Block` and its `BlockHeader` + /// share a hash tree root (the header's `body_root` is the body's root), so + /// this equals the key under which the parent's snapshot/diff is stored. + /// + /// # Assumptions about how the base is modified into the target + /// + /// The diff stores only part of `target` and is lossless *only* because the + /// state transition changes the base (parent) state in a restricted way. + /// `reconstruct` depends on each of these; a future STF that broke one would + /// make reconstructed states silently wrong, not just fail: + /// + /// - **`config` and `validators` are unchanged from base to target.** They + /// are not stored in the diff; reconstruction takes them from the nearest + /// ancestor snapshot. (The lean STF never mutates either: `validators` is + /// fixed at genesis and `config` is static.) + /// - **`historical_block_hashes` grows only by appending the parent block + /// root, then one zero per skipped slot.** (`process_block` pushes the + /// parent root and zero-fills skipped slots, leaving the existing prefix + /// intact.) Nothing is stored for it: `reconstruct` regenerates the tail + /// from `base_root` and the slot gap. This function validates the invariant + /// and returns a [`StateDiffError`] if a future STF breaks it, instead of + /// corrupting reads. + /// - **`latest_block_header` is not stored here.** It is read back from the + /// `BlockHeaders` table during reconstruction; the persisted post-state + /// caches the real `state_root` there, so the two are byte-identical. + /// + /// All remaining fields (`slot`, both checkpoints, and the three + /// justification fields) are captured verbatim, so the diff makes no + /// assumption about how those change. + /// + /// # Errors + /// + /// Returns [`StateDiffError`] if the `historical_block_hashes` append does + /// not match the assumption above: a length that disagrees with the slot + /// gap, a first appended entry other than `base_root`, or a non-zero entry + /// for a skipped slot. These are state-transition invariants, so the + /// production caller treats a failure as a bug and `expect`s on it. + pub fn from_states(pre_state: &State, post_state: State) -> Result { + let base_root = pre_state.latest_block_header.hash_tree_root(); + let base_hbh_len = pre_state.historical_block_hashes.len(); + let State { + slot, + latest_justified, + latest_finalized, + historical_block_hashes, + justified_slots, + justifications_roots, + justifications_validators, + .. + } = post_state; + + // The diff stores no historical_block_hashes; reconstruct regenerates the + // appended tail from base_root and the slot gap. Validate the append + // matched that shape so a future STF that broke it fails here, not + // silently later. + let hbh = historical_block_hashes.into_inner(); + let slot_gap = (slot - pre_state.slot) as usize; + validate_history_append(&hbh, base_hbh_len, base_root, slot_gap)?; + + Ok(Self { + base_root, + slot, + latest_justified, + latest_finalized, + justified_slots, + justifications_roots, + justifications_validators, + }) + } +} + +/// Validate that the post-state's `historical_block_hashes` extends the +/// pre-state's by exactly the tail the STF appends: the base (parent) block +/// root, then one zero per skipped slot. This is the invariant that lets a diff +/// store no history (see [`StateDiff::from_states`]); `reconstruct` relies on it +/// to regenerate the tail from `base_root` and the slot gap. +fn validate_history_append( + hbh: &[H256], + base_hbh_len: usize, + base_root: H256, + slot_gap: usize, +) -> Result<(), StateDiffError> { + if hbh.len() < base_hbh_len { + return Err(StateDiffError::HistoryShrank { + actual: hbh.len(), + base: base_hbh_len, + }); + } + let appended = &hbh[base_hbh_len..]; + if appended.len() != slot_gap { + return Err(StateDiffError::AppendLengthMismatch { + appended: appended.len(), + gap: slot_gap, + }); + } + if appended.first().copied() != Some(base_root) { + return Err(StateDiffError::FirstAppendedNotBase); + } + if !appended[1..].iter().all(|h| *h == H256::ZERO) { + return Err(StateDiffError::SkippedSlotsNotZero); + } + Ok(()) +} + +/// 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 by +/// appending each diff's `base_root` and one zero per skipped slot, 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"); + + // Replay the appended history: each diff added its base (parent) block root, + // then one zero per slot skipped before it. Both are recovered from base_root + // and the gap to the previous state's slot, so no hbh is stored in the diff. + let mut hbh: Vec = snapshot.historical_block_hashes.to_vec(); + let mut prev_slot = snapshot.slot; + for diff in diffs { + hbh.push(diff.base_root); + let empty_slots = (diff.slot - prev_slot - 1) as usize; + hbh.extend(std::iter::repeat_n(H256::ZERO, empty_slots)); + prev_slot = diff.slot; + } + 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::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_states_captures_base_root_and_absolute_fields() { + // base at slot 0; post at slot 5 skips slots 1-4 (zero-filled). + let base = base_state(); + let mut post = child_state(&base, 5); + let expected_justified = Checkpoint { + root: h256(7), + slot: 4, + }; + post.latest_justified = expected_justified; + + let diff = StateDiff::from_states(&base, post).expect("valid append"); + + assert_eq!(diff.base_root, base.latest_block_header.hash_tree_root()); + assert_eq!(diff.slot, 5); + assert_eq!(diff.latest_justified, expected_justified); + } + + /// A block header distinct from any snapshot/diff field, so the test can + /// assert it is passed through `reconstruct` verbatim. + fn header_at(slot: u64) -> BlockHeader { + BlockHeader { + slot, + proposer_index: 7, + parent_root: h256(51), + state_root: h256(99), + body_root: h256(88), + } + } + + /// Build the post-state of a block at `slot` whose parent post-state is + /// `parent`, mirroring the STF: append the parent block root (the hash of + /// `parent`'s `latest_block_header`), zero-fill skipped slots, and set the + /// child's own header. Absolute fields are inherited; callers override what + /// they assert on. Feeds realistic states into the production `from_states`, + /// so the tests exercise diff creation rather than fabricating diffs by hand. + fn child_state(parent: &State, slot: u64) -> State { + let parent_root = parent.latest_block_header.hash_tree_root(); + let empty_slots = (slot - parent.slot - 1) as usize; + + let mut hbh = parent.historical_block_hashes.to_vec(); + hbh.push(parent_root); + hbh.extend(std::iter::repeat_n(H256::ZERO, empty_slots)); + + let mut child = parent.clone(); + child.slot = slot; + child.historical_block_hashes = hbh.try_into().unwrap(); + child.latest_block_header = BlockHeader { + slot, + proposer_index: 0, + parent_root, + state_root: H256::ZERO, + body_root: H256::ZERO, + }; + child + } + + #[test] + fn reconstruct_round_trips_a_diff_chain() { + // Snapshot at slot 100 with one pre-existing historical root. + let mut snapshot = base_state(); + snapshot.slot = 100; + snapshot.latest_block_header = header_at(100); + snapshot.historical_block_hashes = vec![h256(1)].try_into().unwrap(); + + // s1 is the snapshot's child (consecutive slot); s2 is s1's child three + // slots later, so slots 102 and 103 are skipped and zero-filled. s2 also + // carries distinctive absolute fields the reconstruction must adopt. + let s1 = child_state(&snapshot, 101); + let mut s2 = child_state(&s1, 104); + s2.latest_justified = Checkpoint { + root: h256(7), + slot: 101, + }; + s2.latest_finalized = Checkpoint { + root: h256(8), + slot: 100, + }; + s2.justified_slots = JustifiedSlots::try_from(vec![true, false, true]).unwrap(); + s2.justifications_roots = JustificationRoots::try_from(vec![h256(9)]).unwrap(); + s2.justifications_validators = JustificationValidators::try_from(vec![true]).unwrap(); + + // Diffs are built the production way, from each (pre, post) pair. + let diff1 = StateDiff::from_states(&snapshot, s1.clone()).expect("valid append"); + let diff2 = StateDiff::from_states(&s1, s2.clone()).expect("valid append"); + + let reconstructed = reconstruct(snapshot, &[diff1, diff2], s2.latest_block_header.clone()); + + // Full round-trip: structural fields (config/validators) from the snapshot, + // absolute fields from the last diff, and the appended-with-gaps history. + assert_eq!(reconstructed.to_ssz(), s2.to_ssz()); + } + + #[test] + fn reconstruct_with_single_diff_round_trips() { + let mut snapshot = base_state(); + snapshot.slot = 7; + snapshot.latest_block_header = header_at(7); + snapshot.historical_block_hashes = vec![h256(1)].try_into().unwrap(); + + let mut child = child_state(&snapshot, 8); + child.latest_justified = Checkpoint { + root: h256(7), + slot: 7, + }; + + let diff = StateDiff::from_states(&snapshot, child.clone()).expect("valid append"); + let reconstructed = reconstruct(snapshot, &[diff], child.latest_block_header.clone()); + + assert_eq!(reconstructed.to_ssz(), child.to_ssz()); + } +} diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index a4b7a725..747d66dc 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -1,6 +1,9 @@ use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::num::NonZeroUsize; use std::sync::{Arc, LazyLock, Mutex}; +use lru::LruCache; + use crate::api::{StorageBackend, StorageWriteBatch, Table}; use crate::error::Error; @@ -15,6 +18,8 @@ use ethlambda_types::{ state::{ChainConfig, State, anchor_pair_is_consistent}, }; use libssz::{SszDecode, SszEncode}; + +use crate::state_diff::StateDiff; use thiserror::Error; use tracing::{info, warn}; @@ -84,8 +89,21 @@ 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"; -/// ~3.3 hours of state history at 4-second slots (12000 / 4 = 3000). -const STATES_TO_KEEP: usize = 3_000; +/// Persist a full-state snapshot whenever a block's slot crosses a multiple of +/// this value (relative to its parent's slot). +/// +/// Snapshots are the only entries written to `States` (plus the bootstrap +/// anchor); they are never pruned and bound state-reconstruction diff walks to +/// at most this many steps. ~68 minutes at 4-second slots. +const SNAPSHOT_ANCHOR_INTERVAL: u64 = 1_024; + +/// Number of reconstructed/imported states memoized in memory. +/// +/// States are content-addressed by block root and immutable, so the cache never +/// needs invalidation; it only bounds how many recent states stay hot for reads +/// (e.g. a block's `parent_state` right after import). A miss falls back to a +/// snapshot read or a diff-chain reconstruction. +const STATE_CACHE_CAPACITY: usize = 32; /// 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 @@ -522,6 +540,15 @@ pub struct Store { known_payloads: Arc>, /// In-memory gossip signatures, consumed at interval 2 aggregation. gossip_signatures: Arc>, + /// LRU memoization of states by block root, shared across `Store` clones. + /// Avoids reconstructing recent states from diffs on every read. + state_cache: Arc>>, +} + +/// Build an empty state cache sized to [`STATE_CACHE_CAPACITY`]. +fn new_state_cache() -> Arc>> { + let capacity = NonZeroUsize::new(STATE_CACHE_CAPACITY).expect("cache capacity is non-zero"); + Arc::new(Mutex::new(LruCache::new(capacity))) } impl Store { @@ -594,6 +621,7 @@ impl Store { gossip_signatures: Arc::new(Mutex::new(GossipSignatureBuffer::new( GOSSIP_SIGNATURE_CAP, ))), + state_cache: new_state_cache(), }) } @@ -664,7 +692,9 @@ impl Store { .expect("put block body"); } - // State + // State snapshot. The anchor has no parent in the store, so it is + // the base of every diff chain: store it as a full snapshot in + // `States` (never pruned) so reconstruction always terminates here. let state_entries = vec![(anchor_block_root.to_ssz(), anchor_state.to_ssz())]; batch .put_batch(Table::States, state_entries) @@ -691,6 +721,7 @@ impl Store { gossip_signatures: Arc::new(Mutex::new(GossipSignatureBuffer::new( GOSSIP_SIGNATURE_CAP, ))), + state_cache: new_state_cache(), }) } @@ -819,33 +850,24 @@ impl Store { Ok(()) } - /// Prune old states and blocks to keep storage bounded. + /// Prune finalized block signatures to keep signature storage bounded. + /// + /// State diffs, block headers, block bodies, and full-state snapshots are + /// all retained for the full history and are never pruned. Only signatures + /// of finalized blocks older than the pruning window 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, - /// causing infinite re-processing loops when fallback pruning is active. + /// pruning until after a batch of blocks has been fully processed. pub fn prune_old_data(&mut self) { - let protected_roots = [ - self.latest_finalized().root, - self.latest_justified().root, - self.head(), - ]; let finalized_slot = self.latest_finalized().slot; let tip_slot = self .get_block_header(&self.head()) .map_or(finalized_slot, |header| header.slot); - let pruned_states = self - .prune_old_states(&protected_roots) - .expect("prune old states"); let pruned_signatures = self .prune_old_block_signatures(finalized_slot, tip_slot) .expect("prune old block signatures"); - if pruned_states > 0 || pruned_signatures > 0 { - info!( - pruned_states, - pruned_signatures, "Pruned old states and block signatures" - ); + if pruned_signatures > 0 { + info!(pruned_signatures, "Pruned old finalized block signatures"); } } @@ -949,55 +971,6 @@ impl Store { pruned_new + pruned_known } - /// Prune old states beyond the retention window. - /// - /// Keeps the most recent `STATES_TO_KEEP` states (by slot), plus any - /// states whose roots appear in `protected_roots` (finalized, justified). - /// - /// Returns the number of states pruned. - pub fn prune_old_states(&mut self, protected_roots: &[H256]) -> Result { - 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, &[]) - .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 Ok(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(); - - // Skip the retention window, collect remaining keys for deletion - let keys_to_delete: Vec> = 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"); - } - Ok(count) - } - /// Prune signatures of old finalized blocks, keeping a recent window. /// /// Signatures within [`SIGNATURE_PRUNING_RANGE`] slots of `tip_slot` are @@ -1134,12 +1107,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(); @@ -1160,9 +1134,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, }; @@ -1178,26 +1152,124 @@ 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 { + // Memoized hot states first (states are immutable per root). + if let Some(state) = self.state_cache.lock().unwrap().get(root) { + return Some(state.clone()); + } + // Anchor snapshot in `States`, otherwise reconstruct from the diff chain. + let snapshot = { + let view = self.backend.begin_read().expect("read view"); + view.get(Table::States, &root.to_ssz()) + .expect("get") + .map(|bytes| State::from_ssz_bytes(&bytes).expect("valid state")) + }; + let state = snapshot.or_else(|| self.reconstruct_state(root))?; + self.state_cache.lock().unwrap().put(*root, state.clone()); + Some(state) + } + + /// 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 view = self.backend.begin_read().expect("read view"); - view.get(Table::States, &root.to_ssz()) - .expect("get") - .map(|bytes| State::from_ssz_bytes(&bytes).expect("valid state")) + let mut diffs: Vec = Vec::new(); + let mut cursor = *root; + let snapshot = loop { + if let Some(bytes) = view.get(Table::States, &cursor.to_ssz()).expect("get") { + break State::from_ssz_bytes(&bytes).expect("valid state"); + } + let diff_bytes = view + .get(Table::StateDiffs, &cursor.to_ssz()) + .expect("get")?; + let diff = StateDiff::from_ssz_bytes(&diff_bytes).expect("valid state diff"); + cursor = diff.base_root; + diffs.push(diff); + }; + drop(view); + + // `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. + /// Persist a post-block state as a parent-linked diff, snapshotting at anchors. + /// + /// Every non-genesis state gets a `StateDiffs` entry (never pruned, so the + /// full state history is preserved). A full snapshot is written to `States` + /// only when the block crosses a [`SNAPSHOT_ANCHOR_INTERVAL`] boundary; these + /// anchors are never pruned and bound the reconstruction walk. The state is + /// also inserted into the in-memory cache so the immediate next read (e.g. as + /// a child block's parent state) is hot without reconstruction. + /// + /// The diff is built against the parent state, identified by the post-state's + /// own `latest_block_header.parent_root` (the state transition sets it to the + /// block's parent) and fetched via [`get_state`](Self::get_state). The parent + /// was persisted when its own block was imported, so this read is normally a + /// cache hit; a cold cache falls back to a snapshot read or a diff-chain + /// reconstruction. + /// + /// # Panics + /// + /// Panics if no state exists for the parent root: a child state can only be + /// inserted after its parent's state has been persisted. pub fn insert_state(&mut self, root: H256, state: State) -> Result<(), Error> { + // The post-state's latest_block_header is the block's own header, so its + // parent_root identifies the parent (base) state to diff against. + let parent_root = state.latest_block_header.parent_root; + let parent_state = self + .get_state(&parent_root) + .expect("parent state must exist to diff against"); + let is_anchor = + state.slot / SNAPSHOT_ANCHOR_INTERVAL > parent_state.slot / SNAPSHOT_ANCHOR_INTERVAL; + + // Snapshot only at anchors; serialize before `state` is consumed. + let snapshot_bytes = is_anchor.then(|| state.to_ssz()); + // Memoize the post-state for fast reads, then move it into the diff so + // its multi-MB justification fields are not cloned again. + self.state_cache.lock().unwrap().put(root, state.clone()); + let diff_bytes = StateDiff::from_states(&parent_state, state) + .expect("state transition produced a non-append historical_block_hashes") + .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"); + if let Some(snapshot_bytes) = snapshot_bytes { + batch + .put_batch(Table::States, vec![(key, snapshot_bytes)]) + .expect("put state snapshot"); + } batch.commit().expect("commit"); Ok(()) } @@ -1485,15 +1557,11 @@ mod tests { use super::*; use crate::backend::InMemoryBackend; - /// Insert a block header (and dummy body + signature) for a given root and slot. - fn insert_header(backend: &dyn StorageBackend, root: H256, slot: u64) { - let header = BlockHeader { - slot, - proposer_index: 0, - parent_root: H256::ZERO, - state_root: H256::ZERO, - body_root: H256::ZERO, - }; + /// Insert a block header (and dummy body + signature) for a given root, slot, + /// and parent. The stored header equals `header_at(slot, parent_root)`, so a + /// state built from the same `(slot, parent_root)` reconstructs byte-identically. + fn insert_header(backend: &dyn StorageBackend, root: H256, slot: u64, parent_root: H256) { + let header = header_at(slot, parent_root); let mut batch = backend.begin_write().expect("write batch"); let key = root.to_ssz(); batch @@ -1511,13 +1579,12 @@ mod tests { batch.commit().expect("commit"); } - /// Insert a dummy state for a given root. - fn insert_state(backend: &dyn StorageBackend, root: H256) { + /// 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"); - let key = root.to_ssz(); batch - .put_batch(Table::States, vec![(key, vec![0u8; 4])]) - .expect("put state"); + .put_batch(Table::States, vec![(root.to_ssz(), state.to_ssz())]) + .expect("put snapshot"); batch.commit().expect("commit"); } @@ -1562,6 +1629,7 @@ mod tests { gossip_signatures: Arc::new(Mutex::new(GossipSignatureBuffer::new( GOSSIP_SIGNATURE_CAP, ))), + state_cache: new_state_cache(), } } @@ -1575,6 +1643,7 @@ mod tests { gossip_signatures: Arc::new(Mutex::new(GossipSignatureBuffer::new( GOSSIP_SIGNATURE_CAP, ))), + state_cache: new_state_cache(), } } } @@ -1588,7 +1657,7 @@ mod tests { // Blocks at slots 0..12, each with header + body + signature. for i in 0..13u64 { - insert_header(backend.as_ref(), root(i), i); + insert_header(backend.as_ref(), root(i), i, H256::ZERO); } // Healthy finality: non-finalized gap (5) < SIGNATURE_PRUNING_RANGE. @@ -1619,7 +1688,7 @@ mod tests { let mut store = Store::test_store_with_backend(backend.clone()); for i in 0..10u64 { - insert_header(backend.as_ref(), root(i), i); + insert_header(backend.as_ref(), root(i), i, H256::ZERO); } // Deep non-finality: gap (tip - finalized) > SIGNATURE_PRUNING_RANGE, so @@ -1639,7 +1708,7 @@ mod tests { let mut store = Store::test_store_with_backend(backend.clone()); for i in 0..10u64 { - insert_header(backend.as_ref(), root(i), i); + insert_header(backend.as_ref(), root(i), i, H256::ZERO); } // Early chain: tip < SIGNATURE_PRUNING_RANGE → cutoff saturates to 0, @@ -1649,199 +1718,126 @@ mod tests { assert_eq!(count_entries(backend.as_ref(), Table::BlockSignatures), 10); } - // ============ State Pruning Tests ============ + // ============ State Diff Reconstruction Tests ============ - #[test] - fn prune_old_states_within_retention() { - let backend = Arc::new(InMemoryBackend::new()); - let mut store = Store::test_store_with_backend(backend.clone()); + use ethlambda_types::state::Validator; - // Insert STATES_TO_KEEP headers + states - for i in 0..STATES_TO_KEEP as u64 { - insert_header(backend.as_ref(), root(i), i); - insert_state(backend.as_ref(), root(i)); + /// The header `insert_header` writes for a given slot and parent. + fn header_at(slot: u64, parent_root: H256) -> BlockHeader { + BlockHeader { + slot, + proposer_index: 0, + parent_root, + state_root: H256::ZERO, + body_root: H256::ZERO, } - assert_eq!( - count_entries(backend.as_ref(), Table::States), - STATES_TO_KEEP - ); - - let pruned = store.prune_old_states(&[]).expect("prune"); - assert_eq!(pruned, 0); } - #[test] - fn prune_old_states_exceeding_retention() { - 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 { - 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(&[]).expect("prune"); - assert_eq!(pruned, 5); - assert_eq!( - count_entries(backend.as_ref(), Table::States), - STATES_TO_KEEP - ); - - // Oldest states should be gone - for i in 0..5u64 { - assert!(!has_key(backend.as_ref(), Table::States, &root(i))); - } - // Newest states should remain - for i in 5..total as u64 { - assert!(has_key(backend.as_ref(), Table::States, &root(i))); - } + /// A real `State` at `slot` whose `latest_block_header` matches what + /// `insert_header` stores for `(slot, parent_root)`; `parent_root` is also the + /// base the diff is built against (`insert_state` reads it back from the + /// post-state's `latest_block_header`). + fn sample_state(slot: u64, parent_root: H256, 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, parent_root); + state.historical_block_hashes = hbh.try_into().unwrap(); + state } #[test] - fn prune_old_states_preserves_protected() { + fn get_state_reconstructs_from_diff() { 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 { - insert_header(backend.as_ref(), root(i), i); - insert_state(backend.as_ref(), root(i)); - } - - let finalized_root = root(0); - let justified_root = root(2); - let pruned = store - .prune_old_states(&[finalized_root, justified_root]) - .expect("prune"); + // Genesis snapshot at slot 0; its block root is its header's hash. + let s0 = sample_state(0, H256::ZERO, vec![]); + let r0 = s0.latest_block_header.hash_tree_root(); + insert_header(backend.as_ref(), r0, 0, H256::ZERO); + insert_snapshot(backend.as_ref(), r0, &s0); + + // Child at slot 1 (parent r0): appends r0 (slot 0's block root), sets a checkpoint. + let mut s1 = sample_state(1, r0, vec![r0]); + s1.latest_justified = Checkpoint { + root: root(7), + slot: 0, + }; + let r1 = s1.latest_block_header.hash_tree_root(); + insert_header(backend.as_ref(), r1, 1, r0); + store.insert_state(r1, s1.clone()).expect("insert state"); - // 5 would be pruned, but 2 are protected - assert_eq!(pruned, 3); - assert!(has_key(backend.as_ref(), Table::States, &finalized_root)); - assert!(has_key(backend.as_ref(), Table::States, &justified_root)); - } + // Not an anchor, so no snapshot was written; only the diff. + assert!(!has_key(backend.as_ref(), Table::States, &r1)); - // ============ Periodic Pruning Tests ============ + // Hot path: the just-imported state is memoized in the cache. + assert_eq!(store.get_state(&r1).unwrap().to_ssz(), s1.to_ssz()); - /// Set up finalized and justified checkpoints in metadata. - fn set_checkpoints(backend: &dyn StorageBackend, finalized: Checkpoint, justified: Checkpoint) { - let mut batch = backend.begin_write().expect("write batch"); - batch - .put_batch( - Table::Metadata, - vec![ - (KEY_LATEST_FINALIZED.to_vec(), finalized.to_ssz()), - (KEY_LATEST_JUSTIFIED.to_vec(), justified.to_ssz()), - ], - ) - .expect("put checkpoints"); - batch.commit().expect("commit"); + // A cold store (empty cache, shared backend) reconstructs from the diff, + // byte-identically. + let cold = Store::test_store_with_backend(backend.clone()); + let reconstructed = cold.get_state(&r1).expect("reconstructs from diff"); + assert_eq!(reconstructed.to_ssz(), s1.to_ssz()); } #[test] - fn fallback_pruning_removes_old_states_and_blocks() { + fn get_state_reconstructs_across_multiple_diffs() { let backend = Arc::new(InMemoryBackend::new()); let mut store = Store::test_store_with_backend(backend.clone()); - // Use roots that are within the retention window as finalized/justified - let finalized_root = root(0); - let justified_root = root(1); - set_checkpoints( - backend.as_ref(), - Checkpoint { - slot: 0, - root: finalized_root, - }, - Checkpoint { - slot: 1, - root: justified_root, - }, - ); + // Snapshot s0, then two chained diffs s1 -> s2; each block root is the + // hash of its header, as in production. + let s0 = sample_state(0, H256::ZERO, vec![]); + let r0 = s0.latest_block_header.hash_tree_root(); + insert_header(backend.as_ref(), r0, 0, H256::ZERO); + insert_snapshot(backend.as_ref(), r0, &s0); - // Insert more than STATES_TO_KEEP headers + states. - let total_states = STATES_TO_KEEP + 5; - for i in 0..total_states as u64 { - insert_header(backend.as_ref(), root(i), i); - insert_state(backend.as_ref(), root(i)); - } + let s1 = sample_state(1, r0, vec![r0]); + let r1 = s1.latest_block_header.hash_tree_root(); + insert_header(backend.as_ref(), r1, 1, r0); + store.insert_state(r1, s1.clone()).expect("insert state"); - assert_eq!(count_entries(backend.as_ref(), Table::States), total_states); - assert_eq!( - count_entries(backend.as_ref(), Table::BlockHeaders), - total_states - ); - - // Use the last inserted root as head. Calling update_checkpoints with - // head_only triggers the fallback path (finalization doesn't advance). - let head_root = root(total_states as u64 - 1); - store - .update_checkpoints(ForkCheckpoints::head_only(head_root)) - .expect("update_checkpoints should succeed"); - - // update_checkpoints no longer prunes states/blocks inline — the caller - // must invoke prune_old_data() separately (after a block cascade completes). - assert_eq!(count_entries(backend.as_ref(), Table::States), total_states); - - 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. - assert_eq!( - count_entries(backend.as_ref(), Table::States), - STATES_TO_KEEP + 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)); + let s2 = sample_state(2, r1, vec![r0, r1]); + let r2 = s2.latest_block_header.hash_tree_root(); + insert_header(backend.as_ref(), r2, 2, r1); + store.insert_state(r2, s2.clone()).expect("insert state"); - // Headers and bodies are never pruned, so all are retained. - assert_eq!( - count_entries(backend.as_ref(), Table::BlockHeaders), - total_states - ); + // Neither child is an anchor, so a cold store reconstructs s2 by walking + // the diff chain back to the s0 snapshot. + assert!(!has_key(backend.as_ref(), Table::States, &r1)); + assert!(!has_key(backend.as_ref(), Table::States, &r2)); + let cold = Store::test_store_with_backend(backend.clone()); + let reconstructed = cold.get_state(&r2).expect("reconstructs across diffs"); + assert_eq!(reconstructed.to_ssz(), s2.to_ssz()); } #[test] - fn fallback_pruning_no_op_within_retention() { + fn insert_state_snapshots_only_on_boundary_crossing() { let backend = Arc::new(InMemoryBackend::new()); let mut store = Store::test_store_with_backend(backend.clone()); - set_checkpoints( - backend.as_ref(), - Checkpoint { - slot: 0, - root: root(0), - }, - Checkpoint { - slot: 0, - root: root(0), - }, - ); - - // Insert exactly STATES_TO_KEEP entries (no excess) - for i in 0..STATES_TO_KEEP as u64 { - insert_header(backend.as_ref(), root(i), i); - insert_state(backend.as_ref(), root(i)); - } + let s0 = sample_state(SNAPSHOT_ANCHOR_INTERVAL - 1, H256::ZERO, vec![]); + let r0 = s0.latest_block_header.hash_tree_root(); + insert_header(backend.as_ref(), r0, s0.slot, H256::ZERO); + insert_snapshot(backend.as_ref(), r0, &s0); - // Use the last inserted root as head - let head_root = root(STATES_TO_KEEP as u64 - 1); - store - .update_checkpoints(ForkCheckpoints::head_only(head_root)) - .expect("update checkpoints"); - store.prune_old_data(); + // Crossing the interval boundary records an anchor. + let s1 = sample_state(SNAPSHOT_ANCHOR_INTERVAL, r0, vec![r0]); + let r1 = s1.latest_block_header.hash_tree_root(); + insert_header(backend.as_ref(), r1, s1.slot, r0); + store.insert_state(r1, s1.clone()).expect("insert state"); + assert!(has_key(backend.as_ref(), Table::States, &r1)); - // 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 - ); + // A non-crossing child does not. + let s2 = sample_state(SNAPSHOT_ANCHOR_INTERVAL + 1, r1, vec![r0, r1]); + let r2 = s2.latest_block_header.hash_tree_root(); + insert_header(backend.as_ref(), r2, s2.slot, r1); + store.insert_state(r2, s2.clone()).expect("insert state"); + assert!(!has_key(backend.as_ref(), Table::States, &r2)); } // ============ PayloadBuffer Tests ============ @@ -2634,6 +2630,17 @@ mod tests { assert!(store.get_signed_block(&root).is_none()); } + /// The bootstrap anchor is stored as a full snapshot in `States`, the base of + /// every diff chain that reconstruction terminates at. + #[test] + fn from_anchor_state_stores_bootstrap_snapshot() { + 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)); + } + // ============ from_db_state Tests ============ #[test]