@adonis-agora/durable 0.59.0 → 0.60.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 (88) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/assets/spa/assets/index-70hGzOnT.css +1 -0
  3. package/dist/assets/spa/assets/index-BI1EANJO.js +16 -0
  4. package/dist/assets/spa/assets/index-BI1EANJO.js.map +1 -0
  5. package/dist/assets/spa/index.html +2 -2
  6. package/dist/providers/durable_provider.d.ts.map +1 -1
  7. package/dist/providers/durable_provider.js +3 -0
  8. package/dist/providers/durable_provider.js.map +1 -1
  9. package/dist/src/codec-state-store.d.ts +10 -1
  10. package/dist/src/codec-state-store.d.ts.map +1 -1
  11. package/dist/src/codec-state-store.js +25 -0
  12. package/dist/src/codec-state-store.js.map +1 -1
  13. package/dist/src/dashboard/index.d.ts +1 -1
  14. package/dist/src/dashboard/index.js +1 -1
  15. package/dist/src/define_config.d.ts +13 -0
  16. package/dist/src/define_config.d.ts.map +1 -1
  17. package/dist/src/define_config.js.map +1 -1
  18. package/dist/src/engine.d.ts +75 -1
  19. package/dist/src/engine.d.ts.map +1 -1
  20. package/dist/src/engine.js +510 -23
  21. package/dist/src/engine.js.map +1 -1
  22. package/dist/src/errors.d.ts.map +1 -1
  23. package/dist/src/errors.js +4 -0
  24. package/dist/src/errors.js.map +1 -1
  25. package/dist/src/index.d.ts +1 -1
  26. package/dist/src/index.js +1 -1
  27. package/dist/src/interfaces.d.ts +94 -1
  28. package/dist/src/interfaces.d.ts.map +1 -1
  29. package/dist/src/interfaces.js +11 -0
  30. package/dist/src/interfaces.js.map +1 -1
  31. package/dist/src/sticky-continuation.d.ts +141 -0
  32. package/dist/src/sticky-continuation.d.ts.map +1 -0
  33. package/dist/src/sticky-continuation.js +231 -0
  34. package/dist/src/sticky-continuation.js.map +1 -0
  35. package/dist/src/stores/factory.d.ts +6 -0
  36. package/dist/src/stores/factory.d.ts.map +1 -1
  37. package/dist/src/stores/factory.js +3 -0
  38. package/dist/src/stores/factory.js.map +1 -1
  39. package/dist/src/stores/lucid-pg-hot-path.d.ts +30 -0
  40. package/dist/src/stores/lucid-pg-hot-path.d.ts.map +1 -0
  41. package/dist/src/stores/lucid-pg-hot-path.js +67 -0
  42. package/dist/src/stores/lucid-pg-hot-path.js.map +1 -0
  43. package/dist/src/stores/lucid.d.ts +30 -1
  44. package/dist/src/stores/lucid.d.ts.map +1 -1
  45. package/dist/src/stores/lucid.js +456 -17
  46. package/dist/src/stores/lucid.js.map +1 -1
  47. package/dist/src/testing/in-memory-state-store.d.ts +12 -1
  48. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  49. package/dist/src/testing/in-memory-state-store.js +101 -1
  50. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  51. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  52. package/dist/src/testing-kit/state-store-conformance.js +199 -0
  53. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  54. package/dist/src/transport-pool.d.ts +10 -0
  55. package/dist/src/transport-pool.d.ts.map +1 -1
  56. package/dist/src/transport-pool.js +23 -2
  57. package/dist/src/transport-pool.js.map +1 -1
  58. package/dist/src/transports/bullmq/bullmq-transport.d.ts +41 -1
  59. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  60. package/dist/src/transports/bullmq/bullmq-transport.js +283 -5
  61. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  62. package/dist/src/transports/bullmq/deps.d.ts +12 -1
  63. package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
  64. package/dist/src/transports/bullmq/deps.js.map +1 -1
  65. package/dist/src/transports/bullmq/naming.d.ts +10 -0
  66. package/dist/src/transports/bullmq/naming.d.ts.map +1 -1
  67. package/dist/src/transports/bullmq/naming.js +16 -0
  68. package/dist/src/transports/bullmq/naming.js.map +1 -1
  69. package/dist/src/transports/db.d.ts +3 -1
  70. package/dist/src/transports/db.d.ts.map +1 -1
  71. package/dist/src/transports/db.js +4 -4
  72. package/dist/src/transports/db.js.map +1 -1
  73. package/dist/src/transports/queue.d.ts +3 -1
  74. package/dist/src/transports/queue.d.ts.map +1 -1
  75. package/dist/src/transports/queue.js +2 -2
  76. package/dist/src/transports/queue.js.map +1 -1
  77. package/dist/src/transports/worker-keys.d.ts +2 -1
  78. package/dist/src/transports/worker-keys.d.ts.map +1 -1
  79. package/dist/src/transports/worker-keys.js +3 -2
  80. package/dist/src/transports/worker-keys.js.map +1 -1
  81. package/dist/src/workflow-ctx.d.ts +7 -0
  82. package/dist/src/workflow-ctx.d.ts.map +1 -1
  83. package/dist/src/workflow-ctx.js +28 -1
  84. package/dist/src/workflow-ctx.js.map +1 -1
  85. package/package.json +3 -3
  86. package/dist/assets/spa/assets/index-Co0ef3-q.css +0 -1
  87. package/dist/assets/spa/assets/index-DmgfShPp.js +0 -16
  88. 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
@@ -162,6 +167,8 @@ const leaseRefused = new WeakMap();
162
167
  * parent's next turn.
163
168
  */
164
169
  const LOCAL_RUN_CONCURRENCY = 256;
170
+ /** Pending runs one {@link WorkflowEngine.runPending} poll takes at most. */
171
+ const PENDING_POLL_BATCH = 100;
165
172
  /** How many dispatched runs this instance takes from the transport's run queue at once. */
