Skip to content

Commit c08509a

Browse files
committed
docs(claude-md): collapse storage docs into a pointer to data_storage.md
CLAUDE.md had grown a near-duplicate of docs/data_storage.md once the BlockRoots/table-count fixes landed on this branch. Collapse the three storage sections under Common Gotchas into a short paragraph plus a few must-not-forget bullets, following the pattern already used for the HTTP Servers section. Before deleting anything, verified each CLAUDE.md claim against source and moved what data_storage.md was missing there instead: the BlockRoots table (key encoding, per-table section, write-path and pruning-list entries), the constant-at-runtime facts for Metadata["config"] and the genesis validator registry, and a stronger statement of the StateDiff config/validators invariant naming validate_history_append. Also fixes two inaccuracies found during verification: BlockRoots backs get_signed_blocks_by_slot_range (BlocksByRange serving), not the RPC by-slot endpoint, which resolves through historical_block_hashes instead; and ChainConfig has no validator_count field, only genesis_time.
1 parent 2f2facb commit c08509a

2 files changed

Lines changed: 88 additions & 77 deletions

File tree

CLAUDE.md

Lines changed: 18 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -350,60 +350,24 @@ incremental, and line-tables-only debuginfo, so rebuilds are much faster than
350350
- Signature spec tests use `on_block()` which always verifies
351351
- Crypto tests marked `#[ignore]` (slow leanVM operations)
352352

353-
### Storage Architecture
354-
- Blocks are split into three tables: `BlockHeaders`, `BlockBodies`, `BlockSignatures`
355-
- Genesis/anchor blocks have empty bodies (detected via `EMPTY_BODY_ROOT`) — no entry in `BlockBodies`
356-
- Genesis block has no signatures — no entry in `BlockSignatures`
357-
- Non-genesis blocks have a `BlockSignatures` entry until finalized: once below the
358-
finalized boundary, signatures are pruned (`prune_old_block_signatures`) while
359-
headers and bodies are kept forever. `get_signed_block` returns `None` for a
360-
pruned finalized block
361-
- States are stored as parent-linked diffs (`StateDiffs`, never pruned) plus
362-
full-state snapshots (`States`) written only every `SNAPSHOT_ANCHOR_INTERVAL`
363-
slots (and the bootstrap). Neither is ever pruned. `get_state` returns an anchor
364-
snapshot or reconstructs by walking diffs back to the nearest anchor; results are
365-
memoized in an in-memory LRU (`STATE_CACHE_CAPACITY`) so recent reads stay hot
366-
- `StateDiff` omits `config` and `validators` entirely and takes them from the
367-
nearest ancestor snapshot: an STF that ever mutated either would silently corrupt
368-
every reconstructed state. `historical_block_hashes` is likewise not stored but
369-
regenerated from `base_root` plus the slot gap, guarded by `validate_history_append`
370-
- `LiveChain` table provides fast `(slot||root) → parent_root` index for fork choice
371-
- `BlockRoots` is the canonical `slot → root` index, rewritten on every head update:
372-
`block_root_index_changes` walks both branches to their common ancestor, so a reorg
373-
deletes the slots leaving the canonical chain and writes the ones joining it
374-
- Storage uses trait-based API: `StorageBackend` → `StorageReadView` (reads) + `StorageWriteBatch` (atomic writes)
375-
376-
### Storage Tables (8)
377-
378-
These are the variants of the `Table` enum (`crates/storage/src/api/tables.rs`).
379-
380-
| Table | Key → Value | Purpose |
381-
|-------|-------------|---------|
382-
| `BlockHeaders` | H256 → BlockHeader | Block headers by root |
383-
| `BlockBodies` | H256 → BlockBody | Block bodies (empty for genesis) |
384-
| `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 |
385-
| `BlockRoots` | slot → H256 | Canonical block root per slot; rewritten on reorg, never pruned. Backs `get_block_by_slot` and BlocksByRange serving |
386-
| `States` | H256 → State | Full-state snapshots; bootstrap + `SNAPSHOT_ANCHOR_INTERVAL` anchors only; never pruned |
387-
| `StateDiffs` | H256 → StateDiff | Parent-linked state diff per non-genesis state; never pruned |
388-
| `Metadata` | string → various | Store state (time, config, head, safe target, checkpoints) |
389-
| `LiveChain` | (slot\|\|root) → parent\_root | Fast fork choice traversal index |
390-
391-
Attestations and gossip signatures are **not** persisted tables; they live in
392-
in-memory `Store` buffers (`new_payloads`, `known_payloads`, `gossip_signatures`)
393-
and are consumed during the tick pipeline (promotion at intervals 0/4,
394-
aggregation at interval 2).
395-
396-
### What Is Constant in the DB
397-
398-
`Metadata["config"]` (a `ChainConfig`, currently just `genesis_time`) is written once
399-
at bootstrap and never rewritten: it has a getter but no setter. It doubles as the
400-
DB fingerprint, since `from_db_state` refuses to resume a DB whose persisted
401-
`genesis_time` disagrees with the config file.
402-
403-
The genesis validator registry is constant too, but it lives inside each `States`
404-
snapshot rather than in its own table. Everything else moves: the remaining
405-
`Metadata` keys are mutated in place, `BlockRoots` is rewritten on reorg, and the
406-
block/state tables are write-once per entry with a growing key set.
353+
### Storage
354+
355+
Blocks split across `BlockHeaders`/`BlockBodies`/`BlockSignatures`; states are
356+
snapshot (`States`) + diff (`StateDiffs`) pairs; `BlockRoots` and `LiveChain`
357+
index by slot for range serving and fork choice. Attestations and gossip
358+
signatures are not persisted; they live in in-memory `Store` buffers consumed
359+
during the tick pipeline. See [`docs/data_storage.md`](docs/data_storage.md)
360+
for the full reference: what each of the eight tables holds and how it's
361+
keyed, the snapshot/diff reconstruction algorithm, the block-import write
362+
sequence, pruning rules, what never changes at runtime, and startup/restore
363+
behavior.
364+
365+
- `BlockSignatures` is the only pruned block table (below the finalized
366+
boundary); `get_signed_block` returns `None` for a pruned finalized block.
367+
- A `StateDiff` omits `config` and `validators`, trusting they never mutate;
368+
breaking that invariant would silently corrupt every reconstructed state.
369+
- `Metadata["config"]` is written once at bootstrap and never rewritten; it
370+
doubles as the DB's genesis-time fingerprint on resume.
407371

408372
### State Root Computation
409373
- Always computed via `hash_tree_root()` after full state transition

docs/data_storage.md

Lines changed: 70 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This doc explains how ethlambda saves data. Especially,
44
the split between the fork choice `Store` and the `StorageBackend` trait,
5-
what each of the seven tables holds, and which data is in-memory only.
5+
what each of the eight tables holds, and which data is in-memory only.
66

77
## Overview
88

@@ -98,14 +98,14 @@ is built from it, and clones are handed to the BlockChain and P2P actors.
9898
│ │ BlockHeaders │ │ new_payloads │ │
9999
│ │ BlockBodies │ │ (pending aggregated │ │
100100
│ │ BlockSignatures │ │ attestations) │ │
101-
│ │ States │ │ known_payloads │ │
102-
│ │ StateDiffs │ │ (fork-choice-active │ │
103-
│ │ Metadata │ │ attestations) │ │
104-
│ │ LiveChain │ │ gossip_signatures │ │
105-
└─────────────────────┘ │ (raw XMSS sigs │ │
106-
│ awaiting │ │
107-
Survives restarts. │ aggregation) │ │
108-
│ state_cache (LRU) │ │
101+
│ │ BlockRoots │ │ known_payloads │ │
102+
│ │ States │ │ (fork-choice-active │ │
103+
│ │ StateDiffs │ │ attestations) │ │
104+
│ │ Metadata │ │ gossip_signatures │ │
105+
│ LiveChain │ │ (raw XMSS sigs │ │
106+
└─────────────────────┘ │ awaiting │ │
107+
│ aggregation) │ │
108+
Survives restarts. │ state_cache (LRU) │ │
109109
│ └──────────────────────┘ │
110110
│ │
111111
│ Lost on restart. │
@@ -114,21 +114,22 @@ is built from it, and clones are handed to the BlockChain and P2P actors.
114114

115115
## The Tables
116116

117-
The seven variants of the `Table` enum (`crates/storage/src/api/tables.rs`):
117+
The eight variants of the `Table` enum (`crates/storage/src/api/tables.rs`):
118118

119119
| Table | Key | Value | Pruned? |
120120
| ----------------- | ----------- | ----------------------------------------- | -------------------------------- |
121121
| `BlockHeaders` | root | `BlockHeader` | never |
122122
| `BlockBodies` | root | `BlockBody` | never |
123123
| `BlockSignatures` | slot ‖ root | aggregate proof (`MultiMessageAggregate`) | yes: finalized older than ~1 day |
124+
| `BlockRoots` | slot | block root (`H256`) | never |
124125
| `States` | root | full `State` snapshot | never |
125126
| `StateDiffs` | root | `StateDiff` | never |
126127
| `Metadata` | string | SSZ scalars | never |
127128
| `LiveChain` | slot ‖ root | `parent_root` | yes: below finalized |
128129

129130
### Key encoding
130131

131-
Two key layouts are used:
132+
Three key layouts are used:
132133

133134
- **Root-keyed** tables use the 32-byte SSZ encoding of the block root
134135
(`root.to_ssz()`).
@@ -137,6 +138,10 @@ Two key layouts are used:
137138
32-byte root. Big-endian means lexicographic key order equals numeric slot
138139
order, so pruning can iterate from the start of the table and stop at the
139140
first key past its cutoff instead of scanning everything.
141+
- **Slot-only** (`BlockRoots`) uses `encode_block_root_key`: just the 8-byte
142+
big-endian slot, since the value already holds the root. This table is
143+
never pruned, so the ordering buys nothing here; it is kept only for
144+
consistency with the other slot-prefixed keys.
140145

141146
### BlockHeaders
142147

@@ -168,12 +173,35 @@ rather than a fabricated block.
168173

169174
This is the one block table that **is** pruned; see [Pruning](#pruning).
170175

176+
### BlockRoots
177+
178+
`slot → H256`, the canonical block root at each slot. Rewritten on every head
179+
update inside `update_checkpoints`: `block_root_index_changes` walks the old
180+
and new head's branches back to their common ancestor, deleting the slots
181+
that leave the canonical chain and writing the ones that join it. A reorg
182+
therefore touches only the affected slot range, not the whole table. Never
183+
pruned.
184+
185+
Backs `get_signed_blocks_by_slot_range`, which serves BlocksByRange requests
186+
over req/resp (`crates/net/p2p/src/req_resp/handlers.rs`). It does **not**
187+
back the RPC `GET /lean/v0/blocks/:slot` endpoint: that handler resolves a
188+
slot through the head state's `historical_block_hashes` instead
189+
(`resolve_slot` in `crates/net/rpc/src/blocks.rs`), so a block on a side fork
190+
is reachable there only by root, never by slot.
191+
171192
### States
172193

173194
`root → State` (full SSZ snapshot). Holds full-state snapshots **only**: the
174195
bootstrap anchor written at initialization, plus one anchor whenever a block
175-
crosses a 1024-slot boundary. Never pruned — these anchors are the base every
176-
diff chain resolves against, so reconstruction always terminates.
196+
crosses a `SNAPSHOT_ANCHOR_INTERVAL`-slot boundary. Never pruned — these
197+
anchors are the base every diff chain resolves against, so reconstruction
198+
always terminates.
199+
200+
The genesis validator registry is constant for the life of the chain
201+
(`validators` is fixed at genesis; the lean STF never mutates it), but it has
202+
no table of its own: it rides inside every `States` snapshot alongside
203+
`config`, which `StateDiff` reconstruction relies on (see
204+
[State Storage](#state-storage-snapshots--diffs)).
177205

178206
### StateDiffs
179207

@@ -190,12 +218,20 @@ fields:
190218
| Key | Type | Meaning |
191219
| ------------------ | ------------- | ------------------------------------------------------ |
192220
| `time` | `u64` | Intervals elapsed since genesis (the store clock) |
193-
| `config` | `ChainConfig` | Chain configuration (genesis time, validator count) |
221+
| `config` | `ChainConfig` | Chain configuration (currently just `genesis_time`) |
194222
| `head` | `H256` | Current fork choice head |
195223
| `safe_target` | `H256` | Current safe target (see [lmd_ghost.md](lmd_ghost.md)) |
196224
| `latest_justified` | `Checkpoint` | Latest justified checkpoint |
197225
| `latest_finalized` | `Checkpoint` | Latest finalized checkpoint |
198226

227+
`config` is the odd one out: `init_store` writes it once at bootstrap and
228+
nothing ever rewrites it afterward (it has a getter, `Store::config`, but no
229+
setter). That also makes it the DB's fingerprint: `from_db_state` refuses to
230+
resume a data directory whose persisted `genesis_time` disagrees with the
231+
node's own config file, treating the mismatch as an empty DB (see
232+
[Startup and Restore](#startup-and-restore)). Every other `Metadata` key is
233+
mutated in place as the chain progresses.
234+
199235
### LiveChain
200236

201237
`slot ‖ root → parent_root`. A pure **index** for fork choice: it lets
@@ -219,9 +255,9 @@ or change predictably. Instead, `insert_state` writes:
219255
1. **Always** a `StateDiff` keyed by the block root, linked to its parent via
220256
`base_root` (the block's `parent_root`).
221257
2. **Only at anchors** a full snapshot into `States`. A block is an anchor
222-
when it crosses a `SNAPSHOT_ANCHOR_INTERVAL = 1024` slot boundary relative
258+
when it crosses a `SNAPSHOT_ANCHOR_INTERVAL` slot boundary relative
223259
to its parent (~68 minutes at 4-second slots). This bounds any
224-
reconstruction walk to at most 1024 diff applications.
260+
reconstruction walk to at most `SNAPSHOT_ANCHOR_INTERVAL` diff applications.
225261

226262
A `StateDiff` stores only what cannot be recovered elsewhere: the target slot,
227263
justified/finalized checkpoints, and the justification fields
@@ -235,6 +271,16 @@ small under healthy finality). The rest is deliberately omitted:
235271
| `latest_block_header` | The `BlockHeaders` table |
236272
| `historical_block_hashes` | Regenerated from `base_root` + the slot gap (the state transition appends the parent root plus one zero per skipped slot, so the append is fully predictable) |
237273

274+
Omitting `config` and `validators` is a bet, not a fallback: a diff carries no
275+
copy of either, so if a future state transition ever mutated one, every state
276+
reconstructed past that point would silently pick up the ancestor snapshot's
277+
stale value instead. The `historical_block_hashes` append is checked rather
278+
than trusted blindly: `validate_history_append`
279+
(`crates/storage/src/state_diff.rs`) rejects a diff whose appended hashes
280+
don't match the expected slot gap or aren't zero-filled for skipped slots,
281+
so a broken append surfaces at diff-creation time instead of corrupting a
282+
later reconstruction.
283+
238284
Reads go through `get_state`, which tries three levels:
239285

240286
1. An in-memory LRU cache (`STATE_CACHE_CAPACITY = 32` states, keyed by block
@@ -298,6 +344,7 @@ sequence of independent write batches:
298344
299345
└─ 4. update_head() Metadata: head
300346
(re-runs fork choice) (+ justified/finalized if advanced,
347+
+ BlockRoots diff (canonical index),
301348
+ pruning on finalization)
302349
```
303350

@@ -340,10 +387,10 @@ processed):
340387
not needed for fork choice, reorg safety, or re-aggregation once outside
341388
the window.
342389

343-
**Never pruned:** `BlockHeaders`, `BlockBodies`, `States`, `StateDiffs`, and
344-
`Metadata`. Headers, bodies, and the snapshot+diff chain are the full
345-
historical record; only the proof blobs and the fork choice index are
346-
disposable.
390+
**Never pruned:** `BlockHeaders`, `BlockBodies`, `BlockRoots`, `States`,
391+
`StateDiffs`, and `Metadata`. Headers, bodies, the canonical slot index, and
392+
the snapshot+diff chain are the full historical record; only the proof blobs
393+
and the (non-finalized) fork choice index are disposable.
347394

348395
## In-Memory Only (Lost on Restart)
349396

@@ -369,7 +416,7 @@ pools.
369416

370417
After a restart these buffers start empty: pending attestations and
371418
un-aggregated gossip signatures are lost and must be re-collected from the
372-
network. Everything persisted in the seven tables survives.
419+
network. Everything persisted in the eight tables survives.
373420

374421
## Startup and Restore
375422

@@ -385,8 +432,8 @@ A `Store` is created through one of three constructors in
385432
The first two funnel into `init_store`, which writes the anchor in **one
386433
atomic batch**: all six `Metadata` keys (time = 0, config, head = safe_target
387434
= anchor root, justified = finalized = anchor checkpoint), the anchor header,
388-
the body if non-empty, a full snapshot into `States` (the base of every future
389-
diff chain), and the anchor's `LiveChain` entry.
435+
its `BlockRoots` entry, the body if non-empty, a full snapshot into `States`
436+
(the base of every future diff chain), and the anchor's `LiveChain` entry.
390437

391438
`from_db_state` is the restore path: it reads `config` and `latest_finalized`
392439
from `Metadata`, returning `None` for an empty DB or a `genesis_time`

0 commit comments

Comments
 (0)