eth/sequencer, rawdb: record served preconfirmations to close the open-block loss window - #2427
Conversation
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.
pratikspatil024
left a comment
There was a problem hiding this comment.
Good find, and the diagnosis is right — the gap is real and it's in my code. auditHeight returns auditNoSeal when a generation has no seal, and recordVerdict groups that with auditMatch, so the height is walked, nothing is recorded, no counter moves, and the watermark advances over it. Of the three ways a height can get past that pass, retentionskipped and unheld at least increment something; auditNoSeal was the one that was completely silent, and it's the one you hit.
I'd also had the gap scoped too narrowly in my own notes — I'd written it down as "the producer died before publishing at all", where the real condition is "no seal in the store", which includes published-but-unsealed. That's the far more likely crash shape, so thanks for pinning it properly.
The approach looks right to me: it extends the pass rather than replacing it, which keeps the store-side comparison covering preconfs served by other nodes while the local commitment covers what the store never sealed. Nice handling of the cases beyond the headline one — reconciling on auditUnknown instead of clearing, so an undecodable or wrong-height seal can't bury a broken promise before the watermark passes it, and having reconcileSkippedServed scan the served prefix rather than every skipped height.
Two inline comments, neither blocking: the block == nil path reading as a mismatch when it's really "can't judge", and the serve-path write being unmeasured. Prefix-fold semantics look correct to me — folding canonical's leading count transactions means an insertion or reorder anywhere in the served prefix trips it, which is exactly the transactionIndex 2 → 3 from run 04.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## v2.10.2-candidate #2427 +/- ##
=====================================================
- Coverage 57.93% 57.46% -0.48%
=====================================================
Files 957 957
Lines 177763 175548 -2215
=====================================================
- Hits 102984 100875 -2109
+ Misses 69118 69009 -109
- Partials 5661 5664 +3
... and 42 files with indirect coverage changes
🚀 New features to boost your workflow:
|
cffls
left a comment
There was a problem hiding this comment.
Confirmed the gap against the base independently of the write-up: auditHeightInto treats NOT_FOUND as "nothing was promised", recordVerdict no-ops on auditNoSeal, and the consumer's served state is purely in-memory (the only durable consumer-side writes are invalidations, written at invalidation time) — so a node that served a preconf and then lost the process leaves the broken promise unrecorded. Scenario 2 is real, and this PR targets exactly the three branches that let it through, plus the retention-skipped range that nobody would have noticed leaking. Writing the commitment before the receipts reach callers, IfAbsent so the live path's stronger record keeps precedence, treating a malformed record as an error rather than "nothing served", and refusing to let an unusable seal clear a commitment are all the right calls.
One thing I'd change before merge: the commitment is weaker than the live judgement it stands in for. foldServed commits to transaction identity and order only, while entryMatchesCanonical also checks the execution context (ParentHash, Time, GasLimit, BaseFee, Difficulty) and the receipts. That leaves a slice of scenario 2 open on exactly the handover this fix targets — details inline at audit.go:421. Cheap to close (seed the fold with the header context) and it makes the two paths agree by construction.
+1 to Pratik's block == nil point; inline at audit.go:413.
Also: codecov is at 86.5% patch vs the 90% gate, mostly consumer.go (57%) — the different-parent test below plus a test for the live-path clear in markCanonicalHeadAudited should carry it over.
…nt; 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>
|
Thanks both — all addressed in c92eedf:
Ready for another look. |
/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>
…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>
…nt; 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>
/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>
8248d2f to
79d16fa
Compare
What
bor_getInvalidPreconfBlocksmust 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 it: the producer serves preconfirmations for an open block, then dies 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 — the severe case, an invalid preconf absent from the ledger.
Fix
Record the promise durably, at serve time. When receipts are about to reach callers (
indexExecutedTransactions), the node persists a per-height commitment to its own chaindb: the count of transactions preconfirmed there and a keccak fold over their hashes in served order (WritePreconfServed). It lives in a keyspace off the block path, so it survives the crash that loses the store generation.Reconcile it when the store cannot confirm a height — it holds nothing (
NOT_FOUND), holds a generation it never sealed, returns a seal that is unusable (undecodable or wrong-height), or the height falls in a retention-skipped range. The audit folds the canonical block's leading transactions the same way and compares; a divergence is recorded asserved_mismatch, a distinct reason fromunobserved_mismatchbecause the node did serve this one. Commitments are cleared once judged.Notes / boundaries
tx.Hash()rather than the on-wirecommitmentscheme — cheaper on the serve path, no tag or chain seed.Testing
Unit/rawdb: round-trip and range reads for the commitment (incl. malformed value); audit records
served_mismatchforNOT_FOUND, unsealed, retention-skip, and unusable-seal cases; kept promises and store-confirmed heights record nothing; commitments cleared once judged.go test -raceclean oncore/rawdbandeth/sequencer.Durability (deterministic, 5/5):
TestServedCommitmentSurvivesCrashAndIsAudited— real on-disk pebble crash (close/reopen); no commitment → unrecorded (the bug), durable commitment → recorded.Live devnet A/B (custom bor image, deterministic fault injection): serve a divergent preconf at height H, fsync, crash (
SIGKILL-equivalent), restart, store returnsNOT_FOUNDfor H.Trace of one fixed run (RPC bor logs):
🤖 Generated with Claude Code