Skip to content

Commit b690ca4

Browse files
edg-lilitteri
andauthored
feat(l1): overlay + read cascade for deep reorgs (#6687)
> 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>
1 parent ec208dc commit b690ca4

39 files changed

Lines changed: 4511 additions & 151 deletions

.github/workflows/deep_reorg.yaml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
name: Deep reorg (manual)
2+
3+
# Manual trigger only -- this scenario takes ~1 hour and requires a Kurtosis
4+
# cluster. It is NOT a PR gate.
5+
on:
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
11+
env:
12+
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
13+
CARGO_NET_RETRY: "10"
14+
15+
jobs:
16+
deep-reorg:
17+
name: Deep reorg partition test
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Checkout sources
21+
uses: actions/checkout@v6
22+
23+
- name: Free Disk Space
24+
uses: ./.github/actions/free-disk
25+
26+
- name: Setup Rust Environment
27+
uses: ./.github/actions/setup-rust
28+
29+
- name: Build ethrex (release, rocksdb + metrics)
30+
run: |
31+
cargo build --release --bin ethrex --features rocksdb,metrics
32+
33+
- name: Build ethrex Docker image
34+
run: |
35+
docker build -t ethrex:local .
36+
37+
- name: Install Kurtosis CLI
38+
run: |
39+
echo "deb [trusted=yes] https://apt.fury.io/kurtosis-tech/ /" \
40+
| sudo tee /etc/apt/sources.list.d/kurtosis.list
41+
sudo apt-get update
42+
sudo apt-get install -y kurtosis-cli
43+
kurtosis version
44+
45+
- name: Start Kurtosis engine
46+
run: kurtosis engine start
47+
48+
- name: Run deep-reorg enclave
49+
id: kurtosis-run
50+
run: |
51+
kurtosis run --enclave deep-reorg \
52+
github.com/ethpandaops/ethereum-package \
53+
--args-file tooling/reorgs/disruptoor/network-partition-deep-reorg.yaml
54+
55+
- name: Collect enclave logs on failure
56+
if: failure()
57+
run: |
58+
kurtosis enclave dump deep-reorg /tmp/enclave-logs || true
59+
60+
- name: Upload enclave logs as artifact
61+
if: failure()
62+
uses: actions/upload-artifact@v6
63+
with:
64+
name: deep-reorg-enclave-logs
65+
path: /tmp/enclave-logs
66+
if-no-files-found: ignore
67+
68+
- name: Tear down enclave
69+
if: always()
70+
run: kurtosis enclave rm deep-reorg --force || true

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/ethrex/cli.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,14 @@ pub struct Options {
460460
env = "ETHREX_PRECOMPUTE_WITNESSES"
461461
)]
462462
pub precompute_witnesses: bool,
463+
#[arg(
464+
long = "max-reorg-depth",
465+
value_name = "MAX_REORG_DEPTH",
466+
help = "Optional operator override for the maximum reorg depth. Omit for finality-bounded cap. Set to 0 to disable deep reorgs entirely. Set to d to reject reorgs of depth > d.",
467+
help_heading = "Node options",
468+
env = "ETHREX_MAX_REORG_DEPTH"
469+
)]
470+
pub max_reorg_depth: Option<u64>,
463471
}
464472

465473
impl Options {
@@ -554,6 +562,7 @@ impl Default for Options {
554562
no_bal_parallel_exec: false,
555563
no_bal_prefetch: false,
556564
no_bal_parallel_trie: false,
565+
max_reorg_depth: None,
557566
}
558567
}
559568
}

cmd/ethrex/initializers.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,7 @@ pub async fn init_l1(
784784
bal_parallel_exec_enabled: !opts.no_bal_parallel_exec,
785785
bal_prefetch_enabled: !opts.no_bal_prefetch,
786786
bal_parallel_trie_enabled: !opts.no_bal_parallel_trie,
787+
max_reorg_depth: opts.max_reorg_depth,
787788
gap_admit_occupancy_threshold: opts.mempool_gap_admit_occupancy_threshold,
788789
},
789790
);

cmd/ethrex/l2/command.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,7 @@ impl Command {
494494
latest_hash_on_batch,
495495
latest_hash_on_batch,
496496
latest_hash_on_batch,
497+
None,
497498
)
498499
.await?;
499500

cmd/ethrex/l2/initializers.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ pub async fn init_l2(
256256
bal_parallel_exec_enabled: true,
257257
bal_prefetch_enabled: true,
258258
bal_parallel_trie_enabled: true,
259+
max_reorg_depth: opts.node_opts.max_reorg_depth,
259260
gap_admit_occupancy_threshold: opts.node_opts.mempool_gap_admit_occupancy_threshold,
260261
};
261262

crates/blockchain/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ tokio-util.workspace = true
3636
[dev-dependencies]
3737
tokio = { workspace = true, features = ["full"] }
3838
ethrex-levm.workspace = true
39+
tempfile.workspace = true
3940

