@adonis-agora/durable 0.53.0 → 0.54.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 (57) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/src/buffered-child-signals.d.ts +46 -0
  3. package/dist/src/buffered-child-signals.d.ts.map +1 -0
  4. package/dist/src/buffered-child-signals.js +293 -0
  5. package/dist/src/buffered-child-signals.js.map +1 -0
  6. package/dist/src/codec-state-store.d.ts +9 -1
  7. package/dist/src/codec-state-store.d.ts.map +1 -1
  8. package/dist/src/codec-state-store.js +26 -0
  9. package/dist/src/codec-state-store.js.map +1 -1
  10. package/dist/src/commands/worker.d.ts +2 -1
  11. package/dist/src/commands/worker.d.ts.map +1 -1
  12. package/dist/src/commands/worker.js +44 -0
  13. package/dist/src/commands/worker.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 +63 -1
  17. package/dist/src/engine.d.ts.map +1 -1
  18. package/dist/src/engine.js +326 -56
  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 +38 -0
  23. package/dist/src/interfaces.d.ts.map +1 -1
  24. package/dist/src/interfaces.js.map +1 -1
  25. package/dist/src/stores/lucid.d.ts +5 -1
  26. package/dist/src/stores/lucid.d.ts.map +1 -1
  27. package/dist/src/stores/lucid.js +111 -5
  28. package/dist/src/stores/lucid.js.map +1 -1
  29. package/dist/src/testing/in-memory-state-store.d.ts +5 -1
  30. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  31. package/dist/src/testing/in-memory-state-store.js +42 -3
  32. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  33. package/dist/src/testing-kit/run-dispatch-contract.d.ts.map +1 -1
  34. package/dist/src/testing-kit/run-dispatch-contract.js +98 -0
  35. package/dist/src/testing-kit/run-dispatch-contract.js.map +1 -1
  36. package/dist/src/testing-kit/state-store-conformance.d.ts +6 -0
  37. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  38. package/dist/src/testing-kit/state-store-conformance.js +189 -0
  39. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  40. package/dist/src/transport-pool.d.ts +7 -0
  41. package/dist/src/transport-pool.d.ts.map +1 -1
  42. package/dist/src/transport-pool.js +28 -0
  43. package/dist/src/transport-pool.js.map +1 -1
  44. package/dist/src/transports/bullmq/bullmq-transport.d.ts +10 -4
  45. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  46. package/dist/src/transports/bullmq/bullmq-transport.js +44 -1
  47. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  48. package/dist/src/transports/db.d.ts +9 -1
  49. package/dist/src/transports/db.d.ts.map +1 -1
  50. package/dist/src/transports/db.js +106 -36
  51. package/dist/src/transports/db.js.map +1 -1
  52. package/dist/src/transports/queue.d.ts +9 -1
  53. package/dist/src/transports/queue.d.ts.map +1 -1
  54. package/dist/src/transports/queue.js +28 -1
  55. package/dist/src/transports/queue.js.map +1 -1
  56. package/package.json +3 -3
  57. package/skills/durable-transports-stores/SKILL.md +11 -10
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { InMemoryAdmissionBackend } from './admission.js';
3
3
  import { backoffDelay, MAX_BACKOFF_MS } from './backoff.js';
4
4
  import { BoundedRunner } from './bounded-runner.js';
5
+ import { CHILD_TOKEN_PREFIX, childCompletionNeedsBuffer, unneededChildSignals, } from './buffered-child-signals.js';
5
6
  import { instantCheckpoint, lostStepEvent, stepCheckpoint } from './checkpoints.js';
6
7
  import { CHILD_DELIVERED_TAG, markChildDelivered } from './child-delivery.js';
7
8
  import { completionError, failedCompletion, isFailedCompletion, } from './completion.js';
@@ -79,6 +80,31 @@ const versionKey = (name, version) => `${name}@${version}`;
79
80
  * state, so it must never match the key of the run's earlier start, even within the same millisecond.
80
81
  */
81
82
  const pendingKey = (at) => `${at.getTime()}-${globalThis.crypto.randomUUID().slice(0, 8)}`;
