@adonis-agora/durable 0.51.1 → 0.52.1

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 (85) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/src/bounded-runner.d.ts +29 -0
  3. package/dist/src/bounded-runner.d.ts.map +1 -0
  4. package/dist/src/bounded-runner.js +79 -0
  5. package/dist/src/bounded-runner.js.map +1 -0
  6. package/dist/src/child-delivery.d.ts +14 -0
  7. package/dist/src/child-delivery.d.ts.map +1 -0
  8. package/dist/src/child-delivery.js +17 -0
  9. package/dist/src/child-delivery.js.map +1 -0
  10. package/dist/src/codec-state-store.d.ts +2 -0
  11. package/dist/src/codec-state-store.d.ts.map +1 -1
  12. package/dist/src/codec-state-store.js +5 -0
  13. package/dist/src/codec-state-store.js.map +1 -1
  14. package/dist/src/dashboard/index.d.ts +1 -1
  15. package/dist/src/dashboard/index.js +1 -1
  16. package/dist/src/engine.d.ts +95 -1
  17. package/dist/src/engine.d.ts.map +1 -1
  18. package/dist/src/engine.js +464 -39
  19. package/dist/src/engine.js.map +1 -1
  20. package/dist/src/index.d.ts +1 -1
  21. package/dist/src/index.js +1 -1
  22. package/dist/src/interfaces.d.ts +55 -0
  23. package/dist/src/interfaces.d.ts.map +1 -1
  24. package/dist/src/interfaces.js.map +1 -1
  25. package/dist/src/run-waiting.d.ts +5 -0
  26. package/dist/src/run-waiting.d.ts.map +1 -1
  27. package/dist/src/run-waiting.js +20 -0
  28. package/dist/src/run-waiting.js.map +1 -1
  29. package/dist/src/scheduler.d.ts +7 -1
  30. package/dist/src/scheduler.d.ts.map +1 -1
  31. package/dist/src/scheduler.js +47 -8
  32. package/dist/src/scheduler.js.map +1 -1
  33. package/dist/src/stores/lucid.d.ts +1 -0
  34. package/dist/src/stores/lucid.d.ts.map +1 -1
  35. package/dist/src/stores/lucid.js +7 -0
  36. package/dist/src/stores/lucid.js.map +1 -1
  37. package/dist/src/testing/in-memory-state-store.d.ts +1 -0
  38. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  39. package/dist/src/testing/in-memory-state-store.js +9 -0
  40. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  41. package/dist/src/testing-kit/conformance.d.ts +1 -0
  42. package/dist/src/testing-kit/conformance.d.ts.map +1 -1
  43. package/dist/src/testing-kit/conformance.js +1 -0
  44. package/dist/src/testing-kit/conformance.js.map +1 -1
  45. package/dist/src/testing-kit/run-dispatch-contract.d.ts +29 -0
  46. package/dist/src/testing-kit/run-dispatch-contract.d.ts.map +1 -0
  47. package/dist/src/testing-kit/run-dispatch-contract.js +472 -0
  48. package/dist/src/testing-kit/run-dispatch-contract.js.map +1 -0
  49. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  50. package/dist/src/testing-kit/state-store-conformance.js +14 -0
  51. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  52. package/dist/src/transports/bullmq/bullmq-transport.d.ts +20 -1
  53. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  54. package/dist/src/transports/bullmq/bullmq-transport.js +57 -1
  55. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  56. package/dist/src/transports/bullmq/deps.d.ts +6 -0
  57. package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
  58. package/dist/src/transports/bullmq/deps.js.map +1 -1
  59. package/dist/src/transports/bullmq/naming.d.ts +10 -0
  60. package/dist/src/transports/bullmq/naming.d.ts.map +1 -1
  61. package/dist/src/transports/bullmq/naming.js +15 -0
  62. package/dist/src/transports/bullmq/naming.js.map +1 -1
  63. package/dist/src/transports/db-schema.d.ts +2 -0
  64. package/dist/src/transports/db-schema.d.ts.map +1 -1
  65. package/dist/src/transports/db-schema.js +2 -0
  66. package/dist/src/transports/db-schema.js.map +1 -1
  67. package/dist/src/transports/db.d.ts +22 -1
  68. package/dist/src/transports/db.d.ts.map +1 -1
  69. package/dist/src/transports/db.js +104 -5
  70. package/dist/src/transports/db.js.map +1 -1
  71. package/dist/src/transports/queue-mock-adapter.d.ts +6 -1
  72. package/dist/src/transports/queue-mock-adapter.d.ts.map +1 -1
  73. package/dist/src/transports/queue-mock-adapter.js +7 -1
  74. package/dist/src/transports/queue-mock-adapter.js.map +1 -1
  75. package/dist/src/transports/queue.d.ts +18 -1
  76. package/dist/src/transports/queue.d.ts.map +1 -1
  77. package/dist/src/transports/queue.js +59 -0
  78. package/dist/src/transports/queue.js.map +1 -1
  79. package/dist/src/workflow-ctx.d.ts +1 -1
  80. package/dist/src/workflow-ctx.d.ts.map +1 -1
  81. package/dist/src/workflow-ctx.js +35 -12
  82. package/dist/src/workflow-ctx.js.map +1 -1
  83. package/package.json +2 -1
  84. package/skills/durable-reliability/SKILL.md +3 -2
  85. package/skills/durable-setup/SKILL.md +11 -7
@@ -1,7 +1,9 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { InMemoryAdmissionBackend } from './admission.js';
3
3
  import { backoffDelay, MAX_BACKOFF_MS } from './backoff.js';
4
+ import { BoundedRunner } from './bounded-runner.js';
4
5
  import { instantCheckpoint, lostStepEvent, stepCheckpoint } from './checkpoints.js';
6
+ import { CHILD_DELIVERED_TAG, markChildDelivered } from './child-delivery.js';
5
7
  import { completionError, failedCompletion, isFailedCompletion, } from './completion.js';
6
8
  import { assertConcurrency, concurrencyTag, countRuns, resolveConcurrency, } from './concurrency.js';
