@adonis-agora/durable 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/assets/spa/assets/index-6nmSTfT7.css +1 -0
  3. package/dist/assets/spa/assets/index-DdB9zxuX.js +16 -0
  4. package/dist/assets/spa/assets/index-DdB9zxuX.js.map +1 -0
  5. package/dist/assets/spa/index.html +2 -2
  6. package/dist/providers/dashboard_provider.d.ts +3 -1
  7. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  8. package/dist/providers/dashboard_provider.js +7 -2
  9. package/dist/providers/dashboard_provider.js.map +1 -1
  10. package/dist/providers/durable_provider.d.ts.map +1 -1
  11. package/dist/providers/durable_provider.js +1 -0
  12. package/dist/providers/durable_provider.js.map +1 -1
  13. package/dist/src/codec-state-store.d.ts +2 -0
  14. package/dist/src/codec-state-store.d.ts.map +1 -1
  15. package/dist/src/codec-state-store.js +6 -0
  16. package/dist/src/codec-state-store.js.map +1 -1
  17. package/dist/src/dashboard/gateway-adapter.d.ts.map +1 -1
  18. package/dist/src/dashboard/gateway-adapter.js +10 -0
  19. package/dist/src/dashboard/gateway-adapter.js.map +1 -1
  20. package/dist/src/dashboard/handlers.d.ts +22 -1
  21. package/dist/src/dashboard/handlers.d.ts.map +1 -1
  22. package/dist/src/dashboard/handlers.js +63 -10
  23. package/dist/src/dashboard/handlers.js.map +1 -1
  24. package/dist/src/dashboard/index.d.ts +1 -1
  25. package/dist/src/dashboard/index.js +1 -1
  26. package/dist/src/dashboard/openapi.d.ts.map +1 -1
  27. package/dist/src/dashboard/openapi.js +26 -2
  28. package/dist/src/dashboard/openapi.js.map +1 -1
  29. package/dist/src/define_config.d.ts +6 -0
  30. package/dist/src/define_config.d.ts.map +1 -1
  31. package/dist/src/define_config.js.map +1 -1
  32. package/dist/src/engine.d.ts +44 -3
  33. package/dist/src/engine.d.ts.map +1 -1
  34. package/dist/src/engine.js +226 -28
  35. package/dist/src/engine.js.map +1 -1
  36. package/dist/src/errors.d.ts +12 -1
  37. package/dist/src/errors.d.ts.map +1 -1
  38. package/dist/src/errors.js +8 -1
  39. package/dist/src/errors.js.map +1 -1
  40. package/dist/src/fan-join.d.ts +32 -3
  41. package/dist/src/fan-join.d.ts.map +1 -1
  42. package/dist/src/fan-join.js +58 -4
  43. package/dist/src/fan-join.js.map +1 -1
  44. package/dist/src/index.d.ts +4 -3
  45. package/dist/src/index.d.ts.map +1 -1
  46. package/dist/src/index.js +3 -2
  47. package/dist/src/index.js.map +1 -1
  48. package/dist/src/interfaces.d.ts +55 -9
  49. package/dist/src/interfaces.d.ts.map +1 -1
  50. package/dist/src/interfaces.js.map +1 -1
  51. package/dist/src/run-detail-summary.d.ts +98 -0
  52. package/dist/src/run-detail-summary.d.ts.map +1 -0
  53. package/dist/src/run-detail-summary.js +150 -0
  54. package/dist/src/run-detail-summary.js.map +1 -0
  55. package/dist/src/run-gateway/interface.d.ts +11 -0
  56. package/dist/src/run-gateway/interface.d.ts.map +1 -1
  57. package/dist/src/run-gateway/proxy-run-gateway.d.ts +3 -0
  58. package/dist/src/run-gateway/proxy-run-gateway.d.ts.map +1 -1
  59. package/dist/src/run-gateway/proxy-run-gateway.js +8 -0
  60. package/dist/src/run-gateway/proxy-run-gateway.js.map +1 -1
  61. package/dist/src/run-gateway/run-request-responder.d.ts +4 -3
  62. package/dist/src/run-gateway/run-request-responder.d.ts.map +1 -1
  63. package/dist/src/run-gateway/run-request-responder.js +23 -3
  64. package/dist/src/run-gateway/run-request-responder.js.map +1 -1
  65. package/dist/src/run-gateway/store-run-gateway.d.ts +3 -0
  66. package/dist/src/run-gateway/store-run-gateway.d.ts.map +1 -1
  67. package/dist/src/run-gateway/store-run-gateway.js +7 -0
  68. package/dist/src/run-gateway/store-run-gateway.js.map +1 -1
  69. package/dist/src/stores/lucid.d.ts +1 -0
  70. package/dist/src/stores/lucid.d.ts.map +1 -1
  71. package/dist/src/stores/lucid.js +16 -3
  72. package/dist/src/stores/lucid.js.map +1 -1
  73. package/dist/src/testing/in-memory-state-store.d.ts +1 -0
  74. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  75. package/dist/src/testing/in-memory-state-store.js +14 -6
  76. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  77. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  78. package/dist/src/testing-kit/state-store-conformance.js +59 -0
  79. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  80. package/dist/src/transports/bullmq/adaptive-concurrency.d.ts +144 -0
  81. package/dist/src/transports/bullmq/adaptive-concurrency.d.ts.map +1 -0
  82. package/dist/src/transports/bullmq/adaptive-concurrency.js +312 -0
  83. package/dist/src/transports/bullmq/adaptive-concurrency.js.map +1 -0
  84. package/dist/src/transports/bullmq/bullmq-transport.d.ts +18 -1
  85. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  86. package/dist/src/transports/bullmq/bullmq-transport.js +50 -49
  87. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  88. package/dist/src/transports/bullmq/deps.d.ts +11 -1
  89. package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
  90. package/dist/src/transports/bullmq/deps.js +12 -1
  91. package/dist/src/transports/bullmq/deps.js.map +1 -1
  92. package/dist/src/transports/bullmq/index.d.ts +1 -0
  93. package/dist/src/transports/bullmq/index.d.ts.map +1 -1
  94. package/dist/src/transports/bullmq/index.js +1 -0
  95. package/dist/src/transports/bullmq/index.js.map +1 -1
  96. package/dist/src/transports/factory.d.ts +21 -2
  97. package/dist/src/transports/factory.d.ts.map +1 -1
  98. package/dist/src/transports/factory.js +5 -1
  99. package/dist/src/transports/factory.js.map +1 -1
  100. package/dist/src/workflow-ctx.d.ts +13 -1
  101. package/dist/src/workflow-ctx.d.ts.map +1 -1
  102. package/dist/src/workflow-ctx.js +25 -1
  103. package/dist/src/workflow-ctx.js.map +1 -1
  104. package/package.json +3 -3
  105. package/skills/durable-cluster/SKILL.md +10 -0
  106. package/skills/durable-transports-stores/SKILL.md +37 -0
  107. package/skills/durable-workflows/SKILL.md +19 -1
  108. package/dist/assets/spa/assets/index-DtGMBUDm.css +0 -1
  109. package/dist/assets/spa/assets/index-_VISH9Xg.js +0 -16
  110. package/dist/assets/spa/assets/index-_VISH9Xg.js.map +0 -1
