@adonis-agora/durable 0.54.1 → 0.54.2

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 (57) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/assets/spa/assets/index-9kO8tWjg.js +16 -0
  3. package/dist/assets/spa/assets/index-9kO8tWjg.js.map +1 -0
  4. package/dist/assets/spa/index.html +1 -1
  5. package/dist/src/codec-state-store.d.ts +10 -1
  6. package/dist/src/codec-state-store.d.ts.map +1 -1
  7. package/dist/src/codec-state-store.js +32 -0
  8. package/dist/src/codec-state-store.js.map +1 -1
  9. package/dist/src/dashboard/index.d.ts +1 -1
  10. package/dist/src/dashboard/index.js +1 -1
  11. package/dist/src/engine.d.ts +57 -1
  12. package/dist/src/engine.d.ts.map +1 -1
  13. package/dist/src/engine.js +221 -45
  14. package/dist/src/engine.js.map +1 -1
  15. package/dist/src/index.d.ts +1 -1
  16. package/dist/src/index.js +1 -1
  17. package/dist/src/interfaces.d.ts +47 -0
  18. package/dist/src/interfaces.d.ts.map +1 -1
  19. package/dist/src/interfaces.js.map +1 -1
  20. package/dist/src/stores/lucid-mappers.d.ts +6 -1
  21. package/dist/src/stores/lucid-mappers.d.ts.map +1 -1
  22. package/dist/src/stores/lucid-mappers.js +41 -0
  23. package/dist/src/stores/lucid-mappers.js.map +1 -1
  24. package/dist/src/stores/lucid.d.ts +4 -1
  25. package/dist/src/stores/lucid.d.ts.map +1 -1
  26. package/dist/src/stores/lucid.js +148 -37
  27. package/dist/src/stores/lucid.js.map +1 -1
  28. package/dist/src/testing/in-memory-state-store.d.ts +4 -1
  29. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  30. package/dist/src/testing/in-memory-state-store.js +29 -0
  31. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  32. package/dist/src/testing-kit/run-dispatch-contract.d.ts.map +1 -1
  33. package/dist/src/testing-kit/run-dispatch-contract.js +12 -3
  34. package/dist/src/testing-kit/run-dispatch-contract.js.map +1 -1
  35. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  36. package/dist/src/testing-kit/state-store-conformance.js +189 -0
  37. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  38. package/dist/src/transports/adaptive-concurrency.d.ts +2 -0
  39. package/dist/src/transports/adaptive-concurrency.d.ts.map +1 -1
  40. package/dist/src/transports/adaptive-concurrency.js +39 -9
  41. package/dist/src/transports/adaptive-concurrency.js.map +1 -1
  42. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  43. package/dist/src/transports/bullmq/bullmq-transport.js +8 -4
  44. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  45. package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
  46. package/dist/src/transports/bullmq/deps.js +18 -1
  47. package/dist/src/transports/bullmq/deps.js.map +1 -1
  48. package/dist/src/transports/db.js +4 -4
  49. package/dist/src/transports/db.js.map +1 -1
  50. package/dist/src/transports/queue.d.ts.map +1 -1
  51. package/dist/src/transports/queue.js +8 -4
  52. package/dist/src/transports/queue.js.map +1 -1
  53. package/package.json +1 -1
  54. package/skills/durable-reliability/SKILL.md +2 -1
  55. package/skills/durable-transports-stores/SKILL.md +15 -2
  56. package/dist/assets/spa/assets/index-BEk3-95l.js +0 -16
  57. package/dist/assets/spa/assets/index-BEk3-95l.js.map +0 -1
@@ -407,6 +407,14 @@ export class WorkflowEngine {
407
407
  turnSettledFans = new Map();
408
408
  /** The kept wake reasons each executing turn on this instance serves (see {@link execute}). */
409
409
  turnServedWakes = new Map();
410
+ /**
411
+ * `<runId>:<seq>` of the steps (not of a step fan) whose result this instance settled and whose
412
+ * run it then resumed, with when, kept for a lease. That resume runs a turn after any this instance
413
+ * is executing, or asks the lease holder for one, so the re-check after an in-process turn
414
+ * ({@link recheckWaitSeqs}) has no wake to recover for such a step: resuming again would only
415
+ * replay the run once more.
416
+ */
417
+ settledHere = new Map();
410
418
  /** {@link sweepBufferedSignals}: the id the pass in progress resumes after, `undefined` between passes. */
411
419
  bufferedSweepCursor;
412
420
  bufferedSweepActive = false;
@@ -544,6 +552,7 @@ export class WorkflowEngine {
544
552
  this.latest.get(run.workflow))?.singleton,
545
553
  });
