Skip to content

feat(storage): diff beacon states against their snapshot, not their parent - #684

Merged
MegaRedHand merged 1 commit into
mainfrom
feat/beacon-diff-against-snapshot
Oct 27, 2026
Merged

MegaRedHand merged 1 commit into
mainfrom
feat/beacon-diff-against-snapshot

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

Stacked on #683.

Today a beacon non-anchor state is stored as an xdelta3 delta against its parent. A cold read of a state late in an epoch therefore folds up to 31 deltas in sequence, each over a full encoded state. This PR diffs every non-anchor beacon state against the nearest snapshot on its branch instead, so a cold read is one snapshot plus one delta.

What Changed

  • Writer (state_writer.rs): the writer's memo changes from the parent's encoded bytes (encoded_memo, encoded_parent_bytes) to the current snapshot's bytes (snapshot_memo). A new last_base records which snapshot the last written state belongs to.
    • Common case: the parent is the state written just before, so finding the base needs no read.
    • Miss (a restart, or a fork off an older block): nearest_snapshot_root follows StateDiffs frames to the first root with no diff row. On a beacon directory a state is a snapshot or a diff, never both. It never probes States with contains, since on RocksDB that reads the whole blob value.
  • plan_beacon_state_write: the frame now names the snapshot as its base_root. StateWrite.encoded is Some only for snapshots.
  • Reader: unchanged.
  • Docs:
    • the state writer module doc, insert_state, beacon_state_delta.rs and the StateDiffs table doc;
    • BEACON_SNAPSHOT_INTERVAL / snapshot_interval, which now explain that the interval bounds delta size and snapshot frequency rather than the length of the fold (the value is unchanged);
    • docs/data_storage.md.

Correctness / Behavior Guarantees

  • No DB_VERSION bump: every frame names its own base, so a directory holding parent-linked diffs from an earlier build still reads. The writer's walk also handles such rows when picking the snapshot for a new diff.
  • One writer thread is still required: a diff's snapshot must be committed before the diff is planned, and the reader's walks rely on the commit order.
  • Lean is unchanged: its StateDiff rebuilds historical_block_hashes from the parent link, which only holds when the base is the parent.
  • Trade-off: a diff now carries every change since its snapshot, so diffs grow through the epoch. The total size of an epoch's diffs grows roughly with the square of the number of slots instead of linearly. My estimate from the measured ≈270 KiB per-slot diff is up to about the size of a snapshot per epoch. That estimate is unmeasured. With feat(storage): prune finalized states with non-overlapping range deletes #683's pruning, only the last few epochs are kept, which is why this PR is stacked on it.

Tests Added / Run

  • Added in store.rs:
    • every_beacon_diff_in_an_interval_names_the_intervals_snapshot
    • two_children_of_a_mid_interval_block_both_diff_against_the_snapshot
    • a_restart_mid_interval_keeps_diffing_against_the_snapshot: exercises the memo miss.
    • the_snapshot_walk_follows_old_parent_linked_rows_to_the_snapshot: uses hand-written parent-linked rows.
  • Updated:
    • a_cold_beacon_state_reads_back_as_its_snapshot_plus_one_delta, renamed from the old fold test;
    • the plan_beacon_state_write unit tests in state_writer.rs.
  • Lib tests passing (--profile release-fast --lib):
    • ethlambda-storage: 162
    • ethlambda-state-transition: 325
  • make lint is clean.
  • Not run locally: spec-test suites, and a measurement of real diff sizes on a mainnet follower.

Related Issues / PRs

✅ Verification Checklist

  • Ran make fmt: clean
  • Ran make lint (clippy with -D warnings): clean
  • Ran make test: lib tests of the affected crates only; spec suites not run locally

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

I'll review this PR which changes beacon state storage from parent-linked diffs to snapshot-based diffs. This is a significant storage optimization where diffs are computed against the nearest snapshot rather than the immediate parent.

Overall Assessment

The PR is well-structured with good documentation and comprehensive tests. The core idea—making beacon state reads O(1) in snapshot+delta rather than O(n) in delta chain length—is sound. However, I found several issues ranging from correctness concerns to performance pitfalls.


Critical Issues

1. nearest_snapshot_root can infinite loop on malformed data (crates/storage/src/state_writer.rs)