166
173
  const RUN_CONSUMER_CONCURRENCY = 64;
167
174
  /** The most runs handed to {@link Transport.dispatchRuns} in one call. */
@@ -285,6 +292,16 @@ export class WorkflowEngine {
285
292
  maxRecoveryAttempts;
286
293
  unregisteredWorkflowGraceMs;
287
294
  reconcileMs;
295
+ /** Parked workflow bodies kept between turns (see {@link WorkflowEngineDeps.stickyRuns}). */
296
+ sticky;
297
+ /** Pending runs claimed by {@link runPending} for the turn about to run them, as read by the claim. */
298
+ claimedRuns = new Map();
299
+ /** The continuation each turn executing here runs in, by run id, while the turn runs. */
300
+ turnContinuations = new Map();
301
+ /** Whether a transport routes this instance's step results back to it (see `routeReplies`). */
302
+ repliesRouted = false;
303
+ /** The run row stamp (`updatedAt`, epoch ms) a turn here wrote when it settled `suspended`. */
304
+ suspendStamps = new Map();
288
305
  heartbeatPersistThrottleMs;
289
306
  timerBatchSize;
290
307
  eventBufferTtlMs;
@@ -544,6 +561,9 @@ export class WorkflowEngine {
544
561
  reconcile === undefined || !Number.isFinite(reconcile) || reconcile < 0
545
562
  ? 300_000
546
563
  : reconcile || undefined;
564
+ const stickyRuns = deps.stickyRuns ?? DEFAULT_STICKY_RUNS;
565
+ const stickyIdle = deps.stickyIdleMs ?? DEFAULT_STICKY_IDLE_MS;
566
+ 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
567
  // Validated like `timerBatchSize`: a NaN/negative throttle would silently disable the
548
568
  // cross-instance liveness salvage (`timeoutMs + NaN` never compares true). `0` stays valid.
549
569
  const throttle = deps.heartbeatPersistThrottleMs;
@@ -673,6 +693,11 @@ export class WorkflowEngine {
673
693
  async (event) => {
674
694
  await this.persistStepEvent(event);
675
695
  });
696
+ // Step results come back to the instance holding the body parked on the step, where a transport
697
+ // can route them (see WorkflowEngineDeps.routeReplies).
698
+ if ((deps.routeReplies ?? true) && this.sticky.enabled) {
699
+ this.repliesRouted = this.pool.routeReplies(this.instanceId);
700
+ }
676
701
  // Control plane: re-broadcast lifecycle events from OTHER instances to this instance's
677
702
  // subscribers (cross-pod live-tail), and act on cancellations issued elsewhere. A broker may
678
703
  // echo our own publish back — ignore those (we already handled them locally) to avoid duplicates.
@@ -3609,7 +3634,38 @@ export class WorkflowEngine {
3609
3634
  });
3610
3635
  }