@@ -6,10 +6,10 @@ import { assertConcurrency, concurrencyTag, countRuns, resolveConcurrency, } fro
6
6
  import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
7
7
  import { parseDuration } from './duration.js';
8
8
  import { Entities } from './entities.js';
9
- import { ContinueAsNew, NonDeterminismError, RemoteStepTimeout, WorkflowSuspended, } from './errors.js';
9
+ import { ContinueAsNew, GatherError, NonDeterminismError, RemoteStepTimeout, WorkflowSuspended, } from './errors.js';
10
10
  import { EventAccumulators } from './event-accumulators.js';
11
11
  import { eventMatches, eventMatchOf, eventPrefix } from './events.js';
12
- import { FOLDED_PREFIX, fanOfWaiter, foldedChildIds } from './fan-join.js';
12
+ import { childIdsOf, FOLDED_PREFIX, fanOfWaiter, parseStepFanGroup, stepFanGroup, } from './fan-join.js';
13
13
  import { aggregateAnnouncements } from './handshake/announced.js';
14
14
  import { isNonEmptyRunScope, TERMINAL_RUN_STATUSES } from './interfaces.js';
15
15
  import { asHeartbeat } from './observability-scope.js';
@@ -70,6 +70,18 @@ const versionKey = (name, version) => `${name}@${version}`;
70
70
  * un-checkpointed `-1` undo dispatches of older engine versions.
71
71
  */
72
72
  const COMPENSATION_SEQ_BASE = -2;
73
+ /** How many checkpoint writes / dispatches a step fan sends at once. */
74
+ const STEP_FAN_CHUNK = 200;
75
+ /** The default {@link WorkflowEngineDeps.timerBatchSize}. */
76
+ const DEFAULT_TIMER_BATCH = 500;
77
+ /** Fisher–Yates, in place. */
78
+ function shuffle(items) {
79
+ for (let i = items.length - 1; i > 0; i -= 1) {
80
+ const j = Math.floor(Math.random() * (i + 1));
81
+ [items[i], items[j]] = [items[j], items[i]];
82
+ }
83
+ return items;
84
+ }
73
85
  /**
74
86
  * The routing token a dispatched step lands on: the step's own (broker-safe) name, suffixed by the
75
87
  * tenant the RUN belongs to. `runNamespace` — not the executing engine's namespace — is the tenant
@@ -133,6 +145,7 @@ export class WorkflowEngine {
133
145
  leaseMs;
134
146
  maxRecoveryAttempts;
135
147
  reconcileMs;
148
+ timerBatchSize;
136
149
  remoteRedispatchMs;
137
150
  remoteRedispatchMax;
138
151
  webhookUrl;
@@ -284,6 +297,12 @@ export class WorkflowEngine {
284
297
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
285
298
  // Default 5min; an explicit 0 disables the fallback (opt back into wake-forever-on-lost-event).
286
299
  this.reconcileMs = deps.reconcileMs === undefined ? 300_000 : deps.reconcileMs || undefined;
300
+ // A non-finite or sub-1 value falls back to the default: a NaN limit would stall timer recovery.
301
+ const batch = deps.timerBatchSize;
302
+ this.timerBatchSize =
303
+ batch !== undefined && Number.isFinite(batch) && batch >= 1
304
+ ? Math.floor(batch)
305
+ : DEFAULT_TIMER_BATCH;
287
306
  // Opt-in: unset (or 0) leaves the by-design "re-suspend a lost dispatch, never re-dispatch" behavior.
288
307
  this.remoteRedispatchMs = deps.remoteRedispatchMs || undefined;
289
308
  this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
@@ -1361,6 +1380,31 @@ export class WorkflowEngine {
1361
1380
  }
1362
1381
  })());
1363
1382
  }
1383
+ /**
1384
+ * {@link recheckWaitSeqs} for the step fans a suspended turn waits on: re-drive the run once a fan
1385
+ * has no step left `pending`. The last result of a fan may have opened the join barrier while this
1386
+ * turn held the lease, and its resume no-oped against it. A fan with a step still `pending` is left
1387
+ * to that step's result.
1388
+ */
1389
+ recheckStepFans(runId, fans) {
1390
+ this.trackEffect((async () => {
1391
+ await new Promise((r) => setTimeout(r, 0));
1392
+ for (const fan of fans) {
1393
+ const pending = await this.countPendingSteps(runId, fan.from, fan.to).catch(() => 1);
1394
+ if (pending === 0) {
1395
+ await this.resumeImplicit(runId).catch(() => undefined);
1396
+ return;
1397
+ }
1398
+ }
1399
+ })());
1400
+ }
1401
+ /** {@link StateStore.countCheckpoints} of `pending` steps, or a JS count on a store without it. */
1402
+ async countPendingSteps(runId, from, to) {
1403
+ if (this.store.countCheckpoints) {
1404
+ return this.store.countCheckpoints(runId, from, to, ['pending']);
1405
+ }
1406
+ return (await this.store.listCheckpoints(runId)).filter((c) => c.seq >= from && c.seq <= to && c.status === 'pending').length;
1407
+ }
1364
1408
  /** Track an in-flight execution so {@link drain} can wait for it. */