Lines 341-358: The loop follows base_root chains but has no cycle detection. A corrupted or maliciously crafted database with a cyclic base_root chain will spin forever.

pub(crate) fn nearest_snapshot_root(view: &dyn StorageReadView, root: &H256) -> Option<H256> {
    let mut cursor = *root;
    loop {
        // ...
        match base {
            Some(base_root) => cursor = base_root,  // No check that base_root != cursor
            None => return Some(cursor),
        }
    }
}

Fix: Add a cycle guard. Even a simple visited-set or a max-iterations bound would work:

let mut iterations = 0;
const MAX_INTERVAL: u64 = preset::SLOTS_PER_EPOCH * 2; // generous upper bound
loop {
    iterations += 1;
    if iterations > MAX_INTERVAL { return None; /* or panic */ }
    // ...
}

This is particularly important because the function documents backward compatibility with "parent-linked" diffs—chains that could theoretically be long (though bounded by slot number in practice, corruption is still possible).


2. load_snapshot_base uses expect on begin_read twice without cleanup concern (crates/storage/src/state_writer.rs)

Lines 701-713: Two separate read views are opened in the same function. While the comment notes reentrancy concerns, there's no discussion of resource limits. If nearest_snapshot_root walks a long chain and then we open a second view, we're holding two read transactions simultaneously.

More critically, line 706:

let view = self.backend.begin_read().expect("read view");

This expect is fine for the first read, but if the second begin_read at line 713 fails, we've already potentially done significant work. However, the real issue is that between lines 706 and 713, the first view is dropped—but what if the backend has a limit on concurrent read views? This isn't a bug per se but a resource management concern.


3. block_slot_in failure mode in nearest_snapshot_root is ambiguous (crates/storage/src/state_writer.rs)

Line 343-344:

let slot = block_slot_in(view, Chain::Beacon, &cursor)?;

If block_slot_in returns None, the ? returns None from the whole function, making nearest_snapshot_root return None. But this conflates "no block for this root" (expected for unknown roots) with "database error". The function's docstring says:

None when a root on the way has no block row, which is how a root the store never heard of looks.

However, block_slot_in could return None for other reasons (e.g., database corruption where a diff exists but no block). The function should distinguish "missing block" from "missing diff" more carefully, or at least document that a diff without a corresponding block is treated as unknown.


Security Issues

4. beacon_state_delta::unframe is called without length validation (crates/storage/src/state_writer.rs)

Line 347-349:

let base = view
    .read_with(Table::StateDiffs, &key, |frame| {
        beacon_state_delta::unframe(frame).0
    })
    .expect("read");

The frame is passed directly to unframe. If the database contains a corrupted or maliciously short frame, unframe may panic or return garbage. While this is storage-internal data, in a blockchain context, storage should be treated as untrusted (crash recovery, disk corruption, etc.).

Recommendation: unframe should return Option or Result, and this call site should handle errors gracefully rather than propagating panics via expect.


Performance Issues

5. snapshot_memo replacement strategy causes repeated reads on snapshot alternation (crates/storage/src/state_writer.rs)

Lines 695-713: When load_snapshot_base finds a different snapshot than memoized, it reads the full snapshot bytes from States. If the chain alternates between two forks with different snapshot bases (e.g., near a boundary), this causes repeated large reads.

The scenario: slot interval-1 on fork A (snapshot at 0), slot interval-1 on fork B (snapshot at 0 but different branch). Actually, with the current logic, both would use snapshot at 0. But consider a reorg that alternates—each switch causes a full snapshot read.

Mitigation: Consider an LRU or at least a small cache for recent snapshot bases. The current Option<(H256, Vec<u8>)> is intentionally minimal, but a LruCache<H256, Vec<u8>> with capacity 2-3 would handle common reorg patterns without significant memory overhead.


Correctness Concerns

6. plan_beacon_state_write sets encoded: None for diffs, but test expects this (crates/storage/src/state_writer.rs)

Line 532:

encoded: None,

This is correct per the new design (only snapshots memoize bytes), but the change from Some(target) to None is subtle. The test at line 934-937 verifies this:

assert!(
    plan.encoded.is_none(),
    "only a snapshot is a diff base, so a diff keeps no memo bytes"
);

However, I want to flag that any code path that previously relied on encoded being Some for all beacon writes will now break. The PR doesn't show such callers, but this is a contract change that should be highlighted in commit messages.


7. last_base tracks (state_root, snapshot_root) but state_root may not match latest_block_header.hash_tree_root() (crates/storage/src/state_writer.rs)

Line 579-580:

last_base: Option<(H256, H256)>,

The first component is "the last beacon state this thread wrote" (from root at line 641), but root is the block root passed to insert_state, not necessarily the state root. Let me check...

Looking at insert_state usage in tests: insert_state(root, state) where root is beacon_chain_root(slot) (the block root). And in write_state, root comes from request.root. So last_base.0 is a block root, not a state root.

But load_snapshot_base is called with parent_root which is:

Line 628:

let parent_root = beacon_state.latest_block_header().parent_root;

This is a block root (the parent block's root). So the comparison last_root == parent_root at line 699 compares block roots, which is correct.

However, the docstring says:

Root and [encode_state_value] bytes of the snapshot the newest beacon diffs are taken against

And for last_base:

The last beacon state this thread wrote and the snapshot root it belongs to

The naming is slightly confusing ("last beacon state" but it's actually the last block root). This is consistent with the rest of the codebase (states are keyed by block root), but the documentation could be clearer.


Rust Best Practices

8. nearest_snapshot_root should use while let or explicit termination (crates/storage/src/state_writer.rs)

The loop with match is fine, but given the potential for infinite looping (Item 1), consider making termination more explicit:

pub(crate) fn nearest_snapshot_root(view: &dyn StorageReadView, root: &H256) -> Option<H256> {
    let mut cursor = *root;
    while let Some(slot) = block_slot_in(view, Chain::Beacon, &cursor) {
        let key = encode_slot_root_key(slot, &cursor);
        match view.read_with(Table::StateDiffs, &key, |frame| beacon_state_delta::unframe(frame).0)
            .expect("read") 
        {
            Some(base_root) if base_root != cursor => cursor = base_root,
            Some(_) => return None, // self-reference, corrupted
            None => return Some(cursor),
        }
    }
    None
}

This also adds the self-reference check.


9. load_snapshot_base has redundant begin_read on cache hit path (crates/storage/src/state_writer.rs)

Lines 695-713: The function opens a read view for nearest_snapshot_root even when last_base matches. But then if the snapshot memo already has the right snapshot, it returns early without using the view. The view is dropped at line 695's match end.

Actually, looking more carefully: the nearest_snapshot_root call is inside the _ => arm, so it's only executed on miss. The structure is correct. However, the early return at line 703-705:

if self.snapshot_memo.as_ref().is_some_and(|(root, _)| *root == snapshot_root) {
    return snapshot_root;
}

This happens after the nearest_snapshot_root call. If we're going to check the memo anyway, we could check last_base and snapshot_memo first before opening any read view. But the logic is: we need snapshot_root to check against the memo, and snapshot_root comes from either last_base or nearest_snapshot_root. This is acceptable.


Test Issues

10. Test the_snapshot_walk_follows_old_parent_linked_rows_to_the_snapshot uses dummy data that doesn't round-trip (crates/storage/src/store.rs)

Lines 6322-6350: The test inserts frames with empty deltas:

crate::beacon_state_delta::frame(a, 12, 0, b""),

And snapshot data b"snapshot". This is fine for testing the walk logic in isolation, but it doesn't verify that unframe or the delta encoding actually work with real data. The test is explicitly labeled as testing the walk, so this is acceptable, but consider adding a comment that the delta is intentionally empty.

More concerning: the target_len: 0 with empty delta—will beacon_state_delta::unframe handle this? Looking at the implementation (not shown in diff), if unframe expects a valid SSZ-encoded delta, an empty slice might panic. The test passes, so presumably it's fine.


11. Missing test: reorg across snapshot boundary with different snapshot bases (crates/storage/src/store.rs)

The tests cover:

  • Linear chain with snapshot-based diffs
  • Fork off mid-interval block (both children diff against same snapshot)
  • Restart mid-interval
  • Backward compatibility with parent-linked diffs

Missing: A reorg where the new fork has a different snapshot base than the old fork. For example:

  • Chain A: slots 0 (snapshot), 1, 2, ..., interval (snapshot)
  • Chain B forks at slot interval/2 but builds to slot interval + interval/2

On chain B, slot interval should be a snapshot (interval boundary), and slots after should diff against that snapshot, not chain A's snapshot at 0. This tests the is_anchor logic with parent_slot crossing interval boundaries on a fork.


Documentation Issues

12. Comment typo in state_writer.rs (crates/storage/src/state_writer.rs)

Line 261:

/// plan_beacon_state_write`] names the snapshot of its interval, so

Missing opening bracket: [ before plan_beacon_state_write.


Minor Issues

13. encode_slot_root_key is called with snapshot_root but could use any root (crates/storage/src/state_writer.rs)

Line 711:

let key = encode_slot_root_key(slot, &snapshot_root);

This is correct because snapshot_root is the root being looked up in States. But note that States is keyed by slot || root where root is the block root. The snapshot at slot 0 with root beacon_chain_root(0) is correctly keyed.


14. reconstruct_beacon_state_bytes docstring still mentions "parent" (crates/storage/src/state_writer.rs)

Line 275:

/// Mirrors [`reconstruct_state`]'s walk-then-replay shape for lean, but

Not a parent reference, but the function name and docstring could be updated to emphasize it now handles both parent-linked and snapshot-based deltas. The implementation already does (follows base_root generically), which is correct.


Summary

Priority Item Location
Critical Add cycle detection to nearest_snapshot_root state_writer.rs:341-358
Critical beacon_state_delta::unframe should validate input state_writer.rs:347-349
High Distinguish "no block" from "no diff" errors state_writer.rs:343-344
Medium Consider snapshot cache for reorg patterns state_writer.rs:695-713
Medium Add reorg-across-snapshot-boundary test store.rs tests
Low Fix docstring typo state_writer.rs:261
Low Clarify last_base contains block roots, not state roots state_writer.rs:579-580

The PR is fundamentally correct and the optimization is valuable. The critical fix needed is cycle detection in nearest_snapshot_root before this is safe to merge.


Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

No correctness or consensus-layer bug jumps out in this PR. The change is storage-local, and the new beacon snapshot-base scheme looks internally consistent with the reader, pruning model, and backward-compat path.

What Looks Good

  • crates/storage/src/state_writer.rs:285 keeps beacon reconstruction format-driven by following each frame’s base_root, which preserves compatibility with older parent-linked rows.
  • crates/storage/src/state_writer.rs:620 updates the writer and reader together: writes now diff against the nearest snapshot, while reads still tolerate either one-hop or multi-hop chains.
  • crates/storage/src/state_writer.rs:683 memoizes the active snapshot base in the writer thread, avoiding repeated large snapshot reads in the hot path.
  • crates/storage/src/store.rs:6272 and crates/storage/src/store.rs:6324 add the two tests I’d most want here: old-format traversal and full-interval reconstruction.

Review Notes

  • I did not find changes touching fork choice, attestation validation, justification/finalization, state transition logic, XMSS verification, or SSZ schema semantics directly.
  • Security-wise, this does not appear to widen trust boundaries or add unsafe code; the main risk area is storage correctness, and the backward-compat handling looks deliberate.
  • Performance direction looks positive: beacon reads become snapshot + one delta in the new format, and writes avoid reloading the same snapshot repeatedly.

Minor Concern

  • crates/storage/src/state_writer.rs:342 can loop forever if storage is corrupted with a cycle in StateDiffs base_root links. That may be acceptable for trusted local DB assumptions, but if you want harder corruption resistance, add a hop bound or visited-set guard in nearest_snapshot_root, and possibly also in reconstruct_beacon_state_bytes at crates/storage/src/state_writer.rs:285.

Overall, this looks good to me.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review of PR 684: diff beacon states against their snapshot

I read the diff only and did not build or run the tests. The design is sound and I found no blocking bugs.

What I checked and found correct

  • Writer state.
    • snapshot_memo and last_base stay consistent. The None arm of the commit step records the memo's own root as the base. load_snapshot_base re-checks the memo root against the resolved snapshot root before reusing it.
    • A fork off an older interval or a restart takes the miss path, which reloads the snapshot's States row. The restart and sibling tests cover both.
  • nearest_snapshot_root.
    • It correctly relies on "snapshot xor diff" for beacon directories, so a root with no StateDiffs row is a snapshot.
    • It avoids probing States on RocksDB, as the doc comment explains.
    • It reuses the caller's read view, which avoids the reentrant-lock deadlock.
    • It still follows base_root across multiple hops, which keeps directories written by the old parent-linked build readable without a DB_VERSION bump. The hand-written-row test covers this.
  • Pruning. Diffs in an interval now name that interval's snapshot, which sits at or after the interval start. prune_finalized_states deletes only below the finalized block's interval start, so a retained diff should never lose its base. A snapshot written for a first block after skipped slots, or for a mid-interval checkpoint anchor, still falls inside the retained range. I did not open the pruning code, so this rests on the PR's description of it.
  • Lean. last_base and the memo are untouched on the lean arm because of the early return, and lean's parent-linked diffs are unchanged.
  • Memory. Only snapshots are now kept in the memo (encoded: None for diffs), so the memo holds the same single large buffer as before.

Minor points

  1. Unmeasured cost (state_writer.rs, plan_beacon_state_write). The PR itself says the quadratic growth in diff size is an estimate. The writer still runs xdelta3 plus the verify-decode, and in debug builds the debug_assert that decodes the delta, on every import. Per-import encode time and bytes written now grow across the epoch. I'd measure StateDiffs bytes per epoch and writer latency on a real beacon run before relying on the "about one snapshot per epoch" figure.
  2. Memo thrash on forks (load_snapshot_base). If two live forks sit in different intervals and their imports alternate, each import reloads a snapshot of over 100 MB from RocksDB. That is correct and rare, but a debug or metric on memo misses would show it if it ever happens.
  3. Stale doc (state_writer.rs, reconstruct_beacon_state_bytes). The text still says it walks back "to the nearest States snapshot and folding deltas forward". That remains true, but now the fold is one delta on new data, so the "folding deltas" wording could say so.
  4. Debug-only assertion (state_writer.rs, load_snapshot_base). debug_assert_eq!(*memo_root, root) is the only check that the memo matches the planned snapshot. That is reasonable. The expects on a missing snapshot row would panic the writer thread, the same failure mode as before.

Overall this looks good: the tests cover the common, restart, fork and legacy-row paths, and the docs are consistent with the code.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@MegaRedHand
MegaRedHand added this pull request to stack #685 October 22, 2026 02:09
MegaRedHand added a commit that referenced this pull request Oct 22, 2026
…arent (#684)

Squash of feat/beacon-diff-against-snapshot at a5da3a2 (merge-base 43143ae).
@pablodeymo
pablodeymo force-pushed the feat/beacon-diff-against-snapshot branch from cdf898e to 4d4ca38 Compare October 27, 2026 19:42
@MegaRedHand
MegaRedHand force-pushed the feat/beacon-diff-against-snapshot branch from 4d4ca38 to 69db250 Compare October 27, 2026 20:24
Base automatically changed from feat/prune-finalized-states to main October 27, 2026 20:26
…arent

A beacon non-anchor state was an xdelta3 delta against its parent, so a cold
read folded up to an epoch of deltas in sequence, each over a full encoded
state. Diffing every state against the nearest snapshot on its branch makes a
cold read one snapshot plus one delta, whatever the interval.

The writer keeps the current snapshot's bytes in memory instead of the
parent's, and remembers which snapshot the last written state belongs to, so
the common case reads nothing extra. On a miss (a restart, or a fork off an
older block) it follows StateDiffs frames to the first root with no diff row,
which is the snapshot on a beacon directory.

The reader is unchanged: every frame names its own base, so chains written
parent-linked by an earlier build still fold, and no DB_VERSION bump is needed.

Diffs grow through the epoch, since each carries every change since its
snapshot. Pruning finalized states keeps only a few epochs of them on disk.

Lean keeps parent-linked diffs: its StateDiff regenerates
historical_block_hashes from the parent link.

(cherry picked from commit a5da3a2)
@MegaRedHand
MegaRedHand force-pushed the feat/beacon-diff-against-snapshot branch from 69db250 to 4db207b Compare October 27, 2026 20:26
@MegaRedHand
MegaRedHand merged commit 7992359 into main Oct 27, 2026
1 check passed
@MegaRedHand
MegaRedHand deleted the feat/beacon-diff-against-snapshot branch October 27, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants