feat(l1): state-history journal foundation for deep reorgs - #6686
Conversation
|
Lines of code reportTotal lines added: Detailed view |
Builds on the journal added in #6686. Introduces: - Overlay struct: in-memory aggregated reverse-diff bridging on-disk state at the cache edge D to the virtual state at a deep-reorg pivot T-1. Built by walking STATE_HISTORY[D..=T] descending, with older-entry-wins semantics so the post-walk value at every key is the pre-image at T-1. - OverlayCf enum + classify_by_key_length, reusing PR 1's classify_trie_key. - TrieLayerCache: optional overlay field plus set/clear/lookup accessors. - TrieWrapper::get cascade now reads: layer cache -> overlay -> disk. No orchestration wired yet; PR 3 will install/clear overlays via apply_fork_choice_with_deep_reorg. 14 unit tests covering descending-range load, older-wins, bloom miss, hash mismatch, missing entry, swapped-args rejection, CF classification, cascade short-circuit, and edge cases.
🤖 Codex Code Review
Aside from those points, the block-number/block-hash plumbing through Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewHere is the review: Review:
|
Greptile SummaryThis PR lays the on-disk reverse-diff journal for deep reorg support, writing a per-block
Confidence Score: 4/5The journaling foundation is architecturally sound — pre-image reads use the RCU snapshot before the write batch, journal writes are atomic with trie commits, and finality pruning is correctly guarded behind an advancing-finality check. Two narrow issues exist but neither touches production correctness in practice. The LEB128 decoder silently truncates bits 1-6 of the final byte instead of rejecting the input, and the prev_finalized fallback-to-zero on a length mismatch could prune the entire journal under a future encoding divergence. Both are present defects in the changed code, though the first is harmless for self-written data today and the second requires a future schema change to trigger. crates/storage/journal.rs (read_varint 10th-byte check) and the prev_finalized read in crates/storage/store.rs forkchoice_update_inner deserve a second look before the reorg consumer (PR 2) starts relying on the journal.
|
| Filename | Overview |
|---|---|
| crates/storage/journal.rs | New file: JournalEntry struct, hand-rolled LEB128 varint codec, and 8 unit tests. A subtle overlong-encoding acceptance flaw exists in read_varint at the 10th byte. |
| crates/storage/store.rs | apply_trie_updates now collects pre-images via a snapshot read_view opened before the write batch and stages the JournalEntry atomically; forkchoice_update_inner prunes STATE_HISTORY when finality advances. Logic is sound; 5 new integration tests verify write and prune paths. |
| crates/storage/layering.rs | TrieLayer gains block_number and block_hash fields; commit() returns CommitResult instead of bare Vec. Refactor is clean and correct. |
| crates/storage/backend/in_memory.rs | delete_range follows the same RCU/Arc::make_mut pattern as existing put_batch and delete; consistent with pre-existing non-atomic commit() semantics. |
| crates/storage/backend/rocksdb.rs | delete_range_cf delegates to RocksDB's WriteBatch::delete_range_cf, correctly buffered until commit(). Implementation is straightforward. |
| crates/storage/api/mod.rs | delete_range added to StorageWriteBatch trait with clear half-open range semantics documented. Both implementors updated. |
| crates/storage/api/tables.rs | STATE_HISTORY column family constant added with big-endian key semantics clearly documented; TABLES array updated to 21 entries. |
| crates/storage/trie.rs | classify_trie_key extracted as a pub helper, de-duplicating an inlined key-length check from three call sites. Clean refactor with no behavioral change. |
| crates/storage/lib.rs | journal module added to the crate's public API. Trivial one-line change. |
Sequence Diagram
sequenceDiagram
participant BP as Block Producer
participant TW as TrieUpdate Worker
participant Cache as TrieLayerCache
participant DB as Backend (RocksDB)
participant SH as STATE_HISTORY CF
BP->>TW: store_block_updates(BlockN)
TW->>Cache: put_batch(parent_root, child_root, block_N, hash_N, nodes)
Cache-->>TW: layer cached
TW-->>BP: Ok (phase 1 done)
Note over TW: Phase 2 - disk commit (BlockN-1)
TW->>Cache: commit(root_N-1)
Cache-->>TW: CommitResult
TW->>DB: begin_read() - snapshot (pre-write view)
loop for each (key, value) in nodes
TW->>DB: snapshot.get(table, key) - prev_value
TW->>DB: write_batch.put/delete(table, key, value)
end
TW->>SH: write_batch.put(block_N-1.to_be_bytes(), JournalEntry.encode())
TW->>DB: write_batch.commit() [atomic]
Note over BP,SH: On finality advance (FCU)
BP->>DB: begin_read() - get(CHAIN_DATA, finalized_key) - prev_finalized
BP->>DB: write_batch.put(CHAIN_DATA, finalized_key, new_finalized)
BP->>SH: write_batch.delete_range([0, new_finalized+1))
BP->>DB: write_batch.commit() [atomic]
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
crates/storage/journal.rs:211-228
The overflow check fires on the 11th byte (after `shift` increments to 70), not on the 10th. At `shift=63` the check `shift >= 64` is still false, so `result |= ((b & 0x7f) as u64) << 63` runs. Because `u64` is 64 bits, only bit 0 of `b & 0x7f` survives the shift—bits 1-6 are silently dropped. A final byte of, say, `0x7e` (bits 1-5 set, no continuation) decodes to the same value as `0x00`, masking data corruption in the journal without returning `VarintOverflow`. The standard fix is to add a check for `shift == 63 && b & 0x7e != 0`.
```suggestion
fn read_varint(&mut self) -> Result<u64, JournalDecodeError> {
let mut result: u64 = 0;
let mut shift: u32 = 0;
loop {
let b = self.read_byte()?;
// Maximum 10 bytes for u64 LEB128 (10 * 7 = 70 > 64).
if shift >= 64 {
return Err(JournalDecodeError::VarintOverflow {
offset: self.offset - 1,
});
}
// At shift=63 only bit 0 of the final byte is in range for u64;
// bits 1-6 would shift past position 63 and be silently dropped.
if shift == 63 && b & 0x7e != 0 {
return Err(JournalDecodeError::VarintOverflow {
offset: self.offset - 1,
});
}
result |= ((b & 0x7f) as u64) << shift;
if b & 0x80 == 0 {
return Ok(result);
}
shift += 7;
}
}
```
### Issue 2 of 2
crates/storage/store.rs:1081-1090
**`prev_finalized` may spuriously read back as 0 on data length mismatch**
`bytes.try_into().ok()` silently returns `None` (falls through to `unwrap_or(0)`) whenever the stored byte slice is not exactly 8 bytes—e.g., after a future schema change that writes a different width for `FinalizedBlockNumber`. In that case `prev_finalized = 0`, so `finalized > 0` is almost always true, and the entire journal from block 0 onwards is pruned even if finality did not advance. Since `CHAIN_DATA` already encodes this field as `to_le_bytes()` (8 bytes) and the same codec is used when writing, the current code cannot hit this branch in practice—but the silent fallback hides the bug if encoding ever diverges.
Reviews (1): Last reviewed commit: "feat(l1): persist state-history journal ..." | Re-trigger Greptile
f24e035 to
5f49d31
Compare
Builds on the journal added in #6686. Introduces: - Overlay struct: in-memory aggregated reverse-diff bridging on-disk state at the cache edge D to the virtual state at a deep-reorg pivot T-1. Built by walking STATE_HISTORY[D..=T] descending, with older-entry-wins semantics so the post-walk value at every key is the pre-image at T-1. - OverlayCf enum + classify_by_key_length, reusing PR 1's classify_trie_key. - TrieLayerCache: optional overlay field plus set/clear/lookup accessors. - TrieWrapper::get cascade now reads: layer cache -> overlay -> disk. No orchestration wired yet; PR 3 will install/clear overlays via apply_fork_choice_with_deep_reorg. 14 unit tests covering descending-range load, older-wins, bloom miss, hash mismatch, missing entry, swapped-args rejection, CF classification, cascade short-circuit, and edge cases.
|
Pushed a fixup addressing all bot findings. Short responses: Greptile / Claude — varint 10th-byte truncation ; fixed with Codex / Claude — Greptile / Codex — Codex — Claude — Claude — Claude — Claude — Claude — multi-layer commit attribution ; added code comment explaining the single-layer invariant. Claude — in-memory 26 storage tests pass on InMemory and RocksDB; clippy + fmt clean. |
ElFantasma
left a comment
There was a problem hiding this comment.
Three inline findings (all minor) + a body note.
Body finding: the body's checklist says "no STORE_SCHEMA_VERSION bump (no schema migration required)". Verified — create_missing_column_families(true) (rocksdb.rs:34) creates STATE_HISTORY on the fly when a v13 binary opens a v12 DB, and existing CFs are untouched. Downgrade (v12 binary on v13 DB) is rejected by the TABLES.contains(&cf_name) check at rocksdb.rs:203, which is correct one-way-migration behavior. Worth noting explicitly in the body or CHANGELOG so an operator considering rollback knows the v12 binary won't open a v13 DB.
Adds a per-block reverse-diff journal in a new STATE_HISTORY column family. Each non-batch trie commit captures pre-images for every overwritten trie node and flat-KV entry, then stages a JournalEntry in the same write batch as the trie writes (atomic on commit). Finality pruning issues a single delete_range when finalized advances. No reorg consumer yet; this PR is the audit-trail foundation for the deep-reorg work in #6685. Skipped entirely during full sync (batch_mode).
Per review: - Document the version-bump strategy on JOURNAL_VERSION so PRs 2/3/4 have a policy to follow: drain at finality on bump, or grow per-version decode arms if history needs to survive the upgrade. - Add debug_assert!(layers_to_commit.len() == 1) in TrieLayerCache::commit to runtime-enforce the single-layer invariant the ATTRIBUTION NOTE describes; catches any future change that quietly merges multiple blocks into one journal entry.
f01b356 to
21d8e48
Compare
Builds on the journal added in #6686. Introduces: - Overlay struct: in-memory aggregated reverse-diff bridging on-disk state at the cache edge D to the virtual state at a deep-reorg pivot T-1. Built by walking STATE_HISTORY[D..=T] descending, with older-entry-wins semantics so the post-walk value at every key is the pre-image at T-1. - OverlayCf enum + classify_by_key_length, reusing PR 1's classify_trie_key. - TrieLayerCache: optional overlay field plus set/clear/lookup accessors. - TrieWrapper::get cascade now reads: layer cache -> overlay -> disk. No orchestration wired yet; PR 3 will install/clear overlays via apply_fork_choice_with_deep_reorg. 14 unit tests covering descending-range load, older-wins, bloom miss, hash mismatch, missing entry, swapped-args rejection, CF classification, cascade short-circuit, and edge cases.
Builds on the journal (#6686) and overlay (#6687) to drive deep-reorg applies through the engine API. Issue #6685. Storage primitives in crates/storage/store.rs: - install_overlay_for_reorg: builds an Overlay from STATE_HISTORY[D..=T] and atomically swaps the layer cache for a fresh one with the overlay installed; failure leaves the existing cache intact. - highest_state_history_block_number, is_state_in_layer_cache, clear_reorg_overlay, abort_reorg. - Section 9 reconciliation inside apply_trie_updates: on the first new-chain commit, fold overlay-only entries into the write batch, stage delete_range(STATE_HISTORY, T..=D) before the new T journal entry, and clear the overlay on success. Disk advances D-old to T-new in a single atomic write. Blockchain (crates/blockchain/blockchain.rs): - reorg_in_progress AtomicBool + ReorgGuard RAII; enter_reorg and is_reorg_in_progress; store() accessor for the orchestrator. Fork choice (crates/blockchain/fork_choice.rs): - apply_fork_choice_with_deep_reorg wraps apply_fork_choice. The 128 REORG_DEPTH_LIMIT cap is left in place; PR 4 lifts it. Only the StateNotReachable case is routed to reorg_apply_deep, so mainline behavior is unchanged. - reorg_apply_deep walks to the pivot, installs the overlay, replays side-chain blocks AND head via Blockchain::add_block, then runs forkchoice_update. AbortReorgGuard resets the cache on any failure path or panic; disarmed on success. Engine API: - engine/fork_choice.rs handler switched to the new wrapper. - engine/payload.rs try_execute_payload: when the parent block is known but its state is materialized in neither the layer cache nor disk, stash the block and return ACCEPTED; matches geth's catalyst path. A later forkchoiceUpdated drives the deep-reorg apply. - New PayloadStatus::accepted() constructor. Tests: - 4 storage unit tests covering the new primitives (install/abort/clear, highest_state_history_block_number). - 2 ReorgGuard tests covering RAII semantics including panic unwinding. Workspace builds clean; cargo clippy --workspace -- -D warnings clean; cargo fmt --check clean. The tooling/reorgs deep-reorg scenario is deferred to PR 4, where the 128-block cap is lifted and a CLI knob for commit_threshold makes the end-to-end path naturally reachable in the simulator. Closes part of #6685. Stacked on #6687.
…urnal # Conflicts: # crates/storage/api/mod.rs # crates/storage/backend/in_memory.rs # crates/storage/backend/rocksdb.rs # crates/storage/store.rs
…urnal # Conflicts: # crates/storage/api/tables.rs # crates/storage/store.rs
… ephemeral backend)
ElFantasma
left a comment
There was a problem hiding this comment.
Re-review after clippy Box + docs tweak — still LGTM, no new issues.
…urnal # Conflicts: # crates/storage/layering.rs # crates/storage/store.rs
The state-history work makes apply_fork_choice reach the deep-reorg path (journal prune + PersistMessage::Commit backlog flush), inflating the forkchoiceUpdated futures. Since map_engine_requests awaits each match arm inline, its future is sized to the largest arm and shared by every engine request, so an unboxed forkchoiceUpdated overflowed the 2MB tokio worker stack in debug builds (Reorg Tests). Box these arms like the newPayload arms.
Squashed cherry-pick of PR #6686 (feat/state-history-journal @ e74817f) onto glamsterdam-devnet-7. First of the four deep-reorg PRs. Original commits: - ec89fa8 feat(l1): persist state-history journal at block commit - 21d8e48 feat(l1): harden state-history invariants - 2c7310a fix(l1): clippy lint — box large PersistMessage::Block variant, allow too_many_arguments - 053d1f5 docs(l1): reword in-memory commit() FIXME to NOTE (atomicity moot for ephemeral backend) - e74817f fix(l1): box forkchoiceUpdated engine handlers to avoid stack overflow (cherry picked from commit e74817f)
…g journal Cherry-pick adaptation for glamsterdam-devnet-7. Upstream, PR #6686 threads an is_batch flag from the persist worker into commit_to_disk so that state history is journaled only for live (non-batch) commits. This branch's commit_trie_if_due kept the devnet shape (canonical commit gate only, no depth gate for batch), so the applied patch left is_batch out of scope in its body. Add the parameter and pass bp.wait_for_flush at the call site, mirroring the upstream caller, without changing this branch's commit gate.
…urnal # Conflicts: # crates/networking/rpc/rpc.rs
> Part of the deep-reorg support effort tracked in #6685. This is PR 3 of 4, stacked on #6687. Wires up the orchestration layer; the deep-reorg path is scaffolded but dormant until PR 4 lifts the cap. ## Summary - Adds `install_overlay_for_reorg`, `abort_reorg`, `clear_reorg_overlay`, `is_state_in_layer_cache`, and `highest_state_history_block_number` to `store.rs`. - Section 9 reconciliation in `apply_trie_updates`: on the first new-chain commit after a deep reorg, overlay-only entries are folded into the write batch, the old `STATE_HISTORY[T..=D]` range is deleted, and the overlay is cleared. Disk advances from the OLD chain's edge directly to the new tip in one atomic write. - `reorg_in_progress: AtomicBool` + `ReorgGuard` RAII in `Blockchain`; concurrent FCUs short-circuit to `SYNCING` while a reorg apply pass is in flight. - `apply_fork_choice_with_deep_reorg` in `fork_choice.rs`: routes `StateNotReachable` into `reorg_apply_deep`, all other results fall through unchanged. `AbortReorgGuard` resets the cache on any failure or panic. - Engine API `fork_choice.rs` handler switched to the new wrapper (takes `&context.blockchain`). `try_execute_payload` stashes blocks and returns `ACCEPTED` when the parent is known but its state is not materialized; a later `forkchoiceUpdated` drives the apply. - Removes stale `#[allow(dead_code)]` markers from layering.rs now that the overlay accessors are consumed. - 4 storage unit tests for the new primitives; 2 `ReorgGuard` tests including panic-unwind. ## What's in this PR - `crates/storage/store.rs`: `install_overlay_for_reorg`, `abort_reorg`, `clear_reorg_overlay`, `is_state_in_layer_cache`, `highest_state_history_block_number`, Section 9 reconciliation inside `apply_trie_updates`. 4 new tests. - `crates/blockchain/blockchain.rs`: `reorg_in_progress: AtomicBool`, `ReorgGuard`, `enter_reorg`, `is_reorg_in_progress`, `store()` accessor. 2 new tests. - `crates/blockchain/fork_choice.rs`: `apply_fork_choice_with_deep_reorg`, `reorg_apply_deep`, `AbortReorgGuard`, `map_chain_error_for_fcu`. Head is included in the replay iterator via `chain(once((head.number, head_hash)))`, fixing the missing-head bug from #6561. - `crates/networking/rpc/engine/fork_choice.rs`: switched to `apply_fork_choice_with_deep_reorg`. - `crates/networking/rpc/engine/payload.rs`: ACCEPTED stash path for unmaterialized parent state. - `crates/networking/rpc/types/payload.rs`: `PayloadStatus::accepted()` constructor. - `crates/storage/layering.rs`: drop stale `#[allow(dead_code)]` attrs; `len`/`is_empty` keep theirs (PR 4 target). ## What's NOT in this PR - The 128-block `REORG_DEPTH_LIMIT` cap is not lifted; the deep-reorg path is unreachable on mainline until PR 4. Behavior for end users is unchanged after this merge. - `tooling/reorgs` deep-reorg simulator scenario: deferred to PR 4 (requires a CLI knob or cap removal to be end-to-end reachable). - `map_chain_error_for_fcu` collapses all `ChainError` variants to `StateNotReachable`; a richer mapping with `InvalidAncestor` + last-valid-hash is follow-up. - Crash-during-reconciliation injection test (open question in #6685): deferred. - Historical state RPC queries: not in #6685's scope. ## Test plan - `cargo build --workspace` clean. - `cargo clippy --workspace --no-deps -- -D warnings` clean. - `cargo fmt --check` clean. - `cargo test -p ethrex-storage` (45/45 pass, includes 4 new deep-reorg primitive tests). - `cargo test -p ethrex-blockchain` (2/2 new `ReorgGuard` tests pass, including panic-unwind). ## References - Tracking issue: #6685 - PR 1 (state-history journal): #6686 - PR 2 (overlay + read cascade): #6687
Code comments referenced the tracking issue (#6685), the stack PRs (#6686/#6687/#6689/#6724), the incomplete-journal issue (#7001), and the design plan ("Section 9" reconciliation, numbered ceiling cases, task numbers). All of that context lives in the tracker, not the code, so the references are removed and the comments now describe only the mechanism itself. Also corrects two comments whose content was stale, not just their references: the commit_to_disk single-writer comment (the real invariant is the persist-worker single thread plus disjoint key spaces for the FKV generator and snap-sync healing), the Overlay::from_journal SAFETY comment (now describes the pruning-pause that exists), and the deferral NOTE in apply_fork_choice (the overlay-window trie fallback now covers the after-the-fact incomplete-entry case). Includes a cargo fmt pass over the adversarial-round test (fixes the fmt gate of lint-l1).
> Part of the deep-reorg support effort tracked in #6685. This is PR 2 of 4, stacked on #6686. Adds the in-memory consumer of the journal; no orchestration yet. ## Summary - Adds `Overlay` struct in `crates/storage/layering.rs` that aggregates reverse-diffs from `STATE_HISTORY[D..=T]` (descending, older-entry-wins) to reconstruct virtual state at pivot `T-1`. - Extends `TrieLayerCache` with optional overlay field and wires `TrieWrapper::get` to cascade: layer cache -> overlay -> disk. - 14 new unit tests; 30 storage tests total pass on both InMemory and RocksDB backends. ## What's in this PR - `Overlay` struct: walks the journal range descending, applies older-entry-wins semantics, holds a bloom filter for fast miss short-circuits. - `OverlayCf` enum + `classify_by_key_length`: routes CF entries to the right column family using the same key-length logic as PR 1's `classify_trie_key`. - `OverlayError::InvalidRange { from_block, to_block }`: hard error for swapped caller args (not an I/O error variant). - `TrieLayerCache` set/clear/lookup accessors for the overlay field. - `TrieWrapper::get` cascade hook integrated into the existing read path. - 14 unit tests: descending-range load, older-wins, bloom miss, hash mismatch, missing entry, swapped-args hard error, single-entry-at-genesis, empty-but-present round trip, CF classification, no-overlay short-circuit, set/clear, iter_all_entries. ## What's NOT in this PR - Orchestration: `apply_fork_choice_with_deep_reorg`, `install_overlay_for_reorg`, and the reorg-in-progress flag are in PR 3. - The 128-block reorg cap is not lifted; that's PR 4. - `from_block` accessor rename (triggers clippy `wrong_self_convention`, suppressed); deferred to PR 3. - Swapping `AtomicBloomFilter` for a non-atomic variant during construction for perf; deferred to PR 3 or PR 4. ## Test plan - `cargo fmt` clean. - `cargo clippy` clean. - 14 new tests added in `crates/storage/layering.rs`. - `cargo test -p ethrex-storage` passes all 30 tests on both InMemory and RocksDB backends. ## References - Parent issue: #6685 - PR 1 (state history journal): #6686 --------- Co-authored-by: ilitteri <ivanlitteri@hotmail.com> Co-authored-by: Ivan Litteri <67517699+ilitteri@users.noreply.github.com>
Summary
STATE_HISTORYcolumn family that records a per-block reverse-diff journal: for every trie node or flat-KV entry overwritten at commit time, the pre-image is stored atomically in the same write batch.delete_rangecall.What's in this PR
crates/storage/journal.rs(new, 395 LOC):JournalEntrystruct, hand-rolled varint codec, 8 codec unit tests.crates/storage/api/mod.rs:delete_rangemethod added toStorageWriteBatchtrait.crates/storage/backend/rocksdb.rs:delete_range_cfimpl.crates/storage/backend/in_memory.rs: iterate-and-retaindelete_rangeimpl.crates/storage/api/tables.rs: newSTATE_HISTORYtable constant.crates/storage/layering.rs:TrieLayercarries(block_number, block_hash);commit()returnsCommitResultwith block identity instead of bareVec.crates/storage/store.rs:apply_trie_updatesopens a read view beforebegin_write, collects pre-images per CF intoFlatDiffs, stages theJournalEntry;forkchoice_update_innerprunes the journal when finalized advances. 5 new integration tests at bottom.crates/storage/trie.rs: extractedclassify_trie_keyhelper (was duplicated across three call sites).What's NOT in this PR
multi_get_cf.commit()is a pre-existing no-op (FIXME); the newdelete_rangeinherits that but it is not a regression.batch_mode == true).Test plan
cargo test -p ethrex-storage --features rocksdb).cargo fmt --checkclean.cargo clippy -p ethrex-storageclean on all touched files.Closes part of #6685. Supersedes #6561.