This doc explains how ethlambda saves data. Especially,
the split between the fork choice Store and the StorageBackend trait,
what each of the thirteen tables holds, and which data is in-memory only.
All chain data flows through a single high-level type, the Store
(crates/storage/src/store.rs), which persists it through a small pluggable
key-value abstraction, the StorageBackend trait
(crates/storage/src/api/traits.rs). Two backends implement the trait:
RocksDB for production and an in-memory backend for tests.
Everything persisted is SSZ-encoded bytes.
LAYERED ARCHITECTURE
────────────────────
┌──────────────────┐ ┌──────────────────┐
│ BlockChain actor │ │ P2P actor │
└────────┬─────────┘ └────────┬─────────┘
│ (cloned Store: shared │
│ backend + buffers) │
▼ ▼
┌─────────────────────────────────────────────────┐
│ Store │
│ crates/storage/src/store.rs │
│ │
│ • table selection, key encoding, SSZ codec │
│ • snapshot-vs-diff decisions, pruning │
│ • in-memory attestation buffers + state cache │
└────────────────────────┬────────────────────────┘
│ begin_read() / begin_write()
▼
┌─────────────────────────────────────────────────┐
│ StorageBackend trait │
│ crates/storage/src/api/traits.rs │
│ │
│ raw bytes in, raw bytes out │
└───────────┬─────────────────────────┬───────────┘
│ (production) │ (test)
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ RocksDBBackend │ │ InMemoryBackend │
│ (production, one │ │ (tests, HashMap per │
│ column family per │ │ table, lost on │
│ table) │ │ drop) │
└───────────────────────┘ └───────────────────────┘
The StorageBackend trait knows nothing about consensus types. It moves raw
bytes in and out of named tables:
begin_read()returns aStorageReadView. Its one required lookup isread(table, key, read_fn), which lends the value toread_fnwithout copying it;get(an owned copy) andcontainsare built on it, andStorageReadViewExt::read_withdecodes straight from the borrowed bytes. It also hasprefix_iterator(table, prefix).begin_write()returns aStorageWriteBatchwithput_batch,delete_batch, andcommit(). A batch stages puts and deletes across multiple tables and applies them atomically on commit.
The two implementations live in crates/storage/src/backend/:
| Backend | Details |
|---|---|
RocksDBBackend |
One column family per table. Writes go through a native WriteBatch with sync=false (no fsync per commit). States and StateDiffs keep values of 4 KiB and up in LZ4-compressed blob files, so compaction never rewrites a snapshot and a lookup miss never reads one. |
InMemoryBackend |
A HashMap per table behind an RwLock. Its prefix_iterator sorts keys lexicographically to match RocksDB's iteration order, because pruning relies on slot-ordered early-stop scans (see Key encoding). |
The Store owns everything the backend doesn't: which table each datum goes
to, how keys are built, SSZ encoding/decoding, when to write a full state
snapshot versus a diff, and when to prune. It is the only writer to the
backend.
A naming subtlety: the Store struct lives in the storage crate
(crates/storage/src/store.rs), while the fork choice logic that drives it
(on_block, on_tick, update_head, ...) lives in
crates/blockchain/src/store.rs as free functions taking &mut Store.
Store is Clone, and every field is an Arc, so clones are cheap and all
clones share the same backend and the same in-memory pools. At startup
(bin/ethlambda/src/main.rs) one Arc<RocksDBBackend> is opened, one Store
is built from it, and clones are handed to the BlockChain and P2P actors.
INSIDE THE STORE
────────────────
┌───────────────────────────────── Store ──────────────────────────────────┐
│ │
│ PERSISTED (via backend) IN-MEMORY ONLY │
│ ─────────────────────── ────────────── │
│ ┌────────────────────────────────┐ ┌──────────────────────┐ │
│ │ BlockHeaders │ │ new_payloads │ │
│ │ BlockBodies │ │ (pending aggregated │ │
│ │ BlockProof │ │ attestations) │ │
│ │ BlockRoots │ │ known_payloads │ │
│ │ States │ │ (fork-choice-active │ │
│ │ StateDiffs │ │ attestations) │ │
│ │ Metadata │ │ gossip_signatures │ │
│ │ LiveChain │ │ (raw XMSS sigs │ │
│ │ DataColumns │ │ awaiting │ │
│ │ PendingDataColumns │ │ aggregation) │ │
│ │ ExecutionPayloadEnvelopes │ │ state_cache (LRU) │ │
│ │ BlockTimeliness │ └──────────────────────┘ │
│ │ BeaconUnrealizedJustifications │ │
│ └────────────────────────────────┘ │
│ Survives restarts, except │
│ PendingDataColumns, which Lost on restart. │
│ is cleared at startup. │
└──────────────────────────────────────────────────────────────────────────┘
The thirteen variants of the Table enum (crates/storage/src/api/tables.rs):
| Table | Key | Value | Pruned? |
|---|---|---|---|
BlockHeaders |
root | BlockHeader, or a whole beacon block |
never |
BlockBodies |
root | BlockBody (lean only) |
never |
BlockProof |
slot ‖ root | aggregate proof (MultiMessageAggregate) |
yes: finalized older than ~1 day |
BlockRoots |
slot | block root (H256) |
never |
States |
slot ‖ root | full State snapshot |
yes: finalized snapshot intervals |
StateDiffs |
slot ‖ root | StateDiff |
yes: finalized snapshot intervals |
Metadata |
string | SSZ scalars | never |
LiveChain |
slot ‖ root | parent_root |
yes: below finalized |
DataColumns |
slot ‖ root ‖ column_index | DataColumnSidecar (SSZ-encoded) |
no (see DataColumns below) |
PendingDataColumns |
slot ‖ root ‖ column_index | DataColumnSidecar (SSZ-encoded), unverified |
yes: on replay, below finalized, and wholly at startup |
ExecutionPayloadEnvelopes |
slot ‖ root | gloas SignedExecutionPayloadEnvelope (SSZ), verified only |
no: beacon block rows are kept too |
BlockTimeliness |
root | one byte per timeliness deadline (0 or 1), gloas blocks only | no |
BeaconUnrealizedJustifications |
root | (slot, Checkpoint) (beacon only) |
yes: below the finalized block's own slot (see below) |
Four key layouts are used:
- Root-keyed tables use the 32-byte SSZ encoding of the block root
(
root.to_ssz()). - Slot-prefixed tables (
BlockProof,LiveChain,States,StateDiffs) useencode_slot_root_key: an 8-byte big-endian slot followed by the 32-byte root. Big-endian means lexicographic key order equals numeric slot order, so pruning can iterate from the start of the table and stop at the first key past its cutoff, or drop a whole slot range with one range delete. ForStatesandStateDiffsthe slot is the slot of the block the state belongs to (the slot in that root'sBlockHeadersrow, equal to the state'slatest_block_header.slot), notstate.slot(): a beacon anchor state can be advanced past its block. A reader that only has a root looks the slot up inBlockHeaders; a root with no block row has no state. - Slot-prefixed, then column (
DataColumns) extends the sameslot ‖ rootprefix with an 8-byte big-endiancolumn_index, viadata_column_key. A block's own sidecars therefore share one lexicographic run under theirslot ‖ root, so a prefix scan over just that pair (whatdata_column_indices_forand the by-range handler both do; see DataColumns) recovers every column of one block without touching any other block's. - Slot-only (
BlockRoots) usesencode_block_root_key: just the 8-byte big-endian slot, since the value already holds the root. This table is never pruned, so the ordering buys nothing here; it is kept only for consistency with the other slot-prefixed keys. - Root-keyed with the slot in the value (
BeaconUnrealizedJustifications) is the inverse trade from the slot-prefixed tables above: both of this table's readers (get_voting_source,is_ffg_competitive) look up a root with no slot in hand, so keying by root alone keeps that lookup a single point read, and the slot rides along in the value purely for the pruner to read back. The cost is on the pruner's side instead: with no slot prefix to stop early on, it decodes every row's value to compare its slot, cheap here since the table only ever holds the unfinalized window's worth of leaves between two finalizations. See BeaconUnrealizedJustifications below.
root → BlockHeader. Written for every block, including the genesis/anchor
block, and never pruned: headers are the permanent record of the chain.
Headers are also read back during state reconstruction (see
State Storage).
On a beacon directory this row holds the whole SignedBeaconBlock
instead, prefixed with a one-byte fork selector the way a States value is,
since a beacon block's shape varies by fork and SSZ carries no type tag. The
two shapes never coexist in one table: a data directory holds one chain for
its whole life (see Chain).
Store::block_entry is the chain-agnostic reader for a stored block's slot
and parent root, and is what the fork-choice tree walk and the BlockRoots
index diff share. On the beacon arm it decodes the whole block to reach those
two fields, so a caller walking a chain of them should build
Store::block_index once instead, which reads the same links out of
LiveChain.
root → BlockBody, lean only. Written for every block except those
with an empty body: if header.body_root == EMPTY_BODY_ROOT (the hash tree
root of BlockBody::default()), nothing is stored and reads synthesize
BlockBody::default(). This covers the genesis block and checkpoint sync
anchors, whose bodies are either empty or unavailable. Never pruned.
The split exists so a header-only query need not pay for the body, and so an empty body can be left out entirely. A beacon block has neither property, and nothing reads a beacon header without its block, so the beacon arm writes no row here at all: a second row would only add a write and a way for the two to disagree.
slot ‖ root → MultiMessageAggregate. This table stores the block's merged
aggregate proof blob. It is keyed by slot ‖ root so that pruning can scan in
slot order and stop early.
Stored separately from headers/bodies because the genesis block has no proof.
get_signed_block synthesizes an empty proof for the slot-0 anchor only; for
any other block a missing entry (a pruned finalized block) surfaces as None
rather than a fabricated block.
This is the one block table that is pruned; see Pruning.
slot → H256, the canonical block root at each slot. Rewritten on every head
update inside update_checkpoints: block_root_index_changes walks the old
and new head's branches back to their common ancestor, deleting the slots
that leave the canonical chain and writing the ones that join it. A reorg
therefore touches only the affected slot range, not the whole table. Never
pruned.
That walk never writes the common ancestor itself, and at bootstrap the head
is the anchor, so the anchor's own slot is written separately: by init_store
on a lean directory, and by index_beacon_anchor (called from the beacon
get_forkchoice_store) on a beacon one, keyed by the anchor block's slot
rather than the anchor state's. A beacon directory bootstrapped before
index_beacon_anchor existed has no row for its anchor.
Backs get_signed_blocks_by_slot_range, which serves BlocksByRange requests
over req/resp (crates/net/p2p/src/req_resp/handlers.rs). It does not
back the RPC GET /lean/v0/blocks/:slot endpoint: that handler resolves a
slot through the head state's historical_block_hashes instead
(resolve_slot in crates/net/rpc/src/blocks.rs), so a block on a side fork
is reachable there only by root, never by slot.
slot ‖ root → State (full SSZ snapshot). Holds full-state snapshots
only: the bootstrap anchor written at initialization, plus one anchor
whenever a block crosses a SNAPSHOT_ANCHOR_INTERVAL-slot boundary. These
anchors are the base every diff chain resolves against, so reconstruction
always terminates. Pruned below the finalized block's snapshot interval (see
Pruning), which never removes a snapshot a kept state's chain
still needs.
The genesis validator registry is constant for the life of the chain
(validators is fixed at genesis; the lean STF never mutates it), but it has
no table of its own: it rides inside every States snapshot alongside
config, which StateDiff reconstruction relies on (see
State Storage).
slot ‖ root → StateDiff. A diff written for every non-anchor state,
pruned together with States. Lean's is parent-linked; a beacon diff is a
byte delta against the nearest snapshot on its branch, so a beacon state is one
snapshot plus one delta. See State Storage for what a
diff contains and how states are rebuilt.
String keys mapping to (mostly) SSZ-encoded scalars — the Store's own
persistent fields. Four are written on every directory, whichever chain it
holds; the rest are chain-specific, since lean and a beacon directory keep
different checkpoints.
| Key | Type | Chain | Meaning |
|---|---|---|---|
db_version |
u64 |
both | On-disk format version this directory was written at |
chain |
raw byte | both | Which consensus protocol this directory holds (Chain) |
preset |
raw byte | both | Which SSZ preset the writing build used (Preset) |
time |
u64 |
both | The store clock, as a UNIX timestamp in milliseconds |
config |
Config |
both | The node's runtime configuration |
head |
H256 |
both | Current fork choice head |
anchor_slot |
u64 |
both | The slot this directory's chain begins at |
states_pruned_below |
u64 |
both | Exclusive upper bound of the States/StateDiffs slots already range-deleted; absent means 0 |
safe_target |
H256 |
lean | Current safe target (see lmd_ghost.md) |
latest_justified |
Checkpoint |
both | Latest justified checkpoint |
latest_finalized |
Checkpoint |
both | Latest finalized checkpoint |
beacon_unrealized_justified |
Checkpoint |
beacon | Unrealized justified checkpoint (beacon Checkpoint) |
beacon_unrealized_finalized |
Checkpoint |
beacon | Unrealized finalized checkpoint (beacon Checkpoint) |
Rows marked for one chain are never written on the other, so reaching one through the wrong chain's accessor panics naming the key rather than reading a zero. That is deliberate: a data directory holds one chain for its whole life.
time is the only clock either chain keeps, and it is a UNIX millisecond.
Milliseconds because the row has to be fine enough for the finest grid either
chain schedules on, which is lean's interval: with INTERVALS_PER_SLOT
intervals to a slot, most interval boundaries fall strictly between two whole
seconds, and a second-resolution row could not name them.
Everything coarser is derived from it, exactly and in one direction:
| Reading | Method | Used by |
|---|---|---|
| Milliseconds past genesis | Store::ms_since_genesis |
placing a moment within the current slot, against the beacon basis-point deadlines |
| Intervals past genesis | Store::intervals_since_genesis |
lean's tick pipeline, its future-slot guards, its fork-choice fixtures and the Hive driver |
| Slot | Store::current_slot |
both chains, including the beacon get_slots_since_genesis |
Nothing stores a derived reading, so none of them can fall out of step with
the row or with each other. The beacon specification denominates its own
Store.time in seconds, so on_tick and on_tick_per_slot convert on the
way in and the fixture harness divides on the way out; that conversion lives
at those edges rather than in a second row.
Lean's on_tick writes the row only at interval boundaries, since the
boundary is what it has actually processed: it walks forward one interval at a
time running that interval's duty, and the leftover milliseconds up to the
tick's own timestamp carry no duty that has run. The one time it moves the
clock backwards is the deliberate rewind that replays a slot after a gap.
db_version, chain and preset are what from_db_state checks before it
decodes anything else, and the two tags are raw bytes rather than SSZ for that
reason: they have to be readable by a build that would decode the rest of the
directory into the wrong shape. db_version catches a layout change this build
knows about; chain catches lean rows being opened as beacon or the reverse;
preset catches a preset-minimal build opening a mainnet directory, which
the other two cannot, since both presets write the same layout at the same
version while bounding every SSZ container differently. None of the three has a
migration path.
config and chain are the odd ones out among the mutable rows: written at
bootstrap (init_store for lean, init_beacon for beacon) and not mutated as
the chain progresses. chain is never rewritten (it has a getter,
Store::chain, and no setter). config is rewritten in one place,
Store::replace_config, by a beacon resume whose config file changed values
the directory does not contradict (see below), and that method panics unless
its Store holds the only handle to the cached copy. Because neither changes
once a Store is shared, the Store keeps a copy of each in memory, and
reads of them never reach the backend. Every other Metadata key is mutated
in place as the chain progresses.
config used to double as the DB's fingerprint on its own, with
from_db_state refusing to resume a data directory belonging to another
network. from_db_state no longer judges the network or the chain (see
Startup and Restore): it reads config and chain
back and hands both to the caller, which compares config's genesis_time
and slot duration (and genesis_validators_root, computed off the anchored
state itself rather than stored in Metadata) against the network it was
configured for, and chain against the sub-command it is running. A beacon
resume goes further, comparing every chain value in config (the slot time,
PRESET_BASE, the churn limits, ...) against the config.yaml it was started
with, in first_config_difference. CONFIG_NAME is exempt: a label with no
consensus effect, so a changed one is only warned about.
The fork schedule and BLOB_SCHEDULE are compared against what the directory
holds instead, because scheduling a fork is an edit to exactly those values,
made after every existing node initialized its directory. A fork's version or
epoch may change as long as both the stored and the new epoch lie past the
latest epoch the directory has processed: the highest LiveChain slot, or the
finalized state's slot when that is later (a checkpoint-sync anchor advanced
through empty slots). So a fork that was unset and is now scheduled ahead of
the chain resumes, while one moved onto or off a processed epoch is refused.
A resume that passes then writes the config file over config with
Store::replace_config: the node runs on the stored config, so keeping the
old one would leave it on the old schedule. From then on the file's values,
CONFIG_NAME included, are the ones the node reports.
A beacon directory shares head, latest_justified and latest_finalized
rather than keeping parallel ones. init_beacon seeds all three from one
trusted anchor, exactly as init_store seeds them on a lean directory, so
update_checkpoints is a single writer for both chains instead of growing an
absent-key branch per chain. The specification gives a trusted anchor's
finality the same starting value as its justification, rather than one actually
reached through the FFG rules, which is why both start equal.
The two chains denominate a checkpoint differently, and the stored form is
lean's: a beacon epoch is written as its own start slot. That is what makes the
conversion exact in both directions, through
Store::beacon_checkpoint_as_stored on the way in and
Store::beacon_finalized_checkpoint on the way out, and it is what lets the
shared finalization-advance comparison read a beacon checkpoint with no second
rule for it.
Note what is not in the table. There is no stored beacon head row:
beacon_head() derives its pair from head plus the head block's own entry,
because a second row denominated in slot ‖ root would be a value that could
drift from the first.
init_beacon writes the two beacon-only keys plus db_version, chain,
preset, config, time, head, anchor_slot, latest_justified and
latest_finalized in one atomic batch.
anchor_slot is the odd one among these: every other row is either a format
tag or something a later writer moves, while this one is written once at
bootstrap and never again, like config and chain. It has to be persisted
rather than derived because it is the store's only record of where its chain
starts. latest_finalized is seeded to the anchor as well, but climbs away
from it at the first finalization, so after that nothing else on disk can
answer the question. Both bootstrap paths take it from the anchor block's own
slot — anchor_state.latest_block_header.slot on lean, anchor_state.slot()
on beacon — rather than from the anchor checkpoint, whose beacon form is
epoch-denominated and would name the epoch's start slot instead. from_db_state
reads it back into a Store field, so Store::anchor_slot() costs no backend
round trip; the Status message's earliest_available_slot and the
data_column_sidecars_by_range/1 floor are both that one value, which is what
keeps a refusal from contradicting an advertisement. A directory's finalized state root, whichever chain it
holds, is read through Store::finalized_state_root. That needs no
chain-specific branch: both chains keep their finalized checkpoint in
latest_finalized, and the epoch-to-slot conversion above touches only the
slot, never the root. It treats a zero root as Error::UnanchoredDirectory
rather than a valid answer, since both init_store and init_beacon always
anchor at a real block root, so a zero root means the directory was built some
other way or its metadata was corrupted at rest.
Note that this is not the SSZ StateConfig carried inside State. That one is
merkleized into the state root, so its layout is fixed by the spec and holds only
genesis_time; the runtime Config adds the slot duration and the beacon fork
schedule, which the node needs to schedule duties but which never enter a state
root.
slot ‖ root → parent_root. A pure index for fork choice: it lets
get_live_chain() build the root → (slot, parent_root) block tree without
deserializing a single block. It contains the finalized anchor plus all
non-finalized blocks, and is pruned as finalization advances (the finalized
block itself is kept) on both chains.
The pruning horizon differs by chain because the two store their finalized
checkpoint's slot differently. Lean's is always a real block's own slot, so
update_checkpoints prunes to it directly. A beacon checkpoint's stored slot
is its epoch's start slot (Store::beacon_checkpoint_as_stored), which can
name a slot nobody ever built a block for; pruning to it directly would
delete the finalized block's own row whenever that start slot was skipped,
since the block then sits strictly below it, and every fork-choice walk that
asks for the ancestor at that start slot (get_checkpoint_block) would fall
through the gap into a hard SpecAssert instead of finding it. So the beacon
arm looks up the finalized root's own slot (from BlockHeaders, which is
never pruned) and prunes to that instead: lower than or equal to the epoch
start slot, and always a slot some row actually occupies.
Presence in LiveChain is what makes a block visible to fork choice:
insert_pending_block deliberately writes a block's header/body/proof
without a LiveChain entry, persisting the heavy proof data (~3 KB+)
while the block waits for its parent. When the block is later processed,
insert_signed_block overwrites the same keys (idempotent) and adds the
LiveChain entry.
slot ‖ root ‖ column_index → DataColumnSidecar (SSZ-encoded). Beacon only:
this is where a fulu data-availability-sampling follower keeps the column
sidecars its own node id assigned it custody of, so it can verify a block's
availability and answer data_column_sidecars_by_{root,range}/1 for peers;
see beacon_wire.md for the wire side
and how a node's custody set is selected.
Keyed slot-first, ahead of the root, for the same reason BlockProof is: it
gives a by-range answer a prefix scan per slot instead of a full-table scan,
and gives a future pruner a scan that stops early once it passes its cutoff.
Every writer and reader already has the slot in hand — an arriving sidecar
carries signed_block_header.message.slot, and the availability check and the
held-block release path both start from the block itself — so the slot prefix
costs nothing to supply.
Sidecars are written on arrival, once the p2p layer has verified one (gossip
validation for a sidecar gossip accepted, the chain checks in
beacon::gossip::column::chain_checks for any other), rather than at block
import. That ordering is what lets the availability
gate read a block's columns before the block itself is allowed to import (a
column has to exist first for the gate to find it), and what lets a restart
keep every sidecar this node already paid a KZG batch to verify rather than
re-fetching and re-verifying them. One consequence: a sidecar is stored for
any block whose header, parent and proposer check out, whether or not that
block ever actually imports — an equivocation or an orphaned fork's sidecars
land here too, since gossip only requires a known, unfinalized parent, not a
canonical one. data_column_sidecars_in_range (the by-range handler's source)
compensates by restricting each slot's answer to that slot's canonical root
via BlockRoots, so a losing sibling's columns are stored but never served.
DataColumns is the one table in the enum with no pruning rule at all,
not even the finalized-window pruning LiveChain and BlockProof get: every
sidecar this node has ever custodied is kept forever, by design, until a
pruner is written (MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS is the
epoch depth spec gives a future one permission to prune below). That makes it
the one table whose size a long-running node must actually watch rather than
assume bounded: at the blob cap this is roughly 360 KB per slot across the
columns this node custodies, nearer 1 GB per day at current mainnet blob
counts. lean_table_bytes{table="data_columns"} (see metrics.md)
is that growth made visible; watch it, and size the disk against however long
the node runs unattended before a pruner exists.
Gloas only. A row in ExecutionPayloadEnvelopes exists for an envelope that
passed verification, keyed by the slot of the block it fulfills (the envelope
has no slot of its own) so a by-range handler can scan a slot window. The
table is the durable form of the store's verified_payloads set. Losing that
set on restart would silently drop every full payload branch from fork choice.
BlockTimeliness is the durable form of the gloas part of block_timeliness.
Pre-gloas blocks write no row, since nothing reads their timeliness after a
restart.
Store::from_db_state reloads both for every LiveChain block at or above the
finalized block's slot, and reseeds the in-memory payload-committee vote
vectors empty for each gloas block in that window. The votes themselves are not
persisted: the next block's payload attestations refill them. Neither table is
pruned; rows outside the window are simply not read.
The execution client's verdict on each reloaded payload is in-memory scratch
too, so the chain actor seeds one at startup: SYNCING when an execution
client is configured, so its first forkchoiceUpdated VALID for the head
promotes them, and VALID without one, as the follower trusts every envelope it
applies then. The head node's payload status (Store::head_payload_status) is
likewise not stored; it is unknown until the first head computation after a
restart, and the envelope by-range server withholds the head's envelope until it
is known.
Same key and same encoding as DataColumns, holding sidecars that have not
been verified yet. A sidecar lands here when its block's parent has no
post-state for the chain checks to check the proposer against: the parent may
still be in flight, or it may be a block the availability gate is itself
holding. The specification's gossip rule for that case is [IGNORE] with an
explicit licence to come back to it, so the sidecar is parked rather than
dropped, and sent back through the chain checks when the parent gains a
post-state.
Two tables rather than one, and that is the whole point of this one. A parked
sidecar has passed only the cheap structural checks — not its inclusion proof,
not its KZG batch, not its proposer signature, which are held back so a replay
pays for them once rather than once per attempt. data_column_indices_for
reads DataColumns and nothing else, and that read is what the data
availability gate believes; an unverified row there would let a peer satisfy
the gate with a column nothing ever judged. A row moves from here to
DataColumns only by passing every check when it is sent back.
The chain actor keeps one key per parked row in memory
(sidecars_awaiting_parent, keyed by the parent root it waits on) and the
bytes here, because a sidecar carries a cell per blob and the queue's size is
chosen by whichever peer is gossiping.
Nothing caps that queue. The finality sweep that evicts held blocks also
deletes the rows of parked sidecars at or below the finalized slot, and that is
the only thing reclaiming them, so it bounds how long a row lives but not how
fast rows arrive. The chain checks do not require a sidecar's
parent_root to name a block this node knows. Every parked sidecar, gossiped
or fetched, has had its header's signature checked against the head state
before it gets this far, but only when a head state is already cached and
the header's proposer_index names a validator in it: with no cached head
state, or a proposer index that names none (u64::MAX, say), the checks skip
that step and a made-up header still reaches here. A peer exploiting either
gap can still park rows as fast as it can invent a slot, proposer and index.
Watch
lean_sidecars_awaiting_parent: it is the only signal that this is
happening, and unlike DataColumns these rows were written on a peer's
say-so.
That in-memory map is also the only index into this table, and it does not
survive a restart, so start_actor clears the table outright before building
an empty one. Nothing is lost: a parked sidecar had passed no check worth
preserving, and the block it belongs to asks for its columns again. Without
that, every crash would leave every row it had parked unreadable, kept until
the directory was deleted.
root → (slot, Checkpoint), beacon only. Persists the beacon fork
choice's per-block unrealized justified checkpoint — consensus-specs'
store.unrealized_justifications[block_root] — which used to live only in
BeaconScratch::unrealized_justifications, an in-memory HashMap with no
backing table. A restart emptied that map with nothing to refill it: once a
pre-restart block was a leaf from an epoch older than the store's clock,
get_voting_source (and, through it, filter_block_tree/get_head) hit a
hard SpecAssert("block_root in store.unrealized_justifications") on every
tick, freezing the head. This table is what makes the value survive a
restart; Store::unrealized_justification/Store::set_unrealized_justification
keep the in-memory map too, as a write-through cache in front of it, since
get_voting_source reads this for every block from a prior epoch and a cache
hit costs no backend round trip.
Keyed by root alone rather than slot ‖ root, unlike LiveChain/BlockProof:
see Key encoding above for why. The slot travels in the value
instead, written alongside the checkpoint by Store::set_unrealized_justification.
Two writers: the anchor, at bootstrap (get_forkchoice_store), and
compute_pulled_up_tip, called from on_block for every newly imported
block. Both call Store::set_unrealized_justification after the block's own
insert_signed_block/insert_state have already run — compute_pulled_up_tip
needs the block's post-state, which does not exist until those have — so the
write is not in the same atomic batch as the block's import. A crash between
the two leaves that one block without an unrealized-justification entry;
has_state then skips re-importing it on resume, so nothing ever fills the
row in afterward either. get_voting_source no longer treats that miss as
fatal: it falls back to store.beacon_justified_checkpoint(), the way Prysm
seeds a rebuilt node's forkchoice store, and logs at debug rather than
raising. That is safe because the fallback only affects whether the block
counts as viable in filter_block_tree (voting_source.epoch == store.justified.epoch is always true for it), never how much weight it
casts once it does. is_ffg_competitive still raises on a miss: nothing in
production calls it.
Pruned on finalization, on the same horizon LiveChain is (the finalized
block's own slot, read from BlockHeaders via block_entry, not the stored
checkpoint's epoch-start slot — see LiveChain above for why):
Store::prune_unrealized_justifications deletes every entry below that slot
and evicts the same roots from the in-memory cache, keeping the finalized
block's own entry. That entry has to survive: after a restart with nothing
yet imported past the anchor, the anchor itself is the only leaf get_head
has to work with.
Storing a full State per block would be wasteful: most fields never change
or change predictably. Instead, insert_state writes:
- Always a
StateDiffkeyed by the block's slot and root, linked to its parent viabase_root(the block'sparent_root). - Only at anchors a full snapshot into
States. A block is an anchor when it crosses aSNAPSHOT_ANCHOR_INTERVALslot boundary relative to its parent (~68 minutes at 4-second slots). This bounds any reconstruction walk to at mostSNAPSHOT_ANCHOR_INTERVALdiff applications.
This is the lean arm. A beacon state is also a diff or, at an anchor, a
snapshot, but the diff is a byte delta (beacon_state_delta) taken against the
nearest snapshot on its branch, not against its parent, and its frame names
that snapshot as base_root. Reading a beacon state is therefore one snapshot
plus exactly one delta, however far into the interval the state sits; the cost
is that a delta carries every change since its snapshot, so it grows across
the interval. The reader follows each frame's base_root and does not assume
one hop, so a directory whose diffs were written parent-linked by an earlier
build still reads correctly (no DB_VERSION bump was needed), and a diff after
a restart or on a fork finds its snapshot by walking StateDiffs frames until
a root with no diff row.
A StateDiff stores only what cannot be recovered elsewhere: the target slot,
justified/finalized checkpoints, and the justification fields
(justified_slots, justifications_roots, justifications_validators,
stored in full — they are bounded by the non-finalized window, so they stay
small under healthy finality). The rest is deliberately omitted:
| Omitted field | Recovered from |
|---|---|
config, validators |
The snapshot (they never change) |
latest_block_header |
The BlockHeaders table |
historical_block_hashes |
Regenerated from base_root + the slot gap |
The historical_block_hashes append is checked rather
than trusted blindly: validate_history_append
(crates/storage/src/state_diff.rs) rejects a diff whose appended hashes
don't match the expected slot gap or aren't zero-filled for skipped slots,
so a broken append surfaces at diff-creation time instead of corrupting a
later reconstruction.
Reads go through get_state, which tries three levels:
- An in-memory LRU cache (
STATE_CACHE_CAPACITY = 32states, keyed by block root). States are content-addressed and immutable, so the cache never needs invalidation. The common case — reading the parent state right after importing its block — is a cache hit. - A full snapshot in
States. - Reconstruction: walk
base_rootpointers back throughStateDiffsuntil a snapshot is found, then replay the diffs forward. On lean that is a chain of parent-linked diffs; on beacon it is one hop, to the interval's snapshot.
STATE RECONSTRUCTION
────────────────────
get_state(D): not in the cache and no snapshot → rebuild in two passes.
Pass 1: walk backward from D, following each diff's base_root pointer
and collecting diffs, until a block with a snapshot is found:
┌────────┐ base=C ┌────────┐ base=B ┌────────┐ base=A ┌──────────┐
│ diff D │ ───────▶ │ diff C │ ───────▶ │ diff B │ ───────▶ │ snapshot │
└────────┘ └────────┘ └────────┘ │ at A │
(target) (StateDiffs table) └──────────┘
(States table)
Pass 2: starting from the snapshot, apply the diffs oldest-first:
state A ──apply B──▶ state B ──apply C──▶ state C ──apply D──▶ state D ✓
The rebuilt state D gets its latest_block_header from the BlockHeaders
table and is memoized in the LRU cache before being returned.
If the diff chain is broken or the target's header is missing, get_state
returns None rather than a partial state.
Block import (on_block in crates/blockchain/src/store.rs) commits a
sequence of independent write batches:
BLOCK IMPORT WRITE SEQUENCE
───────────────────────────
on_block(signed_block)
│
├─ 1. update_checkpoints() Metadata: head,
│ (only if the post-state latest_justified
│ justified a higher slot) (+ triggers pruning)
│
├─ 2. insert_signed_block() ┐ BlockHeaders[root]
│ │ BlockBodies[root] (lean, if non-empty)
│ ├─one batch─ BlockProof[slot‖root]
│ │ LiveChain[slot‖root]
│ ┘
│
├─ 3. insert_state() cache + PendingStates insert (synchronous),
│ (hands the state to the then a hand-off to the background writer
│ storage crate's own writer thread, which commits
│ thread; see below) StateDiffs[slot‖root]
│ States[slot‖root] (anchors only)
│ on its own schedule, not this one
│
└─ 4. update_head() Metadata: head
(re-runs fork choice) (+ justified/finalized if advanced,
+ BlockRoots diff (canonical index),
+ pruning on finalization)
Each numbered step is atomic on its own, but the import as a whole is not
one transaction, and step 3 is no longer one write at all: insert_state
returns once the state is cached and buffered (see crates/storage/src/state_writer.rs),
not once the writer thread has actually committed it. That thread drains a
FIFO one state behind the importer at most (STATE_WRITE_QUEUE_CAPACITY = 2,
plus the one write it can be holding), so steps 1, 2 and 4 can all reach disk
before step 3's own commit does. An unclean shutdown inside that window can
therefore leave head (from step 4), or latest_justified (from step 1, for
an even earlier block whose own step 3 might itself still be mid-flight), or
the finalized checkpoint update_head derives from the head state, naming a
root this directory has no state for yet — the exact thing "never leave
metadata referencing missing data" used to rule out, before the state write
moved off the importer's thread.
Store::repair_head, called by a resuming caller (see
Startup and Restore below), is what restores that
property for the head: it walks back to the newest ancestor with a persisted
state and rewinds there, dropping the LiveChain rows of whatever it hops
over so fork choice does not walk straight back to the stateless tip.
Justified and finalized are never rewound the same way — they are consensus
statements, not a pointer a repair may quietly move to an earlier one — so
Store::verify_anchor_states checks them instead, and a directory that fails
it is treated as needing a fresh anchor. Re-importing a block whose LiveChain
row was dropped this way is idempotent the same way any duplicate is: the skip
check is keyed on has_state, not on whether the block is already on record,
so a stateless block is reprocessed rather than skipped.
Pruning is driven by finalization and splits into a cheap immediate phase and a deferred heavy phase.
Immediately, when finalization advances (inside update_checkpoints):
prune_live_chain: deletesLiveChainentries below a horizon, keeping the finalized block itself. This keeps the fork choice working set bounded to the non-finalized chain. The horizon isfinalized.sloton lean, and the finalized block's own slot (read fromBlockHeaders) on beacon — see LiveChain for why the two differ.prune_gossip_signatures: drops buffered in-memory gossip signatures at or below the finalized slot.prune_stale_aggregated_payloads: drops in-memory aggregated payloads (both pending and known) whose target slot is at or below the finalized slot.prune_unrealized_justifications: beacon only, and only alongsideprune_live_chainin the sameChain::Beaconarm, on the same horizon (the finalized block's own slot) — see BeaconUnrealizedJustifications above. Deletes entries below that slot from the table and evicts the same roots from the in-memory cache, keeping the finalized block's own entry.
Deferred (prune_old_data, called after a batch of blocks has been
processed):
-
prune_old_block_proofs: deletesBlockProofentries belowcutoff = tip_slot − BLOCK_PROOF_PRUNING_RANGE(21,600 slots, ~1 day at 4-second slots) — but only whencutoff ≤ finalized_slot, i.e. the entire pruned range lies within finalized history. Non-finalized proofs are never touched. Finalized blocks can never revert, so their proofs are not needed for fork choice, reorg safety, or re-aggregation once outside the window. -
prune_finalized_states: range-deletesStatesandStateDiffsover[watermark, cut), wherecutis the start of the snapshot interval that holds the finalized block (its own slot fromBlockHeaders, not the checkpoint's epoch-start slot) andwatermarkisstates_pruned_below, the previous call'scut. The watermark is written in the same atomic batch as the deletes, so consecutive ranges are adjacent and never overlap, even across restarts. Safe because a diff-chain walk stops at the first snapshot on its branch, and the first block of any branch inside an interval is a snapshot, so every walk from a kept state stops at or aftercut; blocks below finality are never imported again, so nothing is written below the watermark afterwards. A state handed to the writer thread before finality moved can still be queued, though, so a call that has a range to delete first flushes that thread (StateWriterHandle::flush), and every earlier write commits before the tombstones do. Lean runs it insideprune_old_data; beacon runs it from the chain actor's finality horizon, after every tick and import, where the common case is one metadata read.
Never pruned, by design: BlockHeaders, BlockBodies, BlockRoots, and
Metadata. Headers, bodies and the canonical slot index are the full
historical record (and the slot lookup every state read depends on); only the
proof blobs, the finalized states and the (non-finalized) fork choice index are
disposable.
Not pruned yet, unlike the six above: DataColumns. It is not part of the
historical-record set above; it simply has no pruning rule written for it,
which is a gap rather than a decision — see DataColumns for
what bounds it in the meantime (lean_table_bytes{table="data_columns"}) and
where a future pruner is meant to land
(MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS).
Five Store fields never touch the backend directly, beacon with the
exceptions its row below names. All are bounded buffers (or, for
beacon, bounded in practice by the validator set and the unfinalized
window) shared across Store clones:
| Buffer | Capacity | Contents |
|---|---|---|
new_payloads |
64 messages | Pending aggregated attestation proofs, not yet active for fork choice |
known_payloads |
512 messages | Fork-choice-active aggregated proofs |
gossip_signatures |
2048 signatures | Raw per-validator XMSS signatures awaiting aggregation (each ~3 KB, so ~6 MB worst case) |
state_cache |
32 states | LRU memoization of block and checkpoint post-states (either chain), each held behind an Arc so a hit is not a copy; one bound covers both kinds, keyed apart by a small enum, and a miss is just a reconstruction rather than an error |
beacon |
unbounded | Beacon fork-choice scratch: proposer boost root, block timeliness, equivocating validator indices, latest messages, PoW blocks, and unrealized justifications. Most of it is not persisted: proposer boost resets every slot, pre-gloas timeliness is read only by the same-slot reorg helpers, equivocators come back from replaying attester slashings on sync, latest messages from one epoch of attestations, and PoW blocks stand in for an execution-client call a restarted node would simply make again. The exceptions are a gloas block's timeliness and verified payload (see ExecutionPayloadEnvelopes and BlockTimeliness) and unrealized justifications: this map is a write-through cache in front of BeaconUnrealizedJustifications (see that table above), so it is not lost, only refilled lazily on the next miss |
The payload buffers evict FIFO when full, and redundant proofs (whose participants are a subset of an existing proof for the same attestation data) are skipped on insert.
Note that the per-validator "latest attestation" maps used by fork choice are
not stored anywhere — they are derived on demand from these buffers via
extract_latest_known_attestations and friends. See the attestation pipeline
section of lmd_ghost.md for how attestations move between the
pools.
After a restart these buffers start empty: pending attestations and
un-aggregated gossip signatures are lost and must be re-collected from the
network. Everything persisted in the thirteen tables survives, except PendingDataColumns, which is cleared outright: its only index is in memory.
A Store is created through one of four constructors in
crates/storage/src/store.rs:
| Constructor | When | What it does |
|---|---|---|
from_anchor_state |
Lean genesis boot | Initializes from the genesis state (no anchor block body) |
get_forkchoice_store |
Lean checkpoint sync | Initializes from a downloaded finalized state + anchor block, after validating they are consistent |
init_beacon |
Beacon checkpoint sync | Writes a beacon directory's Metadata in one atomic batch, all seeded to one trusted anchor. The anchor block and state themselves are written separately, by ethlambda_state_transition::beacon::fork_choice::get_forkchoice_store (the specification's own construction rules, which this crate cannot depend on) |
from_db_state |
Resume from an existing data directory | Loads whichever chain the directory holds, without judging whether it is the right one |
The first two funnel into init_store, which writes the anchor in one
atomic batch: the Metadata keys a lean directory needs (the format version
and the chain and preset tags, time, config, head = safe_target = anchor root,
justified = finalized = anchor checkpoint), the anchor header, its
BlockRoots entry, the body if non-empty, a full snapshot into States keyed by the anchor's slot and root (the
base of every future diff chain), and the anchor's LiveChain entry. time
starts at genesis rather than at zero because it is an absolute timestamp, so
genesis is the value that means "the clock has not moved yet"; every derived
reading is zero there.
init_beacon's own atomic batch is the beacon-directory keys listed under
Metadata above; the anchor's States/BlockHeaders/BlockBodies
entries are written by its caller instead, through the same insert_state and
insert_signed_block an ordinary block import uses, followed by the anchor's
own BeaconUnrealizedJustifications row (set_unrealized_justification), the
same order every later block's import follows too — see
BeaconUnrealizedJustifications above. The
one row an import does not write is the anchor's BlockRoots entry, which
index_beacon_anchor adds right after the block (see BlockRoots).
from_db_state is the restore path: it reads db_version, preset, chain,
config and anchor_slot from Metadata, returning None for an empty DB. A format
mismatch is still fatal, failing with Error::DbVersionMismatch or
Error::PresetMismatch rather than reading on, but from_db_state no longer
judges the network or the chain, and it does not mutate anything either: it
hands back whichever chain the directory holds, without comparing either
against anything or repairing anything. Both are now the caller's job, in a
fixed order: fetch_initial_state (lean) and fetch_initial_beacon_state
(beacon) each check Store::chain() against the sub-command they are running
under, then call Store::verify_anchor_states — which reads back both the
justified and finalized checkpoints (see Metadata above) and
confirms each has a persisted state, returning the finalized state — before
running verify_state_genesis against it. Only once both of those pass does
either caller call Store::repair_head, described under
Write Paths above: it is a
mutation, so it waits until the store it would mutate has been judged fit to
resume at all, and it leans on verify_anchor_states having already confirmed
finalized has a state, which is what lets it treat reaching finalized's slot
during its own walk as a plain stopping point rather than something it has to
guard against.
A chain mismatch aborts with CheckpointSyncError::WrongChain; a genesis
mismatch aborts with CheckpointSyncError::Genesis, wrapping
GenesisMismatch::GenesisTime or ::GenesisValidatorsRoot; a missing anchor
state (storage::Error::AnchorStateLost) is treated exactly like a stale DB,
described next. All three used to be raised (the first two) or were not yet
possible to raise (the third) from inside from_db_state itself; none of that
judgment lives in the storage crate anymore, now that the checks that need it
moved one layer up, to the caller that actually knows which network and which
chain it wants. None of these failures are treated as an empty directory:
writing a fresh anchor on top would leave the foreign (or merely incomplete)
chain's rows in place, to be served to peers.
At startup, each chain prefers this path but only accepts the on-disk store if
its head is at most MAX_RESUMABLE_DB_STATE_AGE = 450 slots behind the
current slot: ~30 minutes at lean's four-second slots, ~90 minutes at beacon's
twelve-second ones. A staler DB, or one whose anchor checkpoint state is
missing, falls through to checkpoint sync, which writes a fresh anchor on top
of the existing data.
| File | Component |
|---|---|
crates/storage/src/store.rs |
Store: persistence logic, in-memory buffers, pruning, constructors |
crates/storage/src/api/traits.rs |
StorageBackend, StorageReadView, StorageWriteBatch |
crates/storage/src/api/tables.rs |
The Table enum |
crates/storage/src/state_diff.rs |
StateDiff: diff creation and state reconstruction |
crates/storage/src/backend/rocksdb.rs |
Production RocksDB backend |
crates/storage/src/backend/in_memory.rs |
Test backend |
crates/blockchain/src/store.rs |
Fork choice logic driving the Store (on_block, on_tick, ...) |