Skip to content

Commit c98a7bc

Browse files
committed
fix(prover-node): rebuild pruned checkpoint provers and recover the epoch
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 previously kept the prover alive (markPruned) so a re-add of identical content could reuse its in-flight work, but a prune that removes the checkpoint also breaks the fork reads, so the reuse only ever handed back a poisoned prover: a re-add or a full EpochSession recreate re-referenced the same rejected blockProofs and failed immediately, and the node silently abandoned the epoch until a process restart. Drop the reuse machinery — a pruned prover cannot survive: - CheckpointStore.cancelAndRemoveAboveBlock cancels and removes orphaned provers (replacing markPrunedAboveBlock); a re-add builds a fresh prover. - Remove the pruned flag / markPruned / markCanonical from CheckpointProver and the SlotWatcher that only reaped lingering pruned provers. Recover the epoch by rebuilding on re-add rather than racing the failure. A prune unwinds world-state before the prover-node cancels the session, so a prover mid-fork faults while live and the session goes terminal `failed`; that race cannot be won from the session side, so make the terminal state harmless: - SessionManager.openFullSessionIfReady drops a terminal session 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). - runSession skips the failure-upload when the session's checkpoints no longer match canonical content, so a prune-invalidated failure emits no spurious post-mortem. Cancel the pruned prover's in-flight work promptly: thread the abort signal into PublicProcessor.process so a cancel stops the current block's public execution immediately instead of running it to completion. The expensive proving is unaffected: broker proofs are content-addressed and survive the prune (cancelJobsOnStop defaults to false), so an identical re-add reuses them; only witness generation, which cannot survive a prune, is redone.
1 parent ab4dfec commit c98a7bc

10 files changed

Lines changed: 236 additions & 292 deletions

File tree

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

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -340,22 +340,20 @@ describe('single-node/proving/optimistic', () => {
340340
timeout: 30,
341341
});
342342

