You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(prover-node): make epoch proving robust to prune-induced fork faults (#24678)
Follow-up to #24436 (A-1290). That PR fixed the immediate prune-induced
failure but left a class of race conditions around L1-reorg prunes:
every "was this failure a prune or a genuine failure?" predicate samples
control-plane state that lags the data-plane world-state unwind, so it
is racy by construction.
The fix pushes the fact down to where it is unambiguous — **decouple "a
checkpoint prover failed" (a fact about the prover) from "the epoch
failed" (a decision)**, and never treat a *cancel* (prune / reap /
shutdown) as a failure. A genuine failure is then told apart from a
prune with no control-plane sampling.
## What changes
- **`CheckpointProver`** — gains `isFailed()`, set when its block proofs
reject for a non-cancel reason (a sub-tree fault or a prune-induced fork
fault). On failure it fires an `onFailed` callback so the owner can
upload that single checkpoint's post-mortem. A cancel leaves
`isFailed()` false.
- **`EpochSession`** — a fault now settles in one of two terminal
states, decided by the provers:
- `stopped` — a `CheckpointProver` under it failed. May be a prune, so
it is **not** uploaded and the session is dropped; the epoch recovers
when a prune/re-add installs a fresh prover.
- `failed` (`hasFailed()`) — the session's **own** top-tree/submit work
failed while *every* prover was healthy. Healthy provers rule out a
prune, so this is a genuine, race-free failure.
- **`SessionManager`** — removed `lastTickEpoch`. Two mechanisms stop a
doomed epoch being re-proved every tick: `openFullSessionIfReady`
refuses to build over a set containing a failed prover, and
`recreateInvalidSessions` **retains** a genuinely-`failed` full session
as a do-not-re-prove marker, replacing it only when its canonical
content changes (a re-add). A `failed` session fires `onSessionFailed`
(session-level upload). Recovery from prune/reorg flows through the
ungated `checkpoint`/`prune` triggers, reusing already-completed
sub-proofs from the content-addressed broker.
- **`ProverNode`** — uploads post-mortems at **two levels**: per
checkpoint (`tryUploadCheckpointFailure`, wired to
`CheckpointProver.onFailed` — fires for prune-induced faults too, on
purpose) and per session (`tryUploadEpochFailure`, wired to
`onSessionFailed`). `expireEpoch` only releases the chonk cache and
reaps — it does **not** upload, since a missed-window epoch's provers
may already be pruned by then.
- **Re-proving** — add `rerunCheckpointProvingJob` to re-prove a single
downloaded checkpoint in isolation (sub-tree only; no epoch top-tree or
L1 submit), sharing offline setup with `rerunEpochProvingJob`.
`downloadEpochProvingJob` is unchanged (same serialized format).
## Tests
- `epoch-session.test.ts` — a fault ends `stopped` (a prover failed) vs
`failed` (top-tree/submit with healthy provers).
- `checkpoint-prover.test.ts` — a mid-proof fork fault sets `isFailed()`
and fires `onFailed` once; a cancel does neither.
- `session-manager.test.ts` — skips building over a failed prover;
retains + uploads a `failed` session and replaces it on content change;
does not upload on `stopped`; data-plane fault + re-add (identical and
different content) recovers and completes.
- `prover-node.test.ts` — a registered checkpoint prover failure routes
to `tryUploadCheckpointFailure`; `expireEpoch` reaps without uploading.
- `upload_failed_proof.test.ts` (e2e) — the existing epoch upload/rerun
test, plus a new checkpoint test: force a sub-tree failure via the
`checkpointProveOverride` hook, capture the per-checkpoint upload, and
re-prove it via `rerunCheckpointProvingJob`.
- prover-node README rewritten to the failed-prover / two-level-upload
model, with the "how a genuine failure is told apart from a prune"
rationale.
prover-node unit suite green; full `yarn build`; format + lint clean.
Resolves A-1418.
Expiry runs at the end of every `handleBlockStreamEvent` call (not on any specific
347
-
event type). An epoch `E` is expired once the chain reaches the start of epoch
348
-
`E + proofSubmissionEpochs + 1` — the deadline beyond which an L1 submission for
349
-
`E` would be rejected. A monotonic high-water mark (`lastExpiredEpoch`) makes the
350
-
sweep cheap: it advances per event and never revisits an epoch. It is seeded at
351
-
`start()` from the last fully-proven epoch (computed in `computeStartupState`),
352
-
so on a restart we never re-sweep epochs that already reached L1.
366
+
The sweep is driven solely by the `expiryTicker` (a `RunningPromise` armed in `start()`), which
367
+
calls `checkEpochExpiry()` every poll interval whether or not block-stream events arrive. It is
368
+
deliberately **not** run from `handleBlockStreamEvent` — expiry is a background sweep keyed off the
369
+
archiver's synced slot, not part of event processing — and because a `RunningPromise` never overlaps
370
+
its own runs, no two sweeps can race. An epoch `E` is expired once the chain reaches the start of epoch
371
+
`E + proofSubmissionEpochs + 1` — the deadline beyond which an L1 submission for `E` would be rejected.
372
+
Expiry only releases the chonk cache and reaps `E`'s provers.
373
+
A monotonic high-water mark (`lastExpiredEpoch`) makes the sweep cheap: it only
374
+
advances after a sweep completes and never revisits an epoch. It is seeded at `start()` from the last
375
+
fully-proven epoch (`resolveLastFullyProvenEpoch`), so on a restart we never re-sweep epochs that already
376
+
reached L1.
353
377
354
378
### Periodic tick
355
379
356
380
`SessionManager.start()` arms a `RunningPromise` that fires
357
381
`reconcile({ kind: 'tick' })` every `tickIntervalMs`. The tick picks up epochs that
358
382
became complete by time alone (no fresh checkpoint event) and advances to the
359
-
next unproven epoch once the previous one lands on L1. A monotonic high-water
360
-
mark (`lastTickEpoch`) prevents the tick from re-opening an epoch whose `EpochSession`
361
-
already terminated; the mark advances only after an `EpochSession` actually exists for
362
-
the epoch, so transient blockers (max-pending-jobs reached, archiver still
363
-
indexing) leave the mark in place and the next tick retries.
383
+
next unproven epoch once the previous one lands on L1. The tick is not gated by any per-epoch
384
+
bookkeeping; two things keep it from re-proving a doomed epoch:
385
+
386
+
-`openFullSessionIfReady` refuses to build a session when any `CheckpointProver` in the set has
387
+
**failed** (`isFailed()`). So a prover fault (possibly a prune) skips the epoch cheaply each tick —
388
+
no session, no proving — until a prune/re-add installs a fresh prover.
389
+
- A session that failed on its own account (`hasFailed()` — top-tree/submit failed with every prover
390
+
healthy) is **retained** by `recreateInvalidSessions` rather than deleted, so the tick's
391
+
`fullSessions.has(epoch)` check skips it. It is replaced only when its canonical content changes (a
392
+
re-add), so a deterministically-failing epoch is not re-proved every tick.
393
+
394
+
Transient blockers (max-pending-jobs reached, archiver still indexing) create no session and no failed
395
+
prover, so the next tick simply tries again.
364
396
365
397
## Walkthroughs
366
398
@@ -467,6 +499,20 @@ by keeping the prover around. Removing it on prune and rebuilding on re-add is
467
499
correct and simpler; the expensive block-rollup proofs are still reused when content re-appears,
468
500
because the proving broker is content-addressed independently of the prover's lifetime.
469
501
502
+
### How is a genuine failure told apart from a prune, without a racy classification?
503
+
504
+
Knowledge of an L1 reorg reaches the prover-node on two causally unordered channels: the control
505
+
plane (`chain-pruned` → `onPrune`) and the data plane (world-state unwinds, and an in-flight fork
506
+
read faults inside a `CheckpointProver` mid-proof). If the `EpochSession` reacted to a fault by asking
507
+
"was this a prune or a genuine failure?", every answer would sample control-plane state that lags the
508
+
data-plane unwind — racy by construction. We remove the question by pushing the fact down to where it
509
+
is unambiguous: a `CheckpointProver` records `isFailed()` when *its* block proofs reject. The session
510
+
then reads a clean signal: if any prover failed, the fault might be a prune (`stopped`) — don't upload,
511
+
don't rebuild over it, recover on re-add; if *no* prover failed yet the session's own top-tree/submit
512
+
work failed, that is definitively **not** a prune (`failed`) — a genuine, race-free failure that is
513
+
retained (so a deterministic failure isn't re-proved every tick) and uploaded once. No control-plane
514
+
sampling, no timing race — the two prover-level and session-level facts carry the whole decision.
515
+
470
516
## Configuration
471
517
472
518
| Env var | Description |
@@ -475,7 +521,7 @@ because the proving broker is content-addressed independently of the prover's li
475
521
|`PROVER_NODE_MAX_PENDING_JOBS`| Cap on the number of non-terminal `EpochSession`s (full + partial). When at limit, reconcile defers opening new full `EpochSession`s; explicit `startProof` calls throw. |
476
522
|`PROVER_NODE_EPOCH_PROVING_DELAY_MS`| Optional sleep at the start of each `EpochSession`, before the TopTreeJob is constructed. Used in tests to give late events time to land. |
477
523
|`TX_GATHERING_TIMEOUT_MS`| Per-block tx gather deadline used by each `CheckpointProver`. |
478
-
|`PROVER_NODE_FAILED_EPOCH_STORE`| If set, failed `EpochSession`s upload their proving data (every `CheckpointProver`'s txs + register-time data, regardless of sub-tree completion) to this file store. |
524
+
|`PROVER_NODE_FAILED_EPOCH_STORE`| If set, a full session that fails on its own account (top-tree/submit, provers healthy) uploads its proving data (every `CheckpointProver`'s txs + register-time data, regardless of sub-tree completion) to this file store. |
479
525
|`PROVER_NODE_DISABLE_PROOF_PUBLISH`| If true, the publishing service runs `analyzeEpochProofSubmission` (estimates L1 fees) instead of actually submitting. |
0 commit comments