@adonis-agora/durable 0.46.1 → 0.48.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 +31 -0
- package/dist/assets/spa/assets/{index-DU6cGcJz.css → index-DtGMBUDm.css} +1 -1
- package/dist/assets/spa/assets/{index-CL6q2kMB.js → index-_VISH9Xg.js} +4 -4
- package/dist/assets/spa/assets/index-_VISH9Xg.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/durable_provider.d.ts +0 -25
- package/dist/providers/durable_provider.d.ts.map +1 -1
- package/dist/providers/durable_provider.js +29 -5
- package/dist/providers/durable_provider.js.map +1 -1
- package/dist/src/codec-state-store.d.ts +3 -0
- package/dist/src/codec-state-store.d.ts.map +1 -1
- package/dist/src/codec-state-store.js +7 -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/index.d.ts +1 -1
- package/dist/src/dashboard/index.js +1 -1
- package/dist/src/engine.d.ts +47 -0
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +251 -18
- package/dist/src/engine.js.map +1 -1
- package/dist/src/errors.d.ts +18 -11
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +9 -7
- package/dist/src/errors.js.map +1 -1
- package/dist/src/fan-join.d.ts +4 -3
- package/dist/src/fan-join.d.ts.map +1 -1
- package/dist/src/fan-join.js +5 -4
- 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 +3 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/interfaces.d.ts +49 -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/stores/lucid.d.ts +1 -0
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +11 -1
- 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 +7 -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 +19 -0
- package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.js +13 -3
- package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
- package/dist/src/transports/bullmq/deps.d.ts +4 -0
- package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
- package/dist/src/transports/bullmq/deps.js.map +1 -1
- package/dist/src/transports/bullmq/serialization.d.ts +16 -0
- package/dist/src/transports/bullmq/serialization.d.ts.map +1 -1
- package/dist/src/transports/bullmq/serialization.js +8 -0
- package/dist/src/transports/bullmq/serialization.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 +12 -0
- package/dist/src/transports/factory.d.ts.map +1 -1
- package/dist/src/transports/factory.js +24 -0
- 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.map +1 -1
- package/dist/src/workflow-ctx.js +19 -5
- 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 +4 -4
- package/skills/durable-workflows/SKILL.md +4 -1
- package/dist/assets/spa/assets/index-CL6q2kMB.js.map +0 -1
package/dist/src/engine.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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';
|
|
@@ -10,8 +10,10 @@ import { ContinueAsNew, NonDeterminismError, RemoteStepTimeout, WorkflowSuspende
|
|
|
10
10
|
import { EventAccumulators } from './event-accumulators.js';
|
|
11
11
|
import { eventMatches, eventMatchOf, eventPrefix } from './events.js';
|
|
12
12
|
import { FOLDED_PREFIX, fanOfWaiter, foldedChildIds } 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)
|
|
@@ -198,6 +202,8 @@ export class WorkflowEngine {
|
|
|
198
202
|
updateValidators = new Map();
|
|
199
203
|
/** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
|
|
200
204
|
cancelRequested = new Set();
|
|
205
|
+
/** {@link announcedRoutes} reads, by routing token + workflow + version. */
|
|
206
|
+
announcedRoutesCache = new Map();
|
|
201
207
|
/** The merged schedule set the worker loop ticks with (see {@link adoptSchedules}). */
|
|
202
208
|
#allSchedules = [];
|
|
203
209
|
/** Runtime pause overrides by schedule key (console-issued; win over the config's `paused`). */
|
|
@@ -545,6 +551,7 @@ export class WorkflowEngine {
|
|
|
545
551
|
name,
|
|
546
552
|
version,
|
|
547
553
|
fn,
|
|
554
|
+
onError: this.checkedRoutes(name, opts?.onError),
|
|
548
555
|
tags: opts?.tags,
|
|
549
556
|
origin: opts?.origin,
|
|
550
557
|
singleton: opts?.singleton,
|
|
@@ -575,6 +582,7 @@ export class WorkflowEngine {
|
|
|
575
582
|
const registered = {
|
|
576
583
|
name,
|
|
577
584
|
version,
|
|
585
|
+
onError: this.checkedRoutes(name, opts.onError),
|
|
578
586
|
// A remote workflow has no in-process body; execute() branches on `remote` before this is read.
|
|
579
587
|
fn: () => {
|
|
580
588
|
throw new Error(`workflow ${name} is remote — it has no in-process body`);
|
|
@@ -613,6 +621,9 @@ export class WorkflowEngine {
|
|
|
613
621
|
group: name,
|
|
614
622
|
executor: new RemoteWorkflowExecutor(this.primaryTransport, name),
|
|
615
623
|
});
|
|
624
|
+
const registered = this.workflows.get(versionKey(name, '1'));
|
|
625
|
+
if (registered)
|
|
626
|
+
registered.convention = true;
|
|
616
627
|
return true;
|
|
617
628
|
}
|
|
618
629
|
/**
|
|
@@ -1689,6 +1700,24 @@ export class WorkflowEngine {
|
|
|
1689
1700
|
if (run.status === 'failed' || run.status === 'dead') {
|
|
1690
1701
|
const isFailureCompletion = (v) => typeof v === 'object' && v !== null && v.ok === false;
|
|
1691
1702
|
for (const cp of await this.store.listCheckpoints(runId)) {
|
|
1703
|
+
// A retry runs the body again, so a failure it handed to an onError handler is retired: kept in
|
|
1704
|
+
// the timeline under another name, never followed again, and the handler is not retried. Its
|
|
1705
|
+
// `onError:<code>` tag goes too: the tag says how the run ended, and that is being redone.
|
|
1706
|
+
const handlerId = handoffHandlerId(runId, cp);
|
|
1707
|
+
if (handlerId !== undefined) {
|
|
1708
|
+
await this.store.takeSignalWaiter(`child:${handlerId}`);
|
|
1709
|
+
await this.store.saveCheckpoint({
|
|
1710
|
+
...cp,
|
|
1711
|
+
name: retiredHandoffName(handlerId),
|
|
1712
|
+
status: 'completed',
|
|
1713
|
+
});
|
|
1714
|
+
const tags = run.tags?.filter((t) => !t.startsWith('onError:'));
|
|
1715
|
+
if (tags && tags.length !== run.tags?.length) {
|
|
1716
|
+
await this.store.updateRun(runId, { tags, updatedAt: new Date() });
|
|
1717
|
+
run.tags = tags;
|
|
1718
|
+
}
|
|
1719
|
+
continue;
|
|
1720
|
+
}
|
|
1692
1721
|
if (cp.status === 'failed') {
|
|
1693
1722
|
await this.store.saveCheckpoint({
|
|
1694
1723
|
...cp,
|
|
@@ -2208,7 +2237,7 @@ export class WorkflowEngine {
|
|
|
2208
2237
|
? buffered.payload
|
|
2209
2238
|
: settled.status === 'completed'
|
|
2210
2239
|
? { ok: true, value: settled.output }
|
|
2211
|
-
:
|
|
2240
|
+
: failedCompletion(settled.error ?? { message: settled.status });
|
|
2212
2241
|
await this.signal(token, completion);
|
|
2213
2242
|
}
|
|
2214
2243
|
/**
|
|
@@ -2597,6 +2626,169 @@ export class WorkflowEngine {
|
|
|
2597
2626
|
* {@link runExecution}'s settle/suspend; the lease is held by {@link execute}. The result that lands
|
|
2598
2627
|
* us back here (a remote step finished, a timer fired) goes through `resume` like any TS workflow.
|
|
2599
2628
|
*/
|
|
2629
|
+
/**
|
|
2630
|
+
* The registered onError routes whose handler workflow nothing can run: not registered on this
|
|
2631
|
+
* engine, and no live worker group serves it. Meant for a boot-time warning: a typo in a handler's
|
|
2632
|
+
* name would otherwise surface only when the first run fails.
|
|
2633
|
+
*/
|
|
2634
|
+
async unservedOnErrorRoutes() {
|
|
2635
|
+
const unserved = [];
|
|
2636
|
+
let groups;
|
|
2637
|
+
for (const registered of this.workflows.values()) {
|
|
2638
|
+
for (const route of registered.onError ?? []) {
|
|
2639
|
+
if (this.latest.has(route.workflow))
|
|
2640
|
+
continue;
|
|
2641
|
+
groups ??= await this.pool.listWorkerGroups().catch(() => []);
|
|
2642
|
+
if (groups.some((g) => g === route.workflow || g.startsWith(`${route.workflow}@`)))
|
|
2643
|
+
continue;
|
|
2644
|
+
unserved.push({ workflow: registered.name, route });
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
return unserved;
|
|
2648
|
+
}
|
|
2649
|
+
/**
|
|
2650
|
+
* The onError routes the live workers on `token` announce for a workflow the engine serves by
|
|
2651
|
+
* convention: the one set they agree on, or none when they state none or cannot be read. Workers
|
|
2652
|
+
* that announce different sets (a rollout in progress) route nothing, and the run's failure is
|
|
2653
|
+
* reported as a `run.unrouted` event. Announcers of another version of the workflow are ignored. The
|
|
2654
|
+
* read is cached for {@link ANNOUNCED_ROUTES_TTL_MS}, so a burst of failures costs one read.
|
|
2655
|
+
*/
|
|
2656
|
+
async announcedRoutes(run, token, error) {
|
|
2657
|
+
const key = `${token}\n${run.workflow}\n${run.workflowVersion}`;
|
|
2658
|
+
const now = this.clock();
|
|
2659
|
+
let cached = this.announcedRoutesCache.get(key);
|
|
2660
|
+
if (!cached || now - cached.at > ANNOUNCED_ROUTES_TTL_MS) {
|
|
2661
|
+
const sets = new Map();
|
|
2662
|
+
for (const announced of aggregateAnnouncements(await this.liveDescriptorsFor(token))) {
|
|
2663
|
+
if (announced.name !== run.workflow)
|
|
2664
|
+
continue;
|
|
2665
|
+
if (announced.version !== undefined && announced.version !== run.workflowVersion)
|
|
2666
|
+
continue;
|
|
2667
|
+
for (const routes of announced.onError)
|
|
2668
|
+
sets.set(JSON.stringify(routes), routes);
|
|
2669
|
+
}
|
|
2670
|
+
cached = { at: now, sets: [...sets.values()] };
|
|
2671
|
+
this.announcedRoutesCache.set(key, cached);
|
|
2672
|
+
}
|
|
2673
|
+
if (cached.sets.length > 1) {
|
|
2674
|
+
this.emit({
|
|
2675
|
+
type: 'run.unrouted',
|
|
2676
|
+
runId: run.id,
|
|
2677
|
+
workflow: run.workflow,
|
|
2678
|
+
namespace: run.namespace,
|
|
2679
|
+
error,
|
|
2680
|
+
reason: `live workers announce ${cached.sets.length} different onError route sets`,
|
|
2681
|
+
});
|
|
2682
|
+
return undefined;
|
|
2683
|
+
}
|
|
2684
|
+
return cached.sets[0];
|
|
2685
|
+
}
|
|
2686
|
+
/** Validate a workflow's {@link OnErrorRoute}s at registration. */
|
|
2687
|
+
checkedRoutes(name, routes) {
|
|
2688
|
+
if (!routes?.length)
|
|
2689
|
+
return undefined;
|
|
2690
|
+
assertRoutes(name, routes);
|
|
2691
|
+
return routes;
|
|
2692
|
+
}
|
|
2693
|
+
/**
|
|
2694
|
+
* Route a failed run to its {@link OnErrorRoute} handler. Starts the handler run and waits for it the
|
|
2695
|
+
* way a parent waits for a `ctx.child`: a `child:<handlerId>` waiter plus a `running` placeholder
|
|
2696
|
+
* checkpoint carrying the handler's name and input, so the handler's terminal state wakes the run
|
|
2697
|
+
* (buffered and reconciled like any child). From then on every execution of the run only follows the
|
|
2698
|
+
* handler ({@link followHandoff}); the body never runs again. Undefined when no route matches: the
|
|
2699
|
+
* run fails as usual.
|
|
2700
|
+
*
|
|
2701
|
+
* The placeholder sits past every seq the body used and is left out of {@link remoteHistory}.
|
|
2702
|
+
*/
|
|
2703
|
+
async routeFailure(run, error) {
|
|
2704
|
+
const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
|
|
2705
|
+
const routes = registered?.convention
|
|
2706
|
+
? await this.announcedRoutes(run, registered.remote?.group ?? run.workflow, error)
|
|
2707
|
+
: registered?.onError;
|
|
2708
|
+
const route = routeFor(routes, error);
|
|
2709
|
+
if (!route)
|
|
2710
|
+
return undefined;
|
|
2711
|
+
const checkpoints = await this.handoffCheckpoints(run.id);
|
|
2712
|
+
const live = liveHandoff(run.id, checkpoints);
|
|
2713
|
+
if (live)
|
|
2714
|
+
return this.followHandoff(run, live);
|
|
2715
|
+
// One handoff per attempt: a retry retires the previous one, so the next sits past it.
|
|
2716
|
+
const attempt = checkpoints.length;
|
|
2717
|
+
const seq = HANDOFF_SEQ_BASE + attempt;
|
|
2718
|
+
const handlerId = onErrorRunId(run.id, attempt);
|
|
2719
|
+
const handler = {
|
|
2720
|
+
workflow: route.workflow,
|
|
2721
|
+
input: { runId: run.id, workflow: run.workflow, input: run.input, error },
|
|
2722
|
+
};
|
|
2723
|
+
await this.store.putSignalWaiter({ token: `child:${handlerId}`, runId: run.id, seq });
|
|
2724
|
+
await this.store.saveCheckpoint({
|
|
2725
|
+
...instantCheckpoint({
|
|
2726
|
+
runId: run.id,
|
|
2727
|
+
seq,
|
|
2728
|
+
name: handoffName(handlerId),
|
|
2729
|
+
kind: 'signal',
|
|
2730
|
+
status: 'running',
|
|
2731
|
+
}),
|
|
2732
|
+
input: handler,
|
|
2733
|
+
});
|
|
2734
|
+
// Tagged, so the runs whose failures were handed off are a filter away in the console.
|
|
2735
|
+
const tag = `onError:${route.code ?? '*'}`;
|
|
2736
|
+
if (!run.tags?.includes(tag)) {
|
|
2737
|
+
const tags = [...(run.tags ?? []), tag];
|
|
2738
|
+
await this.store.updateRun(run.id, { tags, updatedAt: new Date() });
|
|
2739
|
+
run.tags = tags;
|
|
2740
|
+
}
|
|
2741
|
+
this.emit({
|
|
2742
|
+
type: 'run.routed',
|
|
2743
|
+
runId: run.id,
|
|
2744
|
+
workflow: run.workflow,
|
|
2745
|
+
namespace: run.namespace,
|
|
2746
|
+
name: route.workflow,
|
|
2747
|
+
error,
|
|
2748
|
+
});
|
|
2749
|
+
this.startChildDeferred(handler.workflow, handler.input, handlerId, {
|
|
2750
|
+
namespace: run.namespace,
|
|
2751
|
+
});
|
|
2752
|
+
return { kind: 'suspended' };
|
|
2753
|
+
}
|
|
2754
|
+
/** A run's handoff checkpoints, live and retired: the only ones at {@link HANDOFF_SEQ_BASE} and up. */
|
|
2755
|
+
async handoffCheckpoints(runId) {
|
|
2756
|
+
const handoffs = await this.store.listCheckpointsByNamePrefix?.(runId, [
|
|
2757
|
+
`signal:child:${runId}.onError.`,
|
|
2758
|
+
`onError:retried:${runId}.onError.`,
|
|
2759
|
+
]);
|
|
2760
|
+
return (handoffs ?? (await this.store.listCheckpoints(runId))).filter((cp) => cp.seq >= HANDOFF_SEQ_BASE);
|
|
2761
|
+
}
|
|
2762
|
+
/**
|
|
2763
|
+
* Where a run that handed its failure off stands: the handler's outcome once it settled (from the
|
|
2764
|
+
* wake's checkpoint, or off the handler run when the wake never arrived), else still waiting. Starts
|
|
2765
|
+
* the handler if the turn that handed off crashed before its deferred start ran.
|
|
2766
|
+
*/
|
|
2767
|
+
async followHandoff(run, handoff) {
|
|
2768
|
+
if (handoff.status === 'completed') {
|
|
2769
|
+
const outcome = handoff.output;
|
|
2770
|
+
return outcome.ok
|
|
2771
|
+
? { kind: 'completed', output: outcome.value }
|
|
2772
|
+
: { kind: 'failed', error: completionError(outcome) };
|
|
2773
|
+
}
|
|
2774
|
+
const handlerId = handoffHandlerId(run.id, handoff);
|
|
2775
|
+
const handlerRun = await this.store.getRun(handlerId);
|
|
2776
|
+
if (handlerRun?.status === 'completed')
|
|
2777
|
+
return { kind: 'completed', output: handlerRun.output };
|
|
2778
|
+
if (handlerRun && TERMINAL_RUN_STATUSES.includes(handlerRun.status)) {
|
|
2779
|
+
return {
|
|
2780
|
+
kind: 'failed',
|
|
2781
|
+
error: handlerRun.error ?? { message: `onError handler ${handlerId} ${handlerRun.status}` },
|
|
2782
|
+
};
|
|
2783
|
+
}
|
|
2784
|
+
if (!handlerRun) {
|
|
2785
|
+
const handler = handoff.input;
|
|
2786
|
+
this.startChildDeferred(handler.workflow, handler.input, handlerId, {
|
|
2787
|
+
namespace: run.namespace,
|
|
2788
|
+
});
|
|
2789
|
+
}
|
|
2790
|
+
return { kind: 'suspended' };
|
|
2791
|
+
}
|
|
2600
2792
|
/**
|
|
2601
2793
|
* Apply a terminal/suspended transition once: persist the new run status, emit the matching
|
|
2602
2794
|
* lifecycle event, and (on completion/failure) wake a waiting parent. Both the TS and remote
|
|
@@ -2639,6 +2831,10 @@ export class WorkflowEngine {
|
|
|
2639
2831
|
return { runId: run.id, status: 'completed', output: outcome.output };
|
|
2640
2832
|
}
|
|
2641
2833
|
if (outcome.kind === 'failed') {
|
|
2834
|
+
const handed = await this.routeFailure(run, outcome.error);
|
|
2835
|
+
if (handed && handed.kind !== 'failed')
|
|
2836
|
+
return this.settleRun(run, handed);
|
|
2837
|
+
const error = handed?.error ?? outcome.error;
|
|
2642
2838
|
// Same race as the `completed` branch above — guard identically. ADDITIONALLY exclude
|
|
2643
2839
|
// `failed`: a stray resume of an already-failed run (a redelivered signal, the settle-recheck
|
|
2644
2840
|
// re-drive) replays and deterministically re-fails — re-applying the settle would re-emit
|
|
@@ -2649,7 +2845,7 @@ export class WorkflowEngine {
|
|
|
2649
2845
|
// status), so it is unaffected.
|
|
2650
2846
|
const applied = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES.filter((s) => s !== 'failed'), {
|
|
2651
2847
|
status: 'failed',
|
|
2652
|
-
error
|
|
2848
|
+
error,
|
|
2653
2849
|
updatedAt,
|
|
2654
2850
|
});
|
|
2655
2851
|
if (!applied)
|
|
@@ -2659,10 +2855,10 @@ export class WorkflowEngine {
|
|
|
2659
2855
|
runId: run.id,
|
|
2660
2856
|
workflow: run.workflow,
|
|
2661
2857
|
namespace: run.namespace,
|
|
2662
|
-
error
|
|
2858
|
+
error,
|
|
2663
2859
|
});
|
|
2664
|
-
this.notifyParent(run.id,
|
|
2665
|
-
return { runId: run.id, status: 'failed', error
|
|
2860
|
+
this.notifyParent(run.id, failedCompletion(error));
|
|
2861
|
+
return { runId: run.id, status: 'failed', error };
|
|
2666
2862
|
}
|
|
2667
2863
|
// This outcome was computed by a turn that started from a possibly-stale run snapshot. If the run
|
|
2668
2864
|
// was cancelled WHILE that turn was still executing (e.g. `ctx.all`'s failFast cancelling a
|
|
@@ -2746,7 +2942,13 @@ export class WorkflowEngine {
|
|
|
2746
2942
|
const routePlan = await this.planRoute(remote.group, registered.requires);
|
|
2747
2943
|
if (routePlan?.status === 'blocked')
|
|
2748
2944
|
return this.parkBlocked(run, routePlan);
|
|
2749
|
-
const
|
|
2945
|
+
const checkpoints = await this.store.listCheckpoints(run.id);
|
|
2946
|
+
const handoff = liveHandoff(run.id, checkpoints);
|
|
2947
|
+
if (handoff) {
|
|
2948
|
+
// A run that handed its failure to an onError handler only follows the handler from here on.
|
|
2949
|
+
return this.settleRun(run, await this.followHandoff(run, handoff));
|
|
2950
|
+
}
|
|
2951
|
+
const history = await this.remoteHistory(run.id, checkpoints);
|
|
2750
2952
|
let decision;
|
|
2751
2953
|
try {
|
|
2752
2954
|
decision = await remote.executor.advance(run, history);
|
|
@@ -3007,8 +3209,8 @@ export class WorkflowEngine {
|
|
|
3007
3209
|
}
|
|
3008
3210
|
}
|
|
3009
3211
|
/** 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);
|
|
3212
|
+
async remoteHistory(runId, listed) {
|
|
3213
|
+
const checkpoints = listed ?? (await this.store.listCheckpoints(runId));
|
|
3012
3214
|
const kindOf = {
|
|
3013
3215
|
remote: 'call',
|
|
3014
3216
|
local: 'step',
|
|
@@ -3021,13 +3223,16 @@ export class WorkflowEngine {
|
|
|
3021
3223
|
// A child run resolves THROUGH the signal machinery (a `child:<id>` waiter notified on the
|
|
3022
3224
|
// child's terminal state), so its checkpoint is kind `signal` with a `signal:child:` name and
|
|
3023
3225
|
// a Completion payload. Surface it as a `child` event with the value/error unwrapped.
|
|
3226
|
+
// onError handoffs sit past every op a body can reach; no replay is told about them.
|
|
3227
|
+
if (cp.seq >= HANDOFF_SEQ_BASE)
|
|
3228
|
+
continue;
|
|
3024
3229
|
if (cp.kind === 'signal' && cp.name.startsWith('signal:child:')) {
|
|
3025
3230
|
const completion = cp.output;
|
|
3026
3231
|
events.push({
|
|
3027
3232
|
seq: cp.seq,
|
|
3028
3233
|
kind: 'child',
|
|
3029
3234
|
output: completion?.ok ? completion.value : undefined,
|
|
3030
|
-
error: completion && completion.ok === false ?
|
|
3235
|
+
error: completion && completion.ok === false ? completionError(completion) : undefined,
|
|
3031
3236
|
});
|
|
3032
3237
|
continue;
|
|
3033
3238
|
}
|
|
@@ -3270,7 +3475,7 @@ export class WorkflowEngine {
|
|
|
3270
3475
|
child.status === 'dead') {
|
|
3271
3476
|
await deliverChild(child.status === 'completed'
|
|
3272
3477
|
? { ok: true, value: child.output }
|
|
3273
|
-
:
|
|
3478
|
+
: failedCompletion(child.error ?? { message: child.status }));
|
|
3274
3479
|
}
|
|
3275
3480
|
}
|
|
3276
3481
|
}
|
|
@@ -3330,6 +3535,11 @@ export class WorkflowEngine {
|
|
|
3330
3535
|
// counted an attempt (or dead-lettered it), flipped it to `pending` and re-enqueued a second
|
|
3331
3536
|
// execution. The lease must outlive the state it protects.
|
|
3332
3537
|
try {
|
|
3538
|
+
// A run that handed its failure to an onError handler only follows the handler from here on.
|
|
3539
|
+
const handoff = liveHandoff(run.id, snapshot);
|
|
3540
|
+
if (handoff && !this.cancelRequested.has(run.id)) {
|
|
3541
|
+
return await this.settleRun(run, await this.followHandoff(run, handoff));
|
|
3542
|
+
}
|
|
3333
3543
|
// Establish the ambient ctx for the duration of this body turn so context-aware statics
|
|
3334
3544
|
// (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
|
|
3335
3545
|
// `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
|
|
@@ -3439,6 +3649,7 @@ export class WorkflowEngine {
|
|
|
3439
3649
|
const error = {
|
|
3440
3650
|
message: err instanceof Error ? err.message : String(err),
|
|
3441
3651
|
stack: err instanceof Error ? err.stack : undefined,
|
|
3652
|
+
...codeAndDetails(err),
|
|
3442
3653
|
};
|
|
3443
3654
|
// Saga: undo completed steps in reverse, each retried up to `compensationRetries`. Outcomes
|
|
3444
3655
|
// are emitted as `compensate:<step>` step events so a stranded undo is VISIBLE (not silently
|
|
@@ -3950,7 +4161,11 @@ export class WorkflowEngine {
|
|
|
3950
4161
|
// becomes this deadline, so a reconcile re-drive lands exactly when it's due to re-dispatch.
|
|
3951
4162
|
if (existing.wakeAt == null) {
|
|
3952
4163
|
const wakeAt = this.clock() + this.remoteRedispatchMs;
|
|
3953
|
-
|
|
4164
|
+
// The result may have settled the row since it was read: then nothing was written, and the
|
|
4165
|
+
// step is re-read from the store.
|
|
4166
|
+
if (!(await this.savePendingCheckpoint({ ...existing, wakeAt }))) {
|
|
4167
|
+
return this.callRemote(runId, seq, step, input, queue, transport, undefined, admission, runNamespace);
|
|
4168
|
+
}
|
|
3954
4169
|
throw new WorkflowSuspended(wakeAt, [seq]);
|
|
3955
4170
|
}
|
|
3956
4171
|
if (this.clock() < existing.wakeAt)
|
|
@@ -3966,7 +4181,7 @@ export class WorkflowEngine {
|
|
|
3966
4181
|
const reAttempt = existing.attempts + 1;
|
|
3967
4182
|
const nextDeadline = this.clock() + this.remoteRedispatchMs;
|
|
3968
4183
|
const reEnqueuedAt = new Date();
|
|
3969
|
-
await this.
|
|
4184
|
+
const claimed = await this.savePendingCheckpoint({
|
|
3970
4185
|
...existing,
|
|
3971
4186
|
attempts: reAttempt,
|
|
3972
4187
|
wakeAt: nextDeadline,
|
|
@@ -3980,6 +4195,9 @@ export class WorkflowEngine {
|
|
|
3980
4195
|
lostStepEvent(this.clock(), reAttempt, 'redispatched'),
|
|
3981
4196
|
],
|
|
3982
4197
|
});
|
|
4198
|
+
if (!claimed) {
|
|
4199
|
+
return this.callRemote(runId, seq, step, input, queue, transport, undefined, admission, runNamespace);
|
|
4200
|
+
}
|
|
3983
4201
|
await this.dispatchRemoteTask({
|
|
3984
4202
|
runId,
|
|
3985
4203
|
seq,
|
|
@@ -4132,6 +4350,13 @@ export class WorkflowEngine {
|
|
|
4132
4350
|
* (never an unhandled rejection) — a failure warns and surfaces as a still-`suspended` run that
|
|
4133
4351
|
* recovery re-drives, never as a silent strand.
|
|
4134
4352
|
*/
|
|
4353
|
+
/** {@link StateStore.saveCheckpointIfPending}, or a plain save on a store without it. */
|
|
4354
|
+
async savePendingCheckpoint(checkpoint) {
|
|
4355
|
+
if (this.store.saveCheckpointIfPending)
|
|
4356
|
+
return this.store.saveCheckpointIfPending(checkpoint);
|
|
4357
|
+
await this.store.saveCheckpoint(checkpoint);
|
|
4358
|
+
return true;
|
|
4359
|
+
}
|
|
4135
4360
|
async completeRemoteResult(result) {
|
|
4136
4361
|
const runId = await this.settleRemoteCheckpoint(result);
|
|
4137
4362
|
if (runId === undefined)
|
|
@@ -4175,17 +4400,20 @@ export class WorkflowEngine {
|
|
|
4175
4400
|
return result.runId;
|
|
4176
4401
|
return undefined;
|
|
4177
4402
|
}
|
|
4178
|
-
// A result settling this step frees its flow-control slot (no-op if it wasn't queued).
|
|
4179
|
-
//
|
|
4180
|
-
|
|
4403
|
+
// A result settling this step frees its flow-control slot (no-op if it wasn't queued). Released
|
|
4404
|
+
// on every early return below too, so a cancellation can't leak the slot; on the settling path
|
|
4405
|
+
// only by the delivery that claims the row, so a duplicated result can't free it twice.
|
|
4181
4406
|
// Drop a late result for a run that was cancelled/finished meanwhile — don't complete the step
|
|
4182
4407
|
// or resume (the run is already terminal). This is the engine side of cooperative cancellation.
|
|
4183
|
-
if (run && (run.status === 'cancelled' || run.status === 'completed'))
|
|
4408
|
+
if (run && (run.status === 'cancelled' || run.status === 'completed')) {
|
|
4409
|
+
await this.releaseQueueSlot(cp);
|
|
4184
4410
|
return undefined;
|
|
4411
|
+
}
|
|
4185
4412
|
// A late SUCCESS for a failed/dead run: salvage the finished work onto the checkpoint — an
|
|
4186
4413
|
// explicit retry's replay then short-circuits this step instead of re-running it (the work may
|
|
4187
4414
|
// be minutes of real compute) — but never resume. A late FAILURE has nothing to salvage: drop.
|
|
4188
4415
|
if (run && (run.status === 'failed' || run.status === 'dead')) {
|
|
4416
|
+
await this.releaseQueueSlot(cp);
|
|
4189
4417
|
if (result.status === 'completed') {
|
|
4190
4418
|
await this.store.saveCheckpoint({
|
|
4191
4419
|
...cp,
|
|
@@ -4200,7 +4428,9 @@ export class WorkflowEngine {
|
|
|
4200
4428
|
}
|
|
4201
4429
|
const finishedAt = new Date();
|
|
4202
4430
|
const startedAt = result.startedAt ? new Date(result.startedAt) : cp.startedAt;
|
|
4203
|
-
|
|
4431
|
+
// Claimed, not blindly written: another delivery settled it first, which is the redelivery case
|
|
4432
|
+
// above — re-drive the resume rather than settle twice.
|
|
4433
|
+
const claimed = await this.savePendingCheckpoint({
|
|
4204
4434
|
...cp,
|
|
4205
4435
|
status: result.status,
|
|
4206
4436
|
output: result.status === 'completed' ? result.output : cp.output,
|
|
@@ -4209,6 +4439,9 @@ export class WorkflowEngine {
|
|
|
4209
4439
|
startedAt,
|
|
4210
4440
|
finishedAt,
|
|
4211
4441
|
});
|
|
4442
|
+
if (!claimed)
|
|
4443
|
+
return terminal ? undefined : result.runId;
|
|
4444
|
+
await this.releaseQueueSlot(cp);
|
|
4212
4445
|
this.emit({
|
|
4213
4446
|
type: result.status === 'completed' ? 'step.completed' : 'step.failed',
|
|
4214
4447
|
runId: result.runId,
|