Repository navigation
feat: replay an explicit block list with --block-list - #234
Conversation
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>
… 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>
…ock-list-v2 # Conflicts: # AGENTS.md
…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>
Claude review status
✅ Review clean Last reviewed: 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
left a comment
There was a problem hiding this comment.
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.
Summary
Adds
--block-list <file>tostateless-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_bloomand verifies neither the witness nor the state roots, so its re-sweep of that pool is not a validation.#starts a comment (on its own line or after a number) and blank lines are skipped, so aninspect --dump-poolfile works as is. Duplicates collapse; any other line, or a list with no numbers, fails the run, naming the line, before anything is fetched.ValidatorFetcher::fetch(R2 first when configured, then the RPC witness chain) andValidatorProcessor::process, with up to--block-list-concurrencyblocks 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_pipelineis bypassed: its fetcher window, advancer and halt/retry cycle all assume a contiguous range, and theBlockFetcher/BlockProcessortraits are the reuse point.--start-block,--end-blockand--report-validation-endpoint, so a sparse set of heights can never move the upstream validated pointer.--genesis-fileis required and replaces any genesis stored in the data dir, so a stale one cannot validate blocks past a hardfork it does not schedule.--block-list-timeout-secs(default 300) and recorded asfetch_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-secsand--block-list-concurrencyreject 0.validation_failedoutcome (ValidatorProcessor::processalready reports it); a panic in the replay glue itself is a bug and ends the run.results.jsonl(one flushed line per block, with the error text verbatim;retryableis true for fetch failures, both timeouts, and transient validation failures) andfailed.txt(the failures in the input format, ready to be passed back as--block-list) to--block-list-output-dir(default--data-dir).failed.txtholds 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 isstateless_common::block_list, below.Shared with coverage-replayer
backfill --blocks-file's and this one, and they had drifted (trailing comments). Both now callstateless_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.stateless_common::atomic_write(write_atomic,write_scratch), andfailed.txtis written withwrite_atomic. The temporaries'.tmpsuffix is exported asTEMP_SUFFIXand is whatDataDir::clear_leftoverssweeps.binary_idis unchanged (megaevm:f78e75c6d2cf:fnv29c78292ed52d3f7).This supersedes the unmerged
liquan/feat/replay-block-listbranch, 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,
retryableon timeouts, the bounded head read, and the per-core validate cap).Known and left as is: a
process_timeoutfrees its validate slot while thespawn_blockingvalidation 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 outertokio::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.
failed.txtempty, no WARN or ERROR lines.replay, drops a panic wrapper that only covered the async glue, and trims the printed summary; the fetch and validate path is unchanged.Tests
--block-list.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_scratchround 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 --checkandcargo sort --checkall pass.🤖 Generated with Claude Code