546
554
  this.pool.bind(async (result) => {
555
+ this.heartbeatWrites.delete(result.stepId);
547
556
  // In-memory path (a `timeoutMs` step awaiting on THIS instance): resolve its pending promise.
548
557
  const waiter = this.pending.get(result.stepId);
549
558
  if (waiter) {
@@ -1158,9 +1167,35 @@ export class WorkflowEngine {
1158
1167
  if (this.heartbeatPersists.size > 1024)
1159
1168
  this.heartbeatPersists.clear();
1160
1169
  this.heartbeatPersists.set(id, { at: now, hadProgress: beat.progress !== undefined });
1161
- void this.store
1162
- .recordStepHeartbeat(beat.runId, beat.seq, new Date(beat.at ?? now), beat.progress)
1163
- .catch(() => undefined);
1170
+ this.heartbeatWrites.set(id, {
1171
+ runId: beat.runId,
1172
+ seq: beat.seq,
1173
+ at: new Date(beat.at ?? now),
1174
+ progress: beat.progress,
1175
+ });
1176
+ if (!this.heartbeatWriter) {
1177
+ this.heartbeatWriter = this.writeHeartbeats().finally(() => {
1178
+ this.heartbeatWriter = undefined;
1179
+ });
1180
+ this.trackEffect(this.heartbeatWriter);
1181
+ }
1182
+ }
1183
+ /** Beats {@link persistHeartbeat} let through and {@link writeHeartbeats} has not written yet, by
1184
+ * stepId: a newer beat of the same step replaces the queued one, a step's result drops it. */
1185
+ heartbeatWrites = new Map();
1186
+ heartbeatWriter;
1187
+ /**
1188
+ * Write the queued beats one at a time, oldest first, until none is left. One write in flight keeps
1189
+ * a burst of beats (every step of a wide fan sends one when a worker picks it up) from taking the
1190
+ * store's connections away from the turns and results, and a step that finishes while its beat
1191
+ * waits never has it written: nothing reads a settled step's liveness.
1192
+ */
1193
+ async writeHeartbeats() {
1194
+ const record = this.store.recordStepHeartbeat?.bind(this.store);
1195
+ for (const [id, beat] of this.heartbeatWrites) {
1196
+ this.heartbeatWrites.delete(id);
1197
+ await record?.(beat.runId, beat.seq, beat.at, beat.progress).catch(() => undefined);
1198
+ }
1164
1199
  }
1165
1200
  /**
1166
1201
  * Declare this process a worker: flush consumer subscriptions parked by a transport whose
@@ -2127,13 +2162,19 @@ export class WorkflowEngine {
2127
2162
  * delivery's own resume no-oped against this turn's still-held lease (see the WorkflowSuspended
2128
2163
  * catch in runExecution). Deferred one macrotask so the caller's `finally` releases the lease
2129
2164
  * first; tracked so `drain()` waits for the re-driven turn's writes.
2165
+ *
2166
+ * With `settledHereResumes`, a step in {@link settledHere} needs no re-drive: the turn that
2167
+ * suspended on it read its snapshot before the settle, so the settle's resume queued behind that
2168
+ * turn or asked the lease holder for one. A remote workflow's decision is applied after the turn
2169
+ * that computed it ended, so the settle's resume may already have run, and it passes `false`.
2130
2170
  */
2131
- recheckWaitSeqs(runId, seqs) {
2171
+ recheckWaitSeqs(runId, seqs, settledHereResumes = false) {
2132
2172
  this.trackEffect((async () => {
2133
2173
  await new Promise((r) => setTimeout(r, 0));
2134
2174
  for (const seq of seqs) {
2135
- const cp = await this.store.getCheckpoint(runId, seq).catch(() => null);
2136
- if (cp && (cp.status === 'completed' || cp.status === 'failed')) {
2175
+ if (await this.checkpointSettled(runId, seq).catch(() => false)) {
2176
+ if (settledHereResumes && this.settledHere.has(`${runId}:${seq}`))
2177
+ return;
2137
2178
  await this.resumeImplicit(runId).catch(() => undefined);
2138
2179
  return;
2139
2180
  }
@@ -2159,6 +2200,14 @@ export class WorkflowEngine {
2159
2200
  })());
2160
2201
  }
2161
2202
  /** {@link StateStore.countCheckpoints} of `pending` steps, or a JS count on a store without it. */
2203
+ /** Whether the checkpoint at `seq` is settled: a `COUNT` when the store has one, else the row. */
2204
+ async checkpointSettled(runId, seq) {
2205
+ if (this.store.countCheckpoints) {
2206
+ return (await this.store.countCheckpoints(runId, seq, seq, ['completed', 'failed'])) > 0;
2207
+ }
2208
+ const cp = await this.store.getCheckpoint(runId, seq);
2209
+ return cp?.status === 'completed' || cp?.status === 'failed';
2210
+ }
2162
2211
  async countPendingSteps(runId, from, to) {
2163
2212
  if (this.store.countCheckpoints) {
2164
2213
  return this.store.countCheckpoints(runId, from, to, ['pending']);
@@ -4379,7 +4428,7 @@ export class WorkflowEngine {
4379
4428
  const routePlan = await this.planRoute(registered.convention ? conventionGroup(run.workflow, run.namespace) : remote.group, registered.requires);
4380
4429
  if (routePlan?.status === 'blocked')
4381
4430
  return this.parkBlocked(run, routePlan);
4382
- const checkpoints = await this.store.listCheckpoints(run.id);
4431
+ const checkpoints = await this.replayCheckpoints(run.id);
4383
4432
  const handoff = liveHandoff(run.id, checkpoints);
4384
4433
  if (handoff) {
4385
4434
  // A run that handed its failure to an onError handler only follows the handler from here on.
@@ -4943,6 +4992,42 @@ export class WorkflowEngine {
4943
4992
  /** Earliest clock time a step's lease is worth LOOKING at again (`stepId` → clock ms) — the read
4944
4993
  * throttle for {@link rearmStepLease}, the sibling of {@link heartbeatPersists}. */
4945
4994
  stepLeaseRearms = new Map();
4995
+ /**
4996
+ * {@link stepLeaseRearms} for the steps of a fan this instance sent, by run: one entry per sent
4997
+ * block of consecutive seqs instead of one per step, since they share the lease stamped at send.
4998
+ */
4999
+ fanLeaseRearms = new Map();
5000
+ noteFanLease(runId, seqs, lease) {
5001
+ if (this.remoteRedispatchMs == null || seqs.length === 0)
5002
+ return;
5003
+ let from = seqs[0];
5004
+ let to = from;
5005
+ for (const seq of seqs) {
5006
+ if (seq < from)
5007
+ from = seq;
5008
+ if (seq > to)
5009
+ to = seq;
5010
+ }
5011
+ // A gap would cover steps sent earlier, under an older lease.
5012
+ if (to - from + 1 !== seqs.length)
5013
+ return;
5014
+ if (this.fanLeaseRearms.size > 1024)
5015
+ this.fanLeaseRearms.clear();
5016
+ const blocks = this.fanLeaseRearms.get(runId) ?? [];
5017
+ blocks.push({ from, to, lookAgainAt: lease - Math.ceil(this.remoteRedispatchMs / 2) });
5018
+ this.fanLeaseRearms.set(runId, blocks);
5019
+ }
5020
+ fanLeaseLookAgainAt(runId, seq, now) {
5021
+ const blocks = this.fanLeaseRearms.get(runId);
5022
+ if (!blocks)
5023
+ return undefined;
5024
+ const live = blocks.filter((b) => now < b.lookAgainAt);
5025
+ if (live.length === 0)
5026
+ this.fanLeaseRearms.delete(runId);
5027
+ else if (live.length < blocks.length)
5028
+ this.fanLeaseRearms.set(runId, live);
5029
+ return live.find((b) => seq >= b.from && seq <= b.to)?.lookAgainAt;
5030
+ }
4946
5031
  /**
4947
5032
  * A step-scoped heartbeat for a durably-suspended remote step: push its LEASE forward so a worker
4948
5033
  * that is still working on it is never presumed dead and double-dispatched. The durable counterpart
@@ -4989,7 +5074,7 @@ export class WorkflowEngine {
4989
5074
  return;
4990
5075
  const id = stepId(runId, seq);
4991
5076
  const now = this.clock();
4992
- const lookAgainAt = this.stepLeaseRearms.get(id);
5077
+ const lookAgainAt = this.stepLeaseRearms.get(id) ?? this.fanLeaseLookAgainAt(runId, seq, now);
4993
5078
  if (lookAgainAt !== undefined && now < lookAgainAt)
4994
5079
  return;
4995
5080
  const cp = await this.store.getCheckpoint(runId, seq);
@@ -5397,7 +5482,7 @@ export class WorkflowEngine {
5397
5482
  // Read once at execution start: a checkpoint written AFTER this snapshot (the signal/timer/child
5398
5483
  // this resume wakes on, or one written later in this same execution) is absent from the map, and
5399
5484
  // the ctx falls back to the live store for any absent seq — so replay semantics are unchanged.
5400
- const snapshot = await this.store.listCheckpoints(run.id);
5485
+ const snapshot = await this.replayCheckpoints(run.id);
5401
5486
  const replay = new Map();
5402
5487
  for (const cp of snapshot)
5403
5488
  replay.set(cp.seq, cp);
@@ -5490,7 +5575,7 @@ export class WorkflowEngine {
5490
5575
  // all with reconcileMs: 0). Re-check the seqs this suspension waits on and re-drive if one
5491
5576
  // already settled.
5492
5577
  if (settled.status === 'suspended' && err.waitSeqs?.length) {
5493
- this.recheckWaitSeqs(run.id, err.waitSeqs);
5578
+ this.recheckWaitSeqs(run.id, err.waitSeqs, true);
5494
5579
  }
5495
5580
  if (settled.status === 'suspended' && err.stepFans?.length) {
5496
5581
  this.recheckStepFans(run.id, err.stepFans);
@@ -6311,7 +6396,11 @@ export class WorkflowEngine {
6311
6396
  ? unsent
6312
6397
  : unsent.slice(0, opts.concurrency);
6313
6398
  if (batch.length > 0) {
6314
- await this.sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace);
6399
+ const lease = await this.sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace);
6400
+ if (lease !== undefined) {
6401
+ const at = this.stepLeaseWakeAt(lease);
6402
+ wakeAt = Math.min(wakeAt ?? at, at);
6403
+ }
6315
6404
  }
6316
6405
  if (inFlight + batch.length > 0) {
6317
6406
  throw new WorkflowSuspended(wakeAt, undefined, undefined, [{ from: first, to: last }]);
@@ -6343,7 +6432,8 @@ export class WorkflowEngine {
6343
6432
  /**
6344
6433
  * Write the `pending` checkpoints of a step fan's new steps, then dispatch them: one
6345
6434
  * {@link StateStore.saveCheckpoints} call and one {@link TransportPool.dispatchMany} for the batch,
6346
- * where the store and transport have them.
6435
+ * where the store and transport have them. With the lost-dispatch self-heal on, each step is
6436
+ * written with its lease already stamped, and the lease is returned for the run to park on.
6347
6437
  */
6348
6438
  async sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace) {
6349
6439
  const token = stepGroup(step, runNamespace);
@@ -6355,6 +6445,7 @@ export class WorkflowEngine {
6355
6445
  for (const i of batch)
6356
6446
  validInputs.set(i, await input(i));
6357
6447
  const enqueuedAt = new Date();
6448
+ const lease = this.remoteRedispatchMs == null ? undefined : this.clock() + this.remoteRedispatchMs;
6358
6449
  const checkpoints = batch.map((i) => {
6359
6450
  const seq = positions[i];
6360
6451
  return {
@@ -6371,6 +6462,7 @@ export class WorkflowEngine {
6371
6462
  enqueuedAt,
6372
6463
  startedAt: enqueuedAt,
6373
6464
  finishedAt: enqueuedAt,
6465
+ ...(lease !== undefined ? { wakeAt: lease } : {}),
6374
6466
  };
6375
6467
  });
6376
6468
  if (this.store.saveCheckpoints) {
@@ -6398,6 +6490,10 @@ export class WorkflowEngine {
6398
6490
  for (const cp of checkpoints) {
6399
6491
  this.emit({ type: 'step.started', runId, seq: cp.seq, name: step.name, kind: 'remote' });
6400
6492
  }
6493
+ if (lease !== undefined) {
6494
+ this.noteFanLease(runId, checkpoints.map((cp) => cp.seq), lease);
6495
+ }
6496
+ return lease;
6401
6497
  }
6402
6498
  /**
6403
6499
  * Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
@@ -6418,6 +6514,13 @@ export class WorkflowEngine {
6418
6514
  attempt: task.attempt,
6419
6515
  }, task.transport);
6420
6516
  }
6517
+ /** A turn's read of its run's checkpoints: {@link StateStore.listCheckpointsForReplay}, or every
6518
+ * column through {@link StateStore.listCheckpoints} on a store without it. */
6519
+ replayCheckpoints(runId) {
6520
+ return this.store.listCheckpointsForReplay
6521
+ ? this.store.listCheckpointsForReplay(runId)
6522
+ : this.store.listCheckpoints(runId);
6523
+ }
6421
6524
  /**
6422
6525
  * Complete a durable remote step from its worker result and resume the run — runs on whichever
6423
6526
  * instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.
@@ -6500,8 +6603,28 @@ export class WorkflowEngine {
6500
6603
  return;
6501
6604
  if (await this.stepFanStillRunning(cp, result, queuedIn))
6502
6605
  return;
6606
+ if (!parseStepFanGroup(cp.parallelGroup))
6607
+ this.noteSettledHere(result.runId, [cp.seq]);
6503
6608
  this.resumeInBackground(result.runId, fanWakeOf(cp, result));
6504
6609
  }
6610
+ /**
6611
+ * Record `seqs` of `runId` in {@link settledHere}, dropping the entries older than a lease. A key is
6612
+ * re-inserted at the end, so the map stays in the order its keys were noted and pruning stops at
6613
+ * the first fresh entry.
6614
+ */
6615
+ noteSettledHere(runId, seqs) {
6616
+ const now = this.clock();
6617
+ for (const seq of seqs) {
6618
+ const key = `${runId}:${seq}`;
6619
+ this.settledHere.delete(key);
6620
+ this.settledHere.set(key, now);
6621
+ }
6622
+ for (const [key, at] of this.settledHere) {
6623
+ if (now - at <= this.leaseMs)
6624
+ break;
6625
+ this.settledHere.delete(key);
6626
+ }
6627
+ }
6505
6628
  resumeInBackground(runId, fan) {
6506
6629
  this.trackEffect(this.resumeImplicit(runId, fan).catch((err) => {
6507
6630
  console.warn(`[adonis-durable] background resume of run ${runId} failed (the run recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
@@ -6565,20 +6688,11 @@ export class WorkflowEngine {
6565
6688
  resumable.push({ cp: read.value, result });
6566
6689
  });
6567
6690
  const finishedAt = new Date();
6568
- const writes = pending.map(({ index, cp }) => {
6569
- const result = results[index];
6570
- return {
6571
- ...cp,
6572
- status: result.status,
6573
- output: result.status === 'completed' ? result.output : cp.output,
6574
- error: result.error,
6575
- events: result.events ?? cp.events,
6576
- startedAt: result.startedAt ? new Date(result.startedAt) : cp.startedAt,
6577
- finishedAt,
6578
- };
6579
- });
6580
6691
  // Claimed, not blindly written: a duplicate delivery may have settled a step first.
6581
- const claims = await this.savePendingCheckpoints(writes);
6692
+ const claims = await this.settlePendingCheckpoints(pending.map(({ index, cp }) => ({
6693
+ cp,
6694
+ settlement: settlementOf(cp, results[index], finishedAt),
6695
+ })));
6582
6696
  for (const [k, claim] of claims.entries()) {
6583
6697
  const { index, cp } = pending[k];
6584
6698
  const result = results[index];
@@ -6624,9 +6738,14 @@ export class WorkflowEngine {
6624
6738
  if (fan)
6625
6739
  highest.set(fan.firstSeq, Math.max(highest.get(fan.firstSeq) ?? cp.seq, cp.seq));
6626
6740
  }
6741
+ // The one resume below replays every step of the batch.
6742
+ const singles = resumable
6743
+ .filter(({ cp }) => !parseStepFanGroup(cp.parallelGroup))
6744
+ .map(({ cp }) => cp.seq);
6627
6745
  for (const { cp, result } of resumable) {
6628
6746
  const fan = parseStepFanGroup(cp.parallelGroup);
6629
6747
  if (!fan || result.status === 'failed') {
6748
+ this.noteSettledHere(runId, singles);
6630
6749
  this.resumeInBackground(runId);
6631
6750
  return outcomes;
6632
6751
  }
@@ -6639,6 +6758,7 @@ export class WorkflowEngine {
6639
6758
  barriers.set(fan.firstSeq, closed);
6640
6759
  }
6641
6760
  if (!(await closed)) {
6761
+ this.noteSettledHere(runId, singles);
6642
6762
  this.resumeInBackground(runId, {
6643
6763
  first: fan.firstSeq,
6644
6764
  seq: highest.get(fan.firstSeq) ?? cp.seq,
@@ -6648,6 +6768,29 @@ export class WorkflowEngine {
6648
6768
  }
6649
6769
  return outcomes;
6650
6770
  }
6771
+ /**
6772
+ * Settle pending steps, each only while its row is still `pending`; per entry, whether it wrote
6773
+ * or the error that kept it from writing. Through {@link StateStore.settleCheckpointsIfPending}
6774
+ * when the store has it: one call, or one per entry when that call rejects (it then wrote
6775
+ * nothing). Else each step's whole row is written through {@link savePendingCheckpoints}.
6776
+ */
6777
+ async settlePendingCheckpoints(entries) {
6778
+ const settle = this.store.settleCheckpointsIfPending?.bind(this.store);
6779
+ if (!settle) {
6780
+ return this.savePendingCheckpoints(entries.map(({ cp, settlement }) => ({ ...cp, ...settlement })));
6781
+ }
6782
+ const settlements = entries.map((e) => e.settlement);
6783
+ try {
6784
+ const wrote = await settle(settlements);
6785
+ return settlements.map((_, i) => wrote[i] === true);
6786
+ }
6787
+ catch (error) {
6788
+ if (settlements.length === 1)
6789
+ return [{ error }];
6790
+ }
6791
+ const settled = await Promise.allSettled(settlements.map((s) => settle([s])));
6792
+ return settled.map((r) => r.status === 'fulfilled' ? r.value[0] === true : { error: r.reason });
6793
+ }
6651
6794
  /**
6652
6795
  * {@link savePendingCheckpoint} for checkpoints at distinct seqs: one
6653
6796
  * {@link StateStore.saveCheckpointsIfPending} call when the store has it, else one write each.
@@ -6684,12 +6827,35 @@ export class WorkflowEngine {
6684
6827
  return true;
6685
6828
  return (await this.countPendingSteps(result.runId, fan.firstSeq, lastSeq)) > 0;
6686
6829
  }
6830
+ /**
6831
+ * {@link StateStore.getCheckpointsForSettle}, when the store can use what it leaves out: only
6832
+ * with {@link StateStore.settleCheckpointsIfPending}, since a settle without it (and the salvage
6833
+ * of a late success onto a failed run) writes back the whole row it read.
6834
+ */
6835
+ settleReader() {
6836
+ return this.store.settleCheckpointsIfPending
6837
+ ? this.store.getCheckpointsForSettle?.bind(this.store)
6838
+ : undefined;
6839
+ }
6687
6840
  /**
6688
6841
  * The checkpoints of one run at `seqs`, each as its own outcome: one
6689
6842
  * {@link StateStore.getCheckpoints} call when the store has it, else one read each. The batch
6690
6843
  * call rejecting falls back to one read each, so a failing read fails only its own entry.
6691
6844
  */
6692
6845
  async readCheckpoints(runId, seqs) {
6846
+ const forSettle = this.settleReader();
6847
+ if (forSettle) {
6848
+ if (seqs.length > 1) {
6849
+ try {
6850
+ const bySeq = new Map((await forSettle(runId, seqs)).map((cp) => [cp.seq, cp]));
6851
+ return seqs.map((seq) => ({ status: 'fulfilled', value: bySeq.get(seq) ?? null }));
6852
+ }
6853
+ catch {
6854
+ // Fall through: one by one.
6855
+ }
6856
+ }
6857
+ return Promise.allSettled(seqs.map(async (seq) => (await forSettle(runId, [seq]))[0] ?? null));
6858
+ }
6693
6859
  if (seqs.length > 1 && this.store.getCheckpoints) {
6694
6860
  try {
6695
6861
  const bySeq = new Map((await this.store.getCheckpoints(runId, seqs)).map((cp) => [cp.seq, cp]));
@@ -6708,7 +6874,10 @@ export class WorkflowEngine {
6708
6874
  * late result for a terminal run — terminal is terminal).
6709
6875
  */
6710
6876
  async settleRemoteCheckpoint(result) {
6711
- const cp = await this.store.getCheckpoint(result.runId, result.seq);
6877
+ const forSettle = this.settleReader();
6878
+ const cp = forSettle
6879
+ ? ((await forSettle(result.runId, [result.seq]))[0] ?? null)
6880
+ : await this.store.getCheckpoint(result.runId, result.seq);
6712
6881
  if (!cp)
6713
6882
  return undefined;
6714
6883
  const run = await this.store.getRun(result.runId);
@@ -6751,31 +6920,25 @@ export class WorkflowEngine {
6751
6920
  if (run && (run.status === 'failed' || run.status === 'dead')) {
6752
6921
  await this.releaseQueueSlot(cp);
6753
6922
  if (result.status === 'completed') {
6754
- await this.store.saveCheckpoint({
6755
- ...cp,
6756
- status: 'completed',
6757
- output: result.output,
6758
- events: result.events ?? cp.events,
6759
- startedAt: result.startedAt ? new Date(result.startedAt) : cp.startedAt,
6760
- finishedAt: new Date(),
6761
- });
6923
+ const salvage = { ...settlementOf(cp, result, new Date()), error: cp.error };
6924
+ if (this.store.settleCheckpointsIfPending) {
6925
+ await this.store.settleCheckpointsIfPending([salvage]);
6926
+ }
6927
+ else {
6928
+ await this.store.saveCheckpoint({ ...cp, ...salvage });
6929
+ }
6762
6930
  }
6763
6931
  return undefined;
6764
6932
  }
6765
6933
  const finishedAt = new Date();
6766
- const startedAt = result.startedAt ? new Date(result.startedAt) : cp.startedAt;
6934
+ const settlement = settlementOf(cp, result, finishedAt);
6935
+ const startedAt = settlement.startedAt;
6767
6936
  // Claimed, not blindly written: another delivery settled it first, which is the redelivery case
6768
6937
  // above — re-drive the resume rather than settle twice.
6769
- const claimed = await this.savePendingCheckpoint({
6770
- ...cp,
6771
- status: result.status,
6772
- output: result.status === 'completed' ? result.output : cp.output,
6773
- error: result.error,
6774
- events: result.events ?? cp.events,
6775
- startedAt,
6776
- finishedAt,
6777
- });
6778
- if (!claimed)
6938
+ const [claim] = await this.settlePendingCheckpoints([{ cp, settlement }]);
6939
+ if (typeof claim === 'object')
6940
+ throw claim.error;
6941
+ if (!claim)
6779
6942
  return terminal ? undefined : cp;
6780
6943
  await this.releaseQueueSlot(cp);
6781
6944
  this.emit({
@@ -7060,6 +7223,19 @@ export class WorkflowEngine {
7060
7223
  function conventionGroup(name, namespace) {
7061
7224
  return tenantGroup(sanitizeQueueToken(name), namespace);
7062
7225
  }
7226
+ /** What `result` settles onto its step's pending checkpoint `cp`. */
7227
+ function settlementOf(cp, result, finishedAt) {
7228
+ return {
7229
+ runId: cp.runId,
7230
+ seq: cp.seq,
7231
+ status: result.status,
7232
+ output: result.status === 'completed' ? result.output : cp.output,
7233
+ error: result.error,
7234
+ events: result.events ?? cp.events,
7235
+ startedAt: result.startedAt ? new Date(result.startedAt) : cp.startedAt,
7236
+ finishedAt,
7237
+ };
7238
+ }
7063
7239
  /** Key of an inline remote turn's liveness reset in `heartbeatResets` (step keys are `runId:seq`). */
7064
7240
  function turnBeatKey(runId) {
7065
7241
  return `turn:${runId}`;