@adonis-agora/durable 0.47.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.
- package/CHANGELOG.md +55 -0
- package/dist/assets/spa/assets/index-6nmSTfT7.css +1 -0
- package/dist/assets/spa/assets/index-DdB9zxuX.js +16 -0
- package/dist/assets/spa/assets/index-DdB9zxuX.js.map +1 -0
- package/dist/assets/spa/index.html +2 -2
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +1 -0
- package/dist/configure.js.map +1 -1
- package/dist/providers/dashboard_provider.d.ts +3 -1
- package/dist/providers/dashboard_provider.d.ts.map +1 -1
- package/dist/providers/dashboard_provider.js +7 -2
- package/dist/providers/dashboard_provider.js.map +1 -1
- package/dist/providers/durable_provider.d.ts +0 -25
- package/dist/providers/durable_provider.d.ts.map +1 -1
- package/dist/providers/durable_provider.js +30 -5
- package/dist/providers/durable_provider.js.map +1 -1
- package/dist/src/codec-state-store.d.ts +2 -0
- package/dist/src/codec-state-store.d.ts.map +1 -1
- package/dist/src/codec-state-store.js +6 -0
- package/dist/src/codec-state-store.js.map +1 -1
- package/dist/src/completion.d.ts +11 -0
- package/dist/src/completion.d.ts.map +1 -1
- package/dist/src/completion.js +18 -1
- package/dist/src/completion.js.map +1 -1
- package/dist/src/dashboard/gateway-adapter.d.ts.map +1 -1
- package/dist/src/dashboard/gateway-adapter.js +10 -0
- package/dist/src/dashboard/gateway-adapter.js.map +1 -1
- package/dist/src/dashboard/handlers.d.ts +22 -1
- package/dist/src/dashboard/handlers.d.ts.map +1 -1
- package/dist/src/dashboard/handlers.js +63 -10
- package/dist/src/dashboard/handlers.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +1 -1
- package/dist/src/dashboard/index.js +1 -1
- package/dist/src/dashboard/openapi.d.ts.map +1 -1
- package/dist/src/dashboard/openapi.js +26 -2
- package/dist/src/dashboard/openapi.js.map +1 -1
- package/dist/src/define_config.d.ts +6 -0
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/engine.d.ts +89 -3
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +448 -39
- package/dist/src/engine.js.map +1 -1
- package/dist/src/errors.d.ts +30 -12
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +17 -8
- package/dist/src/errors.js.map +1 -1
- package/dist/src/fan-join.d.ts +36 -6
- package/dist/src/fan-join.d.ts.map +1 -1
- package/dist/src/fan-join.js +63 -8
- package/dist/src/fan-join.js.map +1 -1
- package/dist/src/handshake/announced.d.ts +94 -0
- package/dist/src/handshake/announced.d.ts.map +1 -0
- package/dist/src/handshake/announced.js +113 -0
- package/dist/src/handshake/announced.js.map +1 -0
- package/dist/src/handshake/descriptor.d.ts +59 -0
- package/dist/src/handshake/descriptor.d.ts.map +1 -1
- package/dist/src/handshake/descriptor.js +38 -0
- package/dist/src/handshake/descriptor.js.map +1 -1
- package/dist/src/index.d.ts +6 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/interfaces.d.ts +85 -4
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/interfaces.js.map +1 -1
- package/dist/src/metrics.d.ts +5 -0
- package/dist/src/metrics.d.ts.map +1 -1
- package/dist/src/metrics.js +14 -1
- package/dist/src/metrics.js.map +1 -1
- package/dist/src/on-error.d.ts +59 -0
- package/dist/src/on-error.d.ts.map +1 -0
- package/dist/src/on-error.js +71 -0
- package/dist/src/on-error.js.map +1 -0
- package/dist/src/otel/durable-metrics.d.ts +6 -0
- package/dist/src/otel/durable-metrics.d.ts.map +1 -1
- package/dist/src/otel/durable-metrics.js +22 -2
- package/dist/src/otel/durable-metrics.js.map +1 -1
- package/dist/src/otel/durable-otel.d.ts.map +1 -1
- package/dist/src/otel/durable-otel.js +15 -0
- package/dist/src/otel/durable-otel.js.map +1 -1
- package/dist/src/protocol.d.ts.map +1 -1
- package/dist/src/protocol.js +1 -0
- package/dist/src/protocol.js.map +1 -1
- package/dist/src/run-detail-summary.d.ts +98 -0
- package/dist/src/run-detail-summary.d.ts.map +1 -0
- package/dist/src/run-detail-summary.js +150 -0
- package/dist/src/run-detail-summary.js.map +1 -0
- package/dist/src/run-gateway/interface.d.ts +11 -0
- package/dist/src/run-gateway/interface.d.ts.map +1 -1
- package/dist/src/run-gateway/proxy-run-gateway.d.ts +3 -0
- package/dist/src/run-gateway/proxy-run-gateway.d.ts.map +1 -1
- package/dist/src/run-gateway/proxy-run-gateway.js +8 -0
- package/dist/src/run-gateway/proxy-run-gateway.js.map +1 -1
- package/dist/src/run-gateway/run-request-responder.d.ts +4 -3
- package/dist/src/run-gateway/run-request-responder.d.ts.map +1 -1
- package/dist/src/run-gateway/run-request-responder.js +23 -3
- package/dist/src/run-gateway/run-request-responder.js.map +1 -1
- package/dist/src/run-gateway/store-run-gateway.d.ts +3 -0
- package/dist/src/run-gateway/store-run-gateway.d.ts.map +1 -1
- package/dist/src/run-gateway/store-run-gateway.js +7 -0
- package/dist/src/run-gateway/store-run-gateway.js.map +1 -1
- package/dist/src/stores/lucid.d.ts +1 -0
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +17 -4
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/testing/in-memory-state-store.d.ts +1 -0
- package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
- package/dist/src/testing/in-memory-state-store.js +14 -6
- 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 +59 -0
- package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
- package/dist/src/transports/bullmq/adaptive-concurrency.d.ts +144 -0
- package/dist/src/transports/bullmq/adaptive-concurrency.d.ts.map +1 -0
- package/dist/src/transports/bullmq/adaptive-concurrency.js +312 -0
- package/dist/src/transports/bullmq/adaptive-concurrency.js.map +1 -0
- package/dist/src/transports/bullmq/bullmq-transport.d.ts +18 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.js +50 -49
- package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
- package/dist/src/transports/bullmq/deps.d.ts +11 -1
- package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
- package/dist/src/transports/bullmq/deps.js +12 -1
- package/dist/src/transports/bullmq/deps.js.map +1 -1
- package/dist/src/transports/bullmq/index.d.ts +1 -0
- package/dist/src/transports/bullmq/index.d.ts.map +1 -1
- package/dist/src/transports/bullmq/index.js +1 -0
- package/dist/src/transports/bullmq/index.js.map +1 -1
- package/dist/src/transports/db-schema.d.ts +12 -0
- package/dist/src/transports/db-schema.d.ts.map +1 -1
- package/dist/src/transports/db-schema.js +24 -0
- package/dist/src/transports/db-schema.js.map +1 -1
- package/dist/src/transports/db.d.ts +14 -0
- package/dist/src/transports/db.d.ts.map +1 -1
- package/dist/src/transports/db.js +34 -0
- package/dist/src/transports/db.js.map +1 -1
- package/dist/src/transports/event-emitter.d.ts +8 -0
- package/dist/src/transports/event-emitter.d.ts.map +1 -1
- package/dist/src/transports/event-emitter.js +27 -0
- package/dist/src/transports/event-emitter.js.map +1 -1
- package/dist/src/transports/factory.d.ts +33 -2
- package/dist/src/transports/factory.d.ts.map +1 -1
- package/dist/src/transports/factory.js +29 -1
- package/dist/src/transports/factory.js.map +1 -1
- package/dist/src/transports/queue.d.ts +19 -0
- package/dist/src/transports/queue.d.ts.map +1 -1
- package/dist/src/transports/queue.js +17 -0
- package/dist/src/transports/queue.js.map +1 -1
- package/dist/src/transports/worker-keys-db.d.ts +17 -0
- package/dist/src/transports/worker-keys-db.d.ts.map +1 -0
- package/dist/src/transports/worker-keys-db.js +43 -0
- package/dist/src/transports/worker-keys-db.js.map +1 -0
- package/dist/src/transports/worker-keys.d.ts +56 -0
- package/dist/src/transports/worker-keys.d.ts.map +1 -0
- package/dist/src/transports/worker-keys.js +92 -0
- package/dist/src/transports/worker-keys.js.map +1 -0
- package/dist/src/worker-runtime/index.d.ts +1 -1
- package/dist/src/worker-runtime/index.d.ts.map +1 -1
- package/dist/src/worker-runtime/index.js.map +1 -1
- package/dist/src/worker-runtime/worker-runtime.d.ts +7 -2
- package/dist/src/worker-runtime/worker-runtime.d.ts.map +1 -1
- package/dist/src/worker-runtime/worker-runtime.js +22 -20
- package/dist/src/worker-runtime/worker-runtime.js.map +1 -1
- package/dist/src/workflow-ctx.d.ts +13 -1
- package/dist/src/workflow-ctx.d.ts.map +1 -1
- package/dist/src/workflow-ctx.js +44 -6
- package/dist/src/workflow-ctx.js.map +1 -1
- package/dist/src/workflow-discovery.d.ts.map +1 -1
- package/dist/src/workflow-discovery.js +2 -1
- package/dist/src/workflow-discovery.js.map +1 -1
- package/dist/src/workflow-ref.d.ts +31 -0
- package/dist/src/workflow-ref.d.ts.map +1 -1
- package/dist/src/workflow-ref.js +22 -0
- package/dist/src/workflow-ref.js.map +1 -1
- package/dist/stubs/database/migrations/create_durable_transport_worker_keys.stub +24 -0
- package/package.json +5 -5
- package/skills/durable-cluster/SKILL.md +10 -0
- package/skills/durable-transports-stores/SKILL.md +37 -0
- package/skills/durable-workflows/SKILL.md +22 -1
- package/dist/assets/spa/assets/index-CL6q2kMB.js +0 -16
- package/dist/assets/spa/assets/index-CL6q2kMB.js.map +0 -1
- package/dist/assets/spa/assets/index-DU6cGcJz.css +0 -1
package/dist/src/engine.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
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 { isFailedCompletion } from './completion.js';
|
|
4
|
+
import { completionError, failedCompletion, isFailedCompletion, } from './completion.js';
|
|
5
5
|
import { assertConcurrency, concurrencyTag, countRuns, resolveConcurrency, } from './concurrency.js';
|
|
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,
|
|
12
|
+
import { childIdsOf, FOLDED_PREFIX, fanOfWaiter, parseStepFanGroup, stepFanGroup, } from './fan-join.js';
|
|
13
|
+
import { aggregateAnnouncements } from './handshake/announced.js';
|
|
13
14
|
import { isNonEmptyRunScope, TERMINAL_RUN_STATUSES } from './interfaces.js';
|
|
14
15
|
import { asHeartbeat } from './observability-scope.js';
|
|
16
|
+
import { assertRoutes, codeAndDetails, HANDOFF_SEQ_BASE, handoffHandlerId, handoffName, liveHandoff, onErrorRunId, retiredHandoffName, routeFor, } from './on-error.js';
|
|
15
17
|
import { breakpointToken, stepId } from './protocol.js';
|
|
16
18
|
import { RemoteWorkflowExecutor } from './remote-workflow-executor.js';
|
|
17
19
|
import { scanRunValueFacets } from './run-value-facets.js';
|
|
@@ -58,6 +60,8 @@ const SETTLE_ELIGIBLE_STATUSES = [
|
|
|
58
60
|
'blocked',
|
|
59
61
|
'failed',
|
|
60
62
|
];
|
|
63
|
+
/** How long a read of the onError routes a fleet announces is reused. */
|
|
64
|
+
const ANNOUNCED_ROUTES_TTL_MS = 5_000;
|
|
61
65
|
const versionKey = (name, version) => `${name}@${version}`;
|
|
62
66
|
/**
|
|
63
67
|
* First checkpoint seq reserved for saga compensations: compensation `idx` (registration order)
|
|
@@ -66,6 +70,18 @@ const versionKey = (name, version) => `${name}@${version}`;
|
|
|
66
70
|
* un-checkpointed `-1` undo dispatches of older engine versions.
|
|
67
71
|
*/
|
|
68
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
|
+
}
|
|
69
85
|
/**
|
|
70
86
|
* The routing token a dispatched step lands on: the step's own (broker-safe) name, suffixed by the
|
|
71
87
|
* tenant the RUN belongs to. `runNamespace` — not the executing engine's namespace — is the tenant
|
|
@@ -129,6 +145,7 @@ export class WorkflowEngine {
|
|
|
129
145
|
leaseMs;
|
|
130
146
|
maxRecoveryAttempts;
|
|
131
147
|
reconcileMs;
|
|
148
|
+
timerBatchSize;
|
|
132
149
|
remoteRedispatchMs;
|
|
133
150
|
remoteRedispatchMax;
|
|
134
151
|
webhookUrl;
|
|
@@ -198,6 +215,8 @@ export class WorkflowEngine {
|
|
|
198
215
|
updateValidators = new Map();
|
|
199
216
|
/** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
|
|
200
217
|
cancelRequested = new Set();
|
|
218
|
+
/** {@link announcedRoutes} reads, by routing token + workflow + version. */
|
|
219
|
+
announcedRoutesCache = new Map();
|
|
201
220
|
/** The merged schedule set the worker loop ticks with (see {@link adoptSchedules}). */
|
|
202
221
|
#allSchedules = [];
|
|
203
222
|
/** Runtime pause overrides by schedule key (console-issued; win over the config's `paused`). */
|
|
@@ -278,6 +297,12 @@ export class WorkflowEngine {
|
|
|
278
297
|
this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
|
|
279
298
|
// Default 5min; an explicit 0 disables the fallback (opt back into wake-forever-on-lost-event).
|
|
280
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;
|
|
281
306
|
// Opt-in: unset (or 0) leaves the by-design "re-suspend a lost dispatch, never re-dispatch" behavior.
|
|
282
307
|
this.remoteRedispatchMs = deps.remoteRedispatchMs || undefined;
|
|
283
308
|
this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
|
|
@@ -545,6 +570,7 @@ export class WorkflowEngine {
|
|
|
545
570
|
name,
|
|
546
571
|
version,
|
|
547
572
|
fn,
|
|
573
|
+
onError: this.checkedRoutes(name, opts?.onError),
|
|
548
574
|
tags: opts?.tags,
|
|
549
575
|
origin: opts?.origin,
|
|
550
576
|
singleton: opts?.singleton,
|
|
@@ -575,6 +601,7 @@ export class WorkflowEngine {
|
|
|
575
601
|
const registered = {
|
|
576
602
|
name,
|
|
577
603
|
version,
|
|
604
|
+
onError: this.checkedRoutes(name, opts.onError),
|
|
578
605
|
// A remote workflow has no in-process body; execute() branches on `remote` before this is read.
|
|
579
606
|
fn: () => {
|
|
580
607
|
throw new Error(`workflow ${name} is remote — it has no in-process body`);
|
|
@@ -613,6 +640,9 @@ export class WorkflowEngine {
|
|
|
613
640
|
group: name,
|
|
614
641
|
executor: new RemoteWorkflowExecutor(this.primaryTransport, name),
|
|
615
642
|
});
|
|
643
|
+
const registered = this.workflows.get(versionKey(name, '1'));
|
|
644
|
+
if (registered)
|
|
645
|
+
registered.convention = true;
|
|
616
646
|
return true;
|
|
617
647
|
}
|
|
618
648
|
/**
|
|
@@ -1350,6 +1380,31 @@ export class WorkflowEngine {
|
|
|
1350
1380
|
}
|
|
1351
1381
|
})());
|
|
1352
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
|
+
}
|
|
1353
1408
|
/** Track an in-flight execution so {@link drain} can wait for it. */
|
|
1354
1409
|
track(runId, p) {
|
|
1355
1410
|
this.inflight.set(p, runId);
|
|
@@ -1625,8 +1680,10 @@ export class WorkflowEngine {
|
|
|
1625
1680
|
* not due re-suspends cheaply without running new work.
|
|
1626
1681
|
*/
|
|
1627
1682
|
async resumeDueTimers(nowMs = this.clock()) {
|
|
1628
|
-
// Capped per tick (a backlog drains over several polls) — a store may ignore the cap.
|
|
1629
|
-
|
|
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)));
|
|
1630
1687
|
const blocked = await this.dueBlockedRuns(nowMs);
|
|
1631
1688
|
return this.resumeLeased([...due, ...blocked], nowMs);
|
|
1632
1689
|
}
|
|
@@ -1689,6 +1746,24 @@ export class WorkflowEngine {
|
|
|
1689
1746
|
if (run.status === 'failed' || run.status === 'dead') {
|
|
1690
1747
|
const isFailureCompletion = (v) => typeof v === 'object' && v !== null && v.ok === false;
|
|
1691
1748
|
for (const cp of await this.store.listCheckpoints(runId)) {
|
|
1749
|
+
// A retry runs the body again, so a failure it handed to an onError handler is retired: kept in
|
|
1750
|
+
// the timeline under another name, never followed again, and the handler is not retried. Its
|
|
1751
|
+
// `onError:<code>` tag goes too: the tag says how the run ended, and that is being redone.
|
|
1752
|
+
const handlerId = handoffHandlerId(runId, cp);
|
|
1753
|
+
if (handlerId !== undefined) {
|
|
1754
|
+
await this.store.takeSignalWaiter(`child:${handlerId}`);
|
|
1755
|
+
await this.store.saveCheckpoint({
|
|
1756
|
+
...cp,
|
|
1757
|
+
name: retiredHandoffName(handlerId),
|
|
1758
|
+
status: 'completed',
|
|
1759
|
+
});
|
|
1760
|
+
const tags = run.tags?.filter((t) => !t.startsWith('onError:'));
|
|
1761
|
+
if (tags && tags.length !== run.tags?.length) {
|
|
1762
|
+
await this.store.updateRun(runId, { tags, updatedAt: new Date() });
|
|
1763
|
+
run.tags = tags;
|
|
1764
|
+
}
|
|
1765
|
+
continue;
|
|
1766
|
+
}
|
|
1692
1767
|
if (cp.status === 'failed') {
|
|
1693
1768
|
await this.store.saveCheckpoint({
|
|
1694
1769
|
...cp,
|
|
@@ -2208,7 +2283,7 @@ export class WorkflowEngine {
|
|
|
2208
2283
|
? buffered.payload
|
|
2209
2284
|
: settled.status === 'completed'
|
|
2210
2285
|
? { ok: true, value: settled.output }
|
|
2211
|
-
:
|
|
2286
|
+
: failedCompletion(settled.error ?? { message: settled.status });
|
|
2212
2287
|
await this.signal(token, completion);
|
|
2213
2288
|
}
|
|
2214
2289
|
/**
|
|
@@ -2597,6 +2672,169 @@ export class WorkflowEngine {
|
|
|
2597
2672
|
* {@link runExecution}'s settle/suspend; the lease is held by {@link execute}. The result that lands
|
|
2598
2673
|
* us back here (a remote step finished, a timer fired) goes through `resume` like any TS workflow.
|
|
2599
2674
|
*/
|
|
2675
|
+
/**
|
|
2676
|
+
* The registered onError routes whose handler workflow nothing can run: not registered on this
|
|
2677
|
+
* engine, and no live worker group serves it. Meant for a boot-time warning: a typo in a handler's
|
|
2678
|
+
* name would otherwise surface only when the first run fails.
|
|
2679
|
+
*/
|
|
2680
|
+
async unservedOnErrorRoutes() {
|
|
2681
|
+
const unserved = [];
|
|
2682
|
+
let groups;
|
|
2683
|
+
for (const registered of this.workflows.values()) {
|
|
2684
|
+
for (const route of registered.onError ?? []) {
|
|
2685
|
+
if (this.latest.has(route.workflow))
|
|
2686
|
+
continue;
|
|
2687
|
+
groups ??= await this.pool.listWorkerGroups().catch(() => []);
|
|
2688
|
+
if (groups.some((g) => g === route.workflow || g.startsWith(`${route.workflow}@`)))
|
|
2689
|
+
continue;
|
|
2690
|
+
unserved.push({ workflow: registered.name, route });
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
return unserved;
|
|
2694
|
+
}
|
|
2695
|
+
/**
|
|
2696
|
+
* The onError routes the live workers on `token` announce for a workflow the engine serves by
|
|
2697
|
+
* convention: the one set they agree on, or none when they state none or cannot be read. Workers
|
|
2698
|
+
* that announce different sets (a rollout in progress) route nothing, and the run's failure is
|
|
2699
|
+
* reported as a `run.unrouted` event. Announcers of another version of the workflow are ignored. The
|
|
2700
|
+
* read is cached for {@link ANNOUNCED_ROUTES_TTL_MS}, so a burst of failures costs one read.
|
|
2701
|
+
*/
|
|
2702
|
+
async announcedRoutes(run, token, error) {
|
|
2703
|
+
const key = `${token}\n${run.workflow}\n${run.workflowVersion}`;
|
|
2704
|
+
const now = this.clock();
|
|
2705
|
+
let cached = this.announcedRoutesCache.get(key);
|
|
2706
|
+
if (!cached || now - cached.at > ANNOUNCED_ROUTES_TTL_MS) {
|
|
2707
|
+
const sets = new Map();
|
|
2708
|
+
for (const announced of aggregateAnnouncements(await this.liveDescriptorsFor(token))) {
|
|
2709
|
+
if (announced.name !== run.workflow)
|
|
2710
|
+
continue;
|
|
2711
|
+
if (announced.version !== undefined && announced.version !== run.workflowVersion)
|
|
2712
|
+
continue;
|
|
2713
|
+
for (const routes of announced.onError)
|
|
2714
|
+
sets.set(JSON.stringify(routes), routes);
|
|
2715
|
+
}
|
|
2716
|
+
cached = { at: now, sets: [...sets.values()] };
|
|
2717
|
+
this.announcedRoutesCache.set(key, cached);
|
|
2718
|
+
}
|
|
2719
|
+
if (cached.sets.length > 1) {
|
|
2720
|
+
this.emit({
|
|
2721
|
+
type: 'run.unrouted',
|
|
2722
|
+
runId: run.id,
|
|
2723
|
+
workflow: run.workflow,
|
|
2724
|
+
namespace: run.namespace,
|
|
2725
|
+
error,
|
|
2726
|
+
reason: `live workers announce ${cached.sets.length} different onError route sets`,
|
|
2727
|
+
});
|
|
2728
|
+
return undefined;
|
|
2729
|
+
}
|
|
2730
|
+
return cached.sets[0];
|
|
2731
|
+
}
|
|
2732
|
+
/** Validate a workflow's {@link OnErrorRoute}s at registration. */
|
|
2733
|
+
checkedRoutes(name, routes) {
|
|
2734
|
+
if (!routes?.length)
|
|
2735
|
+
return undefined;
|
|
2736
|
+
assertRoutes(name, routes);
|
|
2737
|
+
return routes;
|
|
2738
|
+
}
|
|
2739
|
+
/**
|
|
2740
|
+
* Route a failed run to its {@link OnErrorRoute} handler. Starts the handler run and waits for it the
|
|
2741
|
+
* way a parent waits for a `ctx.child`: a `child:<handlerId>` waiter plus a `running` placeholder
|
|
2742
|
+
* checkpoint carrying the handler's name and input, so the handler's terminal state wakes the run
|
|
2743
|
+
* (buffered and reconciled like any child). From then on every execution of the run only follows the
|
|
2744
|
+
* handler ({@link followHandoff}); the body never runs again. Undefined when no route matches: the
|
|
2745
|
+
* run fails as usual.
|
|
2746
|
+
*
|
|
2747
|
+
* The placeholder sits past every seq the body used and is left out of {@link remoteHistory}.
|
|
2748
|
+
*/
|
|
2749
|
+
async routeFailure(run, error) {
|
|
2750
|
+
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
|
|
2751
|
+
const routes = registered?.convention
|
|
2752
|
+
? await this.announcedRoutes(run, registered.remote?.group ?? run.workflow, error)
|
|
2753
|
+
: registered?.onError;
|
|
2754
|
+
const route = routeFor(routes, error);
|
|
2755
|
+
if (!route)
|
|
2756
|
+
return undefined;
|
|
2757
|
+
const checkpoints = await this.handoffCheckpoints(run.id);
|
|
2758
|
+
const live = liveHandoff(run.id, checkpoints);
|
|
2759
|
+
if (live)
|
|
2760
|
+
return this.followHandoff(run, live);
|
|
2761
|
+
// One handoff per attempt: a retry retires the previous one, so the next sits past it.
|
|
2762
|
+
const attempt = checkpoints.length;
|
|
2763
|
+
const seq = HANDOFF_SEQ_BASE + attempt;
|
|
2764
|
+
const handlerId = onErrorRunId(run.id, attempt);
|
|
2765
|
+
const handler = {
|
|
2766
|
+
workflow: route.workflow,
|
|
2767
|
+
input: { runId: run.id, workflow: run.workflow, input: run.input, error },
|
|
2768
|
+
};
|
|
2769
|
+
await this.store.putSignalWaiter({ token: `child:${handlerId}`, runId: run.id, seq });
|
|
2770
|
+
await this.store.saveCheckpoint({
|
|
2771
|
+
...instantCheckpoint({
|
|
2772
|
+
runId: run.id,
|
|
2773
|
+
seq,
|
|
2774
|
+
name: handoffName(handlerId),
|
|
2775
|
+
kind: 'signal',
|
|
2776
|
+
status: 'running',
|
|
2777
|
+
}),
|
|
2778
|
+
input: handler,
|
|
2779
|
+
});
|
|
2780
|
+
// Tagged, so the runs whose failures were handed off are a filter away in the console.
|
|
2781
|
+
const tag = `onError:${route.code ?? '*'}`;
|
|
2782
|
+
if (!run.tags?.includes(tag)) {
|
|
2783
|
+
const tags = [...(run.tags ?? []), tag];
|
|
2784
|
+
await this.store.updateRun(run.id, { tags, updatedAt: new Date() });
|
|
2785
|
+
run.tags = tags;
|
|
2786
|
+
}
|
|
2787
|
+
this.emit({
|
|
2788
|
+
type: 'run.routed',
|
|
2789
|
+
runId: run.id,
|
|
2790
|
+
workflow: run.workflow,
|
|
2791
|
+
namespace: run.namespace,
|
|
2792
|
+
name: route.workflow,
|
|
2793
|
+
error,
|
|
2794
|
+
});
|
|
2795
|
+
this.startChildDeferred(handler.workflow, handler.input, handlerId, {
|
|
2796
|
+
namespace: run.namespace,
|
|
2797
|
+
});
|
|
2798
|
+
return { kind: 'suspended' };
|
|
2799
|
+
}
|
|
2800
|
+
/** A run's handoff checkpoints, live and retired: the only ones at {@link HANDOFF_SEQ_BASE} and up. */
|
|
2801
|
+
async handoffCheckpoints(runId) {
|
|
2802
|
+
const handoffs = await this.store.listCheckpointsByNamePrefix?.(runId, [
|
|
2803
|
+
`signal:child:${runId}.onError.`,
|
|
2804
|
+
`onError:retried:${runId}.onError.`,
|
|
2805
|
+
]);
|
|
2806
|
+
return (handoffs ?? (await this.store.listCheckpoints(runId))).filter((cp) => cp.seq >= HANDOFF_SEQ_BASE);
|
|
2807
|
+
}
|
|
2808
|
+
/**
|
|
2809
|
+
* Where a run that handed its failure off stands: the handler's outcome once it settled (from the
|
|
2810
|
+
* wake's checkpoint, or off the handler run when the wake never arrived), else still waiting. Starts
|
|
2811
|
+
* the handler if the turn that handed off crashed before its deferred start ran.
|
|
2812
|
+
*/
|
|
2813
|
+
async followHandoff(run, handoff) {
|
|
2814
|
+
if (handoff.status === 'completed') {
|
|
2815
|
+
const outcome = handoff.output;
|
|
2816
|
+
return outcome.ok
|
|
2817
|
+
? { kind: 'completed', output: outcome.value }
|
|
2818
|
+
: { kind: 'failed', error: completionError(outcome) };
|
|
2819
|
+
}
|
|
2820
|
+
const handlerId = handoffHandlerId(run.id, handoff);
|
|
2821
|
+
const handlerRun = await this.store.getRun(handlerId);
|
|
2822
|
+
if (handlerRun?.status === 'completed')
|
|
2823
|
+
return { kind: 'completed', output: handlerRun.output };
|
|
2824
|
+
if (handlerRun && TERMINAL_RUN_STATUSES.includes(handlerRun.status)) {
|
|
2825
|
+
return {
|
|
2826
|
+
kind: 'failed',
|
|
2827
|
+
error: handlerRun.error ?? { message: `onError handler ${handlerId} ${handlerRun.status}` },
|
|
2828
|
+
};
|
|
2829
|
+
}
|
|
2830
|
+
if (!handlerRun) {
|
|
2831
|
+
const handler = handoff.input;
|
|
2832
|
+
this.startChildDeferred(handler.workflow, handler.input, handlerId, {
|
|
2833
|
+
namespace: run.namespace,
|
|
2834
|
+
});
|
|
2835
|
+
}
|
|
2836
|
+
return { kind: 'suspended' };
|
|
2837
|
+
}
|
|
2600
2838
|
/**
|
|
2601
2839
|
* Apply a terminal/suspended transition once: persist the new run status, emit the matching
|
|
2602
2840
|
* lifecycle event, and (on completion/failure) wake a waiting parent. Both the TS and remote
|
|
@@ -2639,6 +2877,10 @@ export class WorkflowEngine {
|
|
|
2639
2877
|
return { runId: run.id, status: 'completed', output: outcome.output };
|
|
2640
2878
|
}
|
|
2641
2879
|
if (outcome.kind === 'failed') {
|
|
2880
|
+
const handed = await this.routeFailure(run, outcome.error);
|
|
2881
|
+
if (handed && handed.kind !== 'failed')
|
|
2882
|
+
return this.settleRun(run, handed);
|
|
2883
|
+
const error = handed?.error ?? outcome.error;
|
|
2642
2884
|
// Same race as the `completed` branch above — guard identically. ADDITIONALLY exclude
|
|
2643
2885
|
// `failed`: a stray resume of an already-failed run (a redelivered signal, the settle-recheck
|
|
2644
2886
|
// re-drive) replays and deterministically re-fails — re-applying the settle would re-emit
|
|
@@ -2649,7 +2891,7 @@ export class WorkflowEngine {
|
|
|
2649
2891
|
// status), so it is unaffected.
|
|
2650
2892
|
const applied = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES.filter((s) => s !== 'failed'), {
|
|
2651
2893
|
status: 'failed',
|
|
2652
|
-
error
|
|
2894
|
+
error,
|
|
2653
2895
|
updatedAt,
|
|
2654
2896
|
});
|
|
2655
2897
|
if (!applied)
|
|
@@ -2659,10 +2901,10 @@ export class WorkflowEngine {
|
|
|
2659
2901
|
runId: run.id,
|
|
2660
2902
|
workflow: run.workflow,
|
|
2661
2903
|
namespace: run.namespace,
|
|
2662
|
-
error
|
|
2904
|
+
error,
|
|
2663
2905
|
});
|
|
2664
|
-
this.notifyParent(run.id,
|
|
2665
|
-
return { runId: run.id, status: 'failed', error
|
|
2906
|
+
this.notifyParent(run.id, failedCompletion(error));
|
|
2907
|
+
return { runId: run.id, status: 'failed', error };
|
|
2666
2908
|
}
|
|
2667
2909
|
// This outcome was computed by a turn that started from a possibly-stale run snapshot. If the run
|
|
2668
2910
|
// was cancelled WHILE that turn was still executing (e.g. `ctx.all`'s failFast cancelling a
|
|
@@ -2746,7 +2988,13 @@ export class WorkflowEngine {
|
|
|
2746
2988
|
const routePlan = await this.planRoute(remote.group, registered.requires);
|
|
2747
2989
|
if (routePlan?.status === 'blocked')
|
|
2748
2990
|
return this.parkBlocked(run, routePlan);
|
|
2749
|
-
const
|
|
2991
|
+
const checkpoints = await this.store.listCheckpoints(run.id);
|
|
2992
|
+
const handoff = liveHandoff(run.id, checkpoints);
|
|
2993
|
+
if (handoff) {
|
|
2994
|
+
// A run that handed its failure to an onError handler only follows the handler from here on.
|
|
2995
|
+
return this.settleRun(run, await this.followHandoff(run, handoff));
|
|
2996
|
+
}
|
|
2997
|
+
const history = await this.remoteHistory(run.id, checkpoints);
|
|
2750
2998
|
let decision;
|
|
2751
2999
|
try {
|
|
2752
3000
|
decision = await remote.executor.advance(run, history);
|
|
@@ -3007,8 +3255,8 @@ export class WorkflowEngine {
|
|
|
3007
3255
|
}
|
|
3008
3256
|
}
|
|
3009
3257
|
/** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
|
|
3010
|
-
async remoteHistory(runId) {
|
|
3011
|
-
const checkpoints = await this.store.listCheckpoints(runId);
|
|
3258
|
+
async remoteHistory(runId, listed) {
|
|
3259
|
+
const checkpoints = listed ?? (await this.store.listCheckpoints(runId));
|
|
3012
3260
|
const kindOf = {
|
|
3013
3261
|
remote: 'call',
|
|
3014
3262
|
local: 'step',
|
|
@@ -3021,13 +3269,16 @@ export class WorkflowEngine {
|
|
|
3021
3269
|
// A child run resolves THROUGH the signal machinery (a `child:<id>` waiter notified on the
|
|
3022
3270
|
// child's terminal state), so its checkpoint is kind `signal` with a `signal:child:` name and
|
|
3023
3271
|
// a Completion payload. Surface it as a `child` event with the value/error unwrapped.
|
|
3272
|
+
// onError handoffs sit past every op a body can reach; no replay is told about them.
|
|
3273
|
+
if (cp.seq >= HANDOFF_SEQ_BASE)
|
|
3274
|
+
continue;
|
|
3024
3275
|
if (cp.kind === 'signal' && cp.name.startsWith('signal:child:')) {
|
|
3025
3276
|
const completion = cp.output;
|
|
3026
3277
|
events.push({
|
|
3027
3278
|
seq: cp.seq,
|
|
3028
3279
|
kind: 'child',
|
|
3029
3280
|
output: completion?.ok ? completion.value : undefined,
|
|
3030
|
-
error: completion && completion.ok === false ?
|
|
3281
|
+
error: completion && completion.ok === false ? completionError(completion) : undefined,
|
|
3031
3282
|
});
|
|
3032
3283
|
continue;
|
|
3033
3284
|
}
|
|
@@ -3270,7 +3521,7 @@ export class WorkflowEngine {
|
|
|
3270
3521
|
child.status === 'dead') {
|
|
3271
3522
|
await deliverChild(child.status === 'completed'
|
|
3272
3523
|
? { ok: true, value: child.output }
|
|
3273
|
-
:
|
|
3524
|
+
: failedCompletion(child.error ?? { message: child.status }));
|
|
3274
3525
|
}
|
|
3275
3526
|
}
|
|
3276
3527
|
}
|
|
@@ -3330,6 +3581,11 @@ export class WorkflowEngine {
|
|
|
3330
3581
|
// counted an attempt (or dead-lettered it), flipped it to `pending` and re-enqueued a second
|
|
3331
3582
|
// execution. The lease must outlive the state it protects.
|
|
3332
3583
|
try {
|
|
3584
|
+
// A run that handed its failure to an onError handler only follows the handler from here on.
|
|
3585
|
+
const handoff = liveHandoff(run.id, snapshot);
|
|
3586
|
+
if (handoff && !this.cancelRequested.has(run.id)) {
|
|
3587
|
+
return await this.settleRun(run, await this.followHandoff(run, handoff));
|
|
3588
|
+
}
|
|
3333
3589
|
// Establish the ambient ctx for the duration of this body turn so context-aware statics
|
|
3334
3590
|
// (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
|
|
3335
3591
|
// `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
|
|
@@ -3432,6 +3688,9 @@ export class WorkflowEngine {
|
|
|
3432
3688
|
if (settled.status === 'suspended' && err.waitSeqs?.length) {
|
|
3433
3689
|
this.recheckWaitSeqs(run.id, err.waitSeqs);
|
|
3434
3690
|
}
|
|
3691
|
+
if (settled.status === 'suspended' && err.stepFans?.length) {
|
|
3692
|
+
this.recheckStepFans(run.id, err.stepFans);
|
|
3693
|
+
}
|
|
3435
3694
|
if (settled.status === 'suspended')
|
|
3436
3695
|
suspendedOnFan = err.fan;
|
|
3437
3696
|
return settled;
|
|
@@ -3439,6 +3698,7 @@ export class WorkflowEngine {
|
|
|
3439
3698
|
const error = {
|
|
3440
3699
|
message: err instanceof Error ? err.message : String(err),
|
|
3441
3700
|
stack: err instanceof Error ? err.stack : undefined,
|
|
3701
|
+
...codeAndDetails(err),
|
|
3442
3702
|
};
|
|
3443
3703
|
// Saga: undo completed steps in reverse, each retried up to `compensationRetries`. Outcomes
|
|
3444
3704
|
// are emitted as `compensate:<step>` step events so a stranded undo is VISIBLE (not silently
|
|
@@ -3717,6 +3977,7 @@ export class WorkflowEngine {
|
|
|
3717
3977
|
completeStep: (s) => this.completeStep(s),
|
|
3718
3978
|
failStep: (s) => this.failStep(s),
|
|
3719
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),
|
|
3720
3981
|
// Defer so a fast child can't reentrantly resume a still-running parent. The child inherits the
|
|
3721
3982
|
// PARENT RUN's namespace, not this engine's: an operator legitimately executes every tenant's
|
|
3722
3983
|
// parents, and without this the child would fall to the operator's own pool.
|
|
@@ -3910,7 +4171,7 @@ export class WorkflowEngine {
|
|
|
3910
4171
|
}));
|
|
3911
4172
|
return blocked;
|
|
3912
4173
|
}
|
|
3913
|
-
async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace) {
|
|
4174
|
+
async callRemote(runId, seq, step, input, queue, transport, replay, admission, runNamespace, parallelGroup) {
|
|
3914
4175
|
// Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
|
|
3915
4176
|
// from the snapshot — not yet dispatched, or written after the snapshot — falls back to the store.
|
|
3916
4177
|
let existing = replay?.get(seq) ?? (await this.store.getCheckpoint(runId, seq));
|
|
@@ -3953,7 +4214,7 @@ export class WorkflowEngine {
|
|
|
3953
4214
|
// The result may have settled the row since it was read: then nothing was written, and the
|
|
3954
4215
|
// step is re-read from the store.
|
|
3955
4216
|
if (!(await this.savePendingCheckpoint({ ...existing, wakeAt }))) {
|
|
3956
|
-
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);
|
|
3957
4218
|
}
|
|
3958
4219
|
throw new WorkflowSuspended(wakeAt, [seq]);
|
|
3959
4220
|
}
|
|
@@ -3985,7 +4246,7 @@ export class WorkflowEngine {
|
|
|
3985
4246
|
],
|
|
3986
4247
|
});
|
|
3987
4248
|
if (!claimed) {
|
|
3988
|
-
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);
|
|
3989
4250
|
}
|
|
3990
4251
|
await this.dispatchRemoteTask({
|
|
3991
4252
|
runId,
|
|
@@ -4081,6 +4342,7 @@ export class WorkflowEngine {
|
|
|
4081
4342
|
// released by WHICHEVER instance receives the result — the in-memory stepQueue map only
|
|
4082
4343
|
// covers the dispatching pod, and a slot released by no one starves the queue fleet-wide.
|
|
4083
4344
|
queue: admittedQueue,
|
|
4345
|
+
...(parallelGroup !== undefined ? { parallelGroup } : {}),
|
|
4084
4346
|
enqueuedAt,
|
|
4085
4347
|
startedAt: enqueuedAt, // placeholders until the worker result lands
|
|
4086
4348
|
finishedAt: enqueuedAt,
|
|
@@ -4099,6 +4361,150 @@ export class WorkflowEngine {
|
|
|
4099
4361
|
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
4100
4362
|
throw new WorkflowSuspended(undefined, [seq]);
|
|
4101
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
|
+
}
|
|
4102
4508
|
/**
|
|
4103
4509
|
* Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
|
|
4104
4510
|
* dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
|
|
@@ -4147,18 +4553,34 @@ export class WorkflowEngine {
|
|
|
4147
4553
|
return true;
|
|
4148
4554
|
}
|
|
4149
4555
|
async completeRemoteResult(result) {
|
|
4150
|
-
const
|
|
4151
|
-
if (
|
|
4556
|
+
const cp = await this.settleRemoteCheckpoint(result);
|
|
4557
|
+
if (cp === undefined)
|
|
4152
4558
|
return;
|
|
4559
|
+
if (await this.stepFanStillRunning(cp, result))
|
|
4560
|
+
return;
|
|
4561
|
+
const runId = result.runId;
|
|
4153
4562
|
this.trackEffect(this.resumeImplicit(runId).catch((err) => {
|
|
4154
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)}`);
|
|
4155
4564
|
}));
|
|
4156
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
|
+
}
|
|
4157
4579
|
/**
|
|
4158
4580
|
* The durable half of {@link completeRemoteResult}: settle `result` onto its checkpoint (or re-drive
|
|
4159
|
-
* the resume when a redelivered result finds it already settled). Returns the
|
|
4160
|
-
* `undefined` when no resume is wanted (no checkpoint, or a
|
|
4161
|
-
* 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).
|
|
4162
4584
|
*/
|
|
4163
4585
|
async settleRemoteCheckpoint(result) {
|
|
4164
4586
|
const cp = await this.store.getCheckpoint(result.runId, result.seq);
|
|
@@ -4186,7 +4608,7 @@ export class WorkflowEngine {
|
|
|
4186
4608
|
// explicit `requeue`/`durable:retry`, never to a stray result.
|
|
4187
4609
|
if (cp.status !== 'pending') {
|
|
4188
4610
|
if (!terminal)
|
|
4189
|
-
return
|
|
4611
|
+
return cp;
|
|
4190
4612
|
return undefined;
|
|
4191
4613
|
}
|
|
4192
4614
|
// A result settling this step frees its flow-control slot (no-op if it wasn't queued). Released
|
|
@@ -4229,7 +4651,7 @@ export class WorkflowEngine {
|
|
|
4229
4651
|
finishedAt,
|
|
4230
4652
|
});
|
|
4231
4653
|
if (!claimed)
|
|
4232
|
-
return terminal ? undefined :
|
|
4654
|
+
return terminal ? undefined : cp;
|
|
4233
4655
|
await this.releaseQueueSlot(cp);
|
|
4234
4656
|
this.emit({
|
|
4235
4657
|
type: result.status === 'completed' ? 'step.completed' : 'step.failed',
|
|
@@ -4242,7 +4664,7 @@ export class WorkflowEngine {
|
|
|
4242
4664
|
queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
|
|
4243
4665
|
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
4244
4666
|
});
|
|
4245
|
-
return
|
|
4667
|
+
return cp;
|
|
4246
4668
|
}
|
|
4247
4669
|
/**
|
|
4248
4670
|
* A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
|
|
@@ -4470,17 +4892,4 @@ export class RemoteStepError extends Error {
|
|
|
4470
4892
|
}
|
|
4471
4893
|
/** Checkpoint names that record a parent→child edge: an awaited child, a folded group of them, a spawn. */
|
|
4472
4894
|
const CHILD_EDGE_PREFIXES = ['signal:child:', FOLDED_PREFIX, 'spawn:'];
|
|
4473
|
-
/** The child run ids a parent's {@link CHILD_EDGE_PREFIXES} checkpoints point at. */
|
|
4474
|
-
function childIdsOf(checkpoints) {
|
|
4475
|
-
const ids = [];
|
|
4476
|
-
for (const cp of checkpoints) {
|
|
4477
|
-
if (cp.name.startsWith('signal:child:'))
|
|
4478
|
-
ids.push(cp.name.slice('signal:child:'.length));
|
|
4479
|
-
if (cp.name.startsWith(FOLDED_PREFIX))
|
|
4480
|
-
ids.push(...foldedChildIds(cp));
|
|
4481
|
-
if (cp.name.startsWith('spawn:') && typeof cp.output === 'string')
|
|
4482
|
-
ids.push(cp.output);
|
|
4483
|
-
}
|
|
4484
|
-
return ids;
|
|
4485
|
-
}
|
|
4486
4895
|
//# sourceMappingURL=engine.js.map
|