@aztec/prover-node 5.0.0-rc.1 → 5.0.0

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.
Files changed (36) hide show
  1. package/README.md +73 -68
  2. package/dest/actions/rerun-epoch-proving-job.d.ts +2 -2
  3. package/dest/actions/rerun-epoch-proving-job.d.ts.map +1 -1
  4. package/dest/checkpoint-store.d.ts +42 -37
  5. package/dest/checkpoint-store.d.ts.map +1 -1
  6. package/dest/checkpoint-store.js +65 -77
  7. package/dest/job/checkpoint-prover.d.ts +6 -16
  8. package/dest/job/checkpoint-prover.d.ts.map +1 -1
  9. package/dest/job/checkpoint-prover.js +15 -35
  10. package/dest/job/epoch-session.d.ts +2 -2
  11. package/dest/job/epoch-session.d.ts.map +1 -1
  12. package/dest/job/epoch-session.js +20 -9
  13. package/dest/metrics.d.ts +7 -4
  14. package/dest/metrics.d.ts.map +1 -1
  15. package/dest/metrics.js +14 -6
  16. package/dest/proof-publishing-service.d.ts +4 -2
  17. package/dest/proof-publishing-service.d.ts.map +1 -1
  18. package/dest/proof-publishing-service.js +1 -0
  19. package/dest/prover-node-publisher.d.ts +5 -5
  20. package/dest/prover-node-publisher.d.ts.map +1 -1
  21. package/dest/prover-node-publisher.js +2 -3
  22. package/dest/prover-node.d.ts +25 -15
  23. package/dest/prover-node.d.ts.map +1 -1
  24. package/dest/prover-node.js +152 -51
  25. package/dest/session-manager.d.ts +2 -2
  26. package/dest/session-manager.d.ts.map +1 -1
  27. package/dest/session-manager.js +38 -8
  28. package/package.json +23 -23
  29. package/src/checkpoint-store.ts +66 -85
  30. package/src/job/checkpoint-prover.ts +17 -40
  31. package/src/job/epoch-session.ts +21 -9
  32. package/src/metrics.ts +15 -12
  33. package/src/proof-publishing-service.ts +4 -1
  34. package/src/prover-node-publisher.ts +11 -10
  35. package/src/prover-node.ts +170 -60
  36. package/src/session-manager.ts +38 -7
@@ -5,14 +5,16 @@ import { BlockNumber, CheckpointNumber, EpochNumber } from '@aztec/foundation/br
5
5
  import { assertRequired, compact, pick } from '@aztec/foundation/collection';
6
6
  import { memoize } from '@aztec/foundation/decorators';
7
7
  import { createLogger } from '@aztec/foundation/log';
8
+ import { RunningPromise } from '@aztec/foundation/running-promise';
8
9
  import { DateProvider, executeTimeout } from '@aztec/foundation/timer';
9
10
  import type { EpochProverFactory } from '@aztec/prover-client';
10
11
  import { getLastSiblingPath } from '@aztec/prover-client/helpers';
11
12
  import { ChonkCache } from '@aztec/prover-client/orchestrator';
12
13
  import { PublicProcessorFactory } from '@aztec/simulator/server';
13
14
  import {
15
+ EventDrivenL2BlockStream,
16
+ type L2BlockId,
14
17
  type L2BlockSource,
15
- L2BlockStream,
16
18
  type L2BlockStreamEvent,
17
19
  type L2BlockStreamEventHandler,
18
20
  L2TipsMemoryStore,
@@ -28,7 +30,6 @@ import {
28
30
  type ITxProvider,
29
31
  type ProverNodeApi,
30
32
  type Service,
31
- type WorldStateSyncStatus,
32
33
  type WorldStateSynchronizer,
33
34
  tryStop,
34
35
  } from '@aztec/stdlib/interfaces/server';
@@ -86,7 +87,7 @@ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Tra
86
87
  /** In-memory store for the L2BlockStream's local data provider. */
87
88
  private tipsStore: L2TipsMemoryStore;
88
89
  /** Block stream for checkpoint and reorg detection. */
89
- private blockStream: L2BlockStream | undefined;
90
+ private blockStream: EventDrivenL2BlockStream | undefined;
90
91
  /**
91
92
  * Highest epoch whose proof-submission window has passed. Monotonic high-water mark.
92
93
  * Seeded from the last fully-proven epoch at start(); advanced on every block-stream
@@ -95,6 +96,17 @@ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Tra
95
96
  */
96
97
  protected lastExpiredEpoch: EpochNumber | undefined;
97
98
 
99
+ /**
100
+ * Highest checkpoint number whose proving-side handling has completed (or that was legitimately skipped).
101
+ * The catch-up loop walks from here to each `chain-checkpointed` tip event. Seeded at start() from the last
102
+ * checkpoint of the last fully-proven epoch (or 0), so a restart reprocesses the partially-proven epoch rather
103
+ * than trusting a checkpointed tip that may sit ahead of unproven checkpoints. Clamped down on a prune.
104
+ */
105
+ protected lastProcessedCheckpoint: CheckpointNumber = CheckpointNumber.ZERO;
106
+
107
+ /** Periodic tick that runs the epoch-expiry sweep during idle periods when no block-stream events arrive. */
108
+ private expiryTicker: RunningPromise | undefined;
109
+
98
110
  public readonly tracer: Tracer;
99
111
 
100
112
  protected publishingService: ProofPublishingService | undefined;
@@ -157,7 +169,6 @@ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Tra
157
169
  txGatheringTimeoutMs: this.config.txGatheringTimeoutMs,
158
170
  deadline: undefined,
159
171
  },
160
- { slotWatcherPollIntervalMs: this.config.proverNodePollingIntervalMs },
161
172
  this.log.getBindings(),
162
173
  );
163
174
  }
@@ -198,17 +209,6 @@ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Tra
198
209
  return this.sessionManager;
199
210
  }
200
211
 
201
- /** Returns world state status. */
202
- public async getWorldStateSyncStatus(): Promise<WorldStateSyncStatus> {
203
- const { syncSummary } = await this.worldState.status();
204
- return syncSummary;
205
- }
206
-
207
- /** Returns archiver status. */
208
- public getL2Tips() {
209
- return this.l2BlockSource.getL2Tips();
210
- }
211
-
212
212
  /** Returns the underlying prover instance. */
213
213
  public getProver() {
214
214
  return this.prover;
@@ -219,17 +219,25 @@ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Tra
219
219
  public async handleBlockStreamEvent(event: L2BlockStreamEvent): Promise<void> {
220
220
  switch (event.type) {
221
221
  case 'chain-checkpointed':
222
- await this.handleCheckpointEvent(event.checkpoint);
222
+ await this.processCheckpointJump(event.checkpoint.number);
223
223
  break;
224
224
  case 'chain-pruned':
225
- await this.handlePruneEvent(event.checkpointed.checkpoint);
225
+ await this.handlePruneEvent(event.block);
226
226
  break;
227
227
  case 'chain-proven':
228
228
  this.publishingService?.onChainProven(BlockNumber(event.block.number));
229
229
  break;
230
+ // The proposed tip drives only the tips store's walk-back history (recorded below); the prover-node
231
+ // tracks checkpoints, not proposed blocks. `blocks-added` is never emitted in tips-only mode, and
232
+ // `chain-finalized` carries nothing the prover-node acts on.
233
+ case 'chain-proposed':
230
234
  case 'chain-finalized':
231
235
  case 'blocks-added':
232
236
  break;
237
+ default: {
238
+ const _: never = event;
239
+ break;
240
+ }
233
241
  }
234
242
  // Expiry is driven by the archiver's latest synced L2 slot
235
243
  await this.checkEpochExpiry();
@@ -239,34 +247,92 @@ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Tra
239
247
  await this.tipsStore.handleBlockStreamEvent(event);
240
248
  }
241
249
 
242
- /** Register a new checkpoint with the store and notify the session manager. */
243
- private async handleCheckpointEvent(publishedCheckpoint: PublishedCheckpoint) {
244
- const checkpoint = publishedCheckpoint.checkpoint;
245
- const slotNumber = checkpoint.header.slotNumber;
246
- const l1Constants = await this.getL1Constants();
247
- const epochNumber = getEpochAtSlot(slotNumber, l1Constants);
248
-
249
- if (await this.isEpochFullyProven(epochNumber, l1Constants)) {
250
- this.log.debug(`Skipping checkpoint ${checkpoint.number} for already-proven epoch ${epochNumber}`);
250
+ /**
251
+ * Walks every checkpoint between the local cursor and the newly-reported checkpointed tip, registering
252
+ * each one that belongs to an epoch that can still be proven. The block stream now delivers a single thin
253
+ * `chain-checkpointed` tip event per pass rather than one fat event per checkpoint, so this drives the
254
+ * catch-up itself: light metadata first (`getCheckpointsData`) to decide relevance per epoch, then a heavy
255
+ * `getCheckpoints` fetch only for checkpoints in provable epochs.
256
+ *
257
+ * The cursor advances one checkpoint at a time and only after that checkpoint's proving-side handling has
258
+ * fully succeeded, preserving the A-1041 at-least-once semantics: a mid-jump failure leaves the cursor
259
+ * behind so the next pass retries from the first checkpoint that did not complete.
260
+ */
261
+ private async processCheckpointJump(targetCheckpoint: CheckpointNumber): Promise<void> {
262
+ if (targetCheckpoint <= this.lastProcessedCheckpoint) {
251
263
  return;
252
264
  }
265
+ const l1Constants = await this.getL1Constants();
253
266
 
254
- if (await this.isEpochPastProofSubmissionWindow(epochNumber, l1Constants)) {
255
- this.log.debug(
256
- `Skipping checkpoint ${checkpoint.number} for epoch ${epochNumber} past its proof-submission window`,
257
- );
258
- return;
267
+ // Cap the catch-up at the `(proofSubmissionEpochs + 1) * epochDuration` most recent checkpoints.
268
+ // When the cursor is much further behind (e.g. resyncing after a long time offline), fetching the whole gap could
269
+ // load thousands of checkpoints we cannot act on: anything older than the last two epochs is already past
270
+ // its proof-submission window, so we skip it and jump the cursor forward to the start of the capped range.
271
+ const maxCheckpoints = (l1Constants.proofSubmissionEpochs + 1) * l1Constants.epochDuration;
272
+ let from = CheckpointNumber(this.lastProcessedCheckpoint + 1);
273
+ if (Number(targetCheckpoint - from) + 1 > maxCheckpoints) {
274
+ const cappedFrom = CheckpointNumber(targetCheckpoint - maxCheckpoints + 1);
275
+ this.log.warn(`Skipping unprovable checkpoints during catch-up; the prover node is far behind`, {
276
+ from,
277
+ cappedFrom,
278
+ targetCheckpoint,
279
+ maxCheckpoints,
280
+ });
281
+ // Advance the cursor past the skipped checkpoints so they are never retried.
282
+ this.lastProcessedCheckpoint = CheckpointNumber(cappedFrom - 1);
283
+ from = cappedFrom;
259
284
  }
285
+ const limit = Number(targetCheckpoint - from) + 1;
286
+ const metadatas = await this.l2BlockSource.getCheckpointsData({ from, limit });
287
+
288
+ // Per-epoch relevance is cached so a multi-checkpoint epoch resolves it once. Skipping is whole-epoch
289
+ // only: the SessionManager requires an epoch's checkpoints fully covered before it opens a session, so we
290
+ // never drop an individual checkpoint inside an epoch we will prove.
291
+ const epochSkippable = new Map<EpochNumber, boolean>();
292
+ for (const metadata of metadatas) {
293
+ const epochNumber = getEpochAtSlot(metadata.header.slotNumber, l1Constants);
294
+ let skippable = epochSkippable.get(epochNumber);
295
+ if (skippable === undefined) {
296
+ skippable =
297
+ (await this.isEpochFullyProven(epochNumber, l1Constants)) ||
298
+ (await this.isEpochPastProofSubmissionWindow(epochNumber, l1Constants));
299
+ epochSkippable.set(epochNumber, skippable);
300
+ }
301
+ if (skippable) {
302
+ this.log.debug(`Skipping checkpoint ${metadata.checkpointNumber} for unprovable epoch ${epochNumber}`);
303
+ } else {
304
+ await this.registerCheckpoint(metadata.checkpointNumber, epochNumber);
305
+ }
306
+ // Advance only after the checkpoint's handling succeeded (or it was legitimately skipped). registerCheckpoint
307
+ // throws on failure, which leaves the cursor here for the next pass to retry (A-1041).
308
+ this.lastProcessedCheckpoint = metadata.checkpointNumber;
309
+ }
310
+ }
260
311
 
312
+ /** Heavy-fetch a single checkpoint, register it with the store, and notify the session manager. */
313
+ private async registerCheckpoint(checkpointNumber: CheckpointNumber, epochNumber: EpochNumber): Promise<void> {
314
+ const published = await this.l2BlockSource.getCheckpoint({ number: checkpointNumber });
315
+ if (!published) {
316
+ throw new Error(`Checkpoint ${checkpointNumber} not found in block source during catch-up`);
317
+ }
318
+ const checkpoint = published.checkpoint;
261
319
  this.log.info(`New checkpoint ${checkpoint.number} for epoch ${epochNumber}`, {
262
320
  checkpointNumber: checkpoint.number,
263
321
  epochNumber,
264
- slotNumber,
322
+ slotNumber: checkpoint.header.slotNumber,
265
323
  });
266
324
 
267
- const registerData = await this.collectRegisterData(checkpoint, publishedCheckpoint.attestations);
325
+ const registerData = await this.collectRegisterData(checkpoint, published.attestations);
268
326
  await this.checkpointStore.addOrUpdate(checkpoint, registerData);
269
327
  await this.sessionManager?.onCheckpointAdded(epochNumber);
328
+
329
+ // Tips-only mode delivers no blocks, so record one witness per checkpointed block: a reorg into the checkpoint's
330
+ // range then prunes at the true divergence instead of the nearest sparse tip anchor.
331
+ await this.tipsStore.recordBlockHashes(
332
+ await Promise.all(
333
+ checkpoint.blocks.map(async block => ({ number: block.number, hash: (await block.header.hash()).toString() })),
334
+ ),
335
+ );
270
336
  }
271
337
 
272
338
  /**
@@ -295,10 +361,41 @@ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Tra
295
361
  };
296
362
  }
297
363
 
298
- /** Mark every prover above the prune threshold as pruned and notify the session manager. */
299
- private async handlePruneEvent(prunedCheckpoint: { number: CheckpointNumber; hash: string }) {
300
- this.log.warn(`Chain pruned to checkpoint ${prunedCheckpoint.number}`, { prunedCheckpoint });
301
- const affected = this.checkpointStore.markPrunedAfter(prunedCheckpoint.number);
364
+ /**
365
+ * Marks every prover orphaned by the prune as pruned, clamps the catch-up cursor below the prune target's
366
+ * checkpoint, and notifies the session manager. Keyed off the prune target block (the highest surviving block)
367
+ * rather than the source's checkpointed tip, which can sit above the target after a re-checkpoint and would leave
368
+ * orphaned provers canonical. Throws (rather than warning) if the cursor floor cannot be resolved, so the pass
369
+ * fails and the prune is retried next iteration.
370
+ */
371
+ private async handlePruneEvent(prunedToBlock: L2BlockId) {
372
+ this.log.warn(`Chain pruned to block ${prunedToBlock.number}`, { prunedToBlock });
373
+
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
376
+ // everything untouched and the next pass retries the whole handler (the tips cursor only advances on success).
377
+ let cursorFloor: CheckpointNumber;
378
+ if (prunedToBlock.number === 0) {
379
+ cursorFloor = CheckpointNumber.ZERO;
380
+ } else {
381
+ const targetData = await this.l2BlockSource.getBlockData({ number: prunedToBlock.number });
382
+ if (targetData === undefined) {
383
+ throw new Error(
384
+ `No block data found for prune target block ${prunedToBlock.number}; cannot clamp checkpoint cursor`,
385
+ );
386
+ }
387
+ // Clamp to `cpAtTarget - 1`: a mid-checkpoint target leaves that checkpoint partially orphaned and it must be
388
+ // reprocessed. Over-clamping merely re-registers a checkpoint (at-least-once by design — A-1041); under-clamping
389
+ // would permanently skip a rebuilt same-number checkpoint.
390
+ cursorFloor = CheckpointNumber(Math.max(0, Number(targetData.checkpointNumber) - 1));
391
+ }
392
+
393
+ const affected = this.checkpointStore.cancelAndRemoveAboveBlock(prunedToBlock.number);
394
+
395
+ if (this.lastProcessedCheckpoint > cursorFloor) {
396
+ this.lastProcessedCheckpoint = cursorFloor;
397
+ }
398
+
302
399
  if (affected.length === 0) {
303
400
  return;
304
401
  }
@@ -409,14 +506,24 @@ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Tra
409
506
  // Now that the store + manager exist, arm the live-state observable gauges.
410
507
  this.jobMetrics.observeState(this.checkpointStore, this.sessionManager);
411
508
 
412
- const { startingBlock, lastFullyProvenEpoch } = await this.computeStartupState();
509
+ const { lastFullyProvenEpoch } = await this.resolveLastFullyProvenEpoch();
413
510
  this.lastExpiredEpoch = lastFullyProvenEpoch;
414
- this.blockStream = new L2BlockStream(this.l2BlockSource, this.tipsStore, this, this.log, {
511
+ this.lastProcessedCheckpoint = await this.computeStartingCheckpoint(lastFullyProvenEpoch);
512
+ this.blockStream = new EventDrivenL2BlockStream(this.l2BlockSource, this.tipsStore, this, this.log, {
415
513
  pollIntervalMS: this.config.proverNodePollingIntervalMs,
416
- startingBlock,
514
+ tipsOnly: true,
417
515
  });
418
516
  this.blockStream.start();
419
517
 
518
+ // With thin once-per-pass tip events, the expiry sweep no longer fires once per checkpoint; drive it
519
+ // from a periodic tick so epochs still expire during idle/no-event periods.
520
+ this.expiryTicker = new RunningPromise(
521
+ () => this.checkEpochExpiry(),
522
+ this.log,
523
+ this.config.proverNodePollingIntervalMs,
524
+ );
525
+ this.expiryTicker.start();
526
+
420
527
  await this.rewardsMetrics.start();
421
528
  this.l1Metrics.start();
422
529
  this.log.info(`Started Prover Node with prover id ${this.prover.getProverId().toString()}`, this.config);
@@ -426,6 +533,7 @@ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Tra
426
533
  this.log.info('Stopping ProverNode');
427
534
  this.jobMetrics.stopObservingState();
428
535
  await this.blockStream?.stop();
536
+ await this.expiryTicker?.stop();
429
537
  if (this.sessionManager) {
430
538
  await this.sessionManager.stop();
431
539
  }
@@ -552,38 +660,40 @@ export class ProverNode implements L2BlockStreamEventHandler, ProverNodeApi, Tra
552
660
  }
553
661
 
554
662
  /**
555
- * Resolves the L2BlockStream's starting block and the last fully-proven epoch in one
556
- * pass. The starting block is the first block of the next unproven epoch (or the start
557
- * of the partially-proven epoch if the proven tip falls mid-epoch). The fully-proven
558
- * epoch is `provenEpoch` when the proven tip is the last block of its epoch, otherwise
559
- * `provenEpoch - 1`, or `undefined` if no block is proven yet.
663
+ * Resolves the last fully-proven epoch from L1 proven state, used to seed the catch-up cursor (via
664
+ * `computeStartingCheckpoint`) and `lastExpiredEpoch`. The fully-proven epoch is `provenEpoch` when the
665
+ * proven tip is the last block of its epoch, otherwise `provenEpoch - 1`, or `undefined` if no block is
666
+ * proven yet (so a restart reprocesses the partially-proven epoch rather than trusting a stale tip).
560
667
  */
561
- protected async computeStartupState(): Promise<{
562
- startingBlock: BlockNumber;
563
- lastFullyProvenEpoch: EpochNumber | undefined;
564
- }> {
668
+ protected async resolveLastFullyProvenEpoch(): Promise<{ lastFullyProvenEpoch: EpochNumber | undefined }> {
565
669
  const provenBlockNumber = await this.l2BlockSource.getBlockNumber({ tag: 'proven' });
566
670
  if (!provenBlockNumber || provenBlockNumber <= 0) {
567
- return { startingBlock: BlockNumber(1), lastFullyProvenEpoch: undefined };
671
+ return { lastFullyProvenEpoch: undefined };
568
672
  }
569
673
  const l1Constants = await this.getL1Constants();
570
674
  const provenHeader = (await this.l2BlockSource.getBlockData({ number: BlockNumber(provenBlockNumber) }))?.header;
571
675
  if (!provenHeader) {
572
- return { startingBlock: BlockNumber(provenBlockNumber + 1), lastFullyProvenEpoch: undefined };
676
+ return { lastFullyProvenEpoch: undefined };
573
677
  }
574
678
  const provenEpoch = getEpochAtSlot(provenHeader.getSlot(), l1Constants);
575
679
  if (await this.isProvenBlockLastOfItsEpoch(BlockNumber(provenBlockNumber), provenEpoch, l1Constants)) {
576
- return { startingBlock: BlockNumber(provenBlockNumber + 1), lastFullyProvenEpoch: provenEpoch };
680
+ return { lastFullyProvenEpoch: provenEpoch };
577
681
  }
578
- const epochCheckpoints = await this.l2BlockSource.getCheckpointsData({ epoch: provenEpoch });
579
- const firstBlockOfEpoch =
580
- epochCheckpoints.length > 0 ? epochCheckpoints[0].startBlock : BlockNumber(provenBlockNumber);
581
- this.log.info(
582
- `Starting L2BlockStream at block ${firstBlockOfEpoch} (start of partially-proven epoch ${provenEpoch})`,
583
- { provenBlockNumber, provenEpoch, firstBlockOfEpoch },
584
- );
585
682
  const lastFullyProvenEpoch = provenEpoch > 0 ? EpochNumber(provenEpoch - 1) : undefined;
586
- return { startingBlock: firstBlockOfEpoch, lastFullyProvenEpoch };
683
+ return { lastFullyProvenEpoch };
684
+ }
685
+
686
+ /**
687
+ * Resolves the catch-up cursor seed: the last checkpoint of the last fully-proven epoch, or 0 if none. Seeding
688
+ * from a checkpoint (rather than a checkpointed tip) guarantees a restart reprocesses every checkpoint of the
689
+ * partially-proven epoch, since the checkpointed tip can sit ahead of the last fully-proven checkpoint.
690
+ */
691
+ protected async computeStartingCheckpoint(lastFullyProvenEpoch: EpochNumber | undefined): Promise<CheckpointNumber> {
692
+ if (lastFullyProvenEpoch === undefined) {
693
+ return CheckpointNumber.ZERO;
694
+ }
695
+ const checkpoints = await this.l2BlockSource.getCheckpointsData({ epoch: lastFullyProvenEpoch });
696
+ return checkpoints.at(-1)?.checkpointNumber ?? CheckpointNumber.ZERO;
587
697
  }
588
698
 
589
699
  private async gatherPreviousBlockHeader(previousBlockNumber: number) {
@@ -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
  }
@@ -268,7 +268,7 @@ export class SessionManager {
268
268
  this.fullSessions.delete(key);
269
269
  continue;
270
270
  }
271
- const canonical = this.canonicalCheckpointsForSpec(session.getSpec());
271
+ const canonical = this.checkpointsForSpec(session.getSpec());
272
272
  if (!this.checkpointsMatch(session.getCheckpoints(), canonical)) {
273
273
  this.fireAndForgetCancel(session, 'canonical content changed');
274
274
  this.fullSessions.delete(key);
@@ -284,7 +284,7 @@ export class SessionManager {
284
284
  this.partialSessions.delete(key);
285
285
  continue;
286
286
  }
287
- const canonical = this.canonicalCheckpointsForSpec(session.getSpec());
287
+ const canonical = this.checkpointsForSpec(session.getSpec());
288
288
  if (!this.checkpointsMatch(session.getCheckpoints(), canonical)) {
289
289
  this.fireAndForgetCancel(session, 'canonical content changed');
290
290
  this.partialSessions.delete(key);
@@ -298,6 +298,8 @@ export class SessionManager {
298
298
  }
299
299
 
300
300
  private async openFullSessionIfReady(epoch: EpochNumber): Promise<void> {
301
+ // `recreateInvalidSessions` runs at the top of every reconcile and deletes terminal sessions
302
+ // before this is called, so a session present here is live and already covers the epoch.
301
303
  if (this.fullSessions.has(epoch)) {
302
304
  return;
303
305
  }
@@ -314,7 +316,7 @@ export class SessionManager {
314
316
  return;
315
317
  }
316
318
  const [fromSlot, toSlot] = getSlotRangeForEpoch(epoch, l1Constants);
317
- const canonical = this.deps.checkpointStore.listCanonicalInSlotRange(fromSlot, toSlot);
319
+ const canonical = this.deps.checkpointStore.listInSlotRange(fromSlot, toSlot);
318
320
  if (!this.archiverFullyCovered(archiverCps, canonical)) {
319
321
  this.log.debug(`Skipping full-session open for epoch ${epoch}: archiver checkpoints not all in store`, {
320
322
  archiverCount: archiverCps.length,
@@ -329,7 +331,7 @@ export class SessionManager {
329
331
  }
330
332
 
331
333
  private openPartialSession(spec: SessionSpec): void {
332
- const canonical = this.deps.checkpointStore.listCanonicalInSlotRange(spec.fromSlot, spec.toSlot);
334
+ const canonical = this.deps.checkpointStore.listInSlotRange(spec.fromSlot, spec.toSlot);
333
335
  if (canonical.length === 0) {
334
336
  return;
335
337
  }
@@ -403,6 +405,17 @@ export class SessionManager {
403
405
  const state = await session.start();
404
406
  this.log.info(`Session ${session.getId()} exited with state ${state}`);
405
407
  if (state === 'failed' && this.deps.onSessionFailed) {
408
+ // Best-effort suppression of the spurious post-mortem upload a prune produces: if the session's
409
+ // checkpoints no longer match the store's current set, the failure was caused by the content
410
+ // changing under it, not a genuine proving fault, so skip the upload. This is inherently racy —
411
+ // the store lags the world-state unwind, so a fault observed before the prune is reconciled here
412
+ // still uploads. The epoch is recovered regardless by recreating the session on re-add.
413
+ if (!this.checkpointsMatch(session.getCheckpoints(), this.checkpointsForSpec(session.getSpec()))) {
414
+ this.log.info(`Skipping failure upload for session ${session.getId()}: canonical content changed`, {
415
+ ...session.getSpec(),
416
+ });
417
+ return;
418
+ }
406
419
  try {
407
420
  await this.deps.onSessionFailed(session);
408
421
  } catch (err) {
@@ -447,6 +460,24 @@ export class SessionManager {
447
460
  return live >= max;
448
461
  }
449
462
 
463
+ /**
464
+ * Maps a reconcile trigger to the epochs whose full session should be (re)opened.
465
+ *
466
+ * This is where the "don't retry a genuinely-failed epoch, but do recover a pruned one" invariant
467
+ * lives — enforced by which triggers are gated by `lastTickEpoch`:
468
+ *
469
+ * - The periodic `tick` IS gated: once a tick has opened a session for an epoch, `lastTickEpoch`
470
+ * advances to it and later ticks skip it (`epoch <= lastTickEpoch`). So a failed attempt is never
471
+ * resubmitted on a loop by the tick.
472
+ * - `checkpoint` and `prune` are deliberately NOT gated. They only fire when the epoch's canonical
473
+ * content actually changes — a checkpoint arrives, or a reorg prunes/replaces one — which is
474
+ * exactly when re-attempting is correct.
475
+ *
476
+ * A genuine proving failure produces no content change, hence no checkpoint/prune event, so only
477
+ * the gated tick could reopen it — and it won't. A prune + re-add fires ungated events, so the
478
+ * epoch is reopened through this path (and `openFullSessionIfReady` rebuilds over the fresh
479
+ * provers). See the "onTick does not retry ... but recovers ... re-added" test.
480
+ */
450
481
  private async epochsForTrigger(trigger: ReconcileTrigger): Promise<EpochNumber[]> {
451
482
  switch (trigger.kind) {
452
483
  case 'checkpoint':
@@ -481,8 +512,8 @@ export class SessionManager {
481
512
  return getEpochAtSlot(header.getSlot(), await this.getL1Constants());
482
513
  }
483
514
 
484
- private canonicalCheckpointsForSpec(spec: SessionSpec): CheckpointProver[] {
485
- return this.deps.checkpointStore.listCanonicalInSlotRange(spec.fromSlot, spec.toSlot);
515
+ private checkpointsForSpec(spec: SessionSpec): CheckpointProver[] {
516
+ return this.deps.checkpointStore.listInSlotRange(spec.fromSlot, spec.toSlot);
486
517
  }
487
518
 
488
519
  private fireAndForgetCancel(session: EpochSession, reason: string): void {