@adonis-agora/durable 0.45.0 → 0.46.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.
- package/CHANGELOG.md +24 -0
- package/dist/assets/spa/assets/index-Cxg7WNCV.js +16 -0
- package/dist/assets/spa/assets/index-Cxg7WNCV.js.map +1 -0
- package/dist/assets/spa/index.html +1 -1
- package/dist/src/admission-redis/redis-admission-backend.d.ts +1 -0
- package/dist/src/admission-redis/redis-admission-backend.d.ts.map +1 -1
- package/dist/src/admission-redis/redis-admission-backend.js +4 -1
- package/dist/src/admission-redis/redis-admission-backend.js.map +1 -1
- package/dist/src/codec-state-store.d.ts +9 -1
- package/dist/src/codec-state-store.d.ts.map +1 -1
- package/dist/src/codec-state-store.js +20 -0
- package/dist/src/codec-state-store.js.map +1 -1
- package/dist/src/commands/worker.d.ts.map +1 -1
- package/dist/src/commands/worker.js +2 -0
- package/dist/src/commands/worker.js.map +1 -1
- package/dist/src/completion.d.ts +2 -0
- package/dist/src/completion.d.ts.map +1 -1
- package/dist/src/completion.js +5 -0
- package/dist/src/completion.js.map +1 -1
- package/dist/src/control-plane-redis/redis-control-plane.d.ts.map +1 -1
- package/dist/src/control-plane-redis/redis-control-plane.js +3 -2
- package/dist/src/control-plane-redis/redis-control-plane.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +1 -1
- package/dist/src/dashboard/index.js +1 -1
- package/dist/src/diagnostics-bridge.d.ts +4 -1
- package/dist/src/diagnostics-bridge.d.ts.map +1 -1
- package/dist/src/diagnostics-bridge.js +9 -3
- package/dist/src/diagnostics-bridge.js.map +1 -1
- package/dist/src/engine.d.ts +50 -1
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +178 -47
- package/dist/src/engine.js.map +1 -1
- package/dist/src/errors.d.ts +16 -1
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +4 -1
- package/dist/src/errors.js.map +1 -1
- package/dist/src/fan-join.d.ts +61 -0
- package/dist/src/fan-join.d.ts.map +1 -0
- package/dist/src/fan-join.js +97 -0
- package/dist/src/fan-join.js.map +1 -0
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/interfaces.d.ts +58 -9
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/interfaces.js.map +1 -1
- package/dist/src/run-waiting.d.ts +6 -1
- package/dist/src/run-waiting.d.ts.map +1 -1
- package/dist/src/run-waiting.js +19 -0
- package/dist/src/run-waiting.js.map +1 -1
- package/dist/src/safe-subscribe.d.ts +11 -0
- package/dist/src/safe-subscribe.d.ts.map +1 -0
- package/dist/src/safe-subscribe.js +24 -0
- package/dist/src/safe-subscribe.js.map +1 -0
- package/dist/src/scheduler.d.ts +1 -1
- package/dist/src/scheduler.d.ts.map +1 -1
- package/dist/src/scheduler.js +4 -0
- package/dist/src/scheduler.js.map +1 -1
- package/dist/src/schedules.d.ts +2 -0
- package/dist/src/schedules.d.ts.map +1 -1
- package/dist/src/schedules.js +4 -0
- package/dist/src/schedules.js.map +1 -1
- package/dist/src/stores/lucid.d.ts +4 -1
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +62 -0
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/telescope/data-providers.d.ts.map +1 -1
- package/dist/src/telescope/data-providers.js +13 -3
- package/dist/src/telescope/data-providers.js.map +1 -1
- package/dist/src/testing/in-memory-state-store.d.ts +4 -1
- package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
- package/dist/src/testing/in-memory-state-store.js +18 -0
- package/dist/src/testing/in-memory-state-store.js.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.js +105 -0
- package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts +10 -0
- package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.js +48 -10
- package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
- package/dist/src/transports/factory.d.ts +9 -0
- package/dist/src/transports/factory.d.ts.map +1 -1
- package/dist/src/transports/factory.js +2 -0
- package/dist/src/transports/factory.js.map +1 -1
- package/dist/src/workflow-ctx.d.ts.map +1 -1
- package/dist/src/workflow-ctx.js +214 -70
- package/dist/src/workflow-ctx.js.map +1 -1
- package/package.json +4 -4
- package/skills/durable-workflows/SKILL.md +4 -1
- package/dist/assets/spa/assets/index-BCFDK6g_.js +0 -16
- package/dist/assets/spa/assets/index-BCFDK6g_.js.map +0 -1
package/dist/src/engine.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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.
|
|
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
|
|
1656
|
-
//
|
|
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
|
-
|
|
1674
|
-
//
|
|
1675
|
-
// the
|
|
1676
|
-
//
|
|
1677
|
-
//
|
|
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
|
|
1682
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
2215
|
-
|
|
2216
|
-
|
|
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
|
|
2225
|
-
//
|
|
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,
|
|
2229
|
-
: (await this.store.listCheckpoints(parentRunId)).filter((cp) =>
|
|
2230
|
-
for (const
|
|
2231
|
-
|
|
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
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
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
|