@dudousxd/nestjs-durable-core 0.43.0 → 0.44.1
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/dist/index.cjs +123 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +74 -13
- package/dist/index.d.ts +74 -13
- package/dist/index.js +123 -44
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -260,6 +260,16 @@ interface WorkflowRun {
|
|
|
260
260
|
* durable state required for correctness — a transport without priority support ignores it.
|
|
261
261
|
*/
|
|
262
262
|
priority?: number | undefined;
|
|
263
|
+
/**
|
|
264
|
+
* REMOTE workflow turn awaiting a decision: the `taskId` of the turn the engine dispatched to a
|
|
265
|
+
* workflow worker and then SUSPENDED on. Set by the dispatch-and-suspend path; matched by
|
|
266
|
+
* {@link WorkflowEngine.completeRemoteDecision} so ONLY the decision for the currently-awaited turn
|
|
267
|
+
* is applied (a stale/duplicate/foreign decision is dropped), then cleared the moment it is applied.
|
|
268
|
+
* This is what makes a workflow-turn decision multi-instance safe: any engine instance that receives
|
|
269
|
+
* the decision looks the run up by `decision.runId` and applies it durably, instead of resolving an
|
|
270
|
+
* in-memory promise that only the dispatching instance held. Absent on non-remote / not-awaiting runs.
|
|
271
|
+
*/
|
|
272
|
+
awaitingDecisionTaskId?: string | undefined;
|
|
263
273
|
createdAt: Date;
|
|
264
274
|
updatedAt: Date;
|
|
265
275
|
}
|
|
@@ -784,7 +794,25 @@ interface WorkflowStepEvent {
|
|
|
784
794
|
* concerns, identical for in-process and remote workflows.
|
|
785
795
|
*/
|
|
786
796
|
interface WorkflowExecutor {
|
|
787
|
-
|
|
797
|
+
/**
|
|
798
|
+
* INLINE replay: advance the turn and RETURN its decision (the in-process executor runs the
|
|
799
|
+
* registered TS function synchronously). The engine awaits this under the run's lease. Provide this
|
|
800
|
+
* OR {@link dispatch} — exactly one. An executor with only `advance` is single-instance by nature
|
|
801
|
+
* (the decision is produced in-process, not delivered over a broker).
|
|
802
|
+
*/
|
|
803
|
+
advance?(run: WorkflowRun, history: HistoryEvent[], pendingSignals?: WorkflowTask['pendingSignals']): Promise<WorkflowDecision>;
|
|
804
|
+
/**
|
|
805
|
+
* DISPATCH-AND-SUSPEND (broker-backed, multi-instance safe): enqueue the turn to a worker under the
|
|
806
|
+
* ENGINE-SUPPLIED `taskId`, WITHOUT awaiting the decision. The engine generates `taskId`, records it
|
|
807
|
+
* on the run as the awaited marker, AND releases the run's lease — all BEFORE calling this — so the
|
|
808
|
+
* worker's {@link WorkflowDecision} (delivered over {@link Transport.onDecision} and applied DURABLY
|
|
809
|
+
* by {@link WorkflowEngine.completeRemoteDecision}, which looks the run up by `decision.runId`) can
|
|
810
|
+
* never arrive ahead of its marker or contend with a still-held lease and be dropped. This method
|
|
811
|
+
* therefore ONLY enqueues — it owns no taskId generation and returns nothing. No in-memory await, so
|
|
812
|
+
* the decision is never lost to a point-to-point broker handing it to a non-dispatching instance.
|
|
813
|
+
* Provide this OR {@link advance} — exactly one.
|
|
814
|
+
*/
|
|
815
|
+
dispatch?(run: WorkflowRun, history: HistoryEvent[], taskId: string, pendingSignals?: WorkflowTask['pendingSignals']): Promise<void>;
|
|
788
816
|
}
|
|
789
817
|
/**
|
|
790
818
|
* A transport in an ordered pool, identified by `id`. The engine dispatches on the first by default
|
|
@@ -1969,6 +1997,14 @@ declare class WorkflowEngine {
|
|
|
1969
1997
|
*/
|
|
1970
1998
|
private settleRun;
|
|
1971
1999
|
private runRemoteExecution;
|
|
2000
|
+
/**
|
|
2001
|
+
* Apply a remote workflow turn's {@link WorkflowDecision}: persist the local steps it ran, then
|
|
2002
|
+
* settle (completed/failed/cancelled) or dispatch its blocking ops and suspend (continue). Shared by
|
|
2003
|
+
* the INLINE-advance path ({@link runRemoteExecution}, under the held lease) and the durable
|
|
2004
|
+
* DISPATCH path ({@link completeRemoteDecision}, which holds the lease itself). Idempotent transitions
|
|
2005
|
+
* — the caller has already ensured this is the decision for the currently-awaited turn.
|
|
2006
|
+
*/
|
|
2007
|
+
private applyDecision;
|
|
1972
2008
|
/** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
|
|
1973
2009
|
private remoteHistory;
|
|
1974
2010
|
/** Apply a turn's commands: persist recorded local steps, dispatch remote calls, schedule timers.
|
|
@@ -2025,6 +2061,21 @@ declare class WorkflowEngine {
|
|
|
2025
2061
|
* A no-op if the checkpoint isn't `pending` (a duplicate or late delivery).
|
|
2026
2062
|
*/
|
|
2027
2063
|
private completeRemoteResult;
|
|
2064
|
+
/**
|
|
2065
|
+
* Apply a remote workflow-turn {@link WorkflowDecision} durably — runs on WHATEVER instance receives
|
|
2066
|
+
* it over the broker (the dispatching one may be gone, or a point-to-point broker handed it to a
|
|
2067
|
+
* non-dispatcher). The durable, multi-instance-safe twin of {@link completeRemoteResult} for the
|
|
2068
|
+
* workflow TURN: look the run up by `decision.runId`, verify the decision matches the run's
|
|
2069
|
+
* CURRENTLY-awaited turn (`awaitingDecisionTaskId`) — so a stale / duplicate / foreign / re-driven
|
|
2070
|
+
* decision is dropped (applied AT MOST ONCE) — lease it, clear the marker, and apply the decision.
|
|
2071
|
+
*/
|
|
2072
|
+
private completeRemoteDecision;
|
|
2073
|
+
/**
|
|
2074
|
+
* A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
|
|
2075
|
+
* push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
|
|
2076
|
+
* re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
|
|
2077
|
+
*/
|
|
2078
|
+
private rearmDecisionDeadline;
|
|
2028
2079
|
/**
|
|
2029
2080
|
* A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
|
|
2030
2081
|
* re-contend now instead of at their retry tick. Snapshot-and-clear — a run still blocked after the
|
|
@@ -2193,25 +2244,35 @@ declare function remoteStep<TInput, TOutput>(config: RemoteStepConfig<TInput, TO
|
|
|
2193
2244
|
|
|
2194
2245
|
/**
|
|
2195
2246
|
* A {@link WorkflowExecutor} backed by a {@link Transport}: it advances a remote workflow by
|
|
2196
|
-
*
|
|
2197
|
-
*
|
|
2198
|
-
*
|
|
2247
|
+
* DISPATCHING a {@link WorkflowTask} over the broker under the ENGINE-SUPPLIED `taskId`, WITHOUT
|
|
2248
|
+
* awaiting the decision. Pass one to `engine.registerRemote(name, version, { group, executor })` so a
|
|
2249
|
+
* workflow authored in another SDK (e.g. the Python `durable-worker`) is driven over Redis/BullMQ.
|
|
2199
2250
|
*
|
|
2200
|
-
*
|
|
2201
|
-
*
|
|
2202
|
-
*
|
|
2251
|
+
* Multi-instance safe by construction: the executor holds NO in-memory state correlating a dispatched
|
|
2252
|
+
* turn to its decision. The engine records the `taskId` on the run as the awaited marker AND releases
|
|
2253
|
+
* the run's lease BEFORE calling `dispatch`, so the worker's {@link import('./interfaces').WorkflowDecision},
|
|
2254
|
+
* delivered over the transport's `onDecision` and applied DURABLY (by run id) on whatever engine
|
|
2255
|
+
* instance consumes it — even one that did not dispatch the turn — can never arrive ahead of its marker
|
|
2256
|
+
* or contend with a still-held lease and be dropped. A point-to-point broker can therefore hand the
|
|
2257
|
+
* decision to any instance without it being dropped (the bug this design fixes).
|
|
2258
|
+
*
|
|
2259
|
+
* Recovery-safe: if the engine crashes (or a decision is genuinely lost because the worker died), the
|
|
2260
|
+
* suspended turn's heartbeat-rearmed window (`remoteAdvanceSilenceMs`) lapses and the timer poller
|
|
2261
|
+
* re-dispatches a fresh task with the same history — the worker's replay is deterministic, so it
|
|
2262
|
+
* returns the same decision; a late decision for the old `taskId` no longer matches the run's
|
|
2263
|
+
* currently-awaited turn and is dropped.
|
|
2203
2264
|
*/
|
|
2204
2265
|
declare class RemoteWorkflowExecutor implements WorkflowExecutor {
|
|
2205
2266
|
private readonly transport;
|
|
2206
2267
|
private readonly group;
|
|
2207
|
-
|
|
2208
|
-
private readonly pending;
|
|
2209
|
-
private subscribed;
|
|
2210
|
-
constructor(transport: Transport, group: string, opts?: {
|
|
2268
|
+
constructor(transport: Transport, group: string, _opts?: {
|
|
2211
2269
|
timeoutMs?: number;
|
|
2212
2270
|
});
|
|
2213
|
-
|
|
2214
|
-
|
|
2271
|
+
/**
|
|
2272
|
+
* Enqueue one workflow turn under the engine-supplied `taskId`. Does NOT await the decision — the
|
|
2273
|
+
* engine has already suspended the run on `taskId` and applies the decision durably via `onDecision`.
|
|
2274
|
+
*/
|
|
2275
|
+
dispatch(run: WorkflowRun, history: HistoryEvent[], taskId: string, pendingSignals?: WorkflowTask['pendingSignals']): Promise<void>;
|
|
2215
2276
|
}
|
|
2216
2277
|
|
|
2217
2278
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -260,6 +260,16 @@ interface WorkflowRun {
|
|
|
260
260
|
* durable state required for correctness — a transport without priority support ignores it.
|
|
261
261
|
*/
|
|
262
262
|
priority?: number | undefined;
|
|
263
|
+
/**
|
|
264
|
+
* REMOTE workflow turn awaiting a decision: the `taskId` of the turn the engine dispatched to a
|
|
265
|
+
* workflow worker and then SUSPENDED on. Set by the dispatch-and-suspend path; matched by
|
|
266
|
+
* {@link WorkflowEngine.completeRemoteDecision} so ONLY the decision for the currently-awaited turn
|
|
267
|
+
* is applied (a stale/duplicate/foreign decision is dropped), then cleared the moment it is applied.
|
|
268
|
+
* This is what makes a workflow-turn decision multi-instance safe: any engine instance that receives
|
|
269
|
+
* the decision looks the run up by `decision.runId` and applies it durably, instead of resolving an
|
|
270
|
+
* in-memory promise that only the dispatching instance held. Absent on non-remote / not-awaiting runs.
|
|
271
|
+
*/
|
|
272
|
+
awaitingDecisionTaskId?: string | undefined;
|
|
263
273
|
createdAt: Date;
|
|
264
274
|
updatedAt: Date;
|
|
265
275
|
}
|
|
@@ -784,7 +794,25 @@ interface WorkflowStepEvent {
|
|
|
784
794
|
* concerns, identical for in-process and remote workflows.
|
|
785
795
|
*/
|
|
786
796
|
interface WorkflowExecutor {
|
|
787
|
-
|
|
797
|
+
/**
|
|
798
|
+
* INLINE replay: advance the turn and RETURN its decision (the in-process executor runs the
|
|
799
|
+
* registered TS function synchronously). The engine awaits this under the run's lease. Provide this
|
|
800
|
+
* OR {@link dispatch} — exactly one. An executor with only `advance` is single-instance by nature
|
|
801
|
+
* (the decision is produced in-process, not delivered over a broker).
|
|
802
|
+
*/
|
|
803
|
+
advance?(run: WorkflowRun, history: HistoryEvent[], pendingSignals?: WorkflowTask['pendingSignals']): Promise<WorkflowDecision>;
|
|
804
|
+
/**
|
|
805
|
+
* DISPATCH-AND-SUSPEND (broker-backed, multi-instance safe): enqueue the turn to a worker under the
|
|
806
|
+
* ENGINE-SUPPLIED `taskId`, WITHOUT awaiting the decision. The engine generates `taskId`, records it
|
|
807
|
+
* on the run as the awaited marker, AND releases the run's lease — all BEFORE calling this — so the
|
|
808
|
+
* worker's {@link WorkflowDecision} (delivered over {@link Transport.onDecision} and applied DURABLY
|
|
809
|
+
* by {@link WorkflowEngine.completeRemoteDecision}, which looks the run up by `decision.runId`) can
|
|
810
|
+
* never arrive ahead of its marker or contend with a still-held lease and be dropped. This method
|
|
811
|
+
* therefore ONLY enqueues — it owns no taskId generation and returns nothing. No in-memory await, so
|
|
812
|
+
* the decision is never lost to a point-to-point broker handing it to a non-dispatching instance.
|
|
813
|
+
* Provide this OR {@link advance} — exactly one.
|
|
814
|
+
*/
|
|
815
|
+
dispatch?(run: WorkflowRun, history: HistoryEvent[], taskId: string, pendingSignals?: WorkflowTask['pendingSignals']): Promise<void>;
|
|
788
816
|
}
|
|
789
817
|
/**
|
|
790
818
|
* A transport in an ordered pool, identified by `id`. The engine dispatches on the first by default
|
|
@@ -1969,6 +1997,14 @@ declare class WorkflowEngine {
|
|
|
1969
1997
|
*/
|
|
1970
1998
|
private settleRun;
|
|
1971
1999
|
private runRemoteExecution;
|
|
2000
|
+
/**
|
|
2001
|
+
* Apply a remote workflow turn's {@link WorkflowDecision}: persist the local steps it ran, then
|
|
2002
|
+
* settle (completed/failed/cancelled) or dispatch its blocking ops and suspend (continue). Shared by
|
|
2003
|
+
* the INLINE-advance path ({@link runRemoteExecution}, under the held lease) and the durable
|
|
2004
|
+
* DISPATCH path ({@link completeRemoteDecision}, which holds the lease itself). Idempotent transitions
|
|
2005
|
+
* — the caller has already ensured this is the decision for the currently-awaited turn.
|
|
2006
|
+
*/
|
|
2007
|
+
private applyDecision;
|
|
1972
2008
|
/** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
|
|
1973
2009
|
private remoteHistory;
|
|
1974
2010
|
/** Apply a turn's commands: persist recorded local steps, dispatch remote calls, schedule timers.
|
|
@@ -2025,6 +2061,21 @@ declare class WorkflowEngine {
|
|
|
2025
2061
|
* A no-op if the checkpoint isn't `pending` (a duplicate or late delivery).
|
|
2026
2062
|
*/
|
|
2027
2063
|
private completeRemoteResult;
|
|
2064
|
+
/**
|
|
2065
|
+
* Apply a remote workflow-turn {@link WorkflowDecision} durably — runs on WHATEVER instance receives
|
|
2066
|
+
* it over the broker (the dispatching one may be gone, or a point-to-point broker handed it to a
|
|
2067
|
+
* non-dispatcher). The durable, multi-instance-safe twin of {@link completeRemoteResult} for the
|
|
2068
|
+
* workflow TURN: look the run up by `decision.runId`, verify the decision matches the run's
|
|
2069
|
+
* CURRENTLY-awaited turn (`awaitingDecisionTaskId`) — so a stale / duplicate / foreign / re-driven
|
|
2070
|
+
* decision is dropped (applied AT MOST ONCE) — lease it, clear the marker, and apply the decision.
|
|
2071
|
+
*/
|
|
2072
|
+
private completeRemoteDecision;
|
|
2073
|
+
/**
|
|
2074
|
+
* A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
|
|
2075
|
+
* push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
|
|
2076
|
+
* re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
|
|
2077
|
+
*/
|
|
2078
|
+
private rearmDecisionDeadline;
|
|
2028
2079
|
/**
|
|
2029
2080
|
* A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
|
|
2030
2081
|
* re-contend now instead of at their retry tick. Snapshot-and-clear — a run still blocked after the
|
|
@@ -2193,25 +2244,35 @@ declare function remoteStep<TInput, TOutput>(config: RemoteStepConfig<TInput, TO
|
|
|
2193
2244
|
|
|
2194
2245
|
/**
|
|
2195
2246
|
* A {@link WorkflowExecutor} backed by a {@link Transport}: it advances a remote workflow by
|
|
2196
|
-
*
|
|
2197
|
-
*
|
|
2198
|
-
*
|
|
2247
|
+
* DISPATCHING a {@link WorkflowTask} over the broker under the ENGINE-SUPPLIED `taskId`, WITHOUT
|
|
2248
|
+
* awaiting the decision. Pass one to `engine.registerRemote(name, version, { group, executor })` so a
|
|
2249
|
+
* workflow authored in another SDK (e.g. the Python `durable-worker`) is driven over Redis/BullMQ.
|
|
2199
2250
|
*
|
|
2200
|
-
*
|
|
2201
|
-
*
|
|
2202
|
-
*
|
|
2251
|
+
* Multi-instance safe by construction: the executor holds NO in-memory state correlating a dispatched
|
|
2252
|
+
* turn to its decision. The engine records the `taskId` on the run as the awaited marker AND releases
|
|
2253
|
+
* the run's lease BEFORE calling `dispatch`, so the worker's {@link import('./interfaces').WorkflowDecision},
|
|
2254
|
+
* delivered over the transport's `onDecision` and applied DURABLY (by run id) on whatever engine
|
|
2255
|
+
* instance consumes it — even one that did not dispatch the turn — can never arrive ahead of its marker
|
|
2256
|
+
* or contend with a still-held lease and be dropped. A point-to-point broker can therefore hand the
|
|
2257
|
+
* decision to any instance without it being dropped (the bug this design fixes).
|
|
2258
|
+
*
|
|
2259
|
+
* Recovery-safe: if the engine crashes (or a decision is genuinely lost because the worker died), the
|
|
2260
|
+
* suspended turn's heartbeat-rearmed window (`remoteAdvanceSilenceMs`) lapses and the timer poller
|
|
2261
|
+
* re-dispatches a fresh task with the same history — the worker's replay is deterministic, so it
|
|
2262
|
+
* returns the same decision; a late decision for the old `taskId` no longer matches the run's
|
|
2263
|
+
* currently-awaited turn and is dropped.
|
|
2203
2264
|
*/
|
|
2204
2265
|
declare class RemoteWorkflowExecutor implements WorkflowExecutor {
|
|
2205
2266
|
private readonly transport;
|
|
2206
2267
|
private readonly group;
|
|
2207
|
-
|
|
2208
|
-
private readonly pending;
|
|
2209
|
-
private subscribed;
|
|
2210
|
-
constructor(transport: Transport, group: string, opts?: {
|
|
2268
|
+
constructor(transport: Transport, group: string, _opts?: {
|
|
2211
2269
|
timeoutMs?: number;
|
|
2212
2270
|
});
|
|
2213
|
-
|
|
2214
|
-
|
|
2271
|
+
/**
|
|
2272
|
+
* Enqueue one workflow turn under the engine-supplied `taskId`. Does NOT await the decision — the
|
|
2273
|
+
* engine has already suspended the run on `taskId` and applies the decision durably via `onDecision`.
|
|
2274
|
+
*/
|
|
2275
|
+
dispatch(run: WorkflowRun, history: HistoryEvent[], taskId: string, pendingSignals?: WorkflowTask['pendingSignals']): Promise<void>;
|
|
2215
2276
|
}
|
|
2216
2277
|
|
|
2217
2278
|
/**
|
package/dist/index.js
CHANGED
|
@@ -701,42 +701,24 @@ function eventMatches(payload, match) {
|
|
|
701
701
|
__name(eventMatches, "eventMatches");
|
|
702
702
|
|
|
703
703
|
// src/remote-workflow-executor.ts
|
|
704
|
-
var taskCounter = 0;
|
|
705
704
|
var RemoteWorkflowExecutor = class {
|
|
706
705
|
static {
|
|
707
706
|
__name(this, "RemoteWorkflowExecutor");
|
|
708
707
|
}
|
|
709
708
|
transport;
|
|
710
709
|
group;
|
|
711
|
-
|
|
712
|
-
pending = /* @__PURE__ */ new Map();
|
|
713
|
-
subscribed = false;
|
|
714
|
-
constructor(transport, group, opts = {}) {
|
|
710
|
+
constructor(transport, group, _opts = {}) {
|
|
715
711
|
this.transport = transport;
|
|
716
712
|
this.group = group;
|
|
717
|
-
this.opts = opts;
|
|
718
|
-
}
|
|
719
|
-
ensureSubscribed() {
|
|
720
|
-
if (this.subscribed) return;
|
|
721
|
-
if (!this.transport.onDecision) {
|
|
722
|
-
throw new Error("transport does not support workflow decisions (onDecision)");
|
|
723
|
-
}
|
|
724
|
-
this.subscribed = true;
|
|
725
|
-
this.transport.onDecision(async (decision) => {
|
|
726
|
-
const resolve = this.pending.get(decision.taskId);
|
|
727
|
-
if (resolve) {
|
|
728
|
-
this.pending.delete(decision.taskId);
|
|
729
|
-
resolve(decision);
|
|
730
|
-
}
|
|
731
|
-
});
|
|
732
713
|
}
|
|
733
|
-
|
|
714
|
+
/**
|
|
715
|
+
* Enqueue one workflow turn under the engine-supplied `taskId`. Does NOT await the decision — the
|
|
716
|
+
* engine has already suspended the run on `taskId` and applies the decision durably via `onDecision`.
|
|
717
|
+
*/
|
|
718
|
+
async dispatch(run, history, taskId, pendingSignals) {
|
|
734
719
|
if (!this.transport.dispatchWorkflowTask) {
|
|
735
720
|
throw new Error("transport does not support workflow tasks (dispatchWorkflowTask)");
|
|
736
721
|
}
|
|
737
|
-
this.ensureSubscribed();
|
|
738
|
-
taskCounter += 1;
|
|
739
|
-
const taskId = `${run.id}:wf:${taskCounter}`;
|
|
740
722
|
const task = {
|
|
741
723
|
taskId,
|
|
742
724
|
runId: run.id,
|
|
@@ -744,23 +726,14 @@ var RemoteWorkflowExecutor = class {
|
|
|
744
726
|
workflowVersion: run.workflowVersion,
|
|
745
727
|
input: run.input,
|
|
746
728
|
history,
|
|
729
|
+
...pendingSignals ? {
|
|
730
|
+
pendingSignals
|
|
731
|
+
} : {},
|
|
747
732
|
group: this.group,
|
|
748
733
|
priority: run.priority,
|
|
749
734
|
attempt: 1
|
|
750
735
|
};
|
|
751
|
-
const decision = new Promise((resolve, reject) => {
|
|
752
|
-
this.pending.set(taskId, resolve);
|
|
753
|
-
const { timeoutMs } = this.opts;
|
|
754
|
-
if (timeoutMs) {
|
|
755
|
-
const timer = setTimeout(() => {
|
|
756
|
-
this.pending.delete(taskId);
|
|
757
|
-
reject(new RemoteWorkflowTimeout(taskId, timeoutMs));
|
|
758
|
-
}, timeoutMs);
|
|
759
|
-
timer.unref?.();
|
|
760
|
-
}
|
|
761
|
-
});
|
|
762
736
|
await this.transport.dispatchWorkflowTask(task);
|
|
763
|
-
return decision;
|
|
764
737
|
}
|
|
765
738
|
};
|
|
766
739
|
|
|
@@ -881,13 +854,16 @@ var TransportPool = class {
|
|
|
881
854
|
/** Register the engine's result/heartbeat/step-event handlers on EVERY transport — a result can come
|
|
882
855
|
* back on whichever one delivered the task, so failover stays symmetric. `onStepEvent` is wired only
|
|
883
856
|
* on transports that carry streamed workflow step lifecycle (BullMQ); others simply skip it. */
|
|
884
|
-
bind(onResult, onHeartbeat, onStepEvent) {
|
|
857
|
+
bind(onResult, onHeartbeat, onStepEvent, onDecision) {
|
|
885
858
|
for (const { transport } of this.transports) {
|
|
886
859
|
transport.onResult(onResult);
|
|
887
860
|
transport.onHeartbeat(onHeartbeat);
|
|
888
861
|
if (onStepEvent && transport.onStepEvent) {
|
|
889
862
|
transport.onStepEvent(onStepEvent);
|
|
890
863
|
}
|
|
864
|
+
if (onDecision && transport.onDecision) {
|
|
865
|
+
transport.onDecision(onDecision);
|
|
866
|
+
}
|
|
891
867
|
}
|
|
892
868
|
}
|
|
893
869
|
/**
|
|
@@ -1645,14 +1621,27 @@ var WorkflowEngine = class {
|
|
|
1645
1621
|
},
|
|
1646
1622
|
// A heartbeat resets the liveness window for whatever it targets: an in-flight long STEP (keyed
|
|
1647
1623
|
// by stepId, see callRemote) or — when stepId is absent — the in-flight workflow TURN (keyed by
|
|
1648
|
-
// runId
|
|
1624
|
+
// runId). For an INLINE-advance turn the reset is an in-memory callback; for a DISPATCH-suspend
|
|
1625
|
+
// turn there is no in-memory waiter, so a run-scoped beat rearms the suspended turn's durable
|
|
1626
|
+
// re-drive deadline (`wakeAt`) instead, keeping a live worker's turn from being re-dispatched.
|
|
1649
1627
|
async (beat) => {
|
|
1650
|
-
this.heartbeatResets.get(beat.stepId ?? beat.runId)
|
|
1628
|
+
const reset = this.heartbeatResets.get(beat.stepId ?? beat.runId);
|
|
1629
|
+
if (reset) {
|
|
1630
|
+
reset();
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
if (beat.stepId === void 0) await this.rearmDecisionDeadline(beat.runId);
|
|
1651
1634
|
},
|
|
1652
1635
|
// A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
|
|
1653
1636
|
// it's checkpointed live, not all-at-once when the long turn ends.
|
|
1654
1637
|
async (event) => {
|
|
1655
1638
|
await this.persistStepEvent(event);
|
|
1639
|
+
},
|
|
1640
|
+
// A workflow-turn DECISION delivered over the broker — applied durably (by run id) on whatever
|
|
1641
|
+
// instance consumes it, NOT correlated to an in-memory promise on the dispatcher. This is the
|
|
1642
|
+
// multi-instance fix: a point-to-point broker may hand the decision to a non-dispatching instance.
|
|
1643
|
+
async (decision) => {
|
|
1644
|
+
await this.completeRemoteDecision(decision);
|
|
1656
1645
|
}
|
|
1657
1646
|
);
|
|
1658
1647
|
this.controlPlane?.onControl((msg) => {
|
|
@@ -2281,7 +2270,7 @@ var WorkflowEngine = class {
|
|
|
2281
2270
|
* `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
|
|
2282
2271
|
*/
|
|
2283
2272
|
waitForRun(runId, opts) {
|
|
2284
|
-
const isSettled = /* @__PURE__ */ __name((
|
|
2273
|
+
const isSettled = /* @__PURE__ */ __name((run) => run.status !== "pending" && run.status !== "running" && run.status !== "cancelling" && !(run.status === "suspended" && run.awaitingDecisionTaskId !== void 0), "isSettled");
|
|
2285
2274
|
const toResult = /* @__PURE__ */ __name((run) => ({
|
|
2286
2275
|
runId,
|
|
2287
2276
|
status: run.status,
|
|
@@ -2307,7 +2296,7 @@ var WorkflowEngine = class {
|
|
|
2307
2296
|
}, "finish");
|
|
2308
2297
|
const check = /* @__PURE__ */ __name(() => {
|
|
2309
2298
|
void this.store.getRun(runId).then((run) => {
|
|
2310
|
-
if (run && isSettled(run
|
|
2299
|
+
if (run && isSettled(run)) finish(run);
|
|
2311
2300
|
});
|
|
2312
2301
|
}, "check");
|
|
2313
2302
|
off = this.subscribe((ev) => {
|
|
@@ -2914,10 +2903,10 @@ var WorkflowEngine = class {
|
|
|
2914
2903
|
});
|
|
2915
2904
|
}
|
|
2916
2905
|
if (registered.singleton && !await this.singletons.admit(run, registered.singleton)) {
|
|
2917
|
-
const
|
|
2906
|
+
const wakeAt = this.singletons.retryWakeAt();
|
|
2918
2907
|
await this.store.updateRun(run.id, {
|
|
2919
2908
|
status: "suspended",
|
|
2920
|
-
wakeAt
|
|
2909
|
+
wakeAt,
|
|
2921
2910
|
updatedAt: /* @__PURE__ */ new Date()
|
|
2922
2911
|
});
|
|
2923
2912
|
this.emit({
|
|
@@ -2932,10 +2921,36 @@ var WorkflowEngine = class {
|
|
|
2932
2921
|
};
|
|
2933
2922
|
}
|
|
2934
2923
|
const history = await this.remoteHistory(run.id);
|
|
2924
|
+
if (remote.executor.dispatch) {
|
|
2925
|
+
const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID().slice(0, 8)}`;
|
|
2926
|
+
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
2927
|
+
const wakeAt = silenceMs != null ? this.clock() + silenceMs : void 0;
|
|
2928
|
+
await this.store.updateRun(run.id, {
|
|
2929
|
+
status: "suspended",
|
|
2930
|
+
awaitingDecisionTaskId: taskId,
|
|
2931
|
+
wakeAt,
|
|
2932
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
2933
|
+
});
|
|
2934
|
+
this.emit({
|
|
2935
|
+
type: "run.suspended",
|
|
2936
|
+
runId: run.id,
|
|
2937
|
+
workflow: run.workflow
|
|
2938
|
+
});
|
|
2939
|
+
await this.store.releaseRunLock(run.id);
|
|
2940
|
+
await remote.executor.dispatch(run, history, taskId);
|
|
2941
|
+
return {
|
|
2942
|
+
runId: run.id,
|
|
2943
|
+
status: "suspended"
|
|
2944
|
+
};
|
|
2945
|
+
}
|
|
2946
|
+
const advanceExecutor = remote.executor.advance;
|
|
2947
|
+
if (!advanceExecutor) {
|
|
2948
|
+
throw new Error(`workflow ${run.workflow}@${run.workflowVersion}: executor implements neither advance nor dispatch`);
|
|
2949
|
+
}
|
|
2935
2950
|
let decision;
|
|
2936
2951
|
try {
|
|
2937
2952
|
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
2938
|
-
const advance = remote.executor
|
|
2953
|
+
const advance = advanceExecutor.call(remote.executor, run, history);
|
|
2939
2954
|
decision = silenceMs != null ? await this.awaitWithLivenessDeadline(run.id, advance, silenceMs, () => new RemoteWorkflowTimeout(run.id, silenceMs)) : await advance;
|
|
2940
2955
|
} catch (err) {
|
|
2941
2956
|
if (err instanceof RemoteWorkflowTimeout) {
|
|
@@ -2966,6 +2981,16 @@ var WorkflowEngine = class {
|
|
|
2966
2981
|
error
|
|
2967
2982
|
};
|
|
2968
2983
|
}
|
|
2984
|
+
return this.applyDecision(run, decision);
|
|
2985
|
+
}
|
|
2986
|
+
/**
|
|
2987
|
+
* Apply a remote workflow turn's {@link WorkflowDecision}: persist the local steps it ran, then
|
|
2988
|
+
* settle (completed/failed/cancelled) or dispatch its blocking ops and suspend (continue). Shared by
|
|
2989
|
+
* the INLINE-advance path ({@link runRemoteExecution}, under the held lease) and the durable
|
|
2990
|
+
* DISPATCH path ({@link completeRemoteDecision}, which holds the lease itself). Idempotent transitions
|
|
2991
|
+
* — the caller has already ensured this is the decision for the currently-awaited turn.
|
|
2992
|
+
*/
|
|
2993
|
+
async applyDecision(run, decision) {
|
|
2969
2994
|
if (decision.status === "completed") {
|
|
2970
2995
|
await this.applyCommands(run, decision.commands);
|
|
2971
2996
|
return this.settleRun(run, {
|
|
@@ -3563,6 +3588,60 @@ var WorkflowEngine = class {
|
|
|
3563
3588
|
});
|
|
3564
3589
|
}
|
|
3565
3590
|
/**
|
|
3591
|
+
* Apply a remote workflow-turn {@link WorkflowDecision} durably — runs on WHATEVER instance receives
|
|
3592
|
+
* it over the broker (the dispatching one may be gone, or a point-to-point broker handed it to a
|
|
3593
|
+
* non-dispatcher). The durable, multi-instance-safe twin of {@link completeRemoteResult} for the
|
|
3594
|
+
* workflow TURN: look the run up by `decision.runId`, verify the decision matches the run's
|
|
3595
|
+
* CURRENTLY-awaited turn (`awaitingDecisionTaskId`) — so a stale / duplicate / foreign / re-driven
|
|
3596
|
+
* decision is dropped (applied AT MOST ONCE) — lease it, clear the marker, and apply the decision.
|
|
3597
|
+
*/
|
|
3598
|
+
async completeRemoteDecision(decision) {
|
|
3599
|
+
const run = await this.store.getRun(decision.runId);
|
|
3600
|
+
if (!run || run.awaitingDecisionTaskId !== decision.taskId) return;
|
|
3601
|
+
if (run.status === "cancelled" || run.status === "completed" || run.status === "dead") return;
|
|
3602
|
+
if (run.namespace !== void 0 && run.namespace !== this.namespace) return;
|
|
3603
|
+
const nowMs = this.clock();
|
|
3604
|
+
if (!await this.store.tryLockRun(decision.runId, this.instanceId, nowMs + this.leaseMs, nowMs)) {
|
|
3605
|
+
return;
|
|
3606
|
+
}
|
|
3607
|
+
const renew = setInterval(() => {
|
|
3608
|
+
void this.store.renewRunLock(decision.runId, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
|
|
3609
|
+
}, Math.max(50, Math.floor(this.leaseMs / 2)));
|
|
3610
|
+
renew.unref?.();
|
|
3611
|
+
try {
|
|
3612
|
+
const fresh = await this.store.getRun(decision.runId);
|
|
3613
|
+
if (!fresh || fresh.awaitingDecisionTaskId !== decision.taskId) return;
|
|
3614
|
+
if (fresh.status === "cancelled" || fresh.status === "completed" || fresh.status === "dead") {
|
|
3615
|
+
return;
|
|
3616
|
+
}
|
|
3617
|
+
await this.store.updateRun(decision.runId, {
|
|
3618
|
+
awaitingDecisionTaskId: void 0,
|
|
3619
|
+
wakeAt: void 0,
|
|
3620
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
3621
|
+
});
|
|
3622
|
+
fresh.awaitingDecisionTaskId = void 0;
|
|
3623
|
+
await this.applyDecision(fresh, decision);
|
|
3624
|
+
} finally {
|
|
3625
|
+
clearInterval(renew);
|
|
3626
|
+
await this.store.releaseRunLock(decision.runId);
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
/**
|
|
3630
|
+
* A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
|
|
3631
|
+
* push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
|
|
3632
|
+
* re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
|
|
3633
|
+
*/
|
|
3634
|
+
async rearmDecisionDeadline(runId) {
|
|
3635
|
+
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
3636
|
+
if (silenceMs == null) return;
|
|
3637
|
+
const run = await this.store.getRun(runId);
|
|
3638
|
+
if (!run || run.status !== "suspended" || run.awaitingDecisionTaskId === void 0) return;
|
|
3639
|
+
await this.store.updateRun(runId, {
|
|
3640
|
+
wakeAt: this.clock() + silenceMs,
|
|
3641
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
3642
|
+
});
|
|
3643
|
+
}
|
|
3644
|
+
/**
|
|
3566
3645
|
* A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
|
|
3567
3646
|
* re-contend now instead of at their retry tick. Snapshot-and-clear — a run still blocked after the
|
|
3568
3647
|
* retry re-registers itself, and one that's gone (cancelled/admitted) is dropped. Best-effort.
|