@adonis-agora/durable 0.45.0 → 0.46.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 (92) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assets/spa/assets/index-CL6q2kMB.js +16 -0
  3. package/dist/assets/spa/assets/index-CL6q2kMB.js.map +1 -0
  4. package/dist/assets/spa/index.html +1 -1
  5. package/dist/src/admission-redis/redis-admission-backend.d.ts +1 -0
  6. package/dist/src/admission-redis/redis-admission-backend.d.ts.map +1 -1
  7. package/dist/src/admission-redis/redis-admission-backend.js +4 -1
  8. package/dist/src/admission-redis/redis-admission-backend.js.map +1 -1
  9. package/dist/src/codec-state-store.d.ts +9 -1
  10. package/dist/src/codec-state-store.d.ts.map +1 -1
  11. package/dist/src/codec-state-store.js +20 -0
  12. package/dist/src/codec-state-store.js.map +1 -1
  13. package/dist/src/commands/worker.d.ts.map +1 -1
  14. package/dist/src/commands/worker.js +2 -0
  15. package/dist/src/commands/worker.js.map +1 -1
  16. package/dist/src/completion.d.ts +2 -0
  17. package/dist/src/completion.d.ts.map +1 -1
  18. package/dist/src/completion.js +5 -0
  19. package/dist/src/completion.js.map +1 -1
  20. package/dist/src/control-plane-redis/redis-control-plane.d.ts.map +1 -1
  21. package/dist/src/control-plane-redis/redis-control-plane.js +3 -2
  22. package/dist/src/control-plane-redis/redis-control-plane.js.map +1 -1
  23. package/dist/src/dashboard/index.d.ts +1 -1
  24. package/dist/src/dashboard/index.js +1 -1
  25. package/dist/src/diagnostics-bridge.d.ts +4 -1
  26. package/dist/src/diagnostics-bridge.d.ts.map +1 -1
  27. package/dist/src/diagnostics-bridge.js +9 -3
  28. package/dist/src/diagnostics-bridge.js.map +1 -1
  29. package/dist/src/engine.d.ts +50 -1
  30. package/dist/src/engine.d.ts.map +1 -1
  31. package/dist/src/engine.js +178 -47
  32. package/dist/src/engine.js.map +1 -1
  33. package/dist/src/errors.d.ts +16 -1
  34. package/dist/src/errors.d.ts.map +1 -1
  35. package/dist/src/errors.js +4 -1
  36. package/dist/src/errors.js.map +1 -1
  37. package/dist/src/fan-join.d.ts +61 -0
  38. package/dist/src/fan-join.d.ts.map +1 -0
  39. package/dist/src/fan-join.js +97 -0
  40. package/dist/src/fan-join.js.map +1 -0
  41. package/dist/src/index.d.ts +2 -1
  42. package/dist/src/index.d.ts.map +1 -1
  43. package/dist/src/index.js +2 -1
  44. package/dist/src/index.js.map +1 -1
  45. package/dist/src/interfaces.d.ts +58 -9
  46. package/dist/src/interfaces.d.ts.map +1 -1
  47. package/dist/src/interfaces.js.map +1 -1
  48. package/dist/src/run-waiting.d.ts +6 -1
  49. package/dist/src/run-waiting.d.ts.map +1 -1
  50. package/dist/src/run-waiting.js +19 -0
  51. package/dist/src/run-waiting.js.map +1 -1
  52. package/dist/src/safe-subscribe.d.ts +11 -0
  53. package/dist/src/safe-subscribe.d.ts.map +1 -0
  54. package/dist/src/safe-subscribe.js +24 -0
  55. package/dist/src/safe-subscribe.js.map +1 -0
  56. package/dist/src/scheduler.d.ts +1 -1
  57. package/dist/src/scheduler.d.ts.map +1 -1
  58. package/dist/src/scheduler.js +4 -0
  59. package/dist/src/scheduler.js.map +1 -1
  60. package/dist/src/schedules.d.ts +2 -0
  61. package/dist/src/schedules.d.ts.map +1 -1
  62. package/dist/src/schedules.js +4 -0
  63. package/dist/src/schedules.js.map +1 -1
  64. package/dist/src/stores/lucid.d.ts +4 -1
  65. package/dist/src/stores/lucid.d.ts.map +1 -1
  66. package/dist/src/stores/lucid.js +62 -0
  67. package/dist/src/stores/lucid.js.map +1 -1
  68. package/dist/src/telescope/data-providers.d.ts.map +1 -1
  69. package/dist/src/telescope/data-providers.js +13 -3
  70. package/dist/src/telescope/data-providers.js.map +1 -1
  71. package/dist/src/testing/in-memory-state-store.d.ts +4 -1
  72. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  73. package/dist/src/testing/in-memory-state-store.js +18 -0
  74. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  75. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  76. package/dist/src/testing-kit/state-store-conformance.js +105 -0
  77. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  78. package/dist/src/transports/bullmq/bullmq-transport.d.ts +10 -0
  79. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  80. package/dist/src/transports/bullmq/bullmq-transport.js +48 -10
  81. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  82. package/dist/src/transports/factory.d.ts +9 -0
  83. package/dist/src/transports/factory.d.ts.map +1 -1
  84. package/dist/src/transports/factory.js +2 -0
  85. package/dist/src/transports/factory.js.map +1 -1
  86. package/dist/src/workflow-ctx.d.ts.map +1 -1
  87. package/dist/src/workflow-ctx.js +214 -70
  88. package/dist/src/workflow-ctx.js.map +1 -1
  89. package/package.json +5 -5
  90. package/skills/durable-workflows/SKILL.md +4 -1
  91. package/dist/assets/spa/assets/index-BCFDK6g_.js +0 -16
  92. package/dist/assets/spa/assets/index-BCFDK6g_.js.map +0 -1
