@adonis-agora/durable 0.59.0 → 1.0.0-rc.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 (136) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/assets/spa/assets/index-70hGzOnT.css +1 -0
  3. package/dist/assets/spa/assets/index-CJjMAUau.js +16 -0
  4. package/dist/assets/spa/assets/index-CJjMAUau.js.map +1 -0
  5. package/dist/assets/spa/index.html +2 -2
  6. package/dist/providers/dashboard_provider.d.ts +1 -1
  7. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  8. package/dist/providers/dashboard_provider.js +4 -2
  9. package/dist/providers/dashboard_provider.js.map +1 -1
  10. package/dist/providers/durable_provider.d.ts.map +1 -1
  11. package/dist/providers/durable_provider.js +3 -0
  12. package/dist/providers/durable_provider.js.map +1 -1
  13. package/dist/src/base-workflow.d.ts +12 -1
  14. package/dist/src/base-workflow.d.ts.map +1 -1
  15. package/dist/src/base-workflow.js +13 -2
  16. package/dist/src/base-workflow.js.map +1 -1
  17. package/dist/src/codec-state-store.d.ts +10 -1
  18. package/dist/src/codec-state-store.d.ts.map +1 -1
  19. package/dist/src/codec-state-store.js +25 -0
  20. package/dist/src/codec-state-store.js.map +1 -1
  21. package/dist/src/control-plane-redis/redis-control-plane.d.ts +70 -7
  22. package/dist/src/control-plane-redis/redis-control-plane.d.ts.map +1 -1
  23. package/dist/src/control-plane-redis/redis-control-plane.js +108 -7
  24. package/dist/src/control-plane-redis/redis-control-plane.js.map +1 -1
  25. package/dist/src/control-planes/factory.d.ts +15 -3
  26. package/dist/src/control-planes/factory.d.ts.map +1 -1
  27. package/dist/src/control-planes/factory.js +3 -1
  28. package/dist/src/control-planes/factory.js.map +1 -1
  29. package/dist/src/dashboard/gateway-adapter.d.ts +1 -1
  30. package/dist/src/dashboard/gateway-adapter.js +1 -1
  31. package/dist/src/dashboard/handlers.d.ts +1 -1
  32. package/dist/src/dashboard/handlers.js +1 -1
  33. package/dist/src/dashboard/index.d.ts +1 -1
  34. package/dist/src/dashboard/index.d.ts.map +1 -1
  35. package/dist/src/dashboard/index.js +1 -1
  36. package/dist/src/dashboard/index.js.map +1 -1
  37. package/dist/src/dashboard/openapi.js +1 -1
  38. package/dist/src/dashboard/openapi.js.map +1 -1
  39. package/dist/src/define_config.d.ts +14 -1
  40. package/dist/src/define_config.d.ts.map +1 -1
  41. package/dist/src/define_config.js.map +1 -1
  42. package/dist/src/engine.d.ts +81 -1
  43. package/dist/src/engine.d.ts.map +1 -1
  44. package/dist/src/engine.js +532 -28
  45. package/dist/src/engine.js.map +1 -1
  46. package/dist/src/errors.d.ts.map +1 -1
  47. package/dist/src/errors.js +4 -0
  48. package/dist/src/errors.js.map +1 -1
  49. package/dist/src/index.d.ts +2 -2
  50. package/dist/src/index.d.ts.map +1 -1
  51. package/dist/src/index.js +1 -1
  52. package/dist/src/index.js.map +1 -1
  53. package/dist/src/interfaces.d.ts +122 -5
  54. package/dist/src/interfaces.d.ts.map +1 -1
  55. package/dist/src/interfaces.js +11 -0
  56. package/dist/src/interfaces.js.map +1 -1
  57. package/dist/src/run-gateway/proxy-run-gateway.d.ts.map +1 -1
  58. package/dist/src/run-gateway/proxy-run-gateway.js +7 -14
  59. package/dist/src/run-gateway/proxy-run-gateway.js.map +1 -1
  60. package/dist/src/run-gateway/run-request-responder.d.ts +1 -2
  61. package/dist/src/run-gateway/run-request-responder.d.ts.map +1 -1
  62. package/dist/src/run-gateway/run-request-responder.js +1 -2
  63. package/dist/src/run-gateway/run-request-responder.js.map +1 -1
  64. package/dist/src/scheduler.d.ts +0 -5
  65. package/dist/src/scheduler.d.ts.map +1 -1
  66. package/dist/src/scheduler.js.map +1 -1
  67. package/dist/src/sticky-continuation.d.ts +141 -0
  68. package/dist/src/sticky-continuation.d.ts.map +1 -0
  69. package/dist/src/sticky-continuation.js +231 -0
  70. package/dist/src/sticky-continuation.js.map +1 -0
  71. package/dist/src/stores/factory.d.ts +6 -0
  72. package/dist/src/stores/factory.d.ts.map +1 -1
  73. package/dist/src/stores/factory.js +3 -0
  74. package/dist/src/stores/factory.js.map +1 -1
  75. package/dist/src/stores/lucid-pg-hot-path.d.ts +30 -0
  76. package/dist/src/stores/lucid-pg-hot-path.d.ts.map +1 -0
  77. package/dist/src/stores/lucid-pg-hot-path.js +67 -0
  78. package/dist/src/stores/lucid-pg-hot-path.js.map +1 -0
  79. package/dist/src/stores/lucid.d.ts +30 -1
  80. package/dist/src/stores/lucid.d.ts.map +1 -1
  81. package/dist/src/stores/lucid.js +456 -17
  82. package/dist/src/stores/lucid.js.map +1 -1
  83. package/dist/src/testing/in-memory-state-store.d.ts +12 -1
  84. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  85. package/dist/src/testing/in-memory-state-store.js +101 -1
  86. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  87. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  88. package/dist/src/testing-kit/state-store-conformance.js +199 -0
  89. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  90. package/dist/src/transport-pool.d.ts +10 -0
  91. package/dist/src/transport-pool.d.ts.map +1 -1
  92. package/dist/src/transport-pool.js +23 -2
  93. package/dist/src/transport-pool.js.map +1 -1
  94. package/dist/src/transports/bullmq/bullmq-transport.d.ts +41 -1
  95. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  96. package/dist/src/transports/bullmq/bullmq-transport.js +283 -5
  97. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  98. package/dist/src/transports/bullmq/deps.d.ts +12 -1
  99. package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
  100. package/dist/src/transports/bullmq/deps.js.map +1 -1
  101. package/dist/src/transports/bullmq/naming.d.ts +13 -3
  102. package/dist/src/transports/bullmq/naming.d.ts.map +1 -1
  103. package/dist/src/transports/bullmq/naming.js +19 -3
  104. package/dist/src/transports/bullmq/naming.js.map +1 -1
  105. package/dist/src/transports/db.d.ts +3 -8
  106. package/dist/src/transports/db.d.ts.map +1 -1
  107. package/dist/src/transports/db.js +4 -4
  108. package/dist/src/transports/db.js.map +1 -1
  109. package/dist/src/transports/event-emitter.d.ts +0 -5
  110. package/dist/src/transports/event-emitter.d.ts.map +1 -1
  111. package/dist/src/transports/event-emitter.js.map +1 -1
  112. package/dist/src/transports/factory.d.ts +0 -15
  113. package/dist/src/transports/factory.d.ts.map +1 -1
  114. package/dist/src/transports/factory.js +0 -3
  115. package/dist/src/transports/factory.js.map +1 -1
  116. package/dist/src/transports/queue.d.ts +3 -8
  117. package/dist/src/transports/queue.d.ts.map +1 -1
  118. package/dist/src/transports/queue.js +2 -2
  119. package/dist/src/transports/queue.js.map +1 -1
  120. package/dist/src/transports/worker-keys.d.ts +2 -1
  121. package/dist/src/transports/worker-keys.d.ts.map +1 -1
  122. package/dist/src/transports/worker-keys.js +3 -2
  123. package/dist/src/transports/worker-keys.js.map +1 -1
  124. package/dist/src/workflow-ctx.d.ts +7 -0
  125. package/dist/src/workflow-ctx.d.ts.map +1 -1
  126. package/dist/src/workflow-ctx.js +28 -1
  127. package/dist/src/workflow-ctx.js.map +1 -1
  128. package/dist/stubs/config/durable.stub +1 -1
  129. package/package.json +7 -7
  130. package/skills/durable-cluster/SKILL.md +4 -3
  131. package/skills/durable-setup/SKILL.md +6 -3
  132. package/skills/durable-transports-stores/SKILL.md +3 -22
  133. package/skills/durable-workflows/SKILL.md +7 -4
  134. package/dist/assets/spa/assets/index-Co0ef3-q.css +0 -1
  135. package/dist/assets/spa/assets/index-DmgfShPp.js +0 -16
  136. package/dist/assets/spa/assets/index-DmgfShPp.js.map +0 -1
@@ -35,6 +35,7 @@ import { ScheduleClient } from './schedules.js';
35
35
  import { SINGLETON_RESERVED_TAGS, withoutAdmission, withSingletonTracked, } from './singleton-admission.js';
36
36
  import { SingletonGate } from './singleton-gate.js';
37
37
  import { validateStandard, validateStandardSync, } from './standard-schema.js';
38
+ import { StickyCache, StickyContinuation } from './sticky-continuation.js';
38
39
  import { sanitizeQueueToken, tenantGroup } from './tenant-group.js';
39
40
  import { TransportPool } from './transport-pool.js';
40
41
  import { workflowAls } from './workflow-als.js';
@@ -49,6 +50,10 @@ function mergeTags(staticTags, runTags) {
49
50
  /** A breakpoint checkpoint's `name` is `breakpoint` (or `breakpoint:<label>`). This name — not the
50
51
  * reused `signal` kind — is the explicit marker the dashboard and `continue()` detect it by. */
51
52
  const BREAKPOINT = 'breakpoint';
53
+ /** Default size of the sticky continuation cache (see {@link WorkflowEngineDeps.stickyRuns}). */
54
+ const DEFAULT_STICKY_RUNS = 2000;
55
+ /** Default idle limit of a parked body in the sticky cache. */
56
+ const DEFAULT_STICKY_IDLE_MS = 300_000;
52
57
  const isBreakpoint = (cp) => cp.status === 'pending' && cp.name.startsWith(BREAKPOINT);
53
58
  /**
54
59
  * Every `RunStatus` from which a turn's outcome (`completed` / `failed` / `suspended` / `blocked`) is a
@@ -107,6 +112,11 @@ function wakeReasonSeen(payload, settledFans) {
107
112
  typeof pair[1] === 'number' &&
108
113
  (settledFans.get(pair[0]) ?? -1) >= pair[1]);
109
114
  }
115
+ /**
116
+ * The control-plane target of a message about a run in `namespace`: the run's namespace, so the
117
+ * instances serving it hear it wherever the publisher is. Unknown → the publisher's own channel.
118
+ */
119
+ const runTarget = (namespace) => namespace === undefined ? undefined : { namespace };
110
120
  /** Whether `status` is one a run never leaves on its own. */
111
121
  const isTerminalRun = (status) => TERMINAL_RUN_STATUSES.includes(status);
112
122
  /**
@@ -162,6 +172,8 @@ const leaseRefused = new WeakMap();
162
172
  * parent's next turn.
163
173
  */
164
174
  const LOCAL_RUN_CONCURRENCY = 256;
175
+ /** Pending runs one {@link WorkflowEngine.runPending} poll takes at most. */
176
+ const PENDING_POLL_BATCH = 100;
165
177
  /** How many dispatched runs this instance takes from the transport's run queue at once. */
166
178
  const RUN_CONSUMER_CONCURRENCY = 64;
167
179
  /** The most runs handed to {@link Transport.dispatchRuns} in one call. */
@@ -285,6 +297,16 @@ export class WorkflowEngine {
285
297
  maxRecoveryAttempts;
286
298
  unregisteredWorkflowGraceMs;
287
299
  reconcileMs;
300
+ /** Parked workflow bodies kept between turns (see {@link WorkflowEngineDeps.stickyRuns}). */
301
+ sticky;
302
+ /** Pending runs claimed by {@link runPending} for the turn about to run them, as read by the claim. */
303
+ claimedRuns = new Map();
304
+ /** The continuation each turn executing here runs in, by run id, while the turn runs. */
305
+ turnContinuations = new Map();
306
+ /** Whether a transport routes this instance's step results back to it (see `routeReplies`). */
307
+ repliesRouted = false;
308
+ /** The run row stamp (`updatedAt`, epoch ms) a turn here wrote when it settled `suspended`. */
309
+ suspendStamps = new Map();
288
310
  heartbeatPersistThrottleMs;
289
311
  timerBatchSize;
290
312
  eventBufferTtlMs;
@@ -530,6 +552,8 @@ export class WorkflowEngine {
530
552
  // which is what it must stay on to reach every tenant's queues.
531
553
  if (this.namespace !== undefined)
532
554
  this.pool.useNamespace(this.namespace);
555
+ if (this.namespace !== undefined)
556
+ this.controlPlane?.useNamespace?.(this.namespace);
533
557
  this.leaseMs = deps.leaseMs ?? 30_000;
534
558
  this.bootedAt = this.clock();
535
559
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
@@ -544,6 +568,9 @@ export class WorkflowEngine {
544
568
  reconcile === undefined || !Number.isFinite(reconcile) || reconcile < 0
545
569
  ? 300_000
546
570
  : reconcile || undefined;
571
+ const stickyRuns = deps.stickyRuns ?? DEFAULT_STICKY_RUNS;
572
+ const stickyIdle = deps.stickyIdleMs ?? DEFAULT_STICKY_IDLE_MS;
573
+ this.sticky = new StickyCache(Number.isFinite(stickyRuns) ? Math.max(0, Math.floor(stickyRuns)) : DEFAULT_STICKY_RUNS, Number.isFinite(stickyIdle) && stickyIdle >= 0 ? stickyIdle : DEFAULT_STICKY_IDLE_MS);
547
574
  // Validated like `timerBatchSize`: a NaN/negative throttle would silently disable the
548
575
  // cross-instance liveness salvage (`timeoutMs + NaN` never compares true). `0` stays valid.
549
576
  const throttle = deps.heartbeatPersistThrottleMs;
@@ -673,6 +700,11 @@ export class WorkflowEngine {
673
700
  async (event) => {
674
701
  await this.persistStepEvent(event);
675
702
  });
703
+ // Step results come back to the instance holding the body parked on the step, where a transport
704
+ // can route them (see WorkflowEngineDeps.routeReplies).
705
+ if ((deps.routeReplies ?? true) && this.sticky.enabled) {
706
+ this.repliesRouted = this.pool.routeReplies(this.instanceId);
707
+ }
676
708
  // Control plane: re-broadcast lifecycle events from OTHER instances to this instance's
677
709
  // subscribers (cross-pod live-tail), and act on cancellations issued elsewhere. A broker may
678
710
  // echo our own publish back — ignore those (we already handled them locally) to avoid duplicates.
@@ -1353,6 +1385,14 @@ export class WorkflowEngine {
1353
1385
  * worker bridge can use this for cooperative cancellation: abort the in-flight work for `runId`
1354
1386
  * instead of finishing it just to have the result discarded. Returns an unsubscribe function.
1355
1387
  */
1388
+ /**
1389
+ * Observe the control-plane traffic of every namespace, not only this engine's own: the process
1390
+ * mounts a console (the durable dashboard) whose live tail follows runs of every namespace. The
1391
+ * dashboard provider calls it; a control plane without namespaced channels ignores it.
1392
+ */
1393
+ observeAllNamespaces() {
1394
+ this.controlPlane?.observeAll?.();
1395
+ }
1356
1396
  onCancel(listener) {
1357
1397
  this.cancelListeners.add(listener);
1358
1398
  return () => this.cancelListeners.delete(listener);
@@ -2660,7 +2700,9 @@ export class WorkflowEngine {
2660
2700
  this.eventsToBroadcast.shift();
2661
2701
  msg = { kind: 'event', event: first, from: this.instanceId };
2662
2702
  }
2663
- await this.controlPlane.publishControl(msg).catch(() => {
2703
+ // A run's own event goes to its namespace's channel; step events carry none and stay here.
2704
+ const target = msg.kind === 'event' ? runTarget(msg.event.namespace) : undefined;
2705
+ await this.controlPlane.publishControl(msg, target).catch(() => {
2664
2706
  // control-plane delivery is best-effort observability; never break execution
2665
2707
  });
2666
2708
  }
@@ -2817,7 +2859,7 @@ export class WorkflowEngine {
2817
2859
  // queue already delivers it to a consuming instance.
2818
2860
  if (this.controlPlane && !this.runQueue) {
2819
2861
  void this.controlPlane
2820
- .publishControl({ kind: 'enqueued', runId, from: this.instanceId })
2862
+ .publishControl({ kind: 'enqueued', runId, from: this.instanceId }, runTarget(run.namespace))
2821
2863
  .catch(() => undefined);
2822
2864
  }
2823
2865
  }
@@ -3609,7 +3651,38 @@ export class WorkflowEngine {
3609
3651
  });
3610
3652
  }
3611
3653
  async resumeRunNow(runId, allowFailed) {
3612
- const run = await this.store.getRun(runId);
3654
+ // A run just claimed for this turn (runPending) is held, and was read by the claim.
3655
+ const claimed = this.claimedRuns.get(runId);
3656
+ if (claimed) {
3657
+ this.claimedRuns.delete(runId);
3658
+ return this.resumeLoadedRun(claimed, runId, allowFailed);
3659
+ }
3660
+ // Take the lease and read the run in one round trip when the store can: `execute` then finds
3661
+ // the lease held for this turn. Not when a caller already holds it for this turn (a leased
3662
+ // sweep) or a turn of the run is in flight here; a failed take reads the run as before.
3663
+ const lockAndGet = this.lockedForTurn.has(runId) || this.executionLeases.has(runId)
3664
+ ? undefined
3665
+ : this.store.tryLockRunAndGet;
3666
+ let run = null;
3667
+ if (lockAndGet) {
3668
+ const nowMs = this.clock();
3669
+ run = await lockAndGet.call(this.store, runId, this.instanceId, nowMs + this.leaseMs, nowMs);
3670
+ }
3671
+ if (!run)
3672
+ return this.resumeLoadedRun(await this.store.getRun(runId), runId, allowFailed);
3673
+ this.lockedForTurn.add(runId);
3674
+ try {
3675
+ return await this.resumeLoadedRun(run, runId, allowFailed);
3676
+ }
3677
+ finally {
3678
+ // Still marked: the turn never started (a finished run, another namespace, a registration
3679
+ // that failed), so the lease taken above goes back.
3680
+ if (this.lockedForTurn.delete(runId)) {
3681
+ await this.store.releaseRunLock(runId, this.instanceId).catch(() => undefined);
3682
+ }
3683
+ }
3684
+ }
3685
+ async resumeLoadedRun(run, runId, allowFailed) {
3613
3686
  if (!run)
3614
3687
  throw new Error(`run ${runId} not found`);
3615
3688
  // A definitively-finished run must not be re-executed (e.g. a worker result landing after the
@@ -3750,6 +3823,8 @@ export class WorkflowEngine {
3750
3823
  */
3751
3824
  beginDrain(spentMs = 0) {
3752
3825
  this.draining = true;
3826
+ // Parked bodies are only an optimization: the runs replay wherever they resume next.
3827
+ this.sticky.clear();
3753
3828
  // Tell the local steps still running: a long one can stop between units of work instead of
3754
3829
  // being cut off when the drain budget runs out.
3755
3830
  if (!this.drainAbort.signal.aborted)
@@ -3923,7 +3998,7 @@ export class WorkflowEngine {
3923
3998
  this.notifyCancelled(run.id);
3924
3999
  if (this.controlPlane) {
3925
4000
  void this.controlPlane
3926
- .publishControl({ kind: 'cancel', runId: run.id, from: this.instanceId })
4001
+ .publishControl({ kind: 'cancel', runId: run.id, from: this.instanceId }, runTarget(run.namespace))
3927
4002
  .catch(() => undefined);
3928
4003
  }
3929
4004
  this.notifyParent(run.id, { ok: false, error: error.message });
@@ -4661,9 +4736,58 @@ export class WorkflowEngine {
4661
4736
  async runPending(nowMs = this.clock()) {
4662
4737
  // Oldest-first (FIFO), capped per call so a backlog drains over several polls without one sweep
4663
4738
  // fetching unboundedly. A run not picked up this tick is picked up the next.
4664
- const pending = await asHeartbeat(() => this.store.listPendingRuns(100, this.namespace));
4739
+ // A store that can claims a batch in one statement (rows another instance is claiming are
4740
+ // skipped, not raced for), and the batch runs concurrently, within this engine's run budget.
4741
+ const claim = this.store.claimPendingRuns;
4742
+ if (claim) {
4743
+ if (this.draining)
4744
+ return [];
4745
+ const room = Math.min(PENDING_POLL_BATCH, LOCAL_RUN_CONCURRENCY - this.inflight.size);
4746
+ if (room <= 0)
4747
+ return [];
4748
+ const claimed = await asHeartbeat(() => claim.call(this.store, room, this.instanceId, nowMs + this.leaseMs, nowMs, this.namespace));
4749
+ return this.runClaimed(claimed, nowMs);
4750
+ }
4751
+ const pending = await asHeartbeat(() => this.store.listPendingRuns(PENDING_POLL_BATCH, this.namespace));
4665
4752
  return this.resumeLeased(pending, nowMs);
4666
4753
  }
4754
+ /**
4755
+ * Run `claimed` (pending runs this instance just leased, read as locked): every turn starts at
4756
+ * once, and each re-enters the lease taken for it without reading the run again. A run this
4757
+ * instance cannot resume is skipped and logged, as {@link resumeLeased} does; its lease lapses.
4758
+ */
4759
+ async runClaimed(claimed, nowMs) {
4760
+ const turns = claimed.map(async (run) => {
4761
+ if (this.draining) {
4762
+ // Drain began after the claim: give the lease back; the run stays for another instance.
4763
+ await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
4764
+ return undefined;
4765
+ }
4766
+ this.lockedForTurn.add(run.id);
4767
+ this.claimedRuns.set(run.id, run);
4768
+ try {
4769
+ return await this.resumeImplicit(run.id);
4770
+ }
4771
+ catch (error) {
4772
+ if (error instanceof NamespaceMismatch)
4773
+ return undefined;
4774
+ if (error instanceof WorkflowNotRegistered) {
4775
+ const settled = await this.settleUnregistered(run, nowMs).catch(() => undefined);
4776
+ if (settled)
4777
+ return settled;
4778
+ if (settled !== undefined)
4779
+ return undefined;
4780
+ }
4781
+ console.error(`[adonis-durable] could not resume run ${run.id} (${run.workflow}@${run.workflowVersion}); leaving it for another instance or a later tick:`, error);
4782
+ return undefined;
4783
+ }
4784
+ finally {
4785
+ this.lockedForTurn.delete(run.id);
4786
+ this.claimedRuns.delete(run.id);
4787
+ }
4788
+ });
4789
+ return (await Promise.all(turns)).filter((r) => r !== undefined);
4790
+ }
4667
4791
  /**
4668
4792
  * Resolve once `runId` reaches a settled state — terminal (completed/failed/cancelled/dead) or
4669
4793
  * suspended (handed off to a timer/signal/event). The async counterpart to dispatch: pair it with
@@ -5172,7 +5296,7 @@ export class WorkflowEngine {
5172
5296
  await this.store.bufferSignal(`cancel:${runId}`, true).catch(() => undefined);
5173
5297
  if (this.controlPlane) {
5174
5298
  void this.controlPlane
5175
- .publishControl({ kind: 'cancel', runId, from: this.instanceId })
5299
+ .publishControl({ kind: 'cancel', runId, from: this.instanceId }, runTarget(run.namespace))
5176
5300
  .catch(() => undefined);
5177
5301
  }
5178
5302
  // Tracked (see {@link trackEffect}): the background resume replays the run and drives its saga
@@ -5213,7 +5337,7 @@ export class WorkflowEngine {
5213
5337
  this.notifyCancelled(runId);
5214
5338
  if (this.controlPlane) {
5215
5339
  void this.controlPlane
5216
- .publishControl({ kind: 'cancel', runId, from: this.instanceId })
5340
+ .publishControl({ kind: 'cancel', runId, from: this.instanceId }, runTarget(run.namespace))
5217
5341
  .catch(() => undefined);
5218
5342
  }
5219
5343
  // A cancelled singleton run frees its slot — wake the next gated waiter now (notify-on-release).
@@ -6033,13 +6157,20 @@ export class WorkflowEngine {
6033
6157
  // conditional write IS the guard — it subsumes the read-then-write this branch used to do (that
6034
6158
  // was itself a narrower, TOCTOU-prone version of the same check, and only looked for `cancelled`;
6035
6159
  // this now also catches `completed`/`dead` reached via any other path mid-turn).
6160
+ // Strictly later than the row this turn read: a parked body is recognized by the exact stamp its
6161
+ // suspend wrote (see takeContinuation), so no later settle, by any instance with any clock, may
6162
+ // write that same stamp again, even within the same millisecond.
6163
+ const stamp = new Date(Math.max(updatedAt.getTime(), run.updatedAt.getTime() + 1));
6036
6164
  const applied = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES, await this.withRunTrace(run, {
6037
6165
  status: 'suspended',
6038
6166
  wakeAt: this.reconcileWakeAt(outcome.wakeAt),
6039
- updatedAt,
6167
+ updatedAt: stamp,
6040
6168
  }));
6041
6169
  if (!applied)
6042
6170
  return this.echoCurrentStatus(run.id);
6171
+ run.updatedAt = stamp;
6172
+ if (this.turnContinuations.has(run.id))
6173
+ this.suspendStamps.set(run.id, stamp.getTime());
6043
6174
  this.emit({
6044
6175
  type: 'run.suspended',
6045
6176
  runId: run.id,
@@ -6084,7 +6215,8 @@ export class WorkflowEngine {
6084
6215
  * settled it), so report what is persisted rather than writing over it.
6085
6216
  */
6086
6217
  /**
6087
- * A run's first execution: `pending` -> `running`. A singleton run not yet admitted is stamped
6218
+ * A run's first execution: `pending` -> `running`, resolving whether it moved it (a run cancelled
6219
+ * or deleted since it was read did not move). A singleton run not yet admitted is stamped
6088
6220
  * {@link SINGLETON_TRACKED_TAG} in the same write, BEFORE it competes for a slot, so a run this
6089
6221
  * replica is about to admit or deny is never mistaken for a holder admitted by a replica older than
6090
6222
  * 0.51 (see `legacySingletonHolders`).
@@ -6092,14 +6224,19 @@ export class WorkflowEngine {
6092
6224
  async markStarted(run, singleton) {
6093
6225
  const tags = singleton ? withSingletonTracked(run.tags) : run.tags;
6094
6226
  const retag = tags !== run.tags;
6095
- await this.store.updateRun(run.id, {
6227
+ // A compare-and-set on `pending`: the run was read before this turn took it, and a cancel (or a
6228
+ // delete) landing in between must not be overwritten with `running`, which would resurrect it.
6229
+ const started = await this.store.updateRunIf(run.id, ['pending'], {
6096
6230
  status: 'running',
6097
6231
  ...(retag ? { tags } : {}),
6098
6232
  updatedAt: new Date(),
6099
6233
  });
6234
+ if (!started)
6235
+ return false;
6100
6236
  run.status = 'running';
6101
6237
  if (retag)
6102
6238
  run.tags = tags;
6239
+ return true;
6103
6240
  }
6104
6241
  async singletonWaitResult(run) {
6105
6242
  const current = await this.store.getRun(run.id);
@@ -6143,7 +6280,11 @@ export class WorkflowEngine {
6143
6280
  return { runId: run.id, status: 'suspended' };
6144
6281
  }
6145
6282
  if (run.status === 'pending') {
6146
- await this.markStarted(run, registered?.singleton !== undefined);
6283
+ if (!(await this.markStarted(run, registered?.singleton !== undefined))) {
6284
+ // No longer `pending` (cancelled or deleted since it was read): nothing to run.
6285
+ await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
6286
+ return await this.echoCurrentStatus(run.id);
6287
+ }
6147
6288
  this.emit({
6148
6289
  type: 'run.started',
6149
6290
  runId: run.id,
@@ -6158,7 +6299,14 @@ export class WorkflowEngine {
6158
6299
  // Capability/protocol guard (design §7.5): a remote workflow's turn dispatches a WorkflowTask to
6159
6300
  // its group — if no live worker on that group can run it, park `blocked` before advancing rather
6160
6301
  // than handing the task to the executor to enqueue into a void. Re-checked each turn (recovery).
6161
- const routePlan = await this.planRoute(registered.convention ? conventionGroup(run.workflow, run.namespace) : remote.group, registered.requires);
6302
+ const routeToken = registered.convention
6303
+ ? conventionGroup(run.workflow, run.namespace)
6304
+ : remote.group;
6305
+ let routePlan = await this.planRoute(routeToken, registered.requires);
6306
+ // As in ensureRoutable: re-read before parking, never park on reused descriptors.
6307
+ if (routePlan?.status === 'blocked') {
6308
+ routePlan = await this.planRoute(routeToken, registered.requires, true);
6309
+ }
6162
6310
  if (routePlan?.status === 'blocked')
6163
6311
  return this.parkBlocked(run, routePlan);
6164
6312
  const checkpoints = await this.replayCheckpoints(run.id);
@@ -7204,7 +7352,11 @@ export class WorkflowEngine {
7204
7352
  // gate, so an admitted run's body never starts while it still reads `pending`. A resumed run is
7205
7353
  // already past `pending`, so this fires exactly once.
7206
7354
  if (run.status === 'pending') {
7207
- await this.markStarted(run, registered?.singleton !== undefined);
7355
+ if (!(await this.markStarted(run, registered?.singleton !== undefined))) {
7356
+ // No longer `pending` (cancelled or deleted since it was read): nothing to run.
7357
+ await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
7358
+ return await this.echoCurrentStatus(run.id);
7359
+ }
7208
7360
  this.emit({
7209
7361
  type: 'run.started',
7210
7362
  runId: run.id,
@@ -7218,20 +7370,37 @@ export class WorkflowEngine {
7218
7370
  if (registered?.singleton && !(await this.singletons.admit(run, registered.singleton))) {
7219
7371
  return this.singletonWaitResult(run);
7220
7372
  }
7373
+ // A body this instance parked on dispatched steps in an earlier turn continues from there (see
7374
+ // `sticky-continuation.ts`), when nothing but this instance's own turn has settled the run since.
7375
+ const stickyOk = this.stickyEligible(run);
7376
+ let cont = stickyOk ? this.takeContinuation(run, fn) : undefined;
7377
+ const warm = cont !== undefined;
7378
+ // The parked steps' checkpoints, read before the body resumes: a failing read fails the turn
7379
+ // like a failing history read does, never the run.
7380
+ const parkedFresh = cont ? await this.readParked(run.id, cont) : undefined;
7221
7381
  // Saga compensations registered by completed steps; run in reverse if the run later fails.
7222
- const compensations = [];
7382
+ const compensations = cont ? cont.compensations : [];
7223
7383
  // Load this run's checkpoints ONCE and key them by seq, so replaying the completed prefix reads
7224
7384
  // from memory instead of one `getCheckpoint` SELECT per primitive (the O(N²) replay-reads fix).
7225
7385
  // Read once at execution start: a checkpoint written AFTER this snapshot (the signal/timer/child
7226
7386
  // this resume wakes on, or one written later in this same execution) is absent from the map, and
7227
7387
  // the ctx falls back to the live store for any absent seq — so replay semantics are unchanged.
7228
- const snapshot = await this.replayCheckpoints(run.id);
7229
- const replay = new Map();
7388
+ // A warm continuation already replayed its prefix: it reads only what lies past its park point.
7389
+ const snapshot = cont ? [] : await this.replayCheckpoints(run.id);
7390
+ const replay = cont ? cont.replay : new Map();
7230
7391
  for (const cp of snapshot)
7231
7392
  replay.set(cp.seq, cp);
7393
+ if (!cont && stickyOk) {
7394
+ cont = new StickyContinuation(run.id, fn, run.workflowVersion, compensations, replay, (c) => this.sticky.evict(c.runId));
7395
+ }
7396
+ if (cont)
7397
+ this.turnContinuations.set(run.id, cont);
7398
+ let parkedTurn = false;
7232
7399
  let suspendedOnFan;
7233
7400
  let settledSuspended = false;
7234
- const ctx = createWorkflowCtx(this.ctxHostFor(replay, run.namespace), run.id, compensations, run.workflow);
7401
+ const ctx = warm
7402
+ ? undefined
7403
+ : createWorkflowCtx(this.ctxHostFor(replay, run.namespace, cont), run.id, compensations, run.workflow);
7235
7404
  // Every settle below is `return await`ed, never a bare `return this.settleRun(...)`: inside
7236
7405
  // `try … finally` a bare return runs the `finally` — which RELEASES THE LEASE — as soon as the
7237
7406
  // return expression is evaluated, while the settle's own write is still in flight. A first turn
@@ -7248,8 +7417,33 @@ export class WorkflowEngine {
7248
7417
  // Establish the ambient ctx for the duration of this body turn so context-aware statics
7249
7418
  // (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
7250
7419
  // `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
7251
- const input = await this.turnInput(run, registered);
7252
- const output = await workflowAls.run(ctx, () => fn(ctx, input));
7420
+ let output;
7421
+ if (cont) {
7422
+ if (parkedFresh) {
7423
+ cont.attached = true;
7424
+ await cont.resumeParked(parkedFresh);
7425
+ }
7426
+ else {
7427
+ const input = await this.turnInput(run, registered);
7428
+ const body = ctx;
7429
+ cont.begin(() => workflowAls.run(body, () => fn(body, input)));
7430
+ }
7431
+ const outcome = await cont.next();
7432
+ if (outcome.kind === 'parked') {
7433
+ // Parked on dispatched steps with nothing else in flight: suspend exactly as a thrown
7434
+ // suspension would, and keep the body for the run's next turn here.
7435
+ parkedTurn = true;
7436
+ throw new WorkflowSuspended(outcome.wakeAt, outcome.waitSeqs);
7437
+ }
7438
+ if (outcome.kind === 'threw')
7439
+ throw outcome.error;
7440
+ output = outcome.value;
7441
+ }
7442
+ else {
7443
+ const input = await this.turnInput(run, registered);
7444
+ const body = ctx;
7445
+ output = await workflowAls.run(body, () => fn(body, input));
7446
+ }
7253
7447
  // A compensating cancel requested while this turn ran (or one whose resume replays straight to
7254
7448
  // `return`, with no suspension point left) still wins: undo + settle cancelled, never completed.
7255
7449
  if (await this.takeCancelIntent(run.id)) {
@@ -7312,6 +7506,11 @@ export class WorkflowEngine {
7312
7506
  return await this.compensateAndCancel(run, compensations);
7313
7507
  }
7314
7508
  const settled = await this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
7509
+ const stamp = this.suspendStamps.get(run.id);
7510
+ this.suspendStamps.delete(run.id);
7511
+ if (parkedTurn && cont && settled.status === 'suspended' && stamp !== undefined) {
7512
+ this.keepContinuation(cont, stamp);
7513
+ }
7315
7514
  // Close the settle-vs-delivery race: a signal/result that landed WHILE this turn was still
7316
7515
  // executing wrote its checkpoint durably, but its resume no-oped against our held lease —
7317
7516
  // and this settle just parked the run on nothing but the reconcile fallback (or nothing at
@@ -7370,6 +7569,9 @@ export class WorkflowEngine {
7370
7569
  //
7371
7570
  // A suspended turn keeps the lease instead when a wake it refused asked for another turn, and
7372
7571
  // runs that turn (see `requestWake`).
7572
+ if (cont && this.turnContinuations.get(run.id) === cont) {
7573
+ this.turnContinuations.delete(run.id);
7574
+ }
7373
7575
  let released = true;
7374
7576
  try {
7375
7577
  if (settledSuspended)
@@ -7620,10 +7822,11 @@ export class WorkflowEngine {
7620
7822
  /** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
7621
7823
  * (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
7622
7824
  * module and the engine stays the orchestrator. */
7623
- ctxHostFor(replay, runNamespace) {
7825
+ ctxHostFor(replay, runNamespace, sticky) {
7624
7826
  return {
7625
7827
  store: this.store,
7626
7828
  replay,
7829
+ sticky,
7627
7830
  clock: this.clock,
7628
7831
  stepSignal: this.drainAbort.signal,
7629
7832
  eventBufferTtlMs: this.eventBufferTtlMs,
@@ -7631,7 +7834,18 @@ export class WorkflowEngine {
7631
7834
  startStep: (s) => this.startStep(s),
7632
7835
  completeStep: (s) => this.completeStep(s),
7633
7836
  failStep: (s) => this.failStep(s),
7634
- callRemote: (runId, seq, step, input, queue, transport, admission) => this.callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace),
7837
+ callRemote: (runId, seq, step, input, queue, transport, admission) => {
7838
+ const call = this.callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace);
7839
+ if (!sticky)
7840
+ return call;
7841
+ // In a sticky continuation a call that would suspend parks instead: a later turn
7842
+ // re-evaluates it (the same logic, against its freshly read checkpoint).
7843
+ return call.catch((err) => {
7844
+ if (!(err instanceof WorkflowSuspended) || !sticky.attached)
7845
+ throw err;
7846
+ return sticky.park(seq, err.wakeAt, (fresh) => this.callRemote(runId, seq, step, input, queue, transport, fresh ? new Map([[seq, fresh]]) : undefined, admission, runNamespace, undefined, true));
7847
+ });
7848
+ },
7635
7849
  callRemoteAll: (runId, positions, step, inputs, opts) => this.callRemoteAll(runId, positions, step, inputs, opts, runNamespace),
7636
7850
  // Defer so a fast child can't reentrantly resume a still-running parent. The child inherits the
7637
7851
  // PARENT RUN's namespace, not this engine's: an operator legitimately executes every tenant's
@@ -7762,12 +7976,12 @@ export class WorkflowEngine {
7762
7976
  * descriptor keyspace. Empty when no transport can introspect it (in-process / pre-handshake broker)
7763
7977
  * OR nobody is publishing — either way the guard is skipped and dispatch proceeds legacy
7764
7978
  * assume-compatible (design §7.7). Never throws (the transport degrades to `[]`). */
7765
- async liveDescriptorsFor(token) {
7979
+ async liveDescriptorsFor(token, fresh = false) {
7766
7980
  const read = this.primaryTransport?.listWorkerDescriptors;
7767
7981
  if (!read)
7768
7982
  return [];
7769
7983
  try {
7770
- return await read.call(this.primaryTransport, token);
7984
+ return await read.call(this.primaryTransport, token, fresh ? { fresh } : undefined);
7771
7985
  }
7772
7986
  catch {
7773
7987
  return [];
@@ -7780,8 +7994,8 @@ export class WorkflowEngine {
7780
7994
  * - a `routable` plan — ≥1 live worker is capability-capable AND protocol-compatible (dispatch);
7781
7995
  * - a `blocked` plan — descriptors exist but none can run it (park the run, never dispatch into it).
7782
7996
  */
7783
- async planRoute(token, requires) {
7784
- const descriptors = await this.liveDescriptorsFor(token);
7997
+ async planRoute(token, requires, fresh = false) {
7998
+ const descriptors = await this.liveDescriptorsFor(token, fresh);
7785
7999
  if (descriptors.length === 0)
7786
8000
  return undefined; // legacy/no-runtime path — skip the guard
7787
8001
  return planDispatch(requires ?? [], descriptors, this.cpDescriptor, token);
@@ -7791,7 +8005,10 @@ export class WorkflowEngine {
7791
8005
  * checkpoint + dispatching into a queue nobody consumes (design §7.5). A no-op when the guard does
7792
8006
  * not engage (no descriptors) or a capable+compatible worker exists. */
7793
8007
  async ensureRoutable(token, requires) {
7794
- const plan = await this.planRoute(token, requires);
8008
+ let plan = await this.planRoute(token, requires);
8009
+ // Never park a run on reused descriptors: a capable worker may have joined since they were read.
8010
+ if (plan?.status === 'blocked')
8011
+ plan = await this.planRoute(token, requires, true);
7795
8012
  if (plan?.status === 'blocked')
7796
8013
  throw new WorkflowBlocked(plan);
7797
8014
  }
@@ -7847,7 +8064,21 @@ export class WorkflowEngine {
7847
8064
  }));
7848
8065
  return blocked;
7849
8066
  }
7850
- async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace, parallelGroup) {
8067
+ async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace, parallelGroup,
8068
+ /** `replay` holds this step's checkpoint as just read from the store: no re-read needed. */
8069
+ fresh) {
8070
+ // A position the turn's history does not hold is normally a first dispatch: write its `pending`
8071
+ // row only if none is there, instead of reading the position first. A row that is there (a turn
8072
+ // this one did not see wrote it) is then read and handled below like any other.
8073
+ if (replay !== undefined &&
8074
+ !replay.has(seq) &&
8075
+ this.store.insertCheckpointIfAbsent &&
8076
+ !step.timeoutMs &&
8077
+ this.pool.size > 0 &&
8078
+ !(queue && this.admission.handles(queue)) &&
8079
+ (await this.dispatchIfAbsent(runId, seq, step, input, transport, admission, runNamespace, parallelGroup))) {
8080
+ throw new WorkflowSuspended(undefined, [seq]);
8081
+ }
7851
8082
  // Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
7852
8083
  // from the snapshot — not yet dispatched, or written after the snapshot — falls back to the store.
7853
8084
  let existing = replay?.get(seq) ?? (await this.store.getCheckpoint(runId, seq));
@@ -7857,7 +8088,7 @@ export class WorkflowEngine {
7857
8088
  // the lost-dispatch lease, or a re-dispatch) — written from a stale row, that overwrites the landed
7858
8089
  // `completed` result with `pending` and parks the run on a lease an hour out. So a pending step is
7859
8090
  // re-read from the store before anything acts on it (one read, only at a suspension point).
7860
- if (existing?.status === 'pending' && replay?.has(seq)) {
8091
+ if (existing?.status === 'pending' && replay?.has(seq) && !fresh) {
7861
8092
  existing = (await this.store.getCheckpoint(runId, seq)) ?? existing;
7862
8093
  }
7863
8094
  if (existing && existing.name !== step.name) {
@@ -7938,6 +8169,8 @@ export class WorkflowEngine {
7938
8169
  priority: admission?.priority,
7939
8170
  attempt: reAttempt,
7940
8171
  transport,
8172
+ // A fan's steps are joined by its barrier, not a parked body: their results stay shared.
8173
+ replyHere: parallelGroup === undefined,
7941
8174
  });
7942
8175
  this.emit({
7943
8176
  type: 'step.started',
@@ -8042,10 +8275,66 @@ export class WorkflowEngine {
8042
8275
  attempt,
8043
8276
  transport,
8044
8277
  enqueuedAt,
8278
+ replyHere: parallelGroup === undefined,
8045
8279
  });
8046
8280
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
8047
8281
  throw new WorkflowSuspended(undefined, [seq]);
8048
8282
  }
8283
+ /**
8284
+ * {@link callRemote}'s first dispatch of a step, written with
8285
+ * {@link StateStore.insertCheckpointIfAbsent}: resolves whether it wrote the `pending` row and
8286
+ * dispatched. `false` when the position already has a row, or when no live worker can take the
8287
+ * step (the read-first path then decides, so a step that is in fact done is never blocked).
8288
+ */
8289
+ async dispatchIfAbsent(runId, seq, step, input, transport, admission, runNamespace, parallelGroup) {
8290
+ const validInput = step.input
8291
+ ? await validateStandard(step.input, input, `step "${step.name}" input`, 'invalid_input')
8292
+ : input;
8293
+ const token = stepGroup(step, runNamespace);
8294
+ try {
8295
+ await this.ensureRoutable(token, step.requires);
8296
+ }
8297
+ catch (err) {
8298
+ if (err instanceof WorkflowBlocked)
8299
+ return false;
8300
+ throw err;
8301
+ }
8302
+ const id = stepId(runId, seq);
8303
+ const enqueuedAt = new Date();
8304
+ const insert = this.store.insertCheckpointIfAbsent;
8305
+ const wrote = await insert.call(this.store, {
8306
+ runId,
8307
+ seq,
8308
+ name: step.name,
8309
+ kind: 'remote',
8310
+ stepId: id,
8311
+ status: 'pending',
8312
+ input: validInput,
8313
+ attempts: 1,
8314
+ workerGroup: token,
8315
+ ...(parallelGroup !== undefined ? { parallelGroup } : {}),
8316
+ enqueuedAt,
8317
+ startedAt: enqueuedAt, // placeholders until the worker result lands
8318
+ finishedAt: enqueuedAt,
8319
+ });
8320
+ if (!wrote)
8321
+ return false;
8322
+ await this.dispatchRemoteTask({
8323
+ runId,
8324
+ seq,
8325
+ name: step.name,
8326
+ stepId: id,
8327
+ group: token,
8328
+ input: validInput,
8329
+ priority: admission?.priority,
8330
+ attempt: 1,
8331
+ transport,
8332
+ enqueuedAt,
8333
+ replyHere: parallelGroup === undefined,
8334
+ });
8335
+ this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
8336
+ return true;
8337
+ }
8049
8338
  /**
8050
8339
  * `ctx.all` over a `@Step`: one durable step per input, dispatched together, joined once. The ctx
8051
8340
  * reserves the whole position block up front, so replay re-derives the same seqs.
@@ -8283,11 +8572,85 @@ export class WorkflowEngine {
8283
8572
  context: this.context?.(),
8284
8573
  priority: task.priority,
8285
8574
  attempt: task.attempt,
8575
+ ...(task.replyHere && this.repliesRouted && !this.draining
8576
+ ? { replyTo: this.instanceId }
8577
+ : {}),
8286
8578
  };
8287
8579
  if (task.enqueuedAt)
8288
8580
  remote[DISPATCH_KEY] = dispatchKey(task.stepId, task.attempt, task.enqueuedAt);
8289
8581
  return this.pool.dispatch(remote, task.transport);
8290
8582
  }
8583
+ /**
8584
+ * Whether this turn of `run` may run in a sticky continuation (see `sticky-continuation.ts`). Not
8585
+ * while the engine drains, nor with a compensating cancel requested for the run.
8586
+ */
8587
+ stickyEligible(run) {
8588
+ return this.sticky.enabled && !this.draining && !this.cancelRequested.has(run.id);
8589
+ }
8590
+ /**
8591
+ * The continuation this instance parked for `run`, if it is still the run's latest turn: the run
8592
+ * is `suspended` with the very stamp this instance's suspend wrote, on the same body and version.
8593
+ * Any other settle in between (another instance's turn, a retry, a cancel) changed the stamp, and
8594
+ * the run replays instead. Taking it removes it from the cache.
8595
+ */
8596
+ takeContinuation(run, fn) {
8597
+ const cont = this.sticky.take(run.id);
8598
+ if (!cont)
8599
+ return undefined;
8600
+ const valid = cont.fn === fn &&
8601
+ cont.workflowVersion === run.workflowVersion &&
8602
+ run.status === 'suspended' &&
8603
+ cont.stamp !== undefined &&
8604
+ run.updatedAt.getTime() === cont.stamp;
8605
+ if (!valid) {
8606
+ this.sticky.misses += 1;
8607
+ return undefined;
8608
+ }
8609
+ this.sticky.hits += 1;
8610
+ return cont;
8611
+ }
8612
+ /** Keep `cont` (parked, detached) for the run's next turn here, stamped with its suspend write. */
8613
+ keepContinuation(cont, stamp) {
8614
+ if (cont.unusable)
8615
+ return;
8616
+ cont.stamp = stamp;
8617
+ // Everything up to the park point is consumed; later positions read the store.
8618
+ cont.replay.clear();
8619
+ this.sticky.put(cont);
8620
+ }
8621
+ /**
8622
+ * The checkpoints of the steps `cont` is parked on, as a replay would see them: in one read when
8623
+ * the store batches, and without the input of a step that completed (what
8624
+ * {@link StateStore.getCheckpointsForSettle} leaves out). A step still open or failed is read
8625
+ * whole, since a re-dispatch or a retry sends its input again.
8626
+ */
8627
+ async readParked(runId, cont) {
8628
+ const fresh = new Map();
8629
+ for (const seq of cont.parked.keys())
8630
+ fresh.set(seq, cont.hints.get(seq) ?? null);
8631
+ cont.hints.clear();
8632
+ const seqs = [...fresh].flatMap(([seq, cp]) => (cp ? [] : [seq]));
8633
+ if (seqs.length === 0)
8634
+ return fresh;
8635
+ const lean = this.store.getCheckpointsForSettle ?? this.store.getCheckpoints;
8636
+ if (lean) {
8637
+ for (const cp of await lean.call(this.store, runId, seqs))
8638
+ fresh.set(cp.seq, cp);
8639
+ }
8640
+ else {
8641
+ for (const seq of seqs)
8642
+ fresh.set(seq, await this.store.getCheckpoint(runId, seq));
8643
+ }
8644
+ if (lean === this.store.getCheckpointsForSettle) {
8645
+ for (const seq of seqs) {
8646
+ const cp = fresh.get(seq);
8647
+ if (cp && cp.status !== 'completed') {
8648
+ fresh.set(seq, await this.store.getCheckpoint(runId, seq));
8649
+ }
8650
+ }
8651
+ }
8652
+ return fresh;
8653
+ }
8291
8654
  /** A turn's read of its run's checkpoints: {@link StateStore.listCheckpointsForReplay}, or every
8292
8655
  * column through {@link StateStore.listCheckpoints} on a store without it. */
8293
8656
  replayCheckpoints(runId) {
@@ -8558,6 +8921,9 @@ export class WorkflowEngine {
8558
8921
  * replays every write of the batch, so a second would only replay it again.
8559
8922
  */
8560
8923
  async completeRemoteResults(runId, results, queuedIn = () => false) {
8924
+ if (this.store.settleResultsIfPending) {
8925
+ return this.settleResultsReturning(runId, results, queuedIn);
8926
+ }
8561
8927
  const outcomes = results.map(() => true);
8562
8928
  const settleOneByOne = async (indexes) => {
8563
8929
  for (const i of indexes) {
@@ -8689,6 +9055,144 @@ export class WorkflowEngine {
8689
9055
  }
8690
9056
  return outcomes;
8691
9057
  }
9058
+ /**
9059
+ * {@link completeRemoteResults} on a store with {@link StateStore.settleResultsIfPending}: the
9060
+ * results settle their `pending` rows and read them back, with their run's status, in one call,
9061
+ * instead of reading the rows and the run and then writing. An entry that settled nothing (a
9062
+ * redelivery, a step gone, a late result for a finished run) takes the single-result path, which
9063
+ * reads first and re-drives, drops or salvages. A settled `completed` row is also handed to the
9064
+ * run's parked body, if this instance holds one, so its next turn does not read it again.
9065
+ */
9066
+ async settleResultsReturning(runId, results, queuedIn) {
9067
+ const outcomes = results.map(() => true);
9068
+ const settle = this.store.settleResultsIfPending.bind(this.store);
9069
+ // A second copy of a result in the same batch is a duplicate delivery: the first copy settles
9070
+ // the step (or re-drives the resume), so the copy is dropped like a late one.
9071
+ const seen = new Set();
9072
+ const live = results.flatMap((r, i) => {
9073
+ if (seen.has(r.seq))
9074
+ return [];
9075
+ seen.add(r.seq);
9076
+ return [i];
9077
+ });
9078
+ const finishedAt = new Date();
9079
+ const entries = live.map((i) => {
9080
+ const result = results[i];
9081
+ return {
9082
+ runId,
9083
+ seq: result.seq,
9084
+ status: result.status,
9085
+ output: result.status === 'completed' ? result.output : undefined,
9086
+ error: result.error,
9087
+ events: result.events,
9088
+ startedAt: result.startedAt ? new Date(result.startedAt) : undefined,
9089
+ finishedAt,
9090
+ };
9091
+ });
9092
+ // Never rejects as a whole: the batch call rejecting wrote nothing, so the entries are then
9093
+ // settled one by one and a failing one fails only its own result.
9094
+ let rows;
9095
+ try {
9096
+ rows = await settle(entries);
9097
+ }
9098
+ catch (err) {
9099
+ if (entries.length === 1) {
9100
+ rows = [{ error: err }];
9101
+ }
9102
+ else {
9103
+ const one = await Promise.allSettled(entries.map((e) => settle([e])));
9104
+ rows = one.map((o) => o.status === 'fulfilled' ? (o.value[0] ?? null) : { error: o.reason });
9105
+ }
9106
+ }
9107
+ const resumable = [];
9108
+ const unsettled = [];
9109
+ let run;
9110
+ // The run's parked body: in the cache, or in a turn that is parking it right now.
9111
+ const parked = this.sticky.peek(runId) ?? this.turnContinuations.get(runId);
9112
+ for (const [k, row] of rows.entries()) {
9113
+ const index = live[k];
9114
+ const result = results[index];
9115
+ if (row === null) {
9116
+ unsettled.push(index);
9117
+ continue;
9118
+ }
9119
+ if ('error' in row) {
9120
+ outcomes[index] = { error: row.error };
9121
+ continue;
9122
+ }
9123
+ const cp = row.checkpoint;
9124
+ // The run as it read when this result settled: what the resume owes it (see `resumeOwed`).
9125
+ run ??= { id: runId, status: row.run.status, wakeAt: row.run.wakeAt };
9126
+ try {
9127
+ await this.releaseQueueSlot(cp);
9128
+ }
9129
+ catch (error) {
9130
+ // Written but not acknowledged: the redelivery finds the step settled and re-drives.
9131
+ outcomes[index] = { error };
9132
+ }
9133
+ if (cp.status === 'completed')
9134
+ parked?.hint(cp);
9135
+ this.emit({
9136
+ type: result.status === 'completed' ? 'step.completed' : 'step.failed',
9137
+ runId,
9138
+ seq: result.seq,
9139
+ name: cp.name,
9140
+ kind: cp.kind,
9141
+ output: result.output,
9142
+ error: result.error,
9143
+ queueMs: cp.startedAt.getTime() - cp.enqueuedAt.getTime(),
9144
+ durationMs: finishedAt.getTime() - cp.startedAt.getTime(),
9145
+ });
9146
+ resumable.push({ cp, result });
9147
+ }
9148
+ // Nothing written for these: read first, then re-drive, drop or salvage, one at a time.
9149
+ for (const index of unsettled) {
9150
+ try {
9151
+ await this.completeOneRemoteResult(results[index], queuedIn);
9152
+ }
9153
+ catch (error) {
9154
+ outcomes[index] = { error };
9155
+ }
9156
+ }
9157
+ if (resumable.length === 0)
9158
+ return outcomes;
9159
+ // The barrier logic of the read-first batch path (see completeRemoteResults): one resume.
9160
+ const barriers = new Map();
9161
+ const highest = new Map();
9162
+ for (const { cp } of resumable) {
9163
+ const fan = parseStepFanGroup(cp.parallelGroup);
9164
+ if (fan)
9165
+ highest.set(fan.firstSeq, Math.max(highest.get(fan.firstSeq) ?? cp.seq, cp.seq));
9166
+ }
9167
+ const singles = resumable
9168
+ .filter(({ cp }) => !parseStepFanGroup(cp.parallelGroup))
9169
+ .map(({ cp }) => cp.seq);
9170
+ for (const { cp, result } of resumable) {
9171
+ const fan = parseStepFanGroup(cp.parallelGroup);
9172
+ if (!fan || result.status === 'failed') {
9173
+ this.noteSettledHere(runId, singles);
9174
+ await this.resumeOwed(run, runId);
9175
+ return outcomes;
9176
+ }
9177
+ let closed = barriers.get(fan.firstSeq);
9178
+ if (!closed) {
9179
+ const lastSeq = fan.firstSeq + fan.size - 1;
9180
+ closed = queuedIn(fan.firstSeq, lastSeq)
9181
+ ? Promise.resolve(true)
9182
+ : this.stepFanHasPending(runId, fan.firstSeq, lastSeq).catch(() => false);
9183
+ barriers.set(fan.firstSeq, closed);
9184
+ }
9185
+ if (!(await closed)) {
9186
+ this.noteSettledHere(runId, singles);
9187
+ await this.resumeOwed(run, runId, {
9188
+ first: fan.firstSeq,
9189
+ seq: highest.get(fan.firstSeq) ?? cp.seq,
9190
+ });
9191
+ return outcomes;
9192
+ }
9193
+ }
9194
+ return outcomes;
9195
+ }
8692
9196
  /**
8693
9197
  * Settle pending steps, each only while its row is still `pending`; per entry, whether it wrote
8694
9198
  * or the error that kept it from writing. Through {@link StateStore.settleCheckpointsIfPending}