@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.cjs
CHANGED
|
@@ -773,42 +773,24 @@ function eventMatches(payload, match) {
|
|
|
773
773
|
__name(eventMatches, "eventMatches");
|
|
774
774
|
|
|
775
775
|
// src/remote-workflow-executor.ts
|
|
776
|
-
var taskCounter = 0;
|
|
777
776
|
var RemoteWorkflowExecutor = class {
|
|
778
777
|
static {
|
|
779
778
|
__name(this, "RemoteWorkflowExecutor");
|
|
780
779
|
}
|
|
781
780
|
transport;
|
|
782
781
|
group;
|
|
783
|
-
|
|
784
|
-
pending = /* @__PURE__ */ new Map();
|
|
785
|
-
subscribed = false;
|
|
786
|
-
constructor(transport, group, opts = {}) {
|
|
782
|
+
constructor(transport, group, _opts = {}) {
|
|
787
783
|
this.transport = transport;
|
|
788
784
|
this.group = group;
|
|
789
|
-
this.opts = opts;
|
|
790
|
-
}
|
|
791
|
-
ensureSubscribed() {
|
|
792
|
-
if (this.subscribed) return;
|
|
793
|
-
if (!this.transport.onDecision) {
|
|
794
|
-
throw new Error("transport does not support workflow decisions (onDecision)");
|
|
795
|
-
}
|
|
796
|
-
this.subscribed = true;
|
|
797
|
-
this.transport.onDecision(async (decision) => {
|
|
798
|
-
const resolve = this.pending.get(decision.taskId);
|
|
799
|
-
if (resolve) {
|
|
800
|
-
this.pending.delete(decision.taskId);
|
|
801
|
-
resolve(decision);
|
|
802
|
-
}
|
|
803
|
-
});
|
|
804
785
|
}
|
|
805
|
-
|
|
786
|
+
/**
|
|
787
|
+
* Enqueue one workflow turn under the engine-supplied `taskId`. Does NOT await the decision — the
|
|
788
|
+
* engine has already suspended the run on `taskId` and applies the decision durably via `onDecision`.
|
|
789
|
+
*/
|
|
790
|
+
async dispatch(run, history, taskId, pendingSignals) {
|
|
806
791
|
if (!this.transport.dispatchWorkflowTask) {
|
|
807
792
|
throw new Error("transport does not support workflow tasks (dispatchWorkflowTask)");
|
|
808
793
|
}
|
|
809
|
-
this.ensureSubscribed();
|
|
810
|
-
taskCounter += 1;
|
|
811
|
-
const taskId = `${run.id}:wf:${taskCounter}`;
|
|
812
794
|
const task = {
|
|
813
795
|
taskId,
|
|
814
796
|
runId: run.id,
|
|
@@ -816,23 +798,14 @@ var RemoteWorkflowExecutor = class {
|
|
|
816
798
|
workflowVersion: run.workflowVersion,
|
|
817
799
|
input: run.input,
|
|
818
800
|
history,
|
|
801
|
+
...pendingSignals ? {
|
|
802
|
+
pendingSignals
|
|
803
|
+
} : {},
|
|
819
804
|
group: this.group,
|
|
820
805
|
priority: run.priority,
|
|
821
806
|
attempt: 1
|
|
822
807
|
};
|
|
823
|
-
const decision = new Promise((resolve, reject) => {
|
|
824
|
-
this.pending.set(taskId, resolve);
|
|
825
|
-
const { timeoutMs } = this.opts;
|
|
826
|
-
if (timeoutMs) {
|
|
827
|
-
const timer = setTimeout(() => {
|
|
828
|
-
this.pending.delete(taskId);
|
|
829
|
-
reject(new RemoteWorkflowTimeout(taskId, timeoutMs));
|
|
830
|
-
}, timeoutMs);
|
|
831
|
-
timer.unref?.();
|
|
832
|
-
}
|
|
833
|
-
});
|
|
834
808
|
await this.transport.dispatchWorkflowTask(task);
|
|
835
|
-
return decision;
|
|
836
809
|
}
|
|
837
810
|
};
|
|
838
811
|
|
|
@@ -953,13 +926,16 @@ var TransportPool = class {
|
|
|
953
926
|
/** Register the engine's result/heartbeat/step-event handlers on EVERY transport — a result can come
|
|
954
927
|
* back on whichever one delivered the task, so failover stays symmetric. `onStepEvent` is wired only
|
|
955
928
|
* on transports that carry streamed workflow step lifecycle (BullMQ); others simply skip it. */
|
|
956
|
-
bind(onResult, onHeartbeat, onStepEvent) {
|
|
929
|
+
bind(onResult, onHeartbeat, onStepEvent, onDecision) {
|
|
957
930
|
for (const { transport } of this.transports) {
|
|
958
931
|
transport.onResult(onResult);
|
|
959
932
|
transport.onHeartbeat(onHeartbeat);
|
|
960
933
|
if (onStepEvent && transport.onStepEvent) {
|
|
961
934
|
transport.onStepEvent(onStepEvent);
|
|
962
935
|
}
|
|
936
|
+
if (onDecision && transport.onDecision) {
|
|
937
|
+
transport.onDecision(onDecision);
|
|
938
|
+
}
|
|
963
939
|
}
|
|
964
940
|
}
|
|
965
941
|
/**
|
|
@@ -1717,14 +1693,27 @@ var WorkflowEngine = class {
|
|
|
1717
1693
|
},
|
|
1718
1694
|
// A heartbeat resets the liveness window for whatever it targets: an in-flight long STEP (keyed
|
|
1719
1695
|
// by stepId, see callRemote) or — when stepId is absent — the in-flight workflow TURN (keyed by
|
|
1720
|
-
// runId
|
|
1696
|
+
// runId). For an INLINE-advance turn the reset is an in-memory callback; for a DISPATCH-suspend
|
|
1697
|
+
// turn there is no in-memory waiter, so a run-scoped beat rearms the suspended turn's durable
|
|
1698
|
+
// re-drive deadline (`wakeAt`) instead, keeping a live worker's turn from being re-dispatched.
|
|
1721
1699
|
async (beat) => {
|
|
1722
|
-
this.heartbeatResets.get(beat.stepId ?? beat.runId)
|
|
1700
|
+
const reset = this.heartbeatResets.get(beat.stepId ?? beat.runId);
|
|
1701
|
+
if (reset) {
|
|
1702
|
+
reset();
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
if (beat.stepId === void 0) await this.rearmDecisionDeadline(beat.runId);
|
|
1723
1706
|
},
|
|
1724
1707
|
// A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
|
|
1725
1708
|
// it's checkpointed live, not all-at-once when the long turn ends.
|
|
1726
1709
|
async (event) => {
|
|
1727
1710
|
await this.persistStepEvent(event);
|
|
1711
|
+
},
|
|
1712
|
+
// A workflow-turn DECISION delivered over the broker — applied durably (by run id) on whatever
|
|
1713
|
+
// instance consumes it, NOT correlated to an in-memory promise on the dispatcher. This is the
|
|
1714
|
+
// multi-instance fix: a point-to-point broker may hand the decision to a non-dispatching instance.
|
|
1715
|
+
async (decision) => {
|
|
1716
|
+
await this.completeRemoteDecision(decision);
|
|
1728
1717
|
}
|
|
1729
1718
|
);
|
|
1730
1719
|
this.controlPlane?.onControl((msg) => {
|
|
@@ -2353,7 +2342,7 @@ var WorkflowEngine = class {
|
|
|
2353
2342
|
* `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
|
|
2354
2343
|
*/
|
|
2355
2344
|
waitForRun(runId, opts) {
|
|
2356
|
-
const isSettled = /* @__PURE__ */ __name((
|
|
2345
|
+
const isSettled = /* @__PURE__ */ __name((run) => run.status !== "pending" && run.status !== "running" && run.status !== "cancelling" && !(run.status === "suspended" && run.awaitingDecisionTaskId !== void 0), "isSettled");
|
|
2357
2346
|
const toResult = /* @__PURE__ */ __name((run) => ({
|
|
2358
2347
|
runId,
|
|
2359
2348
|
status: run.status,
|
|
@@ -2379,7 +2368,7 @@ var WorkflowEngine = class {
|
|
|
2379
2368
|
}, "finish");
|
|
2380
2369
|
const check = /* @__PURE__ */ __name(() => {
|
|
2381
2370
|
void this.store.getRun(runId).then((run) => {
|
|
2382
|
-
if (run && isSettled(run
|
|
2371
|
+
if (run && isSettled(run)) finish(run);
|
|
2383
2372
|
});
|
|
2384
2373
|
}, "check");
|
|
2385
2374
|
off = this.subscribe((ev) => {
|
|
@@ -2986,10 +2975,10 @@ var WorkflowEngine = class {
|
|
|
2986
2975
|
});
|
|
2987
2976
|
}
|
|
2988
2977
|
if (registered.singleton && !await this.singletons.admit(run, registered.singleton)) {
|
|
2989
|
-
const
|
|
2978
|
+
const wakeAt = this.singletons.retryWakeAt();
|
|
2990
2979
|
await this.store.updateRun(run.id, {
|
|
2991
2980
|
status: "suspended",
|
|
2992
|
-
wakeAt
|
|
2981
|
+
wakeAt,
|
|
2993
2982
|
updatedAt: /* @__PURE__ */ new Date()
|
|
2994
2983
|
});
|
|
2995
2984
|
this.emit({
|
|
@@ -3004,10 +2993,36 @@ var WorkflowEngine = class {
|
|
|
3004
2993
|
};
|
|
3005
2994
|
}
|
|
3006
2995
|
const history = await this.remoteHistory(run.id);
|
|
2996
|
+
if (remote.executor.dispatch) {
|
|
2997
|
+
const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID().slice(0, 8)}`;
|
|
2998
|
+
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
2999
|
+
const wakeAt = silenceMs != null ? this.clock() + silenceMs : void 0;
|
|
3000
|
+
await this.store.updateRun(run.id, {
|
|
3001
|
+
status: "suspended",
|
|
3002
|
+
awaitingDecisionTaskId: taskId,
|
|
3003
|
+
wakeAt,
|
|
3004
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
3005
|
+
});
|
|
3006
|
+
this.emit({
|
|
3007
|
+
type: "run.suspended",
|
|
3008
|
+
runId: run.id,
|
|
3009
|
+
workflow: run.workflow
|
|
3010
|
+
});
|
|
3011
|
+
await this.store.releaseRunLock(run.id);
|
|
3012
|
+
await remote.executor.dispatch(run, history, taskId);
|
|
3013
|
+
return {
|
|
3014
|
+
runId: run.id,
|
|
3015
|
+
status: "suspended"
|
|
3016
|
+
};
|
|
3017
|
+
}
|
|
3018
|
+
const advanceExecutor = remote.executor.advance;
|
|
3019
|
+
if (!advanceExecutor) {
|
|
3020
|
+
throw new Error(`workflow ${run.workflow}@${run.workflowVersion}: executor implements neither advance nor dispatch`);
|
|
3021
|
+
}
|
|
3007
3022
|
let decision;
|
|
3008
3023
|
try {
|
|
3009
3024
|
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
3010
|
-
const advance = remote.executor
|
|
3025
|
+
const advance = advanceExecutor.call(remote.executor, run, history);
|
|
3011
3026
|
decision = silenceMs != null ? await this.awaitWithLivenessDeadline(run.id, advance, silenceMs, () => new RemoteWorkflowTimeout(run.id, silenceMs)) : await advance;
|
|
3012
3027
|
} catch (err) {
|
|
3013
3028
|
if (err instanceof RemoteWorkflowTimeout) {
|
|
@@ -3038,6 +3053,16 @@ var WorkflowEngine = class {
|
|
|
3038
3053
|
error
|
|
3039
3054
|
};
|
|
3040
3055
|
}
|
|
3056
|
+
return this.applyDecision(run, decision);
|
|
3057
|
+
}
|
|
3058
|
+
/**
|
|
3059
|
+
* Apply a remote workflow turn's {@link WorkflowDecision}: persist the local steps it ran, then
|
|
3060
|
+
* settle (completed/failed/cancelled) or dispatch its blocking ops and suspend (continue). Shared by
|
|
3061
|
+
* the INLINE-advance path ({@link runRemoteExecution}, under the held lease) and the durable
|
|
3062
|
+
* DISPATCH path ({@link completeRemoteDecision}, which holds the lease itself). Idempotent transitions
|
|
3063
|
+
* — the caller has already ensured this is the decision for the currently-awaited turn.
|
|
3064
|
+
*/
|
|
3065
|
+
async applyDecision(run, decision) {
|
|
3041
3066
|
if (decision.status === "completed") {
|
|
3042
3067
|
await this.applyCommands(run, decision.commands);
|
|
3043
3068
|
return this.settleRun(run, {
|
|
@@ -3635,6 +3660,60 @@ var WorkflowEngine = class {
|
|
|
3635
3660
|
});
|
|
3636
3661
|
}
|
|
3637
3662
|
/**
|
|
3663
|
+
* Apply a remote workflow-turn {@link WorkflowDecision} durably — runs on WHATEVER instance receives
|
|
3664
|
+
* it over the broker (the dispatching one may be gone, or a point-to-point broker handed it to a
|
|
3665
|
+
* non-dispatcher). The durable, multi-instance-safe twin of {@link completeRemoteResult} for the
|
|
3666
|
+
* workflow TURN: look the run up by `decision.runId`, verify the decision matches the run's
|
|
3667
|
+
* CURRENTLY-awaited turn (`awaitingDecisionTaskId`) — so a stale / duplicate / foreign / re-driven
|
|
3668
|
+
* decision is dropped (applied AT MOST ONCE) — lease it, clear the marker, and apply the decision.
|
|
3669
|
+
*/
|
|
3670
|
+
async completeRemoteDecision(decision) {
|
|
3671
|
+
const run = await this.store.getRun(decision.runId);
|
|
3672
|
+
if (!run || run.awaitingDecisionTaskId !== decision.taskId) return;
|
|
3673
|
+
if (run.status === "cancelled" || run.status === "completed" || run.status === "dead") return;
|
|
3674
|
+
if (run.namespace !== void 0 && run.namespace !== this.namespace) return;
|
|
3675
|
+
const nowMs = this.clock();
|
|
3676
|
+
if (!await this.store.tryLockRun(decision.runId, this.instanceId, nowMs + this.leaseMs, nowMs)) {
|
|
3677
|
+
return;
|
|
3678
|
+
}
|
|
3679
|
+
const renew = setInterval(() => {
|
|
3680
|
+
void this.store.renewRunLock(decision.runId, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
|
|
3681
|
+
}, Math.max(50, Math.floor(this.leaseMs / 2)));
|
|
3682
|
+
renew.unref?.();
|
|
3683
|
+
try {
|
|
3684
|
+
const fresh = await this.store.getRun(decision.runId);
|
|
3685
|
+
if (!fresh || fresh.awaitingDecisionTaskId !== decision.taskId) return;
|
|
3686
|
+
if (fresh.status === "cancelled" || fresh.status === "completed" || fresh.status === "dead") {
|
|
3687
|
+
return;
|
|
3688
|
+
}
|
|
3689
|
+
await this.store.updateRun(decision.runId, {
|
|
3690
|
+
awaitingDecisionTaskId: void 0,
|
|
3691
|
+
wakeAt: void 0,
|
|
3692
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
3693
|
+
});
|
|
3694
|
+
fresh.awaitingDecisionTaskId = void 0;
|
|
3695
|
+
await this.applyDecision(fresh, decision);
|
|
3696
|
+
} finally {
|
|
3697
|
+
clearInterval(renew);
|
|
3698
|
+
await this.store.releaseRunLock(decision.runId);
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
/**
|
|
3702
|
+
* A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
|
|
3703
|
+
* push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
|
|
3704
|
+
* re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
|
|
3705
|
+
*/
|
|
3706
|
+
async rearmDecisionDeadline(runId) {
|
|
3707
|
+
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
3708
|
+
if (silenceMs == null) return;
|
|
3709
|
+
const run = await this.store.getRun(runId);
|
|
3710
|
+
if (!run || run.status !== "suspended" || run.awaitingDecisionTaskId === void 0) return;
|
|
3711
|
+
await this.store.updateRun(runId, {
|
|
3712
|
+
wakeAt: this.clock() + silenceMs,
|
|
3713
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
3714
|
+
});
|
|
3715
|
+
}
|
|
3716
|
+
/**
|
|
3638
3717
|
* A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
|
|
3639
3718
|
* re-contend now instead of at their retry tick. Snapshot-and-clear — a run still blocked after the
|
|
3640
3719
|
* retry re-registers itself, and one that's gone (cancelled/admitted) is dropped. Best-effort.
|