@adonis-agora/durable 0.16.0 → 0.16.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.
@@ -79,6 +79,14 @@ export class WorkflowEngine {
79
79
  trackStepStart;
80
80
  /** Where a freshly-started run executes — in-process by default (see {@link RunDispatcher}). */
81
81
  runDispatcher;
82
+ /**
83
+ * True when NO custom `runDispatcher` was supplied — i.e. runs execute in-process via the default
84
+ * fire-and-forget dispatcher. Only then does an internal handoff (continue-as-new, a deferred child)
85
+ * own the run's pickup itself (a tracked {@link runOne}) so {@link drain} spans it end to end; with a
86
+ * custom (no-op / broker) dispatcher the run executes off this instance, so the handoff guarantees
87
+ * only the durable persist (the in-process rolled-back-transaction hazard doesn't apply there).
88
+ */
89
+ usesInProcessDispatch;
82
90
  /** The control-plane's OWN handshake descriptor — negotiated against each worker's descriptor to
83
91
  * detect protocol incompatibility before dispatch (design §7.3/§7.4). Built once at construction. */
84
92
  cpDescriptor;
@@ -123,6 +131,17 @@ export class WorkflowEngine {
123
131
  stepQueue = new Map();
124
132
  /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
125
133
  inflight = new Set();
134
+ /**
135
+ * Post-settle side effects (parent notify, singleton wake) fired fire-and-forget AFTER a run's
136
+ * status was persisted. They run OFF the `execute()` path — the caller of `execute()` must never
137
+ * block on them — but they still issue store writes (waking a suspended parent, dispatching a
138
+ * gated singleton run), so `drain()` has to wait for them too. Left untracked, those writes can
139
+ * land after a test's global Lucid transaction rolled back ("Transaction query already complete"
140
+ * as an unhandled rejection) or after a graceful shutdown thought it was done. See {@link track}
141
+ * vs {@link trackEffect}: `inflight` is the execute() path, `postSettle` is everything that
142
+ * escapes it.
143
+ */
144
+ postSettle = new Set();
126
145
  draining = false;
127
146
  constructor(deps) {
128
147
  this.store = deps.store;
@@ -169,6 +188,7 @@ export class WorkflowEngine {
169
188
  // Default: execute the run on this instance, asynchronously, so `start` never blocks on the body.
170
189
  // A failed pickup is swallowed here (the run stays `pending` for a `runPending` poll to retry);
171
190
  // run failures themselves are handled inside `execute` and surfaced as the run's `failed` state.
191
+ this.usesInProcessDispatch = deps.runDispatcher === undefined;
172
192
  this.runDispatcher = deps.runDispatcher ?? {
173
193
  dispatch: (runId) => queueMicrotask(() => void this.runOne(runId).catch(() => { })),
174
194
  };
@@ -474,7 +494,20 @@ export class WorkflowEngine {
474
494
  }
475
495
  }
476
496
  }
477
- async start(workflow, input, runId, opts) {
497
+ start(workflow, input, runId, opts) {
498
+ // Return startCore's promise directly (no async wrapper): the extra microtask an `async`
499
+ // delegator inserts would shift `start`'s resolution one tick later, which is observable in the
500
+ // dispatch timing (a freshly-dispatched run reaching `running` before `start` resolves).
501
+ return this.startCore(workflow, input, runId, opts, true);
502
+ }
503
+ /**
504
+ * The shared body of {@link start}. `dispatch` gates the fire-and-forget hand-off to the run
505
+ * dispatcher (+ the control-plane `enqueued` nudge): the public `start` always dispatches, while an
506
+ * internal in-process handoff ({@link handoffRun}) passes `false` to persist the run and then OWN
507
+ * its pickup with a tracked {@link runOne} — so no detached dispatcher run races it and {@link drain}
508
+ * spans the handoff from the parent's settle until the new run has entered `inflight` and settled.
509
+ */
510
+ async startCore(workflow, input, runId, opts, dispatch) {
478
511
  const name = workflowName(workflow);
479
512
  let registered = this.latest.get(name);
480
513
  // Convention dispatch: an unregistered workflow whose name matches a LIVE worker group is routed
@@ -520,16 +553,68 @@ export class WorkflowEngine {
520
553
  await this.store.createRun(run);
521
554
  // The run is durably enqueued; a dispatcher (in-process by default) executes it — `start` does
522
555
  // NOT run the body inline. Await the terminal/suspended state with `waitForRun(runId)` if needed.
523
- await this.runDispatcher.dispatch(runId);
524
- // Nudge worker instances to pick it up now instead of on their next poll (no-op without a control
525
- // plane; self-receipt is filtered, so it only helps OTHER pods — e.g. an API pod's enqueue).
526
- if (this.controlPlane) {
527
- void this.controlPlane
528
- .publishControl({ kind: 'enqueued', runId, from: this.instanceId })
529
- .catch(() => undefined);
556
+ // An internal in-process handoff persists with `dispatch: false` and drives the pickup itself.
557
+ if (dispatch) {
558
+ await this.runDispatcher.dispatch(runId);
559
+ // Nudge worker instances to pick it up now instead of on their next poll (no-op without a control
560
+ // plane; self-receipt is filtered, so it only helps OTHER pods — e.g. an API pod's enqueue).
561
+ if (this.controlPlane) {
562
+ void this.controlPlane
563
+ .publishControl({ kind: 'enqueued', runId, from: this.instanceId })
564
+ .catch(() => undefined);
565
+ }
530
566
  }
531
567
  return { runId, status: 'pending' };
532
568
  }
569
+ /**
570
+ * Bridge an INTERNAL run handoff — the next execution of a `continue-as-new`, or a deferred child
571
+ * start — into the {@link postSettle} registry so {@link drain} cannot slip through the microtask +
572
+ * store-I/O gap between the parent settling and the new run entering `inflight`. The bare
573
+ * `queueMicrotask(() => void this.start(...))` these replaced left exactly that window open: the
574
+ * parent's `execute()` promise had already left `inflight` and its post-settle effects could have
575
+ * drained before the deferred `start` even ran — so `drain()` could observe both sets empty and
576
+ * return early, letting the continuation's/child's store writes escape onto a torn-down connection
577
+ * (the rolled-back Lucid test transaction → "Transaction query already complete").
578
+ *
579
+ * The tracked promise is registered SYNCHRONOUSLY (at the parent's settle), then defers ONE microtask
580
+ * — preserving the original reentrancy guard so a fast child can't resume a still-suspending parent —
581
+ * before starting. With the default in-process dispatcher it then OWNS the pickup via {@link runOne}
582
+ * (persisting with `dispatch: false` so no detached dispatcher run races it), so the promise stays in
583
+ * `postSettle` until the run has entered `inflight` and settled here; the run is briefly in BOTH sets,
584
+ * which is harmless because {@link drain}'s loop re-snapshots both each iteration. With a custom
585
+ * dispatcher the run executes off-instance, so only the durable persist is guaranteed. A start that
586
+ * throws is handed to `onStartError` (a child delivers it to its waiting parent) instead of vanishing.
587
+ */
588
+ handoffRun(workflow, input, runId, opts, onStartError) {
589
+ this.trackEffect((async () => {
590
+ // Defer one microtask: never reentrantly resume the parent that is still suspending/settling.
591
+ await Promise.resolve();
592
+ try {
593
+ if (this.usesInProcessDispatch) {
594
+ // Persist WITHOUT the fire-and-forget dispatch, then drive the pickup ourselves so this
595
+ // promise is the SOLE executor of the run (no race with a detached dispatcher `runOne`) and
596
+ // stays tracked until the run has entered `inflight` and settled. `runOne` no-ops (returns
597
+ // null) on replay (the run already exists) or while draining (the run is left `pending` for
598
+ // recovery) — the durable persist above is what matters on those paths.
599
+ const result = await this.startCore(workflow, input, runId, opts, false);
600
+ // Guard-free pickup: the continuation/child is in-flight work `drain()` is waiting on, so it
601
+ // runs even while draining (a `runOne` here would no-op on `this.draining`, stranding it
602
+ // `pending` after its persist — persisted-but-not-processed). Mirrors a signal-driven resume.
603
+ if (result.status === 'pending')
604
+ await this.leaseAndResume(runId);
605
+ }
606
+ else {
607
+ // Custom dispatcher owns execution (possibly on another pod); guarantee the persist + its
608
+ // normal dispatch/broadcast. The in-process rolled-back-transaction hazard doesn't apply.
609
+ await this.startCore(workflow, input, runId, opts, true);
610
+ }
611
+ }
612
+ catch (err) {
613
+ if (onStartError)
614
+ onStartError(err);
615
+ }
616
+ })());
617
+ }
533
618
  /** Read a run's current persisted state (or null if unknown). A thin pass-through to the store. */
534
619
  getRun(runId) {
535
620
  return this.store.getRun(runId);
@@ -603,20 +688,50 @@ export class WorkflowEngine {
603
688
  void p.finally(() => this.inflight.delete(p));
604
689
  return p;
605
690
  }
691
+ /**
692
+ * Track a post-settle side effect (parent notify, singleton wake) so {@link drain} waits for it,
693
+ * WITHOUT the `execute()`/`settleRun` caller ever blocking on it — the run's result is returned
694
+ * to its caller the moment its status is persisted; this only holds the effect for `drain()`.
695
+ * Errors are swallowed (these are best-effort wakeups; the durable timer is the real fallback),
696
+ * exactly as the original `void ....catch(() => undefined)` fire-and-forget did.
697
+ */
698
+ trackEffect(p) {
699
+ const tracked = p.catch(() => undefined);
700
+ this.postSettle.add(tracked);
701
+ void tracked.finally(() => this.postSettle.delete(tracked));
702
+ }
606
703
  /**
607
704
  * Graceful shutdown: stop picking up new runs (recovery/timer become no-ops) and wait for
608
705
  * in-flight executions to settle, up to `timeoutMs`. Call from your app's shutdown hook so a
609
706
  * deploy hands off cleanly instead of leaving runs to the lease timeout.
707
+ *
708
+ * Because an internal handoff ({@link handoffRun}) is finishing work already in flight, `drain()`
709
+ * follows a `continue-as-new` chain across its links (and a deferred child through its parent's
710
+ * resume). A HOT continue-as-new loop — one that hands off to a fresh link faster than it settles —
711
+ * therefore keeps `postSettle`/`inflight` non-empty and consumes the ENTIRE `timeoutMs` before this
712
+ * returns. That is by design (tearing down mid-continuation would orphan the continuation's writes);
713
+ * on timeout the frontier link is left persisted and leased, so the next boot's recovery re-drives it
714
+ * (no loss) — the cost is spending the full timeout on shutdown. Size `timeoutMs` accordingly.
610
715
  */
611
716
  async drain(timeoutMs = 10_000) {
612
717
  this.draining = true;
613
- if (this.inflight.size === 0)
718
+ if (this.inflight.size === 0 && this.postSettle.size === 0)
614
719
  return;
615
720
  const timer = new Promise((resolve) => {
616
- const t = setTimeout(resolve, timeoutMs);
721
+ const t = setTimeout(() => resolve('timeout'), timeoutMs);
617
722
  t.unref?.();
618
723
  });
619
- await Promise.race([Promise.allSettled([...this.inflight]), timer]);
724
+ // Loop, don't snapshot-once: settling an in-flight run fires post-settle effects (parent notify,
725
+ // singleton wake) that can resume — and thus re-inflight — another run, which settles and fires
726
+ // more effects. Wait until BOTH sets reach steady-state empty, or the shared timer wins. The
727
+ // `Promise<'timeout'>` sentinel is distinguishable from `allSettled`'s array so the race is
728
+ // unambiguous even when everything resolves in the same tick.
729
+ while (this.inflight.size > 0 || this.postSettle.size > 0) {
730
+ const pending = Promise.allSettled([...this.inflight, ...this.postSettle]);
731
+ const outcome = await Promise.race([pending, timer]);
732
+ if (outcome === 'timeout')
733
+ return;
734
+ }
620
735
  }
621
736
  /**
622
737
  * Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
@@ -733,6 +848,16 @@ export class WorkflowEngine {
733
848
  async runOne(runId) {
734
849
  if (this.draining)
735
850
  return null;
851
+ return this.leaseAndResume(runId);
852
+ }
853
+ /**
854
+ * Lease and resume a run WITHOUT the {@link runOne} draining guard. The guard's job is to stop
855
+ * picking up NEW work once shutdown began; but an internal handoff ({@link handoffRun}) completing a
856
+ * continue-as-new continuation or a deferred child is finishing work already in flight — the very
857
+ * work {@link drain} is waiting on — so it must run even while draining, exactly as a signal-driven
858
+ * resume ({@link deliverSignal}) already does. Returns null if another instance holds the lease.
859
+ */
860
+ async leaseAndResume(runId) {
736
861
  const nowMs = this.clock();
737
862
  const acquired = await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs);
738
863
  if (!acquired)
@@ -1147,7 +1272,9 @@ export class WorkflowEngine {
1147
1272
  * knowing about the child feature.
1148
1273
  */
1149
1274
  notifyParent(runId, completion) {
1150
- void this.signal(`child:${runId}`, completion).catch(() => undefined);
1275
+ // Tracked, not bare fire-and-forget: `signal` can wake a suspended parent and resume it, issuing
1276
+ // store writes that must be awaited by `drain()` even though the settling child returned already.
1277
+ this.trackEffect(this.signal(`child:${runId}`, completion));
1151
1278
  // A fix-and-replay run (`<origin>~retry~<hash>`) is standalone, but its SUCCESS is the
1152
1279
  // origin's outcome for all practical purposes: deliver it on the ORIGIN's token too, so a
1153
1280
  // parent that failed on that child and is retried later consumes this success (buffered or
@@ -1155,7 +1282,7 @@ export class WorkflowEngine {
1155
1282
  // fix attempt must not poison the origin's token.
1156
1283
  const at = runId.lastIndexOf('~retry~');
1157
1284
  if (at !== -1 && completion.ok) {
1158
- void this.signal(`child:${runId.slice(0, at)}`, completion).catch(() => undefined);
1285
+ this.trackEffect(this.signal(`child:${runId.slice(0, at)}`, completion));
1159
1286
  }
1160
1287
  }
1161
1288
  /**
@@ -1171,13 +1298,16 @@ export class WorkflowEngine {
1171
1298
  * correctly observes the failed start.
1172
1299
  */
1173
1300
  startChildDeferred(workflow, input, childId, opts) {
1174
- queueMicrotask(() => void this.start(workflow, input, childId, opts).catch((err) => {
1301
+ // Tracked handoff (see {@link handoffRun}): the deferral + reentrancy guard are preserved, but the
1302
+ // start is now held in `postSettle` so `drain()` waits for the child to enter `inflight` and settle
1303
+ // rather than slipping through the gap before the deferred start ran.
1304
+ this.handoffRun(workflow, input, childId, opts, (err) => {
1175
1305
  const message = err instanceof Error ? err.message : String(err);
1176
1306
  this.notifyParent(childId, {
1177
1307
  ok: false,
1178
1308
  error: `child workflow "${workflow}" failed to start: ${message}`,
1179
1309
  });
1180
- }));
1310
+ });
1181
1311
  }
1182
1312
  /**
1183
1313
  * Cancel a run (e.g. from the dashboard). Returns null if the run does not exist. Pass
@@ -1206,11 +1336,17 @@ export class WorkflowEngine {
1206
1336
  .publishControl({ kind: 'cancel', runId, from: this.instanceId })
1207
1337
  .catch(() => undefined);
1208
1338
  }
1209
- queueMicrotask(() => {
1210
- void this.resume(runId)
1211
- .then(() => this.notifyCancelled(runId))
1212
- .catch(() => undefined);
1213
- });
1339
+ // Tracked (see {@link trackEffect}): the background resume replays the run and drives its saga
1340
+ // compensations to the terminal `cancelled` write. As a bare `queueMicrotask` that whole sequence
1341
+ // ran untracked — and because `resume()` only enters `inflight` once it is actually called (a
1342
+ // microtask later), `drain()` could observe both registries empty in the gap and return BEFORE the
1343
+ // compensation writes, letting them land on a torn-down connection ("Transaction query already
1344
+ // complete"). Holding the deferred resume in `postSettle` closes that gap; `resume()` then also
1345
+ // tracks its own execution in `inflight`, so the run is briefly in both sets (harmless — the drain
1346
+ // loop re-snapshots each iteration). NOT a `handoffRun`: that STARTS a new run via `startCore`;
1347
+ // here an EXISTING run is resumed, whose execution already self-tracks — only the pre-call gap
1348
+ // needed covering. The one-microtask defer is preserved; the promise is registered synchronously.
1349
+ this.trackEffect(Promise.resolve().then(() => this.resume(runId).then(() => this.notifyCancelled(runId))));
1214
1350
  await this.cancelChildren(runId, opts);
1215
1351
  return { runId, status: run.status };
1216
1352
  }
@@ -1234,7 +1370,7 @@ export class WorkflowEngine {
1234
1370
  .catch(() => undefined);
1235
1371
  }
1236
1372
  // A cancelled singleton run frees its slot — wake the next gated waiter now (notify-on-release).
1237
- void this.singletons.wakeNext(run).catch(() => undefined);
1373
+ this.trackEffect(this.singletons.wakeNext(run));
1238
1374
  return { runId, status: 'cancelled', error };
1239
1375
  }
1240
1376
  /**
@@ -1506,7 +1642,7 @@ export class WorkflowEngine {
1506
1642
  result.status === 'failed' ||
1507
1643
  result.status === 'cancelled' ||
1508
1644
  result.status === 'dead')) {
1509
- void this.singletons.wakeNext(run).catch(() => undefined);
1645
+ this.trackEffect(this.singletons.wakeNext(run));
1510
1646
  }
1511
1647
  return result;
1512
1648
  }
@@ -1544,7 +1680,7 @@ export class WorkflowEngine {
1544
1680
  namespace: run.namespace,
1545
1681
  output: outcome.output,
1546
1682
  });
1547
- void this.notifyParent(run.id, { ok: true, value: outcome.output });
1683
+ this.notifyParent(run.id, { ok: true, value: outcome.output });
1548
1684
  return { runId: run.id, status: 'completed', output: outcome.output };
1549
1685
  }
1550
1686
  if (outcome.kind === 'failed') {
@@ -1556,7 +1692,7 @@ export class WorkflowEngine {
1556
1692
  namespace: run.namespace,
1557
1693
  error: outcome.error,
1558
1694
  });
1559
- void this.notifyParent(run.id, { ok: false, error: outcome.error.message });
1695
+ this.notifyParent(run.id, { ok: false, error: outcome.error.message });
1560
1696
  return { runId: run.id, status: 'failed', error: outcome.error };
1561
1697
  }
1562
1698
  // This outcome was computed by a turn that started from a possibly-stale run snapshot. If the run
@@ -1948,9 +2084,12 @@ export class WorkflowEngine {
1948
2084
  workflow: run.workflow,
1949
2085
  namespace: run.namespace,
1950
2086
  });
1951
- void this.notifyParent(run.id, { ok: true, value: undefined });
2087
+ this.notifyParent(run.id, { ok: true, value: undefined });
1952
2088
  const nextId = nextContinuationId(run.id);
1953
- queueMicrotask(() => void this.start(run.workflow, err.input, nextId).catch(() => undefined));
2089
+ // Tracked handoff (see {@link handoffRun}): held in `postSettle` from this settle until the
2090
+ // continuation has entered `inflight` and settled, so `drain()` can't return before the next
2091
+ // run's store writes land. Still deferred + idempotent by the continuation id.
2092
+ this.handoffRun(run.workflow, err.input, nextId);
1954
2093
  return { runId: run.id, status: 'completed' };
1955
2094
  }
1956
2095
  if (err instanceof WorkflowBlocked) {
@@ -2104,8 +2243,13 @@ export class WorkflowEngine {
2104
2243
  // Deferred for the same reentrancy reason as `startChild` above. `cancel()` is already
2105
2244
  // idempotent on a terminal/cancelled run (returns its existing status without side effects), so
2106
2245
  // no extra guard is needed here for the failFast replay case (re-issuing the same cancel calls).
2246
+ // Tracked (see {@link trackEffect}): `cancel()` writes the child — and its whole cancel cascade —
2247
+ // to the store, so this fire-and-forget is held in `postSettle`. Fired from a settling parent
2248
+ // (e.g. failFast cancelling surviving siblings), an untracked cancel's writes could otherwise
2249
+ // escape `drain()` onto a torn-down connection ("Transaction query already complete"). The
2250
+ // one-microtask defer is preserved; the promise is registered synchronously.
2107
2251
  cancelChild: (childId) => {
2108
- queueMicrotask(() => void this.cancel(childId).catch(() => undefined));
2252
+ this.trackEffect(Promise.resolve().then(() => this.cancel(childId)));
2109
2253
  },
2110
2254
  // Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
2111
2255
  upsertSearchAttributes: async (runId, attrs) => {
@@ -2115,8 +2259,13 @@ export class WorkflowEngine {
2115
2259
  updatedAt: new Date(),
2116
2260
  });
2117
2261
  },
2262
+ // Tracked (see {@link trackEffect}): `entities.dispatch` persists via `signalWithStart`
2263
+ // (createRun/signal on the entity's run), so this fire-and-forget is held in `postSettle` — else
2264
+ // an entity op signalled from a settling workflow could write after `drain()` returned (torn-down
2265
+ // connection → "Transaction query already complete"). The one-microtask defer is preserved; the
2266
+ // promise is registered synchronously.
2118
2267
  signalEntity: (name, key, op, arg, reply) => {
2119
- queueMicrotask(() => void this.entities.dispatch(name, key, op, arg, reply).catch(() => undefined));
2268
+ this.trackEffect(Promise.resolve().then(() => this.entities.dispatch(name, key, op, arg, reply)));
2120
2269
  },
2121
2270
  interceptStep: (invocation, body) => this.interceptStep(invocation, body),
2122
2271
  };