@@ -1,18 +1,21 @@
1
1
  import { InMemoryAdmissionBackend } from './admission.js';
2
2
  import { backoffDelay, MAX_BACKOFF_MS } from './backoff.js';
3
3
  import { instantCheckpoint, lostStepEvent, stepCheckpoint } from './checkpoints.js';
4
- import { assertConcurrency, concurrencyTag, resolveConcurrency, } from './concurrency.js';
4
+ import { isFailedCompletion } from './completion.js';
5
+ import { assertConcurrency, concurrencyTag, countRuns, resolveConcurrency, } from './concurrency.js';
5
6
  import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
6
7
  import { parseDuration } from './duration.js';
7
8
  import { Entities } from './entities.js';
8
9
  import { ContinueAsNew, NonDeterminismError, RemoteStepTimeout, WorkflowSuspended, } from './errors.js';
9
10
  import { EventAccumulators } from './event-accumulators.js';
10
11
  import { eventMatches, eventMatchOf, eventPrefix } from './events.js';
12
+ import { FOLDED_PREFIX, fanOfWaiter, foldedChildIds } from './fan-join.js';
11
13
  import { isNonEmptyRunScope, TERMINAL_RUN_STATUSES } from './interfaces.js';
12
14
  import { asHeartbeat } from './observability-scope.js';
13
15
  import { breakpointToken, stepId } from './protocol.js';
14
16
  import { RemoteWorkflowExecutor } from './remote-workflow-executor.js';
15
17
  import { scanRunValueFacets } from './run-value-facets.js';
18
+ import { listSignalWaitersOfRuns } from './run-waiting.js';
16
19
  import { scheduleWindow } from './scheduler.js';
17
20
  import { ScheduleClient } from './schedules.js';
18
21
  import { SingletonGate } from './singleton-gate.js';
