@adonis-agora/durable 0.9.1 → 0.11.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 +12 -0
- package/dist/assets/dashboard.html +116 -3
- package/dist/commands/durable_worker.d.ts +25 -0
- package/dist/commands/durable_worker.d.ts.map +1 -0
- package/dist/commands/durable_worker.js +113 -0
- package/dist/commands/durable_worker.js.map +1 -0
- package/dist/commands/main.d.ts +6 -5
- package/dist/commands/main.d.ts.map +1 -1
- package/dist/commands/main.js +11 -4
- package/dist/commands/main.js.map +1 -1
- package/dist/providers/dashboard_provider.d.ts +38 -0
- package/dist/providers/dashboard_provider.d.ts.map +1 -1
- package/dist/providers/dashboard_provider.js +103 -10
- package/dist/providers/dashboard_provider.js.map +1 -1
- package/dist/providers/durable_provider.d.ts +24 -16
- package/dist/providers/durable_provider.d.ts.map +1 -1
- package/dist/providers/durable_provider.js +195 -20
- package/dist/providers/durable_provider.js.map +1 -1
- package/dist/src/config_types.d.ts +92 -0
- package/dist/src/config_types.d.ts.map +1 -0
- package/dist/src/config_types.js +2 -0
- package/dist/src/config_types.js.map +1 -0
- package/dist/src/dashboard/compat-view.d.ts +13 -0
- package/dist/src/dashboard/compat-view.d.ts.map +1 -0
- package/dist/src/dashboard/compat-view.js +32 -0
- package/dist/src/dashboard/compat-view.js.map +1 -0
- package/dist/src/dashboard/compat.d.ts +76 -0
- package/dist/src/dashboard/compat.d.ts.map +1 -0
- package/dist/src/dashboard/compat.js +130 -0
- package/dist/src/dashboard/compat.js.map +1 -0
- package/dist/src/dashboard/diagnostics-recorder.d.ts +59 -0
- package/dist/src/dashboard/diagnostics-recorder.d.ts.map +1 -0
- package/dist/src/dashboard/diagnostics-recorder.js +75 -0
- package/dist/src/dashboard/diagnostics-recorder.js.map +1 -0
- package/dist/src/dashboard/gateway-adapter.d.ts +37 -0
- package/dist/src/dashboard/gateway-adapter.d.ts.map +1 -0
- package/dist/src/dashboard/gateway-adapter.js +49 -0
- package/dist/src/dashboard/gateway-adapter.js.map +1 -0
- package/dist/src/dashboard/handlers.d.ts +38 -8
- package/dist/src/dashboard/handlers.d.ts.map +1 -1
- package/dist/src/dashboard/handlers.js +17 -1
- package/dist/src/dashboard/handlers.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +10 -3
- package/dist/src/dashboard/index.d.ts.map +1 -1
- package/dist/src/dashboard/index.js +8 -2
- package/dist/src/dashboard/index.js.map +1 -1
- package/dist/src/define_config.d.ts +31 -5
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js +4 -3
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/dispatch-routing.d.ts +105 -0
- package/dist/src/dispatch-routing.d.ts.map +1 -0
- package/dist/src/dispatch-routing.js +114 -0
- package/dist/src/dispatch-routing.js.map +1 -0
- package/dist/src/engine.d.ts +129 -3
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +354 -12
- package/dist/src/engine.js.map +1 -1
- package/dist/src/handshake/descriptor.d.ts +128 -0
- package/dist/src/handshake/descriptor.d.ts.map +1 -0
- package/dist/src/handshake/descriptor.js +133 -0
- package/dist/src/handshake/descriptor.js.map +1 -0
- package/dist/src/handshake/negotiate.d.ts +83 -0
- package/dist/src/handshake/negotiate.d.ts.map +1 -0
- package/dist/src/handshake/negotiate.js +108 -0
- package/dist/src/handshake/negotiate.js.map +1 -0
- package/dist/src/handshake/routing.d.ts +51 -0
- package/dist/src/handshake/routing.d.ts.map +1 -0
- package/dist/src/handshake/routing.js +57 -0
- package/dist/src/handshake/routing.js.map +1 -0
- package/dist/src/index.d.ts +14 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +12 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/interfaces.d.ts +184 -7
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/remote-workflow-executor.d.ts +25 -1
- package/dist/src/remote-workflow-executor.d.ts.map +1 -1
- package/dist/src/remote-workflow-executor.js +41 -0
- package/dist/src/remote-workflow-executor.js.map +1 -1
- package/dist/src/role_bindings.d.ts +85 -0
- package/dist/src/role_bindings.d.ts.map +1 -0
- package/dist/src/role_bindings.js +73 -0
- package/dist/src/role_bindings.js.map +1 -0
- package/dist/src/run-gateway/interface.d.ts +89 -0
- package/dist/src/run-gateway/interface.d.ts.map +1 -0
- package/dist/src/run-gateway/interface.js +2 -0
- package/dist/src/run-gateway/interface.js.map +1 -0
- package/dist/src/run-gateway/proxy-run-gateway.d.ts +84 -0
- package/dist/src/run-gateway/proxy-run-gateway.d.ts.map +1 -0
- package/dist/src/run-gateway/proxy-run-gateway.js +142 -0
- package/dist/src/run-gateway/proxy-run-gateway.js.map +1 -0
- package/dist/src/run-gateway/run-request-responder.d.ts +67 -0
- package/dist/src/run-gateway/run-request-responder.d.ts.map +1 -0
- package/dist/src/run-gateway/run-request-responder.js +228 -0
- package/dist/src/run-gateway/run-request-responder.js.map +1 -0
- package/dist/src/run-gateway/store-run-gateway.d.ts +90 -0
- package/dist/src/run-gateway/store-run-gateway.d.ts.map +1 -0
- package/dist/src/run-gateway/store-run-gateway.js +80 -0
- package/dist/src/run-gateway/store-run-gateway.js.map +1 -0
- package/dist/src/run-gateway/tenant-auth.d.ts +19 -0
- package/dist/src/run-gateway/tenant-auth.d.ts.map +1 -0
- package/dist/src/run-gateway/tenant-auth.js +71 -0
- package/dist/src/run-gateway/tenant-auth.js.map +1 -0
- package/dist/src/services/main.d.ts +16 -3
- package/dist/src/services/main.d.ts.map +1 -1
- package/dist/src/services/main.js +23 -4
- package/dist/src/services/main.js.map +1 -1
- package/dist/src/step-name-symbol.d.ts +3 -0
- package/dist/src/step-name-symbol.d.ts.map +1 -1
- package/dist/src/step-name-symbol.js.map +1 -1
- package/dist/src/step-ref.d.ts +4 -0
- package/dist/src/step-ref.d.ts.map +1 -1
- package/dist/src/step-ref.js +2 -0
- package/dist/src/step-ref.js.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts +130 -0
- package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -0
- package/dist/src/transports/bullmq/bullmq-transport.js +562 -0
- package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -0
- package/dist/src/transports/bullmq/deps.d.ts +59 -0
- package/dist/src/transports/bullmq/deps.d.ts.map +1 -0
- package/dist/src/transports/bullmq/deps.js +41 -0
- package/dist/src/transports/bullmq/deps.js.map +1 -0
- package/dist/src/transports/bullmq/index.d.ts +5 -0
- package/dist/src/transports/bullmq/index.d.ts.map +1 -0
- package/dist/src/transports/bullmq/index.js +5 -0
- package/dist/src/transports/bullmq/index.js.map +1 -0
- package/dist/src/transports/bullmq/naming.d.ts +71 -0
- package/dist/src/transports/bullmq/naming.d.ts.map +1 -0
- package/dist/src/transports/bullmq/naming.js +101 -0
- package/dist/src/transports/bullmq/naming.js.map +1 -0
- package/dist/src/transports/bullmq/serialization.d.ts +56 -0
- package/dist/src/transports/bullmq/serialization.d.ts.map +1 -0
- package/dist/src/transports/bullmq/serialization.js +101 -0
- package/dist/src/transports/bullmq/serialization.js.map +1 -0
- package/dist/src/transports/factory.d.ts +31 -0
- package/dist/src/transports/factory.d.ts.map +1 -1
- package/dist/src/transports/factory.js +21 -0
- package/dist/src/transports/factory.js.map +1 -1
- package/dist/src/worker-runtime/index.d.ts +22 -0
- package/dist/src/worker-runtime/index.d.ts.map +1 -0
- package/dist/src/worker-runtime/index.js +27 -0
- package/dist/src/worker-runtime/index.js.map +1 -0
- package/dist/src/worker-runtime/naming.d.ts +24 -0
- package/dist/src/worker-runtime/naming.d.ts.map +1 -0
- package/dist/src/worker-runtime/naming.js +28 -0
- package/dist/src/worker-runtime/naming.js.map +1 -0
- package/dist/src/worker-runtime/registry.d.ts +81 -0
- package/dist/src/worker-runtime/registry.d.ts.map +1 -0
- package/dist/src/worker-runtime/registry.js +44 -0
- package/dist/src/worker-runtime/registry.js.map +1 -0
- package/dist/src/worker-runtime/worker-runtime.d.ts +155 -0
- package/dist/src/worker-runtime/worker-runtime.d.ts.map +1 -0
- package/dist/src/worker-runtime/worker-runtime.js +265 -0
- package/dist/src/worker-runtime/worker-runtime.js.map +1 -0
- package/dist/src/workflow-ctx.d.ts +9 -0
- package/dist/src/workflow-ctx.d.ts.map +1 -1
- package/dist/src/workflow-ctx.js +78 -27
- package/dist/src/workflow-ctx.js.map +1 -1
- package/dist/src/workflow-turn.d.ts +167 -0
- package/dist/src/workflow-turn.d.ts.map +1 -0
- package/dist/src/workflow-turn.js +372 -0
- package/dist/src/workflow-turn.js.map +1 -0
- package/package.json +7 -1
package/dist/src/engine.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { InMemoryAdmissionBackend } from './admission.js';
|
|
2
2
|
import { backoffDelay } from './backoff.js';
|
|
3
3
|
import { instantCheckpoint, stepCheckpoint } from './checkpoints.js';
|
|
4
|
+
import { WorkflowBlocked, controlPlaneDescriptor, planDispatch, } from './dispatch-routing.js';
|
|
4
5
|
import { parseDuration } from './duration.js';
|
|
5
6
|
import { Entities } from './entities.js';
|
|
6
7
|
import { ContinueAsNew, NonDeterminismError, RemoteStepTimeout, WorkflowSuspended, } from './errors.js';
|
|
@@ -67,6 +68,9 @@ export class WorkflowEngine {
|
|
|
67
68
|
namespace;
|
|
68
69
|
leaseMs;
|
|
69
70
|
maxRecoveryAttempts;
|
|
71
|
+
reconcileMs;
|
|
72
|
+
remoteRedispatchMs;
|
|
73
|
+
remoteRedispatchMax;
|
|
70
74
|
webhookUrl;
|
|
71
75
|
traceparent;
|
|
72
76
|
context;
|
|
@@ -77,6 +81,11 @@ export class WorkflowEngine {
|
|
|
77
81
|
trackStepStart;
|
|
78
82
|
/** Where a freshly-started run executes — in-process by default (see {@link RunDispatcher}). */
|
|
79
83
|
runDispatcher;
|
|
84
|
+
/** The control-plane's OWN handshake descriptor — negotiated against each worker's descriptor to
|
|
85
|
+
* detect protocol incompatibility before dispatch (design §7.3/§7.4). Built once at construction. */
|
|
86
|
+
cpDescriptor;
|
|
87
|
+
/** How long a `blocked` run waits before the recovery poll re-checks the live fleet (design §7.5). */
|
|
88
|
+
blockedPollMs;
|
|
80
89
|
/** Per-key serialization for singleton workflows (admission, back-pressure, notify-on-release). */
|
|
81
90
|
singletons;
|
|
82
91
|
/** Every registered workflow, keyed by `name@version` — so old versions stay runnable. */
|
|
@@ -128,12 +137,29 @@ export class WorkflowEngine {
|
|
|
128
137
|
this.admission.onFreed?.((queue) => this.wakeQueueWaiters(queue));
|
|
129
138
|
this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
|
|
130
139
|
this.namespace = deps.namespace ?? 'default';
|
|
140
|
+
// The control-plane's own handshake descriptor — the side negotiated AGAINST each worker so a
|
|
141
|
+
// protocol-incompatible fleet is detected before dispatch (design §7.3). Scoped to this engine's
|
|
142
|
+
// namespace/partition; capabilities/protocol are config-driven (default: current single major).
|
|
143
|
+
this.cpDescriptor = controlPlaneDescriptor({
|
|
144
|
+
instanceId: this.instanceId,
|
|
145
|
+
...(deps.controlPlaneCapabilities !== undefined
|
|
146
|
+
? { capabilities: deps.controlPlaneCapabilities }
|
|
147
|
+
: {}),
|
|
148
|
+
...(deps.controlPlaneProtocol !== undefined ? { protocol: deps.controlPlaneProtocol } : {}),
|
|
149
|
+
...(this.namespace !== 'default' ? { namespace: this.namespace } : {}),
|
|
150
|
+
});
|
|
151
|
+
this.blockedPollMs = deps.blockedPollMs ?? 5000;
|
|
131
152
|
// Propagate the engine's namespace to its transport(s) so the SAME namespace that partitions the
|
|
132
153
|
// store also partitions the transport's queues/keys — set once on the engine, applied everywhere.
|
|
133
154
|
// The transport makes "default" a no-op (byte-identical names); an empty pool is a no-op too.
|
|
134
155
|
this.pool.useNamespace(this.namespace);
|
|
135
156
|
this.leaseMs = deps.leaseMs ?? 30_000;
|
|
136
157
|
this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
|
|
158
|
+
// Default 5min; an explicit 0 disables the fallback (opt back into wake-forever-on-lost-event).
|
|
159
|
+
this.reconcileMs = deps.reconcileMs === undefined ? 300_000 : deps.reconcileMs || undefined;
|
|
160
|
+
// Opt-in: unset (or 0) leaves the by-design "re-suspend a lost dispatch, never re-dispatch" behavior.
|
|
161
|
+
this.remoteRedispatchMs = deps.remoteRedispatchMs || undefined;
|
|
162
|
+
this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
|
|
137
163
|
this.webhookUrl = deps.webhookUrl;
|
|
138
164
|
this.traceparent = deps.traceparent;
|
|
139
165
|
this.context = deps.context;
|
|
@@ -267,6 +293,7 @@ export class WorkflowEngine {
|
|
|
267
293
|
validateInput: opts?.validateInput,
|
|
268
294
|
onEvent: opts?.onEvent,
|
|
269
295
|
eventBatch: opts?.eventBatch,
|
|
296
|
+
requires: opts?.requires,
|
|
270
297
|
};
|
|
271
298
|
this.workflows.set(versionKey(name, version), registered);
|
|
272
299
|
const current = this.latest.get(name);
|
|
@@ -297,6 +324,7 @@ export class WorkflowEngine {
|
|
|
297
324
|
executionTimeoutMs: opts.executionTimeout != null ? parseDuration(opts.executionTimeout) : undefined,
|
|
298
325
|
validateInput: opts.validateInput,
|
|
299
326
|
remote: { group: opts.group, executor: opts.executor },
|
|
327
|
+
requires: opts.requires,
|
|
300
328
|
};
|
|
301
329
|
this.workflows.set(versionKey(name, version), registered);
|
|
302
330
|
const current = this.latest.get(name);
|
|
@@ -661,11 +689,16 @@ export class WorkflowEngine {
|
|
|
661
689
|
return undefined;
|
|
662
690
|
}
|
|
663
691
|
/**
|
|
664
|
-
* Resume every suspended run whose durable timer is due
|
|
665
|
-
*
|
|
692
|
+
* Resume every suspended run whose durable timer is due — AND every run parked `blocked` (no
|
|
693
|
+
* capable/compatible worker, design §7.5) whose recovery poll is due. Call periodically (a poller)
|
|
694
|
+
* and on boot. A blocked run re-drives to re-check the live fleet: it dispatches the moment a
|
|
695
|
+
* capable+compatible worker has appeared, else re-parks `blocked` with a fresh `wakeAt`. A run still
|
|
696
|
+
* not due re-suspends cheaply without running new work.
|
|
666
697
|
*/
|
|
667
698
|
async resumeDueTimers(nowMs = this.clock()) {
|
|
668
|
-
|
|
699
|
+
const due = await this.store.listDueTimers(nowMs, this.namespace);
|
|
700
|
+
const blocked = await this.dueBlockedRuns(nowMs);
|
|
701
|
+
return this.resumeLeased([...due, ...blocked], nowMs);
|
|
669
702
|
}
|
|
670
703
|
/**
|
|
671
704
|
* Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
|
|
@@ -695,15 +728,124 @@ export class WorkflowEngine {
|
|
|
695
728
|
* inline — or null if the run is unknown. The dashboard "retry" goes through here so it can't block
|
|
696
729
|
* the HTTP request on workflow execution.
|
|
697
730
|
*/
|
|
698
|
-
async requeue(runId) {
|
|
731
|
+
async requeue(runId, _seen) {
|
|
732
|
+
const seen = _seen ?? new Set();
|
|
733
|
+
if (seen.has(runId))
|
|
734
|
+
return null;
|
|
735
|
+
seen.add(runId);
|
|
699
736
|
const run = await this.store.getRun(runId);
|
|
700
737
|
if (!run)
|
|
701
738
|
return null;
|
|
739
|
+
// A FAILED/DEAD run's replay deterministically re-throws its recorded failures — an exhausted
|
|
740
|
+
// failed checkpoint rethrows, and an awaited child's failure completion rethrows through
|
|
741
|
+
// unwrapCompletion — so "retry" without resetting that state is a no-op that re-fails in
|
|
742
|
+
// milliseconds. Reset the failure state first so replay RE-EXECUTES the failed parts:
|
|
743
|
+
// - a `failed` checkpoint becomes retryable-now (attempts 0, wake immediately, `retryable`
|
|
744
|
+
// forced back on): the durable-retry machinery re-dispatches it as a fresh first attempt;
|
|
745
|
+
// - an awaited-child `signal:child:` checkpoint holding a FAILURE completion returns to its
|
|
746
|
+
// live `running` placeholder, so replay re-registers the `child:<id>` waiter — a separately
|
|
747
|
+
// retried child's completion (already buffered, or still to come) then resumes this run.
|
|
748
|
+
// Retry child and parent in either order; signal buffering makes it converge.
|
|
749
|
+
if (run.status === 'failed' || run.status === 'dead') {
|
|
750
|
+
const isFailureCompletion = (v) => typeof v === 'object' && v !== null && v.ok === false;
|
|
751
|
+
for (const cp of await this.store.listCheckpoints(runId)) {
|
|
752
|
+
if (cp.status === 'failed') {
|
|
753
|
+
await this.store.saveCheckpoint({
|
|
754
|
+
...cp,
|
|
755
|
+
attempts: 0,
|
|
756
|
+
wakeAt: this.clock(),
|
|
757
|
+
...(cp.error ? { error: { ...cp.error, retryable: true } } : {}),
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
else if (cp.kind === 'signal' &&
|
|
761
|
+
cp.name.startsWith('signal:child:') &&
|
|
762
|
+
cp.status === 'completed' &&
|
|
763
|
+
isFailureCompletion(cp.output)) {
|
|
764
|
+
await this.store.saveCheckpoint({ ...cp, status: 'running', output: undefined });
|
|
765
|
+
// CASCADE: retrying only the parent is useless in a live engine — replay re-registers
|
|
766
|
+
// the `child:<id>` waiter and the reconciler re-delivers the child's still-FAILED
|
|
767
|
+
// terminal state within seconds, re-failing the parent with the same error. Requeue the
|
|
768
|
+
// failed child too so the pair converges: whichever finishes the handshake last (waiter
|
|
769
|
+
// registered vs completion buffered) resumes the parent.
|
|
770
|
+
//
|
|
771
|
+
// Exception: a SUCCESS already buffered on this token (a completed `~retry~` fix of the
|
|
772
|
+
// child — see notifyParent's origin delivery) is the outcome the replay will consume, so
|
|
773
|
+
// re-running the failed origin would be pure waste. Peek = take + re-buffer.
|
|
774
|
+
const childId = cp.name.slice('signal:child:'.length);
|
|
775
|
+
// Drain-and-restore keeps relative order (a partial take+re-buffer would rotate it).
|
|
776
|
+
const parked = [];
|
|
777
|
+
for (;;) {
|
|
778
|
+
const buffered = await this.store.takeBufferedSignal(`child:${childId}`);
|
|
779
|
+
if (!buffered)
|
|
780
|
+
break;
|
|
781
|
+
parked.push(buffered.payload);
|
|
782
|
+
}
|
|
783
|
+
for (const payload of parked)
|
|
784
|
+
await this.store.bufferSignal(`child:${childId}`, payload);
|
|
785
|
+
const bufferedSuccess = parked.some((payload) => typeof payload === 'object' &&
|
|
786
|
+
payload !== null &&
|
|
787
|
+
payload.ok === true);
|
|
788
|
+
if (!bufferedSuccess) {
|
|
789
|
+
const child = await this.store.getRun(childId);
|
|
790
|
+
if (child && (child.status === 'failed' || child.status === 'dead')) {
|
|
791
|
+
await this.requeue(childId, seen);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
702
797
|
await this.store.releaseRunLock(runId);
|
|
703
|
-
|
|
798
|
+
// Explicit `error: undefined` CLEARS the stale failure (the store spreads the patch over the
|
|
799
|
+
// run) — otherwise the dashboard keeps showing the old error while the run is re-executing.
|
|
800
|
+
await this.store.updateRun(runId, {
|
|
801
|
+
status: 'pending',
|
|
802
|
+
error: undefined,
|
|
803
|
+
updatedAt: new Date(),
|
|
804
|
+
});
|
|
704
805
|
await this.runDispatcher.dispatch(runId);
|
|
705
806
|
return { runId, status: 'pending' };
|
|
706
807
|
}
|
|
808
|
+
/**
|
|
809
|
+
* Explicitly re-dispatch every remote step of `runId` stuck `pending` — the OPERATOR escape hatch for
|
|
810
|
+
* a run whose dispatched step job was LOST (worker crashed with no result, or a transport that dropped
|
|
811
|
+
* the job). No automatic recovery re-drives these: a reconcile re-drive re-suspends a still-pending
|
|
812
|
+
* step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete` only reclaims LEASED runs,
|
|
813
|
+
* and `requeue` just replays back to the pending guard. This re-enqueues the same `stepId` so the
|
|
814
|
+
* (idempotent) step re-runs and its result resumes the run. Safe to call on a healthy run — it re-runs
|
|
815
|
+
* only in-flight `pending` remote steps, which are idempotent by the durable contract. Returns the
|
|
816
|
+
* run's current status (with the count re-dispatched), or null if the run is unknown.
|
|
817
|
+
*/
|
|
818
|
+
async redispatchPending(runId) {
|
|
819
|
+
const run = await this.store.getRun(runId);
|
|
820
|
+
if (!run)
|
|
821
|
+
return null;
|
|
822
|
+
const pending = (await this.store.listCheckpoints(runId)).filter((cp) => cp.kind === 'remote' && cp.status === 'pending');
|
|
823
|
+
let redispatched = 0;
|
|
824
|
+
for (const cp of pending) {
|
|
825
|
+
if (!cp.stepId)
|
|
826
|
+
continue;
|
|
827
|
+
const reAttempt = cp.attempts + 1;
|
|
828
|
+
const reEnqueuedAt = new Date();
|
|
829
|
+
await this.store.saveCheckpoint({
|
|
830
|
+
...cp,
|
|
831
|
+
attempts: reAttempt,
|
|
832
|
+
enqueuedAt: reEnqueuedAt,
|
|
833
|
+
startedAt: reEnqueuedAt,
|
|
834
|
+
finishedAt: reEnqueuedAt,
|
|
835
|
+
});
|
|
836
|
+
await this.dispatchRemoteTask({
|
|
837
|
+
runId,
|
|
838
|
+
seq: cp.seq,
|
|
839
|
+
name: cp.name,
|
|
840
|
+
stepId: cp.stepId,
|
|
841
|
+
group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
|
|
842
|
+
input: cp.input,
|
|
843
|
+
attempt: reAttempt,
|
|
844
|
+
});
|
|
845
|
+
redispatched += 1;
|
|
846
|
+
}
|
|
847
|
+
return { runId, status: run.status, redispatched };
|
|
848
|
+
}
|
|
707
849
|
/**
|
|
708
850
|
* **Fix-and-replay**: re-run a run (typically a `dead`/`failed` one) with a corrected `input`, as a
|
|
709
851
|
* fresh run with clean history. It's a NEW run — `newRunId` defaults to `<runId>~retry~<uuid>` — so
|
|
@@ -982,6 +1124,15 @@ export class WorkflowEngine {
|
|
|
982
1124
|
*/
|
|
983
1125
|
notifyParent(runId, completion) {
|
|
984
1126
|
void this.signal(`child:${runId}`, completion).catch(() => undefined);
|
|
1127
|
+
// A fix-and-replay run (`<origin>~retry~<hash>`) is standalone, but its SUCCESS is the
|
|
1128
|
+
// origin's outcome for all practical purposes: deliver it on the ORIGIN's token too, so a
|
|
1129
|
+
// parent that failed on that child and is retried later consumes this success (buffered or
|
|
1130
|
+
// live) instead of waiting on a child nobody re-runs. Failures stay retry-only — a failed
|
|
1131
|
+
// fix attempt must not poison the origin's token.
|
|
1132
|
+
const at = runId.lastIndexOf('~retry~');
|
|
1133
|
+
if (at !== -1 && completion.ok) {
|
|
1134
|
+
void this.signal(`child:${runId.slice(0, at)}`, completion).catch(() => undefined);
|
|
1135
|
+
}
|
|
985
1136
|
}
|
|
986
1137
|
/**
|
|
987
1138
|
* Deferred child start shared by the in-process ctx host and the remote `startChild` command.
|
|
@@ -1393,7 +1544,11 @@ export class WorkflowEngine {
|
|
|
1393
1544
|
if (latest?.status === 'cancelled') {
|
|
1394
1545
|
return { runId: run.id, status: 'cancelled', error: latest.error };
|
|
1395
1546
|
}
|
|
1396
|
-
await this.store.updateRun(run.id, {
|
|
1547
|
+
await this.store.updateRun(run.id, {
|
|
1548
|
+
status: 'suspended',
|
|
1549
|
+
wakeAt: this.reconcileWakeAt(outcome.wakeAt),
|
|
1550
|
+
updatedAt,
|
|
1551
|
+
});
|
|
1397
1552
|
this.emit({
|
|
1398
1553
|
type: 'run.suspended',
|
|
1399
1554
|
runId: run.id,
|
|
@@ -1402,6 +1557,17 @@ export class WorkflowEngine {
|
|
|
1402
1557
|
});
|
|
1403
1558
|
return { runId: run.id, status: 'suspended' };
|
|
1404
1559
|
}
|
|
1560
|
+
/**
|
|
1561
|
+
* A wakeAt to persist on a suspend: the natural timer if the turn produced one (a `ctx.sleep`, a
|
|
1562
|
+
* remote-step deadline), else the {@link reconcileMs} fallback so an event-waiting run (child/signal/
|
|
1563
|
+
* timeout-less remote step) can't be orphaned forever if its wake is lost. `undefined` only when the
|
|
1564
|
+
* fallback is disabled (`reconcileMs: 0`). See {@link WorkflowEngineDeps.reconcileMs}.
|
|
1565
|
+
*/
|
|
1566
|
+
reconcileWakeAt(wakeAt) {
|
|
1567
|
+
if (wakeAt != null)
|
|
1568
|
+
return wakeAt;
|
|
1569
|
+
return this.reconcileMs != null ? this.clock() + this.reconcileMs : undefined;
|
|
1570
|
+
}
|
|
1405
1571
|
async runRemoteExecution(run, registered) {
|
|
1406
1572
|
const remote = registered.remote;
|
|
1407
1573
|
if (run.status === 'pending') {
|
|
@@ -1426,6 +1592,12 @@ export class WorkflowEngine {
|
|
|
1426
1592
|
await this.store.releaseRunLock(run.id);
|
|
1427
1593
|
return { runId: run.id, status: 'suspended' };
|
|
1428
1594
|
}
|
|
1595
|
+
// Capability/protocol guard (design §7.5): a remote workflow's turn dispatches a WorkflowTask to
|
|
1596
|
+
// its group — if no live worker on that group can run it, park `blocked` before advancing rather
|
|
1597
|
+
// than handing the task to the executor to enqueue into a void. Re-checked each turn (recovery).
|
|
1598
|
+
const routePlan = await this.planRoute(remote.group, registered.requires);
|
|
1599
|
+
if (routePlan?.status === 'blocked')
|
|
1600
|
+
return this.parkBlocked(run, routePlan);
|
|
1429
1601
|
const history = await this.remoteHistory(run.id);
|
|
1430
1602
|
let decision;
|
|
1431
1603
|
try {
|
|
@@ -1757,6 +1929,12 @@ export class WorkflowEngine {
|
|
|
1757
1929
|
queueMicrotask(() => void this.start(run.workflow, err.input, nextId).catch(() => undefined));
|
|
1758
1930
|
return { runId: run.id, status: 'completed' };
|
|
1759
1931
|
}
|
|
1932
|
+
if (err instanceof WorkflowBlocked) {
|
|
1933
|
+
// No live worker can run the next dispatch (capability/protocol) — park `blocked` with the
|
|
1934
|
+
// structured reason + diagnostics instead of suspending on a timer that would re-drive into the
|
|
1935
|
+
// same void. The blocked-recovery poll re-drives it when a capable+compatible worker appears.
|
|
1936
|
+
return this.parkBlocked(run, err.plan);
|
|
1937
|
+
}
|
|
1760
1938
|
if (err instanceof WorkflowSuspended) {
|
|
1761
1939
|
// A compensating cancel resumed this run to reach here: the replay re-registered the saga,
|
|
1762
1940
|
// so undo the completed steps in reverse and mark it cancelled instead of re-suspending.
|
|
@@ -1899,6 +2077,12 @@ export class WorkflowEngine {
|
|
|
1899
2077
|
startChild: (workflow, input, id, priority) => {
|
|
1900
2078
|
this.startChildDeferred(workflow, input, id, { priority });
|
|
1901
2079
|
},
|
|
2080
|
+
// Deferred for the same reentrancy reason as `startChild` above. `cancel()` is already
|
|
2081
|
+
// idempotent on a terminal/cancelled run (returns its existing status without side effects), so
|
|
2082
|
+
// no extra guard is needed here for the failFast replay case (re-issuing the same cancel calls).
|
|
2083
|
+
cancelChild: (childId) => {
|
|
2084
|
+
queueMicrotask(() => void this.cancel(childId).catch(() => undefined));
|
|
2085
|
+
},
|
|
1902
2086
|
// Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
|
|
1903
2087
|
upsertSearchAttributes: async (runId, attrs) => {
|
|
1904
2088
|
const run = await this.store.getRun(runId);
|
|
@@ -1978,6 +2162,89 @@ export class WorkflowEngine {
|
|
|
1978
2162
|
const result = await this.signal(`update:${runId}:${name}`, arg);
|
|
1979
2163
|
return { accepted: true, run: result };
|
|
1980
2164
|
}
|
|
2165
|
+
// ---------------------------------------------------------------------------
|
|
2166
|
+
// Capability- & protocol-aware dispatch guard (design §7.5/§7.6)
|
|
2167
|
+
// ---------------------------------------------------------------------------
|
|
2168
|
+
/** The LIVE worker handshake descriptors advertised on `token`, read from the primary transport's
|
|
2169
|
+
* descriptor keyspace. Empty when no transport can introspect it (in-process / pre-handshake broker)
|
|
2170
|
+
* OR nobody is publishing — either way the guard is skipped and dispatch proceeds legacy
|
|
2171
|
+
* assume-compatible (design §7.7). Never throws (the transport degrades to `[]`). */
|
|
2172
|
+
async liveDescriptorsFor(token) {
|
|
2173
|
+
const read = this.primaryTransport?.listWorkerDescriptors;
|
|
2174
|
+
if (!read)
|
|
2175
|
+
return [];
|
|
2176
|
+
try {
|
|
2177
|
+
return await read.call(this.primaryTransport, token);
|
|
2178
|
+
}
|
|
2179
|
+
catch {
|
|
2180
|
+
return [];
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
/**
|
|
2184
|
+
* Plan a dispatch to `token` for a handler requiring `requires` (design §7.5). Returns:
|
|
2185
|
+
* - `undefined` — NO live descriptors published on this token: the guard does not engage, so a
|
|
2186
|
+
* pre-handshake fleet (or an in-process transport) keeps flowing exactly as before (design §7.7);
|
|
2187
|
+
* - a `routable` plan — ≥1 live worker is capability-capable AND protocol-compatible (dispatch);
|
|
2188
|
+
* - a `blocked` plan — descriptors exist but none can run it (park the run, never dispatch into it).
|
|
2189
|
+
*/
|
|
2190
|
+
async planRoute(token, requires) {
|
|
2191
|
+
const descriptors = await this.liveDescriptorsFor(token);
|
|
2192
|
+
if (descriptors.length === 0)
|
|
2193
|
+
return undefined; // legacy/no-runtime path — skip the guard
|
|
2194
|
+
return planDispatch(requires ?? [], descriptors, this.cpDescriptor, token);
|
|
2195
|
+
}
|
|
2196
|
+
/** Throw {@link WorkflowBlocked} when no live worker can run a dispatch to `token` — so the caller's
|
|
2197
|
+
* run-execution catch parks the run `blocked` (with the plan) instead of persisting a `pending`
|
|
2198
|
+
* checkpoint + dispatching into a queue nobody consumes (design §7.5). A no-op when the guard does
|
|
2199
|
+
* not engage (no descriptors) or a capable+compatible worker exists. */
|
|
2200
|
+
async ensureRoutable(token, requires) {
|
|
2201
|
+
const plan = await this.planRoute(token, requires);
|
|
2202
|
+
if (plan?.status === 'blocked')
|
|
2203
|
+
throw new WorkflowBlocked(plan);
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* Park a run `blocked`: no live worker can run its next dispatch (design §7.5/§7.6). Persists the
|
|
2207
|
+
* `blocked` status with a human reason + a `wakeAt` so the blocked-recovery poll re-drives it, emits
|
|
2208
|
+
* the LOUD structured `capability.unavailable`/`protocol.incompatible` diagnostics event (carrying
|
|
2209
|
+
* both descriptors + the precise delta), and releases the run lease so any instance can re-drive it.
|
|
2210
|
+
* Re-checks the persisted status first so a concurrent `cancel` is never clobbered.
|
|
2211
|
+
*/
|
|
2212
|
+
async parkBlocked(run, blocked) {
|
|
2213
|
+
const latest = await this.store.getRun(run.id);
|
|
2214
|
+
if (latest && (latest.status === 'cancelled' || latest.status === 'completed')) {
|
|
2215
|
+
await this.store.releaseRunLock(run.id);
|
|
2216
|
+
return { runId: run.id, status: latest.status, output: latest.output, error: latest.error };
|
|
2217
|
+
}
|
|
2218
|
+
const error = { message: blocked.reason, retryable: true };
|
|
2219
|
+
const wakeAt = this.clock() + this.blockedPollMs;
|
|
2220
|
+
await this.store.updateRun(run.id, {
|
|
2221
|
+
status: 'blocked',
|
|
2222
|
+
error,
|
|
2223
|
+
wakeAt,
|
|
2224
|
+
updatedAt: new Date(),
|
|
2225
|
+
});
|
|
2226
|
+
this.emit({
|
|
2227
|
+
type: blocked.code,
|
|
2228
|
+
runId: run.id,
|
|
2229
|
+
workflow: run.workflow,
|
|
2230
|
+
namespace: run.namespace,
|
|
2231
|
+
error,
|
|
2232
|
+
diagnostics: blocked.diagnostics,
|
|
2233
|
+
});
|
|
2234
|
+
// Release the lease so the blocked-recovery poll (this or any instance) can re-drive it promptly
|
|
2235
|
+
// once a capable+compatible worker appears — mirrors the suspended path's release.
|
|
2236
|
+
await this.store.releaseRunLock(run.id);
|
|
2237
|
+
return { runId: run.id, status: 'blocked', error };
|
|
2238
|
+
}
|
|
2239
|
+
/** Runs parked `blocked` whose recovery poll is due (`wakeAt <= nowMs`), namespace-scoped like
|
|
2240
|
+
* {@link resumeDueTimers}. Re-driving one re-checks the live fleet: it dispatches if a
|
|
2241
|
+
* capable+compatible worker has appeared, else re-parks with a fresh `wakeAt` (design §7.5). */
|
|
2242
|
+
async dueBlockedRuns(nowMs) {
|
|
2243
|
+
const blocked = await this.store.listRuns({ statuses: ['blocked'] });
|
|
2244
|
+
return blocked.filter((r) => (r.namespace === undefined || r.namespace === this.namespace) &&
|
|
2245
|
+
r.wakeAt !== undefined &&
|
|
2246
|
+
r.wakeAt <= nowMs);
|
|
2247
|
+
}
|
|
1981
2248
|
async callRemote(runId, seq, step, input, queue, transport, replay, admission) {
|
|
1982
2249
|
// Read the prefix from the per-execution snapshot (avoids the O(N²) replay SELECTs); a seq absent
|
|
1983
2250
|
// from the snapshot — not yet dispatched, or written after the snapshot — falls back to the store.
|
|
@@ -1995,8 +2262,57 @@ export class WorkflowEngine {
|
|
|
1995
2262
|
// pod can scale down or crash mid-step without losing the run or re-running completed work.
|
|
1996
2263
|
if (step.timeoutMs)
|
|
1997
2264
|
return this.callRemoteInMemory(runId, seq, step, input, transport);
|
|
1998
|
-
if (existing?.status === 'pending')
|
|
1999
|
-
|
|
2265
|
+
if (existing?.status === 'pending') {
|
|
2266
|
+
// Dispatched; normally we just keep waiting for the result to resume the run. But a LOST dispatch
|
|
2267
|
+
// (worker crashed with no result, or the transport dropped the job) would hang here forever — a
|
|
2268
|
+
// reconcile re-drive replays straight back to this guard. Opt-in self-heal: once the step has been
|
|
2269
|
+
// pending longer than `remoteRedispatchMs`, re-dispatch the same stepId (the idempotent step
|
|
2270
|
+
// re-runs, its result resumes the run), bounded by `remoteRedispatchMax` so a never-settling step
|
|
2271
|
+
// fails instead of looping. Unset (default) keeps the by-design "re-suspend, never re-dispatch".
|
|
2272
|
+
if (this.remoteRedispatchMs == null)
|
|
2273
|
+
throw new WorkflowSuspended();
|
|
2274
|
+
// Stamp a redispatch deadline (clock-space, persisted) the first time we see this pending step,
|
|
2275
|
+
// stable across replays and crashes — mirrors the failed-retry backoff below. The run's wakeAt
|
|
2276
|
+
// becomes this deadline, so a reconcile re-drive lands exactly when it's due to re-dispatch.
|
|
2277
|
+
if (existing.wakeAt == null) {
|
|
2278
|
+
const wakeAt = this.clock() + this.remoteRedispatchMs;
|
|
2279
|
+
await this.store.saveCheckpoint({ ...existing, wakeAt });
|
|
2280
|
+
throw new WorkflowSuspended(wakeAt);
|
|
2281
|
+
}
|
|
2282
|
+
if (this.clock() < existing.wakeAt)
|
|
2283
|
+
throw new WorkflowSuspended(existing.wakeAt);
|
|
2284
|
+
// Past the deadline with no result — the dispatch is presumed lost. Re-dispatch (bounded by
|
|
2285
|
+
// `remoteRedispatchMax` so a step that never settles fails the run instead of looping forever).
|
|
2286
|
+
if (existing.attempts >= this.remoteRedispatchMax) {
|
|
2287
|
+
throw new RemoteStepError({
|
|
2288
|
+
message: `remote step "${step.name}" lost — no result after ${existing.attempts} re-dispatch(es)`,
|
|
2289
|
+
code: 'remote_step_lost',
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
const reAttempt = existing.attempts + 1;
|
|
2293
|
+
const nextDeadline = this.clock() + this.remoteRedispatchMs;
|
|
2294
|
+
const reEnqueuedAt = new Date();
|
|
2295
|
+
await this.store.saveCheckpoint({
|
|
2296
|
+
...existing,
|
|
2297
|
+
attempts: reAttempt,
|
|
2298
|
+
wakeAt: nextDeadline,
|
|
2299
|
+
enqueuedAt: reEnqueuedAt,
|
|
2300
|
+
startedAt: reEnqueuedAt,
|
|
2301
|
+
finishedAt: reEnqueuedAt,
|
|
2302
|
+
});
|
|
2303
|
+
await this.dispatchRemoteTask({
|
|
2304
|
+
runId,
|
|
2305
|
+
seq,
|
|
2306
|
+
name: step.name,
|
|
2307
|
+
stepId: existing.stepId ?? stepId(runId, seq),
|
|
2308
|
+
group: existing.workerGroup ?? tenantGroup(sanitizeQueueToken(step.name), step.partition),
|
|
2309
|
+
input: existing.input,
|
|
2310
|
+
priority: admission?.priority,
|
|
2311
|
+
attempt: reAttempt,
|
|
2312
|
+
transport,
|
|
2313
|
+
});
|
|
2314
|
+
throw new WorkflowSuspended(nextDeadline);
|
|
2315
|
+
}
|
|
2000
2316
|
// Durable retry: a failed attempt re-dispatches up to `retries`, spacing attempts by `backoff` —
|
|
2001
2317
|
// unless the worker marked the error non-retryable (a deterministic verdict like a declined card).
|
|
2002
2318
|
// The retry deadline is stamped on the failed checkpoint as `wakeAt` (clock-space, persisted) the
|
|
@@ -2047,6 +2363,11 @@ export class WorkflowEngine {
|
|
|
2047
2363
|
// used at BOTH the checkpoint's workerGroup and the dispatched task's routing `group`, so the same
|
|
2048
2364
|
// token the worker subscribes to per handler name serves this step.
|
|
2049
2365
|
const token = tenantGroup(sanitizeQueueToken(step.name), step.partition);
|
|
2366
|
+
// Capability/protocol guard (design §7.5): if the live fleet on this token can't run the step,
|
|
2367
|
+
// throw WorkflowBlocked BEFORE the pending checkpoint + dispatch — so the run parks `blocked`
|
|
2368
|
+
// (recovered by the blocked poll) instead of writing a `pending` row a `WorkflowSuspended` re-drive
|
|
2369
|
+
// would mistake for "already dispatched" and wait on forever. A no-op when no descriptors exist.
|
|
2370
|
+
await this.ensureRoutable(token, step.requires);
|
|
2050
2371
|
const enqueuedAt = new Date();
|
|
2051
2372
|
// Persist the pending checkpoint BEFORE dispatching, so a fast result always finds it to complete.
|
|
2052
2373
|
await this.store.saveCheckpoint({
|
|
@@ -2063,21 +2384,39 @@ export class WorkflowEngine {
|
|
|
2063
2384
|
startedAt: enqueuedAt, // placeholders until the worker result lands
|
|
2064
2385
|
finishedAt: enqueuedAt,
|
|
2065
2386
|
});
|
|
2066
|
-
await this.
|
|
2387
|
+
await this.dispatchRemoteTask({
|
|
2067
2388
|
runId,
|
|
2068
2389
|
seq,
|
|
2069
2390
|
name: step.name,
|
|
2070
2391
|
stepId: id,
|
|
2071
2392
|
group: token,
|
|
2072
2393
|
input: validInput,
|
|
2073
|
-
traceparent: this.traceparent?.(),
|
|
2074
|
-
context: this.context?.(),
|
|
2075
2394
|
priority: admission?.priority,
|
|
2076
2395
|
attempt,
|
|
2077
|
-
|
|
2396
|
+
transport,
|
|
2397
|
+
});
|
|
2078
2398
|
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|
|
2079
2399
|
throw new WorkflowSuspended();
|
|
2080
2400
|
}
|
|
2401
|
+
/**
|
|
2402
|
+
* Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
|
|
2403
|
+
* dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
|
|
2404
|
+
* {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable.
|
|
2405
|
+
*/
|
|
2406
|
+
dispatchRemoteTask(task) {
|
|
2407
|
+
return this.pool.dispatch({
|
|
2408
|
+
runId: task.runId,
|
|
2409
|
+
seq: task.seq,
|
|
2410
|
+
name: task.name,
|
|
2411
|
+
stepId: task.stepId,
|
|
2412
|
+
group: task.group,
|
|
2413
|
+
input: task.input,
|
|
2414
|
+
traceparent: this.traceparent?.(),
|
|
2415
|
+
context: this.context?.(),
|
|
2416
|
+
priority: task.priority,
|
|
2417
|
+
attempt: task.attempt,
|
|
2418
|
+
}, task.transport);
|
|
2419
|
+
}
|
|
2081
2420
|
/**
|
|
2082
2421
|
* Complete a durable remote step from its worker result and resume the run — runs on whichever
|
|
2083
2422
|
* instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.
|
|
@@ -2157,6 +2496,9 @@ export class WorkflowEngine {
|
|
|
2157
2496
|
throw new Error('remote steps require a Transport');
|
|
2158
2497
|
const validInput = step.input ? step.input.parse(input) : input;
|
|
2159
2498
|
const token = tenantGroup(sanitizeQueueToken(step.name), step.partition);
|
|
2499
|
+
// Capability/protocol guard (design §7.5) — same as the durable path, before the `step.started`
|
|
2500
|
+
// emit + dispatch, so a step nobody can run parks `blocked` instead of awaiting a phantom worker.
|
|
2501
|
+
await this.ensureRoutable(token, step.requires);
|
|
2160
2502
|
const id = stepId(runId, seq);
|
|
2161
2503
|
const enqueuedAt = new Date();
|
|
2162
2504
|
this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
|