7
9
  import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
@@ -71,6 +73,13 @@ const SETTLE_ELIGIBLE_STATUSES = [
71
73
  /** How long a read of the onError routes a fleet announces is reused. */
72
74
  const ANNOUNCED_ROUTES_TTL_MS = 5_000;
73
75
  const versionKey = (name, version) => `${name}@${version}`;
76
+ /**
77
+ * The {@link RunDispatch.key} of a run put back to `pending` (a retry, a recovery): a new pending
78
+ * state, so it must never match the key of the run's earlier start, even within the same millisecond.
79
+ */
80
+ const pendingKey = (at) => `${at.getTime()}-${globalThis.crypto.randomUUID().slice(0, 8)}`;
81
+ /** Whether `status` is one a run never leaves on its own. */
82
+ const isTerminalRun = (status) => TERMINAL_RUN_STATUSES.includes(status);
74
83
  /**
75
84
  * First checkpoint seq reserved for saga compensations: compensation `idx` (registration order)
76
85
  * persists at `COMPENSATION_SEQ_BASE - idx` (-2, -3, …). Negative so it can never collide with the
@@ -84,6 +93,24 @@ const STEP_FAN_CHUNK = 200;
84
93
  const DEFAULT_TIMER_BATCH = 500;
85
94
  /** The most results of one run settled by one batched write (see `completeRemoteResult`). */
86
95
  const RESULT_BATCH_MAX = 256;
96
+ /**
97
+ * How many runs this instance executes in-process at once when it runs them itself (the default
98
+ * dispatcher without a run queue, deferred child starts): past it, run ids wait in memory in arrival
99
+ * order. The runs are already persisted, so a dropped id is recovered by the pending-run poll or the
100
+ * parent's next turn.
101
+ */
102
+ const LOCAL_RUN_CONCURRENCY = 256;
103
+ /** How many dispatched runs this instance takes from the transport's run queue at once. */
104
+ const RUN_CONSUMER_CONCURRENCY = 64;
105
+ /** The most runs handed to {@link Transport.dispatchRuns} in one call. */
106
+ const RUN_DISPATCH_BATCH = 500;
107
+ /** Tries, and the first backoff, for telling a parent that its child finished (see `deliverToParent`). */
108
+ const PARENT_NOTIFY_ATTEMPTS = 4;
109
+ const PARENT_NOTIFY_BACKOFF_MS = 250;
110
+ /** How often, at most, and how many times a wake dropped on a held lease looks again (see `recheckAfterLease`). */
111
+ const LEASE_RECHECK_MAX_MS = 2_000;
112
+ const LEASE_RECHECK_SLACK_MS = 100;
113
+ const LEASE_RECHECK_ATTEMPTS = 30;
87
114
  /**
88
115
  * How soon (ms) orphan recovery re-drives a REMOTE workflow run found `running` with a call in flight:
89
116
  * its turn was lost (an inline advance timed out, or the replica applying a decision died), so it is
@@ -314,6 +341,26 @@ export class WorkflowEngine {
314
341
  * escapes it.
315
342
  */
316
343
  postSettle = new Set();
344
+ /** Runs this instance dispatched to itself, executed at most {@link LOCAL_RUN_CONCURRENCY} at once. */
345
+ localRuns = new BoundedRunner(LOCAL_RUN_CONCURRENCY);
346
+ /** Deferred child starts (see {@link startChildDeferred}), at most {@link LOCAL_RUN_CONCURRENCY} at once. */
347
+ childStarts = new BoundedRunner(LOCAL_RUN_CONCURRENCY);
348
+ /**
349
+ * The transport whose run queue this engine dispatches runs through and consumes (see
350
+ * {@link Transport.dispatchRuns}), when the primary transport has one and no custom
351
+ * `runDispatcher` was supplied. Unset: runs execute on the instance that dispatches them.
352
+ */
353
+ runQueue;
354
+ /** Stops this instance's run-queue consumer (see {@link Transport.onRun}); unset once stopped. */
355
+ stopRunConsumer;
356
+ /** Runs waiting to go out in the next {@link Transport.dispatchRuns} batch, by run id. */
357
+ runBuffer = new Map();
358
+ runFlushScheduled = false;
359
+ /** When a failed {@link Transport.dispatchRuns} was last reported, and how many failed since. */
360
+ runEnqueueWarnedAt = Number.NEGATIVE_INFINITY;
361
+ runEnqueueFailures = 0;
362
+ /** Re-checks scheduled for runs whose wake found another instance's lease (see {@link recheckAfterLease}). */
363
+ leaseRetries = new Map();
317
364
  draining = false;
318
365
  /** Shutdown wait already spent before/alongside {@link drain} (see {@link beginDrain}). */
319
366
  drainSpentMs = 0;
@@ -419,18 +466,29 @@ export class WorkflowEngine {
419
466
  // run before its execution enters `inflight`, so a bare `queueMicrotask` let `drain()` find both
420
467
  // registries empty in that gap and return while the pickup was still about to write — onto a
421
468
  // closed connection, or a dropped schema. The one-microtask defer is kept.
469
+ //
470
+ // Bounded: a wide fan's children wait here as run ids instead of all executing at once.
422
471
  this.runDispatcher = deps.runDispatcher ?? {
423
- dispatch: (runId) => this.trackEffect(Promise.resolve()
424
- .then(() => this.runOne(runId))
425
- .catch((err) => {
472
+ dispatch: (runId) => this.trackEffect(this.localRuns.submit(runId, () => this.runOne(runId).catch((err) => {
426
473
  console.warn(`[adonis-durable] in-process pickup of run ${runId} failed (a pending-run poll or recovery retries it): ${err instanceof Error ? err.message : String(err)}`);
427
- })),
474
+ }))),
428
475
  };
476
+ // A primary transport with a run queue shared by every instance distributes the runs instead:
477
+ // every run this engine would execute on itself goes through the queue, and every instance
478
+ // consuming it (each driving instance) takes its share. A custom dispatcher owns execution
479
+ // itself, so it never uses the queue.
480
+ const runQueue = this.primaryTransport;
481
+ this.runQueue =
482
+ this.usesInProcessDispatch &&
483
+ typeof runQueue?.dispatchRuns === 'function' &&
484
+ typeof runQueue.onDispatchedRun === 'function'
485
+ ? runQueue
486
+ : undefined;
429
487
  this.singletons = new SingletonGate({
430
488
  store: this.store,
431
489
  clock: this.clock,
432
490
  dispatch: (runId) => {
433
- this.runDispatcher.dispatch(runId);
491
+ void this.dispatchRun(runId, 'wake', { lookup: true });
434
492
  },
435
493
  configFor: (run) => (this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
436
494
  this.latest.get(run.workflow))?.singleton,
@@ -525,6 +583,184 @@ export class WorkflowEngine {
525
583
  wakeWaiter: (token, payload) => this.wakeSignalWaiter(token, payload),
526
584
  ensureStarted: (workflow, input, runId) => this.start(workflow, input, runId),
527
585
  });
586
+ // Consume the run queue last, once the transport has this engine's namespace. A process whose
587
+ // transport defers its consumers (an ace command) still dispatches through the queue and leaves
588
+ // the runs to the instances that consume it.
589
+ this.consumeRuns();
590
+ }
591
+ /**
592
+ * Consume this engine's namespace's run queue (see {@link Transport.onDispatchedRun}), and return
593
+ * the function that stops it; `undefined` when the engine dispatches runs without one (no run queue
594
+ * on the transport, or a custom `runDispatcher`). The engine calls it itself when it is built, so a
595
+ * driving instance consumes with no setup; a second call returns the same stop. {@link drain} stops it.
596
+ */
597
+ consumeRuns() {
598
+ if (!this.runQueue?.onDispatchedRun || this.draining)
599
+ return this.stopRunConsumer;
600
+ this.stopRunConsumer ??= this.runQueue.onDispatchedRun((run) => this.runEnqueued(run), {
601
+ concurrency: RUN_CONSUMER_CONCURRENCY,
602
+ namespace: this.namespace,
603
+ });
604
+ return this.stopRunConsumer;
605
+ }
606
+ /**
607
+ * Execute `runId` wherever this engine executes runs: through the transport's run queue when it has
608
+ * one (see {@link runQueue}), else through the {@link RunDispatcher}. The queue is this engine's
609
+ * namespace's, the one its instances consume (an operator, with no namespace, drives every pool's
610
+ * runs from the transport's own queue). The caller passes the run when it has it at hand, for its
611
+ * `priority`, or `lookup` to read it. A custom dispatcher receives the run id alone.
612
+ */
613
+ dispatchRun(runId, reason, opts = {}) {
614
+ if (!this.runQueue)
615
+ return this.runDispatcher.dispatch(runId);
616
+ const enqueue = (run) => this.enqueueRun({
617
+ runId,
618
+ reason,
619
+ ...(opts.key !== undefined ? { key: opts.key } : {}),
620
+ ...(run?.priority !== undefined ? { priority: run.priority } : {}),
621
+ }, this.namespace);
622
+ if (opts.run || !opts.lookup) {
623
+ enqueue(opts.run);
624
+ return;
625
+ }
626
+ return this.store.getRun(runId).then((run) => enqueue(run ?? undefined), () => enqueue(undefined));
627
+ }
628
+ /**
629
+ * Add `run` to the next {@link Transport.dispatchRuns} batch of `namespace` (this engine's). A batch goes out when
630
+ * the buffer reaches {@link RUN_DISPATCH_BATCH} runs or on the next macrotask, so the starts of a
631
+ * fan's children, which land over many store round trips, are sent together. A wake for a run
632
+ * already in the batch replaces its start: the consumer acts on the run's state either way.
633
+ */
634
+ enqueueRun(run, namespace) {
635
+ const prior = this.runBuffer.get(run.runId);
636
+ if (!prior || run.reason === 'wake')
637
+ this.runBuffer.set(run.runId, { run, namespace });
638
+ if (this.runBuffer.size >= RUN_DISPATCH_BATCH) {
639
+ this.flushRuns();
640
+ return;
641
+ }
642
+ if (this.runFlushScheduled)
643
+ return;
644
+ this.runFlushScheduled = true;
645
+ this.trackEffect(new Promise((resolve) => {
646
+ setImmediate(() => {
647
+ this.runFlushScheduled = false;
648
+ this.flushRuns();
649
+ resolve();
650
+ });
651
+ }));
652
+ }
653
+ /**
654
+ * Send every buffered run (see {@link enqueueRun}) now: one {@link Transport.dispatchRuns} per
655
+ * namespace and {@link RUN_DISPATCH_BATCH} runs.
656
+ */
657
+ flushRuns() {
658
+ const queue = this.runQueue;
659
+ if (!queue?.dispatchRuns || this.runBuffer.size === 0)
660
+ return;
661
+ const byNamespace = new Map();
662
+ for (const { run, namespace } of this.runBuffer.values()) {
663
+ const list = byNamespace.get(namespace) ?? [];
664
+ list.push(run);
665
+ byNamespace.set(namespace, list);
666
+ }
667
+ this.runBuffer.clear();
668
+ this.trackEffect((async () => {
669
+ for (const [namespace, runs] of byNamespace) {
670
+ for (let at = 0; at < runs.length; at += RUN_DISPATCH_BATCH) {
671
+ const batch = runs.slice(at, at + RUN_DISPATCH_BATCH);
672
+ try {
673
+ await queue.dispatchRuns?.(batch, namespace !== undefined ? { namespace } : undefined);
674
+ }
675
+ catch (err) {
676
+ // A start stays `pending` for the pending-run poll. A wake is not left to the run's
677
+ // timer (minutes away): it resumes the run here, as it would without a run queue.
678
+ for (const run of batch) {
679
+ if (run.reason === 'wake' && !this.draining) {
680
+ this.trackEffect(this.resumeImplicit(run.runId).catch(() => undefined));
681
+ }
682
+ }
683
+ this.reportEnqueueFailure(err, "a pending-run poll or the runs' timers pick them up");
684
+ }
685
+ }
686
+ }
687
+ })());
688
+ }
689
+ /** Report a failed {@link Transport.dispatchRuns}, at most once a minute: a queue that is down fails every batch. */
690
+ reportEnqueueFailure(err, fallback) {
691
+ this.runEnqueueFailures += 1;
692
+ const now = Date.now();
693
+ if (now - this.runEnqueueWarnedAt < 60_000)
694
+ return;
695
+ this.runEnqueueWarnedAt = now;
696
+ const failures = this.runEnqueueFailures;
697
+ this.runEnqueueFailures = 0;
698
+ console.warn(`[adonis-durable] could not enqueue runs on the run queue (${failures} failure(s) since the last report; ${fallback}): ${err instanceof Error ? err.message : String(err)}`);
699
+ }
700
+ /**
701
+ * Take a run dispatched through the transport's run queue (see {@link Transport.onDispatchedRun})
702
+ * and act on its state: a `pending` run is run, a suspended (or blocked) one is leased and resumed,
703
+ * a finished one is skipped. A run another instance already took (through its own consumer or its
704
+ * pending-run poll) is no longer `pending`, and its lease admits one executor either way; a resume
705
+ * that finds the lease held looks again once it is free. Resolves once the turn settled here.
706
+ */
707
+ async runEnqueued(dispatch) {
708
+ const runId = typeof dispatch === 'string' ? dispatch : dispatch.runId;
709
+ // Taken from the queue while this instance shuts down: hand it back for another instance.
710
+ const handBack = async (run) => {
711
+ const queue = this.runQueue;
712
+ if (!queue?.dispatchRuns)
713
+ return;
714
+ const namespace = this.namespace;
715
+ try {
716
+ await queue.dispatchRuns([
717
+ {
718
+ runId,
719
+ reason: run?.status === 'pending' ? 'start' : 'wake',
720
+ ...(run?.priority !== undefined ? { priority: run.priority } : {}),
721
+ },
722
+ ], namespace !== undefined ? { namespace } : undefined);
723
+ }
724
+ catch (err) {
725
+ this.reportEnqueueFailure(err, `run ${runId} handed back while draining waits for a pending-run poll or its timer`);
726
+ }
727
+ };
728
+ if (this.draining) {
729
+ await handBack(await this.store.getRun(runId).catch(() => undefined));
730
+ return;
731
+ }
732
+ await this.localRuns.submit(runId, async () => {
733
+ try {
734
+ const current = await this.store.getRun(runId);
735
+ if (!current || isTerminalRun(current.status))
736
+ return;
737
+ if (this.draining) {
738
+ await handBack(current);
739
+ return;
740
+ }
741
+ await this.leaseAndResume(runId, current.status !== 'pending');
742
+ }
743
+ catch (error) {
744
+ console.error(`[adonis-durable] could not run dispatched run ${runId}:`, error);
745
+ }
746
+ });
747
+ }
748
+ /**
749
+ * Wake a suspended run that has something new to see: resume it here, or, with a run queue, on
750
+ * whichever instance of this engine's namespace takes it from the queue (the result then only says
751
+ * it was handed over).
752
+ */
753
+ async wake(runId) {
754
+ if (!this.runQueue)
755
+ return this.resumeImplicit(runId);
756
+ const run = await this.store.getRun(runId);
757
+ if (!run)
758
+ return { runId, status: 'cancelled' };
759
+ if (isTerminalRun(run.status)) {
760
+ return { runId, status: run.status, output: run.output, error: run.error };
761
+ }
762
+ this.enqueueRun({ runId, reason: 'wake', ...(run.priority !== undefined ? { priority: run.priority } : {}) }, this.namespace);
763
+ return { runId, status: run.status };
528
764
  }
529
765
  /**
530
766
  * Register a **durable entity** (a virtual object): a keyed actor whose `handlers` run **serialized
@@ -1079,7 +1315,10 @@ export class WorkflowEngine {
1079
1315
  // dispatcher — doesn't sit `pending` until the next poll. Idempotent: execution is leased, so
1080
1316
  // a duplicate dispatch is a cheap no-op at the worker.
1081
1317
  if (dispatch && prior.status === 'pending') {
1082
- await this.runDispatcher.dispatch(runId);
1318
+ await this.dispatchRun(runId, 'start', {
1319
+ key: String(prior.updatedAt.getTime()),
1320
+ run: prior,
1321
+ });
1083
1322
  }
1084
1323
  return { runId, status: prior.status, output: prior.output, error: prior.error };
1085
1324
  }
@@ -1098,7 +1337,7 @@ export class WorkflowEngine {
1098
1337
  ];
1099
1338
  // The admission marker is owned by the store's atomic claim: a fresh run can neither supply it nor
1100
1339
  // inherit it (`retryWithInput` copies the original run's tags).
1101
- const tags = mergeTags(registered.tags, minted.length ? [...(opts?.tags ?? []), ...minted] : opts?.tags)?.filter((t) => !SINGLETON_RESERVED_TAGS.includes(t));
1340
+ const tags = mergeTags(registered.tags, minted.length ? [...(opts?.tags ?? []), ...minted] : opts?.tags)?.filter((t) => !SINGLETON_RESERVED_TAGS.includes(t) && t !== CHILD_DELIVERED_TAG);
1102
1341
  // Singleton back-pressure: reject a start that would grow the same-key backlog past
1103
1342
  // `limit + maxQueueDepth` (no-op when no maxQueueDepth is configured).
1104
1343
  if (registered.singleton) {
@@ -1146,10 +1385,11 @@ export class WorkflowEngine {
1146
1385
  // NOT run the body inline. Await the terminal/suspended state with `waitForRun(runId)` if needed.
1147
1386
  // An internal in-process handoff persists with `dispatch: false` and drives the pickup itself.
1148
1387
  if (dispatch && !delayed) {
1149
- await this.runDispatcher.dispatch(runId);
1388
+ await this.dispatchRun(runId, 'start', { key: String(run.updatedAt.getTime()), run });
1150
1389
  // Nudge worker instances to pick it up now instead of on their next poll (no-op without a control
1151
- // plane; self-receipt is filtered, so it only helps OTHER pods — e.g. an API pod's enqueue).
1152
- if (this.controlPlane) {
1390
+ // plane; self-receipt is filtered, so it only helps OTHER pods — e.g. an API pod's enqueue). A run
1391
+ // queue already delivers it to a consuming instance.
1392
+ if (this.controlPlane && !this.runQueue) {
1153
1393
  void this.controlPlane
1154
1394
  .publishControl({ kind: 'enqueued', runId, from: this.instanceId })
1155
1395
  .catch(() => undefined);
@@ -1175,14 +1415,18 @@ export class WorkflowEngine {
1175
1415
  * which is harmless because {@link drain}'s loop re-snapshots both each iteration. With a custom
1176
1416
  * dispatcher the run executes off-instance, so only the durable persist is guaranteed. A start that
1177
1417
  * throws is handed to `onStartError` (a child delivers it to its waiting parent) instead of vanishing.
1418
+ * With a run queue ({@link runQueue}) the run is persisted and enqueued like any start, so it
1419
+ * executes on whichever instance takes it.
1420
+ *
1421
+ * `limiter` bounds how many of these run at once (a fan's children wait in it as ids).
1178
1422
  */
1179
- handoffRun(workflow, input, runId, opts, onStartError, onAlreadySettled) {
1180
- this.trackEffect((async () => {
1423
+ handoffRun(workflow, input, runId, opts, onStartError, onAlreadySettled, limiter) {
1424
+ const handoff = async () => {
1181
1425
  // Defer one microtask: never reentrantly resume the parent that is still suspending/settling.
1182
1426
  await Promise.resolve();
1183
1427
  let result;
1184
1428
  try {
1185
- if (this.usesInProcessDispatch) {
1429
+ if (this.usesInProcessDispatch && !this.runQueue) {
1186
1430
  // Persist WITHOUT the fire-and-forget dispatch, then drive the pickup ourselves so this
1187
1431
  // promise is the SOLE executor of the run (no race with a detached dispatcher `runOne`) and
1188
1432
  // stays tracked until the run has entered `inflight` and settled. `runOne` no-ops (returns
@@ -1212,7 +1456,8 @@ export class WorkflowEngine {
1212
1456
  TERMINAL_RUN_STATUSES.includes(result.status)) {
1213
1457
  await onAlreadySettled(result);
1214
1458
  }
1215
- })());
1459
+ };
1460
+ this.trackEffect(limiter ? limiter.submit(runId, handoff) : handoff());
1216
1461
  }
1217
1462
  /** Read a run's current persisted state (or null if unknown). A thin pass-through to the store. */
1218
1463
  getRun(runId) {
@@ -1838,6 +2083,11 @@ export class WorkflowEngine {
1838
2083
  */
1839
2084
  beginDrain(spentMs = 0) {
1840
2085
  this.draining = true;
2086
+ // Take no more runs from the run queue; the ones in flight here finish (and are waited for).
2087
+ const stopRunConsumer = this.stopRunConsumer;
2088
+ this.stopRunConsumer = undefined;
2089
+ if (stopRunConsumer)
2090
+ this.trackEffect(stopRunConsumer());
1841
2091
  if (Number.isFinite(spentMs) && spentMs > 0) {
1842
2092
  this.drainSpentMs += spentMs;
1843
2093
  for (const rearm of this.drainRearms)
@@ -1873,6 +2123,15 @@ export class WorkflowEngine {
1873
2123
  */
1874
2124
  async drain(timeoutMs = 10_000) {
1875
2125
  this.beginDrain();
2126
+ // Runs waiting for an in-process slot are dropped: they are persisted, so the pending-run poll
2127
+ // (a start) or the parent's next turn (a child start) takes them up on another instance.
2128
+ this.localRuns.clear();
2129
+ this.childStarts.clear();
2130
+ for (const timer of this.leaseRetries.values())
2131
+ clearTimeout(timer);
2132
+ this.leaseRetries.clear();
2133
+ // Runs buffered for the run queue go out now rather than on the next macrotask.
2134
+ this.flushRuns();
1876
2135
  if (this.inflight.size === 0 && this.postSettle.size === 0)
1877
2136
  return;
1878
2137
  // The deadline is `start + timeoutMs - spent`, re-read whenever `beginDrain(spentMs)` reports more
@@ -2111,14 +2370,15 @@ export class WorkflowEngine {
2111
2370
  // the whole run (double `run.completed`, double parent notify). Same reasoning as every settle
2112
2371
  // path's SETTLE_ELIGIBLE_STATUSES guard. The lock is released only AFTER the flip (owner-scoped,
2113
2372
  // so a takeover can't be wiped), closing the release→flip gap the old order left open.
2373
+ const repended = new Date();
2114
2374
  const applied = await this.store.updateRunIf(run.id, ['running'], {
2115
2375
  status: 'pending',
2116
- updatedAt: new Date(),
2376
+ updatedAt: repended,
2117
2377
  });
2118
2378
  await this.store.releaseRunLock(run.id, this.instanceId);
2119
2379
  if (!applied)
2120
2380
  continue;
2121
- await this.runDispatcher.dispatch(run.id);
2381
+ await this.dispatchRun(run.id, 'start', { key: pendingKey(repended), run });
2122
2382
  results.push({ runId: run.id, status: 'pending' });
2123
2383
  }
2124
2384
  return results;
@@ -2226,8 +2486,56 @@ export class WorkflowEngine {
2226
2486
  // them all contend for the same leases one row at a time.
2227
2487
  const due = shuffle(await asHeartbeat(() => this.store.listDueTimers(nowMs, this.namespace, this.timerBatchSize)));
2228
2488
  const blocked = await this.dueBlockedRuns(nowMs);
2489
+ if (this.runQueue)
2490
+ return this.handOffDue([...due, ...blocked], nowMs);
2229
2491
  return this.resumeLeased([...due, ...blocked], nowMs);
2230
2492
  }
2493
+ /**
2494
+ * {@link resumeDueTimers} with a run queue: each due run is claimed here, then woken through the
2495
+ * queue, so the instance whose poll found it does not execute every due run itself. The claim (the
2496
+ * lease, held while the run's `wakeAt` moves {@link leaseMs} ahead) keeps every other instance's
2497
+ * poll from handing it off again meanwhile; a wake lost on the way comes due again then.
2498
+ */
2499
+ async handOffDue(runs, nowMs) {
2500
+ if (this.draining)
2501
+ return [];
2502
+ const results = [];
2503
+ let next = 0;
2504
+ const worker = async () => {
2505
+ for (;;) {
2506
+ const run = runs[next];
2507
+ next += 1;
2508
+ if (!run || this.draining)
2509
+ return;
2510
+ try {
2511
+ if (!(await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs))) {
2512
+ continue;
2513
+ }
2514
+ try {
2515
+ const claimed = await this.store.updateRunIf(run.id, [run.status], {
2516
+ wakeAt: nowMs + this.leaseMs,
2517
+ });
2518
+ if (!claimed)
2519
+ continue;
2520
+ }
2521
+ finally {
2522
+ await this.store.releaseRunLock(run.id, this.instanceId);
2523
+ }
2524
+ this.enqueueRun({
2525
+ runId: run.id,
2526
+ reason: 'wake',
2527
+ ...(run.priority !== undefined ? { priority: run.priority } : {}),
2528
+ }, this.namespace);
2529
+ results.push({ runId: run.id, status: run.status });
2530
+ }
2531
+ catch (error) {
2532
+ console.error(`[adonis-durable] could not hand off due run ${run.id}; leaving it for another instance or a later tick:`, error);
2533
+ }
2534
+ }
2535
+ };
2536
+ await Promise.all(Array.from({ length: Math.min(8, runs.length) }, () => worker()));
2537
+ return results;
2538
+ }
2231
2539
  /**
2232
2540
  * Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
2233
2541
  * exactly one instance runs it), then resumes the body. Returns the result, or null if another
@@ -2246,11 +2554,15 @@ export class WorkflowEngine {
2246
2554
  * work {@link drain} is waiting on — so it must run even while draining, exactly as a signal-driven
2247
2555
  * resume ({@link deliverSignal}) already does. Returns null if another instance holds the lease.
2248
2556
  */
2249
- async leaseAndResume(runId) {
2557
+ async leaseAndResume(runId, recheck = false) {
2250
2558
  const nowMs = this.clock();
2251
2559
  const acquired = await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs);
2252
- if (!acquired)
2560
+ if (!acquired) {
2561
+ // A wake (not a start) that lost the lease race may postdate the holder's snapshot.
2562
+ if (recheck)
2563
+ this.recheckAfterLease(runId, undefined);
2253
2564
  return null;
2565
+ }
2254
2566
  // resume() checks the namespace and throws NamespaceMismatch when it doesn't match (releasing the
2255
2567
  // lease) — a foreign run leased here is simply skipped rather than run.
2256
2568
  return this.resumeImplicit(runId).catch((err) => {
@@ -2384,16 +2696,21 @@ export class WorkflowEngine {
2384
2696
  //
2385
2697
  // And it clears a remote workflow turn's decision marker (`awaitingDecisionTaskId`): the requeued
2386
2698
  // run dispatches a FRESH turn, so a late decision of the old one must find no marker to match.
2387
- const staleAdmission = resurrecting ? withoutAdmission(run.tags) : undefined;
2699
+ // A run executed again owes its parent a new completion: drop the stamp that says it was delivered.
2700
+ const requeuedAt = new Date();
2701
+ const kept = resurrecting ? (withoutAdmission(run.tags) ?? run.tags) : run.tags;
2702
+ const staleAdmission = kept !== run.tags || kept?.includes(CHILD_DELIVERED_TAG)
2703
+ ? (kept ?? []).filter((t) => t !== CHILD_DELIVERED_TAG)
2704
+ : undefined;
2388
2705
  await this.store.updateRun(runId, {
2389
2706
  status: 'pending',
2390
2707
  error: undefined,
2391
2708
  awaitingDecisionTaskId: undefined,
2392
2709
  ...(resurrecting ? { recoveryAttempts: 0 } : {}),
2393
2710
  ...(staleAdmission ? { tags: staleAdmission } : {}),
2394
- updatedAt: new Date(),
2711
+ updatedAt: requeuedAt,
2395
2712
  });
2396
- await this.runDispatcher.dispatch(runId);
2713
+ await this.dispatchRun(runId, 'start', { key: pendingKey(requeuedAt), run });
2397
2714
  return { runId, status: 'pending' };
2398
2715
  }
2399
2716
  /**
@@ -2695,7 +3012,7 @@ export class WorkflowEngine {
2695
3012
  if (await this.fanStillWaiting(waiter, payload)) {
2696
3013
  return { runId: waiter.runId, status: 'suspended' };
2697
3014
  }
2698
- return this.resumeImplicit(waiter.runId);
3015
+ return this.wake(waiter.runId);
2699
3016
  }
2700
3017
  /**
2701
3018
  * The `ctx.all` join barrier: true when `waiter` was one child of a fan (or of one wave of it)
@@ -2826,16 +3143,11 @@ export class WorkflowEngine {
2826
3143
  * knowing about the child feature.
2827
3144
  */
2828
3145
  notifyParent(runId, completion) {
2829
- // Tracked, not bare fire-and-forget: `signal` can wake a suspended parent and resume it, issuing
2830
- // store writes that must be awaited by `drain()` even though the settling child returned already.
2831
- // One retry + a warning before giving up: a transiently-failed delivery here used to vanish into
2832
- // trackEffect's swallow, stranding the parent until its reconcile wake. The parent's `ctx.child`
2833
- // re-registration now also RE-DERIVES a terminal child's completion from the run row itself, so
2834
- // a lost notify self-heals — but that costs a reconcile interval; don't lose it silently.
2835
- const deliver = (token) => this.signal(token, completion).catch(() => this.signal(token, completion).catch((err) => {
2836
- 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)}`);
2837
- }));
2838
- this.trackEffect(deliver(`child:${runId}`));
3146
+ // Tracked, not bare fire-and-forget: `signal` can wake a suspended parent, issuing store writes
3147
+ // `drain()` must wait for even though the settling child returned already. The child is already
3148
+ // persisted terminal, so a lost delivery leaves its parent waiting until the parent's next turn
3149
+ // re-derives the completion from the child's run row: hence the retries.
3150
+ this.trackEffect(this.deliverToParent(`child:${runId}`, completion, runId));
2839
3151
  // A fix-and-replay run (`<origin>~retry~<hash>`) is standalone, but its SUCCESS is the
2840
3152
  // origin's outcome for all practical purposes: deliver it on the ORIGIN's token too, so a
2841
3153
  // parent that failed on that child and is retried later consumes this success (buffered or
@@ -2843,7 +3155,51 @@ export class WorkflowEngine {
2843
3155
  // fix attempt must not poison the origin's token.
2844
3156
  const at = runId.lastIndexOf('~retry~');
2845
3157
  if (at !== -1 && completion.ok) {
2846
- this.trackEffect(deliver(`child:${runId.slice(0, at)}`));
3158
+ this.trackEffect(this.deliverToParent(`child:${runId.slice(0, at)}`, completion));
3159
+ }
3160
+ }
3161
+ /**
3162
+ * {@link signal} a child's completion to its parent, retrying a store error with backoff
3163
+ * ({@link PARENT_NOTIFY_ATTEMPTS} tries). A waiter already taken is delivered to directly on a
3164
+ * retry, so the completion is not buffered for a waiter that no longer exists.
3165
+ */
3166
+ async deliverToParent(token, completion, childId) {
3167
+ let waiter;
3168
+ for (let attempt = 1;; attempt += 1) {
3169
+ try {
3170
+ if (waiter === undefined)
3171
+ waiter = await this.store.takeSignalWaiter(token);
3172
+ if (waiter) {
3173
+ await this.deliverSignal(waiter, completion);
3174
+ return;
3175
+ }
3176
+ // No waiter: the parent may already have settled this child from its run row (see
3177
+ // `markChildDelivered`); a buffered copy would then never be consumed.
3178
+ const delivered = async () => childId !== undefined &&
3179
+ ((await this.store.getRun(childId))?.tags?.includes(CHILD_DELIVERED_TAG) ?? false);
3180
+ if (await delivered())
3181
+ return;
3182
+ const resumed = await this.signal(token, completion);
3183
+ // The parent may settle the child from its run row between the look above and the buffer
3184
+ // write (a join that registers its waiter only now): it then stamped the child first, and the
3185
+ // copy just buffered would never be read.
3186
+ if (resumed === null && (await delivered())) {
3187
+ await this.store.takeBufferedSignal(token);
3188
+ }
3189
+ return;
3190
+ }
3191
+ catch (err) {
3192
+ if (err instanceof NamespaceMismatch)
3193
+ return;
3194
+ if (attempt >= PARENT_NOTIFY_ATTEMPTS) {
3195
+ console.warn(`[adonis-durable] failed to deliver child completion on ${token} (the parent recovers on its next turn): ${err instanceof Error ? err.message : String(err)}`);
3196
+ return;
3197
+ }
3198
+ await new Promise((resolve) => {
3199
+ const timer = setTimeout(resolve, PARENT_NOTIFY_BACKOFF_MS * 4 ** (attempt - 1));
3200
+ timer.unref?.();
3201
+ });
3202
+ }
2847
3203
  }
2848
3204
  }
2849
3205
  /**
@@ -2868,7 +3224,11 @@ export class WorkflowEngine {
2868
3224
  ok: false,
2869
3225
  error: `child workflow "${workflow}" failed to start: ${message}`,
2870
3226
  });
2871
- }, (settled) => this.redeliverSettledChild(childId, settled));
3227
+ }, (settled) => this.redeliverSettledChild(childId, settled),
3228
+ // Bounded: a fan's starts wait here instead of all hitting the store at once. A start still
3229
+ // waiting at shutdown is dropped, not failed: the parent's placeholder remains, and its next
3230
+ // turn starts every child that has no run yet.
3231
+ this.childStarts);
2872
3232
  }
2873
3233
  /**
2874
3234
  * A child start that found the child already terminal: its completion may never reach the waiter
@@ -2952,6 +3312,8 @@ export class WorkflowEngine {
2952
3312
  error,
2953
3313
  });
2954
3314
  await this.cancelChildren(runId, opts);
3315
+ // A parent awaiting this run as a child learns now, as a failed child, not on its next turn.
3316
+ this.notifyParent(runId, failedCompletion(error));
2955
3317
  // Notify local cancel listeners now (a worker on this pod), and broadcast so the instance/worker
2956
3318
  // actually running this run learns of it and can abort cooperatively (the store already records
2957
3319
  // `cancelled`, but a busy worker won't re-read it).
@@ -3252,6 +3614,62 @@ export class WorkflowEngine {
3252
3614
  durationMs: Date.now() - step.startedAt.getTime(),
3253
3615
  });
3254
3616
  }
3617
+ /**
3618
+ * A wake that found the run leased by another instance is dropped, and normally that is fine: the
3619
+ * holder's turn either sees what the wake wrote, or re-checks once it releases the lease
3620
+ * ({@link recheckWaitSeqs}, {@link redriveSettledChildren}). Not if the holder goes down first (a
3621
+ * scale-down mid-turn, an OOM): its lease lapses with the run `suspended` on something that has
3622
+ * finished, and nothing wakes it before its timer.
3623
+ *
3624
+ * So the dropped wake is kept here: once the lease is free (looked at every
3625
+ * {@link LEASE_RECHECK_MAX_MS} at most, {@link LEASE_RECHECK_ATTEMPTS} times at most), a run still
3626
+ * `suspended` is woken. A holder that already moved the run on makes this one spare replay that
3627
+ * re-parks. Best effort; the run's timer remains the fallback if this instance goes down too.
3628
+ */
3629
+ recheckAfterLease(runId, lockedUntil, attempt = 1) {
3630
+ if (this.draining || this.leaseRetries.has(runId))
3631
+ return;
3632
+ const remaining = Math.max(0, (lockedUntil ?? this.clock()) - this.clock());
3633
+ const timer = setTimeout(() => {
3634
+ this.leaseRetries.delete(runId);
3635
+ this.trackEffect(this.retryDroppedWake(runId, attempt));
3636
+ }, Math.min(remaining, LEASE_RECHECK_MAX_MS) + LEASE_RECHECK_SLACK_MS);
3637
+ timer.unref?.();
3638
+ this.leaseRetries.set(runId, timer);
3639
+ }
3640
+ async retryDroppedWake(runId, attempt) {
3641
+ if (this.draining)
3642
+ return;
3643
+ const run = await this.store.getRun(runId);
3644
+ if (run?.status !== 'suspended')
3645
+ return;
3646
+ const nowMs = this.clock();
3647
+ if (run.lockedUntil !== undefined && run.lockedUntil > nowMs) {
3648
+ if (attempt < LEASE_RECHECK_ATTEMPTS) {
3649
+ this.recheckAfterLease(runId, run.lockedUntil, attempt + 1);
3650
+ }
3651
+ return;
3652
+ }
3653
+ if (this.runQueue) {
3654
+ this.enqueueRun({
3655
+ runId,
3656
+ reason: 'wake',
3657
+ ...(run.priority !== undefined ? { priority: run.priority } : {}),
3658
+ }, this.namespace);
3659
+ return;
3660
+ }
3661
+ if (!(await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs)))
3662
+ return;
3663
+ const fresh = await this.store.getRun(runId);
3664
+ if (fresh?.status !== 'suspended') {
3665
+ await this.store.releaseRunLock(runId, this.instanceId);
3666
+ return;
3667
+ }
3668
+ await this.resumeImplicit(runId).catch((err) => {
3669
+ if (!(err instanceof NamespaceMismatch))
3670
+ throw err;
3671
+ });
3672
+ }
3255
3673
  async execute(run, fn) {
3256
3674
  const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
3257
3675
  // Hold the lease for the WHOLE execution — whatever path got us here (leased sweep, a signal, a
@@ -3262,6 +3680,7 @@ export class WorkflowEngine {
3262
3680
  const lockNow = this.clock();
3263
3681
  if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
3264
3682
  if (!(await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow))) {
3683
+ this.recheckAfterLease(run.id, run.lockedUntil);
3265
3684
  return { runId: run.id, status: run.status };
3266
3685
  }
3267
3686
  }
@@ -4594,6 +5013,7 @@ export class WorkflowEngine {
4594
5013
  child.status === 'failed' ||
4595
5014
  child.status === 'cancelled' ||
4596
5015
  child.status === 'dead') {
5016
+ await markChildDelivered(this.store, child);
4597
5017
  await deliverChild(child.status === 'completed'
4598
5018
  ? { ok: true, value: child.output }
4599
5019
  : failedCompletion(child.error ?? { message: child.status }));
@@ -4661,6 +5081,7 @@ export class WorkflowEngine {
4661
5081
  namespace: run.namespace,
4662
5082
  error,
4663
5083
  });
5084
+ this.notifyParent(run.id, failedCompletion(error));
4664
5085
  return { runId: run.id, status: 'cancelled', error };
4665
5086
  }
4666
5087
  /** The run body, lease held + renewed by {@link execute}. */
@@ -5353,10 +5774,14 @@ export class WorkflowEngine {
5353
5774
  if (!(await this.savePendingCheckpoint({ ...existing, wakeAt }))) {
5354
5775
  return this.callRemote(runId, seq, step, input, queue, transport, undefined, admission, runNamespace, parallelGroup);
5355
5776
  }
5356
- throw new WorkflowSuspended(wakeAt, [seq]);
5777
+ // Parked on the deadline, but no later than `reconcileMs`: the result's wake is the run's only
5778
+ // other way forward, and a wake lost to a crash or a held lease would otherwise leave a
5779
+ // finished step unnoticed until the deadline (an hour, say). An early wake re-parks here.
5780
+ throw new WorkflowSuspended(this.stepLeaseWakeAt(wakeAt), [seq]);
5781
+ }
5782
+ if (this.clock() < existing.wakeAt) {
5783
+ throw new WorkflowSuspended(this.stepLeaseWakeAt(existing.wakeAt), [seq]);
5357
5784
  }
5358
- if (this.clock() < existing.wakeAt)
5359
- throw new WorkflowSuspended(existing.wakeAt, [seq]);
5360
5785
  // Past the deadline with no result — the dispatch is presumed lost. Re-dispatch (bounded by
5361
5786
  // `remoteRedispatchMax` so a step that never settles fails the run instead of looping forever).
5362
5787
  if (existing.attempts >= this.remoteRedispatchMax) {
@@ -5404,7 +5829,7 @@ export class WorkflowEngine {
5404
5829
  kind: 'remote',
5405
5830
  redispatched: true,
5406
5831
  });
5407
- throw new WorkflowSuspended(nextDeadline, [seq]);
5832
+ throw new WorkflowSuspended(this.stepLeaseWakeAt(nextDeadline), [seq]);
5408
5833
  }
5409
5834
  // Durable retry: a failed attempt re-dispatches up to `retries`, spacing attempts by `backoff` —
5410
5835
  // unless the worker marked the error non-retryable (a deterministic verdict like a declined card).
@@ -6016,7 +6441,7 @@ export class WorkflowEngine {
6016
6441
  const runIds = [...waiters];
6017
6442
  waiters.clear();
6018
6443
  for (const runId of runIds)
6019
- void this.resumeImplicit(runId).catch(() => undefined);
6444
+ void this.wake(runId).catch(() => undefined);
6020
6445
  }
6021
6446
  /**
6022
6447
  * Release the flow-control slot a dispatched step held (if any). The queue comes from the