@@ -291,6 +294,7 @@ export class WorkflowEngine {
291
294
  this.schedules = new ScheduleClient(this.store, {
292
295
  start: (workflow, input, runId, opts) => this.start(workflow, input, runId, opts),
293
296
  getRun: (runId) => this.store.getRun(runId),
297
+ isDraining: () => this.draining,
294
298
  }, { namespace: this.namespace, clock: this.clock });
295
299
  this.stalledAfterMs = Math.max(60_000, deps.stalledAfterMs ?? 900_000);
296
300
  this.trackStepStart = deps.trackStepStart ?? true;
@@ -878,10 +882,11 @@ export class WorkflowEngine {
878
882
  * dispatcher the run executes off-instance, so only the durable persist is guaranteed. A start that
879
883
  * throws is handed to `onStartError` (a child delivers it to its waiting parent) instead of vanishing.
880
884
  */
881
- handoffRun(workflow, input, runId, opts, onStartError) {
885
+ handoffRun(workflow, input, runId, opts, onStartError, onAlreadySettled) {
882
886
  this.trackEffect((async () => {
883
887
  // Defer one microtask: never reentrantly resume the parent that is still suspending/settling.
884
888
  await Promise.resolve();
889
+ let result;
885
890
  try {
886
891
  if (this.usesInProcessDispatch) {
887
892
  // Persist WITHOUT the fire-and-forget dispatch, then drive the pickup ourselves so this
@@ -889,7 +894,7 @@ export class WorkflowEngine {
889
894
  // stays tracked until the run has entered `inflight` and settled. `runOne` no-ops (returns
890
895
  // null) on replay (the run already exists) or while draining (the run is left `pending` for
891
896
  // recovery) — the durable persist above is what matters on those paths.
892
- const result = await this.startCore(workflow, input, runId, opts, false);
897
+ result = await this.startCore(workflow, input, runId, opts, false);
893
898
  // Guard-free pickup: the continuation/child is in-flight work `drain()` is waiting on, so it
894
899
  // runs even while draining (a `runOne` here would no-op on `this.draining`, stranding it
895
900
  // `pending` after its persist — persisted-but-not-processed). Mirrors a signal-driven resume.
@@ -899,12 +904,19 @@ export class WorkflowEngine {
899
904
  else {
900
905
  // Custom dispatcher owns execution (possibly on another pod); guarantee the persist + its
901
906
  // normal dispatch/broadcast. The in-process rolled-back-transaction hazard doesn't apply.
902
- await this.startCore(workflow, input, runId, opts, true);
907
+ result = await this.startCore(workflow, input, runId, opts, true);
903
908
  }
904
909
  }
905
910
  catch (err) {
906
911
  if (onStartError)
907
912
  onStartError(err);
913
+ return;
914
+ }
915
+ // A new run is never terminal, so a terminal result is a run that already existed.
916
+ if (onAlreadySettled &&
917
+ result &&
918
+ TERMINAL_RUN_STATUSES.includes(result.status)) {
919
+ await onAlreadySettled(result);
908
920
  }
909
921
  })());
910
922
  }
@@ -1356,6 +1368,19 @@ export class WorkflowEngine {
1356
1368
  this.postSettle.add(tracked);
1357
1369
  void tracked.finally(() => this.postSettle.delete(tracked));
1358
1370
  }
1371
+ /**
1372
+ * Stop picking up new work without waiting: recovery, timer, pending, schedule and timeout sweeps
1373
+ * (and their per-run lock loops) start nothing once this is called, and {@link runOne} refuses
1374
+ * broker-delivered runs. Runs already started finish. {@link drain} calls it first; the worker loop
1375
+ * calls it the moment a stop is requested, so a tick still in flight stops leasing a backlog.
1376
+ */
1377
+ beginDrain() {
1378
+ this.draining = true;
1379
+ }
1380
+ /** True once {@link beginDrain} (or {@link drain}) has been called. */
1381
+ isDraining() {
1382
+ return this.draining;
1383
+ }
1359
1384
  /**
1360
1385
  * Graceful shutdown: stop picking up new runs (recovery/timer become no-ops) and wait for
1361
1386
  * in-flight executions to settle, up to `timeoutMs`. Call from your app's shutdown hook so a
@@ -1376,7 +1401,7 @@ export class WorkflowEngine {
1376
1401
  * `stepId`, idempotent by the durable contract; a completed step short-circuits).
1377
1402
  */
1378
1403
  async drain(timeoutMs = 10_000) {
1379
- this.draining = true;
1404
+ this.beginDrain();
1380
1405
  if (this.inflight.size === 0 && this.postSettle.size === 0)
1381
1406
  return;
1382
1407
  const timer = new Promise((resolve) => {
@@ -1423,6 +1448,8 @@ export class WorkflowEngine {
1423
1448
  */
1424
1449
  async sweepTimeouts(now = this.clock()) {
1425
1450
  for (const reg of new Set(this.latest.values())) {
1451
+ if (this.draining)
1452
+ return;
1426
1453
  if (reg.executionTimeoutMs == null)
1427
1454
  continue;
1428
1455
  // Push the age predicate into the store: only candidates already past the LATEST version's
@@ -1437,6 +1464,8 @@ export class WorkflowEngine {
1437
1464
  size: 200,
1438
1465
  });
1439
1466
  for (const run of inflight) {
1467
+ if (this.draining)
1468
+ return;
1440
1469
  // Time out against the version the run STARTED on when that registration is known — a fleet
1441
1470
  // mid-deploy must not sweep old-version runs by the newest version's (possibly shorter)
1442
1471
  // timeout. Falls back to the latest registration's timeout for an unknown version.
@@ -1500,6 +1529,8 @@ export class WorkflowEngine {
1500
1529
  return (await this.store.listIncompleteRuns(this.namespace)).filter((r) => r.lockedUntil === undefined || r.lockedUntil <= nowMs);
1501
1530
  });
1502
1531
  for (const listed of incomplete) {
1532
+ if (this.draining)
1533
+ break; // stop requested mid-sweep
1503
1534
  // A live worker renews its lease, so an acquirable lease means the run is genuinely orphaned
1504
1535
  // (its worker crashed). Skip the ones still owned.
1505
1536
  const acquired = await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
@@ -1652,8 +1683,8 @@ export class WorkflowEngine {
1652
1683
  // - a `failed` checkpoint becomes retryable-now (attempts 0, wake immediately, `retryable`
1653
1684
  // forced back on): the durable-retry machinery re-dispatches it as a fresh first attempt;
1654
1685
  // - an awaited-child `signal:child:` checkpoint holding a FAILURE completion returns to its
1655
- // live `running` placeholder, so replay re-registers the `child:<id>` waiter — a separately
1656
- // retried child's completion (already buffered, or still to come) then resumes this run.
1686
+ // live `running` placeholder with its `child:<id>` waiter put back — a separately retried
1687
+ // child's completion (already buffered, or still to come) then resumes this run.
1657
1688
  // Retry child and parent in either order; signal buffering makes it converge.
1658
1689
  if (run.status === 'failed' || run.status === 'dead') {
1659
1690
  const isFailureCompletion = (v) => typeof v === 'object' && v !== null && v.ok === false;
@@ -1670,16 +1701,15 @@ export class WorkflowEngine {
1670
1701
  cp.name.startsWith('signal:child:') &&
1671
1702
  cp.status === 'completed' &&
1672
1703
  isFailureCompletion(cp.output)) {
1673
- await this.store.saveCheckpoint({ ...cp, status: 'running', output: undefined });
1674
- // CASCADE: retrying only the parent is useless in a live engine — replay re-registers
1675
- // the `child:<id>` waiter and the reconciler re-delivers the child's still-FAILED
1676
- // terminal state within seconds, re-failing the parent with the same error. Requeue the
1677
- // failed child too so the pair converges: whichever finishes the handshake last (waiter
1678
- // registered vs completion buffered) resumes the parent.
1704
+ // CASCADE: retrying only the parent is useless in a live engine — the reconciler
1705
+ // re-delivers the child's still-FAILED terminal state within seconds, re-failing the
1706
+ // parent with the same error. Requeue the failed child too so the pair converges:
1707
+ // whichever finishes the handshake last (waiter registered vs completion buffered)
1708
+ // resumes the parent.
1679
1709
  //
1680
1710
  // Exception: a SUCCESS already buffered on this token (a completed `~retry~` fix of the
1681
- // child — see notifyParent's origin delivery) is the outcome the replay will consume, so
1682
- // re-running the failed origin would be pure waste. Peek = take + re-buffer.
1711
+ // child — see notifyParent's origin delivery) is the outcome, so re-running the failed
1712
+ // origin would be pure waste: settle the checkpoint with it right here.
1683
1713
  const childId = cp.name.slice('signal:child:'.length);
1684
1714
  // Drain-and-restore keeps relative order (a partial take+re-buffer would rotate it).
1685
1715
  const parked = [];
@@ -1689,11 +1719,27 @@ export class WorkflowEngine {
1689
1719
  break;
1690
1720
  parked.push(buffered.payload);
1691
1721
  }
1692
- for (const payload of parked)
1693
- await this.store.bufferSignal(`child:${childId}`, payload);
1694
- const bufferedSuccess = parked.some((payload) => typeof payload === 'object' &&
1722
+ const successAt = parked.findIndex((payload) => typeof payload === 'object' &&
1695
1723
  payload !== null &&
1696
1724
  payload.ok === true);
1725
+ const success = successAt === -1 ? undefined : parked.splice(successAt, 1)[0];
1726
+ for (const payload of parked)
1727
+ await this.store.bufferSignal(`child:${childId}`, payload);
1728
+ const bufferedSuccess = success !== undefined;
1729
+ if (bufferedSuccess) {
1730
+ await this.store.saveCheckpoint({ ...cp, output: success });
1731
+ }
1732
+ else {
1733
+ // Back to a live `running` placeholder. Its waiter goes first: replay reads a
1734
+ // placeholder as proof its waiter exists and never re-puts it.
1735
+ await this.store.putSignalWaiter({
1736
+ token: `child:${childId}`,
1737
+ runId,
1738
+ seq: cp.seq,
1739
+ parallelGroup: cp.parallelGroup,
1740
+ });
1741
+ await this.store.saveCheckpoint({ ...cp, status: 'running', output: undefined });
1742
+ }
1697
1743
  if (!bufferedSuccess) {
1698
1744
  const child = await this.store.getRun(childId);
1699
1745
  if (child && (child.status === 'failed' || child.status === 'dead')) {
@@ -1865,6 +1911,9 @@ export class WorkflowEngine {
1865
1911
  next += 1;
1866
1912
  if (!run)
1867
1913
  return;
1914
+ // Re-check per run: a stop requested mid-tick must not lock/start the rest of the backlog.
1915
+ if (this.draining)
1916
+ return;
1868
1917
  const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
1869
1918
  if (!acquired)
1870
1919
  continue;
@@ -1978,8 +2027,60 @@ export class WorkflowEngine {
1978
2027
  // for an ordinary (non-fan) signal/child await.
1979
2028
  parallelGroup: waiter.parallelGroup,
1980
2029
  }));
2030
+ if (await this.fanStillWaiting(waiter, payload)) {
2031
+ return { runId: waiter.runId, status: 'suspended' };
2032
+ }
1981
2033
  return this.resumeImplicit(waiter.runId);
1982
2034
  }
2035
+ /**
2036
+ * The `ctx.all` join barrier: true when `waiter` was one child of a fan (or of one wave of it)
2037
+ * whose siblings are still out, so waking the parent now would only replay the fan and re-suspend.
2038
+ * Skipping that wake is what keeps a fan of N children at O(N) work instead of N replays of N items.
2039
+ *
2040
+ * Safe because the LAST sibling to settle always wakes the parent: each child consumes its own
2041
+ * waiter before it asks, so whichever asks last sees none left. A failed child of a failFast fan
2042
+ * always wakes it. Anything that is not a `ctx.all` waiter, or a store that cannot answer the
2043
+ * question cheaply, wakes the parent as before.
2044
+ */
2045
+ async fanStillWaiting(waiter, payload) {
2046
+ if (!this.store.hasSignalWaiter)
2047
+ return false;
2048
+ const fan = fanOfWaiter(waiter);
2049
+ if (!fan)
2050
+ return false;
2051
+ if (fan.failFast && isFailedCompletion(payload))
2052
+ return false;
2053
+ return this.store.hasSignalWaiter(waiter.runId, fan.waiterPrefix);
2054
+ }
2055
+ /**
2056
+ * Re-drive a run that just suspended on awaited children (a `ctx.all` fan or wave, a `ctx.child`)
2057
+ * which, by now, have settled.
2058
+ *
2059
+ * A wake that finds the run's lease held is dropped. So if a child settles while the parent is
2060
+ * still executing (the turn that dispatched it, or one woken by an earlier child), that wake is
2061
+ * spent, and the turn suspends on a snapshot that predates the child. With the `ctx.all` barrier
2062
+ * that wake was the only one the fan would get, and nothing would wake the run until the
2063
+ * `reconcileMs` sweep.
2064
+ *
2065
+ * Runs after the turn released its lease, so any child that settles from here on wakes the run
2066
+ * itself. Only re-drives for a group whose checkpoints are all settled, which the next turn is
2067
+ * then guaranteed to see, so it cannot loop. A group that still has a waiter is skipped without
2068
+ * reading its checkpoints.
2069
+ */
2070
+ async redriveSettledChildren(runId, fan) {
2071
+ for (const wait of fan.waits) {
2072
+ if (await this.store.hasSignalWaiter?.(runId, wait.waiterPrefix))
2073
+ continue;
2074
+ const checkpoints = this.store.listCheckpointsByNamePrefix
2075
+ ? await this.store.listCheckpointsByNamePrefix(runId, [wait.checkpointPrefix])
2076
+ : (await this.store.listCheckpoints(runId)).filter((cp) => cp.name.startsWith(wait.checkpointPrefix));
2077
+ const group = checkpoints.filter((cp) => cp.name.startsWith(wait.checkpointPrefix));
2078
+ if (group.length > 0 && group.every((cp) => cp.status === 'completed')) {
2079
+ await this.resumeImplicit(runId);
2080
+ return;
2081
+ }
2082
+ }
2083
+ }
1983
2084
  /**
1984
2085
  * Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
1985
2086
  * the run result — or null if no run is (or ends up) waiting for it.
@@ -2092,7 +2193,23 @@ export class WorkflowEngine {
2092
2193
  ok: false,
2093
2194
  error: `child workflow "${workflow}" failed to start: ${message}`,
2094
2195
  });
2095
- });
2196
+ }, (settled) => this.redeliverSettledChild(childId, settled));
2197
+ }
2198
+ /**
2199
+ * A child start that found the child already terminal: its completion may never reach the waiter
2200
+ * the parent just put (it was buffered before that waiter existed, or lost between the child's
2201
+ * terminal write and its notify). Deliver it again, preferring a buffered copy, so the awaiting
2202
+ * parent does not need a run lookup per child to notice.
2203
+ */
2204
+ async redeliverSettledChild(childId, settled) {
2205
+ const token = `child:${childId}`;
2206
+ const buffered = await this.store.takeBufferedSignal(token);
2207
+ const completion = buffered
2208
+ ? buffered.payload
2209
+ : settled.status === 'completed'
2210
+ ? { ok: true, value: settled.output }
2211
+ : { ok: false, error: settled.error?.message ?? settled.status };
2212
+ await this.signal(token, completion);
2096
2213
  }
2097
2214
  /**
2098
2215
  * Cancel a run (e.g. from the dashboard). Returns null if the run does not exist. Pass
@@ -2207,32 +2324,23 @@ export class WorkflowEngine {
2207
2324
  * so a completed parent (or a completed awaited child) would otherwise drop out of the tree. The
2208
2325
  * `signal:child:<id>` checkpoint (the placeholder written when the parent first awaits the child,
2209
2326
  * overwritten as completed/failed when it settles) persists across completion, so we read both and
2210
- * dedupe — the edge stays stable for a finished run, not just a live one.
2327
+ * dedupe — the edge stays stable for a finished run, not just a live one. A settled `ctx.all`
2328
+ * group folded into one `fan:` checkpoint stands for every child in it.
2211
2329
  */
2212
2330
  async getRunChildren(parentRunId) {
2213
2331
  const childIds = new Set();
2214
- // Targeted per-run lookup when the store has it (an indexed `WHERE run_id`); the prefix scan
2215
- // fallback walks EVERY `child:` waiter row for one parent.
2216
- const waiters = this.store.listSignalWaitersByRunIds
2217
- ? await this.store.listSignalWaitersByRunIds([parentRunId])
2218
- : await this.store.listSignalWaiters('child:');
2219
- for (const w of waiters) {
2220
- if (w.runId === parentRunId && w.token.startsWith('child:')) {
2332
+ // The parent's own waiters only (an indexed `WHERE run_id` when the store has it).
2333
+ for (const w of await listSignalWaitersOfRuns(this.store, [parentRunId])) {
2334
+ if (w.token.startsWith('child:'))
2221
2335
  childIds.add(w.token.slice('child:'.length));
2222
- }
2223
2336
  }
2224
- // Targeted read: only the `signal:child:` / `spawn:` checkpoints, not the whole history. Falls
2225
- // back to a full listCheckpoints + in-JS prefix scan for a custom store that omits the method.
2226
- const prefixes = ['signal:child:', 'spawn:'];
2337
+ // Targeted read: only the child-edge checkpoints, not the whole history. Falls back to a full
2338
+ // listCheckpoints + in-JS prefix scan for a custom store that omits the method.
2227
2339
  const childCheckpoints = this.store.listCheckpointsByNamePrefix
2228
- ? await this.store.listCheckpointsByNamePrefix(parentRunId, prefixes)
2229
- : (await this.store.listCheckpoints(parentRunId)).filter((cp) => prefixes.some((p) => cp.name.startsWith(p)));
2230
- for (const cp of childCheckpoints) {
2231
- if (cp.name.startsWith('signal:child:'))
2232
- childIds.add(cp.name.slice('signal:child:'.length));
2233
- if (cp.name.startsWith('spawn:') && typeof cp.output === 'string')
2234
- childIds.add(cp.output);
2235
- }
2340
+ ? await this.store.listCheckpointsByNamePrefix(parentRunId, CHILD_EDGE_PREFIXES)
2341
+ : (await this.store.listCheckpoints(parentRunId)).filter((cp) => CHILD_EDGE_PREFIXES.some((p) => cp.name.startsWith(p)));
2342
+ for (const id of childIdsOf(childCheckpoints))
2343
+ childIds.add(id);
2236
2344
  return [...childIds];
2237
2345
  }
2238
2346
  /**
@@ -2251,14 +2359,15 @@ export class WorkflowEngine {
2251
2359
  * stamp. Prefers the store's indexed method; falls back to filtering the full scan for a custom
2252
2360
  * store without it.
2253
2361
  */
2254
- async listSignalWaitersByRunIds(runIds) {
2255
- if (runIds.length === 0)
2256
- return [];
2257
- if (this.store.listSignalWaitersByRunIds) {
2258
- return this.store.listSignalWaitersByRunIds(runIds);
2259
- }
2260
- const ids = new Set(runIds);
2261
- return (await this.store.listSignalWaiters('')).filter((w) => ids.has(w.runId));
2362
+ listSignalWaitersByRunIds(runIds) {
2363
+ return listSignalWaitersOfRuns(this.store, runIds);
2364
+ }
2365
+ /**
2366
+ * How many runs match `query`: an exact `COUNT` through the store's `countRuns`, or the length of
2367
+ * the full listing for a store without one.
2368
+ */
2369
+ countRuns(query) {
2370
+ return countRuns(this.store, query);
2262
2371
  }
2263
2372
  /** The worker groups this engine dispatches to: every registered remote workflow's group, plus any
2264
2373
  * `extra` the caller declares. Local-step groups (a group consumed by in-process `@DurableStep`
@@ -3211,6 +3320,7 @@ export class WorkflowEngine {
3211
3320
  const replay = new Map();
3212
3321
  for (const cp of snapshot)
3213
3322
  replay.set(cp.seq, cp);
3323
+ let suspendedOnFan;
3214
3324
  const ctx = createWorkflowCtx(this.ctxHostFor(replay, run.namespace), run.id, compensations, run.workflow);
3215
3325
  // Every settle below is `return await`ed, never a bare `return this.settleRun(...)`: inside
3216
3326
  // `try … finally` a bare return runs the `finally` — which RELEASES THE LEASE — as soon as the
@@ -3322,6 +3432,8 @@ export class WorkflowEngine {
3322
3432
  if (settled.status === 'suspended' && err.waitSeqs?.length) {
3323
3433
  this.recheckWaitSeqs(run.id, err.waitSeqs);
3324
3434
  }
3435
+ if (settled.status === 'suspended')
3436
+ suspendedOnFan = err.fan;
3325
3437
  return settled;
3326
3438
  }
3327
3439
  const error = {
@@ -3362,6 +3474,10 @@ export class WorkflowEngine {
3362
3474
  catch (releaseErr) {
3363
3475
  console.warn(`[adonis-durable] releasing the lease of run ${run.id} failed (it expires on its own and recovery re-drives): ${releaseErr instanceof Error ? releaseErr.message : String(releaseErr)}`);
3364
3476
  }
3477
+ if (suspendedOnFan) {
3478
+ const fan = suspendedOnFan;
3479
+ this.trackEffect(this.redriveSettledChildren(run.id, fan));
3480
+ }
3365
3481
  }
3366
3482
  }
3367
3483
  /**
@@ -4330,4 +4446,19 @@ export class RemoteStepError extends Error {
4330
4446
  this.stepError = stepError;
4331
4447
  }
4332
4448
  }
4449
+ /** Checkpoint names that record a parent→child edge: an awaited child, a folded group of them, a spawn. */
4450
+ const CHILD_EDGE_PREFIXES = ['signal:child:', FOLDED_PREFIX, 'spawn:'];
4451
+ /** The child run ids a parent's {@link CHILD_EDGE_PREFIXES} checkpoints point at. */
4452
+ function childIdsOf(checkpoints) {
4453
+ const ids = [];
4454
+ for (const cp of checkpoints) {
4455
+ if (cp.name.startsWith('signal:child:'))
4456
+ ids.push(cp.name.slice('signal:child:'.length));
4457
+ if (cp.name.startsWith(FOLDED_PREFIX))
4458
+ ids.push(...foldedChildIds(cp));
4459
+ if (cp.name.startsWith('spawn:') && typeof cp.output === 'string')
4460
+ ids.push(cp.output);
4461
+ }
4462
+ return ids;
4463
+ }
4333
4464
  //# sourceMappingURL=engine.js.map