3611
3636
  async resumeRunNow(runId, allowFailed) {
3612
- const run = await this.store.getRun(runId);
3637
+ // A run just claimed for this turn (runPending) is held, and was read by the claim.
3638
+ const claimed = this.claimedRuns.get(runId);
3639
+ if (claimed) {
3640
+ this.claimedRuns.delete(runId);
3641
+ return this.resumeLoadedRun(claimed, runId, allowFailed);
3642
+ }
3643
+ // Take the lease and read the run in one round trip when the store can: `execute` then finds
3644
+ // the lease held for this turn. Not when a caller already holds it for this turn (a leased
3645
+ // sweep) or a turn of the run is in flight here; a failed take reads the run as before.
3646
+ const lockAndGet = this.lockedForTurn.has(runId) || this.executionLeases.has(runId)
3647
+ ? undefined
3648
+ : this.store.tryLockRunAndGet;
3649
+ let run = null;
3650
+ if (lockAndGet) {
3651
+ const nowMs = this.clock();
3652
+ run = await lockAndGet.call(this.store, runId, this.instanceId, nowMs + this.leaseMs, nowMs);
3653
+ }
3654
+ if (!run)
3655
+ return this.resumeLoadedRun(await this.store.getRun(runId), runId, allowFailed);
3656
+ this.lockedForTurn.add(runId);
3657
+ try {
3658
+ return await this.resumeLoadedRun(run, runId, allowFailed);
3659
+ }
3660
+ finally {
3661
+ // Still marked: the turn never started (a finished run, another namespace, a registration
3662
+ // that failed), so the lease taken above goes back.
3663
+ if (this.lockedForTurn.delete(runId)) {
3664
+ await this.store.releaseRunLock(runId, this.instanceId).catch(() => undefined);
3665
+ }
3666
+ }
3667
+ }
3668
+ async resumeLoadedRun(run, runId, allowFailed) {
3613
3669
  if (!run)
3614
3670
  throw new Error(`run ${runId} not found`);
3615
3671
  // A definitively-finished run must not be re-executed (e.g. a worker result landing after the
@@ -3750,6 +3806,8 @@ export class WorkflowEngine {
3750
3806
  */
3751
3807
  beginDrain(spentMs = 0) {
3752
3808
  this.draining = true;
3809
+ // Parked bodies are only an optimization: the runs replay wherever they resume next.
3810
+ this.sticky.clear();
3753
3811
  // Tell the local steps still running: a long one can stop between units of work instead of
3754
3812
  // being cut off when the drain budget runs out.
3755
3813
  if (!this.drainAbort.signal.aborted)
@@ -4661,9 +4719,58 @@ export class WorkflowEngine {
4661
4719
  async runPending(nowMs = this.clock()) {
4662
4720
  // Oldest-first (FIFO), capped per call so a backlog drains over several polls without one sweep
4663
4721
  // 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));
4722
+ // A store that can claims a batch in one statement (rows another instance is claiming are
4723
+ // skipped, not raced for), and the batch runs concurrently, within this engine's run budget.
4724
+ const claim = this.store.claimPendingRuns;
4725
+ if (claim) {
4726
+ if (this.draining)
4727
+ return [];
4728
+ const room = Math.min(PENDING_POLL_BATCH, LOCAL_RUN_CONCURRENCY - this.inflight.size);
4729
+ if (room <= 0)
4730
+ return [];
4731
+ const claimed = await asHeartbeat(() => claim.call(this.store, room, this.instanceId, nowMs + this.leaseMs, nowMs, this.namespace));
4732
+ return this.runClaimed(claimed, nowMs);
4733
+ }
4734
+ const pending = await asHeartbeat(() => this.store.listPendingRuns(PENDING_POLL_BATCH, this.namespace));
4665
4735
  return this.resumeLeased(pending, nowMs);
4666
4736
  }
4737
+ /**
4738
+ * Run `claimed` (pending runs this instance just leased, read as locked): every turn starts at
4739
+ * once, and each re-enters the lease taken for it without reading the run again. A run this
4740
+ * instance cannot resume is skipped and logged, as {@link resumeLeased} does; its lease lapses.
4741
+ */
4742
+ async runClaimed(claimed, nowMs) {
4743
+ const turns = claimed.map(async (run) => {
4744
+ if (this.draining) {
4745
+ // Drain began after the claim: give the lease back; the run stays for another instance.
4746
+ await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
4747
+ return undefined;
4748
+ }
4749
+ this.lockedForTurn.add(run.id);
4750
+ this.claimedRuns.set(run.id, run);
4751
+ try {
4752
+ return await this.resumeImplicit(run.id);
4753
+ }
4754
+ catch (error) {
4755
+ if (error instanceof NamespaceMismatch)
4756
+ return undefined;
4757
+ if (error instanceof WorkflowNotRegistered) {
4758
+ const settled = await this.settleUnregistered(run, nowMs).catch(() => undefined);
4759
+ if (settled)
4760
+ return settled;
4761
+ if (settled !== undefined)
4762
+ return undefined;
4763
+ }
4764
+ console.error(`[adonis-durable] could not resume run ${run.id} (${run.workflow}@${run.workflowVersion}); leaving it for another instance or a later tick:`, error);
4765
+ return undefined;
4766
+ }
4767
+ finally {
4768
+ this.lockedForTurn.delete(run.id);
4769
+ this.claimedRuns.delete(run.id);
4770
+ }
4771
+ });
4772
+ return (await Promise.all(turns)).filter((r) => r !== undefined);
4773
+ }
4667
4774
  /**
4668
4775
  * Resolve once `runId` reaches a settled state — terminal (completed/failed/cancelled/dead) or
4669
4776
  * suspended (handed off to a timer/signal/event). The async counterpart to dispatch: pair it with
@@ -6033,13 +6140,20 @@ export class WorkflowEngine {
6033
6140
  // conditional write IS the guard — it subsumes the read-then-write this branch used to do (that
6034
6141
  // was itself a narrower, TOCTOU-prone version of the same check, and only looked for `cancelled`;
6035
6142
  // this now also catches `completed`/`dead` reached via any other path mid-turn).
6143
+ // Strictly later than the row this turn read: a parked body is recognized by the exact stamp its
6144
+ // suspend wrote (see takeContinuation), so no later settle, by any instance with any clock, may
6145
+ // write that same stamp again, even within the same millisecond.
6146
+ const stamp = new Date(Math.max(updatedAt.getTime(), run.updatedAt.getTime() + 1));
6036
6147
  const applied = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES, await this.withRunTrace(run, {
6037
6148
  status: 'suspended',
6038
6149
  wakeAt: this.reconcileWakeAt(outcome.wakeAt),
6039
- updatedAt,
6150
+ updatedAt: stamp,
6040
6151
  }));
6041
6152
  if (!applied)
6042
6153
  return this.echoCurrentStatus(run.id);
6154
+ run.updatedAt = stamp;
6155
+ if (this.turnContinuations.has(run.id))
6156
+ this.suspendStamps.set(run.id, stamp.getTime());
6043
6157
  this.emit({
6044
6158
  type: 'run.suspended',
6045
6159
  runId: run.id,
@@ -6084,7 +6198,8 @@ export class WorkflowEngine {
6084
6198
  * settled it), so report what is persisted rather than writing over it.
6085
6199
  */
6086
6200
  /**
6087
- * A run's first execution: `pending` -> `running`. A singleton run not yet admitted is stamped
6201
+ * A run's first execution: `pending` -> `running`, resolving whether it moved it (a run cancelled
6202
+ * or deleted since it was read did not move). A singleton run not yet admitted is stamped
6088
6203
  * {@link SINGLETON_TRACKED_TAG} in the same write, BEFORE it competes for a slot, so a run this
6089
6204
  * replica is about to admit or deny is never mistaken for a holder admitted by a replica older than
6090
6205
  * 0.51 (see `legacySingletonHolders`).
@@ -6092,14 +6207,19 @@ export class WorkflowEngine {
6092
6207
  async markStarted(run, singleton) {
6093
6208
  const tags = singleton ? withSingletonTracked(run.tags) : run.tags;
6094
6209
  const retag = tags !== run.tags;
6095
- await this.store.updateRun(run.id, {
6210
+ // A compare-and-set on `pending`: the run was read before this turn took it, and a cancel (or a
6211
+ // delete) landing in between must not be overwritten with `running`, which would resurrect it.
6212
+ const started = await this.store.updateRunIf(run.id, ['pending'], {
6096
6213
  status: 'running',
6097
6214
  ...(retag ? { tags } : {}),
6098
6215
  updatedAt: new Date(),
6099
6216
  });
6217
+ if (!started)
6218
+ return false;
6100
6219
  run.status = 'running';
6101
6220
  if (retag)
6102
6221
  run.tags = tags;
6222
+ return true;
6103
6223
  }
6104
6224
  async singletonWaitResult(run) {
6105
6225
  const current = await this.store.getRun(run.id);
@@ -6143,7 +6263,11 @@ export class WorkflowEngine {
6143
6263
  return { runId: run.id, status: 'suspended' };
6144
6264
  }
6145
6265
  if (run.status === 'pending') {
6146
- await this.markStarted(run, registered?.singleton !== undefined);
6266
+ if (!(await this.markStarted(run, registered?.singleton !== undefined))) {
6267
+ // No longer `pending` (cancelled or deleted since it was read): nothing to run.
6268
+ await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
6269
+ return await this.echoCurrentStatus(run.id);
6270
+ }
6147
6271
  this.emit({
6148
6272
  type: 'run.started',
6149
6273
  runId: run.id,
@@ -6158,7 +6282,14 @@ export class WorkflowEngine {
6158
6282
  // Capability/protocol guard (design §7.5): a remote workflow's turn dispatches a WorkflowTask to
6159
6283
  // its group — if no live worker on that group can run it, park `blocked` before advancing rather
6160
6284
  // 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);
6285
+ const routeToken = registered.convention
6286
+ ? conventionGroup(run.workflow, run.namespace)
6287
+ : remote.group;
6288
+ let routePlan = await this.planRoute(routeToken, registered.requires);
6289
+ // As in ensureRoutable: re-read before parking, never park on reused descriptors.
6290
+ if (routePlan?.status === 'blocked') {
6291
+ routePlan = await this.planRoute(routeToken, registered.requires, true);
6292
+ }
6162
6293
  if (routePlan?.status === 'blocked')
6163
6294
  return this.parkBlocked(run, routePlan);
6164
6295
  const checkpoints = await this.replayCheckpoints(run.id);
@@ -7204,7 +7335,11 @@ export class WorkflowEngine {
7204
7335
  // gate, so an admitted run's body never starts while it still reads `pending`. A resumed run is
7205
7336
  // already past `pending`, so this fires exactly once.
7206
7337
  if (run.status === 'pending') {
7207
- await this.markStarted(run, registered?.singleton !== undefined);
7338
+ if (!(await this.markStarted(run, registered?.singleton !== undefined))) {
7339
+ // No longer `pending` (cancelled or deleted since it was read): nothing to run.
7340
+ await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
7341
+ return await this.echoCurrentStatus(run.id);
7342
+ }
7208
7343
  this.emit({
7209
7344
  type: 'run.started',
7210
7345
  runId: run.id,
@@ -7218,20 +7353,37 @@ export class WorkflowEngine {
7218
7353
  if (registered?.singleton && !(await this.singletons.admit(run, registered.singleton))) {
7219
7354
  return this.singletonWaitResult(run);
7220
7355
  }
7356
+ // A body this instance parked on dispatched steps in an earlier turn continues from there (see
7357
+ // `sticky-continuation.ts`), when nothing but this instance's own turn has settled the run since.
7358
+ const stickyOk = this.stickyEligible(run);
7359
+ let cont = stickyOk ? this.takeContinuation(run, fn) : undefined;
7360
+ const warm = cont !== undefined;
7361
+ // The parked steps' checkpoints, read before the body resumes: a failing read fails the turn
7362
+ // like a failing history read does, never the run.
7363
+ const parkedFresh = cont ? await this.readParked(run.id, cont) : undefined;
7221
7364
  // Saga compensations registered by completed steps; run in reverse if the run later fails.
7222
- const compensations = [];
7365
+ const compensations = cont ? cont.compensations : [];
7223
7366
  // Load this run's checkpoints ONCE and key them by seq, so replaying the completed prefix reads
7224
7367
  // from memory instead of one `getCheckpoint` SELECT per primitive (the O(N²) replay-reads fix).
7225
7368
  // Read once at execution start: a checkpoint written AFTER this snapshot (the signal/timer/child
7226
7369
  // this resume wakes on, or one written later in this same execution) is absent from the map, and
7227
7370
  // 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();
7371
+ // A warm continuation already replayed its prefix: it reads only what lies past its park point.
7372
+ const snapshot = cont ? [] : await this.replayCheckpoints(run.id);
7373
+ const replay = cont ? cont.replay : new Map();
7230
7374
  for (const cp of snapshot)
7231
7375
  replay.set(cp.seq, cp);
7376
+ if (!cont && stickyOk) {
7377
+ cont = new StickyContinuation(run.id, fn, run.workflowVersion, compensations, replay, (c) => this.sticky.evict(c.runId));
7378
+ }
7379
+ if (cont)
7380
+ this.turnContinuations.set(run.id, cont);
7381
+ let parkedTurn = false;
7232
7382
  let suspendedOnFan;
7233
7383
  let settledSuspended = false;
7234
- const ctx = createWorkflowCtx(this.ctxHostFor(replay, run.namespace), run.id, compensations, run.workflow);
7384
+ const ctx = warm
7385
+ ? undefined
7386
+ : createWorkflowCtx(this.ctxHostFor(replay, run.namespace, cont), run.id, compensations, run.workflow);
7235
7387
  // Every settle below is `return await`ed, never a bare `return this.settleRun(...)`: inside
7236
7388
  // `try … finally` a bare return runs the `finally` — which RELEASES THE LEASE — as soon as the
7237
7389
  // return expression is evaluated, while the settle's own write is still in flight. A first turn
@@ -7248,8 +7400,33 @@ export class WorkflowEngine {
7248
7400
  // Establish the ambient ctx for the duration of this body turn so context-aware statics
7249
7401
  // (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
7250
7402
  // `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));
7403
+ let output;
7404
+ if (cont) {
7405
+ if (parkedFresh) {
7406
+ cont.attached = true;
7407
+ await cont.resumeParked(parkedFresh);
7408
+ }
7409
+ else {
7410
+ const input = await this.turnInput(run, registered);
7411
+ const body = ctx;
7412
+ cont.begin(() => workflowAls.run(body, () => fn(body, input)));
7413
+ }
7414
+ const outcome = await cont.next();
7415
+ if (outcome.kind === 'parked') {
7416
+ // Parked on dispatched steps with nothing else in flight: suspend exactly as a thrown
7417
+ // suspension would, and keep the body for the run's next turn here.
7418
+ parkedTurn = true;
7419
+ throw new WorkflowSuspended(outcome.wakeAt, outcome.waitSeqs);
7420
+ }
7421
+ if (outcome.kind === 'threw')
7422
+ throw outcome.error;
7423
+ output = outcome.value;
7424
+ }
7425
+ else {
7426
+ const input = await this.turnInput(run, registered);
7427
+ const body = ctx;
7428
+ output = await workflowAls.run(body, () => fn(body, input));
7429
+ }
7253
7430
  // A compensating cancel requested while this turn ran (or one whose resume replays straight to
7254
7431
  // `return`, with no suspension point left) still wins: undo + settle cancelled, never completed.
7255
7432
  if (await this.takeCancelIntent(run.id)) {
@@ -7312,6 +7489,11 @@ export class WorkflowEngine {
7312
7489
  return await this.compensateAndCancel(run, compensations);
7313
7490
  }
7314
7491
  const settled = await this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
7492
+ const stamp = this.suspendStamps.get(run.id);
7493
+ this.suspendStamps.delete(run.id);
7494
+ if (parkedTurn && cont && settled.status === 'suspended' && stamp !== undefined) {
7495
+ this.keepContinuation(cont, stamp);
7496
+ }
7315
7497
  // Close the settle-vs-delivery race: a signal/result that landed WHILE this turn was still
7316
7498
  // executing wrote its checkpoint durably, but its resume no-oped against our held lease —
7317
7499
  // and this settle just parked the run on nothing but the reconcile fallback (or nothing at
@@ -7370,6 +7552,9 @@ export class WorkflowEngine {
7370
7552
  //
7371
7553
  // A suspended turn keeps the lease instead when a wake it refused asked for another turn, and
7372
7554
  // runs that turn (see `requestWake`).
7555
+ if (cont && this.turnContinuations.get(run.id) === cont) {
7556
+ this.turnContinuations.delete(run.id);
7557
+ }
7373
7558
  let released = true;
7374
7559
  try {
7375
7560
  if (settledSuspended)
@@ -7620,10 +7805,11 @@ export class WorkflowEngine {
7620
7805
  /** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
7621
7806
  * (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
7622
7807
  * module and the engine stays the orchestrator. */
7623
- ctxHostFor(replay, runNamespace) {
7808
+ ctxHostFor(replay, runNamespace, sticky) {
7624
7809
  return {
7625
7810
  store: this.store,
7626
7811
  replay,
7812
+ sticky,
7627
7813
  clock: this.clock,
7628
7814
  stepSignal: this.drainAbort.signal,
7629
7815
  eventBufferTtlMs: this.eventBufferTtlMs,
@@ -7631,7 +7817,18 @@ export class WorkflowEngine {
7631
7817
  startStep: (s) => this.startStep(s),
7632
7818
  completeStep: (s) => this.completeStep(s),
7633
7819
  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),
7820
+ callRemote: (runId, seq, step, input, queue, transport, admission) => {
7821
+ const call = this.callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace);
7822
+ if (!sticky)
7823
+ return call;
7824
+ // In a sticky continuation a call that would suspend parks instead: a later turn
7825
+ // re-evaluates it (the same logic, against its freshly read checkpoint).
7826
+ return call.catch((err) => {
7827
+ if (!(err instanceof WorkflowSuspended) || !sticky.attached)
7828
+ throw err;
7829
+ 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));
7830
+ });
7831
+ },
7635
7832
  callRemoteAll: (runId, positions, step, inputs, opts) => this.callRemoteAll(runId, positions, step, inputs, opts, runNamespace),
7636
7833
  // Defer so a fast child can't reentrantly resume a still-running parent. The child inherits the
7637
7834
  // PARENT RUN's namespace, not this engine's: an operator legitimately executes every tenant's
@@ -7762,12 +7959,12 @@ export class WorkflowEngine {
7762
7959
  * descriptor keyspace. Empty when no transport can introspect it (in-process / pre-handshake broker)
7763
7960
  * OR nobody is publishing — either way the guard is skipped and dispatch proceeds legacy
7764
7961
  * assume-compatible (design §7.7). Never throws (the transport degrades to `[]`). */
7765
- async liveDescriptorsFor(token) {
7962
+ async liveDescriptorsFor(token, fresh = false) {
7766
7963
  const read = this.primaryTransport?.listWorkerDescriptors;
7767
7964
  if (!read)
7768
7965
  return [];
7769
7966
  try {
7770
- return await read.call(this.primaryTransport, token);
7967
+ return await read.call(this.primaryTransport, token, fresh ? { fresh } : undefined);
7771
7968
  }
7772
7969
  catch {
7773
7970
  return [];
@@ -7780,8 +7977,8 @@ export class WorkflowEngine {
7780
7977
  * - a `routable` plan — ≥1 live worker is capability-capable AND protocol-compatible (dispatch);
7781
7978
  * - a `blocked` plan — descriptors exist but none can run it (park the run, never dispatch into it).
7782
7979
  */
7783
- async planRoute(token, requires) {
7784
- const descriptors = await this.liveDescriptorsFor(token);
7980
+ async planRoute(token, requires, fresh = false) {
7981
+ const descriptors = await this.liveDescriptorsFor(token, fresh);
7785
7982
  if (descriptors.length === 0)
7786
7983
  return undefined; // legacy/no-runtime path — skip the guard
7787
7984
  return planDispatch(requires ?? [], descriptors, this.cpDescriptor, token);
@@ -7791,7 +7988,10 @@ export class WorkflowEngine {
7791
7988
  * checkpoint + dispatching into a queue nobody consumes (design §7.5). A no-op when the guard does
7792
7989
  * not engage (no descriptors) or a capable+compatible worker exists. */
7793
7990
  async ensureRoutable(token, requires) {
7794
- const plan = await this.planRoute(token, requires);
7991
+ let plan = await this.planRoute(token, requires);
7992
+ // Never park a run on reused descriptors: a capable worker may have joined since they were read.
7993
+ if (plan?.status === 'blocked')
7994
+ plan = await this.planRoute(token, requires, true);
7795
7995
  if (plan?.status === 'blocked')
7796
7996
  throw new WorkflowBlocked(plan);
7797
7997
  }
@@ -7847,7 +8047,21 @@ export class WorkflowEngine {
7847
8047
  }));
7848
8048
  return blocked;
7849
8049
  }
7850
- async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace, parallelGroup) {
8050
+ async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace, parallelGroup,
8051
+ /** `replay` holds this step's checkpoint as just read from the store: no re-read needed. */
8052
+ fresh) {
8053
+ // A position the turn's history does not hold is normally a first dispatch: write its `pending`
8054
+ // row only if none is there, instead of reading the position first. A row that is there (a turn
8055
+ // this one did not see wrote it) is then read and handled below like any other.
8056
+ if (replay !== undefined &&
8057
+ !replay.has(seq) &&
8058
+ this.store.insertCheckpointIfAbsent &&
8059
+ !step.timeoutMs &&
8060
+ this.pool.size > 0 &&
8061
+ !(queue && this.admission.handles(queue)) &&
8062
+ (await this.dispatchIfAbsent(runId, seq, step, input, transport, admission, runNamespace, parallelGroup))) {
8063
+ throw new WorkflowSuspended(undefined, [seq]);
8064
+ }
7851
8065
  // Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
7852
8066
  // from the snapshot — not yet dispatched, or written after the snapshot — falls back to the store.
7853
8067
  let existing = replay?.get(seq) ?? (await this.store.getCheckpoint(runId, seq));
@@ -7857,7 +8071,7 @@ export class WorkflowEngine {
7857
8071
  // the lost-dispatch lease, or a re-dispatch) — written from a stale row, that overwrites the landed
7858
8072
  // `completed` result with `pending` and parks the run on a lease an hour out. So a pending step is
7859
8073
  // 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)) {
8074
+ if (existing?.status === 'pending' && replay?.has(seq) && !fresh) {
7861
8075
  existing = (await this.store.getCheckpoint(runId, seq)) ?? existing;
7862
8076
  }
7863
8077
  if (existing && existing.name !== step.name) {
@@ -7938,6 +8152,8 @@ export class WorkflowEngine {
7938
8152
  priority: admission?.priority,
7939
8153
  attempt: reAttempt,
7940
8154
  transport,
8155
+ // A fan's steps are joined by its barrier, not a parked body: their results stay shared.
8156
+ replyHere: parallelGroup === undefined,
7941
8157
  });
7942
8158
  this.emit({
7943
8159
  type: 'step.started',
@@ -8042,10 +8258,66 @@ export class WorkflowEngine {
8042
8258
  attempt,
8043
8259
  transport,
8044
8260
  enqueuedAt,
8261
+ replyHere: parallelGroup === undefined,
8045
8262
  });
8046
8263
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
8047
8264
  throw new WorkflowSuspended(undefined, [seq]);
8048
8265
  }
8266
+ /**
8267
+ * {@link callRemote}'s first dispatch of a step, written with
8268
+ * {@link StateStore.insertCheckpointIfAbsent}: resolves whether it wrote the `pending` row and
8269
+ * dispatched. `false` when the position already has a row, or when no live worker can take the
8270
+ * step (the read-first path then decides, so a step that is in fact done is never blocked).
8271
+ */
8272
+ async dispatchIfAbsent(runId, seq, step, input, transport, admission, runNamespace, parallelGroup) {
8273
+ const validInput = step.input
8274
+ ? await validateStandard(step.input, input, `step "${step.name}" input`, 'invalid_input')
8275
+ : input;
8276
+ const token = stepGroup(step, runNamespace);
8277
+ try {
8278
+ await this.ensureRoutable(token, step.requires);
8279
+ }
8280
+ catch (err) {
8281
+ if (err instanceof WorkflowBlocked)
8282
+ return false;
8283
+ throw err;
8284
+ }
8285
+ const id = stepId(runId, seq);
8286
+ const enqueuedAt = new Date();
8287
+ const insert = this.store.insertCheckpointIfAbsent;
8288
+ const wrote = await insert.call(this.store, {
8289
+ runId,
8290
+ seq,
8291
+ name: step.name,
8292
+ kind: 'remote',
8293
+ stepId: id,
8294
+ status: 'pending',
8295
+ input: validInput,
8296
+ attempts: 1,
8297
+ workerGroup: token,
8298
+ ...(parallelGroup !== undefined ? { parallelGroup } : {}),
8299
+ enqueuedAt,
8300
+ startedAt: enqueuedAt, // placeholders until the worker result lands
8301
+ finishedAt: enqueuedAt,
8302
+ });
8303
+ if (!wrote)
8304
+ return false;
8305
+ await this.dispatchRemoteTask({
8306
+ runId,
8307
+ seq,
8308
+ name: step.name,
8309
+ stepId: id,
8310
+ group: token,
8311
+ input: validInput,
8312
+ priority: admission?.priority,
8313
+ attempt: 1,
8314
+ transport,
8315
+ enqueuedAt,
8316
+ replyHere: parallelGroup === undefined,
8317
+ });
8318
+ this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
8319
+ return true;
8320
+ }
8049
8321
  /**
8050
8322
  * `ctx.all` over a `@Step`: one durable step per input, dispatched together, joined once. The ctx
8051
8323
  * reserves the whole position block up front, so replay re-derives the same seqs.
@@ -8283,11 +8555,85 @@ export class WorkflowEngine {
8283
8555
  context: this.context?.(),
8284
8556
  priority: task.priority,
8285
8557
  attempt: task.attempt,
8558
+ ...(task.replyHere && this.repliesRouted && !this.draining
8559
+ ? { replyTo: this.instanceId }
8560
+ : {}),
8286
8561
  };
8287
8562
  if (task.enqueuedAt)
8288
8563
  remote[DISPATCH_KEY] = dispatchKey(task.stepId, task.attempt, task.enqueuedAt);
8289
8564
  return this.pool.dispatch(remote, task.transport);
8290
8565
  }
8566
+ /**
8567
+ * Whether this turn of `run` may run in a sticky continuation (see `sticky-continuation.ts`). Not
8568
+ * while the engine drains, nor with a compensating cancel requested for the run.
8569
+ */
8570
+ stickyEligible(run) {
8571
+ return this.sticky.enabled && !this.draining && !this.cancelRequested.has(run.id);
8572
+ }
8573
+ /**
8574
+ * The continuation this instance parked for `run`, if it is still the run's latest turn: the run
8575
+ * is `suspended` with the very stamp this instance's suspend wrote, on the same body and version.
8576
+ * Any other settle in between (another instance's turn, a retry, a cancel) changed the stamp, and
8577
+ * the run replays instead. Taking it removes it from the cache.
8578
+ */
8579
+ takeContinuation(run, fn) {
8580
+ const cont = this.sticky.take(run.id);
8581
+ if (!cont)
8582
+ return undefined;
8583
+ const valid = cont.fn === fn &&
8584
+ cont.workflowVersion === run.workflowVersion &&
8585
+ run.status === 'suspended' &&
8586
+ cont.stamp !== undefined &&
8587
+ run.updatedAt.getTime() === cont.stamp;
8588
+ if (!valid) {
8589
+ this.sticky.misses += 1;
8590
+ return undefined;
8591
+ }
8592
+ this.sticky.hits += 1;
8593
+ return cont;
8594
+ }
8595
+ /** Keep `cont` (parked, detached) for the run's next turn here, stamped with its suspend write. */
8596
+ keepContinuation(cont, stamp) {
8597
+ if (cont.unusable)
8598
+ return;
8599
+ cont.stamp = stamp;
8600
+ // Everything up to the park point is consumed; later positions read the store.
8601
+ cont.replay.clear();
8602
+ this.sticky.put(cont);
8603
+ }
8604
+ /**
8605
+ * The checkpoints of the steps `cont` is parked on, as a replay would see them: in one read when
8606
+ * the store batches, and without the input of a step that completed (what
8607
+ * {@link StateStore.getCheckpointsForSettle} leaves out). A step still open or failed is read
8608
+ * whole, since a re-dispatch or a retry sends its input again.
8609
+ */
8610
+ async readParked(runId, cont) {
8611
+ const fresh = new Map();
8612
+ for (const seq of cont.parked.keys())
8613
+ fresh.set(seq, cont.hints.get(seq) ?? null);
8614
+ cont.hints.clear();
8615
+ const seqs = [...fresh].flatMap(([seq, cp]) => (cp ? [] : [seq]));
8616
+ if (seqs.length === 0)
8617
+ return fresh;
8618
+ const lean = this.store.getCheckpointsForSettle ?? this.store.getCheckpoints;
8619
+ if (lean) {
8620
+ for (const cp of await lean.call(this.store, runId, seqs))
8621
+ fresh.set(cp.seq, cp);
8622
+ }
8623
+ else {
8624
+ for (const seq of seqs)
8625
+ fresh.set(seq, await this.store.getCheckpoint(runId, seq));
8626
+ }
8627
+ if (lean === this.store.getCheckpointsForSettle) {
8628
+ for (const seq of seqs) {
8629
+ const cp = fresh.get(seq);
8630
+ if (cp && cp.status !== 'completed') {
8631
+ fresh.set(seq, await this.store.getCheckpoint(runId, seq));
8632
+ }
8633
+ }
8634
+ }
8635
+ return fresh;
8636
+ }
8291
8637
  /** A turn's read of its run's checkpoints: {@link StateStore.listCheckpointsForReplay}, or every
8292
8638
  * column through {@link StateStore.listCheckpoints} on a store without it. */
8293
8639
  replayCheckpoints(runId) {
@@ -8558,6 +8904,9 @@ export class WorkflowEngine {
8558
8904
  * replays every write of the batch, so a second would only replay it again.
8559
8905
  */
8560
8906
  async completeRemoteResults(runId, results, queuedIn = () => false) {
8907
+ if (this.store.settleResultsIfPending) {
8908
+ return this.settleResultsReturning(runId, results, queuedIn);
8909
+ }
8561
8910
  const outcomes = results.map(() => true);
8562
8911
  const settleOneByOne = async (indexes) => {
8563
8912
  for (const i of indexes) {
@@ -8689,6 +9038,144 @@ export class WorkflowEngine {
8689
9038
  }
8690
9039
  return outcomes;
8691
9040
  }
9041
+ /**
9042
+ * {@link completeRemoteResults} on a store with {@link StateStore.settleResultsIfPending}: the
9043
+ * results settle their `pending` rows and read them back, with their run's status, in one call,
9044
+ * instead of reading the rows and the run and then writing. An entry that settled nothing (a
9045
+ * redelivery, a step gone, a late result for a finished run) takes the single-result path, which
9046
+ * reads first and re-drives, drops or salvages. A settled `completed` row is also handed to the
9047
+ * run's parked body, if this instance holds one, so its next turn does not read it again.
9048
+ */
9049
+ async settleResultsReturning(runId, results, queuedIn) {
9050
+ const outcomes = results.map(() => true);
9051
+ const settle = this.store.settleResultsIfPending.bind(this.store);
9052
+ // A second copy of a result in the same batch is a duplicate delivery: the first copy settles
9053
+ // the step (or re-drives the resume), so the copy is dropped like a late one.
9054
+ const seen = new Set();
9055
+ const live = results.flatMap((r, i) => {
9056
+ if (seen.has(r.seq))
9057
+ return [];
9058
+ seen.add(r.seq);
9059
+ return [i];
9060
+ });
9061
+ const finishedAt = new Date();
9062
+ const entries = live.map((i) => {
9063
+ const result = results[i];
9064
+ return {
9065
+ runId,
9066
+ seq: result.seq,
9067
+ status: result.status,
9068
+ output: result.status === 'completed' ? result.output : undefined,
9069
+ error: result.error,
9070
+ events: result.events,
9071
+ startedAt: result.startedAt ? new Date(result.startedAt) : undefined,
9072
+ finishedAt,
9073
+ };
9074
+ });
9075
+ // Never rejects as a whole: the batch call rejecting wrote nothing, so the entries are then
9076
+ // settled one by one and a failing one fails only its own result.
9077
+ let rows;
9078
+ try {
9079
+ rows = await settle(entries);
9080
+ }
9081
+ catch (err) {
9082
+ if (entries.length === 1) {
9083
+ rows = [{ error: err }];
9084
+ }
9085
+ else {
9086
+ const one = await Promise.allSettled(entries.map((e) => settle([e])));
9087
+ rows = one.map((o) => o.status === 'fulfilled' ? (o.value[0] ?? null) : { error: o.reason });
9088
+ }
9089
+ }
9090
+ const resumable = [];
9091
+ const unsettled = [];
9092
+ let run;
9093
+ // The run's parked body: in the cache, or in a turn that is parking it right now.
9094
+ const parked = this.sticky.peek(runId) ?? this.turnContinuations.get(runId);
9095
+ for (const [k, row] of rows.entries()) {
9096
+ const index = live[k];
9097
+ const result = results[index];
9098
+ if (row === null) {
9099
+ unsettled.push(index);
9100
+ continue;
9101
+ }
9102
+ if ('error' in row) {
9103
+ outcomes[index] = { error: row.error };
9104
+ continue;
9105
+ }
9106
+ const cp = row.checkpoint;
9107
+ // The run as it read when this result settled: what the resume owes it (see `resumeOwed`).
9108
+ run ??= { id: runId, status: row.run.status, wakeAt: row.run.wakeAt };
9109
+ try {
9110
+ await this.releaseQueueSlot(cp);
9111
+ }
9112
+ catch (error) {
9113
+ // Written but not acknowledged: the redelivery finds the step settled and re-drives.
9114
+ outcomes[index] = { error };
9115
+ }
9116
+ if (cp.status === 'completed')
9117
+ parked?.hint(cp);
9118
+ this.emit({
9119
+ type: result.status === 'completed' ? 'step.completed' : 'step.failed',
9120
+ runId,
9121
+ seq: result.seq,
9122
+ name: cp.name,
9123
+ kind: cp.kind,
9124
+ output: result.output,
9125
+ error: result.error,
9126
+ queueMs: cp.startedAt.getTime() - cp.enqueuedAt.getTime(),
9127
+ durationMs: finishedAt.getTime() - cp.startedAt.getTime(),
9128
+ });
9129
+ resumable.push({ cp, result });
9130
+ }
9131
+ // Nothing written for these: read first, then re-drive, drop or salvage, one at a time.
9132
+ for (const index of unsettled) {
9133
+ try {
9134
+ await this.completeOneRemoteResult(results[index], queuedIn);
9135
+ }
9136
+ catch (error) {
9137
+ outcomes[index] = { error };
9138
+ }
9139
+ }
9140
+ if (resumable.length === 0)
9141
+ return outcomes;
9142
+ // The barrier logic of the read-first batch path (see completeRemoteResults): one resume.
9143
+ const barriers = new Map();
9144
+ const highest = new Map();
9145
+ for (const { cp } of resumable) {
9146
+ const fan = parseStepFanGroup(cp.parallelGroup);
9147
+ if (fan)
9148
+ highest.set(fan.firstSeq, Math.max(highest.get(fan.firstSeq) ?? cp.seq, cp.seq));
9149
+ }
9150
+ const singles = resumable
9151
+ .filter(({ cp }) => !parseStepFanGroup(cp.parallelGroup))
9152
+ .map(({ cp }) => cp.seq);
9153
+ for (const { cp, result } of resumable) {
9154
+ const fan = parseStepFanGroup(cp.parallelGroup);
9155
+ if (!fan || result.status === 'failed') {
9156
+ this.noteSettledHere(runId, singles);
9157
+ await this.resumeOwed(run, runId);
9158
+ return outcomes;
9159
+ }
9160
+ let closed = barriers.get(fan.firstSeq);
9161
+ if (!closed) {
9162
+ const lastSeq = fan.firstSeq + fan.size - 1;
9163
+ closed = queuedIn(fan.firstSeq, lastSeq)
9164
+ ? Promise.resolve(true)
9165
+ : this.stepFanHasPending(runId, fan.firstSeq, lastSeq).catch(() => false);
9166
+ barriers.set(fan.firstSeq, closed);
9167
+ }
9168
+ if (!(await closed)) {
9169
+ this.noteSettledHere(runId, singles);
9170
+ await this.resumeOwed(run, runId, {
9171
+ first: fan.firstSeq,
9172
+ seq: highest.get(fan.firstSeq) ?? cp.seq,
9173
+ });
9174
+ return outcomes;
9175
+ }
9176
+ }
9177
+ return outcomes;
9178
+ }
8692
9179
  /**
8693
9180
  * Settle pending steps, each only while its row is still `pending`; per entry, whether it wrote
8694
9181
  * or the error that kept it from writing. Through {@link StateStore.settleCheckpointsIfPending}