83
+ /** The buffered-event name under which the reasons of a run's refused wakes are kept (see `requestWake`). */
84
+ const wakeRequestName = (runId) => `durable:wake-request:${runId}`;
85
+ /** The most kept wake reasons of one run read at once. */
86
+ const WAKE_REQUEST_SCAN = 100;
87
+ /** The fan wake a settled step result brings: none for a step outside a fan, or a failure. */
88
+ function fanWakeOf(cp, result) {
89
+ const fan = parseStepFanGroup(cp.parallelGroup);
90
+ if (!fan || result.status === 'failed')
91
+ return undefined;
92
+ return { first: fan.firstSeq, seq: cp.seq };
93
+ }
94
+ /**
95
+ * Whether a kept wake reason (`payload` of a wake-request event) was already seen by a turn that read
96
+ * the fans in `settledFans` (first seq -> the highest seq it read with nothing in flight): a fan
97
+ * wake the turn read settled at least that far. A generic reason (`null`) is never seen.
98
+ */
99
+ function wakeReasonSeen(payload, settledFans) {
100
+ const fans = payload?.fans;
101
+ if (!Array.isArray(fans) || fans.length === 0)
102
+ return false;
103
+ return fans.every((pair) => Array.isArray(pair) &&
104
+ typeof pair[0] === 'number' &&
105
+ typeof pair[1] === 'number' &&
106
+ (settledFans.get(pair[0]) ?? -1) >= pair[1]);
107
+ }
82
108
  /** Whether `status` is one a run never leaves on its own. */
83
109
  const isTerminalRun = (status) => TERMINAL_RUN_STATUSES.includes(status);
84
110
  /**
@@ -92,6 +118,17 @@ const COMPENSATION_SEQ_BASE = -2;
92
118
  const STEP_FAN_CHUNK = 200;
93
119
  /** The default {@link WorkflowEngineDeps.timerBatchSize}. */
94
120
  const DEFAULT_TIMER_BATCH = 500;
121
+ /** {@link WorkflowEngine.sweepBufferedSignals}: rows read per page. */
122
+ const BUFFERED_SWEEP_PAGE = 500;
123
+ /** {@link WorkflowEngine.sweepBufferedSignals}: pages per call, so one tick stays short. */
124
+ const BUFFERED_SWEEP_PAGES_PER_CALL = 4;
125
+ /**
126
+ * {@link WorkflowEngine.sweepBufferedSignals}: wall-clock budget of one call. Past it the sweep
127
+ * starts no further page or parent read, and the next call resumes where this one stopped.
128
+ */
129
+ const BUFFERED_SWEEP_BUDGET_MS = 250;
130
+ /** {@link WorkflowEngine.sweepBufferedSignals}: rest between two full passes over the table. */
131
+ const BUFFERED_SWEEP_REST_MS = 10 * 60_000;
95
132
  /** The most results of one run settled by one batched write (see `completeRemoteResult`). */
96
133
  const RESULT_BATCH_MAX = 256;
97
134
  /**
@@ -361,6 +398,19 @@ export class WorkflowEngine {
361
398
  runEnqueueFailures = 0;
362
399
  /** Re-checks scheduled for runs whose wake found another instance's lease (see {@link recheckAfterLease}). */
363
400
  leaseRetries = new Map();
401
+ /** Why each run is being woken on this instance, until the turn that answers it starts. */
402
+ wakeReasons = new Map();
403
+ /**
404
+ * The step fans each executing turn on this instance read with nothing in flight: first seq -> the
405
+ * highest seq read. Written by {@link callRemoteAll}, read when the turn lets go of the lease.
406
+ */
407
+ turnSettledFans = new Map();
408
+ /** The kept wake reasons each executing turn on this instance serves (see {@link execute}). */
409
+ turnServedWakes = new Map();
410
+ /** {@link sweepBufferedSignals}: the id the pass in progress resumes after, `undefined` between passes. */
411
+ bufferedSweepCursor;
412
+ bufferedSweepActive = false;
413
+ lastBufferedSweepEndedAt = Number.NEGATIVE_INFINITY;
364
414
  draining = false;
365
415
  /** Shutdown wait already spent before/alongside {@link drain} (see {@link beginDrain}). */
366
416
  drainSpentMs = 0;