1365
1409
  track(runId, p) {
1366
1410
  this.inflight.set(p, runId);
@@ -1636,8 +1680,10 @@ export class WorkflowEngine {
1636
1680
  * not due re-suspends cheaply without running new work.
1637
1681
  */
1638
1682
  async resumeDueTimers(nowMs = this.clock()) {
1639
- // Capped per tick (a backlog drains over several polls) — a store may ignore the cap.
1640
- const due = await asHeartbeat(() => this.store.listDueTimers(nowMs, this.namespace, 200));
1683
+ // Capped per tick (a backlog drains over several polls) — a store may ignore the cap. Shuffled:
1684
+ // every instance loads the same most-overdue batch, and walking it in the same order would have
1685
+ // them all contend for the same leases one row at a time.
1686
+ const due = shuffle(await asHeartbeat(() => this.store.listDueTimers(nowMs, this.namespace, this.timerBatchSize)));
1641
1687
  const blocked = await this.dueBlockedRuns(nowMs);
1642
1688
  return this.resumeLeased([...due, ...blocked], nowMs);
1643
1689
  }
@@ -3642,6 +3688,9 @@ export class WorkflowEngine {
3642
3688
  if (settled.status === 'suspended' && err.waitSeqs?.length) {
3643
3689
  this.recheckWaitSeqs(run.id, err.waitSeqs);
3644
3690
  }
3691
+ if (settled.status === 'suspended' && err.stepFans?.length) {
3692
+ this.recheckStepFans(run.id, err.stepFans);
3693
+ }
3645
3694
  if (settled.status === 'suspended')
3646
3695
  suspendedOnFan = err.fan;
3647
3696
  return settled;
@@ -3928,6 +3977,7 @@ export class WorkflowEngine {
3928
3977
  completeStep: (s) => this.completeStep(s),
3929
3978
  failStep: (s) => this.failStep(s),
3930
3979
  callRemote: (runId, seq, step, input, queue, transport, admission) => this.callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace),
3980
+ callRemoteAll: (runId, positions, step, inputs, opts) => this.callRemoteAll(runId, positions, step, inputs, opts, runNamespace),
3931
3981
  // Defer so a fast child can't reentrantly resume a still-running parent. The child inherits the
3932
3982
  // PARENT RUN's namespace, not this engine's: an operator legitimately executes every tenant's
3933
3983
  // parents, and without this the child would fall to the operator's own pool.
@@ -4121,7 +4171,7 @@ export class WorkflowEngine {
4121
4171
  }));
4122
4172
  return blocked;
4123
4173
  }
4124
- async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace) {
4174
+ async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace, parallelGroup) {
4125
4175
  // Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
4126
4176
  // from the snapshot — not yet dispatched, or written after the snapshot — falls back to the store.
4127
4177
  let existing = replay?.get(seq) ?? (await this.store.getCheckpoint(runId, seq));
@@ -4164,7 +4214,7 @@ export class WorkflowEngine {
4164
4214
  // The result may have settled the row since it was read: then nothing was written, and the
4165
4215
  // step is re-read from the store.
4166
4216
  if (!(await this.savePendingCheckpoint({ ...existing, wakeAt }))) {
4167
- return this.callRemote(runId, seq, step, input, queue, transport, undefined, admission, runNamespace);
4217
+ return this.callRemote(runId, seq, step, input, queue, transport, undefined, admission, runNamespace, parallelGroup);
4168
4218
  }
4169
4219
  throw new WorkflowSuspended(wakeAt, [seq]);
4170
4220
  }
@@ -4196,7 +4246,7 @@ export class WorkflowEngine {
4196
4246
  ],
4197
4247
  });
4198
4248
  if (!claimed) {
4199
- return this.callRemote(runId, seq, step, input, queue, transport, undefined, admission, runNamespace);
4249
+ return this.callRemote(runId, seq, step, input, queue, transport, undefined, admission, runNamespace, parallelGroup);
4200
4250
  }
