@adonis-agora/durable 0.35.0 → 0.36.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 (139) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/dist/assets/spa/assets/index-DU6cGcJz.css +1 -0
  3. package/dist/assets/spa/assets/index-Dx5lG7Vu.js +16 -0
  4. package/dist/assets/spa/assets/index-Dx5lG7Vu.js.map +1 -0
  5. package/dist/assets/spa/index.html +2 -2
  6. package/dist/commands/export_run.d.ts +18 -0
  7. package/dist/commands/export_run.d.ts.map +1 -0
  8. package/dist/commands/export_run.js +45 -0
  9. package/dist/commands/export_run.js.map +1 -0
  10. package/dist/commands/main.d.ts +1 -1
  11. package/dist/commands/main.d.ts.map +1 -1
  12. package/dist/commands/main.js +3 -1
  13. package/dist/commands/main.js.map +1 -1
  14. package/dist/providers/dashboard_provider.d.ts +7 -0
  15. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  16. package/dist/providers/dashboard_provider.js +36 -1
  17. package/dist/providers/dashboard_provider.js.map +1 -1
  18. package/dist/providers/durable_provider.d.ts.map +1 -1
  19. package/dist/providers/durable_provider.js +18 -0
  20. package/dist/providers/durable_provider.js.map +1 -1
  21. package/dist/src/admission-redis/redis-admission-backend.d.ts +2 -1
  22. package/dist/src/admission-redis/redis-admission-backend.d.ts.map +1 -1
  23. package/dist/src/admission-redis/redis-admission-backend.js +35 -8
  24. package/dist/src/admission-redis/redis-admission-backend.js.map +1 -1
  25. package/dist/src/backoff.d.ts +7 -0
  26. package/dist/src/backoff.d.ts.map +1 -1
  27. package/dist/src/backoff.js +12 -2
  28. package/dist/src/backoff.js.map +1 -1
  29. package/dist/src/codec-state-store.d.ts +4 -2
  30. package/dist/src/codec-state-store.d.ts.map +1 -1
  31. package/dist/src/codec-state-store.js +23 -4
  32. package/dist/src/codec-state-store.js.map +1 -1
  33. package/dist/src/commands/worker.d.ts +2 -0
  34. package/dist/src/commands/worker.d.ts.map +1 -1
  35. package/dist/src/commands/worker.js +18 -1
  36. package/dist/src/commands/worker.js.map +1 -1
  37. package/dist/src/dashboard/gateway-adapter.d.ts +13 -1
  38. package/dist/src/dashboard/gateway-adapter.d.ts.map +1 -1
  39. package/dist/src/dashboard/gateway-adapter.js +12 -0
  40. package/dist/src/dashboard/gateway-adapter.js.map +1 -1
  41. package/dist/src/dashboard/handlers.d.ts +47 -1
  42. package/dist/src/dashboard/handlers.d.ts.map +1 -1
  43. package/dist/src/dashboard/handlers.js +141 -8
  44. package/dist/src/dashboard/handlers.js.map +1 -1
  45. package/dist/src/dashboard/index.d.ts +1 -1
  46. package/dist/src/dashboard/index.js +1 -1
  47. package/dist/src/dashboard/openapi.d.ts +13 -0
  48. package/dist/src/dashboard/openapi.d.ts.map +1 -0
  49. package/dist/src/dashboard/openapi.js +395 -0
  50. package/dist/src/dashboard/openapi.js.map +1 -0
  51. package/dist/src/dashboard/run-filter.d.ts +8 -3
  52. package/dist/src/dashboard/run-filter.d.ts.map +1 -1
  53. package/dist/src/dashboard/run-filter.js +35 -4
  54. package/dist/src/dashboard/run-filter.js.map +1 -1
  55. package/dist/src/define_config.d.ts +24 -0
  56. package/dist/src/define_config.d.ts.map +1 -1
  57. package/dist/src/define_config.js.map +1 -1
  58. package/dist/src/engine.d.ts +216 -8
  59. package/dist/src/engine.d.ts.map +1 -1
  60. package/dist/src/engine.js +849 -121
  61. package/dist/src/engine.js.map +1 -1
  62. package/dist/src/errors.d.ts +9 -1
  63. package/dist/src/errors.d.ts.map +1 -1
  64. package/dist/src/errors.js +10 -1
  65. package/dist/src/errors.js.map +1 -1
  66. package/dist/src/index.d.ts +1 -1
  67. package/dist/src/index.js +1 -1
  68. package/dist/src/interfaces.d.ts +116 -7
  69. package/dist/src/interfaces.d.ts.map +1 -1
  70. package/dist/src/pollers.d.ts +26 -2
  71. package/dist/src/pollers.d.ts.map +1 -1
  72. package/dist/src/pollers.js +47 -4
  73. package/dist/src/pollers.js.map +1 -1
  74. package/dist/src/queue.d.ts +9 -1
  75. package/dist/src/queue.d.ts.map +1 -1
  76. package/dist/src/queue.js +26 -7
  77. package/dist/src/queue.js.map +1 -1
  78. package/dist/src/run-value-facets.d.ts +1 -1
  79. package/dist/src/run-value-facets.d.ts.map +1 -1
  80. package/dist/src/run-value-facets.js +7 -1
  81. package/dist/src/run-value-facets.js.map +1 -1
  82. package/dist/src/scheduler.d.ts +15 -1
  83. package/dist/src/scheduler.d.ts.map +1 -1
  84. package/dist/src/scheduler.js +36 -1
  85. package/dist/src/scheduler.js.map +1 -1
  86. package/dist/src/stores/lucid-mappers.d.ts +5 -0
  87. package/dist/src/stores/lucid-mappers.d.ts.map +1 -1
  88. package/dist/src/stores/lucid-mappers.js +8 -0
  89. package/dist/src/stores/lucid-mappers.js.map +1 -1
  90. package/dist/src/stores/lucid-schema.d.ts.map +1 -1
  91. package/dist/src/stores/lucid-schema.js +53 -7
  92. package/dist/src/stores/lucid-schema.js.map +1 -1
  93. package/dist/src/stores/lucid.d.ts +10 -3
  94. package/dist/src/stores/lucid.d.ts.map +1 -1
  95. package/dist/src/stores/lucid.js +74 -67
  96. package/dist/src/stores/lucid.js.map +1 -1
  97. package/dist/src/testing/in-memory-state-store.d.ts +4 -2
  98. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  99. package/dist/src/testing/in-memory-state-store.js +54 -8
  100. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  101. package/dist/src/testing-kit/replay.d.ts +12 -0
  102. package/dist/src/testing-kit/replay.d.ts.map +1 -1
  103. package/dist/src/testing-kit/replay.js +33 -0
  104. package/dist/src/testing-kit/replay.js.map +1 -1
  105. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  106. package/dist/src/testing-kit/state-store-conformance.js +68 -0
  107. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  108. package/dist/src/transports/bullmq/bullmq-transport.d.ts +0 -5
  109. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  110. package/dist/src/transports/bullmq/bullmq-transport.js +92 -10
  111. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  112. package/dist/src/transports/bullmq/deps.d.ts +2 -0
  113. package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
  114. package/dist/src/transports/bullmq/deps.js.map +1 -1
  115. package/dist/src/transports/bullmq/serialization.d.ts +2 -1
  116. package/dist/src/transports/bullmq/serialization.d.ts.map +1 -1
  117. package/dist/src/transports/bullmq/serialization.js +9 -3
  118. package/dist/src/transports/bullmq/serialization.js.map +1 -1
  119. package/dist/src/transports/db.d.ts +3 -1
  120. package/dist/src/transports/db.d.ts.map +1 -1
  121. package/dist/src/transports/db.js +89 -35
  122. package/dist/src/transports/db.js.map +1 -1
  123. package/dist/src/transports/queue.d.ts +7 -1
  124. package/dist/src/transports/queue.d.ts.map +1 -1
  125. package/dist/src/transports/queue.js +23 -1
  126. package/dist/src/transports/queue.js.map +1 -1
  127. package/dist/src/workflow-ctx.d.ts.map +1 -1
  128. package/dist/src/workflow-ctx.js +77 -14
  129. package/dist/src/workflow-ctx.js.map +1 -1
  130. package/dist/src/workflow-discovery.d.ts.map +1 -1
  131. package/dist/src/workflow-discovery.js +1 -0
  132. package/dist/src/workflow-discovery.js.map +1 -1
  133. package/dist/src/workflow-ref.d.ts +6 -0
  134. package/dist/src/workflow-ref.d.ts.map +1 -1
  135. package/dist/src/workflow-ref.js.map +1 -1
  136. package/package.json +2 -2
  137. package/dist/assets/spa/assets/index-CNnTzTNK.css +0 -1
  138. package/dist/assets/spa/assets/index-DxLx4LRM.js +0 -16
  139. package/dist/assets/spa/assets/index-DxLx4LRM.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import { InMemoryAdmissionBackend } from './admission.js';
2
- import { backoffDelay } from './backoff.js';
2
+ import { backoffDelay, MAX_BACKOFF_MS } from './backoff.js';
3
3
  import { instantCheckpoint, stepCheckpoint } from './checkpoints.js';
4
4
  import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
5
5
  import { parseDuration } from './duration.js';
@@ -11,6 +11,7 @@ import { asHeartbeat } from './observability-scope.js';
11
11
  import { breakpointToken, stepId } from './protocol.js';
12
12
  import { RemoteWorkflowExecutor } from './remote-workflow-executor.js';
13
13
  import { scanRunValueFacets } from './run-value-facets.js';
14
+ import { scheduleWindow } from './scheduler.js';
14
15
  import { SingletonGate } from './singleton-gate.js';
15
16
  import { sanitizeQueueToken, tenantGroup } from './tenant-group.js';
16
17
  import { TransportPool } from './transport-pool.js';
@@ -52,6 +53,13 @@ const SETTLE_ELIGIBLE_STATUSES = [
52
53
  'failed',
53
54
  ];
54
55
  const versionKey = (name, version) => `${name}@${version}`;
56
+ /**
57
+ * First checkpoint seq reserved for saga compensations: compensation `idx` (registration order)
58
+ * persists at `COMPENSATION_SEQ_BASE - idx` (-2, -3, …). Negative so it can never collide with the
59
+ * positional replay's `>= 0` seqs, and starting at -2 so it also never collides with the legacy
60
+ * un-checkpointed `-1` undo dispatches of older engine versions.
61
+ */
62
+ const COMPENSATION_SEQ_BASE = -2;
55
63
  /**
56
64
  * The routing token a dispatched step lands on: the step's own (broker-safe) name, suffixed by the
57
65
  * tenant the RUN belongs to. `runNamespace` — not the executing engine's namespace — is the tenant
@@ -115,6 +123,18 @@ export class WorkflowEngine {
115
123
  /** Establish the originating context ambiently around a local step body (see {@link WorkflowEngineDeps.rehydrate}). Default passthrough. */
116
124
  rehydrate;
117
125
  compensationRetries;