@@ -1529,6 +1579,68 @@ export class WorkflowEngine {
1529
1579
  * deleted (the parent→children edge is read from them). Use for retention/cleanup; this is a
1530
1580
  * destructive store operation, not a cancellation — a running run should be {@link cancel}led first.
1531
1581
  */
1582
+ /**
1583
+ * Delete buffered child completions no parent will ever consume (see
1584
+ * `childCompletionNeedsBuffer`): those of root runs, of children whose parent already settled them
1585
+ * or is gone, or terminal for good. Earlier versions buffered every completion that found no
1586
+ * waiter, and nothing else ever removes such rows.
1587
+ *
1588
+ * Walks the buffered signals by primary key, {@link BUFFERED_SWEEP_PAGE} rows at a time and at most
1589
+ * {@link BUFFERED_SWEEP_PAGES_PER_CALL} pages per call, so a large backlog drains over a few worker
1590
+ * ticks without a long query or a big delete. Each page is decided set-based (see
1591
+ * `unneededChildSignals`): a handful of store calls per page and per open parent, none of them a
1592
+ * read by checkpoint name for an id that names its seq. A call stops once `budgetMs` of wall-clock
1593
+ * time is spent (default {@link BUFFERED_SWEEP_BUDGET_MS}), between pages or between two parents'
1594
+ * reads, and the next call resumes there. A pass that reaches the end rests
1595
+ * {@link BUFFERED_SWEEP_REST_MS} before the next. The worker loop starts it every tick without
1596
+ * waiting for it; a call made while another is running returns 0. Skipped while draining, on a
1597
+ * namespaced engine (a buffered signal carries no namespace, and a scoped read would report another
1598
+ * tenant's parent as gone), and on a store without `listBufferedSignals`/`deleteBufferedSignals`.
1599
+ * Returns how many rows it deleted.
1600
+ */
1601
+ async sweepBufferedSignals(now = this.clock(), budgetMs = BUFFERED_SWEEP_BUDGET_MS) {
1602
+ const { listBufferedSignals, deleteBufferedSignals } = this.store;
1603
+ if (!listBufferedSignals || !deleteBufferedSignals)
1604
+ return 0;
1605
+ if (this.draining || this.namespace !== undefined || this.bufferedSweepActive)
1606
+ return 0;
1607
+ if (this.bufferedSweepCursor === undefined &&
1608
+ now - this.lastBufferedSweepEndedAt < BUFFERED_SWEEP_REST_MS) {
1609
+ return 0;
1610
+ }
1611
+ this.bufferedSweepActive = true;
1612
+ const deadline = Date.now() + budgetMs;
1613
+ let deleted = 0;
1614
+ try {
1615
+ for (let page = 0; page < BUFFERED_SWEEP_PAGES_PER_CALL; page += 1) {
1616
+ if (page > 0 && (Date.now() >= deadline || this.draining))
1617
+ break;
1618
+ const rows = await listBufferedSignals.call(this.store, CHILD_TOKEN_PREFIX, this.bufferedSweepCursor, BUFFERED_SWEEP_PAGE);
1619
+ const { doomed, decided } = await unneededChildSignals(this.store, rows, deadline);
1620
+ if (doomed.length > 0) {
1621
+ await deleteBufferedSignals.call(this.store, doomed.map((row) => row.id));
1622
+ deleted += doomed.length;
1623
+ }
1624
+ if (decided < rows.length) {
1625
+ // Out of time mid-page: resume after the last row decided.
1626
+ if (decided > 0)
1627
+ this.bufferedSweepCursor = rows[decided - 1].id;
1628
+ break;
1629
+ }
1630
+ const last = rows.at(-1);
1631
+ if (rows.length < BUFFERED_SWEEP_PAGE || !last) {
1632
+ this.bufferedSweepCursor = undefined;
1633
+ this.lastBufferedSweepEndedAt = now;
1634
+ break;
1635
+ }
1636
+ this.bufferedSweepCursor = last.id;
1637
+ }
1638
+ }
1639
+ finally {
1640
+ this.bufferedSweepActive = false;
1641
+ }
1642
+ return deleted;
1643
+ }
1532
1644
  /**
1533
1645
  * Register a pager hook for STRANDED runs — the failure `docs/reliability/failure-modes.mdx` calls
1534
1646
  * "the stranded signature": an in-flight run untouched past `stalledAfterMs` that is either
@@ -1898,9 +2010,19 @@ export class WorkflowEngine {
1898
2010
  * {@link resume}) may re-drive it. Otherwise a stale waiter or a late result left behind by a
1899
2011
  * failed run silently re-executes its body.
1900
2012
  */
1901
- resumeImplicit(runId) {
2013
+ resumeImplicit(runId, fan) {
2014
+ this.noteWakeReason(runId, fan);
1902
2015
  return this.resumeRun(runId, false);
1903
2016
  }
2017
+ /** Record why `runId` is being woken: a step fan's barrier opening, or (`undefined`) anything else. */
2018
+ noteWakeReason(runId, fan) {
2019
+ const reason = this.wakeReasons.get(runId) ?? { generic: false, fans: new Map() };
2020
+ if (fan)
2021
+ reason.fans.set(fan.first, Math.max(reason.fans.get(fan.first) ?? fan.seq, fan.seq));
2022
+ else
2023
+ reason.generic = true;
2024
+ this.wakeReasons.set(runId, reason);
2025
+ }
1904
2026
  /**
1905
2027
  * One execution of a run at a time in this process. A resume that lands while the run executes
1906
2028
  * here neither joins that execution (the double-run) nor no-ops against its held lease (a lost
@@ -3185,6 +3307,12 @@ export class WorkflowEngine {
3185
3307
  ((await this.store.getRun(childId))?.tags?.includes(CHILD_DELIVERED_TAG) ?? false);
3186
3308
  if (await delivered())
3187
3309
  return;
3310
+ // Buffer it only for a parent that can still consume it (see childCompletionNeedsBuffer): a
3311
+ // root run, a child whose parent is gone, finished for good or already holds the outcome
3312
+ // would leave a row nothing ever takes.
3313
+ if (!(await childCompletionNeedsBuffer(this.store, token.slice(CHILD_TOKEN_PREFIX.length), completion))) {
3314
+ return;
3315
+ }
3188
3316
  const resumed = await this.signal(token, completion);
3189
3317
  // The parent may settle the child from its run row between the look above and the buffer
3190
3318
  // write (a join that registers its waiter only now): it then stamped the child first, and the
@@ -3632,11 +3760,20 @@ export class WorkflowEngine {
3632
3760
  * ({@link recheckAfterLease}) runs too, as the fast path when the holder goes down mid-turn; it
3633
3761
  * stops as soon as a turn served the request.
3634
3762
  */
3635
- async requestWake(runId, lockedUntil) {
3763
+ async requestWake(runId, lockedUntil, reason = { generic: true, fans: new Map() }) {
3636
3764
  if (this.draining)
3637
3765
  return;
3638
3766
  let marked;
3639
3767
  try {
3768
+ // The reason first, the stamp second: a holder that finds the stamp finds the reason too.
3769
+ await this.store
3770
+ .bufferEvent({
3771
+ name: wakeRequestName(runId),
3772
+ payload: reason.generic ? null : { fans: [...reason.fans] },
3773
+ id: globalThis.crypto.randomUUID(),
3774
+ publishedAt: this.clock(),
3775
+ })
3776
+ .catch(() => undefined);
3640
3777
  marked = await this.store.updateRunIf(runId, ['running', 'suspended', 'blocked'], {
3641
3778
  wakeRequestedAt: this.clock(),
3642
3779
  });
@@ -3664,6 +3801,41 @@ export class WorkflowEngine {
3664
3801
  throw err;
3665
3802
  });
3666
3803
  }
3804
+ /**
3805
+ * Let go of the lease of a run this instance's turn just suspended ({@link releaseSuspendedLease}),
3806
+ * unless a wake refused during the turn asked for another one. A wake whose every kept reason this
3807
+ * turn already saw (a step fan's barrier opening for a fan the turn read settled at least that far,
3808
+ * or a reason the turn found kept when it started) is spent instead: its reasons are deleted, its
3809
+ * stamp cleared, and the lease let go of again. That is the second instance that saw a wave settle
3810
+ * at the same moment: the turn holding the lease already ran it. Resolves `false` when another turn
3811
+ * is owed.
3812
+ */
3813
+ async letGoOfSuspendedTurn(runId) {
3814
+ const served = this.turnServedWakes.get(runId) ?? new Set();
3815
+ const settledFans = this.turnSettledFans.get(runId) ?? new Map();
3816
+ for (let round = 0; round < 5; round += 1) {
3817
+ if (await this.releaseSuspendedLease(runId)) {
3818
+ if (served.size > 0)
3819
+ this.trackEffect(this.dropWakeReasons(runId, served));
3820
+ return true;
3821
+ }
3822
+ const kept = await this.store.listBufferedEvents(wakeRequestName(runId), WAKE_REQUEST_SCAN);
3823
+ const seen = kept.filter((k) => served.has(k.id) || wakeReasonSeen(k.payload, settledFans));
3824
+ // No reason kept (an older engine stamped it), or one this turn has not seen: run again.
3825
+ if (kept.length === 0 || seen.length < kept.length)
3826
+ return false;
3827
+ await Promise.all(seen.map((k) => this.store.removeBufferedEvent(k.id).catch(() => false)));
3828
+ await this.store.updateRun(runId, { wakeRequestedAt: undefined });
3829
+ // A reason kept after the look above may have had its stamp cleared just now: look again.
3830
+ const spent = new Set(seen.map((k) => k.id));
3831
+ const later = await this.store.listBufferedEvents(wakeRequestName(runId), WAKE_REQUEST_SCAN);
3832
+ if (later.some((k) => !spent.has(k.id) && !wakeReasonSeen(k.payload, settledFans))) {
3833
+ await this.store.updateRun(runId, { wakeRequestedAt: this.clock() });
3834
+ return false;
3835
+ }
3836
+ }
3837
+ return false;
3838
+ }
3667
3839
  /**
3668
3840
  * Let go of the lease of a run this instance's turn just suspended, unless a wake refused during
3669
3841
  * the turn asked for another one ({@link requestWake}): then keep it and resolve `false`, and the
@@ -3750,16 +3922,28 @@ export class WorkflowEngine {
3750
3922
  // the lease periodically so a long run keeps it (a crashed worker's lease still expires and is
3751
3923
  // reclaimed by periodic recovery).
3752
3924
  const lockNow = this.clock();
3925
+ // The wakes this turn answers: it reads the store after they were recorded.
3926
+ const reason = this.wakeReasons.get(run.id) ?? { generic: true, fans: new Map() };
3927
+ this.wakeReasons.delete(run.id);
3753
3928
  if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
3754
3929
  if (!(await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow))) {
3755
- this.trackEffect(this.requestWake(run.id, run.lockedUntil));
3930
+ this.trackEffect(this.requestWake(run.id, run.lockedUntil, reason));
3756
3931
  return { runId: run.id, status: run.status };
3757
3932
  }
3758
3933
  }
3759
3934
  // This turn reads the run's checkpoints after this point, so it sees whatever the wakes that
3760
- // asked for it wrote: their request is served.
3935
+ // asked for it wrote: their request is served. Cleared before the reasons are read, so a request
3936
+ // whose reason this misses has its stamp land after the clear (see `requestWake`).
3937
+ const served = new Set();
3938
+ this.turnServedWakes.set(run.id, served);
3939
+ this.turnSettledFans.set(run.id, new Map());
3761
3940
  if (run.wakeRequestedAt !== undefined) {
3762
3941
  await this.store.updateRun(run.id, { wakeRequestedAt: undefined });
3942
+ for (const kept of await this.store
3943
+ .listBufferedEvents(wakeRequestName(run.id), WAKE_REQUEST_SCAN)
3944
+ .catch(() => [])) {
3945
+ served.add(kept.id);
3946
+ }
3763
3947
  }
3764
3948
  const lease = { handedOff: false };
3765
3949
  this.executionLeases.set(run.id, lease);
@@ -3799,6 +3983,9 @@ export class WorkflowEngine {
3799
3983
  result.status === 'dead')) {
3800
3984
  this.trackEffect(this.singletons.wakeNext(run));
3801
3985
  }
3986
+ // A finished run takes no more turns: the reasons kept for one are spent.
3987
+ if (isTerminalRun(result.status))
3988
+ this.trackEffect(this.dropWakeReasons(run.id));
3802
3989
  return result;
3803
3990
  }
3804
3991
  finally {
@@ -3806,8 +3993,18 @@ export class WorkflowEngine {
3806
3993
  this.leaseLostRuns.delete(run.id);
3807
3994
  if (this.executionLeases.get(run.id) === lease)
3808
3995
  this.executionLeases.delete(run.id);
3996
+ if (this.turnServedWakes.get(run.id) === served)
3997
+ this.turnServedWakes.delete(run.id);
3998
+ this.turnSettledFans.delete(run.id);
3809
3999
  }
3810
4000
  }
4001
+ /** Delete the kept wake reasons of `runId` (all, or the ones in `only`). */
4002
+ async dropWakeReasons(runId, only) {
4003
+ const kept = await this.store.listBufferedEvents(wakeRequestName(runId), WAKE_REQUEST_SCAN);
4004
+ await Promise.all(kept
4005
+ .filter((k) => !only || only.has(k.id))
4006
+ .map((k) => this.store.removeBufferedEvent(k.id).catch(() => false)));
4007
+ }
3811
4008
  /**
3812
4009
  * Advance a remote (cross-SDK) workflow one turn: hand its history to the executor (which dispatches
3813
4010
  * a workflow task to the worker and awaits its replay) and apply the decision. Mirrors
@@ -5348,7 +5545,7 @@ export class WorkflowEngine {
5348
5545
  let released = true;
5349
5546
  try {
5350
5547
  if (settledSuspended)
5351
- released = await this.releaseSuspendedLease(run.id);
5548
+ released = await this.letGoOfSuspendedTurn(run.id);
5352
5549
  else
5353
5550
  await this.store.releaseRunLock(run.id, this.instanceId);
5354
5551
  }
@@ -6051,6 +6248,7 @@ export class WorkflowEngine {
6051
6248
  const failures = [];
6052
6249
  const unsent = [];
6053
6250
  let inFlight = 0;
6251
+ let highest = -1;
6054
6252
  let wakeAt;
6055
6253
  for (let i = 0; i < inputs.length; i += 1) {
6056
6254
  const seq = positions[i];
@@ -6061,10 +6259,22 @@ export class WorkflowEngine {
6061
6259
  }
6062
6260
  if (cp.name !== step.name)
6063
6261
  throw new NonDeterminismError(runId, seq, step.name, cp.name);
6262
+ if (seq > highest)
6263
+ highest = seq;
6064
6264
  if (cp.status === 'completed') {
6065
6265
  outputs[i] = cp.output;
6066
6266
  continue;
6067
6267
  }
6268
+ // Still out, and nothing to write for it: this turn read it a moment ago, so it waits on it
6269
+ // without reading it again. A result landing since then wakes the run once this turn ends.
6270
+ if (cp.status === 'pending' && this.stepIsWaiting(cp)) {
6271
+ inFlight += 1;
6272
+ if (cp.wakeAt != null) {
6273
+ const at = this.stepLeaseWakeAt(cp.wakeAt);
6274
+ wakeAt = Math.min(wakeAt ?? at, at);
6275
+ }
6276
+ continue;
6277
+ }
6068
6278
  try {
6069
6279
  outputs[i] = await this.callRemote(runId, seq, step, inputs[i], undefined, opts.transport, undefined, opts.admission, runNamespace, group);
6070
6280
  }
@@ -6090,6 +6300,11 @@ export class WorkflowEngine {
6090
6300
  }
6091
6301
  }
6092
6302
  }
6303
+ // Read with nothing in flight: a fan wake asking to look at it this far has been seen.
6304
+ if (inFlight === 0 && highest >= 0) {
6305
+ const settled = this.turnSettledFans.get(runId);
6306
+ settled?.set(first, Math.max(settled.get(first) ?? highest, highest));
6307
+ }
6093
6308
  const batch = inFlight > 0
6094
6309
  ? []
6095
6310
  : opts.concurrency === undefined
@@ -6116,7 +6331,20 @@ export class WorkflowEngine {
6116
6331
  throw new GatherError(failures, inputs.length);
6117
6332
  return outputs;
6118
6333
  }
6119
- /** Write the `pending` checkpoints of a step fan's new steps, then dispatch them. */
6334
+ /**
6335
+ * Whether a `pending` step a turn just read only waits for its result: {@link callRemote} would
6336
+ * suspend on it without writing (no redispatch deadline to stamp, the one stamped not yet due).
6337
+ */
6338
+ stepIsWaiting(cp) {
6339
+ if (this.remoteRedispatchMs == null)
6340
+ return true;
6341
+ return cp.wakeAt != null && this.clock() < cp.wakeAt;
6342
+ }
6343
+ /**
6344
+ * Write the `pending` checkpoints of a step fan's new steps, then dispatch them: one
6345
+ * {@link StateStore.saveCheckpoints} call and one {@link TransportPool.dispatchMany} for the batch,
6346
+ * where the store and transport have them.
6347
+ */
6120
6348
  async sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace) {
6121
6349
  const token = stepGroup(step, runNamespace);
6122
6350
  await this.ensureRoutable(token, step.requires);
@@ -6127,42 +6355,48 @@ export class WorkflowEngine {
6127
6355
  for (const i of batch)
6128
6356
  validInputs.set(i, await input(i));
6129
6357
  const enqueuedAt = new Date();
6130
- for (let at = 0; at < batch.length; at += STEP_FAN_CHUNK) {
6131
- await Promise.all(batch.slice(at, at + STEP_FAN_CHUNK).map((i) => {
6132
- const seq = positions[i];
6133
- return this.store.saveCheckpoint({
6134
- runId,
6135
- seq,
6136
- name: step.name,
6137
- kind: 'remote',
6138
- stepId: stepId(runId, seq),
6139
- status: 'pending',
6140
- input: validInputs.get(i),
6141
- attempts: 1,
6142
- workerGroup: token,
6143
- parallelGroup: group,
6144
- enqueuedAt,
6145
- startedAt: enqueuedAt,
6146
- finishedAt: enqueuedAt,
6147
- });
6148
- }));
6358
+ const checkpoints = batch.map((i) => {
6359
+ const seq = positions[i];
6360
+ return {
6361
+ runId,
6362
+ seq,
6363
+ name: step.name,
6364
+ kind: 'remote',
6365
+ stepId: stepId(runId, seq),
6366
+ status: 'pending',
6367
+ input: validInputs.get(i),
6368
+ attempts: 1,
6369
+ workerGroup: token,
6370
+ parallelGroup: group,
6371
+ enqueuedAt,
6372
+ startedAt: enqueuedAt,
6373
+ finishedAt: enqueuedAt,
6374
+ };
6375
+ });
6376
+ if (this.store.saveCheckpoints) {
6377
+ await this.store.saveCheckpoints(checkpoints);
6149
6378
  }
6150
- for (let at = 0; at < batch.length; at += STEP_FAN_CHUNK) {
6151
- await Promise.all(batch.slice(at, at + STEP_FAN_CHUNK).map(async (i) => {
6152
- const seq = positions[i];
6153
- await this.dispatchRemoteTask({
6154
- runId,
6155
- seq,
6156
- name: step.name,
6157
- stepId: stepId(runId, seq),
6158
- group: token,
6159
- input: validInputs.get(i),
6160
- priority: opts.admission?.priority,
6161
- attempt: 1,
6162
- transport: opts.transport,
6163
- });
6164
- this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
6165
- }));
6379
+ else {
6380
+ for (let at = 0; at < checkpoints.length; at += STEP_FAN_CHUNK) {
6381
+ await Promise.all(checkpoints.slice(at, at + STEP_FAN_CHUNK).map((cp) => this.store.saveCheckpoint(cp)));
6382
+ }
6383
+ }
6384
+ const traceparent = this.traceparent?.();
6385
+ const context = this.context?.();
6386
+ await this.pool.dispatchMany(checkpoints.map((cp) => ({
6387
+ runId,
6388
+ seq: cp.seq,
6389
+ name: step.name,
6390
+ stepId: cp.stepId,
6391
+ group: token,
6392
+ input: cp.input,
6393
+ traceparent,
6394
+ context,
6395
+ priority: opts.admission?.priority,
6396
+ attempt: 1,
6397
+ })), opts.transport);
6398
+ for (const cp of checkpoints) {
6399
+ this.emit({ type: 'step.started', runId, seq: cp.seq, name: step.name, kind: 'remote' });
6166
6400
  }
6167
6401
  }
6168
6402
  /**
@@ -6243,7 +6477,7 @@ export class WorkflowEngine {
6243
6477
  const batch = queue.splice(0, RESULT_BATCH_MAX);
6244
6478
  let outcomes;
6245
6479
  try {
6246
- outcomes = await this.completeRemoteResults(runId, batch.map((q) => q.result));
6480
+ outcomes = await this.completeRemoteResults(runId, batch.map((q) => q.result), (from, to) => queue.some(({ result }) => result.seq >= from && result.seq <= to));
6247
6481
  }
6248
6482
  catch (error) {
6249
6483
  outcomes = batch.map(() => ({ error }));
@@ -6260,16 +6494,16 @@ export class WorkflowEngine {
6260
6494
  this.resultQueues.delete(runId);
6261
6495
  }
6262
6496
  /** The single-result path: settle `result`, then resume its run unless its fan is still running. */
6263
- async completeOneRemoteResult(result) {
6497
+ async completeOneRemoteResult(result, queuedIn) {
6264
6498
  const cp = await this.settleRemoteCheckpoint(result);
6265
6499
  if (cp === undefined)
6266
6500
  return;
6267
- if (await this.stepFanStillRunning(cp, result))
6501
+ if (await this.stepFanStillRunning(cp, result, queuedIn))
6268
6502
  return;
6269
- this.resumeInBackground(result.runId);
6503
+ this.resumeInBackground(result.runId, fanWakeOf(cp, result));
6270
6504
  }
6271
- resumeInBackground(runId) {
6272
- this.trackEffect(this.resumeImplicit(runId).catch((err) => {
6505
+ resumeInBackground(runId, fan) {
6506
+ this.trackEffect(this.resumeImplicit(runId, fan).catch((err) => {
6273
6507
  console.warn(`[adonis-durable] background resume of run ${runId} failed (the run recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
6274
6508
  }));
6275
6509
  }
@@ -6279,12 +6513,12 @@ export class WorkflowEngine {
6279
6513
  * {@link completeOneRemoteResult} on each in turn, with one resume for the batch: that resume
6280
6514
  * replays every write of the batch, so a second would only replay it again.
6281
6515
  */
6282
- async completeRemoteResults(runId, results) {
6516
+ async completeRemoteResults(runId, results, queuedIn = () => false) {
6283
6517
  const outcomes = results.map(() => true);
6284
6518
  const settleOneByOne = async (indexes) => {
6285
6519
  for (const i of indexes) {
6286
6520
  try {
6287
- await this.completeOneRemoteResult(results[i]);
6521
+ await this.completeOneRemoteResult(results[i], queuedIn);
6288
6522
  }
6289
6523
  catch (error) {
6290
6524
  outcomes[i] = { error };
@@ -6313,7 +6547,7 @@ export class WorkflowEngine {
6313
6547
  run.status === 'dead') {
6314
6548
  return settleOneByOne(live);
6315
6549
  }
6316
- const reads = await Promise.allSettled(live.map((i) => this.store.getCheckpoint(runId, results[i].seq)));
6550
+ const reads = await this.readCheckpoints(runId, live.map((i) => results[i].seq));
6317
6551
  // Checkpoints whose result should resume the run (barrier permitting): the ones this batch
6318
6552
  // settled, and redeliveries of ones settled before — whose resume may never have happened.
6319
6553
  const resumable = [];
@@ -6378,9 +6612,18 @@ export class WorkflowEngine {
6378
6612
  resumable.push({ cp, result });
6379
6613
  }
6380
6614
  // Asked after every write of the batch committed, so the batch holding a fan's last step sees
6381
- // its barrier open; once per fan. A barrier that cannot be read counts as open: a spare resume
6382
- // replays and re-suspends, a lost one strands the run.
6615
+ // its barrier open; once per fan, and not at all while another of the fan's results is queued
6616
+ // behind this batch on this instance: that result asks it after its own write (a settle, or a
6617
+ // redelivery of a settled step), so under a backlog the barrier is read once per drained queue,
6618
+ // not once per batch. A barrier that cannot be read counts as open: a spare resume replays and
6619
+ // re-suspends, a lost one strands the run.
6383
6620
  const barriers = new Map();
6621
+ const highest = new Map();
6622
+ for (const { cp } of resumable) {
6623
+ const fan = parseStepFanGroup(cp.parallelGroup);
6624
+ if (fan)
6625
+ highest.set(fan.firstSeq, Math.max(highest.get(fan.firstSeq) ?? cp.seq, cp.seq));
6626
+ }
6384
6627
  for (const { cp, result } of resumable) {
6385
6628
  const fan = parseStepFanGroup(cp.parallelGroup);
6386
6629
  if (!fan || result.status === 'failed') {
@@ -6389,11 +6632,17 @@ export class WorkflowEngine {
6389
6632
  }
6390
6633
  let closed = barriers.get(fan.firstSeq);
6391
6634
  if (!closed) {
6392
- closed = this.countPendingSteps(runId, fan.firstSeq, fan.firstSeq + fan.size - 1).then((n) => n > 0, () => false);
6635
+ const lastSeq = fan.firstSeq + fan.size - 1;
6636
+ closed = queuedIn(fan.firstSeq, lastSeq)
6637
+ ? Promise.resolve(true)
6638
+ : this.countPendingSteps(runId, fan.firstSeq, lastSeq).then((n) => n > 0, () => false);
6393
6639
  barriers.set(fan.firstSeq, closed);
6394
6640
  }
6395
6641
  if (!(await closed)) {
6396
- this.resumeInBackground(runId);
6642
+ this.resumeInBackground(runId, {
6643
+ first: fan.firstSeq,
6644
+ seq: highest.get(fan.firstSeq) ?? cp.seq,
6645
+ });
6397
6646
  return outcomes;
6398
6647
  }
6399
6648
  }
@@ -6425,11 +6674,32 @@ export class WorkflowEngine {
6425
6674
  * which makes a fan of N steps cost O(N²) checkpoint reads. A redelivered result goes through it
6426
6675
  * too: its re-driven resume is only owed once the fan has settled.
6427
6676
  */
6428
- async stepFanStillRunning(cp, result) {
6677
+ async stepFanStillRunning(cp, result, queuedIn = () => false) {
6429
6678
  const fan = parseStepFanGroup(cp.parallelGroup);
6430
6679
  if (!fan || result.status === 'failed')
6431
6680
  return false;
6432
- return ((await this.countPendingSteps(result.runId, fan.firstSeq, fan.firstSeq + fan.size - 1)) > 0);
6681
+ const lastSeq = fan.firstSeq + fan.size - 1;
6682
+ // Left to the result of this fan queued behind this one (see `completeRemoteResults`).
6683
+ if (queuedIn(fan.firstSeq, lastSeq))
6684
+ return true;
6685
+ return (await this.countPendingSteps(result.runId, fan.firstSeq, lastSeq)) > 0;
6686
+ }
6687
+ /**
6688
+ * The checkpoints of one run at `seqs`, each as its own outcome: one
6689
+ * {@link StateStore.getCheckpoints} call when the store has it, else one read each. The batch
6690
+ * call rejecting falls back to one read each, so a failing read fails only its own entry.
6691
+ */
6692
+ async readCheckpoints(runId, seqs) {
6693
+ if (seqs.length > 1 && this.store.getCheckpoints) {
6694
+ try {
6695
+ const bySeq = new Map((await this.store.getCheckpoints(runId, seqs)).map((cp) => [cp.seq, cp]));
6696
+ return seqs.map((seq) => ({ status: 'fulfilled', value: bySeq.get(seq) ?? null }));
6697
+ }
6698
+ catch {
6699
+ // Fall through: one by one.
6700
+ }
6701
+ }
6702
+ return Promise.allSettled(seqs.map((seq) => this.store.getCheckpoint(runId, seq)));
6433
6703
  }
6434
6704
  /**
6435
6705
  * The durable half of {@link completeRemoteResult}: settle `result` onto its checkpoint (or re-drive