Repository navigation
op-reth: model-based test of the proofs engine state machine - #23451
Draft
einar-oplabs wants to merge 2 commits into
Draft
einar-oplabs wants to merge 2 commits into
einar-oplabs wants to merge 2 commits into
Conversation
einar-oplabs
force-pushed
the
einar/23382-proofs-sync-loop
branch
from
October 15, 2026 16:08
7c931b9 to
df1d7b4
Compare
einar-oplabs
force-pushed
the
einar/23381-proofs-engine-model
branch
from
October 15, 2026 16:08
4838120 to
f299c3a
Compare
einar-oplabs
force-pushed
the
einar/23382-proofs-sync-loop
branch
from
October 15, 2026 16:37
df1d7b4 to
86a72c1
Compare
einar-oplabs
force-pushed
the
einar/23381-proofs-engine-model
branch
2 times, most recently
from
October 15, 2026 21:14
2383bbd to
53ccf4c
Compare
einar-oplabs
force-pushed
the
einar/23382-proofs-sync-loop
branch
from
October 16, 2026 04:24
6d4965f to
9ba9e6c
Compare
A proptest-driven integration target that runs random interleavings of commits, reorgs, reverts, sync targets, flushes, failed saves, stale notifications and crashes over reth's persistence model, and holds the proofs engine and its store to a reference model after every step. The matrix samples reth's persistence threshold and masking window, the engine's flush cadence, the proofs path and the proof window per case. Two ignored cases reproduce tracked bugs: a crash after which reth rebuilds a different chain leaves the store's latest block off the canonical chain (ethereum-optimism/protocol-team#464), and a reth that prunes bodies through the acknowledged tip stalls the sync loop (ethereum-optimism/protocol-team#478). Seams: EngineHandle::tip() under the test-utils cfg next to flush(), the live target's chain and reorg helpers moved to tests/live/chain.rs and simulated_reth.rs so both targets share them, SimulatedReth::{persisted, revert_to, crash_and_restart}, BlockView::read_from and ProofTarget::accounts. nextest gives the engine_model binary a longer slow-timeout, since its whole proptest is one test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…sisted head The model held the store to `latest - earliest <= window`, which only a pruner that always prunes to `latest - window` satisfies. The reth 2.6 pin's pruner (#23358) prunes no further than reth's persisted head, so the store legitimately keeps more under a short window while reth's persistence lags. Each model block now records reth's persisted head when reth committed it. A save that writes block `latest` ran after that commit and reth's persisted head never moves down, so the save pruned at least to `min(latest - window, persisted_at_commit(latest))`; and no save's tip exceeds the highest head the chain has had, so no save pruned past `highest_head - window`. The earliest block must lie between the two, which holds under either pruner; on this pin the lower end is weaker than the old `latest - window`, the price of being pin-agnostic. Keeping reth persisted through `latest - window` at every save would have let the old invariant stand, but it is not equivalent: under a window of 4 it caps reth's in-memory chain at 4 blocks, which shrinks the reorgs, reverts and crash losses the pruned-window cells exercise, and it keeps the cap from ever binding, so the run would never see the pruner hold the window back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
einar-oplabs
force-pushed
the
einar/23381-proofs-engine-model
branch
from
October 16, 2026 04:24
e47a007 to
3f76589
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A randomized, model-based test of the proofs engine (
rust/op-reth/crates/trie/src/engine): random interleavings of commits, reorgs, reverts, sync targets, flushes, failed saves, stale notifications and crashes, driven over reth's persistence model, with the store and the engine held to a reference model after every step. The engine's bugs are interleaving bugs (ethereum-optimism/protocol-team#464, #477, #478), and the deterministic cases of #23441 and #23449 only cover the interleavings someone thought of.What the test does
New integration target
rust/op-reth/crates/trie/tests/engine_model.rs(its own target so that it and the live target do not share a time budget):tests/engine_model/model.rs): the canonical chain as the consensus layer holds it (hash, parent, state root per block), the proof window, a lower bound on what the store has persisted, the fork points whose hash the engine holds for certain, and the engine's possible states as a small set of(tip, sync target)candidates. Saves and the sync loop run in the background, so the model predicts a set of outcomes per step and the harness narrows it with the tip it observes once the engine settles. It knows nothing of the engine's internals.SimulatedRethfromtests/live/simulated_reth.rs(a realBlockchainProviderdriven by reth's threshold rule), extended withrevert_to,crash_and_restart(loses everything above reth's persisted tip and reopens from the database) and apersistedaccessor. Faults: a store wrapper whose next write transaction fails (tests/engine_model/faults.rs, for a failed save), and a block reported as replaced through a staleChainCommitteddelivered after reth reorged it away (the execute path then finds no parent state).tests/engine_model/actions.rs), chosen per step with proptest:Commit{n},Reorg{depth, length},Revert{depth},SyncTo{n}(reth commitsnblocks the engine only learns of through a sync target),Flush,FailNextSave,StaleCommit{depth, length},BogusReorg(a one-block reorg on a parent no block has, at a fork point the engine knows), andCrash(Same)(drop the engine without flushing, reopen the store, spawn a fresh engine,sync_to(best), then reth rebuilds the lost blocks from the same chain and the harness delivers them as live notifications, with no WAL replay).Crash(Different)exists but only the tracked-bug run enables it.tests/engine_model/invariants.rs): the engine settles on a tip the model predicts within 30 s; the store's earliest and latest blocks are on the canonical chain; the latest is at or below the engine's tip and at or above what the store had persisted; the window holds at most its configured size; the stored state at the window's ends and at the last fork point serves proofs that hash to the canonical state root there (window and views read in one MDBX read transaction, so a concurrent save cannot prune a height from under the check); every valid delivery is taken (aParentHashMismatchis accepted only where the model says the engine's tip at that height is another block, which a stale notification can cause); a reorg or revert at or below the window's earliest block is refused withUnwindBeyondEarliestnaming an earliest block the window held around the call, and ends the case since reth moved on without the store; a reorg whose first block does not build on the stored fork block is refused with the stored hash; at the end of a case, after settling, fewer blocks than the persistence threshold stay buffered. Every engine call runs on its own thread with a 30 s bound, so a stalled or panicked engine fails the case instead of hanging it (op-reth: liveness test of the proofs engine under a slow store #23392).ENGINE_MODEL_TRANSCRIPT=1prints the transcript of passing cases too.model.rs,rstest): the delivery rules (at or below the tip, next block, parent state missing, beyond the next block, mid-sync, refusals on a stale tip), reorg and revert skip-or-unwind, settling, and the restart bounds.Matrix
Sampled per case: reth persistence threshold {7, 50} x state masking {0; 30 only with threshold 50, since reth rejects masking + buffer target >= threshold} x memory-block-buffer target {5} x engine (persistence, backpressure) thresholds {(1, 2), (5, 10)} x proofs path by verification interval {0 (index), 1 (execute), 4 (mixed)} x proof window {1000 (never pruned), 4 (pruned on every save)}. Store:
MdbxProofsStoragein a tempdir. Masking is a parameter of the persistence model only: on develop reth has no partial persistence, so it only decides which persisted blocks stay in memory (see #23441).Default 24 cases, up to 24 steps each;
PROPTEST_CASES=<n>raises it (the config goes throughcontextualize_config, so the otherPROPTEST_*variables apply too).rust/.config/nextest.tomlgives theengine_modelbinary a 1 m slow period and a 5 m limit, since the whole proptest is one test and a failing case shrinks for up to a minute on top of the run.Seams
EngineHandle::tip()undercfg(any(test, feature = "test-utils")), next toflush(): the model has to wait for the sync loop to settle without side effects, and the only other way to read the tip (probing with a block above it) raises the sync target when the engine is behind.tests/live/chain.rs: the chain and store helpers oftests/live.rs, moved so that both integration targets share them (tests/live.rskeeps its scenario runner and tests).SimulatedReth::{persisted, revert_to, crash_and_restart}, andreorg_block,reorg_block_without_dataandUNRELATED_PARENTmoved there fromreorg_revert_edges.rsso both targets share them;BlockView::read_from(provider, ...)andProofTarget::accounts(...).Left for later, from the
rust-code-reviewerpass: a sharedDiskSnapshotinstore_view.rs(reorg_revert_edges.rshas one), one store wrapper with hooks instead ofStoreWithSaveFaults,StoreWithWriteGateandStoreWithReadHook, andScenario::assert_tipreadingengine.tip()instead of probing.Local verification
Tracked bugs reproduced (ignored, naming the issue)
ethereum-optimism/protocol-team#464 (store ahead of reth after an unclean shutdown).
engine_and_store_follow_the_model_through_crashes_rebuilding_a_different_chainis the random run withCrash(Different)enabled; it shrinks to the two-step sequence thatstore_follows_reth_through_a_crash_rebuilding_a_different_chainreplays deterministically:The harness restarts the ExEx the way it starts today (fresh engine,
sync_to(best), live notifications). #23358 adds a startup reconcile to the ExEx; the PR that un-skips these cases has to add the same step toHarness::crash.ethereum-optimism/protocol-team#478 (
FinishedHeightacknowledges heights not yet persisted).sync_loop_completes_while_reth_prunes_bodies_through_the_acknowledged_tipruns[SyncTo { count: 40 }, Commit { count: 1 }]with reth pruning bodies through the acknowledged tip (Config::body_pruning):The pruner model is
SimulatedReth::prune_bodies_throughfrom #23449, called with the notification tip the ExEx acknowledges.Findings from the random runs
BlockView::read_fromreads them from one transaction now.ChainCommittedwhose parent the sync loop has already replaced makes the engine returnParentHashMismatch(its tip at that height is the replacing block). That is the engine's contract and the model predicts it; for the ExEx it is fatal (handle_notificationreturns the error). The window is narrow (the loop has to cross the reorg point while the ExEx processes the stale notification) and no issue tracks it yet; it is not reproduced deterministically here.Stacked on #23449, #23445, #23442, #23441, #23437, #23434, #23429, #23428, #23421 and #23418.
Closes #23381
🤖 Generated with Claude Code
CI follow-up: the window invariant holds on both reth pins
engine_and_store_follow_the_modelheld the store tolatest - earliest <= window, which only develop's pruner satisfies; the pruner of the reth 2.6 bump with #23358 prunes no further than reth's persisted head, so the store legitimately keeps more under a short window while reth's persistence lags (the store holds blocks 0..=5, more than its window of 4). The model now records reth's persisted head at each block's commit and requires the earliest block to lie inmax(0, min(latest - window, persisted_at_commit(latest))) ..= max(0, highest_head - window), which holds under either pruner (on develop the lower end is weaker than the oldlatest - window, the price of being pin-agnostic). The commit explains why this was chosen over keeping reth persisted throughlatest - windowat every save: that is not equivalent, since it caps reth's in-memory chain at the window and keeps the cap from ever binding. The twoCrash(Different)cases stay ignored (#464; they need #23358's startup reconcile in the harness). Verified withcargo test -p reth-optimism-trie --test engine_model: 23 passed, 3 ignored on develop; on the bump evaluation treeengine_and_store_follow_the_modeland the model's unit tests pass (its un-ignoredCrash(Different)cases fail there for the #464 reason, unchanged).