126
+ compensationTimeoutMs;
127
+ retention;
128
+ // -Infinity so the FIRST sweep always runs (a fake/test clock may start near 0).
129
+ #lastRetentionSweepAt = Number.NEGATIVE_INFINITY;
130
+ /** Archival hooks run (awaited, in order) before a retention eviction deletes a run. */
131
+ evictListeners = new Set();
132
+ stalledAfterMs;
133
+ /** Pager hooks for the stranded-run signature (see {@link onStalled}). */
134
+ stalledListeners = new Set();
135
+ /** `runId → run.updatedAt ms` at last stalled notification, so one episode pages once. */
136
+ #notifiedStalled = new Map();
137
+ #lastStalledSweepAt = Number.NEGATIVE_INFINITY;
118
138
  /** Persist a `running` checkpoint at the start of a local step body (see {@link WorkflowEngineDeps.trackStepStart}). */
119
139
  trackStepStart;
120
140
  /** Where a freshly-started run executes — in-process by default (see {@link RunDispatcher}). */
@@ -163,6 +183,10 @@ export class WorkflowEngine {
163
183
  updateValidators = new Map();
164
184
  /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
165
185
  cancelRequested = new Set();
186
+ /** The merged schedule set the worker loop ticks with (see {@link adoptSchedules}). */
187
+ #allSchedules = [];
188
+ /** Runtime pause overrides by schedule key (console-issued; win over the config's `paused`). */
189
+ #schedulePauseOverrides = new Map();
166
190
  /** Flow-control admission backend for remote steps (see {@link registerQueue}). */
167
191
  admission;
168
192
  /** Runs on THIS instance blocked on admission, by queue — woken early on a freed-slot signal. */
@@ -170,7 +194,15 @@ export class WorkflowEngine {
170
194
  /** Which queue a dispatched step took a slot from, by stepId — so the result can release it. */
171
195
  stepQueue = new Map();
172
196
  /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
173
- inflight = new Set();
197
+ inflight = new Map();
198
+ /**
199
+ * Runs whose recovery lease this instance LOST while their turn was still executing — a renew
200
+ * came back false (another instance took over after expiry), or drain released the lease on
201
+ * timeout. A fenced turn must not settle: its outcome was computed by a now-zombie executor, and
202
+ * the new owner is (re)executing the same run — writing would clobber/interleave with the owner's
203
+ * writes. `settleRun` checks this and degrades to an echo; `execute`'s finally clears the mark.
204
+ */
205
+ leaseLostRuns = new Set();
174
206
  /**
175
207
  * Post-settle side effects (parent notify, singleton wake) fired fire-and-forget AFTER a run's
176
208
  * status was persisted. They run OFF the `execute()` path — the caller of `execute()` must never
@@ -229,6 +261,9 @@ export class WorkflowEngine {
229
261
  // Default passthrough: with no bridge supplied, a local step body runs exactly as before.
230
262
  this.rehydrate = deps.rehydrate ?? ((_carrier, fn) => fn());
231
263
  this.compensationRetries = Math.max(1, deps.compensationRetries ?? 1);
264
+ this.compensationTimeoutMs = Math.max(1_000, deps.compensationTimeoutMs ?? 300_000);
265
+ this.retention = deps.retention;
266
+ this.stalledAfterMs = Math.max(60_000, deps.stalledAfterMs ?? 900_000);
232
267
  this.trackStepStart = deps.trackStepStart ?? true;
233
268
  // Default: execute the run on this instance, asynchronously, so `start` never blocks on the body.
234
269
  // A failed pickup is swallowed here (the run stays `pending` for a `runPending` poll to retry);
@@ -291,6 +326,11 @@ export class WorkflowEngine {
291
326
  else if (msg.kind === 'cancel') {
292
327
  this.notifyCancelled(msg.runId);
293
328
  }
329
+ else if (msg.kind === 'schedulePause') {
330
+ // A console on another instance paused/resumed a schedule — apply it here so THIS ticker
331
+ // honors it too (runtime-only; a deploy resets to the config).
332
+ this.#schedulePauseOverrides.set(msg.key, msg.paused);
333
+ }
294
334
  else if (msg.kind === 'enqueued') {
295
335
  // A run was enqueued on another instance — let worker subscribers pick it up immediately
296
336
  // instead of waiting for the next poll. (Self-broadcasts are already filtered above.)
@@ -322,6 +362,83 @@ export class WorkflowEngine {
322
362
  * and fires. Idempotent per `key`: the first schedule registered for a key wins and a later duplicate
323
363
  * key is ignored with a warning (so re-scanning, or a config entry overriding it, can't double-fire).
324
364
  */
365
+ /**
366
+ * Adopt the FULL schedule set the worker loop ticks with (config + discovered, already merged) so
367
+ * the runtime schedule-control surface (list/pause/resume/trigger) operates on exactly what fires.
368
+ * Called by `runTick`; idempotent.
369
+ */
370
+ adoptSchedules(schedules) {
371
+ this.#allSchedules = schedules;
372
+ }
373
+ /**
374
+ * The runtime pause override for a schedule key: `true`/`false` when the console set one (wins
375
+ * over the config's `paused`), `undefined` when untouched. Runtime-only — a deploy resets it.
376
+ */
377
+ schedulePauseOverride(key) {
378
+ return this.#schedulePauseOverrides.get(key);
379
+ }
380
+ /**
381
+ * The schedules this engine ticks, with their live control state and fire windows — what a
382
+ * console's Schedules tab lists. `paused` reflects the EFFECTIVE state (runtime override, else
383
+ * config); `lastRunStatus` is the current window's run, when it exists.
384
+ */
385
+ async listSchedules(now = this.clock()) {
386
+ const out = [];
387
+ for (const s of [...this.#allSchedules, ...this.#discoveredSchedules]) {
388
+ if (out.some((x) => x.key === s.key))
389
+ continue; // adopted set already contains discovered ones post-merge
390
+ const window = scheduleWindow(s, now);
391
+ const currentRun = await this.store.getRun(window.runId).catch(() => null);
392
+ const override = this.#schedulePauseOverrides.get(s.key);
393
+ out.push({
394
+ key: s.key,
395
+ workflow: s.workflow,
396
+ cron: s.cron,
397
+ everyMs: s.everyMs,
398
+ timezone: s.timezone,
399
+ overlap: s.overlap,
400
+ namespace: s.namespace,
401
+ paused: override ?? s.paused ?? false,
402
+ pausedAtRuntime: override !== undefined,
403
+ lastFireAt: window.lastFireAt,
404
+ nextFireAt: window.nextFireAt,
405
+ currentWindowRunId: window.runId,
406
+ lastRunStatus: currentRun?.status,
407
+ });
408
+ }
409
+ return out;
410
+ }
411
+ /**
412
+ * Pause/resume a schedule at RUNTIME, fleet-wide: applied on this instance and broadcast on the
413
+ * control plane so every ticking peer applies it too. Runtime-only state — a deploy resets it to
414
+ * the config's `paused`; pin the config for permanence. Returns false for an unknown key.
415
+ */
416
+ setSchedulePaused(key, paused) {
417
+ const known = this.#allSchedules.some((s) => s.key === key) ||
418
+ this.#discoveredSchedules.some((s) => s.key === key);
419
+ if (!known)
420
+ return false;
421
+ this.#schedulePauseOverrides.set(key, paused);
422
+ if (this.controlPlane) {
423
+ void this.controlPlane
424
+ .publishControl({ kind: 'schedulePause', key, paused, from: this.instanceId })
425
+ .catch(() => undefined);
426
+ }
427
+ return true;
428
+ }
429
+ /**
430
+ * Fire a schedule's CURRENT window now (console "run now"). Idempotent by the window's
431
+ * deterministic run id — triggering a window that already fired returns the existing run instead
432
+ * of forking a duplicate. Returns null for an unknown key.
433
+ */
434
+ async triggerSchedule(key, now = this.clock()) {
435
+ const s = this.#allSchedules.find((x) => x.key === key) ??
436
+ this.#discoveredSchedules.find((x) => x.key === key);
437
+ if (!s)
438
+ return null;
439
+ const window = scheduleWindow(s, now);
440
+ return this.start(s.workflow, s.input, window.runId, { namespace: s.namespace });
441
+ }
325
442
  registerSchedules(schedules) {
326
443
  const seen = new Set(this.#discoveredSchedules.map((s) => s.key));
327
444
  for (const schedule of schedules) {
@@ -387,6 +504,7 @@ export class WorkflowEngine {
387
504
  version,
388
505
  fn,
389
506
  tags: opts?.tags,
507
+ origin: opts?.origin,
390
508
  singleton: opts?.singleton,
391
509
  executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : undefined,
392
510
  validateInput: opts?.validateInput,
@@ -620,6 +738,13 @@ export class WorkflowEngine {
620
738
  // the same id is a no-op, returning the existing run's state instead of starting a duplicate.
621
739
  const prior = await this.store.getRun(runId);
622
740
  if (prior) {
741
+ // A PENDING prior still needs an executor: re-nudge the dispatcher so a redelivered trigger —
742
+ // or a continuation persisted pre-settle (see the ContinueAsNew handoff) picked up by a custom
743
+ // dispatcher — doesn't sit `pending` until the next poll. Idempotent: execution is leased, so
744
+ // a duplicate dispatch is a cheap no-op at the worker.
745
+ if (dispatch && prior.status === 'pending') {
746
+ await this.runDispatcher.dispatch(runId);
747
+ }
623
748
  return { runId, status: prior.status, output: prior.output, error: prior.error };
624
749
  }
625
750
  const now = new Date();
@@ -633,11 +758,19 @@ export class WorkflowEngine {
633
758
  if (registered.singleton) {
634
759
  await this.singletons.assertCapacity(name, registered.singleton, input);
635
760
  }
761
+ // Delayed start: park the run on its durable wake timer instead of dispatching now (see
762
+ // StartOptions.startAt). The timer poller path that wakes sleeps does the rest.
763
+ const startAtMs = opts?.startAt instanceof Date ? opts.startAt.getTime() : opts?.startAt;
764
+ // Compared against the ENGINE clock (injectable), not wall time — the same clock the timer
765
+ // poller will use to decide the run is due.
766
+ const delayed = startAtMs !== undefined && startAtMs > this.clock();
636
767
  const run = {
637
768
  id: runId,
638
769
  workflow: name,
639
770
  workflowVersion: registered.version,
640
- status: 'pending',
771
+ status: delayed ? 'suspended' : 'pending',
772
+ ...(delayed ? { wakeAt: startAtMs } : {}),
773
+ origin: opts?.origin ?? registered.origin,
641
774
  namespace: opts?.namespace ?? this.namespace,
642
775
  input,
643
776
  tags,
@@ -646,11 +779,27 @@ export class WorkflowEngine {
646
779
  createdAt: now,
647
780
  updatedAt: now,
648
781
  };
649
- await this.store.createRun(run);
782
+ try {
783
+ await this.store.createRun(run);
784
+ }
785
+ catch (err) {
786
+ // Lost a true createRun RACE (two pods scheduler-ticking the same window, concurrent
787
+ // `signalWithStart`s for one entity id): the read-before-create above saw no prior, but the
788
+ // store's duplicate-id rejection says someone else won the insert in the gap. Converge on the
789
+ // winner — re-read and report ITS state, exactly like the prior-run branch — instead of
790
+ // surfacing a unique-constraint error (which would abort a scheduler tick, or worse, lose the
791
+ // signal half of a signalWithStart). A re-read that finds nothing means the throw was a real
792
+ // store failure: rethrow it.
793
+ const winner = await this.store.getRun(runId);
794
+ if (winner) {
795
+ return { runId, status: winner.status, output: winner.output, error: winner.error };
796
+ }
797
+ throw err;
798
+ }
650
799
  // The run is durably enqueued; a dispatcher (in-process by default) executes it — `start` does
651
800
  // NOT run the body inline. Await the terminal/suspended state with `waitForRun(runId)` if needed.
652
801
  // An internal in-process handoff persists with `dispatch: false` and drives the pickup itself.
653
- if (dispatch) {
802
+ if (dispatch && !delayed) {
654
803
  await this.runDispatcher.dispatch(runId);
655
804
  // Nudge worker instances to pick it up now instead of on their next poll (no-op without a control
656
805
  // plane; self-receipt is filtered, so it only helps OTHER pods — e.g. an API pod's enqueue).
@@ -660,7 +809,7 @@ export class WorkflowEngine {
660
809
  .catch(() => undefined);
661
810
  }
662
811
  }
663
- return { runId, status: 'pending' };
812
+ return { runId, status: run.status };
664
813
  }
665
814
  /**
666
815
  * Bridge an INTERNAL run handoff — the next execution of a `continue-as-new`, or a deferred child
@@ -745,6 +894,142 @@ export class WorkflowEngine {
745
894
  * deleted (the parent→children edge is read from them). Use for retention/cleanup; this is a
746
895
  * destructive store operation, not a cancellation — a running run should be {@link cancel}led first.
747
896
  */
897
+ /**
898
+ * Register a pager hook for STRANDED runs — the failure `docs/reliability/failure-modes.mdx` calls
899
+ * "the stranded signature": an in-flight run untouched past `stalledAfterMs` that is either
900
+ * suspended with NO wake timer (wake-forever) or parked on an old pending remote step whose worker
901
+ * heartbeat has gone silent. The worker tick sweeps for these (throttled to one pass a minute) and
902
+ * notifies each episode ONCE (re-notifies only after the run progresses and strands again). Wire
903
+ * your alerting here exactly like {@link onDead}. Returns an unsubscribe fn.
904
+ */
905
+ onStalled(listener) {
906
+ this.stalledListeners.add(listener);
907
+ return () => this.stalledListeners.delete(listener);
908
+ }
909
+ /**
910
+ * Detect stranded in-flight runs and notify {@link onStalled} listeners. Self-throttled to one
911
+ * pass a minute; a no-op without listeners. Returns the runs notified THIS pass.
912
+ */
913
+ async sweepStalled(now = this.clock()) {
914
+ if (this.stalledListeners.size === 0 || this.draining)
915
+ return [];
916
+ if (now - this.#lastStalledSweepAt < 60_000)
917
+ return [];
918
+ this.#lastStalledSweepAt = now;
919
+ const candidates = await this.store.listRuns({
920
+ statuses: ['suspended', 'running'],
921
+ namespace: this.namespace,
922
+ updatedBefore: now - this.stalledAfterMs,
923
+ limit: 100,
924
+ });
925
+ const notified = [];
926
+ for (const run of candidates) {
927
+ // One notification per episode: skip a run we already paged about that hasn't moved since.
928
+ if (this.#notifiedStalled.get(run.id) === run.updatedAt.getTime())
929
+ continue;
930
+ // The stranded signature, disambiguated from healthy long work:
931
+ // (a) suspended with NO wake timer — nothing will ever re-drive it (reconcileMs: 0 deployments);
932
+ // (b) an old pending REMOTE step whose worker heartbeat is silent — dispatched and lost.
933
+ // A long `ctx.sleep` (wakeAt in the future) and a beating long step are NOT stalled.
934
+ let stalePending = null;
935
+ for (const cp of await this.store.listCheckpoints(run.id)) {
936
+ if (cp.kind !== 'remote' || cp.status !== 'pending')
937
+ continue;
938
+ const ageMs = now - cp.enqueuedAt.getTime();
939
+ const heartbeatAgeMs = cp.lastHeartbeatAt ? now - cp.lastHeartbeatAt.getTime() : null;
940
+ if (ageMs < this.stalledAfterMs)
941
+ continue;
942
+ if (heartbeatAgeMs !== null && heartbeatAgeMs < this.stalledAfterMs)
943
+ continue; // alive mid-flight
944
+ if (!stalePending || ageMs > stalePending.ageMs) {
945
+ stalePending = {
946
+ seq: cp.seq,
947
+ name: cp.name,
948
+ attempts: cp.attempts,
949
+ ageMs,
950
+ heartbeatAgeMs,
951
+ };
952
+ }
953
+ }
954
+ const wakeForever = run.status === 'suspended' && run.wakeAt === undefined;
955
+ if (!wakeForever && !stalePending)
956
+ continue;
957
+ const info = {
958
+ run,
959
+ ageMs: now - run.updatedAt.getTime(),
960
+ stalePending,
961
+ wakeForever,
962
+ };
963
+ this.#notifiedStalled.set(run.id, run.updatedAt.getTime());
964
+ if (this.#notifiedStalled.size > 1_000)
965
+ this.#notifiedStalled.clear(); // bounded; re-pages at worst
966
+ for (const listener of this.stalledListeners) {
967
+ try {
968
+ listener(info);
969
+ }
970
+ catch {
971
+ /* a pager hook must not break the sweep */
972
+ }
973
+ }
974
+ notified.push(info);
975
+ }
976
+ return notified;
977
+ }
978
+ /**
979
+ * Register an archival hook run before a retention eviction deletes a run (it receives the run
980
+ * and its full checkpoint timeline — e.g. write them to S3/file). Awaited in registration order;
981
+ * a hook that THROWS skips that run's deletion, so a broken archive never loses data. Returns an
982
+ * unsubscribe fn.
983
+ */
984
+ onEvict(listener) {
985
+ this.evictListeners.add(listener);
986
+ return () => this.evictListeners.delete(listener);
987
+ }
988
+ /**
989
+ * Retention sweep: hard-delete terminal runs whose last activity is older than the configured age
990
+ * (see {@link WorkflowEngineDeps.retention}). Self-throttled to one pass per minute per instance —
991
+ * call it every tick, it's a no-op most of the time. Returns how many runs were deleted (subtree
992
+ * counts included). Bounded per pass (100 candidates per status); a large backlog drains across
993
+ * passes.
994
+ *
995
+ * Caveat: eviction cascades DOWN (a deleted run takes its child subtree) but does not know about
996
+ * a still-live PARENT awaiting a custom-id child — size the ages well beyond your longest-running
997
+ * parents.
998
+ */
999
+ async sweepRetention(now = this.clock()) {
1000
+ if (!this.retention || this.draining)
1001
+ return 0;
1002
+ if (now - this.#lastRetentionSweepAt < 60_000)
1003
+ return 0;
1004
+ this.#lastRetentionSweepAt = now;
1005
+ let evicted = 0;
1006
+ for (const status of ['completed', 'failed', 'cancelled', 'dead']) {
1007
+ const maxAgeMs = this.retention[status];
1008
+ if (maxAgeMs == null || maxAgeMs <= 0)
1009
+ continue;
1010
+ const candidates = await this.store.listRuns({
1011
+ statuses: [status],
1012
+ namespace: this.namespace,
1013
+ updatedBefore: now - maxAgeMs,
1014
+ limit: 100,
1015
+ });
1016
+ for (const run of candidates) {
1017
+ try {
1018
+ if (this.evictListeners.size > 0) {
1019
+ const checkpoints = await this.store.listCheckpoints(run.id);
1020
+ for (const listener of this.evictListeners)
1021
+ await listener(run, checkpoints);
1022
+ }
1023
+ evicted += await this.deleteRun(run.id);
1024
+ }
1025
+ catch (err) {
1026
+ // An archival hook (or the delete itself) failed — keep the run; the next sweep retries.
1027
+ console.warn(`[adonis-durable] retention eviction of run ${run.id} skipped: ${err instanceof Error ? err.message : String(err)}`);
1028
+ }
1029
+ }
1030
+ }
1031
+ return evicted;
1032
+ }
748
1033
  async deleteRun(runId) {
749
1034
  const run = await this.store.getRun(runId);
750
1035
  if (!run)
@@ -755,6 +1040,17 @@ export class WorkflowEngine {
755
1040
  deleted += await this.deleteRun(childId);
756
1041
  }
757
1042
  await this.store.deleteRun(runId);
1043
+ // Sweep this run's token-keyed buffered signals too — they are not run-scoped rows, so the
1044
+ // store's deleteRun can't cascade them. Without this every fire-and-forget child that was never
1045
+ // joined leaks its buffered `child:<id>` completion FOREVER (one row per spawn), and a raced
1046
+ // compensate-cancel can leave a `cancel:<id>` marker behind.
1047
+ for (const token of [`child:${runId}`, `cancel:${runId}`]) {
1048
+ for (;;) {
1049
+ const buffered = await this.store.takeBufferedSignal(token).catch(() => null);
1050
+ if (!buffered)
1051
+ break;
1052
+ }
1053
+ }
758
1054
  return deleted + 1;
759
1055
  }
760
1056
  async resume(runId) {
@@ -775,7 +1071,7 @@ export class WorkflowEngine {
775
1071
  if (this.namespace !== undefined &&
776
1072
  run.namespace !== undefined &&
777
1073
  run.namespace !== this.namespace) {
778
- await this.store.releaseRunLock(runId);
1074
+ await this.store.releaseRunLock(runId, this.instanceId);
779
1075
  throw new NamespaceMismatch();
780
1076
  }
781
1077
  // Pin to the version the run STARTED on — replay is positional, so running a changed
@@ -791,11 +1087,29 @@ export class WorkflowEngine {
791
1087
  if (!registered) {
792
1088
  throw new Error(`workflow ${run.workflow}@${run.workflowVersion} is not registered — keep the prior version deployed so in-flight runs can drain (skew protection)`);
793
1089
  }
794
- return this.track(this.execute(run, registered.fn));
1090
+ return this.track(runId, this.execute(run, registered.fn));
1091
+ }
1092
+ /**
1093
+ * Deferred re-drive after a suspended settle whose waited checkpoint(s) already settled — the
1094
+ * delivery's own resume no-oped against this turn's still-held lease (see the WorkflowSuspended
1095
+ * catch in runExecution). Deferred one macrotask so the caller's `finally` releases the lease
1096
+ * first; tracked so `drain()` waits for the re-driven turn's writes.
1097
+ */
1098
+ recheckWaitSeqs(runId, seqs) {
1099
+ this.trackEffect((async () => {
1100
+ await new Promise((r) => setTimeout(r, 0));
1101
+ for (const seq of seqs) {
1102
+ const cp = await this.store.getCheckpoint(runId, seq).catch(() => null);
1103
+ if (cp && (cp.status === 'completed' || cp.status === 'failed')) {
1104
+ await this.resume(runId).catch(() => undefined);
1105
+ return;
1106
+ }
1107
+ }
1108
+ })());
795
1109
  }
796
1110
  /** Track an in-flight execution so {@link drain} can wait for it. */
797
- track(p) {
798
- this.inflight.add(p);
1111
+ track(runId, p) {
1112
+ this.inflight.set(p, runId);
799
1113
  void p.finally(() => this.inflight.delete(p));
800
1114
  return p;
801
1115
  }
@@ -821,8 +1135,14 @@ export class WorkflowEngine {
821
1135
  * resume). A HOT continue-as-new loop — one that hands off to a fresh link faster than it settles —
822
1136
  * therefore keeps `postSettle`/`inflight` non-empty and consumes the ENTIRE `timeoutMs` before this
823
1137
  * returns. That is by design (tearing down mid-continuation would orphan the continuation's writes);
824
- * on timeout the frontier link is left persisted and leased, so the next boot's recovery re-drives it
1138
+ * on timeout the frontier link is left persisted with the lease released, so the next boot's recovery re-drives it
825
1139
  * (no loss) — the cost is spending the full timeout on shutdown. Size `timeoutMs` accordingly.
1140
+ *
1141
+ * On return — whether the executions settled or the timeout won — `drain()` releases the recovery
1142
+ * locks of runs still tracked in-flight in this process (best-effort, swallowed per-run). This
1143
+ * process is going away, so a held lease would only delay the next pod's `recoverIncomplete` until
1144
+ * expiry; after release the next pod replays deterministically (a pending step re-dispatches the same
1145
+ * `stepId`, idempotent by the durable contract; a completed step short-circuits).
826
1146
  */
827
1147
  async drain(timeoutMs = 10_000) {
828
1148
  this.draining = true;
@@ -838,10 +1158,31 @@ export class WorkflowEngine {
838
1158
  // `Promise<'timeout'>` sentinel is distinguishable from `allSettled`'s array so the race is
839
1159
  // unambiguous even when everything resolves in the same tick.
840
1160
  while (this.inflight.size > 0 || this.postSettle.size > 0) {
841
- const pending = Promise.allSettled([...this.inflight, ...this.postSettle]);
1161
+ const pending = Promise.allSettled([...this.inflight.keys(), ...this.postSettle]);
842
1162
  const outcome = await Promise.race([pending, timer]);
843
- if (outcome === 'timeout')
1163
+ if (outcome === 'timeout') {
1164
+ await this.releaseInflightLocks();
844
1165
  return;
1166
+ }
1167
+ }
1168
+ await this.releaseInflightLocks();
1169
+ }
1170
+ /**
1171
+ * Best-effort release of the recovery locks still held by in-flight executions. Called on `drain()`
1172
+ * return so a shutting-down process hands off fast. Never throws (swallowed per-run).
1173
+ */
1174
+ async releaseInflightLocks() {
1175
+ for (const runId of [...this.inflight.values()]) {
1176
+ try {
1177
+ // Owner-scoped: only clear leases THIS instance holds. Also fence the still-running turn —
1178
+ // the next pod may re-lease immediately, so this turn's eventual settle must become a no-op
1179
+ // echo instead of clobbering whatever the new owner writes (see settleRun's guard).
1180
+ this.leaseLostRuns.add(runId);
1181
+ await this.store.releaseRunLock(runId, this.instanceId);
1182
+ }
1183
+ catch {
1184
+ /* best-effort: drain() must never throw because of this */
1185
+ }
845
1186
  }
846
1187
  }
847
1188
  /**
@@ -853,21 +1194,24 @@ export class WorkflowEngine {
853
1194
  for (const reg of new Set(this.latest.values())) {
854
1195
  if (reg.executionTimeoutMs == null)
855
1196
  continue;
856
- const deadline = now - reg.executionTimeoutMs;
857
- const inflight = [
858
- ...(await this.store.listRuns({
859
- workflow: reg.name,
860
- status: 'running',
861
- namespace: this.namespace,
862
- })),
863
- ...(await this.store.listRuns({
864
- workflow: reg.name,
865
- status: 'suspended',
866
- namespace: this.namespace,
867
- })),
868
- ];
1197
+ // Push the age predicate into the store: only candidates already past the LATEST version's
1198
+ // timeout come back (bounded), instead of every in-flight run of the workflow per tick. A
1199
+ // run pinned to an older version with a SHORTER timeout is swept a little later (when it
1200
+ // crosses this bound) — the per-run check below still applies its own version's timeout.
1201
+ const inflight = await this.store.listRuns({
1202
+ workflow: reg.name,
1203
+ statuses: ['running', 'suspended'],
1204
+ namespace: this.namespace,
1205
+ createdBefore: now - reg.executionTimeoutMs,
1206
+ limit: 200,
1207
+ });
869
1208
  for (const run of inflight) {
870
- if (run.createdAt.getTime() > deadline)
1209
+ // Time out against the version the run STARTED on when that registration is known — a fleet
1210
+ // mid-deploy must not sweep old-version runs by the newest version's (possibly shorter)
1211
+ // timeout. Falls back to the latest registration's timeout for an unknown version.
1212
+ const timeoutMs = this.workflows.get(versionKey(run.workflow, run.workflowVersion))?.executionTimeoutMs ??
1213
+ reg.executionTimeoutMs;
1214
+ if (timeoutMs == null || run.createdAt.getTime() > now - timeoutMs)
871
1215
  continue;
872
1216
  const error = { message: 'execution timeout', code: 'execution_timeout' };
873
1217
  // Conditional on the exact statuses just queried: an await separates this loop's iterations
@@ -888,6 +1232,20 @@ export class WorkflowEngine {
888
1232
  namespace: run.namespace,
889
1233
  error,
890
1234
  });
1235
+ // A sweep is a cancellation — give it `cancel()`'s full semantics, not just the status write:
1236
+ // cascade to the child subtree (they'd otherwise keep running, orphaned but live), tell the
1237
+ // instance/worker actually executing the run to abort cooperatively, wake a waiting parent
1238
+ // (it consumes this like a failed child instead of hanging to its reconcile), and free a
1239
+ // singleton slot.
1240
+ await this.cancelChildren(run.id, undefined);
1241
+ this.notifyCancelled(run.id);
1242
+ if (this.controlPlane) {
1243
+ void this.controlPlane
1244
+ .publishControl({ kind: 'cancel', runId: run.id, from: this.instanceId })
1245
+ .catch(() => undefined);
1246
+ }
1247
+ this.notifyParent(run.id, { ok: false, error: error.message });
1248
+ this.trackEffect(this.singletons.wakeNext(run));
891
1249
  }
892
1250
  }
893
1251
  }
@@ -899,7 +1257,17 @@ export class WorkflowEngine {
899
1257
  if (this.draining)
900
1258
  return [];
901
1259
  const results = [];
902
- const incomplete = await asHeartbeat(() => this.store.listIncompleteRuns(this.namespace));
1260
+ // Ask the store for ORPHANS only (running + free/expired lease, bounded): on a healthy fleet
1261
+ // that's ~zero rows per tick, instead of fetching every running run and issuing one doomed
1262
+ // lock-probe UPDATE per row per worker per second. Falls back to the old full-list + in-process
1263
+ // filter for a custom store without the optimized method.
1264
+ const incomplete = await asHeartbeat(async () => {
1265
+ const nowMs = this.clock();
1266
+ if (this.store.listOrphanedRuns) {
1267
+ return this.store.listOrphanedRuns(nowMs, 100, this.namespace);
1268
+ }
1269
+ return (await this.store.listIncompleteRuns(this.namespace)).filter((r) => r.lockedUntil === undefined || r.lockedUntil <= nowMs);
1270
+ });
903
1271
  for (const run of incomplete) {
904
1272
  // A live worker renews its lease, so an acquirable lease means the run is genuinely orphaned
905
1273
  // (its worker crashed). Skip the ones still owned.
@@ -914,8 +1282,20 @@ export class WorkflowEngine {
914
1282
  }
915
1283
  // Re-enqueue rather than resume inline: recovery must NOT block (boot, or a poll tick) on a
916
1284
  // long workflow step. A dispatcher/worker re-runs it, replaying its checkpoints.
917
- await this.store.releaseRunLock(run.id);
918
- await this.store.updateRun(run.id, { status: 'pending', updatedAt: new Date() });
1285
+ //
1286
+ // Conditional on still-`running`, WHILE the lease is held: a redelivered worker result (or any
1287
+ // other resume path) could have driven this run to a terminal state between the listing above
1288
+ // and here — an unconditional write would clobber `completed` back to `pending` and re-execute
1289
+ // the whole run (double `run.completed`, double parent notify). Same reasoning as every settle
1290
+ // path's SETTLE_ELIGIBLE_STATUSES guard. The lock is released only AFTER the flip (owner-scoped,
1291
+ // so a takeover can't be wiped), closing the release→flip gap the old order left open.
1292
+ const applied = await this.store.updateRunIf(run.id, ['running'], {
1293
+ status: 'pending',
1294
+ updatedAt: new Date(),
1295
+ });
1296
+ await this.store.releaseRunLock(run.id, this.instanceId);
1297
+ if (!applied)
1298
+ continue;
919
1299
  await this.runDispatcher.dispatch(run.id);
920
1300
  results.push({ runId: run.id, status: 'pending' });
921
1301
  }
@@ -934,7 +1314,7 @@ export class WorkflowEngine {
934
1314
  code: 'max_recovery_attempts',
935
1315
  };
936
1316
  await this.store.updateRun(run.id, { status: 'dead', error, updatedAt: new Date() });
937
- await this.store.releaseRunLock(run.id);
1317
+ await this.store.releaseRunLock(run.id, this.instanceId);
938
1318
  this.emit({
939
1319
  type: 'run.failed',
940
1320
  runId: run.id,
@@ -957,7 +1337,8 @@ export class WorkflowEngine {
957
1337
  * not due re-suspends cheaply without running new work.
958
1338
  */
959
1339
  async resumeDueTimers(nowMs = this.clock()) {
960
- const due = await asHeartbeat(() => this.store.listDueTimers(nowMs, this.namespace));
1340
+ // Capped per tick (a backlog drains over several polls) — a store may ignore the cap.
1341
+ const due = await asHeartbeat(() => this.store.listDueTimers(nowMs, this.namespace, 200));
961
1342
  const blocked = await this.dueBlockedRuns(nowMs);
962
1343
  return this.resumeLeased([...due, ...blocked], nowMs);
963
1344
  }
@@ -1187,10 +1568,15 @@ export class WorkflowEngine {
1187
1568
  resolve(toResult(run));
1188
1569
  };
1189
1570
  const check = () => {
1190
- void this.store.getRun(runId).then((run) => {
1571
+ // Swallow a transient store error: the next settling event re-checks, and the timeout still
1572
+ // bounds the wait. Unhandled, one hiccup here was a process-fatal unhandled rejection.
1573
+ void this.store
1574
+ .getRun(runId)
1575
+ .then((run) => {
1191
1576
  if (run && shouldResolve(run.status))
1192
1577
  finish(run);
1193
- });
1578
+ })
1579
+ .catch(() => undefined);
1194
1580
  };
1195
1581
  // React only to this run's settling events (not its every step event), and subscribe BEFORE the
1196
1582
  // initial read so a run that settles in between isn't missed.
@@ -1211,14 +1597,26 @@ export class WorkflowEngine {
1211
1597
  if (this.draining)
1212
1598
  return []; // shutting down — don't pick up new runs
1213
1599
  const results = [];
1214
- for (const run of runs) {
1215
- const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
1216
- if (!acquired)
1217
- continue;
1218
- // A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
1219
- const settled = onLocked ? await onLocked(run) : undefined;
1220
- results.push(settled ?? (await this.resume(run.id)));
1221
- }
1600
+ // Bounded parallelism: one slow turn (a long local step) must not serialize the other 99 picked
1601
+ // up this tick behind it. The lease acquire stays inside each task, so exactly-one-executor is
1602
+ // unchanged; 8 concurrent turns is enough to hide one straggler without stampeding the store.
1603
+ const CONCURRENCY = 8;
1604
+ let next = 0;
1605
+ const worker = async () => {
1606
+ for (;;) {
1607
+ const run = runs[next];
1608
+ next += 1;
1609
+ if (!run)
1610
+ return;
1611
+ const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
1612
+ if (!acquired)
1613
+ continue;
1614
+ // A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
1615
+ const settled = onLocked ? await onLocked(run) : undefined;
1616
+ results.push(settled ?? (await this.resume(run.id)));
1617
+ }
1618
+ };
1619
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, runs.length) }, () => worker()));
1222
1620
  return results;
1223
1621
  }
1224
1622
  /**
@@ -1397,7 +1795,14 @@ export class WorkflowEngine {
1397
1795
  notifyParent(runId, completion) {
1398
1796
  // Tracked, not bare fire-and-forget: `signal` can wake a suspended parent and resume it, issuing
1399
1797
  // store writes that must be awaited by `drain()` even though the settling child returned already.
1400
- this.trackEffect(this.signal(`child:${runId}`, completion));
1798
+ // One retry + a warning before giving up: a transiently-failed delivery here used to vanish into
1799
+ // trackEffect's swallow, stranding the parent until its reconcile wake. The parent's `ctx.child`
1800
+ // re-registration now also RE-DERIVES a terminal child's completion from the run row itself, so
1801
+ // a lost notify self-heals — but that costs a reconcile interval; don't lose it silently.
1802
+ const deliver = (token) => this.signal(token, completion).catch(() => this.signal(token, completion).catch((err) => {
1803
+ console.warn(`[adonis-durable] failed to deliver child completion on ${token} (the parent recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
1804
+ }));
1805
+ this.trackEffect(deliver(`child:${runId}`));
1401
1806
  // A fix-and-replay run (`<origin>~retry~<hash>`) is standalone, but its SUCCESS is the
1402
1807
  // origin's outcome for all practical purposes: deliver it on the ORIGIN's token too, so a
1403
1808
  // parent that failed on that child and is retried later consumes this success (buffered or
@@ -1405,7 +1810,7 @@ export class WorkflowEngine {
1405
1810
  // fix attempt must not poison the origin's token.
1406
1811
  const at = runId.lastIndexOf('~retry~');
1407
1812
  if (at !== -1 && completion.ok) {
1408
- this.trackEffect(this.signal(`child:${runId.slice(0, at)}`, completion));
1813
+ this.trackEffect(deliver(`child:${runId.slice(0, at)}`));
1409
1814
  }
1410
1815
  }
1411
1816
  /**
@@ -1454,6 +1859,11 @@ export class WorkflowEngine {
1454
1859
  // (its lease acquire fails and it no-ops); the broadcast tells that worker to abort cooperatively.
1455
1860
  if (opts?.compensate && (run.status === 'suspended' || run.status === 'running')) {
1456
1861
  this.cancelRequested.add(runId);
1862
+ // DURABLE cancel intent: the in-memory flag above only survives on this pod, this boot. The
1863
+ // buffered `cancel:<id>` marker is what lets the compensating unwind survive a crash or be
1864
+ // picked up by ANOTHER pod (its resume replays into the WorkflowSuspended catch, which checks
1865
+ // the marker when the flag is absent). Consumed there once `cancelled` is durably recorded.
1866
+ await this.store.bufferSignal(`cancel:${runId}`, true).catch(() => undefined);
1457
1867
  if (this.controlPlane) {
1458
1868
  void this.controlPlane
1459
1869
  .publishControl({ kind: 'cancel', runId, from: this.instanceId })
@@ -1540,9 +1950,15 @@ export class WorkflowEngine {
1540
1950
  */
1541
1951
  async getRunChildren(parentRunId) {
1542
1952
  const childIds = new Set();
1543
- for (const w of await this.store.listSignalWaiters('child:')) {
1544
- if (w.runId === parentRunId)
1953
+ // Targeted per-run lookup when the store has it (an indexed `WHERE run_id`); the prefix scan
1954
+ // fallback walks EVERY `child:` waiter row for one parent.
1955
+ const waiters = this.store.listSignalWaitersByRunIds
1956
+ ? await this.store.listSignalWaitersByRunIds([parentRunId])
1957
+ : await this.store.listSignalWaiters('child:');
1958
+ for (const w of waiters) {
1959
+ if (w.runId === parentRunId && w.token.startsWith('child:')) {
1545
1960
  childIds.add(w.token.slice('child:'.length));
1961
+ }
1546
1962
  }
1547
1963
  // Targeted read: only the `signal:child:` / `spawn:` checkpoints, not the whole history. Falls
1548
1964
  // back to a full listCheckpoints + in-JS prefix scan for a custom store that omits the method.
@@ -1569,6 +1985,20 @@ export class WorkflowEngine {
1569
1985
  listSignalWaiters(prefix) {
1570
1986
  return this.store.listSignalWaiters(prefix);
1571
1987
  }
1988
+ /**
1989
+ * Targeted per-run waiter lookup (`run_id IN (...)`) for the dashboard's page-scoped `waiting`
1990
+ * stamp. Prefers the store's indexed method; falls back to filtering the full scan for a custom
1991
+ * store without it.
1992
+ */
1993
+ async listSignalWaitersByRunIds(runIds) {
1994
+ if (runIds.length === 0)
1995
+ return [];
1996
+ if (this.store.listSignalWaitersByRunIds) {
1997
+ return this.store.listSignalWaitersByRunIds(runIds);
1998
+ }
1999
+ const ids = new Set(runIds);
2000
+ return (await this.store.listSignalWaiters('')).filter((w) => ids.has(w.runId));
2001
+ }
1572
2002
  /** The worker groups this engine dispatches to: every registered remote workflow's group, plus any
1573
2003
  * `extra` the caller declares. Local-step groups (a group consumed by in-process `@DurableStep`
1574
2004
  * workers, e.g. `pipeline`) aren't derivable from registrations — pass them via `extra` so a group
@@ -1588,13 +2018,10 @@ export class WorkflowEngine {
1588
2018
  * (only the BullMQ transport implements `groupHealth`). */
1589
2019
  async workerHealth(extra = []) {
1590
2020
  const groups = new Set([...this.knownGroups(extra), ...(await this.pool.listWorkerGroups())]);
1591
- const out = [];
1592
- for (const group of groups) {
1593
- const health = await this.pool.groupHealth(group);
1594
- if (health)
1595
- out.push(health);
1596
- }
1597
- return out;
2021
+ // One round per group, in parallel — the dashboard polls this, and each group's health is an
2022
+ // independent SCAN + job-count query; serial rounds made /workers latency scale linearly.
2023
+ const healths = await Promise.all([...groups].map((group) => this.pool.groupHealth(group)));
2024
+ return healths.filter((h) => h != null);
1598
2025
  }
1599
2026
  /**
1600
2027
  * Persist a streamed local-step lifecycle event from a remote workflow worker (see
@@ -1756,8 +2183,17 @@ export class WorkflowEngine {
1756
2183
  }
1757
2184
  }
1758
2185
  const renew = setInterval(() => {
2186
+ // A renew that returns FALSE means the lease was taken over (this turn stalled past expiry
2187
+ // and another instance reclaimed the run) — this executor is now a zombie. Fence it: mark
2188
+ // the run so its eventual settle degrades to a no-op echo instead of clobbering the new
2189
+ // owner's writes. A renew that THROWS (transient store error) is not evidence of takeover,
2190
+ // so it stays swallowed — the next interval retries.
1759
2191
  void this.store
1760
2192
  .renewRunLock(run.id, this.instanceId, this.clock() + this.leaseMs)
2193
+ .then((renewed) => {
2194
+ if (!renewed)
2195
+ this.leaseLostRuns.add(run.id);
2196
+ })
1761
2197
  .catch(() => undefined);
1762
2198
  }, Math.max(50, Math.floor(this.leaseMs / 2)));
1763
2199
  renew.unref?.();
@@ -1782,6 +2218,7 @@ export class WorkflowEngine {
1782
2218
  }
1783
2219
  finally {
1784
2220
  clearInterval(renew);
2221
+ this.leaseLostRuns.delete(run.id);
1785
2222
  }
1786
2223
  }
1787
2224
  /**
@@ -1798,6 +2235,13 @@ export class WorkflowEngine {
1798
2235
  * keeps its own lease handling (the TS executor's `finally`, the remote path's caller).
1799
2236
  */
1800
2237
  async settleRun(run, outcome) {
2238
+ // Zombie fence: this turn's lease was lost mid-execution (renew returned false, or drain
2239
+ // released it on timeout) — another instance owns and is (re)executing the run. This outcome
2240
+ // was computed by a stale executor; report the store's current state instead of writing. The
2241
+ // status-CAS below would NOT catch this case: the new owner's run is legitimately back in a
2242
+ // settle-eligible status while it executes.
2243
+ if (this.leaseLostRuns.has(run.id))
2244
+ return this.echoCurrentStatus(run.id);
1801
2245
  const updatedAt = new Date();
1802
2246
  if (outcome.kind === 'completed') {
1803
2247
  // This outcome was computed by a turn that started from a possibly-stale run snapshot. If the
@@ -1825,8 +2269,15 @@ export class WorkflowEngine {
1825
2269
  return { runId: run.id, status: 'completed', output: outcome.output };
1826
2270
  }
1827
2271
  if (outcome.kind === 'failed') {
1828
- // Same race as the `completed` branch above — guard identically.
1829
- const applied = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES, {
2272
+ // Same race as the `completed` branch above — guard identically. ADDITIONALLY exclude
2273
+ // `failed`: a stray resume of an already-failed run (a redelivered signal, the settle-recheck
2274
+ // re-drive) replays and deterministically re-fails — re-applying the settle would re-emit
2275
+ // `run.failed` and RE-NOTIFY the parent, whose waiter the first notify already consumed, so
2276
+ // the duplicate gets BUFFERED as a stale failure that poisons a later retry's rendezvous
2277
+ // (observed: a cascade-requeued child's fresh success lost to the stale buffered failure). A
2278
+ // legitimate retry always transitions through `pending`/`running` first (requeue flips the
2279
+ // status), so it is unaffected.
2280
+ const applied = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES.filter((s) => s !== 'failed'), {
1830
2281
  status: 'failed',
1831
2282
  error: outcome.error,
1832
2283
  updatedAt,
@@ -2177,10 +2628,48 @@ export class WorkflowEngine {
2177
2628
  // dashboard renders the fan as one parallel group. Undefined for a lone (non-fan) child.
2178
2629
  parallelGroup: cmd.parallelGroup,
2179
2630
  });
2180
- if (!(await this.store.getRun(childId))) {
2181
- // Same inheritance rule as the in-process `ctx.child` path: the child belongs to the run
2182
- // that spawned it, not to whichever engine happened to advance that run.
2183
- this.startChildDeferred(cmd.workflow, cmd.input, childId, { namespace: run.namespace });
2631
+ // Lost-wake guard (mirrors the in-process `ctx.child`/`ctx.all`): the child may have ALREADY
2632
+ // finished while no waiter was registered — consume its buffered completion, or re-derive the
2633
+ // completion from the child's own terminal run row when even the buffer was lost (crash
2634
+ // between the child's terminal write and its parent notify). Without this the re-registered
2635
+ // waiter waits forever on a signal that already fired.
2636
+ const deliverChild = async (payload) => {
2637
+ await this.store.removeSignalWaiter({
2638
+ token: `child:${childId}`,
2639
+ runId: run.id,
2640
+ seq: cmd.seq,
2641
+ });
2642
+ await this.store.saveCheckpoint(instantCheckpoint({
2643
+ runId: run.id,
2644
+ seq: cmd.seq,
2645
+ name: `signal:child:${childId}`,
2646
+ kind: 'signal',
2647
+ output: payload,
2648
+ parallelGroup: cmd.parallelGroup,
2649
+ }));
2650
+ // Re-drive on a macrotask, AFTER this turn suspends and frees the run lock (same reorder
2651
+ // as the buffered `waitSignal` delivery above).
2652
+ setTimeout(() => void this.resume(run.id).catch(() => undefined), 0);
2653
+ };
2654
+ const bufferedChild = await this.store.takeBufferedSignal(`child:${childId}`);
2655
+ if (bufferedChild) {
2656
+ await deliverChild(bufferedChild.payload);
2657
+ }
2658
+ else {
2659
+ const child = await this.store.getRun(childId);
2660
+ if (!child) {
2661
+ // Same inheritance rule as the in-process `ctx.child` path: the child belongs to the run
2662
+ // that spawned it, not to whichever engine happened to advance that run.
2663
+ this.startChildDeferred(cmd.workflow, cmd.input, childId, { namespace: run.namespace });
2664
+ }
2665
+ else if (child.status === 'completed' ||
2666
+ child.status === 'failed' ||
2667
+ child.status === 'cancelled' ||
2668
+ child.status === 'dead') {
2669
+ await deliverChild(child.status === 'completed'
2670
+ ? { ok: true, value: child.output }
2671
+ : { ok: false, error: child.error?.message ?? child.status });
2672
+ }
2184
2673
  }
2185
2674
  }
2186
2675
  else {
@@ -2215,7 +2704,7 @@ export class WorkflowEngine {
2215
2704
  workflow: run.workflow,
2216
2705
  namespace: run.namespace,
2217
2706
  });
2218
- await this.store.releaseRunLock(run.id);
2707
+ await this.store.releaseRunLock(run.id, this.instanceId);
2219
2708
  return { runId: run.id, status: 'suspended' };
2220
2709
  }
2221
2710
  // Saga compensations registered by completed steps; run in reverse if the run later fails.
@@ -2239,9 +2728,22 @@ export class WorkflowEngine {
2239
2728
  }
2240
2729
  catch (err) {
2241
2730
  if (err instanceof ContinueAsNew) {
2242
- // Hand off to a fresh execution with a clean history: complete this run, then start the next
2243
- // (`<id>~N`) with the new input. Deferred + idempotent by the continuation id, so a crash
2244
- // mid-handoff re-derives the same next run instead of forking.
2731
+ // Hand off to a fresh execution with a clean history: persist the next run (`<id>~N`), then
2732
+ // complete this one. Zombie fence first — a fenced turn must not fork a continuation either.
2733
+ if (this.leaseLostRuns.has(run.id))
2734
+ return this.echoCurrentStatus(run.id);
2735
+ const nextId = nextContinuationId(run.id);
2736
+ // Persist the continuation BEFORE the parent's terminal write — the durable ordering that
2737
+ // makes the chain crash-safe. The old order (complete parent, then create the continuation
2738
+ // from an in-memory deferred start) left a window where a hard crash (SIGKILL/OOM) lost the
2739
+ // chain forever: a `completed` parent with no continuation is indistinguishable from a
2740
+ // normally-finished run, and no recovery path re-derives the next link (recoverIncomplete
2741
+ // only scans `running`). With the continuation persisted first, a crash in the gap leaves
2742
+ // the parent `running` — recovery replays it, ContinueAsNew re-throws, and this createRun
2743
+ // converges on the already-persisted continuation (idempotent by the continuation id).
2744
+ // `dispatch: false`: the tracked handoff below owns the pickup. The continuation inherits
2745
+ // the parent RUN's namespace so a tenant's chain stays in its worker-pool partition.
2746
+ await this.startCore(run.workflow, err.input, nextId, { namespace: run.namespace }, false);
2245
2747
  await this.store.updateRun(run.id, {
2246
2748
  status: 'completed',
2247
2749
  output: undefined,
@@ -2255,11 +2757,11 @@ export class WorkflowEngine {
2255
2757
  namespace: run.namespace,
2256
2758
  });
2257
2759
  this.notifyParent(run.id, { ok: true, value: undefined });
2258
- const nextId = nextContinuationId(run.id);
2259
2760
  // Tracked handoff (see {@link handoffRun}): held in `postSettle` from this settle until the
2260
2761
  // continuation has entered `inflight` and settled, so `drain()` can't return before the next
2261
- // run's store writes land. Still deferred + idempotent by the continuation id.
2262
- this.handoffRun(run.workflow, err.input, nextId);
2762
+ // run's store writes land. Still deferred; its startCore call converges on the run persisted
2763
+ // above (prior-run branch) and drives the pickup.
2764
+ this.handoffRun(run.workflow, err.input, nextId, { namespace: run.namespace });
2263
2765
  return { runId: run.id, status: 'completed' };
2264
2766
  }
2265
2767
  if (err instanceof WorkflowBlocked) {
@@ -2271,15 +2773,37 @@ export class WorkflowEngine {
2271
2773
  if (err instanceof WorkflowSuspended) {
2272
2774
  // A compensating cancel resumed this run to reach here: the replay re-registered the saga,
2273
2775
  // so undo the completed steps in reverse and mark it cancelled instead of re-suspending.
2274
- if (this.cancelRequested.has(run.id)) {
2275
- this.cancelRequested.delete(run.id);
2776
+ // The intent lives in TWO places: the in-memory `cancelRequested` fast path (same pod, same
2777
+ // boot), and the DURABLE buffered `cancel:<id>` marker `cancel()` persisted — so a crash or
2778
+ // a cross-pod handoff mid-cancellation can't silently forget the cancel and settle the run
2779
+ // back to `suspended` as if nothing happened. Peek the marker (take + re-buffer) rather than
2780
+ // consume it: it must survive a crash mid-unwind; it's drained below once `cancelled` is
2781
+ // durably recorded.
2782
+ let cancelling = this.cancelRequested.delete(run.id);
2783
+ if (!cancelling) {
2784
+ const marker = await this.store.takeBufferedSignal(`cancel:${run.id}`);
2785
+ if (marker) {
2786
+ cancelling = true;
2787
+ await this.store
2788
+ .bufferSignal(`cancel:${run.id}`, marker.payload)
2789
+ .catch(() => undefined);
2790
+ }
2791
+ }
2792
+ if (cancelling) {
2276
2793
  for (let i = compensations.length - 1; i >= 0; i -= 1) {
2277
2794
  const comp = compensations[i];
2278
2795
  if (comp)
2279
- await this.runCompensation(run, comp);
2796
+ await this.runCompensation(run, comp, i);
2280
2797
  }
2281
2798
  const error = { message: 'cancelled' };
2282
2799
  await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
2800
+ // The cancellation is durably recorded — consume the marker(s) so a later retry of this
2801
+ // run id doesn't get spuriously re-cancelled.
2802
+ for (;;) {
2803
+ const m = await this.store.takeBufferedSignal(`cancel:${run.id}`).catch(() => null);
2804
+ if (!m)
2805
+ break;
2806
+ }
2283
2807
  this.emit({
2284
2808
  type: 'run.failed',
2285
2809
  runId: run.id,
@@ -2289,7 +2813,16 @@ export class WorkflowEngine {
2289
2813
  });
2290
2814
  return { runId: run.id, status: 'cancelled', error };
2291
2815
  }
2292
- return this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
2816
+ const settled = await this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
2817
+ // Close the settle-vs-delivery race: a signal/result that landed WHILE this turn was still
2818
+ // executing wrote its checkpoint durably, but its resume no-oped against our held lease —
2819
+ // and this settle just parked the run on nothing but the reconcile fallback (or nothing at
2820
+ // all with reconcileMs: 0). Re-check the seqs this suspension waits on and re-drive if one
2821
+ // already settled.
2822
+ if (settled.status === 'suspended' && err.waitSeqs?.length) {
2823
+ this.recheckWaitSeqs(run.id, err.waitSeqs);
2824
+ }
2825
+ return settled;
2293
2826
  }
2294
2827
  const error = {
2295
2828
  message: err instanceof Error ? err.message : String(err),
@@ -2303,14 +2836,15 @@ export class WorkflowEngine {
2303
2836
  const comp = compensations[i];
2304
2837
  if (!comp)
2305
2838
  continue;
2306
- await this.runCompensation(run, comp);
2839
+ await this.runCompensation(run, comp, i);
2307
2840
  }
2308
2841
  return this.settleRun(run, { kind: 'failed', error });
2309
2842
  }
2310
2843
  finally {
2311
2844
  // Release the recovery lease once the run reaches a terminal/suspended state, so the
2312
- // next instance (or the timer poller) can pick it up promptly.
2313
- await this.store.releaseRunLock(run.id);
2845
+ // next instance (or the timer poller) can pick it up promptly. Owner-scoped: if this turn's
2846
+ // lease was already taken over (zombie executor), the new owner's lease is left untouched.
2847
+ await this.store.releaseRunLock(run.id, this.instanceId);
2314
2848
  }
2315
2849
  }
2316
2850
  /**
@@ -2319,17 +2853,39 @@ export class WorkflowEngine {
2319
2853
  * compensation is skipped so it can't mask the original failure. Handles both compensation shapes:
2320
2854
  * a LOCAL `fn` (run in-process) and a DISPATCHED undo (an ordinary step def sent to a worker and
2321
2855
  * awaited inline — the worker serving its name runs it with the {@link StepUndo} envelope).
2322
- */
2323
- async runCompensation(run, comp) {
2856
+ *
2857
+ * DURABLE: each compensation owns a reserved NEGATIVE checkpoint seq (`-2 - idx`, disjoint from
2858
+ * the positional replay's `>= 0` space and the legacy `-1`), written `pending` before a dispatch
2859
+ * and settled on its outcome. A re-driven unwind — crash mid-unwind, a lease takeover replaying
2860
+ * the failing body back into this catch, a worker result consumed by another pod — therefore
2861
+ * SKIPS undos already done and RESUMES the attempt count, instead of re-running the whole unwind
2862
+ * blind from in-memory state that no longer exists.
2863
+ */
2864
+ async runCompensation(run, comp, idx) {
2324
2865
  const name = `compensate:${comp.name}`;
2325
- for (let attempt = 1; attempt <= this.compensationRetries; attempt += 1) {
2866
+ const seq = COMPENSATION_SEQ_BASE - idx;
2867
+ const existing = await this.store.getCheckpoint(run.id, seq);
2868
+ // Already undone by a previous drive of this unwind (possibly on another pod) — skip.
2869
+ if (existing?.status === 'completed')
2870
+ return;
2871
+ const priorAttempts = existing?.attempts ?? 0;
2872
+ // Permanently failed and already reported — skip (never mask the original failure).
2873
+ if (existing?.status === 'failed' && priorAttempts >= this.compensationRetries)
2874
+ return;
2875
+ // A `pending` row re-enters AT its recorded attempt (await the in-flight dispatch rather than
2876
+ // blindly re-issuing); a `failed` row resumes the count after its recorded attempts.
2877
+ const firstAttempt = existing?.status === 'pending' ? Math.max(1, priorAttempts) : priorAttempts + 1;
2878
+ for (let attempt = firstAttempt; attempt <= this.compensationRetries; attempt += 1) {
2326
2879
  const startedAt = Date.now();
2327
2880
  try {
2328
2881
  if ('fn' in comp) {
2329
2882
  await comp.fn();
2883
+ // Persist the completed undo so a re-driven unwind never re-runs it — crash-safe skipping
2884
+ // instead of leaning solely on the "compensations are idempotent" contract.
2885
+ await this.store.saveCheckpoint(instantCheckpoint({ runId: run.id, seq, name, kind: 'local' }));
2330
2886
  }
2331
2887
  else {
2332
- await this.dispatchCompensation(run, comp.dispatch.def, comp.dispatch.args, attempt);
2888
+ await this.dispatchCompensation(run, comp.dispatch.def, comp.dispatch.args, attempt, seq);
2333
2889
  }
2334
2890
  this.emit({
2335
2891
  type: 'step.completed',
@@ -2342,6 +2898,24 @@ export class WorkflowEngine {
2342
2898
  return;
2343
2899
  }
2344
2900
  catch (err) {
2901
+ // Record the failed attempt (best-effort) so a re-driven unwind resumes the count.
2902
+ const error = { message: err instanceof Error ? err.message : String(err) };
2903
+ const at = new Date();
2904
+ await this.store
2905
+ .saveCheckpoint({
2906
+ runId: run.id,
2907
+ seq,
2908
+ name,
2909
+ kind: 'fn' in comp ? 'local' : 'remote',
2910
+ stepId: `${run.id}:compensate:${comp.name}:${attempt}`,
2911
+ status: 'failed',
2912
+ attempts: attempt,
2913
+ error,
2914
+ enqueuedAt: existing?.enqueuedAt ?? at,
2915
+ startedAt: at,
2916
+ finishedAt: at,
2917
+ })
2918
+ .catch(() => undefined);
2345
2919
  if (attempt >= this.compensationRetries) {
2346
2920
  this.emit({
2347
2921
  type: 'step.failed',
@@ -2349,7 +2923,7 @@ export class WorkflowEngine {
2349
2923
  workflow: run.workflow,
2350
2924
  name,
2351
2925
  kind: 'local',
2352
- error: { message: err instanceof Error ? err.message : String(err) },
2926
+ error,
2353
2927
  durationMs: Date.now() - startedAt,
2354
2928
  });
2355
2929
  }
@@ -2357,42 +2931,146 @@ export class WorkflowEngine {
2357
2931
  }
2358
2932
  }
2359
2933
  /**
2360
- * Dispatch a saga undo step to a worker and await its result INLINE (the saga unwind already runs
2361
- * outside the positional replay, so this is not checkpointed — it's a one-shot dispatch + await).
2362
- * Routes by the undo def's own name/partition token, applies its own optional input schema, and
2363
- * honours its own liveness `timeoutMs` via the heartbeat window. Throws on a worker-reported failure
2934
+ * Dispatch a saga undo step to a worker and await its result. Routes by the undo def's own
2935
+ * name/partition token, applies its own optional input schema, and honours its own liveness
2936
+ * `timeoutMs` via the heartbeat window (else the engine's `compensationTimeoutMs` bounds the
2937
+ * wait, so a lost result can't hold the unwind open forever). Throws on a worker-reported failure
2364
2938
  * or timeout so `runCompensation`'s retry/skip logic applies uniformly to both compensation shapes.
2939
+ *
2940
+ * The undo is CHECKPOINTED at its reserved negative `seq` (pending before dispatch, settled on the
2941
+ * outcome), which is what makes the await multi-pod-safe: a result popped by ANOTHER engine
2942
+ * instance sharing the results queue lands in its `completeRemoteResult`, settles this row, and
2943
+ * the checkpoint poll below observes it — the old in-memory-only waiter simply dropped that result
2944
+ * (`getCheckpoint(runId, -1)` → null) and hung the failing run's unwind forever.
2365
2945
  */
2366
- async dispatchCompensation(run, def, args, attempt) {
2946
+ async dispatchCompensation(run, def, args, attempt, seq) {
2367
2947
  if (this.pool.size === 0)
2368
2948
  throw new Error('dispatched compensation requires a Transport');
2369
2949
  const token = stepGroup(def, run.namespace);
2370
2950
  const validInput = def.input ? def.input.parse(args) : args;
2371
2951
  const id = `${run.id}:compensate:${def.name}:${attempt}`;
2952
+ const name = `compensate:${def.name}`;
2953
+ const existing = await this.store.getCheckpoint(run.id, seq);
2372
2954
  const resultPromise = new Promise((resolve, reject) => {
2373
2955
  this.pending.set(id, { resolve, reject });
2374
2956
  });
2957
+ const enqueuedAt = new Date();
2375
2958
  try {
2376
- await this.pool.dispatch({
2377
- runId: run.id,
2378
- seq: -1,
2379
- name: def.name,
2380
- stepId: id,
2381
- group: token,
2382
- input: validInput,
2383
- traceparent: this.traceparent?.(),
2384
- context: this.context?.(),
2385
- attempt: 1,
2386
- });
2387
- await (def.timeoutMs
2959
+ // A pending row already at (or past) this attempt means the dispatch itself already happened
2960
+ // (crash after dispatch, or a re-drive while the worker is mid-undo): re-await the in-flight
2961
+ // undo instead of double-issuing it.
2962
+ const alreadyDispatched = existing?.status === 'pending' && existing.attempts >= attempt;
2963
+ if (!alreadyDispatched) {
2964
+ // Persist the pending checkpoint BEFORE dispatching — the durable anchor for the cross-pod
2965
+ // settle and for re-drive resumption.
2966
+ await this.store.saveCheckpoint({
2967
+ runId: run.id,
2968
+ seq,
2969
+ name,
2970
+ kind: 'remote',
2971
+ stepId: id,
2972
+ status: 'pending',
2973
+ input: validInput,
2974
+ attempts: attempt,
2975
+ workerGroup: token,
2976
+ enqueuedAt,
2977
+ startedAt: enqueuedAt,
2978
+ finishedAt: enqueuedAt,
2979
+ });
2980
+ await this.pool.dispatch({
2981
+ runId: run.id,
2982
+ seq,
2983
+ name: def.name,
2984
+ stepId: id,
2985
+ group: token,
2986
+ input: validInput,
2987
+ traceparent: this.traceparent?.(),
2988
+ context: this.context?.(),
2989
+ attempt: 1,
2990
+ });
2991
+ }
2992
+ const awaited = def.timeoutMs
2388
2993
  ? this.awaitWithHeartbeat(id, resultPromise, def.timeoutMs)
2389
- : resultPromise);
2994
+ : resultPromise;
2995
+ await this.awaitCompensationOutcome(run.id, seq, name, id, token, attempt, awaited,
2996
+ // With a heartbeat window the def's own liveness handling governs; otherwise bound the wait.
2997
+ def.timeoutMs ? undefined : this.compensationTimeoutMs, enqueuedAt);
2390
2998
  }
2391
- catch (err) {
2999
+ finally {
2392
3000
  this.pending.delete(id);
2393
- throw err;
2394
3001
  }
2395
3002
  }
3003
+ /**
3004
+ * Resolve one dispatched undo attempt from EITHER source: the in-memory waiter (the result landed
3005
+ * on THIS pod — persist the settled row ourselves, since the waiter path bypasses
3006
+ * `completeRemoteResult`) or the checkpoint row (settled by another instance sharing the results
3007
+ * queue, where the in-memory waiter never fires). Bounded by `timeoutMs` when given. Resolves on a
3008
+ * completed undo; rejects on a worker-reported failure or timeout.
3009
+ */
3010
+ awaitCompensationOutcome(runId, seq, name, stepId, token, attempt, awaited, timeoutMs, enqueuedAt) {
3011
+ return new Promise((resolve, reject) => {
3012
+ let done = false;
3013
+ let timer;
3014
+ let poll;
3015
+ const cleanup = () => {
3016
+ if (poll)
3017
+ clearInterval(poll);
3018
+ if (timer)
3019
+ clearTimeout(timer);
3020
+ };
3021
+ const finish = (fn) => {
3022
+ if (done)
3023
+ return;
3024
+ done = true;
3025
+ cleanup();
3026
+ fn();
3027
+ };
3028
+ poll = setInterval(() => {
3029
+ void this.store
3030
+ .getCheckpoint(runId, seq)
3031
+ .then((cp) => {
3032
+ if (!cp || cp.attempts < attempt)
3033
+ return;
3034
+ if (cp.status === 'completed')
3035
+ finish(resolve);
3036
+ else if (cp.status === 'failed') {
3037
+ finish(() => reject(new RemoteStepError(cp.error)));
3038
+ }
3039
+ })
3040
+ .catch(() => undefined);
3041
+ }, 1_000);
3042
+ poll.unref?.();
3043
+ if (timeoutMs != null) {
3044
+ timer = setTimeout(() => {
3045
+ finish(() => reject(new RemoteStepError({
3046
+ message: `compensation "${name}" timed out after ${timeoutMs}ms with no worker result`,
3047
+ code: 'compensation_timeout',
3048
+ })));
3049
+ }, timeoutMs);
3050
+ timer.unref?.();
3051
+ }
3052
+ awaited.then((resolution) => {
3053
+ void this.store
3054
+ .saveCheckpoint({
3055
+ runId,
3056
+ seq,
3057
+ name,
3058
+ kind: 'remote',
3059
+ stepId,
3060
+ status: 'completed',
3061
+ output: resolution.output,
3062
+ events: resolution.events,
3063
+ attempts: attempt,
3064
+ workerGroup: token,
3065
+ enqueuedAt,
3066
+ startedAt: resolution.startedAt ? new Date(resolution.startedAt) : enqueuedAt,
3067
+ finishedAt: new Date(),
3068
+ })
3069
+ .catch(() => undefined)
3070
+ .then(() => finish(resolve));
3071
+ }, (err) => finish(() => reject(err)));
3072
+ });
3073
+ }
2396
3074
  /** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
2397
3075
  * (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
2398
3076
  * module and the engine stays the orchestrator. */
@@ -2586,15 +3264,18 @@ export class WorkflowEngine {
2586
3264
  * {@link resumeDueTimers}. Re-driving one re-checks the live fleet: it dispatches if a
2587
3265
  * capable+compatible worker has appeared, else re-parks with a fresh `wakeAt` (design §7.5). */
2588
3266
  async dueBlockedRuns(nowMs) {
2589
- const blocked = await asHeartbeat(() => this.store.listRuns({ statuses: ['blocked'] }));
2590
- // Unlike the store-side poll paths, this filters in memory, so the operator case is explicit:
2591
- // an engine with no namespace drives every pool's blocked runs, exactly as it drives their
2592
- // pending and due ones.
2593
- return blocked.filter((r) => (this.namespace === undefined ||
2594
- r.namespace === undefined ||
2595
- r.namespace === this.namespace) &&
2596
- r.wakeAt !== undefined &&
2597
- r.wakeAt <= nowMs);
3267
+ // Both predicates push into the store now (`wakeBefore` + namespace + a bound): only DUE
3268
+ // blocked runs come back, instead of every blocked run per tick filtered in memory. An engine
3269
+ // with no namespace still drives every pool's blocked runs (the filter is simply omitted); a
3270
+ // legacy run row without a namespace reads back as 'default' from every shipped store, so the
3271
+ // pushdown matches the old in-memory semantics.
3272
+ const blocked = await asHeartbeat(() => this.store.listRuns({
3273
+ statuses: ['blocked'],
3274
+ namespace: this.namespace,
3275
+ wakeBefore: nowMs,
3276
+ limit: 200,
3277
+ }));
3278
+ return blocked;
2598
3279
  }
2599
3280
  async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace) {
2600
3281
  // Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
@@ -2612,7 +3293,7 @@ export class WorkflowEngine {
2612
3293
  // checkpoint, and let the result resume the run on whichever instance receives it — so a worker
2613
3294
  // pod can scale down or crash mid-step without losing the run or re-running completed work.
2614
3295
  if (step.timeoutMs)
2615
- return this.callRemoteInMemory(runId, seq, step, input, transport, runNamespace);
3296
+ return this.callRemoteInMemory(runId, seq, step, input, transport, runNamespace, queue, admission);
2616
3297
  if (existing?.status === 'pending') {
2617
3298
  // Dispatched; normally we just keep waiting for the result to resume the run. But a LOST dispatch
2618
3299
  // (worker crashed with no result, or the transport dropped the job) would hang here forever — a
@@ -2621,17 +3302,17 @@ export class WorkflowEngine {
2621
3302
  // re-runs, its result resumes the run), bounded by `remoteRedispatchMax` so a never-settling step
2622
3303
  // fails instead of looping. Unset (default) keeps the by-design "re-suspend, never re-dispatch".
2623
3304
  if (this.remoteRedispatchMs == null)
2624
- throw new WorkflowSuspended();
3305
+ throw new WorkflowSuspended(undefined, [seq]);
2625
3306
  // Stamp a redispatch deadline (clock-space, persisted) the first time we see this pending step,
2626
3307
  // stable across replays and crashes — mirrors the failed-retry backoff below. The run's wakeAt
2627
3308
  // becomes this deadline, so a reconcile re-drive lands exactly when it's due to re-dispatch.
2628
3309
  if (existing.wakeAt == null) {
2629
3310
  const wakeAt = this.clock() + this.remoteRedispatchMs;
2630
3311
  await this.store.saveCheckpoint({ ...existing, wakeAt });
2631
- throw new WorkflowSuspended(wakeAt);
3312
+ throw new WorkflowSuspended(wakeAt, [seq]);
2632
3313
  }
2633
3314
  if (this.clock() < existing.wakeAt)
2634
- throw new WorkflowSuspended(existing.wakeAt);
3315
+ throw new WorkflowSuspended(existing.wakeAt, [seq]);
2635
3316
  // Past the deadline with no result — the dispatch is presumed lost. Re-dispatch (bounded by
2636
3317
  // `remoteRedispatchMax` so a step that never settles fails the run instead of looping forever).
2637
3318
  if (existing.attempts >= this.remoteRedispatchMax) {
@@ -2662,7 +3343,7 @@ export class WorkflowEngine {
2662
3343
  attempt: reAttempt,
2663
3344
  transport,
2664
3345
  });
2665
- throw new WorkflowSuspended(nextDeadline);
3346
+ throw new WorkflowSuspended(nextDeadline, [seq]);
2666
3347
  }
2667
3348
  // Durable retry: a failed attempt re-dispatches up to `retries`, spacing attempts by `backoff` —
2668
3349
  // unless the worker marked the error non-retryable (a deterministic verdict like a declined card).
@@ -2687,6 +3368,7 @@ export class WorkflowEngine {
2687
3368
  // Flow control: a queued call that can't be admitted (concurrency/rate) does NOT dispatch — the
2688
3369
  // run re-suspends with the queue's retry time and the timer poller re-tries admission later, so
2689
3370
  // the limit is durable. The admitted slot is released when the result lands (completeRemoteResult).
3371
+ let admittedQueue;
2690
3372
  if (queue && this.admission.handles(queue)) {
2691
3373
  // Admission carries the per-call priority + fairness key (default the runId so each run is its
2692
3374
  // own fairness bucket), and the stepId as a STABLE waiter id so the backend tracks one waiter
@@ -2706,6 +3388,7 @@ export class WorkflowEngine {
2706
3388
  }
2707
3389
  this.queueWaiters.get(queue)?.delete(runId);
2708
3390
  this.stepQueue.set(id, queue);
3391
+ admittedQueue = queue;
2709
3392
  }
2710
3393
  // Optional runtime schema: validate only when the def carries one (a bare `@Step()` / string-name
2711
3394
  // call carries none — the engine passes the input through untouched then).
@@ -2731,6 +3414,10 @@ export class WorkflowEngine {
2731
3414
  input: validInput,
2732
3415
  attempts: attempt,
2733
3416
  workerGroup: token,
3417
+ // Persist which flow-control queue this step's admitted slot belongs to, so the slot is
3418
+ // released by WHICHEVER instance receives the result — the in-memory stepQueue map only
3419
+ // covers the dispatching pod, and a slot released by no one starves the queue fleet-wide.
3420
+ queue: admittedQueue,
2734
3421
  enqueuedAt,
2735
3422
  startedAt: enqueuedAt, // placeholders until the worker result lands
2736
3423
  finishedAt: enqueuedAt,
@@ -2747,7 +3434,7 @@ export class WorkflowEngine {
2747
3434
  transport,
2748
3435
  });
2749
3436
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
2750
- throw new WorkflowSuspended();
3437
+ throw new WorkflowSuspended(undefined, [seq]);
2751
3438
  }
2752
3439
  /**
2753
3440
  * Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
@@ -2802,7 +3489,7 @@ export class WorkflowEngine {
2802
3489
  }
2803
3490
  // A result settling this step frees its flow-control slot (no-op if it wasn't queued). Done
2804
3491
  // before the terminal-run early-returns below, so a cancellation can't leak the slot.
2805
- await this.releaseQueueSlot(cp.stepId);
3492
+ await this.releaseQueueSlot(cp);
2806
3493
  // Drop a late result for a run that was cancelled/finished meanwhile — don't complete the step
2807
3494
  // or resume (the run is already terminal). This is the engine side of cooperative cancellation.
2808
3495
  if (run && (run.status === 'cancelled' || run.status === 'completed'))
@@ -2861,16 +3548,22 @@ export class WorkflowEngine {
2861
3548
  for (const runId of runIds)
2862
3549
  void this.resume(runId).catch(() => undefined);
2863
3550
  }
2864
- /** Release the flow-control slot a dispatched step held (if any), by its stepId. */
2865
- async releaseQueueSlot(id) {
2866
- const queue = this.stepQueue.get(id);
3551
+ /**
3552
+ * Release the flow-control slot a dispatched step held (if any). The queue comes from the
3553
+ * CHECKPOINT (durable — works on whichever instance receives the result), falling back to the
3554
+ * dispatching pod's in-memory map for checkpoints written before the `queue` column existed.
3555
+ * Exactly-once is anchored on the caller's pending→settled transition: only the delivery that
3556
+ * finds the checkpoint still `pending` releases.
3557
+ */
3558
+ async releaseQueueSlot(cp) {
3559
+ const queue = cp.queue ?? this.stepQueue.get(cp.stepId);
3560
+ this.stepQueue.delete(cp.stepId);
2867
3561
  if (queue === undefined)
2868
3562
  return;
2869
- this.stepQueue.delete(id);
2870
- await this.admission.release(queue, id);
3563
+ await this.admission.release(queue, cp.stepId);
2871
3564
  }
2872
3565
  /** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
2873
- async callRemoteInMemory(runId, seq, step, input, transport, runNamespace) {
3566
+ async callRemoteInMemory(runId, seq, step, input, transport, runNamespace, queue, admission) {
2874
3567
  if (this.pool.size === 0)
2875
3568
  throw new Error('remote steps require a Transport');
2876
3569
  const validInput = step.input ? step.input.parse(input) : input;
@@ -2879,6 +3572,39 @@ export class WorkflowEngine {
2879
3572
  // emit + dispatch, so a step nobody can run parks `blocked` instead of awaiting a phantom worker.
2880
3573
  await this.ensureRoutable(token, step.requires);
2881
3574
  const id = stepId(runId, seq);
3575
+ // Flow control — the same admission gate the durable path applies. A `{ queue }` on a
3576
+ // `timeoutMs` step used to be SILENTLY ignored (this path branched off before the gate), so the
3577
+ // documented cap simply didn't hold for liveness-timeout steps. The slot spans every retry
3578
+ // attempt of this call and is released in the `finally` below on THIS pod (the await lives
3579
+ // here); it is deliberately NOT persisted on the checkpoint — a cross-pod result consumer
3580
+ // releasing from the row would double-free against this finally.
3581
+ let admittedQueue;
3582
+ if (queue && this.admission.handles(queue)) {
3583
+ const decision = await this.admission.tryAdmit(queue, {
3584
+ priority: admission?.priority,
3585
+ key: admission?.fairnessKey ?? runId,
3586
+ waiterId: id,
3587
+ });
3588
+ if (!decision.ok) {
3589
+ const waiters = this.queueWaiters.get(queue) ?? new Set();
3590
+ waiters.add(runId);
3591
+ this.queueWaiters.set(queue, waiters);
3592
+ throw new WorkflowSuspended(decision.retryAt);
3593
+ }
3594
+ this.queueWaiters.get(queue)?.delete(runId);
3595
+ admittedQueue = queue;
3596
+ }
3597
+ try {
3598
+ return await this.callRemoteInMemoryAdmitted(runId, seq, step, validInput, token, id, transport);
3599
+ }
3600
+ finally {
3601
+ if (admittedQueue !== undefined) {
3602
+ await this.admission.release(admittedQueue, id).catch(() => undefined);
3603
+ }
3604
+ }
3605
+ }
3606
+ /** The dispatch/await/retry body of {@link callRemoteInMemory}, once routable and admitted. */
3607
+ async callRemoteInMemoryAdmitted(runId, seq, step, validInput, token, id, transport) {
2882
3608
  const enqueuedAt = new Date();
2883
3609
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
2884
3610
  // Retry policy differs from a LOCAL step on purpose: a local `ctx.step` retries any non-fatal
@@ -2991,7 +3717,9 @@ export class WorkflowEngine {
2991
3717
  // while RUNNING". Without the split, a dispatch-anchored window false-failed a healthy
2992
3718
  // single-concurrency fleet whose next batch queued ~15min behind the current one.
2993
3719
  let seenBeat = false;
2994
- const window = () => (seenBeat ? timeoutMs : (pickupTimeoutMs ?? timeoutMs));
3720
+ // Clamp to Node's setTimeout max (2³¹−1 ms): past it Node fires the timer ~immediately, which
3721
+ // would turn a generous liveness window into an instant RemoteStepTimeout.
3722
+ const window = () => Math.min(seenBeat ? timeoutMs : (pickupTimeoutMs ?? timeoutMs), MAX_BACKOFF_MS);
2995
3723
  const cleanup = () => {
2996
3724
  clearTimeout(timer);
2997
3725
  this.heartbeatResets.delete(id);