Skip to content

Commit fe5976e

Browse files
committed
refactor(prover-node): address review feedback
- README: replace the stale prune model (markPruned/markCanonical/SlotWatcher, in-flight-work reuse) with cancel-and-remove-on-prune + rebuild-on-re-add. - CheckpointStore.stop() now awaits teardowns still in flight for provers already removed by a prune or reap (tracked in a self-clearing set), not just live ones. - Drop the now-redundant "canonical" method naming (every prover in the store is canonical): listCanonical* -> list*, canonicalCheckpointsForSpec -> checkpointsForSpec. - Remove the openFullSessionIfReady terminal-replace guard; recreateInvalidSessions already deletes terminal sessions before it runs. - Note that the failure-upload suppression is best-effort and inherently races the prune (the store lags the world-state unwind).
1 parent dea205a commit fe5976e

6 files changed

Lines changed: 140 additions & 141 deletions

File tree

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`. |

yarn-project/prover-node/src/checkpoint-store.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ describe('CheckpointStore', () => {
8989

9090
const proverA = await store.addOrUpdate(a, makeRegisterData());
9191
await expect(store.addOrUpdate(b, makeRegisterData())).rejects.toThrow(
92-
/canonical checkpoint already occupies this slot/i,
92+
/a different checkpoint already occupies this slot/i,
9393
);
9494

9595
// After the predecessor is pruned (cancelled and removed), the replacement is accepted and keys
@@ -119,7 +119,7 @@ describe('CheckpointStore', () => {
119119
expect(affected.map(p => p.checkpoint.number)).toEqual([3, 4]);
120120
expect(affected.every(p => p.isCancelled())).toBe(true);
121121
// The orphaned provers are gone from the store; only 1 and 2 remain.
122-
expect(store.listCanonical().map(p => p.checkpoint.number)).toEqual([1, 2]);
122+
expect(store.list().map(p => p.checkpoint.number)).toEqual([1, 2]);
123123
});
124124

125125
it('cancelAndRemoveAboveBlock removes a checkpoint whose block range straddles the target (partially orphaned)', async () => {
@@ -131,7 +131,7 @@ describe('CheckpointStore', () => {
131131
const affected = store.cancelAndRemoveAboveBlock(BlockNumber(6));
132132
expect(affected.map(p => p.checkpoint.number)).toEqual([1]);
133133
expect(affected[0].isCancelled()).toBe(true);
134-
expect(store.listCanonical()).toEqual([]);
134+
expect(store.list()).toEqual([]);
135135
});
136136

137137
it('reapExpired drops provers whose epoch is ≤ expiredEpoch', async () => {
@@ -149,15 +149,15 @@ describe('CheckpointStore', () => {
149149
expect(remainingNumbers).toEqual([3]);
150150
});
151151

152-
it('listCanonicalForEpoch returns only provers in the epoch slot range', async () => {
152+
it('listForEpoch returns only provers in the epoch slot range', async () => {
153153
// With epochDuration=1, each epoch's slot range is exactly [slot, slot].
154154
const cp1 = await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, slotNumber: SlotNumber(10) });
155155
const cp2 = await Checkpoint.random(CheckpointNumber(2), { numBlocks: 1, slotNumber: SlotNumber(11) });
156156
await store.addOrUpdate(cp1, makeRegisterData());
157157
await store.addOrUpdate(cp2, makeRegisterData());
158158

159-
const epoch10 = await store.listCanonicalForEpoch(EpochNumber(10));
160-
const epoch11 = await store.listCanonicalForEpoch(EpochNumber(11));
159+
const epoch10 = await store.listForEpoch(EpochNumber(10));
160+
const epoch11 = await store.listForEpoch(EpochNumber(11));
161161
expect(epoch10.map(p => p.checkpoint.number)).toEqual([1]);
162162
expect(epoch11.map(p => p.checkpoint.number)).toEqual([2]);
163163
});

yarn-project/prover-node/src/checkpoint-store.ts

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export type CheckpointProverFactory = (args: CheckpointProverArgs, deps: Checkpo
2828
*/
2929
export class CheckpointStore {
3030
private readonly provers = new Map<string, CheckpointProver>();
31+
/** Teardowns of provers already removed from `provers` (by prune or reap), awaited on `stop()`. */
32+
private readonly pendingTeardowns = new Set<Promise<void>>();
3133
private readonly log: Logger;
3234

3335
constructor(
@@ -44,13 +46,24 @@ export class CheckpointStore {
4446
}
4547

4648
public async stop(): Promise<void> {
47-
// Cancel every live prover; await teardown.
49+
// Cancel every live prover, then await both their teardown and any still in flight for provers
50+
// already removed by a prune or reap.
4851
const provers = Array.from(this.provers.values());
4952
this.provers.clear();
5053
for (const prover of provers) {
5154
prover.cancel();
5255
}
53-
await Promise.allSettled(provers.map(p => p.whenDone()));
56+
await Promise.allSettled([...provers.map(p => p.whenDone()), ...this.pendingTeardowns]);
57+
}
58+
59+
/**
60+
* Tracks the teardown of a prover just removed from the store so `stop()` can await it. The entry
61+
* removes itself once teardown settles, so the set stays bounded by the number in flight.
62+
*/
63+
private trackTeardown(prover: CheckpointProver): void {
64+
const done = prover.whenDone();
65+
this.pendingTeardowns.add(done);
66+
void done.finally(() => this.pendingTeardowns.delete(done));
5467
}
5568

5669
/**
@@ -76,7 +89,7 @@ export class CheckpointStore {
7689
if (prover.slotNumber === checkpoint.header.slotNumber) {
7790
throw new Error(
7891
`Cannot add checkpoint ${checkpoint.number} (archive ${checkpoint.archive.root}) at slot ${checkpoint.header.slotNumber}: ` +
79-
`a different canonical checkpoint already occupies this slot. Prune it first.`,
92+
`a different checkpoint already occupies this slot. Prune it first.`,
8093
);
8194
}
8295
}
@@ -104,7 +117,7 @@ export class CheckpointStore {
104117
const lastBlockNumber = prover.checkpoint.blocks.at(-1)!.number;
105118
if (lastBlockNumber > targetBlockNumber) {
106119
prover.cancel();
107-
void prover.whenDone();
120+
this.trackTeardown(prover);
108121
this.provers.delete(id);
109122
affected.push(prover);
110123
}
@@ -123,7 +136,7 @@ export class CheckpointStore {
123136
if (prover.epochNumber <= expiredEpoch) {
124137
reaped.push({ id, checkpointNumber: prover.checkpoint.number, epochNumber: prover.epochNumber });
125138
prover.cancel({ routine: true });
126-
void prover.whenDone();
139+
this.trackTeardown(prover);
127140
this.provers.delete(id);
128141
}
129142
}
@@ -151,24 +164,23 @@ export class CheckpointStore {
151164
return Array.from(this.provers.values());
152165
}
153166

154-
/** Canonical provers in the store, sorted by checkpoint number. */
155-
public listCanonical(): CheckpointProver[] {
167+
/** Provers in the store, sorted by checkpoint number. */
168+
public list(): CheckpointProver[] {
156169
return Array.from(this.provers.values()).sort((a, b) => a.checkpoint.number - b.checkpoint.number);
157170
}
158171

159172
/**
160-
* Canonical provers whose slot is in the supplied epoch's slot range, sorted by
161-
* checkpoint number.
173+
* Provers whose slot is in the supplied epoch's slot range, sorted by checkpoint number.
162174
*/
163-
public async listCanonicalForEpoch(epoch: EpochNumber): Promise<CheckpointProver[]> {
175+
public async listForEpoch(epoch: EpochNumber): Promise<CheckpointProver[]> {
164176
const l1Constants = await this.l2BlockSource.getL1Constants();
165177
const [fromSlot, toSlot] = getSlotRangeForEpoch(epoch, l1Constants);
166-
return this.listCanonicalInSlotRange(fromSlot, toSlot);
178+
return this.listInSlotRange(fromSlot, toSlot);
167179
}
168180

169-
/** Canonical provers whose slot falls within `[fromSlot, toSlot]`, sorted by checkpoint number. */
170-
public listCanonicalInSlotRange(fromSlot: SlotNumber, toSlot: SlotNumber): CheckpointProver[] {
171-
return this.listCanonical().filter(p => p.slotNumber >= fromSlot && p.slotNumber <= toSlot);
181+
/** Provers whose slot falls within `[fromSlot, toSlot]`, sorted by checkpoint number. */
182+
public listInSlotRange(fromSlot: SlotNumber, toSlot: SlotNumber): CheckpointProver[] {
183+
return this.list().filter(p => p.slotNumber >= fromSlot && p.slotNumber <= toSlot);
172184
}
173185
}
174186

yarn-project/prover-node/src/metrics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export class ProverNodeJobMetrics {
9696
this.activeCheckpoints = this.meter.createObservableGauge(Metrics.PROVER_NODE_ACTIVE_CHECKPOINTS);
9797
this.activeEpochSessions = this.meter.createObservableGauge(Metrics.PROVER_NODE_ACTIVE_EPOCH_SESSIONS);
9898
this.stateObserver = (observer: BatchObservableResult) => {
99-
observer.observe(this.activeCheckpoints!, checkpointStore.listCanonical().length);
99+
observer.observe(this.activeCheckpoints!, checkpointStore.listAll().length);
100100
let full = 0;
101101
let partial = 0;
102102
for (const session of sessionManager.allSessions()) {

0 commit comments

Comments
 (0)