sequencer, rawdb, ethapi: audit the store for the window a node did not watch - #2388
Conversation
…ot watch bor_getInvalidPreconfBlocks reported a node's downtime as clean no matter what the store held during it. Invalidation records come from one place - the in-memory PendingStore reconciled against canonical blocks - and a node that was down holds no entries for the heights it missed, so nothing is compared and nothing is recorded. Silence was indistinguishable from every preconfirmation in the window having held. Nothing recorded where the consumer last watched either. On a cold start resumeRequest anchors at the local canonical head, so the window between the last watched height and the current head was never requested, and the store's caught-up-to-tip frame was received and discarded rather than ending a catch-up phase. An audit pass now walks the unaudited window and compares the store's final sealed header at each height against the canonical hash, recording a mismatch as unobserved_mismatch: the store sealed something that never became canonical, and this node served nothing from it. The other reasons all mean a preconfirmation reached callers and was then invalidated, which is a stronger claim, so it gets its own reason rather than sharing theirs. The comparison needs no execution and no historic state, which is what makes it viable on a pruned node, and it runs on its own connection off the session loop so closing a gap never delays a reconnect. It is triggered on consumer start, on every session loss, and when a stream reaches the tip. Session loss gets a pass because a stream that drops while the node stays up leaves the same kind of hole as a restart. PreconfAuditedThrough carries the position across restarts, and it may only step one height at a time from the live path: a jump would carry it over the catch-up backlog the session dropped without comparing, so a gap asks for a pass instead. PreconfUnauditedThrough records a window the depth bound skipped, so an empty invalidation range there reads as unknown rather than clean. A node with no watermark seeds at the current head and audits nothing - auditing backwards from an arbitrary point on first enable would produce records with no operational meaning. GetBlock returns a height's whole generation, transaction records included, so a wide walk pays for payloads the seal comparison never reads. That is why sequencer.audit-window bounds one pass, defaulting to roughly an hour of blocks. The canonical-head reconciliation and the backlog helpers move into their own files to keep consumer.go and consumer_session.go inside the repository size checks, matching how exec.go was kept separate on this branch. Separately, an open more than backlogOpenDepth below the canonical head is now dropped without the parent-state lookup that fails on a pruned node, and without skip's reset of the speculative tip - nothing was ever published for a height the chain already holds, so there is nothing to invalidate. A producer rebuilding the tip after a rotation lands within a block or two of the head and still executes; its generation can yet win the height. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## cffls/sequence-publisher #2388 +/- ##
============================================================
+ Coverage 57.03% 57.11% +0.07%
============================================================
Files 952 953 +1
Lines 174451 174862 +411
============================================================
+ Hits 99497 99871 +374
- Misses 69283 69315 +32
- Partials 5671 5676 +5
... and 31 files with indirect coverage changes
🚀 New features to boost your workflow:
|
…d code The audit pass was only reachable through its injected fetch hook, so runAuditPass itself - the dial, the per-height GetBlock, the guarded watermark write - was almost entirely untested. It now runs against a real ConsumerService server on a local listener, with the unreachable-endpoint and nothing-to-audit cases alongside it; the latter asserts no connection is opened at all, since a trigger fires on every session retry and must not each cost a dial. Filling the remaining branches turned up what the mutation run had been pointing at: nothing asserted the pass's own counters, the window depth boundary, the checkpoint write, recordVerdict's four verdicts, or what happens when the database refuses a write. The canonical-head handler and the live marker were covered only through their helpers, so removing either call site went unnoticed. behindHead is split out of behindCanonicalHead so the depth boundary is testable without a chain deep enough to reach it. Reverts the canonical_head.go extraction from the previous commit. Moving evictLoop and the canonical-head reconciliation into a new file made every one of those pre-existing lines count as added, which pulled code this change does not touch into both the patch-coverage and the mutation denominators. The backlog helpers stay in backlog.go - that file holds only new code. consumer.go returns to 544 lines, inside the 800-line check CI applies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
|
codegenie review |
🧞 Codegenie ReviewTwo verified findings, both low severity, in the new startup audit-pass code. One is a real correctness gap in the rawdb watermark accessor: Coverage: 28 of 33 hunks reviewed (14 deep, 9 normal, 5 light); 5 hunks skipped by configured rules ( Open questions not resolved during review (worth confirming before merge): whether a canonical head at watermark+1 can advance the mark past a height whose store entries have not yet streamed in; whether CoverageReviewed 28/33 hunks.
|
There was a problem hiding this comment.
🟡 Changes recommended
There are a few concrete correctness/API semantics issues to address (including a uint64 overflow risk in the new backlog predicate and JSON presence semantics for the new audit-status RPC).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR adds an “audit pass” to the Sequence Store consumer so a node can retrospectively detect and record invalid preconfirmations for chain heights it did not observe while offline (or while the stream was replaying history), and exposes audit watermarks via a new Bor RPC method.
Changes:
- Introduces a bounded, non-executing audit walker (
eth/sequencer/audit.go) that compares the store’s final sealed header per height against canonical hashes and recordsunobserved_mismatchinvalidations plus persisted audit/unaudited watermarks. - Extends the sequencer consumer to (a) track when it is truly “watching the tip” and (b) drop deeply-backlogged opens without hitting pruned-state paths.
- Adds rawdb accessors + schema keys for
PreconfAuditedThrough/PreconfUnauditedThrough, wires a new CLI flag (sequencer.audit-window), and addsbor_getPreconfAuditStatus.
File summaries
| File | Description |
|---|---|
| internal/ethapi/bor_api.go | Adds PreconfAuditStatus and GetPreconfAuditStatus RPC surface. |
| internal/ethapi/bor_api_test.go | Tests new audit-status RPC serialization and watermark reads. |
| internal/cli/server/sequencer_flags_test.go | Tests config→ethconfig wiring for sequencer.audit-window. |
| internal/cli/server/flags.go | Adds sequencer.audit-window CLI flag. |
| internal/cli/server/config.go | Adds SequencerConfig.AuditWindow and gating helper sequencerAuditWindow(). |
| eth/sequencer/consumer.go | Adds “watching tip” gating + audit triggers/loop and watermark advancement logic. |
| eth/sequencer/consumer_session.go | Drops backlog opens when sufficiently behind canonical head. |
| eth/sequencer/consumer_prepare.go | Propagates “live” marker frame so the consumer can start “watching”. |
| eth/sequencer/backlog.go | New depth predicate + backlog-open drop behavior. |
| eth/sequencer/audit.go | New audit implementation: windowing, gRPC reads, verdicts, watermark persistence. |
| eth/sequencer/audit_watermark_test.go | Unit tests for watermark stepping rules, “live” frame, backlog predicate, audit loop. |
| eth/sequencer/audit_pass_test.go | Unit tests for audit window selection, mismatch/unknown/no-seal handling, checkpointing, write failures. |
| eth/sequencer/audit_pass_grpc_test.go | End-to-end gRPC audit pass tests (dial-skipping, unreachable store, mismatch recording). |
| eth/ethconfig/config.go | Adds SequencerAuditWindow to runtime config. |
| eth/backend.go | Wires SequencerAuditWindow into consumer via SetAuditWindow. |
| docs/cli/server.md | Regenerated CLI docs (includes sequencer.audit-window and rpc.txsync.maxconcurrent). |
| docs/cli/default_config.toml | Regenerated default config (adds sequencer.audit-window and txsync.maxconcurrent). |
| core/rawdb/schema.go | Adds rawdb keys for audit/unaudited watermarks. |
| core/rawdb/accessors_preconf.go | Adds read/write helpers for PreconfAuditedThrough and PreconfUnauditedThrough. |
| core/rawdb/accessors_preconf_test.go | Tests watermark semantics and truncated-value handling. |
Review details
- Files reviewed: 20/20 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🧞 Codegenie Review
Two verified findings, both low severity, in the new startup audit-pass code. One is a real correctness gap in the rawdb watermark accessor: readPreconfHeight collapses DB read errors and malformed values into the same (0, false) "never audited" sentinel, which makes windowToAudit seed the watermark at the current canonical head and report an uncompared window as clean — the exact false-clean answer this PR exists to remove. The other is a test-quality gap: TestAuditPassSkipsTheDialWithNothingToAudit asserts only that the watermark is untouched, which stays true whether or not the pre-dial early return exists, so the no-dial-per-session-retry contract it is named for is not actually covered.
Coverage: 28 of 33 hunks reviewed (14 deep, 9 normal, 5 light); 5 hunks skipped by configured rules (docs/cli/default_config.toml, docs/cli/server.md). No failed hunks, no degraded planning, verification complete.
Open questions not resolved during review (worth confirming before merge): whether a canonical head at watermark+1 can advance the mark past a height whose store entries have not yet streamed in; whether SetAuditWindow(0) from eth/backend.go keeps the package default depth or zeroes the startup audit; whether auditor.run persists partial progress at the last verified height on fetch error/cancellation given auditCheckpointInterval is 256; whether SetAuditWindow is always called before Start so the unsynchronized c.auditWindow write cannot race auditLoop; and whether a backlog open dropped while s.env != nil leaves an unresolved PendingStore entry since dropBacklogOpen never calls invalidatePendingFrom.
Reviewed 28/33 hunks.
Incomplete work: skipped 5.
Coverage disclosure:
- docs/cli/default_config.toml: configured skip rule
- docs/cli/server.md: configured skip rule
🙋 Needs human attention:
- behindHead computes number+backlogOpenDepth, which wraps for absurd numbers near MaxUint64 from a faulty/malicious store entry, making the open silently dropped instead of skipped/invalidated. Are open block numbers validated upstream against the local head before applyOpen?
- Is any iteration over a key prefix in the DB affected by the new plain keys "PreconfAuditedThrough"/"PreconfUnauditedThrough" (e.g., a prefix scan that would now pick them up)?
— codegenie v0.5.6 (a662388fde) · View Workflow Job
…sent one readPreconfHeight returned the same (0, false) for a missing key, a failed read, and a value that was not eight bytes. windowToAudit treats that sentinel as "this node has never audited" and seeds the watermark at the current head, so a read-only failure marked every height in the unaudited window as compared - and because both write paths are monotonic, the mark could never be walked back. bor_getPreconfAuditStatus then reported auditedThrough=head with no gap: the false-clean answer this feature exists to prevent. The read now probes with Has, then Get, then the length, and returns an error for anything that is not a clean present-or-absent answer. Absence still seeds the watermark; a failure aborts the pass, and persist and advanceAudited hold rather than write over a value they could not compare against. The RPC returns the error instead of an absent mark. WritePreconfUnauditedThrough treats an unreadable current mark as absent and writes anyway, since recording a known gap beats leaving the window unrecorded. behindHead compares by subtraction. The height arrives from the store, so number+backlogOpenDepth could wrap for a value near the top of the range and read as backlog. TestAuditPassSkipsTheDialWithNothingToAudit could not fail on the behaviour it was named for: with the watermark at head, deleting the pre-dial short-circuit leaves run to recheck the window and return, so the watermark stays put either way. Counting accepted connections does not fix it either - grpc.NewClient is lazy, so no connection is attempted when run returns first. The short-circuit saves a client and a per-retry warning on a bad endpoint, not a connection; the comment now says so and says there is no test for it, and the test asserts what is true: no reads and no connections when there is nothing to audit. The same trap caught the first version of the read-failure test, which asserted that no window resolved - true whether the read failed or reported absence. It now asserts that nothing was written, and was confirmed to fail against the old code by finding the watermark seeded at the head. The audit-window flag's help text says that zero uses the built-in window; docs regenerated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…backlog change Three findings from a kurtosis devnet with the store live, none of which the unit tests could see. The audit ran every two seconds. Pre-Rio, deterministic() fails, so run() retries every consumerRetryDelay, and requestAudit() sat at the top of that loop - so every retry built a gRPC client, walked the store, and tore the client down, indefinitely, for any node sitting pre-Rio or with a persistently failing stream. The trigger moves into runSession, which asks for a pass only after a session that actually ran. Measured on the devnet: 66 pre-Rio session retries now produce 0 audit passes, against roughly one per retry before. The summary could not distinguish a height the store held nothing for from one it compared. "walked=129 mismatched=0" read as a clean audit of 129 heights when the first post-Rio pass had actually compared 2 - publishing starts at Rio activation, so the rest were NotFound. walked and compared are now separate, and the log carries both. The backlog-open drop is removed. It never fired: it needs p2p import to run 64+ blocks ahead of the store stream, and across a 330-block downtime the stream kept pace, so behindCanonicalHead never returned true. The pruned-state warning storm that motivated it did not reproduce on either build. An earlier comparison suggested a 20x reduction in skipped opens, but that control was built from feat/pbc-rpc-endpoints before #2373's later parent-resolution work - the code being counted - and against the PR's real base the difference disappears. That leaves a behaviour change in the preconf path, while preconfirmation coverage is being measured, with nothing exercising it. consumer_session.go is back to base as a result. Devnet evidence for what remains: a consumer stopped at watermark 272 under sustained load and restarted walked heights 278-451 in three passes with compared == walked throughout and no mismatches; with the gateway stopped, the head advanced to 1361 while the watermark held at 1299, and the gap closed on reconnect. Preconfirmations are unaffected - 50/50 preconfirmed, p50 117ms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removing backlog.go took its exhaustive depth-predicate table with it, which had been killing a large share of the tier-1 mutants, so the quality gate fell to 77.3% on a pool that no longer contained them. Two of the survivors were real gaps rather than an artifact of the smaller pool. Nothing asserted that a returned session stops the consumer watching the tip. If it did not, the next canonical head would advance the audit watermark across a window nobody compared - the invariant a gateway outage exercised on the devnet, where the head ran from 1295 to 1361 while the mark held at 1299. The reset moves from run() into runSession() as a defer, which is where it belongs and makes it reachable from a test. Nothing asserted that Start wires the audit loop, so a restart would never close its window. The seeding pass reaches no further than the local chain, so the test drives Start against an unreachable store. Both tests were checked against the code they guard. The remaining survivors are log statements, the receive-buffer arithmetic in the client options, and one equivalent mutant: relaxing `number <= watermark` to `<` in markCanonicalHeadAudited falls through to advanceAudited, which is monotonic and writes nothing at that height, so no test can separate them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dict Three findings from re-reading the audit against questions its tests were not asking. The devnet campaign had already passed and the quality gate was green, so none of these were visible from either side. The audit could overwrite an invalidation the live path had already recorded. invalidPreconfKey is one key per height and the write was a bare Put, so a pass judging a height the live path had judged replaced it. The reasons correlate: a height whose served preconfirmation missed canonical is where the store's final seal probably missed it too, so this was reachable whenever a session dropped between the live record and the watermark advancing past that height. It ran in the worst direction - unobserved_mismatch asserts nothing was served from the height, so the ledger would have reported no user-visible impact where a preconfirmation had in fact been served to callers and then invalidated. The audit now writes only where a height carries no record, and counts what it left alone. The check and the write are not atomic; the accessor documents that rather than implying it away. A window the store held nothing for read as clean. NOT_FOUND cannot tell "the producer never published here" from "retention aged this height out", and the pass advanced the watermark across either - so a node down longer than retention walked its window, compared nothing, recorded nothing, and reported auditedThrough across the whole range. That is the same silence-as-clean this work exists to remove, arriving at the retention boundary instead of the downtime one. A run of NOT_FOUND at the oldest end of a walked window now raises PreconfUnauditedThrough, which already means not-compared; when the whole window is unheld the two marks meet. The rule stays narrow on purpose - a hole in the middle is the store having been down for those heights, not a floor - and a test pins that narrowness so it cannot widen by accident. getPreconfAuditStatus had no read-failure coverage, and an unreadable mark returned a nil status with no error, which serializes as null and reads as never-audited from the one method built to avoid a clean-looking answer. Now covered per mark, because corrupting both lets the first read short-circuit and leaves the second branch unexercised. One case of the same shape is left as it is. A height the store held but that could not be decided still advances the watermark unrecorded, and it cannot use these marks: unauditedThrough is a prefix, so raising it for one scattered height would declare the entire history below it uncompared. Recording it needs per-height state, which is a storage and API decision rather than a fix, so it stays counted and logged alongside the reorg and repair-path limits - all three documented for review rather than changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cffls
left a comment
There was a problem hiding this comment.
Thanks, the watermark approach is the right shape. A few changes before this lands, plus one follow-up that belongs in sequence-store rather than here.
1. Return the audit state from bor_getInvalidPreconfBlocks itself
Callers should not need a second call to interpret an empty result. Since auditedThrough is a prefix mark, the not-yet-audited part of any query range is always its tail, so one extra field is enough:
// bor_getInvalidPreconfBlocks("0x5", "0x60")
{
"invalid": ["0x9", "0x6"],
"pendingFrom": "0x4e"
}invalid: heights in range with an invalidation record, newest first, ashexutil.Uint64. Thereasonfield is not needed on the wire; callers only need the heights. Keep it in the DB for logs.pendingFrom: the first height in range the audit has not reached, i.e.max(from, auditedThrough + 1); heights from there totoare pending and callers query again later for them.nullwhen the whole range is audited. Equal tofromwhen the node has not audited yet.- Every other height in range is clean.
- Cap the range at 1024 heights (
to - from + 1 <= 1024) and return an error above that, likefrom > todoes today. With one record per height this makes the silent 1024-record truncation inReadInvalidPreconfsInRangeandInvalidPreconfQueryLimitunnecessary. - A failed watermark read returns an RPC error, not a result with
pendingFromnull.
With this in place bor_getPreconfAuditStatus and PreconfAuditStatus can go; the range call covers what they report.
2. Audit depth: walk from the watermark to head, bounded by the store, not by a block count
The audit should start at auditedThrough + 1 and continue to head. The store's retention is the ceiling; a separate block window on our side is a second bound operators would have to keep aligned with it, so defaultAuditWindow, auditDepth, skippedTo, recordSkippedWindow, SetAuditWindow, the --sequencer.audit-window flag and its config plumbing, TestSequencerAuditWindow, and the docs/cli changes can be dropped. The unauditedThrough mark, leadingUnheld, and their accessors go with them; auditedThrough is the only mark the API needs.
Two details for the walk:
- Do not probe heights the store no longer holds one
GetBlockat a time. ARangerequest withafterunset resolves to the oldest entry the store still serves, and that entry'sBlockOpen.block_numberis where the walk starts. If it is aboveauditedThrough + 1, log a warning with the skipped range and count it in a metric so operators can alert on it, then continue from there. - A NOT_FOUND mid-walk means the store aged the height out while the walk was running or nothing was published there; treat it the same way, log and count, and advance.
Progress checkpointing every 256 heights stays as is.
3. Follow-up in sequence-store
The gateway serves GetBlock and Range from an in-memory window bounded by --window-bytes (256 MiB by default), which is shorter than the log's retention. The gateway should serve as far back as the log retains so the audit can cover a full outage. That is a sequence-store change and will be tracked there; nothing in this PR waits on it. Once the served depth grows, a seal-only read in the proto would keep the walk cheap, since GetBlock currently returns full transaction payloads.
Fold the audit's coverage of a range into bor_getInvalidPreconfBlocks as pendingFrom, so a caller needs no second call to tell an unaudited range from a clean one, and cap the range at 1024 heights instead of truncating the response: one record per height means bounding the request bounds the answer. bor_getPreconfAuditStatus goes with it. Replace the audit's block-count depth bound with the store's retention floor, read from a Range with after unset. A second bound on this side is one operators have to keep aligned with the store's, so the sequencer.audit-window flag and the PreconfUnauditedThrough mark go too. Heights below the floor, and any NOT_FOUND mid-walk, are logged and counted under sequencer/audit/ rather than marked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All three are in, and the PR is smaller for it — the knob, the
One thing I'd like your explicit yes on. With Two smaller notes:
Still open from the original list: reorg policy (8). Everything else your review settled. |
Brings in #2400 (cap coalesced published records at the store message limit), #2394, #2393 and the producer-only publisher-endpoint check. No conflicts: the base's config.go change sits in the same SequencerConfig this branch removed a field from, and the two edits do not overlap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review on 0xPolygon/bor#2388 folded the audit's coverage into bor_getInvalidPreconfBlocks as a pendingFrom field and removed bor_getPreconfAuditStatus along with the unauditedThrough mark, so the method this suite probed no longer exists and the bare array of {number, reason} becomes an object of {invalid, pendingFrom}. Detect which shape came back and check each against its own contract: numbers and reasons for the array, hex heights plus a present pendingFrom and the 1024-height request cap for the object. The bor this leg builds still returns the array, so both have to be accepted for the object branch to go live on its own when the ref moves. A missing pendingFrom fails while a null one passes: null is the legitimate "fully audited", and telling the two apart is the whole reason the field exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Checked the head; it matches what you describe. Answers in order:
|
Review asked for the walk to stop at the finalized head rather than the chain head. A verdict is only as good as the canonical chain it was reached against, and the mark never rewinds, so a height judged before a reorg replaced it would keep a verdict about a block that no longer exists. At or below a milestone that cannot happen, and heights above it report as pendingFrom, which is what they are. The canonical-head path needs the same ceiling. The review took it to be safe already, on the grounds that a reorg there produces a reorged record — but reconcileCanonicalLocked removes the pending entry once the height is reconciled, whether it matched or was invalidated, so a reorg arriving after that writes no record anywhere and the mark has already passed the height. Both paths now stop at finality. A node with no milestone source falls back to the head: bounding at a finality it cannot see would freeze the watermark forever. That is not the same as a source reporting nothing final yet, which judges nothing, so the two are distinguished rather than collapsed into one absent value. WhitelistedMilestone grows a nil guard. The consumer holds it and calls it from the audit loop, which Start launches during construction, so it cannot assume the handler is up; without the guard the wiring tests nil-dereference it, which showed up as one flaky package failure before it showed up as a test. Also per review: the floor read is one page and one rule, since the gateway's served window always begins at an open, and ReadInvalidPreconfs keeps its caller-supplied limit with no ceiling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All four in, pushed as 4. Reorg policy — the walk now stops at finality, and so does the canonical-head path. The bound is exactly as you describe and it is a better answer than rewinding: nothing below the mark can be replaced, so monotonicity costs nothing. But I cannot confirm the second half. So the ceiling has to apply to both paths or the invariant does not hold, and it now does. The live path steps to 1. Metric-only — taken, no 2. Floor — simplified to what you describe: one page, first entry must be an open, anything else resolves nothing and falls back to the walk. The seal case and the paging loop are gone. It reads the invariant off your gateway rather than rediscovering it, so the proto comment is what makes it a contract — no rush, nothing here breaks without it, it just becomes documented rather than assumed. 3. One thing worth flagging from wiring this up: |
* test(sequencer): add preconf rpc and store chaos e2e suites The publisher suite covers whether validators write to the store. These cover what a client sees and whether the store can hurt the chain, both ported down from the manual campaign in #tmp_seqstore-testing-and-qa to a size CI affords. Both scripts source the existing utils rather than editing them, so the functional suite is untouched and the three run as separate workflow steps against one enclave, the way the sibling kurtosis leg already sequences its suites. rpc suite: a preconfirmed receipt is marked preconfirmation:true and carries a null blockHash, which is the client's only signal before canonicalisation; every sampled transaction then has to reach the canonical chain with a real hash and a success status, the 452k-receipt re-fetch scaled to 120. Plus eth_sendRawTransactionSync being registered at all - it was missing from a deployed private RPC, which returns -32601 and breaks any client built on the sync path - the multicall3 pending read looped 25 times, since intermittent failure there becomes random estimateGas failures for geth-based clients, and bor_getInvalidPreconfBlocks answering an array of numbered, reasoned records and rejecting a reversed range. Hashes come from the pending block, not the load generator's output: the pending view is the consumer's own speculative state, so anything in it should be servable as a preconfirmation, and it keeps the suite off polycli's log format. chaos suite: five episodes through one harness - broker stop, ingress latency, gateway pause, a seeded random fault, and an oversized-record burst. Each holds the fault, asserts the chain kept building, repairs, and requires publishing to resume on its own; a store fault that permanently de-registered a publisher would leave a healthy-looking chain with every preconfirmation silently gone. A 1Hz head sampler runs across the whole session and the closing assertion is that no node ever reported one hash for a height and later a different one, which is the property a store fault must not be able to break. Throughput under fault is printed, never asserted. The campaign measured 25-50% cost from store outage or latency, against a design brief that says the store cannot affect block production. A threshold either fails today or freezes whichever number is currently true into CI, so the numbers are output for a human. bor_getPreconfAuditStatus skips on -32601 rather than failing, the same shape the suite already uses for an absent polycli, so it lands now and goes live once the audit ships. Two notes on what the tooling actually supports: polycli has no --sync-txs on the pinned release (or on main), so the sync path gets a registration check rather than a latency measurement; and --calldata needs contract-call mode with a deployed address, so the oversized-record burst uses store mode with --store-data-size. Timeout goes to 75 minutes for the three suites, and the diagnostics dump now triggers on any of them failing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(sequencer): fix four assertions that passed without testing anything Ran both suites against a local kurtosis devnet with the store built from source. Four of the assertions were not measuring what they claimed, and two of them I had already reported as passing. The preconfirmation check was measuring its own latency. It read one pending block of 143 transactions, then walked it with a request per hash; by the tenth, the rest had canonicalised, so preconfirmation was absent and they scored as misses - 6% coverage on a node that was preconfirming correctly. Probed directly, 8 of 8 pending transactions were preconfirmed at 0ms with a null blockHash. A transaction that reached the canonical chain before the harness looked says nothing either way, so those are now excluded rather than counted against coverage, and each receipt is classified: preconfirmed, already-canonical, unmarked, or never-served. A speculative receipt with no preconfirmation flag is now a hard failure, which is the case that would let a client treat unconfirmed state as final. Coverage went from a spurious 6% to 86 of 86 transactions caught while still speculative. The oversized-record burst sent nothing at all. polycli's store mode writes the payload into contract storage at roughly 20k gas per word, so a 32KB transaction wants ~24M gas and fails the node's tx fee cap - 400 submissions, 400 rejections, tps 0. The episode passed anyway, and the entry count it reported as evidence came from the background transfer load. It now sends calldata instead, where zero bytes cost 4 gas each, addressed to an unallocated account so no deployment step is needed. Nothing checked that the burst reached the chain, which is what let that hide. assert_burst_landed counts transactions addressed to the sink across the burst's block range and fails when the window is empty. count_txs_to_sink called rpc_call, which is local to sequencer_rpc_test.sh and not in scope in the utils. Every call failed, the count came back zero, and the new guard reported a burst that had demonstrably landed as missing. It uses rpc_post. Shell has no import graph, so neither bash -n nor shellcheck can see a cross-file function reference; only running it does. The burst sizes are now measured rather than guessed. Against an ingress without the fix, on max.message.bytes=1048576: 32KB x 400 mined puts ~1.3MB in a block and never fences, while 120KB x 240 fences eight times with MESSAGE_TOO_LARGE. The same 120KB burst against an ingress with the fix stays clean, so the episode detects the defect and clears the fix with bor and the burst held constant. 120KB also sits just under the txpool's 128KB ceiling. The default was 32KB, which provably never reached the path. Lowering it disarms the episode silently, because those transactions do land and assert_burst_landed cannot tell they were too small to coalesce past 1MB - the comment says so. The burst now checks publishers per node instead of a summed entry count, which hides one dead publisher. That is not hypothetical: where the store rejects an oversized entry as MALFORMED rather than self-fencing, a producer without the bor-side size cap disables its own publishing and does not re-enable it, so the chain keeps building while that node silently stops preconfirming. A later episode's recovery check is what surfaced it; the burst should catch its own damage. Burst concurrency is configurable - each sender holds its own payload, and the default killed the run on a memory-constrained host. Also verified live: bor_getPreconfAuditStatus reported auditedThrough 0xf8 with unauditedThrough 0x7f, and 127 is exactly the last pre-Rio block, so the unheld-window path behaves as intended against a real store. 120 of 120 sampled transactions canonicalised with no mismatches. The multicall3 pending read succeeded 25 of 25, so that intermittent failure did not reproduce here and is not claimed fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(sequencer): track the reshaped invalid-preconf response Review on 0xPolygon/bor#2388 folded the audit's coverage into bor_getInvalidPreconfBlocks as a pendingFrom field and removed bor_getPreconfAuditStatus along with the unauditedThrough mark, so the method this suite probed no longer exists and the bare array of {number, reason} becomes an object of {invalid, pendingFrom}. Detect which shape came back and check each against its own contract: numbers and reasons for the array, hex heights plus a present pendingFrom and the 1024-height request cap for the object. The bor this leg builds still returns the array, so both have to be accepted for the object branch to go live on its own when the ref moves. A missing pendingFrom fails while a null one passes: null is the legitimate "fully audited", and telling the two apart is the whole reason the field exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci(sequencer): trigger the e2e leg now that #47 has landed Empty commit. The leg's path filter only fires for main, so this PR's suites have never run in CI; #47 merging is what makes them eligible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(sequencer): size the burst payload for Linux argv The burst passed 120KB of calldata as one argv entry: 245762 hex characters. Linux caps a single argument at MAX_ARG_STRLEN (32 pages, 131072 bytes) where macOS allows far more, so the episode ran on a laptop and could not run in CI, which failed it with "Argument list too long". 60KB encodes to 122882 characters, inside the limit, and a check on the encoded length now fails with a reason if anyone raises it again rather than letting polycli discover it a CI run later. Generating the payload with printf's width instead of seq drops a 245760-element argument list on the way there too. The smaller payload should still reach the coalescing cap: a record is capped by bytes before the 64-transaction count, so 18 pending 60KB transactions fill 1MB where 33 were needed at 32KB. That is reasoning rather than a measurement — with bor's own cap in place the assertion passes whether or not the cap engaged — and the note on the constant says so. assert_burst_landed did its job here: it caught that nothing reached the chain and failed instead of asserting over an empty window. Also corrects the episode's comment, which still described the store mode this replaced with contract-call calldata. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picks up #2406; the two changes append to the same metrics var block. # Conflicts: # eth/sequencer/metrics.go
…n-block loss window bor_getInvalidPreconfBlocks must never omit a block whose preconfirmation this node served and then broke. The store audit (#2388) closes the gap for a node's downtime by comparing the store's sealed generation at each height against canonical, so it can only judge heights the store still holds a seal for. One window escapes that: the producer dies after serving preconfirmations for an open block but before sealing it, and its successor rebuilds the height without the store. The store holds no seal - or only an unsealed open - so the audit has nothing to compare, advances past the height, and the broken preconfirmation is never recorded. That is the severe case: an invalid preconf absent from the ledger. Record the node's own promise durably at the moment it serves. As receipts are about to reach callers, persist a per-height commitment to chaindb: the number of transactions preconfirmed there and a keccak fold over their hashes in served order. It survives the crash that loses the store generation because it lives in the node's own database, in a separate keyspace off the block path. The fold is node-local and compared only against itself, serve time versus audit time, so it folds the already-cached tx hashes rather than the on-wire commitment scheme. The audit falls back to the commitment whenever the store cannot confirm a height - it holds nothing, or holds a generation it never sealed. It folds the canonical block's leading transactions the same way and compares: a divergence is a preconfirmation the chain did not keep, recorded as served_mismatch - a distinct reason from unobserved_mismatch, because the node did serve this one. Commitments the audit confirms, and heights the store seals canonical, are cleared; so are the commitments in a retention-skipped range, reconciled before the watermark passes them so the skip neither hides a broken promise nor leaks keys. The commitment holds only the latest generation per height; a superseded generation's promise is left to the live path, as before. The fallback is a read-only monitor - it records a marker and does not touch fork choice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n-block loss window (#2427) * eth/sequencer, rawdb: record served preconfirmations to close the open-block loss window bor_getInvalidPreconfBlocks must never omit a block whose preconfirmation this node served and then broke. The store audit (#2388) closes the gap for a node's downtime by comparing the store's sealed generation at each height against canonical, so it can only judge heights the store still holds a seal for. One window escapes that: the producer dies after serving preconfirmations for an open block but before sealing it, and its successor rebuilds the height without the store. The store holds no seal - or only an unsealed open - so the audit has nothing to compare, advances past the height, and the broken preconfirmation is never recorded. That is the severe case: an invalid preconf absent from the ledger. Record the node's own promise durably at the moment it serves. As receipts are about to reach callers, persist a per-height commitment to chaindb: the number of transactions preconfirmed there and a keccak fold over their hashes in served order. It survives the crash that loses the store generation because it lives in the node's own database, in a separate keyspace off the block path. The fold is node-local and compared only against itself, serve time versus audit time, so it folds the already-cached tx hashes rather than the on-wire commitment scheme. The audit falls back to the commitment whenever the store cannot confirm a height - it holds nothing, or holds a generation it never sealed. It folds the canonical block's leading transactions the same way and compares: a divergence is a preconfirmation the chain did not keep, recorded as served_mismatch - a distinct reason from unobserved_mismatch, because the node did serve this one. Commitments the audit confirms, and heights the store seals canonical, are cleared; so are the commitments in a retention-skipped range, reconciled before the watermark passes them so the skip neither hides a broken promise nor leaks keys. The commitment holds only the latest generation per height; a superseded generation's promise is left to the live path, as before. The fallback is a read-only monitor - it records a marker and does not touch fork choice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * eth/sequencer, rawdb: fold execution context into the served commitment; don't record unjudgeable heights Addresses review on #2427. - Seed the served fold with the block's execution context (contextSeed: ParentHash, Number, Time, GasLimit, BaseFee, Difficulty — the fields the live path's sameExecutionContext checks). A producer handover rebuilds the height with a different context (succession-based Difficulty, often Time or parent), so a preconfirmation served against the old context is now caught even when its transactions are unchanged. Both sides seed identically: the serve path from s.env.header on the first batch, the audit from the canonical block header. - A canonical block not available locally (pruned body, or a snap-sync gap) is no longer read as a broken promise. judgeServedAgainstCanonical returns a three-way verdict; an unjudgeable height is counted uncomparable and its commitment left in place, so the audit cannot write a spurious served_mismatch — the scenario-1 failure mode. - Measure the serve-path write: sequencer/preconf/servedpersist timer. Tests: served_mismatch on a different context with identical txs; an unjudgeable height leaves the commitment and records nothing; the live-path commitment clear in markCanonicalHeadAudited; persistServed round-trip; ReadPreconfServed read failures. Existing served tests now seed commitments with the context. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * eth/sequencer: simplify audit dedup and doc/consistency cleanups /simplify + /security-review follow-ups on #2427 (no behavior change): - Extract recordMismatch: the WriteInvalidPreconfIfAbsent + alreadyJudged bookkeeping was copy-pasted between recordVerdict and judgeServed. - Extract package-level clearServedPreconf: the DeletePreconfServed + log was duplicated between the audit's clearServed and the consumer's live head path. - Drop contextSeed's hand-kept capacity arithmetic (cold path, once per block). - Fix the stale auditSummary.compared doc comment (compared now also counts served-commitment comparisons) and document why judgeServedAgainstCanonical omits receipts (execution is deterministic given parent + ordered tx prefix + context). - Log on the auditUnknown read-error path too, matching reconcileServed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Picks up the component deferred from #2373 (review) — "back track the sequence store if the node is just started" — so that a node which was down for an hour can identify the invalid preconfirmations from while it was down.
bor_getInvalidPreconfBlocksreported that window as clean regardless of what the store held during it. Invalidation records come from one place, the in-memoryPendingStorereconciled against canonical blocks, and a node that was down holds no entries for the heights it missed — so nothing was compared and nothing recorded. Silence was indistinguishable from "every preconfirmation in that window held". Nothing recorded where the consumer last watched, either:resumeRequestcold-starts at the local canonical head, and the store's caught-up-to-tipLiveframe was received and discarded rather than ending a catch-up phase.An audit pass (
eth/sequencer/audit.go) walks from the watermark to the head and compares the store's final sealed header at each height against the canonical hash:unobserved_mismatch— the store sealed something that never became canonical, and this node served nothing from it. The existing reasons (canonical_mismatch,reorged,skipped,session_lost) all mean a preconfirmation reached callers and was then invalidated, which is a stronger claim, so this gets its own reason rather than sharing theirs. The write is conditional on absence: a height the live path already judged keeps that record, since a backfill must not overwrite an observation.Triggers: consumer start, every session loss, and reaching the store tip. Session loss earns a pass because a stream that drops while the node stays up leaves the same class of hole as a restart — which also makes this cover gaps from store outages, not just node restarts.
Position (
core/rawdb):PreconfAuditedThroughcarries the watermark across restarts, and is the only mark. It may only step one height at a time from the live path — a jump would carry it over the catch-up backlog the session dropped without comparing, so a gap requests a pass instead. A node with no watermark seeds at the current head and audits nothing; auditing backwards from an arbitrary point on first enable would produce records with no operational meaning.bor_getInvalidPreconfBlockscarries the audit's coverage of the range alongside the heights, so no second call is needed to interpret an empty result:pendingFromismax(from, auditedThrough + 1): heights from there to the range end are pending rather than clean,nullonce the whole range is audited, and equal tofromon a node that has not audited at all. Every other height in range was compared and matched. The range is capped at 1024 heights and a wider one is an error, which is what makes truncation unnecessary — one record per height means bounding the request bounds the response, so a caller can no longer receive a silently short answer it cannot tell from a complete one. An unreadable watermark is an RPC error, never anullpendingFrom, becausenullis the claim the range was compared.Bounded by the store, not by a block count. The walk runs from
auditedThrough + 1to the head, and the store's retention is the only ceiling. ARangerequest withafterunset resolves to the oldest entry the store still serves; the firstBlockOpenat or after it is the oldest height a pass can compare, and the walk starts there rather than paying oneNOT_FOUNDper aged-out height. The skipped range is logged and counted (sequencer/audit/retentionskipped), as is anyNOT_FOUNDmid-walk (sequencer/audit/unheld).Retention can age out the middle of a block, so the oldest retained entry is not always an open. A seal reached before any open closes a block whose earlier entries are gone — a partial record list is not comparable against a canonical block — so the floor is the height above it; records carry no height at all and the scan pages on. Failing to resolve a floor is not a correctness boundary: the walk then discovers the same heights unheld one at a time, which is the pre-existing behaviour.
Revised after review
Jerry's review asked for three things; all three are in. The PR is smaller as a result — a knob, a database key, an RPC method and their tests are gone.
bor_getInvalidPreconfBlocks(above).bor_getPreconfAuditStatus, thePreconfAuditStatustype, the silent 1024-record truncation andrawdb.InvalidPreconfQueryLimitare removed.sequencer.audit-windowand its config plumbing,defaultAuditWindow,auditDepth,skippedTo,recordSkippedWindow,SetAuditWindow, thePreconfUnauditedThroughkey with its accessors,leadingUnheld, and the docs entries all go with it. The retention floor resolved fromRangereplaces them.sequence-storematter and nothing here waits on it.Two notes on the consequences, neither of which changes what was asked for:
unauditedThroughgone, the watermark advances over the skipped range, so an emptyinvalidwithpendingFrom: nullcovering it reads the same as a compared-and-clean range. The two counters above are the only record, so the alert belongs on them rather than on the RPC. Deliberate per the review; flagging it because it is the one guarantee this revision narrows. Widening the gateway's served depth shrinks the window where it can happen but does not close it.rawdb.ReadInvalidPreconfs(db, limit)— the non-range accessor, called only by tests — now honours the caller's limit with no ceiling of its own, since the constant that clamped it is gone. Say the word if you would rather it kept one.Validated on a kurtosis devnet
Run on
kurtosis-pos@vbhattaccmu/sequence-store-benchmarkwith the storelive (redpanda / ingress / gateway / auditor), two publishing validators, and
two RPC consumers on the same chain — one on this branch, one on
cffls/sequence-publisheras a control. Full write-up in the workspace report;the parts that matter here:
A consumer restarted after downtime audits exactly the window it missed.
Stopped at watermark 272 under sustained load (60 tx per batch, continuous),
restarted after ~160 blocks:
Three passes — startup, session-end, Live — with
compared == walkedthroughout, so the store held a generation at every audited height and all of
them were compared against canonical. The watermark was not re-seeded; the only
watermark seededline is from first boot. In an earlier run the audit started,the stream went live 9 ms later, and the audit finished after — it runs off the
session loop and does not delay the reconnect.
A store outage freezes the watermark rather than advancing it. With the
gateway stopped, the head advanced from 1295 to 1361 while the watermark held
at 1299, then the gap closed on reconnect:
from=1300 through=1395 walked=96 compared=96. Without thewatchinggate the mark would have trackedthe head and declared 62 uncompared heights clean — this is the invariant the
change exists for.
Preconfirmations are unaffected: 50 concurrent
eth_sendRawTransactionSync→ 50/50 succeeded, 50/50 preconfirmed, 50 tx in one block, p50 117 ms, in line
with the figures Vikram posted on 2026-09-05.
Mismatch detection was not exercised. No store-vs-canonical divergence
arose: the store auditor logged zero supersede events for the whole run, so the
condition the verdict detects never occurred — the absence of the trigger, not
a failure of detection. It is unit-covered, including
TestAuditPassOverGRPCRecordsMismatches, which detects one over a realConsumerServiceserver. Forcing it needs deliberate producer contention andbelongs in the e2e rather than a hand-driven devnet.
These runs predate the reshape above. The behaviour they pin — watermark
advance, the
watchinggate, restart coverage, preconf latency — is untouchedby it; the depth-bound and
unauditedThroughobservations from those runs arenot reproduced here because neither exists any more. The retention-floor path
that replaces them is unit-covered only, and the honest position is that it has
not yet run against a real store with an aged-out floor: a devnet's store never
reaches its retention limit inside a test run. What a devnet did show is the
condition the floor now resolves — heights 1..127 hold nothing because the
publisher is gated off before Rio at 128 — and on two independent chains the
old mark landed on exactly 127, so the floor has a real case to resolve the
moment it runs there.
Three defects the devnet found, now fixed
deterministic()fails, sorunretries everyconsumerRetryDelay, andrequestAudit()sat at the topof that loop — a gRPC client built, the store walked, the client torn down,
every 2s indefinitely for any node sitting pre-Rio or with a persistently
failing stream. The trigger moved into
runSession, which asks for a passonly after a session that actually ran. Measured: 66 pre-Rio retries now
produce 0 passes. Covered by
TestIneligibleSessionDoesNotQueueAnAudit,verified to fail against the old placement.
walkedhidcompared. The first post-Rio pass readwalked=129 compared=2— publishing starts at Rio activation, so 127 heights wereNotFound. The old single counter made that look like a clean audit of 129
heights, and I misread it that way myself. Both are now reported.
Debugwhile bor runs at INFO (moot now, see below).Dropped from this PR: the backlog-open change
An earlier revision also dropped store opens more than 64 blocks below the
canonical head, to avoid the
parent state unavailablelookup on a prunednode. It is removed. It never fired — it needs p2p import to run 64+ blocks
ahead of the store stream, and across a 330-block downtime the stream kept
pace. The warning storm that motivated it did not reproduce on either build.
An earlier A/B suggested a 20x reduction in skipped opens, but that control was
built from
feat/pbc-rpc-endpointsbefore #2373's later parent-resolutionwork — the exact code being counted — and against the PR's real base the
difference disappears. That left a behaviour change in the preconf path, while
coverage is being measured, with nothing exercising it.
consumer_session.gois byte-identical to base as a result, so this PR no longer touches the hot
path at all.
Worth reproducing separately if anyone hits the storm again.
Two further defects found reviewing the feature end to end, now fixed
Both were found by re-reading the paths against the questions the tests did not
ask, after the devnet run.
The audit could overwrite a live invalidation record.
invalidPreconfKeyisone key per height and the write was a bare
Put, so a pass that judged aheight the live path had already recorded replaced it. The reasons correlate —
a height whose served preconfirmation missed canonical is exactly where the
store's final seal probably missed it too — so this was reachable whenever a
session dropped between the live record and the watermark advance past it. The
damage ran in the worst direction:
unobserved_mismatchasserts nothing wasserved from this height, so the ledger would have told an operator there was
no user-visible impact at a height where a preconfirmation was served and
then invalidated. The audit now writes only where a height carries no record
(
rawdb.WriteInvalidPreconfIfAbsent) and counts what it left alone.A range the store held nothing for read as clean.
NOT_FOUNDcannotdistinguish the producer never published here from retention aged this
height out, and the pass advanced the watermark across either. So a node down
longer than retention — or restored from an older snapshot — walked its range,
compared nothing, recorded nothing, and reported
auditedThroughacross thewhole of it: silence again indistinguishable from "everything held", which is
the exact bug this PR exists to remove, at the retention boundary instead of
the downtime one.
The first fix for this raised a second
unauditedThroughmark. The reviewreplaced it with the retention floor read from
Range, which is a betteranswer to the same problem: the floor separates aged out from never
published exactly, where a leading run of
NOT_FOUNDcould only ever beconservative and call both unknown. What it does not do is persist the fact —
hence the alerting note above.
One case of the same shape remains unrecorded. A height the store held
but that could not be decided — no canonical hash, or a seal that does not
decode or sits at the wrong height — advances the watermark without a record.
It is counted in the pass summary (
uncomparable) and insequencer/audit/unknown, and logged. All three causes are a store or datafault rather than a normal state, so it should not fire in practice.
Executed tests
eth/sequencer,core/rawdb,internal/ethapi,internal/cli/server,eth/ethconfig.-raceclean oneth/sequencerandcore/rawdb.go vet,gofmtandgolangci-lint(v2.11.4, repo config) clean;gofumptclean on every file this PR touches.auditHeightverdict table and arecordVerdicttable, the summary-counter assertions, the watermark accessor including a truncated stored value, the contiguity andwatchinggates, and theLiveframe.floorFromEntriesover open-first / records-then-open / seal-first / records-only / empty / undecodable-seal pages,storeFloor's paging and its four fall-back cases (no reader, failing read, empty page, no boundary inside the page budget),skipToStoreFlooracross a floor below / at / inside / above the range, and a pass asserting it starts at the floor and never probes a height below it.TestAuditPassStartsAtTheStoreFloorOverGRPCthen pins the request the store really receives —afterunset, which is what resolves to the earliest retained entry — because the unit tests inject the reader and so cannot catch a malformedRangeRequest.pendingFromtable over six watermark positions (absent, below, at the start, inside, at the end, above), the wire-format assertion against the shape in the review, the range cap at exactly 1024 and 1025 heights, the widest expressible range (where a count-based cap would overflow to zero and wave it through), and an unreadable watermark returning an error with a nil result.TestAuditPassOverGRPCRecordsMismatchesstands up a realConsumerServiceserver, points the consumer at it, and asserts the dial, the per-heightGetBlockreads, the recorded mismatch and the watermark write. Unreachable-endpoint and nothing-to-audit cases are covered too, the latter asserting the pass opens no connection at all — a trigger fires on every session retry and must not each cost a dial.TestWatermarkAdvancesOnlyWhileWatchingTheTip,TestIneligibleSessionDoesNotQueueAnAudit,TestAuditDoesNotSeedTheWatermarkOnAReadFailure,TestAuditLeavesALiveRecordInPlaceand the rawdb three-state tests each fail against the code they guard against.--mutation-sample-rate 20samples a different fifth of the pool each time, and the three runs behind this revision sampled 19, 25 and 30 tier-1 mutants respectively, so their scores are not comparable with each other. The same-tree experiment on the previous revision is the one that measures the variance — three runs of identical code scored 78.7% / 68.1% / 78.7% overall with tier-1 at 88.9% / 93.8% / 95.2%. The gate is the tiers, and the survivor list is drawn fresh every run, so it has to be read per run rather than trusted as a fixed set.report,auditUnknownCount, the stopped-early warning, the unheld warning's guard) — tier-3, ungated.persist'scurrent >= numberrelaxed to>still falls through to a write of the same value;fetchOldestVia's error return zeroed leavesstoreFloorreading an empty page, which returns the same "no floor"; andWriteInvalidPreconfIfAbsent's error-path bool is ignored by its only caller, which testserrfirst.accessors_preconf.go:59(if present { return false, nil }→true) is reported as surviving, but applying that mutation by hand failsTestWriteInvalidPreconfIfAbsentdeterministically (second write replaced an existing record), and so does deleting the branch. Worth knowing the survivor list overstates in both directions — an earlier revision of this PR had the same thing happen ataudit.go:305.floorFromEntriesnow type-switches on the entry kind the wayentryHeightalready does, so there is no nil-seal guard whose removal falls through to the same decode error, andstoreFloor's empty-page exit is pinned by a call count — without it, removing the branch changed nothing observable but cost three wasted round trips.docs/cli/{server.md,default_config.toml}regenerated withmake docs; the flag's entries are gone.The automated scenario now exists. The restart-and-audit path above was
driven by hand, and as a standing regression it belongs with Jerry's e2e work
rather than a parallel harness —
pos-workflows#47 and
#52 are both merged to
main, so the sequencer leg gates PRs there. #52 carries the consumer-sidehalf: preconf receipt shape, canonicalisation, the invalid-preconf range
contract, and chaos episodes against the store.
That leg already knows about this PR's response shape. Its range-contract
test detects which shape it got and checks each against its own contract, so
the
{invalid, pendingFrom}branch — including the 1024-height cap and therule that a missing
pendingFromfails where a null one passes — startsasserting by itself once this merges and the workflow's bor ref carries it. No
change needed there.
The mismatch case is still not covered anywhere: forcing a store-vs-canonical
divergence needs deliberate producer contention, which is why neither the
devnet runs above nor the e2e leg has triggered one.
make docsalso addedrpc.txsync.maxconcurrenttodocs/cli/server.md. #2385 regenerated those docs, but they were dropped when #2373 merged intocffls/sequence-publisher, so the flag exists in code with no doc entry. Two lines of generated output; the docs should match the flag set, so I've left the repair in.Rollout notes
Not consensus-affecting, no coordinated upgrade. Confined to the sequence-store consumer, which is off by default and requires explicit configuration.
No new operator knobs. One new rawdb key,
PreconfAuditedThrough, a singleuint64— no migration, no resync. Four new metrics undersequencer/audit/.bor_getInvalidPreconfBlockschanges shape, so any existing caller needs updating: the result is an object rather than an array, the per-recordreasonis no longer on the wire (it stays in the database for logs), and a range wider than 1024 heights is now an error instead of a truncated list. The method has not shipped outside this branch as far as I can tell — worth confirming against anything you have pointed at it.Retention. The audit can only see what the store retained; below the floor
GetBlockanswersNOT_FOUND. The floor is now resolved up front and the skipped heights are counted, so exceeding retention is visible in metrics — but it is not visible in the RPC, which reports those heights as audited. Alert onsequencer/audit/retentionskippedandsequencer/audit/unheld. With the planned 7-day retention any realistic downtime sits inside the window anyway, and the gateway's served depth is the nearer limit until thesequence-storefollow-up lands.A read error stalls rather than skips. Any store read that is not
NOT_FOUNDends the pass, which persists progress and resumes from the same height on the next trigger. A deterministic per-height error therefore freezesauditedThroughat that height and re-warns on every session event. That is the safe direction — the watermark never advances over heights nobody compared, so nothing reads as clean — and it is visible rather than silent, so I have left it alone instead of adding a skip policy that could hide heights. The realistic trigger, a generation over the call's receive limit, needs 33 MB at one height against a ~2 MB gas-bound ceiling, so it is remote.No repair path. The watermark is monotonic, so heights the audit passed over are not revisited: there is no operator hook to re-audit a range, even where the store still holds the data. That is a deliberate consequence of never rewinding the mark. Worth an admin method later if operators actually hit it — I have not added API surface on speculation.
Disk growth. #2373 established that
InvalidPreconf-*is retained without pruning by design, with the gradual growth called out in its rollout notes. This PR does not change that, but it does add a second writer to that keyspace, so the growth rate is no longer bounded by "preconfirmations this node actually served" — a node that restarts repeatedly, or one auditing a range where the store superseded heavily, writes more records than before. Worth a look at whether the existing decision still holds at that rate; I have not assumed it needs changing.Open questions
The review settled most of the original list: the response shape (2), the depth bound (3), a seal-only read (5, deferred behind the gateway's served depth), per-height "not compared" records (6, answered as log plus metric), and the retention floor (7, answered as
Rangewithafterunset). Nothing was raised against the record semantics (1) or seeding at the head on first enable (4), so both stand as built.Still open:
auditedFromalongsidependingFrom— the oldest height the watermark can vouch for — which is one moreuint64and no new method.One semantic worth stating explicitly:
GetBlockresolves to the latest generation at a height, so a preconfirmation that came from an earlier, superseded generation is invisible to this pass. For downtime auditing that's the right question — nothing was served, so what matters is whether the store's final answer matched the chain — but the ledger shouldn't be read as stronger than that.