Skip to content

feat: replay an explicit block list with --block-list - #234

Merged
flyq merged 8 commits into
mainfrom
liquan/feat/replay-block-list-v2
Oct 28, 2026
Merged

flyq merged 8 commits into
mainfrom
liquan/feat/replay-block-list-v2

Conversation

@flyq

@flyq flyq commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds --block-list <file> to stateless-validator: replay exactly the listed block numbers through the validator's own fetch and validate path, then exit.
The chain-following pipeline only advances over a contiguous range, so checking a build against a sparse, hand-picked set of heights (a coverage candidate pool, a list of past incidents) had no entry point.
The immediate use is validating the ~33k-block coverage candidate pool under the reth v2.3 / revm 40 / mega-evm upgrade (#184).
coverage-replayer only compares gas_used / receipts_root / logs_bloom and verifies neither the witness nor the state roots, so its re-sweep of that pool is not a validation.

  • One decimal block number per line; # starts a comment (on its own line or after a number) and blank lines are skipped, so an inspect --dump-pool file works as is. Duplicates collapse; any other line, or a list with no numbers, fails the run, naming the line, before anything is fetched.
  • Each block goes through ValidatorFetcher::fetch (R2 first when configured, then the RPC witness chain) and ValidatorProcessor::process, with up to --block-list-concurrency blocks in flight (default: the pipeline's fetch window, twice the physical cores) and, as in the pipeline, at most one validation per physical core at once. run_pipeline is bypassed: its fetcher window, advancer and halt/retry cycle all assume a contiguous range, and the BlockFetcher / BlockProcessor traits are the reuse point.
  • A pass is the production bar minus one check: the witness verified and replay reproduced the header (state root, withdrawals root, receipts root, logs bloom, gas used). Whether the witness pre-state equals the parent's post-state is not checked, because the parent is not in the list.
  • Nothing is advanced or reported: no anchor, no canonical-chain writes. The flag excludes --start-block, --end-block and --report-validation-endpoint, so a sparse set of heights can never move the upstream validated pointer.
  • --genesis-file is required and replaces any genesis stored in the data dir, so a stale one cannot validate blocks past a hardfork it does not schedule.
  • The RPC client retries transport errors forever, so the fetch and the validation of each block are each bounded by --block-list-timeout-secs (default 300) and recorded as fetch_timeout / process_timeout. The head is read once up front, so the R2 client can tell a block not uploaded yet from a hole in the bucket, and that read is bounded the same way: an unreachable endpoint fails the run instead of hanging it. --block-list-timeout-secs and --block-list-concurrency reject 0.
  • A failing block never stops the run, and the exit status is non-zero if any block did not validate. A panic while validating is that block's validation_failed outcome (ValidatorProcessor::process already reports it); a panic in the replay glue itself is a bug and ends the run.
  • The run writes results.jsonl (one flushed line per block, with the error text verbatim; retryable is true for fetch failures, both timeouts, and transient validation failures) and failed.txt (the failures in the input format, ready to be passed back as --block-list) to --block-list-output-dir (default --data-dir). failed.txt holds only a comment until the run finishes, so an interrupted run never leaves the previous run's retry list beside its own results.

The mode lives in bin/stateless-validator/src/replay.rs; the list format it reads is stateless_common::block_list, below.

Shared with coverage-replayer

  • The block-list format had two parsers, backfill --blocks-file's and this one, and they had drifted (trailing comments). Both now call stateless_common::block_list::{parse_block_list, read_block_list}, which keeps coverage-replayer's acceptance of trailing comments, so no existing pool or list stops parsing, and refuses an empty list for both callers.
  • coverage-replayer's tmp+rename writers move unchanged to stateless_common::atomic_write (write_atomic, write_scratch), and failed.txt is written with write_atomic. The temporaries' .tmp suffix is exported as TEMP_SUFFIX and is what DataDir::clear_leftovers sweeps.
  • stateless-common gains no dependency or dev-dependency, since its Cargo.lock entry is in coverage-replayer's closure: the coverage store's binary_id is unchanged (megaevm:f78e75c6d2cf:fnv29c78292ed52d3f7).

This supersedes the unmerged liquan/feat/replay-block-list branch, which was stacked on the pre-merge upgrade branch.
Its two commits cherry-pick onto main without conflicts. On top of them: a line in AGENTS.md, and fixes from review (the stale retry list, zero-valued flags, retryable on timeouts, the bounded head read, and the per-core validate cap).
Known and left as is: a process_timeout frees its validate slot while the spawn_blocking validation keeps running, so blocks that each validate for longer than the timeout can briefly exceed the per-core cap.
The deeper fix for that, and for timeouts that cannot say which call stalled, is deadline-aware RPC inside ValidatorFetcher / ValidatorProcessor (get_*_with_deadline, get_codes_with_deadline) instead of an outer tokio::time::timeout; that changes shared code paths and is left for a follow-up.

Mainnet validation

Built from this branch at 88e6bad (main merged in: reth v2.3 / revm 40 / mega-evm f78e75c) and run on a 16-core dev host over the union of the coverage candidate pools: cov_v170f's 35,678-block pool and cov_v171/v172's 32,779-block pool (the same block set in both), 37,569 blocks from 1 to 29,830,741.

  • 37,569 validated, 0 failed, in 988 s (38 blocks/s overall). Exit 0, failed.txt empty, no WARN or ERROR lines.
  • Defaults throughout: 32 blocks in flight, 16 validating at once. Witnesses came from the RPC witness endpoint (no R2 for this run).
  • Per-block time (fetch + validate) by height:
Heights Blocks p50 p90 Max
below 7M 1,543 13.5 s 19.8 s 33.8 s
7M–15M 15,547 0.39 s 0.68 s 4.4 s
15M–25M 15,809 0.39 s 0.65 s 5.0 s
25M and up 4,670 0.36 s 0.64 s 3.8 s
  • Blocks below 7M are 4% of the list but half of the total block time; the five slowest (28–34 s) are all between 6.64M and 6.82M. The split between fetch and execution there was not measured.
  • The one commit after 88e6bad (e4ffc4d) renames the module to replay, drops a panic wrapper that only covered the async glue, and trims the printed summary; the fetch and validate path is unchanged.

Tests

  • Unit: list parsing (pool header, bad lines, empty list), summary totals and slowest blocks, the retry list round-tripping through the parser, the JSONL line shape.
  • Unit: an interrupted run's placeholder retry list is refused when passed back as --block-list.
  • Integration (mock RPC, one validate slot so runs with more blocks in flight wait for it): flag combinations, including zero values and the timeout default; replay of a non-contiguous subset of the synthetic fixtures; an unfetchable block timing out, marked retryable, while the blocks queued behind it still validate (single slot); a lookup answered with another height's block is never counted for the requested height; an unreachable head fails the run within its bound and leaves neither the previous results nor the previous retry list.
  • Unit (stateless-common): the shared parser (header and trailing comments, sort and dedup, bad lines named, empty text) and reader (path:line: errors, empty list refused, missing file); write_atomic / write_scratch round trips and temp cleanup on failure.
  • cargo test -p stateless-common -p stateless-validator -p coverage-replayer, cargo clippy --workspace --all-targets --all-features, cargo fmt --all --check and cargo sort --check all pass.

🤖 Generated with Claude Code

flyq and others added 3 commits October 26, 2026 11:12
The chain-following pipeline advances over a contiguous range and cannot visit a sparse set of heights, so checking a build against a hand-picked set of blocks (a coverage candidate pool, a list of past incidents) had no entry point.

`--block-list <file>` reads one block number per line (blank lines and `#` lines skipped, so a candidate-pool file works as is) and drives `ValidatorFetcher::fetch` and `ValidatorProcessor::process` directly, bypassing `run_pipeline`, with up to `--block-list-concurrency` blocks in flight. A pass therefore means the production validator would accept the block, at the same bar as the pipeline minus the parent-root continuity check, which a sparse list cannot supply.

- Nothing is advanced or reported: no anchor is set, the canonical chain is untouched, and the flag excludes `--start-block`, `--end-block` and `--report-validation-endpoint`, because a sparse set of heights must never move the upstream validated pointer.
- `--genesis-file` is required, so a genesis stored in the data dir cannot silently validate blocks past a hardfork it does not know.
- The RPC client retries transport errors forever, so each of the fetch and the validation of a block is bounded by `--block-list-timeout-secs` (default 300) and recorded as `fetch_timeout` / `process_timeout`; otherwise one unservable block would hold the run open indefinitely.
- A failing block never stops the run. The run ends non-zero if any block did not validate, and writes `results.jsonl` (one flushed line per block, error text verbatim) and `failed.txt` (the failures in the input format, to be passed back as `--block-list`).
- The head is read once before the first block so the R2 client can tell a block not yet uploaded from a hole in the bucket.

Tests cover list parsing and its error lines, the flag combinations, a replay of a non-contiguous subset of the synthetic fixtures through the mock RPC, and an unfetchable block timing out while the blocks around it validate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…tinuation and the wrong-height case

The flag-combination test reads the environment through clap's conflict rules, so it failed whenever another test had STATELESS_VALIDATOR_END_BLOCK set; it now holds the env lock.

The timeout test puts the failing block first with a single slot, so the blocks behind it only validate if a failure frees the slot and the run goes on. A test for a lookup answered with another height's block pins that such a block is never counted for the height that was asked for. The block-number comparison after the fetch was unreachable (the RPC client refuses a mismatched header and keeps retrying) and is removed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
flyq and others added 4 commits October 26, 2026 12:02
… panics and a hung head read

- `failed.txt` is truncated to a comment when the run starts and only gets its list at the end, so an interrupted run no longer leaves the previous run's retry list beside its own results. The placeholder parses as an empty list, which is an error, so passing it back fails loudly.
- `--block-list-timeout-secs` and `--block-list-concurrency` reject 0 at parse time. Zero concurrency used to fail only after `results.jsonl` had been truncated, and a zero timeout recorded every block as `fetch_timeout`.
- A panic inside one block's replay is caught inside that block's task, so its outcome carries its own number and is timed from its own start. A panic used to be timed from the start of the run, which put it at the top of the slowest list, and a lost task id fell back to block 0. This also drops the task-id map that existed only to recover the number.
- `retryable` is now true for `fetch_timeout`, `fetch_failed` and `process_timeout`, which say nothing about the block itself, not only for transient validation failures.
- The chain-head read before the first block is bounded by `--block-list-timeout-secs` like each block's stages, so an unreachable endpoint fails the run instead of hanging it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at the core count

- Validation runs under a semaphore sized to the pipeline's worker count (one per physical core), taken outside the validation timeout. `--block-list-concurrency` still bounds the blocks in flight, which defaults to the pipeline's fetch window (twice the cores), so up to that many validations had been running at once on `spawn_blocking`, oversubscribing the cores and inflating per-block times.
- A transient validation failure is classified through `BlockProcessor::error_action`, the seam the pipeline uses, rather than by reading the flag behind it.
- `run` takes the output directory, creates it, and owns the two file names (`RESULTS_FILE`, `RETRY_FILE`), replacing the `Output` struct and the joins in app.rs.
- `ReplaySummary::validated` is derived from `total` and `failed`; the per-status counts come from one map pass; the progress interval is a constant rather than a config field set the same way by every caller.
- `--block-list-timeout-secs` is a `u64` with clap's `default_value_t = 300`, so `--help` shows the default; a default does not satisfy `requires`, so the flag is still rejected without `--block-list`.
- The block-list integration tests share one helper for the DB, contract cache, chain spec, client, fetcher and processor. The mock replays use one validate slot, so the subset test also exercises the wait for a slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hrough stateless-common

The block-list format `coverage-replayer inspect --dump-pool` writes was parsed twice, by `backfill --blocks-file` and by the validator's `--block-list`, and the two copies had already drifted: coverage-replayer took a trailing `# comment` and the validator rejected it. Both now call `stateless_common::block_list`:

- `parse_block_list(text)`: `#` starts a comment on its own line or after a number, blank lines are skipped, any other non-number line fails naming its line, and the result is sorted and deduplicated. Trailing comments are accepted, as `backfill` always did, so no existing pool or hand-written list stops parsing.
- `read_block_list(path)`: reads the file, names a bad line as `path:line:`, and refuses a list with no numbers, the check both callers had made on their own.

coverage-replayer's tmp+rename writers move to `stateless_common::atomic_write` (`write_atomic` with fsyncs, `write_scratch` without) unchanged, and the validator writes `failed.txt` with `write_atomic`, so a run killed mid-write cannot leave half a retry list. The `.tmp` suffix the temporaries end with is exported as `TEMP_SUFFIX` and is what `DataDir::clear_leftovers` sweeps, so the sweep cannot drift from the naming. The coverage-specific reason the fsync matters stays at `archive_profile`.

stateless-common gains no dependency, including no dev-dependency (its tests use a small `ScratchDir` instead of `tempfile`), because its Cargo.lock entry is in coverage-replayer's dependency closure: the coverage store's `binary_id` is unchanged (`megaevm:f78e75c6d2cf:fnv29c78292ed52d3f7`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mega-maxwell

mega-maxwell Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Claude review status

Living comment — rewritten in place. The review workflow keeps this single comment up to date instead of posting a new one each round, so it always describes the latest reviewed commit and the earlier text is intentionally gone. No reply is needed here; reply to a finding in its own review thread, and answer an open question in a reply on this PR. The next review round reconciles your answer.

✅ Review clean

Last reviewed: 88e6bad8..e4ffc4dc · updated 2026-10-26T07:40:31+00:00

New this round: 0 finding(s), 0 question(s) · Resolved this round: 0 · Open questions: 0

… its redundant panic layer

`stateless_common::block_list` is the list file format, shared with coverage-replayer; the validator module of the same name is the mode that runs a list (per-block timeouts, bounded concurrency, results and retry files, the summary). The shared name read as if the parser had not moved, so the module is now `replay`.

- The `catch_unwind` wrapper (`settle`, `panic_message`) and the `futures` dependency it needed are gone. `ValidatorProcessor::process` already runs validation on the blocking pool and turns a panic there into that block's `ValidationFailure`, so the wrapper only ever guarded this module's async glue, where a panic is a bug: such a join error now ends the run, like a failing results sink. Cargo.lock is back to main's.
- `replay_one` builds its `BlockOutcome` once from a `(hash, status, message, retryable)` match instead of through three builder methods.
- The printed summary no longer lists failures one by one: it gives counts by status and the slowest blocks, and each failure's error is in `results.jsonl` and its number in `failed.txt`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@vincent-k2026 vincent-k2026 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. --block-list fetches every listed height and exits non-zero if any fail; a wrong-height answer times out instead of counting as validated. The missing parent-continuity check is the stated sparse-list contract and cannot move the validated pointer.

@flyq
flyq merged commit 8ca2822 into main Oct 28, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants