@aztec/prover-client 0.0.1-commit.f504929 → 0.0.1-commit.f5a9928

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 (101) hide show
  1. package/dest/config.d.ts +1 -1
  2. package/dest/config.d.ts.map +1 -1
  3. package/dest/config.js +16 -2
  4. package/dest/light/lightweight_checkpoint_builder.d.ts +2 -1
  5. package/dest/light/lightweight_checkpoint_builder.d.ts.map +1 -1
  6. package/dest/light/lightweight_checkpoint_builder.js +22 -5
  7. package/dest/mocks/test_context.d.ts +11 -11
  8. package/dest/mocks/test_context.d.ts.map +1 -1
  9. package/dest/mocks/test_context.js +25 -28
  10. package/dest/orchestrator/block-building-helpers.d.ts +1 -1
  11. package/dest/orchestrator/checkpoint-proving-state.d.ts +20 -36
  12. package/dest/orchestrator/checkpoint-proving-state.d.ts.map +1 -1
  13. package/dest/orchestrator/checkpoint-proving-state.js +22 -120
  14. package/dest/orchestrator/checkpoint-sub-tree-orchestrator.d.ts +161 -0
  15. package/dest/orchestrator/checkpoint-sub-tree-orchestrator.d.ts.map +1 -0
  16. package/dest/orchestrator/{orchestrator.js → checkpoint-sub-tree-orchestrator.js} +267 -435
  17. package/dest/orchestrator/chonk-cache.d.ts +39 -0
  18. package/dest/orchestrator/chonk-cache.d.ts.map +1 -0
  19. package/dest/orchestrator/chonk-cache.js +80 -0
  20. package/dest/orchestrator/index.d.ts +4 -2
  21. package/dest/orchestrator/index.d.ts.map +1 -1
  22. package/dest/orchestrator/index.js +3 -1
  23. package/dest/orchestrator/proving-scheduler.d.ts +81 -0
  24. package/dest/orchestrator/proving-scheduler.d.ts.map +1 -0
  25. package/dest/orchestrator/proving-scheduler.js +120 -0
  26. package/dest/orchestrator/top-tree-orchestrator.d.ts +88 -0
  27. package/dest/orchestrator/top-tree-orchestrator.d.ts.map +1 -0
  28. package/dest/orchestrator/top-tree-orchestrator.js +232 -0
  29. package/dest/orchestrator/top-tree-proving-state.d.ts +61 -0
  30. package/dest/orchestrator/top-tree-proving-state.d.ts.map +1 -0
  31. package/dest/orchestrator/top-tree-proving-state.js +185 -0
  32. package/dest/orchestrator/tx-proving-state.d.ts +3 -3
  33. package/dest/orchestrator/tx-proving-state.d.ts.map +1 -1
  34. package/dest/prover-client/prover-client.d.ts +62 -5
  35. package/dest/prover-client/prover-client.d.ts.map +1 -1
  36. package/dest/prover-client/prover-client.js +54 -7
  37. package/dest/proving_broker/broker_prover_facade.d.ts +3 -3
  38. package/dest/proving_broker/broker_prover_facade.d.ts.map +1 -1
  39. package/dest/proving_broker/broker_prover_facade.js +26 -20
  40. package/dest/proving_broker/config.d.ts +9 -73
  41. package/dest/proving_broker/config.d.ts.map +1 -1
  42. package/dest/proving_broker/config.js +3 -3
  43. package/dest/proving_broker/index.d.ts +2 -1
  44. package/dest/proving_broker/index.d.ts.map +1 -1
  45. package/dest/proving_broker/index.js +1 -0
  46. package/dest/proving_broker/proving_broker.d.ts +3 -2
  47. package/dest/proving_broker/proving_broker.d.ts.map +1 -1
  48. package/dest/proving_broker/proving_broker.js +219 -76
  49. package/dest/proving_broker/proving_broker_database/memory.d.ts +5 -2
  50. package/dest/proving_broker/proving_broker_database/memory.d.ts.map +1 -1
  51. package/dest/proving_broker/proving_broker_database/memory.js +14 -1
  52. package/dest/proving_broker/proving_broker_database/persisted.d.ts +15 -2
  53. package/dest/proving_broker/proving_broker_database/persisted.d.ts.map +1 -1
  54. package/dest/proving_broker/proving_broker_database/persisted.js +57 -6
  55. package/dest/proving_broker/proving_broker_database.d.ts +27 -1
  56. package/dest/proving_broker/proving_broker_database.d.ts.map +1 -1
  57. package/dest/proving_broker/rpc.d.ts +3 -1
  58. package/dest/proving_broker/rpc.d.ts.map +1 -1
  59. package/dest/proving_broker/rpc.js +80 -24
  60. package/dest/test/epoch_settlement.d.ts +36 -0
  61. package/dest/test/epoch_settlement.d.ts.map +1 -0
  62. package/dest/test/epoch_settlement.js +52 -0
  63. package/dest/test/index.d.ts +2 -0
  64. package/dest/test/index.d.ts.map +1 -0
  65. package/dest/test/index.js +1 -0
  66. package/dest/test/mock_prover.d.ts +4 -4
  67. package/dest/test/mock_prover.js +2 -2
  68. package/package.json +19 -18
  69. package/src/config.ts +18 -2
  70. package/src/light/lightweight_checkpoint_builder.ts +27 -6
  71. package/src/mocks/test_context.ts +24 -41
  72. package/src/orchestrator/checkpoint-proving-state.ts +21 -162
  73. package/src/orchestrator/{orchestrator.ts → checkpoint-sub-tree-orchestrator.ts} +415 -646
  74. package/src/orchestrator/chonk-cache.ts +99 -0
  75. package/src/orchestrator/index.ts +8 -1
  76. package/src/orchestrator/proving-scheduler.ts +160 -0
  77. package/src/orchestrator/top-tree-orchestrator.ts +384 -0
  78. package/src/orchestrator/top-tree-proving-state.ts +219 -0
  79. package/src/orchestrator/tx-proving-state.ts +3 -3
  80. package/src/prover-client/prover-client.ts +129 -18
  81. package/src/proving_broker/broker_prover_facade.ts +29 -23
  82. package/src/proving_broker/config.ts +3 -2
  83. package/src/proving_broker/index.ts +1 -0
  84. package/src/proving_broker/proving_broker.ts +209 -78
  85. package/src/proving_broker/proving_broker_database/memory.ts +16 -2
  86. package/src/proving_broker/proving_broker_database/persisted.ts +71 -8
  87. package/src/proving_broker/proving_broker_database.ts +30 -0
  88. package/src/proving_broker/rpc.ts +36 -24
  89. package/src/test/epoch_settlement.ts +82 -0
  90. package/src/test/index.ts +1 -0
  91. package/src/test/mock_prover.ts +2 -2
  92. package/dest/orchestrator/epoch-proving-state.d.ts +0 -75
  93. package/dest/orchestrator/epoch-proving-state.d.ts.map +0 -1
  94. package/dest/orchestrator/epoch-proving-state.js +0 -269
  95. package/dest/orchestrator/orchestrator.d.ts +0 -128
  96. package/dest/orchestrator/orchestrator.d.ts.map +0 -1
  97. package/dest/prover-client/server-epoch-prover.d.ts +0 -32
  98. package/dest/prover-client/server-epoch-prover.d.ts.map +0 -1
  99. package/dest/prover-client/server-epoch-prover.js +0 -40
  100. package/src/orchestrator/epoch-proving-state.ts +0 -380
  101. package/src/prover-client/server-epoch-prover.ts +0 -69
@@ -33,6 +33,23 @@ type InProgressMetadata = {
33
33
 
34
34
  type EnqueuedProvingJob = Pick<ProvingJob, 'id' | 'epochNumber'>;
35
35
 
36
+ /** In-memory scheduling metadata for a job. The large `inputsUri` is deliberately excluded — it lives
37
+ * in the database and is read on demand when dispatching to an agent. */
38
+ type ProvingJobMetadata = Pick<ProvingJob, 'id' | 'type' | 'epochNumber'>;
39
+
40
+ /** Settled state kept in memory: the status plus the small payloads (rejected reason, aborted). The
41
+ * large fulfilled proof `value` is NOT kept here — it lives in the database (read on demand) and,
42
+ * transiently, in `pendingResults` while its database write is in flight. */
43
+ type SettledRecord = { status: 'fulfilled' } | { status: 'rejected'; reason: string } | { status: 'aborted' };
44
+
45
+ function toJobMetadata(job: ProvingJob): ProvingJobMetadata {
46
+ return { id: job.id, type: job.type, epochNumber: job.epochNumber };
47
+ }
48
+
49
+ function toSettledRecord(result: ProvingJobSettledResult): SettledRecord {
50
+ return result.status === 'fulfilled' ? { status: 'fulfilled' } : result;
51
+ }
52
+
36
53
  /**
37
54
  * A broker that manages proof requests and distributes them to workers based on their priority.
38
55
  * It takes a backend that is responsible for storing and retrieving proof requests and results.
@@ -69,11 +86,17 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
69
86
  [ProvingRequestType.PARITY_ROOT]: new PriorityMemoryQueue<EnqueuedProvingJob>(provingJobComparator),
70
87
  };
71
88
 
72
- // holds a copy of the database in memory in order to quickly fulfill requests
73
- // this is fine because this broker is the only one that can modify the database
74
- private jobsCache = new Map<ProvingJobId, ProvingJob>();
75
- // as above, but for results
76
- private resultsCache = new Map<ProvingJobId, ProvingJobSettledResult>();
89
+ // Scheduling metadata for every known job (id, type, epochNumber). The large `inputsUri` is NOT held
90
+ // here it lives in the database and is read on demand when a job is dispatched to an agent.
91
+ private jobsCache = new Map<ProvingJobId, ProvingJobMetadata>();
92
+ // Settled status for every settled job. The large fulfilled proof `value` is NOT held here — it lives
93
+ // in the database (read on demand in getProvingJobStatus) and, transiently, in `pendingResults` while
94
+ // its database write is in flight. Small payloads (rejected reason, aborted) are kept inline.
95
+ private resultsCache = new Map<ProvingJobId, SettledRecord>();
96
+ // Transient hold of a fulfilled result's full value, from the moment it settles until its database
97
+ // write commits, so status reads are read-your-writes without retaining every proof. Evicted once the
98
+ // value is durable in the database; kept only if that write failed (preserving the result in memory).
99
+ private pendingResults = new Map<ProvingJobId, ProvingJobSettledResult>();
77
100
 
78
101
  // tracks when each job was enqueued
79
102
  private enqueuedAt = new Map<ProvingJobId, Timer>();
@@ -87,8 +110,9 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
87
110
  // keep track of which proving job has been retried
88
111
  private retries = new Map<ProvingJobId, number>();
89
112
 
90
- // a map of promises that will be resolved when a job is settled
91
- private promises = new Map<ProvingJobId, PromiseWithResolvers<ProvingJobSettledResult>>();
113
+ // a map of promises that are resolved when a job settles. Carries no payload (settled status/results
114
+ // are read from resultsCache/pendingResults/DB) so it never pins a proof value in memory.
115
+ private promises = new Map<ProvingJobId, PromiseWithResolvers<void>>();
92
116
 
93
117
  private cleanupPromise: RunningPromise;
94
118
  private msTimeSource = () => Date.now();
@@ -175,12 +199,12 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
175
199
  status: result ? result.status : 'pending',
176
200
  });
177
201
 
178
- this.jobsCache.set(item.id, item);
202
+ this.jobsCache.set(item.id, toJobMetadata(item));
179
203
  this.promises.set(item.id, promiseWithResolvers());
180
204
 
181
205
  if (result) {
182
- this.promises.get(item.id)!.resolve(result);
183
- this.resultsCache.set(item.id, result);
206
+ this.promises.get(item.id)!.resolve();
207
+ this.resultsCache.set(item.id, toSettledRecord(result));
184
208
  } else {
185
209
  this.enqueueJobInternal(item);
186
210
  }
@@ -211,7 +235,7 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
211
235
  }
212
236
 
213
237
  public getProvingJobStatus(id: ProvingJobId): Promise<ProvingJobStatus> {
214
- return Promise.resolve(this.#getProvingJobStatus(id));
238
+ return this.#getProvingJobStatus(id);
215
239
  }
216
240
 
217
241
  public getCompletedJobs(ids: ProvingJobId[]): Promise<ProvingJobId[]> {
@@ -219,7 +243,7 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
219
243
  }
220
244
 
221
245
  public getProvingJob(filter?: ProvingJobFilter): Promise<GetProvingJobResponse | undefined> {
222
- return Promise.resolve(this.#getProvingJob(filter));
246
+ return this.#getProvingJob(filter);
223
247
  }
224
248
 
225
249
  public reportProvingJobSuccess(
@@ -244,7 +268,7 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
244
268
  startedAt: number,
245
269
  filter?: ProvingJobFilter,
246
270
  ): Promise<{ job: ProvingJob; time: number } | undefined> {
247
- return Promise.resolve(this.#reportProvingJobProgress(id, startedAt, filter));
271
+ return this.#reportProvingJobProgress(id, startedAt, filter);
248
272
  }
249
273
 
250
274
  public async replayProvingJob(
@@ -263,7 +287,7 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
263
287
  this.cleanUpProvingJobState([jobId]);
264
288
 
265
289
  const job: ProvingJob = { id: jobId, type, epochNumber, inputsUri };
266
- this.jobsCache.set(jobId, job);
290
+ this.jobsCache.set(jobId, toJobMetadata(job));
267
291
  await this.database.addProvingJob(job);
268
292
  this.enqueueJobInternal(job);
269
293
 
@@ -271,16 +295,52 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
271
295
  }
272
296
 
273
297
  async #enqueueProvingJob(job: ProvingJob): Promise<ProvingJobStatus> {
274
- // We return the job status at the start of this call
275
- const jobStatus = this.#getProvingJobStatus(job.id);
298
+ // The status returned to the caller reflects the job's state at the start of this call: `not-found`
299
+ // for a brand-new job (it did not exist yet), or the cached status for one already known. Crucially
300
+ // this must NOT `await` before the synchronous `jobsCache`/`resultsCache` gate below, or the
301
+ // enqueue/revive lock would no longer hold. So the not-found default is set synchronously here and
302
+ // the cached branch computes the real status (which may read the DB) only when it returns.
303
+ let jobStatus: ProvingJobStatus = { status: 'not-found' };
276
304
  if (this.jobsCache.has(job.id)) {
277
305
  const existing = this.jobsCache.get(job.id);
278
- assert.deepStrictEqual(job, existing, 'Duplicate proving job ID');
279
- this.logger.warn(`Cached proving job id=${job.id} epochNumber=${job.epochNumber}. Not enqueuing again`, {
280
- provingJobId: job.id,
281
- });
282
- this.instrumentation.incCachedJobs(job.type);
283
- return jobStatus;
306
+ // Identity check is metadata-only: `inputsUri` now lives in the database, not memory, and job ids
307
+ // are content-addressed (same id same inputs), so a mismatch shows up in id/type/epochNumber.
308
+ assert.deepStrictEqual(toJobMetadata(job), existing, 'Duplicate proving job ID');
309
+
310
+ if (this.resultsCache.get(job.id)?.status === 'aborted') {
311
+ // The producer is re-requesting a job it previously cancelled: revive it rather than
312
+ // returning the cached abort, clearing the aborted state in memory and in the database so the
313
+ // revival survives a restart.
314
+ //
315
+ // Concurrency model: `jobsCache` is the enqueue lock. Every path that puts a job on the queue
316
+ // populates `jobsCache` *synchronously, before its first await* (see the "New proving job"
317
+ // block below), so a second concurrent enqueue of the same id observes the entry at the top
318
+ // of this method and takes a cached, no-op branch instead of enqueuing a duplicate. The revive
319
+ // must keep holding that lock: we tear down the settled state and re-set `jobsCache` in a
320
+ // single synchronous span (no await in between), and only then await the database. Because a
321
+ // concurrent re-request can only interleave at that await — by which point `jobsCache` is
322
+ // populated again and the aborted result is gone — it falls into the cached branch and no-ops,
323
+ // so the job is enqueued exactly once. (`cleanUpProvingJobState` also drops the promise, so
324
+ // `enqueueJobInternal` below mints a fresh one for the retry.) This holds unchanged with the
325
+ // slimmer caches: `jobsCache` (now metadata) and `resultsCache` (now settled status) are still
326
+ // synchronous in-memory maps, so the tear-down + re-set span still contains no await.
327
+ this.logger.info(`Reviving aborted proving job id=${job.id} epochNumber=${job.epochNumber}`, {
328
+ provingJobId: job.id,
329
+ });
330
+ this.cleanUpProvingJobState([job.id]);
331
+ this.jobsCache.set(job.id, toJobMetadata(job));
332
+ await this.database.deleteProvingJobResult(job.id);
333
+ // The job is re-set in the cache and about to be re-enqueued below: its start status is in-queue.
334
+ jobStatus = { status: 'in-queue' };
335
+ } else {
336
+ this.logger.warn(`Cached proving job id=${job.id} epochNumber=${job.epochNumber}. Not enqueuing again`, {
337
+ provingJobId: job.id,
338
+ });
339
+ this.instrumentation.incCachedJobs(job.type);
340
+ // Return the job's current status. This reads the fulfilled proof value from the DB when needed;
341
+ // the await is fine here because this branch does not enqueue, so it is outside the lock span.
342
+ return await this.#getProvingJobStatus(job.id);
343
+ }
284
344
  }
285
345
 
286
346
  if (this.isJobStale(job)) {
@@ -293,7 +353,7 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
293
353
  this.logger.info(`New proving job id=${job.id} epochNumber=${job.epochNumber}`, { provingJobId: job.id });
294
354
  try {
295
355
  // do this first so it acts as a "lock". If this job is enqueued again while we're saving it the if at the top will catch it.
296
- this.jobsCache.set(job.id, job);
356
+ this.jobsCache.set(job.id, toJobMetadata(job));
297
357
  await this.database.addProvingJob(job);
298
358
  this.enqueueJobInternal(job);
299
359
  this.instrumentation.incTotalJobs(job.type);
@@ -306,43 +366,80 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
306
366
  }
307
367
 
308
368
  async #cancelProvingJob(id: ProvingJobId): Promise<void> {
309
- if (!this.jobsCache.has(id)) {
369
+ const job = this.jobsCache.get(id);
370
+ if (!job) {
310
371
  this.logger.warn(`Can't cancel a job that doesn't exist id=${id}`, { provingJobId: id });
311
372
  return;
312
373
  }
313
374
 
314
- // notify listeners of the cancellation
315
- if (!this.resultsCache.has(id)) {
316
- this.logger.info(`Cancelling job id=${id}`, { provingJobId: id });
317
- await this.#reportProvingJobError(id, 'Aborted', false, undefined, true);
375
+ // Leave jobs that have already settled (completed or failed) alone: those results are terminal.
376
+ if (this.resultsCache.has(id)) {
377
+ return;
378
+ }
379
+
380
+ this.logger.info(`Cancelling job id=${id}`, { provingJobId: id });
381
+ this.inProgress.delete(id);
382
+
383
+ // Record the cancellation as its own settled state and persist it, so it survives a restart and
384
+ // notifies the current waiter. Unlike a completion or failure this is not terminal: re-enqueuing
385
+ // the same job id revives it (see #enqueueProvingJob), so the abort never permanently blocks the
386
+ // proof.
387
+ this.resultsCache.set(id, { status: 'aborted' });
388
+ this.promises.get(id)?.resolve();
389
+ this.completedJobNotifications.push(id);
390
+ this.instrumentation.incAbortedJobs(job.type);
391
+
392
+ try {
393
+ await this.database.setProvingJobAborted(id);
394
+ } catch (saveErr) {
395
+ this.logger.error(`Failed to save proving job aborted status id=${id}`, saveErr, { provingJobId: id });
396
+ throw saveErr;
318
397
  }
319
398
  }
320
399
 
321
400
  private cleanUpProvingJobState(ids: ProvingJobId[]) {
401
+ const idsToClean = new Set(ids);
322
402
  for (const id of ids) {
323
403
  this.jobsCache.delete(id);
404
+ const deferred = this.promises.get(id);
405
+ if (deferred) {
406
+ deferred.resolve();
407
+ }
324
408
  this.promises.delete(id);
325
409
  this.resultsCache.delete(id);
410
+ this.pendingResults.delete(id);
326
411
  this.inProgress.delete(id);
327
412
  this.retries.delete(id);
328
413
  this.enqueuedAt.delete(id);
329
414
  }
415
+ this.completedJobNotifications = this.completedJobNotifications.filter(id => !idsToClean.has(id));
330
416
  }
331
417
 
332
- #getProvingJobStatus(id: ProvingJobId): ProvingJobStatus {
333
- const result = this.resultsCache.get(id);
334
- if (result) {
335
- return result;
336
- } else {
337
- // no result yet, check if we know the item
338
- const item = this.jobsCache.get(id);
339
-
340
- if (!item) {
341
- return { status: 'not-found' };
418
+ async #getProvingJobStatus(id: ProvingJobId): Promise<ProvingJobStatus> {
419
+ const settled = this.resultsCache.get(id);
420
+ if (settled) {
421
+ if (settled.status !== 'fulfilled') {
422
+ // rejected/aborted carry their (small) payload inline.
423
+ return settled;
424
+ }
425
+ // A fulfilled job's proof value is not held in memory: serve it from the transient hold if its
426
+ // database write is still in flight, otherwise read it back from the database.
427
+ const full = this.pendingResults.get(id) ?? (await this.database.getProvingJobResult(id));
428
+ if (full?.status === 'fulfilled') {
429
+ return full;
342
430
  }
431
+ // Settled as fulfilled but the value is nowhere to be found — should not happen (it is held in
432
+ // pendingResults until its DB write commits). Report not-found rather than a torn result.
433
+ this.logger.error(`Fulfilled proving job id=${id} has no retrievable result value`, { provingJobId: id });
434
+ return { status: 'not-found' };
435
+ }
343
436
 
344
- return { status: this.inProgress.has(id) ? 'in-progress' : 'in-queue' };
437
+ // no result yet, check if we know the item
438
+ const item = this.jobsCache.get(id);
439
+ if (!item) {
440
+ return { status: 'not-found' };
345
441
  }
442
+ return { status: this.inProgress.has(id) ? 'in-progress' : 'in-queue' };
346
443
  }
347
444
 
348
445
  #getCompletedJobs(ids: ProvingJobId[]): Promise<ProvingJobId[]> {
@@ -352,7 +449,9 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
352
449
  return Promise.resolve(notifications.concat(completedJobs));
353
450
  }
354
451
 
355
- #getProvingJob(filter: ProvingJobFilter = { allowList: [] }): { job: ProvingJob; time: number } | undefined {
452
+ async #getProvingJob(
453
+ filter: ProvingJobFilter = { allowList: [] },
454
+ ): Promise<{ job: ProvingJob; time: number } | undefined> {
356
455
  const allowedProofs: ProvingRequestType[] =
357
456
  Array.isArray(filter.allowList) && filter.allowList.length > 0
358
457
  ? [...filter.allowList]
@@ -367,21 +466,40 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
367
466
  // this can happen if the broker crashes and restarts
368
467
  // it's possible agents will report progress or results for jobs that are in the queue (after the restart)
369
468
  while ((enqueuedJob = queue.getImmediate())) {
370
- const job = this.jobsCache.get(enqueuedJob.id);
371
- if (job && !this.inProgress.has(enqueuedJob.id) && !this.resultsCache.has(enqueuedJob.id)) {
469
+ const meta = this.jobsCache.get(enqueuedJob.id);
470
+ if (meta && !this.inProgress.has(enqueuedJob.id) && !this.resultsCache.has(enqueuedJob.id)) {
372
471
  const time = this.msTimeSource();
373
- this.inProgress.set(job.id, {
374
- id: job.id,
472
+ // Claim the job synchronously (before the await below) so a concurrent dispatch can't re-pick
473
+ // it. The same id can sit in a queue more than once (an abort leaves a stale entry that a
474
+ // later revive re-enqueues alongside), so this in-progress claim — not the queue pop — is what
475
+ // dedups those copies across concurrently-polling agents. It must be set before the await.
476
+ this.inProgress.set(meta.id, {
477
+ id: meta.id,
375
478
  startedAt: time,
376
479
  lastUpdatedAt: time,
377
480
  });
378
- const enqueuedAt = this.enqueuedAt.get(job.id);
379
- if (enqueuedAt) {
380
- this.instrumentation.recordJobWait(job.type, enqueuedAt);
381
- // we can clear this flag now.
382
- this.enqueuedAt.delete(job.id);
481
+
482
+ // The large inputs are not kept in memory; read them from the database. They were durably
483
+ // persisted (addProvingJob's write commits) before the job became dispatchable, so this hits.
484
+ const inputsUri = await this.database.getProvingJobInputs(meta.id);
485
+ if (!inputsUri) {
486
+ // The job was cleaned up (or its inputs lost) after we claimed it — release the claim and
487
+ // keep draining the queue for another candidate. Nothing else was mutated yet, so there is
488
+ // no wait metric or enqueued-at timer to unwind.
489
+ this.inProgress.delete(meta.id);
490
+ this.logger.warn(`No inputs found for proving job id=${meta.id}; skipping dispatch`, {
491
+ provingJobId: meta.id,
492
+ });
493
+ continue;
383
494
  }
384
495
 
496
+ // The dispatch is now committed: record the queue wait and clear the enqueued-at timer.
497
+ const enqueuedAt = this.enqueuedAt.get(meta.id);
498
+ if (enqueuedAt) {
499
+ this.instrumentation.recordJobWait(meta.type, enqueuedAt);
500
+ this.enqueuedAt.delete(meta.id);
501
+ }
502
+ const job: ProvingJob = { id: meta.id, type: meta.type, epochNumber: meta.epochNumber, inputsUri };
385
503
  return { job, time };
386
504
  }
387
505
  }
@@ -395,7 +513,6 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
395
513
  err: string,
396
514
  retry = false,
397
515
  filter?: ProvingJobFilter,
398
- aborted = false,
399
516
  ): Promise<GetProvingJobResponse | undefined> {
400
517
  const info = this.inProgress.get(id);
401
518
  const item = this.jobsCache.get(id);
@@ -449,18 +566,13 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
449
566
  },
450
567
  );
451
568
 
452
- // save the result to the cache and notify clients of the job status
453
- // this should work even if our database breaks because the result is cached in memory
454
- const result: ProvingJobSettledResult = { status: 'rejected', reason: String(err) };
455
- this.resultsCache.set(id, result);
456
- this.promises.get(id)!.resolve(result);
569
+ // save the (small) rejection reason to the cache and notify clients of the job status. The reason
570
+ // is kept in memory, so status reads work even if the database write below fails.
571
+ this.resultsCache.set(id, { status: 'rejected', reason: String(err) });
572
+ this.promises.get(id)!.resolve();
457
573
  this.completedJobNotifications.push(id);
458
574
 
459
- if (aborted) {
460
- this.instrumentation.incAbortedJobs(item.type);
461
- } else {
462
- this.instrumentation.incRejectedJobs(item.type);
463
- }
575
+ this.instrumentation.incRejectedJobs(item.type);
464
576
  if (info) {
465
577
  const duration = this.msTimeSource() - info.startedAt;
466
578
  this.instrumentation.recordJobDuration(item.type, duration);
@@ -479,20 +591,20 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
479
591
  return this.#getProvingJob(filter);
480
592
  }
481
593
 
482
- #reportProvingJobProgress(
594
+ async #reportProvingJobProgress(
483
595
  id: ProvingJobId,
484
596
  startedAt: number,
485
597
  filter?: ProvingJobFilter,
486
- ): { job: ProvingJob; time: number } | undefined {
598
+ ): Promise<{ job: ProvingJob; time: number } | undefined> {
487
599
  const job = this.jobsCache.get(id);
488
600
  if (!job) {
489
601
  this.logger.warn(`Proving job id=${id} does not exist`, { provingJobId: id });
490
- return this.#getProvingJob(filter);
602
+ return await this.#getProvingJob(filter);
491
603
  }
492
604
 
493
605
  if (this.resultsCache.has(id)) {
494
606
  this.logger.warn(`Proving job id=${id} has already been completed`, { provingJobId: id });
495
- return this.#getProvingJob(filter);
607
+ return await this.#getProvingJob(filter);
496
608
  }
497
609
 
498
610
  const metadata = this.inProgress.get(id);
@@ -532,7 +644,7 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
532
644
  { provingJobId: id },
533
645
  );
534
646
 
535
- return this.#getProvingJob(filter);
647
+ return await this.#getProvingJob(filter);
536
648
  }
537
649
 
538
650
  async #reportProvingJobSuccess(
@@ -566,12 +678,12 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
566
678
  { provingJobId: id },
567
679
  );
568
680
 
569
- // save result to our local cache and notify clients
570
- // if save to database fails, that's ok because we have the result in memory
571
- // if the broker crashes and needs the result again, we're covered because we can just recompute it
572
- const result: ProvingJobSettledResult = { status: 'fulfilled', value };
573
- this.resultsCache.set(id, result);
574
- this.promises.get(id)!.resolve(result);
681
+ // Mark settled synchronously (the gate that makes concurrent settles no-op, unchanged from before),
682
+ // holding only the status in resultsCache. The large proof value is kept transiently in
683
+ // pendingResults so status reads are read-your-writes until the database write below commits.
684
+ this.resultsCache.set(id, { status: 'fulfilled' });
685
+ this.pendingResults.set(id, { status: 'fulfilled', value });
686
+ this.promises.get(id)!.resolve();
575
687
  this.completedJobNotifications.push(id);
576
688
 
577
689
  this.instrumentation.incResolvedJobs(item.type);
@@ -582,7 +694,11 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
582
694
 
583
695
  try {
584
696
  await this.database.setProvingJobResult(id, value);
697
+ // The value is durable in the database now; drop the transient in-memory copy.
698
+ this.pendingResults.delete(id);
585
699
  } catch (saveErr) {
700
+ // Keep the value in pendingResults so status/result reads still succeed despite the failed write —
701
+ // this preserves the previous "works even if the database breaks" behaviour for this edge.
586
702
  this.logger.error(`Failed to save proving job result id=${id}`, saveErr, {
587
703
  provingJobId: id,
588
704
  });
@@ -594,21 +710,21 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
594
710
  }
595
711
 
596
712
  private async cleanupPass() {
597
- this.cleanupStaleJobs();
598
713
  this.reEnqueueExpiredJobs();
599
714
  const oldestEpochToKeep = this.oldestEpochToKeep();
600
715
  if (oldestEpochToKeep > 0) {
716
+ this.cleanupJobsOlderThanEpoch(EpochNumber(oldestEpochToKeep));
601
717
  await this.database.deleteAllProvingJobsOlderThanEpoch(EpochNumber(oldestEpochToKeep));
602
718
  this.logger.trace(`Deleted all epochs older than ${oldestEpochToKeep}`);
603
719
  }
604
720
  }
605
721
 
606
- private cleanupStaleJobs() {
722
+ private cleanupJobsOlderThanEpoch(epochNumber: EpochNumber) {
607
723
  const jobIds = Array.from(this.jobsCache.keys());
608
724
  const jobsToClean: ProvingJobId[] = [];
609
725
  for (const id of jobIds) {
610
726
  const job = this.jobsCache.get(id)!;
611
- if (this.isJobStale(job)) {
727
+ if (job.epochNumber < epochNumber) {
612
728
  jobsToClean.push(id);
613
729
  }
614
730
  }
@@ -632,15 +748,30 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
632
748
  const now = this.msTimeSource();
633
749
  const msSinceLastUpdate = now - metadata.lastUpdatedAt;
634
750
  if (msSinceLastUpdate >= this.jobTimeoutMs) {
635
- this.logger.warn(`Proving job id=${id} timed out. Adding it back to the queue.`, { provingJobId: id });
636
751
  this.inProgress.delete(id);
637
- this.enqueueJobInternal(item);
638
752
  this.instrumentation.incTimedOutJobs(item.type);
753
+
754
+ const retries = this.retries.get(id) ?? 0;
755
+ if (retries + 1 < this.maxRetries && !this.isJobStale(item)) {
756
+ this.logger.warn(`Proving job id=${id} timed out. Re-enqueueing (retry ${retries + 1}/${this.maxRetries}).`, {
757
+ provingJobId: id,
758
+ });
759
+ this.retries.set(id, retries + 1);
760
+ this.enqueueJobInternal(item);
761
+ } else {
762
+ this.logger.error(`Proving job id=${id} timed out after ${retries + 1} attempts. Marking as failed.`, {
763
+ provingJobId: id,
764
+ });
765
+ this.resultsCache.set(id, { status: 'rejected', reason: 'Timed out' });
766
+ this.promises.get(id)?.resolve();
767
+ this.completedJobNotifications.push(id);
768
+ this.instrumentation.incRejectedJobs(item.type);
769
+ }
639
770
  }
640
771
  }
641
772
  }
642
773
 
643
- private enqueueJobInternal(job: ProvingJob): void {
774
+ private enqueueJobInternal(job: ProvingJobMetadata): void {
644
775
  if (!this.promises.has(job.id)) {
645
776
  this.promises.set(job.id, promiseWithResolvers());
646
777
  }
@@ -652,7 +783,7 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr
652
783
  this.epochHeight = Math.max(this.epochHeight, job.epochNumber);
653
784
  }
654
785
 
655
- private isJobStale(job: ProvingJob) {
786
+ private isJobStale(job: ProvingJobMetadata) {
656
787
  return job.epochNumber < this.oldestEpochToKeep();
657
788
  }
658
789
 
@@ -17,8 +17,12 @@ export class InMemoryBrokerDatabase implements ProvingBrokerDatabase {
17
17
  return this.jobs.get(id);
18
18
  }
19
19
 
20
- getProvingJobResult(id: ProvingJobId): ProvingJobSettledResult | undefined {
21
- return this.results.get(id);
20
+ getProvingJobInputs(id: ProvingJobId): Promise<ProofUri | undefined> {
21
+ return Promise.resolve(this.jobs.get(id)?.inputsUri);
22
+ }
23
+
24
+ getProvingJobResult(id: ProvingJobId): Promise<ProvingJobSettledResult | undefined> {
25
+ return Promise.resolve(this.results.get(id));
22
26
  }
23
27
 
24
28
  addProvingJob(job: ProvingJob): Promise<void> {
@@ -36,6 +40,16 @@ export class InMemoryBrokerDatabase implements ProvingBrokerDatabase {
36
40
  return Promise.resolve();
37
41
  }
38
42
 
43
+ setProvingJobAborted(id: ProvingJobId): Promise<void> {
44
+ this.results.set(id, { status: 'aborted' });
45
+ return Promise.resolve();
46
+ }
47
+
48
+ deleteProvingJobResult(id: ProvingJobId): Promise<void> {
49
+ this.results.delete(id);
50
+ return Promise.resolve();
51
+ }
52
+
39
53
  deleteProvingJobs(ids: ProvingJobId[]): Promise<void> {
40
54
  for (const id of ids) {
41
55
  this.jobs.delete(id);