@aztec/prover-node 5.0.0-rc.2 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aztec/prover-node",
3
- "version": "5.0.0-rc.2",
3
+ "version": "5.0.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./dest/index.js",
@@ -56,28 +56,28 @@
56
56
  ]
57
57
  },
58
58
  "dependencies": {
59
- "@aztec/archiver": "5.0.0-rc.2",
60
- "@aztec/bb-prover": "5.0.0-rc.2",
61
- "@aztec/blob-client": "5.0.0-rc.2",
62
- "@aztec/blob-lib": "5.0.0-rc.2",
63
- "@aztec/constants": "5.0.0-rc.2",
64
- "@aztec/epoch-cache": "5.0.0-rc.2",
65
- "@aztec/ethereum": "5.0.0-rc.2",
66
- "@aztec/foundation": "5.0.0-rc.2",
67
- "@aztec/kv-store": "5.0.0-rc.2",
68
- "@aztec/l1-artifacts": "5.0.0-rc.2",
69
- "@aztec/native": "5.0.0-rc.2",
70
- "@aztec/node-keystore": "5.0.0-rc.2",
71
- "@aztec/node-lib": "5.0.0-rc.2",
72
- "@aztec/noir-protocol-circuits-types": "5.0.0-rc.2",
73
- "@aztec/p2p": "5.0.0-rc.2",
74
- "@aztec/protocol-contracts": "5.0.0-rc.2",
75
- "@aztec/prover-client": "5.0.0-rc.2",
76
- "@aztec/sequencer-client": "5.0.0-rc.2",
77
- "@aztec/simulator": "5.0.0-rc.2",
78
- "@aztec/stdlib": "5.0.0-rc.2",
79
- "@aztec/telemetry-client": "5.0.0-rc.2",
80
- "@aztec/world-state": "5.0.0-rc.2",
59
+ "@aztec/archiver": "5.0.1",
60
+ "@aztec/bb-prover": "5.0.1",
61
+ "@aztec/blob-client": "5.0.1",
62
+ "@aztec/blob-lib": "5.0.1",
63
+ "@aztec/constants": "5.0.1",
64
+ "@aztec/epoch-cache": "5.0.1",
65
+ "@aztec/ethereum": "5.0.1",
66
+ "@aztec/foundation": "5.0.1",
67
+ "@aztec/kv-store": "5.0.1",
68
+ "@aztec/l1-artifacts": "5.0.1",
69
+ "@aztec/native": "5.0.1",
70
+ "@aztec/node-keystore": "5.0.1",
71
+ "@aztec/node-lib": "5.0.1",
72
+ "@aztec/noir-protocol-circuits-types": "5.0.1",
73
+ "@aztec/p2p": "5.0.1",
74
+ "@aztec/protocol-contracts": "5.0.1",
75
+ "@aztec/prover-client": "5.0.1",
76
+ "@aztec/sequencer-client": "5.0.1",
77
+ "@aztec/simulator": "5.0.1",
78
+ "@aztec/stdlib": "5.0.1",
79
+ "@aztec/telemetry-client": "5.0.1",
80
+ "@aztec/world-state": "5.0.1",
81
81
  "source-map-support": "^0.5.21",
82
82
  "tslib": "^2.4.0",
83
83
  "viem": "npm:@aztec/viem@2.38.2"
@@ -1,6 +1,5 @@
1
1
  import type { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
2
2
  import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log';
3
- import { RunningPromise } from '@aztec/foundation/promise';
4
3
  import type { L2BlockSource } from '@aztec/stdlib/block';
5
4
  import type { Checkpoint } from '@aztec/stdlib/checkpoint';
6
5
  import { type L1RollupConstants, getEpochAtSlot, getSlotRangeForEpoch } from '@aztec/stdlib/epoch-helpers';
@@ -19,54 +18,64 @@ export type CheckpointProverFactory = (args: CheckpointProverArgs, deps: Checkpo
19
18
  *
20
19
  * The store survives every epoch / session boundary. A prover lives from its first
21
20
  * `addOrUpdate` call until either:
22
- * - it has been pruned and the L2 chain has moved past its slot (no re-add possible), or
21
+ * - its checkpoint is pruned by an L1 reorg (`cancelAndRemoveAboveBlock`), or
23
22
  * - its epoch's proof-submission window has closed (`reapExpired`), so the proof could no
24
23
  * longer be accepted on L1 even if produced.
25
24
  *
26
- * A re-add of a checkpoint that matches an existing prover's content key reuses the
27
- * existing prover (and flips it back to canonical); the in-flight sub-tree work never
28
- * stops, so a prune-then-re-add of the same content avoids re-proving entirely.
25
+ * A prover's sub-tree work forks world-state per block and does not survive a prune of a base
26
+ * block, so there is nothing to preserve across a reorg: a pruned prover is cancelled and
27
+ * dropped, and a re-add (even of identical content) constructs a fresh prover.
29
28
  */
30
29
  export class CheckpointStore {
31
30
  private readonly provers = new Map<string, CheckpointProver>();
32
- private readonly slotWatcher: RunningPromise;
31
+ /**
32
+ * Teardowns of provers already removed from `provers` (by prune or reap), awaited on `stop()`.
33
+ * Keyed by a monotonic id rather than the prover's content id: a prune-then-re-add can leave two
34
+ * teardowns for the same content id in flight at once, which a content-id key would clobber.
35
+ */
36
+ private readonly pendingTeardowns = new Map<number, Promise<void>>();
37
+ private nextTeardownId = 0;
33
38
  private readonly log: Logger;
34
39
 
35
40
  constructor(
36
- private readonly l2BlockSource: Pick<L2BlockSource, 'getSyncedL2SlotNumber' | 'getL1Constants'>,
41
+ private readonly l2BlockSource: Pick<L2BlockSource, 'getL1Constants'>,
37
42
  private readonly proverDeps: Omit<CheckpointProverDeps, 'log'>,
38
- private readonly options: { slotWatcherPollIntervalMs: number },
39
43
  bindings?: LoggerBindings,
40
44
  private readonly proverFactoryFn: CheckpointProverFactory = (args, deps) => new CheckpointProver(args, deps),
41
45
  ) {
42
46
  this.log = createLogger('prover-node:checkpoint-store', bindings);
43
- this.slotWatcher = new RunningPromise(
44
- () => this.reapPrunedPastSlot(),
45
- this.log,
46
- this.options.slotWatcherPollIntervalMs,
47
- );
48
47
  }
49
48
 
50
49
  public start(): Promise<void> {
51
- this.slotWatcher.start();
52
50
  return Promise.resolve();
53
51
  }
54
52
 
55
53
  public async stop(): Promise<void> {
56
- await this.slotWatcher.stop();
57
- // Cancel every live prover; await teardown.
54
+ // Cancel every live prover, then await both their teardown and any still in flight for provers
55
+ // already removed by a prune or reap.
58
56
  const provers = Array.from(this.provers.values());
59
57
  this.provers.clear();
60
58
  for (const prover of provers) {
61
59
  prover.cancel();
62
60
  }
63
- await Promise.allSettled(provers.map(p => p.whenDone()));
61
+ await Promise.allSettled([...provers.map(p => p.whenDone()), ...this.pendingTeardowns.values()]);
62
+ }
63
+
64
+ /**
65
+ * Tracks the teardown of a prover just removed from the store so `stop()` can await it. The entry
66
+ * removes itself once teardown settles, so the map stays bounded by the number in flight.
67
+ */
68
+ private trackTeardown(prover: CheckpointProver): void {
69
+ const id = this.nextTeardownId++;
70
+ const done = prover.whenDone();
71
+ this.pendingTeardowns.set(id, done);
72
+ void done.finally(() => this.pendingTeardowns.delete(id));
64
73
  }
65
74
 
66
75
  /**
67
76
  * Registers a checkpoint with the store. If a prover already exists for the
68
- * `(number, slot, archive root)` content key, it is reused and marked canonical;
69
- * otherwise a new prover is constructed.
77
+ * `(number, slot, archive root)` content key it is reused (an at-least-once re-registration of
78
+ * still-canonical content); otherwise a new prover is constructed.
70
79
  */
71
80
  public async addOrUpdate(checkpoint: Checkpoint, data: RegisterCheckpointData): Promise<CheckpointProver> {
72
81
  const l1Constants = await this.l2BlockSource.getL1Constants();
@@ -75,18 +84,18 @@ export class CheckpointStore {
75
84
 
76
85
  const existing = this.provers.get(id);
77
86
  if (existing) {
78
- existing.markCanonical();
79
87
  return existing;
80
88
  }
81
89
 
82
- // At most one canonical checkpoint per slot. A different canonical checkpoint at the
83
- // same slot means the caller forgot to prune the old chain before adding the replacement
84
- // — surface it rather than silently creating a parallel canonical chain.
90
+ // At most one canonical checkpoint per slot. A different checkpoint at the same slot means the
91
+ // caller forgot to prune the old chain before adding the replacement — surface it rather than
92
+ // silently creating a parallel canonical chain. A pruned checkpoint has already been removed,
93
+ // so every prover still in the store is canonical.
85
94
  for (const prover of this.provers.values()) {
86
- if (prover.slotNumber === checkpoint.header.slotNumber && !prover.isPruned()) {
95
+ if (prover.slotNumber === checkpoint.header.slotNumber) {
87
96
  throw new Error(
88
97
  `Cannot add checkpoint ${checkpoint.number} (archive ${checkpoint.archive.root}) at slot ${checkpoint.header.slotNumber}: ` +
89
- `a different canonical checkpoint already occupies this slot. Prune it first.`,
98
+ `a different checkpoint already occupies this slot. Prune it first.`,
90
99
  );
91
100
  }
92
101
  }
@@ -97,20 +106,25 @@ export class CheckpointStore {
97
106
  }
98
107
 
99
108
  /**
100
- * Marks every canonical prover that holds a block above the prune target as pruned. A checkpoint is orphaned by a
101
- * prune to block `targetBlockNumber` iff its last block sits above the target — including a checkpoint whose range
102
- * straddles the target (partially orphaned), which block-range marking catches without boundary ambiguity. Keying
103
- * off the surviving block number (rather than a checkpoint number) is correct even when the source has already
109
+ * Cancels and removes every prover that holds a block above the prune target. A checkpoint is orphaned by a prune to
110
+ * block `targetBlockNumber` iff its last block sits above the target — including a checkpoint whose range straddles
111
+ * the target (partially orphaned), which block-range marking catches without boundary ambiguity. Keying off the
112
+ * surviving block number (rather than a checkpoint number) is correct even when the source has already
104
113
  * re-checkpointed past the divergence: the prune event reports the highest surviving block, which by construction
105
114
  * survives on the source, whereas the source's current checkpointed tip can sit above the prune target.
106
- * Sub-tree work keeps running so a re-add of the same content can pick it up. Returns the affected provers.
115
+ *
116
+ * The prover's in-flight sub-tree work forks world-state per block and faults once its base block is pruned, so it
117
+ * cannot be reused; it is cancelled (aborting the fork reads) and dropped. A subsequent re-add constructs a fresh
118
+ * prover. Returns the removed provers.
107
119
  */
108
- public markPrunedAboveBlock(targetBlockNumber: BlockNumber): CheckpointProver[] {
120
+ public cancelAndRemoveAboveBlock(targetBlockNumber: BlockNumber): CheckpointProver[] {
109
121
  const affected: CheckpointProver[] = [];
110
- for (const prover of this.provers.values()) {
122
+ for (const [id, prover] of Array.from(this.provers.entries())) {
111
123
  const lastBlockNumber = prover.checkpoint.blocks.at(-1)!.number;
112
- if (lastBlockNumber > targetBlockNumber && !prover.isPruned()) {
113
- prover.markPruned();
124
+ if (lastBlockNumber > targetBlockNumber) {
125
+ prover.cancel();
126
+ this.trackTeardown(prover);
127
+ this.provers.delete(id);
114
128
  affected.push(prover);
115
129
  }
116
130
  }
@@ -118,20 +132,17 @@ export class CheckpointStore {
118
132
  }
119
133
 
120
134
  /**
121
- * Drops canonical (non-pruned) provers whose epoch is at or below the supplied expired
122
- * epoch. Once an epoch's proof-submission window has closed, its proof can no longer be
123
- * accepted on L1, so the prover is no longer needed.
135
+ * Drops provers whose epoch is at or below the supplied expired epoch. Once an epoch's
136
+ * proof-submission window has closed, its proof can no longer be accepted on L1, so the
137
+ * prover is no longer needed.
124
138
  */
125
139
  public reapExpired(expiredEpoch: EpochNumber): void {
126
140
  const reaped: { id: string; checkpointNumber: CheckpointNumber; epochNumber: EpochNumber }[] = [];
127
141
  for (const [id, prover] of Array.from(this.provers.entries())) {
128
- if (prover.isPruned()) {
129
- continue;
130
- }
131
142
  if (prover.epochNumber <= expiredEpoch) {
132
143
  reaped.push({ id, checkpointNumber: prover.checkpoint.number, epochNumber: prover.epochNumber });
133
144
  prover.cancel({ routine: true });
134
- void prover.whenDone();
145
+ this.trackTeardown(prover);
135
146
  this.provers.delete(id);
136
147
  }
137
148
  }
@@ -154,63 +165,28 @@ export class CheckpointStore {
154
165
  return this.provers.get(CheckpointProver.idFor(checkpoint));
155
166
  }
156
167
 
157
- /** Every prover currently in the store (canonical and pruned), in insertion order. */
168
+ /** Every prover currently in the store, in insertion order. */
158
169
  public listAll(): CheckpointProver[] {
159
170
  return Array.from(this.provers.values());
160
171
  }
161
172
 
162
- /** Canonical (non-pruned) provers in the store, sorted by checkpoint number. */
163
- public listCanonical(): CheckpointProver[] {
164
- return Array.from(this.provers.values())
165
- .filter(p => !p.isPruned())
166
- .sort((a, b) => a.checkpoint.number - b.checkpoint.number);
173
+ /** Provers in the store, sorted by checkpoint number. */
174
+ public list(): CheckpointProver[] {
175
+ return Array.from(this.provers.values()).sort((a, b) => a.checkpoint.number - b.checkpoint.number);
167
176
  }
168
177
 
169
178
  /**
170
- * Canonical provers whose slot is in the supplied epoch's slot range, sorted by
171
- * checkpoint number.
179
+ * Provers whose slot is in the supplied epoch's slot range, sorted by checkpoint number.
172
180
  */
173
- public async listCanonicalForEpoch(epoch: EpochNumber): Promise<CheckpointProver[]> {
181
+ public async listForEpoch(epoch: EpochNumber): Promise<CheckpointProver[]> {
174
182
  const l1Constants = await this.l2BlockSource.getL1Constants();
175
183
  const [fromSlot, toSlot] = getSlotRangeForEpoch(epoch, l1Constants);
176
- return this.listCanonicalInSlotRange(fromSlot, toSlot);
184
+ return this.listInSlotRange(fromSlot, toSlot);
177
185
  }
178
186
 
179
- /** Canonical provers whose slot falls within `[fromSlot, toSlot]`, sorted by checkpoint number. */
180
- public listCanonicalInSlotRange(fromSlot: SlotNumber, toSlot: SlotNumber): CheckpointProver[] {
181
- return this.listCanonical().filter(p => p.slotNumber >= fromSlot && p.slotNumber <= toSlot);
182
- }
183
-
184
- /**
185
- * SlotWatcher tick: reap pruned provers whose slot has passed the chain's synced
186
- * slot. Once the chain has moved past, no re-add can revive the prover and its
187
- * content key is unique enough that an actual re-add would create a new entry.
188
- *
189
- * Protected so unit tests can drive a single tick without spinning up the
190
- * `RunningPromise` and waiting on its interval.
191
- */
192
- protected async reapPrunedPastSlot(): Promise<void> {
193
- let syncedSlot: SlotNumber | undefined;
194
- try {
195
- syncedSlot = await this.l2BlockSource.getSyncedL2SlotNumber();
196
- } catch (err) {
197
- this.log.debug(`SlotWatcher could not read synced slot`, { error: `${err}` });
198
- return;
199
- }
200
- if (syncedSlot === undefined) {
201
- return;
202
- }
203
- for (const [id, prover] of Array.from(this.provers.entries())) {
204
- if (prover.isPruned() && prover.slotNumber < syncedSlot) {
205
- this.log.info(`Reaping pruned CheckpointProver ${id}: slot ${prover.slotNumber} < synced ${syncedSlot}`, {
206
- checkpointNumber: prover.checkpoint.number,
207
- slotNumber: prover.slotNumber,
208
- });
209
- prover.cancel();
210
- void prover.whenDone();
211
- this.provers.delete(id);
212
- }
213
- }
187
+ /** Provers whose slot falls within `[fromSlot, toSlot]`, sorted by checkpoint number. */
188
+ public listInSlotRange(fromSlot: SlotNumber, toSlot: SlotNumber): CheckpointProver[] {
189
+ return this.list().filter(p => p.slotNumber >= fromSlot && p.slotNumber <= toSlot);
214
190
  }
215
191
  }
216
192
 
@@ -59,16 +59,16 @@ export type CheckpointProverArgs = {
59
59
  * The store creates a CheckpointProver once per content-key. Keying on the checkpoint's
60
60
  * own archive root (its post-state) means two checkpoints are "the same" iff they
61
61
  * produce the same archive — so a reorg branch, or a replacement built on the same
62
- * predecessor but with different content, keys to a distinct prover; an identical
63
- * re-add keys to the same one and reuses its in-flight sub-tree work.
62
+ * predecessor but with different content, keys to a distinct prover.
64
63
  *
65
64
  * The prover eagerly starts its own tx gather and sub-tree work in the constructor, so
66
65
  * callers only need to call `whenBlockProofsReady()` to obtain the resulting block-rollup
67
66
  * proofs.
68
67
  *
69
- * The prover survives prune/re-add cycles via `markPruned()` / `markCanonical()`
70
- * sub-tree proving keeps running underneath, so a checkpoint that is re-added after
71
- * a brief reorg can be re-consumed with no re-proving.
68
+ * A CheckpointProver does not survive a prune: its sub-tree work forks world-state per
69
+ * block, and an L1 prune of a base block faults those reads. The store therefore cancels and
70
+ * discards a prover when its checkpoint is pruned, and a re-add (even of identical content)
71
+ * constructs a fresh prover.
72
72
  *
73
73
  * `cancel()` is idempotent. It aborts the gather + sub-tree, rejects the block-proof
74
74
  * promise, and exposes a `whenDone()` that resolves once teardown has unwound.
@@ -92,8 +92,6 @@ export class CheckpointProver {
92
92
  private cancelled = false;
93
93
  private subTree?: CheckpointSubTreeOrchestrator;
94
94
  private completed = false;
95
- /** Pruned in the canonical chain but not yet reaped — sub-tree continues running. */
96
- private pruned = false;
97
95
  private readonly abortController = new AbortController();
98
96
 
99
97
  /** Tracks the eager gather+execute task so `cancel()` and `whenDone()` can await its unwind. */
@@ -146,37 +144,6 @@ export class CheckpointProver {
146
144
  return this.completed;
147
145
  }
148
146
 
149
- public isPruned(): boolean {
150
- return this.pruned;
151
- }
152
-
153
- /**
154
- * Mark this prover as no longer present in the canonical chain. Sub-tree proving keeps
155
- * running so the work survives if the checkpoint is re-added. Idempotent.
156
- */
157
- public markPruned(): void {
158
- if (this.pruned) {
159
- return;
160
- }
161
- this.pruned = true;
162
- this.deps.log.info(`Marking CheckpointProver ${this.id} as pruned`, {
163
- checkpointNumber: this.checkpoint.number,
164
- slotNumber: this.slotNumber,
165
- });
166
- }
167
-
168
- /** Mark this prover as part of the canonical chain again after a re-add. Idempotent. */
169
- public markCanonical(): void {
170
- if (!this.pruned) {
171
- return;
172
- }
173
- this.pruned = false;
174
- this.deps.log.info(`Marking CheckpointProver ${this.id} as canonical`, {
175
- checkpointNumber: this.checkpoint.number,
176
- slotNumber: this.slotNumber,
177
- });
178
- }
179
-
180
147
  /** AbortSignal that fires on cancel — for callers that want to wire their own tasks. */
181
148
  public getAbortSignal(): AbortSignal {
182
149
  return this.abortController.signal;
@@ -432,7 +399,14 @@ export class CheckpointProver {
432
399
  }
433
400
 
434
401
  private async processTxs(publicProcessor: PublicProcessor, txs: Tx[]): Promise<ProcessedTx[]> {
435
- const [processedTxs, failedTxs] = await publicProcessor.process(txs, { deadline: this.deps.deadline });
402
+ // Pass the abort signal so a prune-driven cancel stops the current block's public execution
403
+ // immediately, rather than running it to completion before the next `signal.aborted` check.
404
+ // On abort `process` returns a partial result, the length check below throws, and
405
+ // `gatherAndExecute` swallows it via its `cancelled` guard.
406
+ const [processedTxs, failedTxs] = await publicProcessor.process(txs, {
407
+ deadline: this.deps.deadline,
408
+ signal: this.abortController.signal,
409
+ });
436
410
 
437
411
  if (failedTxs.length) {
438
412
  const failedTxHashes = await Promise.all(failedTxs.map(({ tx }) => tx.getTxHash()));
@@ -228,7 +228,7 @@ export class EpochSession implements Traceable {
228
228
  * Cancels the session. Idempotent. Withdraws any submitted candidate from the
229
229
  * publishing service so the in-flight publisher (if any) is interrupted.
230
230
  */
231
- public async cancel(reason = 'cancelled'): Promise<void> {
231
+ public async cancel(reason = 'cancelled', { abortJobs = true }: { abortJobs?: boolean } = {}): Promise<void> {
232
232
  if (this.isTerminal()) {
233
233
  return;
234
234
  }
@@ -247,7 +247,9 @@ export class EpochSession implements Traceable {
247
247
  if (this.topTreeJob && !this.topTreeJob.isCancelled()) {
248
248
  const job = this.topTreeJob;
249
249
  this.topTreeJob = undefined;
250
- job.cancel();
250
+ // On a clean shutdown we leave the in-flight broker jobs alone so a restart can reuse them;
251
+ // other cancellations (reorg, supersede, deadline) abort them since their inputs are stale.
252
+ job.cancel(abortJobs);
251
253
  this.pendingTopTreeCleanups.push(job);
252
254
  }
253
255
  await this.teardownTopTreeIfNeeded();
@@ -128,7 +128,7 @@ export class TopTreeJob {
128
128
  * via `whenDone()` — the parent collects the cancelled job and awaits all
129
129
  * pending top-tree teardowns at the end of the epoch.
130
130
  */
131
- public cancel(): void {
131
+ public cancel(abortJobs = true): void {
132
132
  if (this.cancelled) {
133
133
  return;
134
134
  }
@@ -146,7 +146,7 @@ export class TopTreeJob {
146
146
  // Fire and forget: parent awaits the cancel-driven teardown via whenDone(); the
147
147
  // chained .catch swallows rejections so the unawaited promise doesn't surface
148
148
  // as an unhandled rejection.
149
- this.cancelPromise = this.runCancel().catch(() => {});
149
+ this.cancelPromise = this.runCancel(abortJobs).catch(() => {});
150
150
  }
151
151
 
152
152
  /** Resolves once the cancel-driven teardown of the underlying orchestrator has unwound. */
@@ -156,9 +156,9 @@ export class TopTreeJob {
156
156
  }
157
157
  }
158
158
 
159
- private async runCancel(): Promise<void> {
159
+ private async runCancel(abortJobs: boolean): Promise<void> {
160
160
  try {
161
- this.topTree.cancel({ abortJobs: true });
161
+ this.topTree.cancel({ abortJobs });
162
162
  } catch (err) {
163
163
  this.deps.log.error('Error cancelling top tree', err);
164
164
  }
package/src/metrics.ts CHANGED
@@ -96,7 +96,7 @@ export class ProverNodeJobMetrics {
96
96
  this.activeCheckpoints = this.meter.createObservableGauge(Metrics.PROVER_NODE_ACTIVE_CHECKPOINTS);
97
97
  this.activeEpochSessions = this.meter.createObservableGauge(Metrics.PROVER_NODE_ACTIVE_EPOCH_SESSIONS);
98
98
  this.stateObserver = (observer: BatchObservableResult) => {
99
- observer.observe(this.activeCheckpoints!, checkpointStore.listCanonical().length);
99
+ observer.observe(this.activeCheckpoints!, checkpointStore.listAll().length);
100
100
  let full = 0;
101
101
  let partial = 0;
102
102
  for (const session of sessionManager.allSessions()) {
@@ -12,9 +12,9 @@ import { getLastSiblingPath } from '@aztec/prover-client/helpers';
12
12
  import { ChonkCache } from '@aztec/prover-client/orchestrator';
13
13
  import { PublicProcessorFactory } from '@aztec/simulator/server';
14
14
  import {
15
+ EventDrivenL2BlockStream,
15
16
  type L2BlockId,
16
17
  type L2BlockSource,
17
- L2BlockStream,
18
18
  type L2BlockStreamEvent,
19
19
  type L2BlockStreamEventHandler,
20
20
  L2TipsMemoryStore,
@@ -87,7 +87,7 @@ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Tra
87
87
  /** In-memory store for the L2BlockStream's local data provider. */
88
88
  private tipsStore: L2TipsMemoryStore;
89
89
  /** Block stream for checkpoint and reorg detection. */
90
- private blockStream: L2BlockStream | undefined;
90
+ private blockStream: EventDrivenL2BlockStream | undefined;
91
91
  /**
92
92
  * Highest epoch whose proof-submission window has passed. Monotonic high-water mark.
93
93
  * Seeded from the last fully-proven epoch at start(); advanced on every block-stream
@@ -169,7 +169,6 @@ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Tra
169
169
  txGatheringTimeoutMs: this.config.txGatheringTimeoutMs,
170
170
  deadline: undefined,
171
171
  },
172
- { slotWatcherPollIntervalMs: this.config.proverNodePollingIntervalMs },
173
172
  this.log.getBindings(),
174
173
  );
175
174
  }
@@ -372,8 +371,8 @@ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Tra
372
371
  private async handlePruneEvent(prunedToBlock: L2BlockId) {
373
372
  this.log.warn(`Chain pruned to block ${prunedToBlock.number}`, { prunedToBlock });
374
373
 
375
- // Resolve the cursor floor BEFORE marking provers: markPrunedAboveBlock returns only newly-marked provers, so a
376
- // throw after marking would leave a retry pass with nothing to act on. Resolving first means a throw leaves
374
+ // Resolve the cursor floor BEFORE removing provers: cancelAndRemoveAboveBlock returns only the provers it removed,
375
+ // so a throw after removing would leave a retry pass with nothing to act on. Resolving first means a throw leaves
377
376
  // everything untouched and the next pass retries the whole handler (the tips cursor only advances on success).
378
377
  let cursorFloor: CheckpointNumber;
379
378
  if (prunedToBlock.number === 0) {
@@ -391,7 +390,7 @@ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Tra
391
390
  cursorFloor = CheckpointNumber(Math.max(0, Number(targetData.checkpointNumber) - 1));
392
391
  }
393
392
 
394
- const affected = this.checkpointStore.markPrunedAboveBlock(prunedToBlock.number);
393
+ const affected = this.checkpointStore.cancelAndRemoveAboveBlock(prunedToBlock.number);
395
394
 
396
395
  if (this.lastProcessedCheckpoint > cursorFloor) {
397
396
  this.lastProcessedCheckpoint = cursorFloor;
@@ -510,7 +509,7 @@ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Tra
510
509
  const { lastFullyProvenEpoch } = await this.resolveLastFullyProvenEpoch();
511
510
  this.lastExpiredEpoch = lastFullyProvenEpoch;
512
511
  this.lastProcessedCheckpoint = await this.computeStartingCheckpoint(lastFullyProvenEpoch);
513
- this.blockStream = new L2BlockStream(this.l2BlockSource, this.tipsStore, this, this.log, {
512
+ this.blockStream = new EventDrivenL2BlockStream(this.l2BlockSource, this.tipsStore, this, this.log, {
514
513
  pollIntervalMS: this.config.proverNodePollingIntervalMs,
515
514
  tipsOnly: true,
516
515
  });
@@ -185,7 +185,7 @@ export class SessionManager {
185
185
  * slot. Dedupes against any existing session covering the same range, returning its id.
186
186
  */
187
187
  public async startProof(epoch: EpochNumber): Promise<string> {
188
- const canonical = await this.deps.checkpointStore.listCanonicalForEpoch(epoch);
188
+ const canonical = await this.deps.checkpointStore.listForEpoch(epoch);
189
189
  if (canonical.length === 0) {
190
190
  throw new EmptyEpochError(epoch);
191
191
  }
@@ -227,7 +227,9 @@ export class SessionManager {
227
227
  await this.epochTicker?.stop();
228
228
  await this.reconcileQueue.cancel();
229
229
  const sessions = this.allSessions();
230
- await Promise.allSettled(sessions.map(s => s.cancel('prover-node stopping')));
230
+ // A clean shutdown is just a restart, so preserve the in-flight broker jobs (abortJobs: false)
231
+ // for the restarted node to reuse rather than re-proving the epoch from scratch.
232
+ await Promise.allSettled(sessions.map(s => s.cancel('prover-node stopping', { abortJobs: false })));
231
233
  }
232
234
 
233
235
  // ---------------- reconcile ----------------
@@ -268,7 +270,7 @@ export class SessionManager {
268
270
  this.fullSessions.delete(key);
269
271
  continue;
270
272
  }
271
- const canonical = this.canonicalCheckpointsForSpec(session.getSpec());
273
+ const canonical = this.checkpointsForSpec(session.getSpec());
272
274
  if (!this.checkpointsMatch(session.getCheckpoints(), canonical)) {
273
275
  this.fireAndForgetCancel(session, 'canonical content changed');
274
276
  this.fullSessions.delete(key);
@@ -284,7 +286,7 @@ export class SessionManager {
284
286
  this.partialSessions.delete(key);
285
287
  continue;
286
288
  }
287
- const canonical = this.canonicalCheckpointsForSpec(session.getSpec());
289
+ const canonical = this.checkpointsForSpec(session.getSpec());
288
290
  if (!this.checkpointsMatch(session.getCheckpoints(), canonical)) {
289
291
  this.fireAndForgetCancel(session, 'canonical content changed');
290
292
  this.partialSessions.delete(key);
@@ -298,6 +300,8 @@ export class SessionManager {
298
300
  }
299
301
 
300
302
  private async openFullSessionIfReady(epoch: EpochNumber): Promise<void> {
303
+ // `recreateInvalidSessions` runs at the top of every reconcile and deletes terminal sessions
304
+ // before this is called, so a session present here is live and already covers the epoch.
301
305
  if (this.fullSessions.has(epoch)) {
302
306
  return;
303
307
  }
@@ -314,7 +318,7 @@ export class SessionManager {
314
318
  return;
315
319
  }
316
320
  const [fromSlot, toSlot] = getSlotRangeForEpoch(epoch, l1Constants);
317
- const canonical = this.deps.checkpointStore.listCanonicalInSlotRange(fromSlot, toSlot);
321
+ const canonical = this.deps.checkpointStore.listInSlotRange(fromSlot, toSlot);
318
322
  if (!this.archiverFullyCovered(archiverCps, canonical)) {
319
323
  this.log.debug(`Skipping full-session open for epoch ${epoch}: archiver checkpoints not all in store`, {
320
324
  archiverCount: archiverCps.length,
@@ -329,7 +333,7 @@ export class SessionManager {
329
333
  }
330
334
 
331
335
  private openPartialSession(spec: SessionSpec): void {
332
- const canonical = this.deps.checkpointStore.listCanonicalInSlotRange(spec.fromSlot, spec.toSlot);
336
+ const canonical = this.deps.checkpointStore.listInSlotRange(spec.fromSlot, spec.toSlot);
333
337
  if (canonical.length === 0) {
334
338
  return;
335
339
  }
@@ -403,6 +407,17 @@ export class SessionManager {
403
407
  const state = await session.start();
404
408
  this.log.info(`Session ${session.getId()} exited with state ${state}`);
405
409
  if (state === 'failed' && this.deps.onSessionFailed) {
410
+ // Best-effort suppression of the spurious post-mortem upload a prune produces: if the session's
411
+ // checkpoints no longer match the store's current set, the failure was caused by the content
412
+ // changing under it, not a genuine proving fault, so skip the upload. This is inherently racy —
413
+ // the store lags the world-state unwind, so a fault observed before the prune is reconciled here
414
+ // still uploads. The epoch is recovered regardless by recreating the session on re-add.
415
+ if (!this.checkpointsMatch(session.getCheckpoints(), this.checkpointsForSpec(session.getSpec()))) {
416
+ this.log.info(`Skipping failure upload for session ${session.getId()}: canonical content changed`, {
417
+ ...session.getSpec(),
418
+ });
419
+ return;
420
+ }
406
421
  try {
407
422
  await this.deps.onSessionFailed(session);
408
423
  } catch (err) {
@@ -447,6 +462,24 @@ export class SessionManager {
447
462
  return live >= max;
448
463
  }
449
464
 
465
+ /**
466
+ * Maps a reconcile trigger to the epochs whose full session should be (re)opened.
467
+ *
468
+ * This is where the "don't retry a genuinely-failed epoch, but do recover a pruned one" invariant
469
+ * lives — enforced by which triggers are gated by `lastTickEpoch`:
470
+ *
471
+ * - The periodic `tick` IS gated: once a tick has opened a session for an epoch, `lastTickEpoch`
472
+ * advances to it and later ticks skip it (`epoch <= lastTickEpoch`). So a failed attempt is never
473
+ * resubmitted on a loop by the tick.
474
+ * - `checkpoint` and `prune` are deliberately NOT gated. They only fire when the epoch's canonical
475
+ * content actually changes — a checkpoint arrives, or a reorg prunes/replaces one — which is
476
+ * exactly when re-attempting is correct.
477
+ *
478
+ * A genuine proving failure produces no content change, hence no checkpoint/prune event, so only
479
+ * the gated tick could reopen it — and it won't. A prune + re-add fires ungated events, so the
480
+ * epoch is reopened through this path (and `openFullSessionIfReady` rebuilds over the fresh
481
+ * provers). See the "onTick does not retry ... but recovers ... re-added" test.
482
+ */
450
483
  private async epochsForTrigger(trigger: ReconcileTrigger): Promise<EpochNumber[]> {
451
484
  switch (trigger.kind) {
452
485
  case 'checkpoint':
@@ -481,8 +514,8 @@ export class SessionManager {
481
514
  return getEpochAtSlot(header.getSlot(), await this.getL1Constants());
482
515
  }
483
516
 
484
- private canonicalCheckpointsForSpec(spec: SessionSpec): CheckpointProver[] {
485
- return this.deps.checkpointStore.listCanonicalInSlotRange(spec.fromSlot, spec.toSlot);
517
+ private checkpointsForSpec(spec: SessionSpec): CheckpointProver[] {
518
+ return this.deps.checkpointStore.listInSlotRange(spec.fromSlot, spec.toSlot);
486
519
  }
487
520
 
488
521
  private fireAndForgetCancel(session: EpochSession, reason: string): void {