4201
4251
  await this.dispatchRemoteTask({
4202
4252
  runId,
@@ -4292,6 +4342,7 @@ export class WorkflowEngine {
4292
4342
  // released by WHICHEVER instance receives the result — the in-memory stepQueue map only
4293
4343
  // covers the dispatching pod, and a slot released by no one starves the queue fleet-wide.
4294
4344
  queue: admittedQueue,
4345
+ ...(parallelGroup !== undefined ? { parallelGroup } : {}),
4295
4346
  enqueuedAt,
4296
4347
  startedAt: enqueuedAt, // placeholders until the worker result lands
4297
4348
  finishedAt: enqueuedAt,
@@ -4310,6 +4361,150 @@ export class WorkflowEngine {
4310
4361
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
4311
4362
  throw new WorkflowSuspended(undefined, [seq]);
4312
4363
  }
4364
+ /**
4365
+ * `ctx.all` over a `@Step`: one durable step per input, dispatched together, joined once. The ctx
4366
+ * reserves the whole position block up front, so replay re-derives the same seqs.
4367
+ *
4368
+ * Each turn reads the fan's checkpoints fresh, hands every step that already has one to
4369
+ * {@link callRemote} (which owns the completed / pending / failed-retry rules), and sends the steps
4370
+ * nobody has sent yet: all of them, or with `concurrency`, the next wave once the last one has
4371
+ * drained. New steps are written `pending` BEFORE any is dispatched, so a turn that runs after a
4372
+ * fast result finds them already sent instead of sending them again. The suspension carries the
4373
+ * fan's block, not its seqs, so the join barrier ({@link stepFanStillRunning}) and
4374
+ * {@link recheckStepFans} wake the parent once per wave, not once per result.
4375
+ */
4376
+ async callRemoteAll(runId, positions, step, inputs, opts, runNamespace) {
4377
+ if (step.timeoutMs) {
4378
+ throw new Error(`ctx.all: step "${step.name}" has a liveness timeoutMs; a step fan needs durable steps`);
4379
+ }
4380
+ if (opts.concurrency !== undefined &&
4381
+ (!Number.isInteger(opts.concurrency) || opts.concurrency < 1)) {
4382
+ throw new Error(`ctx.all: concurrency must be a positive integer, got ${opts.concurrency}`);
4383
+ }
4384
+ if (this.pool.size === 0)
4385
+ throw new Error('remote steps require a Transport');
4386
+ const first = positions[0];
4387
+ const last = positions[positions.length - 1];
4388
+ const group = stepFanGroup(first, positions.length, opts.mode);
4389
+ const bySeq = new Map();
4390
+ for (const cp of await this.store.listCheckpoints(runId)) {
4391
+ if (cp.seq >= first && cp.seq <= last)
4392
+ bySeq.set(cp.seq, cp);
4393
+ }
4394
+ const outputs = new Array(inputs.length);
4395
+ const failures = [];
4396
+ const unsent = [];
4397
+ let inFlight = 0;
4398
+ let wakeAt;
4399
+ for (let i = 0; i < inputs.length; i += 1) {
4400
+ const seq = positions[i];
4401
+ const cp = bySeq.get(seq);
4402
+ if (!cp) {
4403
+ unsent.push(i);
4404
+ continue;
4405
+ }
4406
+ if (cp.name !== step.name)
4407
+ throw new NonDeterminismError(runId, seq, step.name, cp.name);
4408
+ if (cp.status === 'completed') {
4409
+ outputs[i] = cp.output;
4410
+ continue;
4411
+ }
4412
+ try {
4413
+ outputs[i] = await this.callRemote(runId, seq, step, inputs[i], undefined, opts.transport, undefined, opts.admission, runNamespace, group);
4414
+ }
4415
+ catch (err) {
4416
+ if (err instanceof WorkflowSuspended) {
4417
+ inFlight += 1;
4418
+ if (err.wakeAt !== undefined)
4419
+ wakeAt = Math.min(wakeAt ?? err.wakeAt, err.wakeAt);
4420
+ }
4421
+ else if (err instanceof RemoteStepError) {
4422
+ const failure = {
4423
+ index: i,
4424
+ id: stepId(runId, seq),
4425
+ error: err.message,
4426
+ ...codeAndDetails(err),
4427
+ };
4428
+ if (opts.mode === 'failFast')
4429
+ throw new GatherError([failure], inputs.length);
4430
+ failures.push(failure);
4431
+ }
4432
+ else {
4433
+ throw err;
4434
+ }
4435
+ }
4436
+ }
4437
+ const batch = inFlight > 0
4438
+ ? []
4439
+ : opts.concurrency === undefined
4440
+ ? unsent
4441
+ : unsent.slice(0, opts.concurrency);
4442
+ if (batch.length > 0) {
4443
+ await this.sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace);
4444
+ }
4445
+ if (inFlight + batch.length > 0) {
4446
+ throw new WorkflowSuspended(wakeAt, undefined, undefined, [{ from: first, to: last }]);
4447
+ }
4448
+ if (opts.settle) {
4449
+ const failed = new Map(failures.map((f) => [f.index, f]));
4450
+ // `outputs` has a hole at every failed index, which `map` would skip.
4451
+ return Array.from(outputs, (value, i) => {
4452
+ const f = failed.get(i);
4453
+ if (!f)
4454
+ return { status: 'fulfilled', value };
4455
+ const { index: _index, ...rejected } = f;
4456
+ return { status: 'rejected', ...rejected };
4457
+ });
4458
+ }
4459
+ if (failures.length > 0)
4460
+ throw new GatherError(failures, inputs.length);
4461
+ return outputs;
4462
+ }
4463
+ /** Write the `pending` checkpoints of a step fan's new steps, then dispatch them. */
4464
+ async sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace) {
4465
+ const token = stepGroup(step, runNamespace);
4466
+ await this.ensureRoutable(token, step.requires);
4467
+ const input = (i) => step.input ? step.input.parse(inputs[i]) : inputs[i];
4468
+ const validInputs = new Map(batch.map((i) => [i, input(i)]));
4469
+ const enqueuedAt = new Date();
4470
+ for (let at = 0; at < batch.length; at += STEP_FAN_CHUNK) {
4471
+ await Promise.all(batch.slice(at, at + STEP_FAN_CHUNK).map((i) => {
4472
+ const seq = positions[i];
4473
+ return this.store.saveCheckpoint({
4474
+ runId,
4475
+ seq,
4476
+ name: step.name,
4477
+ kind: 'remote',
4478
+ stepId: stepId(runId, seq),
4479
+ status: 'pending',
4480
+ input: validInputs.get(i),
4481
+ attempts: 1,
4482
+ workerGroup: token,
4483
+ parallelGroup: group,
4484
+ enqueuedAt,
4485
+ startedAt: enqueuedAt,
4486
+ finishedAt: enqueuedAt,
4487
+ });
4488
+ }));
4489
+ }
4490
+ for (let at = 0; at < batch.length; at += STEP_FAN_CHUNK) {
4491
+ await Promise.all(batch.slice(at, at + STEP_FAN_CHUNK).map(async (i) => {
4492
+ const seq = positions[i];
4493
+ await this.dispatchRemoteTask({
4494
+ runId,
4495
+ seq,
4496
+ name: step.name,
4497
+ stepId: stepId(runId, seq),
4498
+ group: token,
4499
+ input: validInputs.get(i),
4500
+ priority: opts.admission?.priority,
4501
+ attempt: 1,
4502
+ transport: opts.transport,
4503
+ });
4504
+ this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
4505
+ }));
4506
+ }
4507
+ }
4313
4508
  /**
4314
4509
  * Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
4315
4510
  * dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
@@ -4358,18 +4553,34 @@ export class WorkflowEngine {
4358
4553
  return true;
4359
4554
  }
4360
4555
  async completeRemoteResult(result) {
4361
- const runId = await this.settleRemoteCheckpoint(result);
4362
- if (runId === undefined)
4556
+ const cp = await this.settleRemoteCheckpoint(result);
4557
+ if (cp === undefined)
4363
4558
  return;
4559
+ if (await this.stepFanStillRunning(cp, result))
4560
+ return;
4561
+ const runId = result.runId;
4364
4562
  this.trackEffect(this.resumeImplicit(runId).catch((err) => {
4365
4563
  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)}`);
4366
4564
  }));
4367
4565
  }
4566
+ /**
4567
+ * The join barrier of a step fan: a result for one of its steps resumes the parent only once no
4568
+ * step of the fan is still `pending` (a settled wave), or on a failure (failFast surfaces it, and a
4569
+ * retry needs the parent to schedule it). Without it every result would replay the whole parent,
4570
+ * which makes a fan of N steps cost O(N²) checkpoint reads. A redelivered result goes through it
4571
+ * too: its re-driven resume is only owed once the fan has settled.
4572
+ */
4573
+ async stepFanStillRunning(cp, result) {
4574
+ const fan = parseStepFanGroup(cp.parallelGroup);
4575
+ if (!fan || result.status === 'failed')
4576
+ return false;
4577
+ return ((await this.countPendingSteps(result.runId, fan.firstSeq, fan.firstSeq + fan.size - 1)) > 0);
4578
+ }
4368
4579
  /**
4369
4580
  * The durable half of {@link completeRemoteResult}: settle `result` onto its checkpoint (or re-drive
4370
- * the resume when a redelivered result finds it already settled). Returns the run to resume, or
4371
- * `undefined` when no resume is wanted (no checkpoint, or a late result for a terminal run —
4372
- * terminal is terminal).
4581
+ * the resume when a redelivered result finds it already settled). Returns the step's checkpoint as
4582
+ * read when its run should be resumed, or `undefined` when no resume is wanted (no checkpoint, or a
4583
+ * late result for a terminal run — terminal is terminal).
4373
4584
  */
4374
4585
  async settleRemoteCheckpoint(result) {
4375
4586
  const cp = await this.store.getCheckpoint(result.runId, result.seq);
@@ -4397,7 +4608,7 @@ export class WorkflowEngine {
4397
4608
  // explicit `requeue`/`durable:retry`, never to a stray result.
4398
4609
  if (cp.status !== 'pending') {
4399
4610
  if (!terminal)
4400
- return result.runId;
4611
+ return cp;
4401
4612
  return undefined;
4402
4613
  }
4403
4614
  // A result settling this step frees its flow-control slot (no-op if it wasn't queued). Released
@@ -4440,7 +4651,7 @@ export class WorkflowEngine {
4440
4651
  finishedAt,
4441
4652
  });
4442
4653
  if (!claimed)
4443
- return terminal ? undefined : result.runId;
4654
+ return terminal ? undefined : cp;
4444
4655
  await this.releaseQueueSlot(cp);
4445
4656
  this.emit({
4446
4657
  type: result.status === 'completed' ? 'step.completed' : 'step.failed',
@@ -4453,7 +4664,7 @@ export class WorkflowEngine {
4453
4664
  queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
4454
4665
  durationMs: finishedAt.getTime() - startedAt.getTime(),
4455
4666
  });
4456
- return result.runId;
4667
+ return cp;
4457
4668
  }
4458
4669
  /**
4459
4670
  * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
@@ -4681,17 +4892,4 @@ export class RemoteStepError extends Error {
4681
4892
  }
4682
4893
  /** Checkpoint names that record a parent→child edge: an awaited child, a folded group of them, a spawn. */
4683
4894
  const CHILD_EDGE_PREFIXES = ['signal:child:', FOLDED_PREFIX, 'spawn:'];
4684
- /** The child run ids a parent's {@link CHILD_EDGE_PREFIXES} checkpoints point at. */
4685
- function childIdsOf(checkpoints) {
4686
- const ids = [];
4687
- for (const cp of checkpoints) {
4688
- if (cp.name.startsWith('signal:child:'))
4689
- ids.push(cp.name.slice('signal:child:'.length));
4690
- if (cp.name.startsWith(FOLDED_PREFIX))
4691
- ids.push(...foldedChildIds(cp));
4692
- if (cp.name.startsWith('spawn:') && typeof cp.output === 'string')
4693
- ids.push(cp.output);
4694
- }
4695
- return ids;
4696
- }
4697
4895
  //# sourceMappingURL=engine.js.map