343-
// Verify the prover-node observes the prune. `markPruned()` fires reactively when
344-
// the L2BlockStream emits the prune; the SlotWatcher then reaps the (now pruned)
345-
// prover on its next tick (default 1s), so checking strictly for `isPruned()` would
346-
// race against the reap. Identify the original by `(checkpointNumber, slot)` —
347-
// checkpoint numbers refill sequentially after a reorg, so the replacement reuses
348-
// the same number but lives at a different slot. Accept either state for the
349-
// original: still in the store and pruned, or already reaped.
343+
// Verify the prover-node observes the prune. The prune reactively cancels and removes the
344+
// orphaned prover from the store when the L2BlockStream emits it, so the original should drop
345+
// out of the store (or, if observed mid-race, be cancelled). Identify the original by
346+
// `(checkpointNumber, slot)` — checkpoint numbers refill sequentially after a reorg, so the
347+
// replacement reuses the same number but lives at a different slot.
350348
await retryUntil(
351349
() => {
352350
const prover = proverNode
353351
.getCheckpointStore()
354352
.listAll()
355353
.find(p => p.checkpoint.number === checkpointBeforeReorg && p.slotNumber === originalSlot);
356-
return Promise.resolve(!prover || prover.isPruned());
354+
return Promise.resolve(!prover || prover.isCancelled());
357355
},
358-
`prover marks original checkpoint ${checkpointBeforeReorg} (slot ${originalSlot}) as pruned (or reaps it)`,
356+
`prover cancels and removes original checkpoint ${checkpointBeforeReorg} (slot ${originalSlot})`,
359357
30,
360358
0.2,
361359
);
@@ -385,7 +383,7 @@ describe('single-node/proving/optimistic', () => {
385383
proverNode
386384
.getCheckpointStore()
387385
.listAll()
388-
.some(p => p.checkpoint.number === replacementCheckpoint && !p.isPruned()),
386+
.some(p => p.checkpoint.number === replacementCheckpoint),
389387
),
390388
`prover re-creates sub-tree for replacement checkpoint ${replacementCheckpoint}`,
391389
30,

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

Lines changed: 41 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,26 @@ import { EmptyL1RollupConstants } from '@aztec/stdlib/epoch-helpers';
99

1010
import { mock } from 'jest-mock-extended';
1111

12-
import { type CheckpointProverFactory, CheckpointStore } from './checkpoint-store.js';
12+
import { CheckpointStore } from './checkpoint-store.js';
1313
import type { CheckpointProver } from './job/checkpoint-prover.js';
1414

1515
describe('CheckpointStore', () => {
16-
let store: TestCheckpointStore;
17-
let blockSource: ReturnType<typeof mock<Pick<L2BlockSource, 'getSyncedL2SlotNumber' | 'getL1Constants'>>>;
16+
let store: CheckpointStore;
17+
let blockSource: ReturnType<typeof mock<Pick<L2BlockSource, 'getL1Constants'>>>;
1818
/** Track stub provers we hand back from the factory. */
1919
const stubs: StubProver[] = [];
2020

2121
// Single-slot epochs make every checkpoint live in its own epoch and slot range.
2222
const l1Constants = { ...EmptyL1RollupConstants, epochDuration: 1 };
2323

2424
beforeEach(() => {
25-
blockSource = mock<Pick<L2BlockSource, 'getSyncedL2SlotNumber' | 'getL1Constants'>>();
25+
blockSource = mock<Pick<L2BlockSource, 'getL1Constants'>>();
2626
blockSource.getL1Constants.mockResolvedValue(l1Constants);
2727
stubs.length = 0;
28-
store = new TestCheckpointStore(
28+
store = new CheckpointStore(
2929
blockSource,
3030
// The deps are not exercised — the factory below ignores them.
3131
{} as any,
32-
{ slotWatcherPollIntervalMs: 100 },
3332
undefined,
3433
(args, _deps) => {
3534
const stub = makeStubProver(args.checkpoint, args.epochNumber);
@@ -51,21 +50,35 @@ describe('CheckpointStore', () => {
5150
expect(stubs.length).toBe(1);
5251
});
5352

54-
it('addOrUpdate is idempotent for the same content key (re-add after prune)', async () => {
53+
it('addOrUpdate reuses the prover for a still-canonical duplicate registration', async () => {
54+
// An at-least-once re-registration of a checkpoint that has NOT been pruned reuses the running
55+
// prover rather than rebuilding its in-flight work.
5556
const cp = await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1 });
5657

5758
const first = await store.addOrUpdate(cp, makeRegisterData());
58-
expect(first.isPruned()).toBe(false);
59-
store.markPrunedAboveBlock(BlockNumber(0));
60-
expect(first.isPruned()).toBe(true);
61-
62-
// Re-adding the identical checkpoint (same archive root) reuses the existing prover.
6359
const second = await store.addOrUpdate(cp, makeRegisterData());
6460
expect(second).toBe(first);
65-
expect(second.isPruned()).toBe(false);
6661
expect(stubs.length).toBe(1);
6762
});
6863

64+
it('addOrUpdate rebuilds a fresh prover for a re-add after prune', async () => {
65+
const cp = await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1 });
66+
67+
const first = await store.addOrUpdate(cp, makeRegisterData());
68+
expect(first.isCancelled()).toBe(false);
69+
store.cancelAndRemoveAboveBlock(BlockNumber(0));
70+
// The pruned prover is cancelled and dropped from the store.
71+
expect(first.isCancelled()).toBe(true);
72+
expect(store.getByCheckpoint(cp)).toBeUndefined();
73+
74+
// Re-adding the identical checkpoint (same archive root) constructs a fresh prover — the pruned
75+
// one's forked world-state reads did not survive, so there is nothing to reuse.
76+
const second = await store.addOrUpdate(cp, makeRegisterData());
77+
expect(second).not.toBe(first);
78+
expect(second.isCancelled()).toBe(false);
79+
expect(stubs.length).toBe(2);
80+
});
81+
6982
it('addOrUpdate refuses a conflicting canonical checkpoint at the same slot', async () => {
7083
// Two canonical checkpoints sharing a slot would be a parallel chain. The store rejects
7184
// the second; the caller must prune the first (via the chain-pruned event) before the
@@ -79,17 +92,17 @@ describe('CheckpointStore', () => {
7992
/canonical checkpoint already occupies this slot/i,
8093
);
8194

82-
// After the predecessor is pruned, the replacement is accepted and keys to a distinct
83-
// prover (different archive root → different content id).
84-
store.markPrunedAboveBlock(BlockNumber(0));
85-
expect(proverA.isPruned()).toBe(true);
95+
// After the predecessor is pruned (cancelled and removed), the replacement is accepted and keys
96+
// to a distinct prover (different archive root → different content id).
97+
store.cancelAndRemoveAboveBlock(BlockNumber(0));
98+
expect(proverA.isCancelled()).toBe(true);
8699
const proverB = await store.addOrUpdate(b, makeRegisterData());
87100
expect(proverB).not.toBe(proverA);
88-
expect(proverB.isPruned()).toBe(false);
101+
expect(proverB.isCancelled()).toBe(false);
89102
expect(stubs.length).toBe(2);
90103
});
91104

92-
it('markPrunedAboveBlock marks every prover holding a block above the target and returns them', async () => {
105+
it('cancelAndRemoveAboveBlock cancels and removes every prover holding a block above the target and returns them', async () => {
93106
// Four single-block checkpoints occupying blocks 1..4 (one block each). Pruning to block 2 orphans the
94107
// checkpoints whose last block is above 2 — checkpoints 3 and 4 — and leaves 1 and 2 canonical.
95108
const cps = await timesAsync(4, i =>
@@ -102,23 +115,26 @@ describe('CheckpointStore', () => {
102115
for (const cp of cps) {
103116
await store.addOrUpdate(cp, makeRegisterData());
104117
}
105-
const affected = store.markPrunedAboveBlock(BlockNumber(2));
118+
const affected = store.cancelAndRemoveAboveBlock(BlockNumber(2));
106119
expect(affected.map(p => p.checkpoint.number)).toEqual([3, 4]);
120+
expect(affected.every(p => p.isCancelled())).toBe(true);
121+
// The orphaned provers are gone from the store; only 1 and 2 remain.
107122
expect(store.listCanonical().map(p => p.checkpoint.number)).toEqual([1, 2]);
108123
});
109124

110-
it('markPrunedAboveBlock marks a checkpoint whose block range straddles the target (partially orphaned)', async () => {
125+
it('cancelAndRemoveAboveBlock removes a checkpoint whose block range straddles the target (partially orphaned)', async () => {
111126
// A single checkpoint spanning blocks 5..8. A prune to block 6 lands mid-checkpoint: the checkpoint is partially
112-
// orphaned (blocks 7, 8 are gone) and must be marked, since its last block (8) is above the target.
127+
// orphaned (blocks 7, 8 are gone) and must be removed, since its last block (8) is above the target.
113128
const cp = await Checkpoint.random(CheckpointNumber(1), { numBlocks: 4, startBlockNumber: 5 });
114129
await store.addOrUpdate(cp, makeRegisterData());
115130

116-
const affected = store.markPrunedAboveBlock(BlockNumber(6));
131+
const affected = store.cancelAndRemoveAboveBlock(BlockNumber(6));
117132
expect(affected.map(p => p.checkpoint.number)).toEqual([1]);
133+
expect(affected[0].isCancelled()).toBe(true);
118134
expect(store.listCanonical()).toEqual([]);
119135
});
120136

121-
it('reapExpired drops canonical provers whose epoch is ≤ expiredEpoch', async () => {
137+
it('reapExpired drops provers whose epoch is ≤ expiredEpoch', async () => {
122138
// With epochDuration=1 each checkpoint's slot is also its epoch number.
123139
const cps = await Promise.all([
124140
Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, slotNumber: SlotNumber(1) }),
@@ -133,74 +149,7 @@ describe('CheckpointStore', () => {
133149
expect(remainingNumbers).toEqual([3]);
134150
});
135151

136-
it('reapExpired leaves pruned provers in place', async () => {
137-
const cp = await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, slotNumber: SlotNumber(1) });
138-
await store.addOrUpdate(cp, makeRegisterData());
139-
store.markPrunedAboveBlock(BlockNumber(0));
140-
store.reapExpired(EpochNumber(10));
141-
expect(store.listAll().map(p => p.checkpoint.number)).toEqual([1]);
142-
});
143-
144-
// ---------------- slot watcher ----------------
145-
146-
it('slot watcher reaps pruned provers whose slot is strictly before the synced slot', async () => {
147-
const cp1 = await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, slotNumber: SlotNumber(1) });
148-
const cp2 = await Checkpoint.random(CheckpointNumber(2), { numBlocks: 1, slotNumber: SlotNumber(2) });
149-
const cp3 = await Checkpoint.random(CheckpointNumber(3), { numBlocks: 1, slotNumber: SlotNumber(3) });
150-
for (const cp of [cp1, cp2, cp3]) {
151-
await store.addOrUpdate(cp, makeRegisterData());
152-
}
153-
// Prune everything above checkpoint 0 ⇒ all three flip to pruned.
154-
store.markPrunedAboveBlock(BlockNumber(0));
155-
blockSource.getSyncedL2SlotNumber.mockResolvedValue(SlotNumber(3));
156-
157-
await store.triggerSlotWatcherTick();
158-
159-
// Slots 1 and 2 are < 3 and get reaped; slot 3 is not strictly less, so it stays.
160-
expect(store.listAll().map(p => p.checkpoint.number)).toEqual([3]);
161-
// Reaped stubs were cancelled by the watcher.
162-
expect(stubs.find(s => s.checkpoint.number === 1)!.cancelled).toBe(true);
163-
expect(stubs.find(s => s.checkpoint.number === 2)!.cancelled).toBe(true);
164-
expect(stubs.find(s => s.checkpoint.number === 3)!.cancelled).toBe(false);
165-
});
166-
167-
it('slot watcher leaves canonical provers in place even when their slot is past the synced slot', async () => {
168-
// Canonical provers must survive — only pruned provers are eligible for reaping.
169-
const cp = await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, slotNumber: SlotNumber(1) });
170-
await store.addOrUpdate(cp, makeRegisterData());
171-
blockSource.getSyncedL2SlotNumber.mockResolvedValue(SlotNumber(10));
172-
173-
await store.triggerSlotWatcherTick();
174-
175-
expect(store.listAll().map(p => p.checkpoint.number)).toEqual([1]);
176-
expect(stubs[0].cancelled).toBe(false);
177-
});
178-
179-
it('slot watcher no-ops when getSyncedL2SlotNumber returns undefined', async () => {
180-
const cp = await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, slotNumber: SlotNumber(1) });
181-
await store.addOrUpdate(cp, makeRegisterData());
182-
store.markPrunedAboveBlock(BlockNumber(0));
183-
blockSource.getSyncedL2SlotNumber.mockResolvedValue(undefined);
184-
185-
await store.triggerSlotWatcherTick();
186-
187-
// No synced slot yet ⇒ watcher doesn't know whether the chain has moved past, so it
188-
// keeps the pruned prover around for a possible re-add.
189-
expect(store.listAll().map(p => p.checkpoint.number)).toEqual([1]);
190-
expect(stubs[0].cancelled).toBe(false);
191-
});
192-
193-
it('slot watcher swallows getSyncedL2SlotNumber errors instead of crashing the tick', async () => {
194-
const cp = await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, slotNumber: SlotNumber(1) });
195-
await store.addOrUpdate(cp, makeRegisterData());
196-
store.markPrunedAboveBlock(BlockNumber(0));
197-
blockSource.getSyncedL2SlotNumber.mockRejectedValue(new Error('archiver unavailable'));
198-
199-
await expect(store.triggerSlotWatcherTick()).resolves.toBeUndefined();
200-
expect(store.listAll().map(p => p.checkpoint.number)).toEqual([1]);
201-
});
202-
203-
it('listCanonicalForEpoch returns only canonical provers in the epoch slot range', async () => {
152+
it('listCanonicalForEpoch returns only provers in the epoch slot range', async () => {
204153
// With epochDuration=1, each epoch's slot range is exactly [slot, slot].
205154
const cp1 = await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, slotNumber: SlotNumber(10) });
206155
const cp2 = await Checkpoint.random(CheckpointNumber(2), { numBlocks: 1, slotNumber: SlotNumber(11) });
@@ -220,12 +169,8 @@ type StubProver = {
220169
checkpoint: Checkpoint;
221170
slotNumber: SlotNumber;
222171
epochNumber: EpochNumber;
223-
pruned: boolean;
224172
cancelled: boolean;
225-
isPruned(): boolean;
226173
isCancelled(): boolean;
227-
markPruned(): void;
228-
markCanonical(): void;
229174
cancel(opts?: { routine?: boolean }): void;
230175
whenDone(): Promise<void>;
231176
};
@@ -237,20 +182,10 @@ function makeStubProver(checkpoint: Checkpoint, epochNumber: EpochNumber): StubP
237182
checkpoint,
238183
slotNumber: checkpoint.header.slotNumber,
239184
epochNumber,
240-
pruned: false,
241185
cancelled: false,
242-
isPruned() {
243-
return this.pruned;
244-
},
245186
isCancelled() {
246187
return this.cancelled;
247188
},
248-
markPruned() {
249-
this.pruned = true;
250-
},
251-
markCanonical() {
252-
this.pruned = false;
253-
},
254189
cancel() {
255190
this.cancelled = true;
256191
},
@@ -268,24 +203,3 @@ function makeRegisterData() {
268203
previousArchiveSiblingPath: makeTuple(ARCHIVE_HEIGHT, () => Fr.ZERO),
269204
};
270205
}
271-
272-
/**
273-
* Subclass that exposes the protected `reapPrunedPastSlot` so tests can drive a single
274-
* SlotWatcher tick directly — avoids spinning up the underlying `RunningPromise` and
275-
* waiting on its polling interval.
276-
*/
277-
class TestCheckpointStore extends CheckpointStore {
278-
constructor(
279-
blockSource: ConstructorParameters<typeof CheckpointStore>[0],
280-
proverDeps: ConstructorParameters<typeof CheckpointStore>[1],
281-
options: ConstructorParameters<typeof CheckpointStore>[2],
282-
bindings: ConstructorParameters<typeof CheckpointStore>[3],
283-
factory: CheckpointProverFactory,
284-
) {
285-
super(blockSource, proverDeps, options, bindings, factory);
286-
}
287-
288-
public triggerSlotWatcherTick(): Promise<void> {
289-
return this.reapPrunedPastSlot();
290-
}
291-
}

0 commit comments

Comments
 (0)