Skip to content

Commit c1e6229

Browse files
authored
fix(prover-node): rebuild pruned checkpoint provers and recover the epoch (#24436)
Resolves A-1290. ## Problem A `CheckpointProver`'s sub-tree work forks world-state per block. When an L1 reorg prunes a base block, those fork reads fault and the prover permanently rejects its one-shot `blockProofs` promise. The store keeps the prover alive across a prune (`markPruned`) so a re-add of identical content can reuse its in-flight work. But a prune that removes the checkpoint also breaks the fork reads, so that reuse only ever hands back a poisoned prover: a re-add or a full `EpochSession` recreate re-references the same rejected `blockProofs` and fails immediately. The affected node then silently abandons the epoch until a process restart (a healthy prover elsewhere still proves it, so it's not a network stall). ## Fix **Drop the reuse machinery — a pruned prover cannot survive, so rebuild instead of reuse.** - `CheckpointStore.cancelAndRemoveAboveBlock` cancels and **removes** orphaned provers (replacing `markPrunedAboveBlock`); a re-add builds a fresh prover with a fresh `blockProofs`. - Removed the `pruned` flag / `markPruned` / `markCanonical` from `CheckpointProver`, and the `SlotWatcher` that only reaped lingering pruned provers. - `listCanonical` / `addOrUpdate` simplified — every prover in the store is now canonical. **Recover the epoch by rebuilding on re-add, rather than trying to prevent the terminal state.** A prune unwinds world-state as soon as it's detected — before the prover-node cancels the affected session — so a checkpoint prover mid-fork faults while still live and its session goes terminal `failed`. That race is inherent (the world-state rejection and the prover being cancelled are unordered), so instead of trying to reclassify it as a cancellation, we make the terminal state harmless: - `SessionManager.openFullSessionIfReady` no longer treats a terminal session as "already open" — it drops it and rebuilds. So re-adding an epoch's checkpoints reopens a fresh live session over fresh provers, via the checkpoint/prune triggers (ungated by the tick high-water mark). This reopen depends on the store no longer holding the poisoned prover: with the reuse machinery gone the rebuilt session gets a fresh prover with a healthy `blockProofs`, so it can actually prove rather than re-inheriting the rejected one. - `runSession` skips the failure-upload when the session's checkpoints no longer match canonical content — a prune-invalidated failure isn't a genuine proving failure, so it no longer emits a spurious post-mortem upload / alert. The only non-recovered case is "content pruned and never re-added" (chain stops publishing that epoch's checkpoints) — there's nothing to prove on this node then, so abandoning the epoch is correct. **Cancel the pruned prover's in-flight work promptly.** `CheckpointProver` now threads its abort signal into `PublicProcessor.process`, so a prune-driven cancel stops the current block's public execution immediately instead of running it to completion before the next `signal.aborted` check. This is independent of the recovery logic above — it just avoids wasting CPU re-executing a block whose checkpoint is being discarded. The in-flight block has not yet enqueued a broker proof, so aborting it loses no reusable work. ### Note: broker-level proof reuse is unaffected Removing the `CheckpointProver`-level reuse does not waste proving work. The expensive part — the SNARK proofs — is cached in the proving broker, content-addressed by job id, independent of world-state. `cancelJobsOnStop` defaults to `false`, so cancelling a prover does not abort its broker jobs; on an identical re-add the fresh prover regenerates identical inputs, and the broker dedups against the cached jobs/results (bounded by the broker's per-epoch cleanup). The reuse we deleted was witness-generation reuse, whose world-state forks cannot survive a prune anyway. ## Testing - `session-manager.test.ts`: pins the retry/recovery invariant — the periodic tick does not re-attempt a failed epoch (gated by `lastTickEpoch`), but a checkpoint re-add recovers it (ungated); a `failed` session's epoch is reopened once its checkpoints are re-added; a genuine proving failure still uploads a post-mortem; a failure coinciding with a canonical content change (prune) skips the upload. (Red/green verified for both the gate and the upload suppression.) - `checkpoint-store.test.ts` / `prover-node.test.ts`: pruned provers are cancelled and removed (not flagged); a re-add builds a fresh prover. - `checkpoint-prover.test.ts`: cancelling a prover mid-block aborts the signal its public execution is running under (red/green verified). - Full prover-node suite passes (166 tests); package typechecks clean; lint OK. - Updated `optimistic.parallel.test.ts` (the e2e that spawned this issue) to the cancelled+removed semantics. ## Notes - No operator/contract-dev-facing config or API changed, so no changelog entry. - The terminal `failed` state is not prevented, only recovered from: the world-state unwind and the prover cancellation are unordered, so the failure can't be reliably reclassified from the session side. Recovering on re-add is deterministic and needs no such race to be won.
1 parent 2c4d593 commit c1e6229

12 files changed

Lines changed: 408 additions & 410 deletions

yarn-project/end-to-end/src/single-node/proving/optimistic.parallel.test.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -338,22 +338,20 @@ describe('single-node/proving/optimistic', () => {
338338
timeout: 30,
339339
});
340340

341-
// Verify the prover-node observes the prune. `markPruned()` fires reactively when
342-
// the L2BlockStream emits the prune; the SlotWatcher then reaps the (now pruned)
343-
// prover on its next tick (default 1s), so checking strictly for `isPruned()` would
344-
// race against the reap. Identify the original by `(checkpointNumber, slot)` —
345-
// checkpoint numbers refill sequentially after a reorg, so the replacement reuses
346-
// the same number but lives at a different slot. Accept either state for the
347-
// original: still in the store and pruned, or already reaped.
341+
// Verify the prover-node observes the prune. The prune reactively cancels and removes the
342+
// orphaned prover from the store when the L2BlockStream emits it, so the original should drop
343+
// out of the store (or, if observed mid-race, be cancelled). Identify the original by
344+
// `(checkpointNumber, slot)` — checkpoint numbers refill sequentially after a reorg, so the
345+
// replacement reuses the same number but lives at a different slot.
348346
await retryUntil(
349347
() => {
350348
const prover = proverNode
351349
.getCheckpointStore()
352350
.listAll()
353351
.find(p => p.checkpoint.number === checkpointBeforeReorg && p.slotNumber === originalSlot);
354-
return Promise.resolve(!prover || prover.isPruned());
352+
return Promise.resolve(!prover || prover.isCancelled());
355353
},
356-
`prover marks original checkpoint ${checkpointBeforeReorg} (slot ${originalSlot}) as pruned (or reaps it)`,
354+
`prover cancels and removes original checkpoint ${checkpointBeforeReorg} (slot ${originalSlot})`,
357355
30,
358356
0.2,
359357
);
@@ -383,7 +381,7 @@ describe('single-node/proving/optimistic', () => {
383381
proverNode
384382
.getCheckpointStore()
385383
.listAll()
386-
.some(p => p.checkpoint.number === replacementCheckpoint && !p.isPruned()),
384+
.some(p => p.checkpoint.number === replacementCheckpoint),
387385
),
388386
`prover re-creates sub-tree for replacement checkpoint ${replacementCheckpoint}`,
389387
30,
@@ -875,7 +873,7 @@ describe('single-node/proving/optimistic', () => {
875873
// The session manager constructs a full session over the canonical content for the
876874
// anchored epoch when it completes, then proves it; the store retains the provers
877875
// until expiry.
878-
const epochCheckpointsInStore = await proverNode.getCheckpointStore().listCanonicalForEpoch(epoch);
876+
const epochCheckpointsInStore = await proverNode.getCheckpointStore().listForEpoch(epoch);
879877
const storedNumbers = new Set(epochCheckpointsInStore.map(p => p.checkpoint.number));
880878
for (const n of preSpawnCheckpointNumbers) {
881879
expect(storedNumbers.has(n)).toBe(true);

yarn-project/prover-node/README.md

Lines changed: 53 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ flowchart TB
3535
SessionManager --> EpochTicker[(periodic tick)]
3636
SessionManager --> FullSessions[(fullSessions)]
3737
SessionManager --> PartialSessions[(partialSessions)]
38-
CheckpointStore --> SlotWatcher
3938
FullSessions -.referenced checkpoints.-> CheckpointStore
4039
PartialSessions -.referenced checkpoints.-> CheckpointStore
4140
FullSessions --> TopTreeJob
@@ -56,8 +55,9 @@ The prover-node splits responsibility between four classes:
5655
- **`CheckpointStore`** — a registry of `CheckpointProver` instances keyed by
5756
`(checkpointNumber, slot, archiveRoot)`. Each `CheckpointProver` runs its own sub-tree pipeline
5857
(tx gather → block processing → block-rollup proofs), starting eagerly the moment a
59-
checkpoint is registered. The store is the single source of canonical-vs-pruned
60-
checkpoint content that `EpochSession`s query when assembling their subsets.
58+
checkpoint is registered. The store holds the canonical checkpoint content that
59+
`EpochSession`s query when assembling their subsets; a prune cancels and removes the affected
60+
provers, so every prover in the store is canonical.
6161
- **`SessionManager`** — owns every live `EpochSession`, the serial reconcile queue,
6262
the periodic tick, and all `EpochSession` lifecycle decisions. `ProverNode` calls into it
6363
via `onCheckpointAdded`, `onPrune`, and `startProof`. Every trigger it receives is
@@ -81,43 +81,38 @@ A `CheckpointProver` is content-addressed by `(checkpoint.number, slot, archiveR
8181
where `archiveRoot` is the checkpoint's own archive root (its post-state). Keying on the
8282
post-state makes the identity precise: two checkpoints are "the same" iff they produce
8383
the same archive — so a reorg branch, or a replacement built on the same predecessor but
84-
with different content, yields a different archive root and a distinct `CheckpointProver`, while an
85-
identical re-add collapses to the same `CheckpointProver` and reuses its in-flight work.
84+
with different content, yields a different archive root and a distinct `CheckpointProver`. A prune
85+
cancels and removes a prover — its sub-tree work forks world-state per block and cannot survive the
86+
prune — so a re-add, even of identical content, constructs a fresh `CheckpointProver` (block-rollup
87+
jobs already completed in the proving broker are still reused, as they are content-addressed).
8688

8789
```mermaid
8890
stateDiagram-v2
8991
[*] --> Created
9092
Created --> Proving: gather + execute
9193
Proving --> Proven: sub-tree resolves blockProofs
92-
Proving --> Cancelled: cancel()
94+
Proving --> Cancelled: cancel() (prune / shutdown / epoch-session error)
95+
Proven --> Cancelled: cancel() (prune / reap)
9396
Proven --> Reaped: reapExpired(epoch)
9497
Cancelled --> [*]
95-
96-
state "Pruned (side)" as Pruned
97-
Proving --> Pruned: markPruned()
98-
Pruned --> Proving: markCanonical()
99-
Proven --> Pruned: markPruned()
100-
Pruned --> Reaped: SlotWatcher (slot < syncedSlot)
10198
```
10299

103-
The **`Pruned`** state is a side flag, not a place in the main lifecycle: sub-tree
104-
proving keeps running underneath, so a brief reorg that prunes and immediately
105-
re-adds the same checkpoint avoids any re-proving. The flag only gates *eligibility*
106-
to be included in an `EpochSession``EpochSession`s ask the store for *canonical* (non-pruned)
107-
checkpoints when assembling their subsets.
108-
109-
### Reaping rules
110-
111-
- **Pruned**: the `SlotWatcher` (a `RunningPromise` polling
112-
`l2BlockSource.getSyncedL2SlotNumber`) reaps a pruned `CheckpointProver` when the chain's
113-
synced slot has moved past the `CheckpointProver`'s slot. Once the chain is past that slot,
114-
a re-add with the same content is impossible.
115-
- **Canonical**: `CheckpointStore.reapExpired(expiredEpoch)` drops any canonical
116-
`CheckpointProver` whose epoch is at or below the supplied expired epoch. Once an epoch's
117-
proof-submission window has closed, its proof can no longer be accepted on L1,
118-
so the `CheckpointProver` is no longer needed.
119-
- **Cancelled**: removed immediately by whichever path called `cancel()` (store
120-
shutdown, prune past-slot, `EpochSession` error).
100+
A prune **cancels and removes** the affected `CheckpointProver`s from the store: a prover's
101+
sub-tree work forks world-state per block, and an L1 prune of a base block faults those reads,
102+
so there is nothing to preserve across a reorg. A re-add — even of identical content — therefore
103+
constructs a fresh `CheckpointProver`. `EpochSession`s ask the store for the checkpoints in a slot
104+
range; every prover in the store is canonical.
105+
106+
### Removal rules
107+
108+
- **Pruned**: `CheckpointStore.cancelAndRemoveAboveBlock(target)` cancels and removes every
109+
`CheckpointProver` whose last block is above the prune target, the moment a `chain-pruned`
110+
event arrives.
111+
- **Expired**: `CheckpointStore.reapExpired(expiredEpoch)` drops any `CheckpointProver` whose
112+
epoch is at or below the supplied expired epoch. Once an epoch's proof-submission window has
113+
closed, its proof can no longer be accepted on L1, so the `CheckpointProver` is no longer needed.
114+
- **Shutdown**: `CheckpointStore.stop()` cancels every remaining prover and awaits its teardown
115+
(including teardowns still in flight for provers already removed by a prune or reap).
121116

122117
### Eager tx gathering
123118

@@ -288,8 +283,8 @@ sequenceDiagram
288283
alt content key new
289284
CS->>CP: new CheckpointProver(args)
290285
CP->>CP: eager gather + sub-tree start
291-
else content key matches
292-
CS->>CP: markCanonical()
286+
else content key already live
287+
CS->>CP: reuse existing prover
293288
end
294289
PN->>SM: onCheckpointAdded(epoch)
295290
SM->>SM: queue reconcile({kind:'checkpoint', epoch})
@@ -306,9 +301,9 @@ sequenceDiagram
306301
participant CS as CheckpointStore
307302
participant SM as SessionManager
308303
309-
L2->>PN: chain-pruned{checkpoint}
310-
PN->>CS: markPrunedAfter(checkpoint.number)
311-
CS->>CS: flip every CheckpointProver above threshold to pruned (sub-tree keeps running)
304+
L2->>PN: chain-pruned{block}
305+
PN->>CS: cancelAndRemoveAboveBlock(prunedToBlock)
306+
CS->>CS: cancel + remove every CheckpointProver whose last block is above the target
312307
PN->>SM: onPrune(affectedEpochs)
313308
SM->>SM: queue reconcile({kind:'prune', affectedEpochs})
314309
SM->>SM: walk EpochSessions, cancel-and-recreate those with shifted content
@@ -371,30 +366,28 @@ indexing) leave the mark in place and the next tick retries.
371366

372367
### checkpoint-added → prune → checkpoint-added (reorg resilience)
373368

374-
State: epoch N has checkpoints c1..c4 all canonical (slots s1..s4). `fullSessions[N]`
375-
holds `EpochSession` **A** with spec `{kind:'full', N, fromSlot:s1, toSlot:s4}`, referencing
376-
checkpoints `[c1, c2, c3, c4]`.
369+
State: epoch N has checkpoints c1..c4 (slots s1..s4). `fullSessions[N]` holds `EpochSession`
370+
**A** with spec `{kind:'full', N, fromSlot:s1, toSlot:s4}`, referencing `[c1, c2, c3, c4]`.
377371

378-
1. **chain-pruned arrives, target c3.** Store flips c4 to pruned. Reconcile fires:
379-
for `EpochSession` A, canonical content for `(s1, s4)` is now `[c1, c2, c3]` (c4 pruned).
380-
The frozen set `[c1, c2, c3, c4]` no longer matches → `A.cancel('canonical content
381-
changed')`. Epoch N still complete on L1 → reconcile constructs `EpochSession` **B** with
382-
the same spec `{full, N, s1, s4}` but checkpoints `[c1, c2, c3]`.
372+
1. **chain-pruned arrives, target c3.** The store cancels and removes c4 (its last block is
373+
above the prune target). Reconcile fires: `EpochSession` A's frozen set `[c1, c2, c3, c4]`
374+
includes the now-cancelled c4, so it no longer matches the store's `[c1, c2, c3]`
375+
`A.cancel('canonical content changed')`. Epoch N still complete on L1 → reconcile constructs
376+
`EpochSession` **B** with the same spec `{full, N, s1, s4}` but checkpoints `[c1, c2, c3]`.
383377

384378
2. **`EpochSession` B starts top-tree proving over [c1, c2, c3].**
385379

386-
3. **chain-checkpointed arrives, target c4_re (same content key as old c4).** The
387-
store finds the existing `CheckpointProver` at `(c4.number, s4, c4.archive.root)`
388-
and calls `markCanonical()`. The sub-tree work that never stopped is visible to
389-
`EpochSession`s again. (A re-add with *different* content would have a different archive
390-
root and so get a fresh `CheckpointProver` instead.)
380+
3. **chain-checkpointed arrives, target c4_re (same content key as old c4).** The old c4
381+
prover was removed on the prune, so the store constructs a **fresh** `CheckpointProver` for
382+
c4_re and starts its sub-tree work from scratch. (Its block-rollup jobs are content-addressed
383+
in the proving broker, so any the old c4 already completed are reused rather than re-proved.)
391384

392-
4. **Reconcile fires.** `EpochSession` B's canonical content for `(s1, s4)` is now `[c1, c2,
393-
c3, c4]`, doesn't match its frozen `[c1, c2, c3]``B.cancel(...)`. Construct
394-
`EpochSession` **C** with same spec but checkpoints `[c1, c2, c3, c4]`.
385+
4. **Reconcile fires.** `EpochSession` B's content for `(s1, s4)` is now `[c1, c2, c3, c4_re]`,
386+
doesn't match its frozen `[c1, c2, c3]``B.cancel(...)`. Construct `EpochSession` **C** with
387+
the same spec but checkpoints `[c1, c2, c3, c4_re]`.
395388

396-
5. **`EpochSession` C reuses the long-lived c1..c4 `CheckpointProver` instances.** Sub-tree
397-
work may already be complete; only the top-tree is recomputed. The chonk cache
389+
5. **`EpochSession` C reuses the long-lived c1..c3 `CheckpointProver` instances and the fresh
390+
c4_re.** Only c4_re's witnesses are regenerated; the top-tree is recomputed. The chonk cache
398391
survived the reorg because no epoch in this range has expired yet.
399392

400393

@@ -466,19 +459,19 @@ Keying by tx hash makes the cache survive any reorg up to finality; releasing on
466459
finality means we don't grow the cache indefinitely while still keeping every
467460
reorg-relevant proof.
468461

469-
### Why does the slot watcher only reap pruned `CheckpointProver`s?
462+
### Why is a pruned `CheckpointProver` removed rather than kept for a possible re-add?
470463

471-
Canonical `CheckpointProver`s can't be reaped on a slot heuristic — they're still part of the
472-
proven-chain story. Pruned `CheckpointProver`s, on the other hand, are only kept around in
473-
case the chain re-adds the same content; once the synced slot has moved past, that
474-
re-add is impossible, and the `CheckpointProver` can go. Finality is the right signal for
475-
canonical reaping, because finality is the only state that rules out future reorgs.
464+
A prover's sub-tree work forks world-state per block, and an L1 prune of a base block faults
465+
those in-flight reads — so the work cannot survive the prune, and there is nothing to preserve
466+
by keeping the prover around. Removing it on prune and rebuilding on re-add is therefore both
467+
correct and simpler; the expensive block-rollup proofs are still reused when content re-appears,
468+
because the proving broker is content-addressed independently of the prover's lifetime.
476469

477470
## Configuration
478471

479472
| Env var | Description |
480473
|---|---|
481-
| `PROVER_NODE_POLLING_INTERVAL_MS` | Polling interval for the L2BlockStream, the checkpoint-store slot watcher, and the SessionManager periodic tick. Default 1000 ms. |
474+
| `PROVER_NODE_POLLING_INTERVAL_MS` | Polling interval for the L2BlockStream and the SessionManager periodic tick. Default 1000 ms. |
482475
| `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. |
483476
| `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. |
484477
| `TX_GATHERING_TIMEOUT_MS` | Per-block tx gather deadline used by each `CheckpointProver`. |

0 commit comments

Comments
 (0)