4041
[lib]
4142
path = "./blockchain.rs"
@@ -45,7 +46,7 @@ default = ["secp256k1"]
4546
rayon = ["ethrex-vm/rayon"]
4647
secp256k1 = ["ethrex-common/secp256k1", "ethrex-vm/secp256k1", "rayon"]
4748
c-kzg = ["ethrex-common/c-kzg", "ethrex-vm/c-kzg"]
48-
metrics = ["ethrex-metrics/transactions"]
49+
metrics = ["ethrex-metrics/transactions", "ethrex-metrics/metrics"]
4950
eip-8025 = ["ethrex-common/eip-8025"]
5051
testing = []
5152

crates/blockchain/blockchain.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,12 @@ pub struct Blockchain {
224224
/// Set to true after initial sync completes, never reset to false.
225225
/// Does not reflect whether an ongoing sync is in progress.
226226
is_synced: AtomicBool,
227+
/// Set while a deep-reorg apply pass is in flight. Concurrent
228+
/// FCUs from the engine API short-circuit to SYNCING while this is set,
229+
/// and journal pruning in `forkchoice_update_inner` defers until the apply
230+
/// pass clears it. Managed via [`enter_reorg`](Self::enter_reorg) which
231+
/// returns a [`ReorgGuard`] RAII that clears the flag on drop.
232+
reorg_in_progress: AtomicBool,
227233
/// Configuration options for blockchain behavior.
228234
pub options: BlockchainOptions,
229235
/// Cache of recently built payloads.
@@ -273,6 +279,22 @@ impl std::fmt::Debug for PrewarmedCache {
273279
}
274280
}
275281

282+
/// RAII guard that clears [`Blockchain::reorg_in_progress`] on drop. Returned by
283+
/// [`Blockchain::enter_reorg`]. Ensures the flag is reset on every exit path of
284+
/// the apply pass, including panics that unwind through the guard.
285+
pub struct ReorgGuard<'a> {
286+
blockchain: &'a Blockchain,
287+
}
288+
289+
impl Drop for ReorgGuard<'_> {
290+
fn drop(&mut self) {
291+
self.blockchain.storage.set_journal_pruning_paused(false);
292+
self.blockchain
293+
.reorg_in_progress
294+
.store(false, Ordering::Release);
295+
}
296+
}
297+
276298
/// Configuration options for the blockchain.
277299
#[derive(Debug, Clone)]
278300
pub struct BlockchainOptions {
@@ -311,6 +333,13 @@ pub struct BlockchainOptions {
311333
/// `--no-bal-parallel-trie`) to fall back to streaming `AccountUpdate`s from
312334
/// the executor and merkleizing post-execution.
313335
pub bal_parallel_trie_enabled: bool,
336+
/// Optional operator override for the maximum reorg depth. `None` ; cap is purely
337+
/// physical (layer-cache retention plus journal reach; bounded indirectly by finality
338+
/// because finality advances prune the journal). `Some(d)` ; reject reorgs of
339+
/// depth `> d` with `-38006 TooDeepReorg`. WARNING: `Some(0)` rejects EVERY reorg,
340+
/// including routine 1-2 block reorgs on a healthy network — it is NOT the old
341+
/// 128-block cap. Use a small positive value to approximate the pre-stack behavior.
342+
pub max_reorg_depth: Option<u64>,
314343
/// Mempool occupancy percentage (0-100) at or above which incoming
315344
/// transactions with a nonce gap relative to the sender's on-chain nonce
316345
/// are rejected. Setting to 100 disables the check.
@@ -330,6 +359,7 @@ impl Default for BlockchainOptions {
330359
bal_parallel_exec_enabled: true,
331360
bal_prefetch_enabled: true,
332361
bal_parallel_trie_enabled: true,
362+
max_reorg_depth: None,
333363
gap_admit_occupancy_threshold: DEFAULT_GAP_ADMIT_OCCUPANCY_THRESHOLD,
334364
}
335365
}
@@ -438,6 +468,7 @@ impl Blockchain {
438468
storage: store,
439469
mempool: Mempool::new(blockchain_opts.max_mempool_size),
440470
is_synced: AtomicBool::new(false),
471+
reorg_in_progress: AtomicBool::new(false),
441472
payloads: Arc::new(TokioMutex::new(Vec::new())),
442473
options: blockchain_opts,
443474
merkle_pool: Self::build_merkle_pool(),
@@ -459,6 +490,7 @@ impl Blockchain {
459490
storage: store,
460491
mempool: Mempool::new(MAX_MEMPOOL_SIZE_DEFAULT),
461492
is_synced: AtomicBool::new(false),
493+
reorg_in_progress: AtomicBool::new(false),
462494
payloads: Arc::new(TokioMutex::new(Vec::new())),
463495
options: BlockchainOptions::default(),
464496
merkle_pool: pool,
@@ -471,6 +503,7 @@ impl Blockchain {
471503
storage: store,
472504
mempool: Mempool::new(MAX_MEMPOOL_SIZE_DEFAULT),
473505
is_synced: AtomicBool::new(false),
506+
reorg_in_progress: AtomicBool::new(false),
474507
payloads: Arc::new(TokioMutex::new(Vec::new())),
475508
options: BlockchainOptions::default(),
476509
merkle_pool: Self::build_merkle_pool(),
@@ -498,6 +531,41 @@ impl Blockchain {
498531
Ok(())
499532
}
500533

534+
/// Returns a reference to the underlying [`Store`]. Used by the deep-reorg
535+
/// orchestrator to drive the storage-side primitives.
536+
pub fn store(&self) -> &Store {
537+
&self.storage
538+
}
539+
540+
/// Returns `true` while a deep-reorg apply pass is in flight. Set by
541+
/// [`enter_reorg`](Self::enter_reorg); the engine API's FCU handler should
542+
/// short-circuit to SYNCING when this is `true`.
543+
pub fn is_reorg_in_progress(&self) -> bool {
544+
self.reorg_in_progress.load(Ordering::Acquire)
545+
}
546+
547+
/// Attempts to mark the start of a deep-reorg apply pass. Returns a RAII
548+
/// guard that clears the flag on drop (success, error, or panic) if no other
549+
/// pass is in flight, or `None` if one already is. This is a test-and-set:
550+
/// the flag transition `false -> true` is atomic, so two concurrent FCUs
551+
/// that both reach the deep path can never both acquire the guard. The loser
552+
/// short-circuits to SYNCING and the CL retries. At most one apply pass runs
553+
/// at a time.
554+
pub fn enter_reorg(&self) -> Option<ReorgGuard<'_>> {
555+
self.reorg_in_progress
556+
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
557+
.is_ok()
558+
.then(|| {
559+
// Also pause STATE_HISTORY pruning for the whole pass:
560+
// `Overlay::from_journal` reads entries with no snapshot
561+
// isolation, and syncer-driven forkchoice updates bypass this
562+
// mutex, so a concurrent finality advance could otherwise prune
563+
// the journal out from under overlay construction.
564+
self.storage.set_journal_pruning_paused(true);
565+
ReorgGuard { blockchain: self }
566+
})
567+
}
568+
501569
/// Executes a block withing a new vm instance and state
502570
fn execute_block(
503571
&self,
@@ -4452,3 +4520,58 @@ pub fn compute_sharded_storage_root(
44524520

44534521
Ok((root_hash.finalize(&NativeCrypto), nodes))
44544522
}
4523+
4524+
#[cfg(test)]
4525+
mod reorg_guard_tests {
4526+
use super::*;
4527+
use ethrex_storage::{EngineType, Store};
4528+
4529+
fn make_blockchain() -> Blockchain {
4530+
let dir = tempfile::tempdir().unwrap();
4531+
let store = Store::new(dir.path(), EngineType::InMemory).unwrap();
4532+
Blockchain::default_with_store(store)
4533+
}
4534+
4535+
/// `enter_reorg` SHALL set the flag; the returned guard SHALL clear it on drop.
4536+
#[test]
4537+
fn reorg_guard_sets_and_clears_flag() {
4538+
let blockchain = make_blockchain();
4539+
assert!(!blockchain.is_reorg_in_progress(), "flag starts false");
4540+
4541+
{
4542+
let guard = blockchain.enter_reorg();
4543+
assert!(guard.is_some(), "first enter_reorg must acquire the guard");
4544+
assert!(
4545+
blockchain.is_reorg_in_progress(),
4546+
"flag must be set while guard is alive"
4547+
);
4548+
// A second attempt while the first guard is alive must fail the
4549+
// test-and-set and return None.
4550+
assert!(
4551+
blockchain.enter_reorg().is_none(),
4552+
"concurrent enter_reorg must not acquire a second guard"
4553+
);
4554+
}
4555+
assert!(
4556+
!blockchain.is_reorg_in_progress(),
4557+
"flag must clear when guard drops"
4558+
);
4559+
}
4560+
4561+
/// The guard SHALL clear the flag even when its scope ends via panic unwinding.
4562+
#[test]
4563+
fn reorg_guard_clears_flag_on_panic() {
4564+
let blockchain = make_blockchain();
4565+
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4566+
let _guard = blockchain.enter_reorg();
4567+
assert!(blockchain.is_reorg_in_progress());
4568+
panic!("simulated apply failure");
4569+
}));
4570+
// guard is dropped by the unwind; flag must be clear below.
4571+
assert!(result.is_err(), "panic must propagate out of the scope");
4572+
assert!(
4573+
!blockchain.is_reorg_in_progress(),
4574+
"flag must be cleared after a panicking guard scope"
4575+
);
4576+
}
4577+
}

0 commit comments

Comments
 (0)