@dudousxd/nestjs-durable-core 0.42.0 → 0.44.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/dist/index.cjs +201 -72
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +98 -13
- package/dist/index.d.ts +98 -13
- package/dist/index.js +201 -72
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -700,6 +700,49 @@ function eventMatches(payload, match) {
|
|
|
700
700
|
}
|
|
701
701
|
__name(eventMatches, "eventMatches");
|
|
702
702
|
|
|
703
|
+
// src/remote-workflow-executor.ts
|
|
704
|
+
var taskCounter = 0;
|
|
705
|
+
var RemoteWorkflowExecutor = class {
|
|
706
|
+
static {
|
|
707
|
+
__name(this, "RemoteWorkflowExecutor");
|
|
708
|
+
}
|
|
709
|
+
transport;
|
|
710
|
+
group;
|
|
711
|
+
constructor(transport, group, _opts = {}) {
|
|
712
|
+
this.transport = transport;
|
|
713
|
+
this.group = group;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Dispatch one workflow turn and return its `taskId`. Does NOT await the decision — the engine
|
|
717
|
+
* suspends the run on the returned `taskId` and applies the decision durably via `onDecision`.
|
|
718
|
+
*/
|
|
719
|
+
async dispatch(run, history, pendingSignals) {
|
|
720
|
+
if (!this.transport.dispatchWorkflowTask) {
|
|
721
|
+
throw new Error("transport does not support workflow tasks (dispatchWorkflowTask)");
|
|
722
|
+
}
|
|
723
|
+
taskCounter += 1;
|
|
724
|
+
const taskId = `${run.id}:wf:${taskCounter}`;
|
|
725
|
+
const task = {
|
|
726
|
+
taskId,
|
|
727
|
+
runId: run.id,
|
|
728
|
+
workflow: run.workflow,
|
|
729
|
+
workflowVersion: run.workflowVersion,
|
|
730
|
+
input: run.input,
|
|
731
|
+
history,
|
|
732
|
+
...pendingSignals ? {
|
|
733
|
+
pendingSignals
|
|
734
|
+
} : {},
|
|
735
|
+
group: this.group,
|
|
736
|
+
priority: run.priority,
|
|
737
|
+
attempt: 1
|
|
738
|
+
};
|
|
739
|
+
await this.transport.dispatchWorkflowTask(task);
|
|
740
|
+
return {
|
|
741
|
+
taskId
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
|
|
703
746
|
// src/singleton-gate.ts
|
|
704
747
|
var SINGLETON_RETRY_MS = 1e3;
|
|
705
748
|
var SINGLETON_RETRY_JITTER_MS = 250;
|
|
@@ -801,16 +844,32 @@ var TransportPool = class {
|
|
|
801
844
|
get size() {
|
|
802
845
|
return this.transports.length;
|
|
803
846
|
}
|
|
847
|
+
/**
|
|
848
|
+
* The primary (first) transport — the one `dispatch` prefers when no `preferId` is pinned. A
|
|
849
|
+
* {@link RemoteWorkflowExecutor} built by `engine.remote()` rides it (it needs a single
|
|
850
|
+
* `dispatchWorkflowTask`/`onDecision` Transport, not the whole failover pool). Throws if the pool
|
|
851
|
+
* is empty — there is nothing to dispatch a remote workflow over.
|
|
852
|
+
*/
|
|
853
|
+
get primary() {
|
|
854
|
+
const first = this.transports[0];
|
|
855
|
+
if (!first) {
|
|
856
|
+
throw new Error("no transport configured \u2014 engine.remote() needs a transport (or transports)");
|
|
857
|
+
}
|
|
858
|
+
return first.transport;
|
|
859
|
+
}
|
|
804
860
|
/** Register the engine's result/heartbeat/step-event handlers on EVERY transport — a result can come
|
|
805
861
|
* back on whichever one delivered the task, so failover stays symmetric. `onStepEvent` is wired only
|
|
806
862
|
* on transports that carry streamed workflow step lifecycle (BullMQ); others simply skip it. */
|
|
807
|
-
bind(onResult, onHeartbeat, onStepEvent) {
|
|
863
|
+
bind(onResult, onHeartbeat, onStepEvent, onDecision) {
|
|
808
864
|
for (const { transport } of this.transports) {
|
|
809
865
|
transport.onResult(onResult);
|
|
810
866
|
transport.onHeartbeat(onHeartbeat);
|
|
811
867
|
if (onStepEvent && transport.onStepEvent) {
|
|
812
868
|
transport.onStepEvent(onStepEvent);
|
|
813
869
|
}
|
|
870
|
+
if (onDecision && transport.onDecision) {
|
|
871
|
+
transport.onDecision(onDecision);
|
|
872
|
+
}
|
|
814
873
|
}
|
|
815
874
|
}
|
|
816
875
|
/**
|
|
@@ -1568,14 +1627,27 @@ var WorkflowEngine = class {
|
|
|
1568
1627
|
},
|
|
1569
1628
|
// A heartbeat resets the liveness window for whatever it targets: an in-flight long STEP (keyed
|
|
1570
1629
|
// by stepId, see callRemote) or — when stepId is absent — the in-flight workflow TURN (keyed by
|
|
1571
|
-
// runId
|
|
1630
|
+
// runId). For an INLINE-advance turn the reset is an in-memory callback; for a DISPATCH-suspend
|
|
1631
|
+
// turn there is no in-memory waiter, so a run-scoped beat rearms the suspended turn's durable
|
|
1632
|
+
// re-drive deadline (`wakeAt`) instead, keeping a live worker's turn from being re-dispatched.
|
|
1572
1633
|
async (beat) => {
|
|
1573
|
-
this.heartbeatResets.get(beat.stepId ?? beat.runId)
|
|
1634
|
+
const reset = this.heartbeatResets.get(beat.stepId ?? beat.runId);
|
|
1635
|
+
if (reset) {
|
|
1636
|
+
reset();
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
if (beat.stepId === void 0) await this.rearmDecisionDeadline(beat.runId);
|
|
1574
1640
|
},
|
|
1575
1641
|
// A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
|
|
1576
1642
|
// it's checkpointed live, not all-at-once when the long turn ends.
|
|
1577
1643
|
async (event) => {
|
|
1578
1644
|
await this.persistStepEvent(event);
|
|
1645
|
+
},
|
|
1646
|
+
// A workflow-turn DECISION delivered over the broker — applied durably (by run id) on whatever
|
|
1647
|
+
// instance consumes it, NOT correlated to an in-memory promise on the dispatcher. This is the
|
|
1648
|
+
// multi-instance fix: a point-to-point broker may hand the decision to a non-dispatching instance.
|
|
1649
|
+
async (decision) => {
|
|
1650
|
+
await this.completeRemoteDecision(decision);
|
|
1579
1651
|
}
|
|
1580
1652
|
);
|
|
1581
1653
|
this.controlPlane?.onControl((msg) => {
|
|
@@ -1720,6 +1792,28 @@ var WorkflowEngine = class {
|
|
|
1720
1792
|
if (!current || isNewerVersion(version, current.version)) this.latest.set(name, registered);
|
|
1721
1793
|
}
|
|
1722
1794
|
/**
|
|
1795
|
+
* The convenience form of {@link registerRemote}: register a workflow whose body runs on a worker
|
|
1796
|
+
* `group` (e.g. the Python `durable-worker`, or a separate TS worker), without hand-building a
|
|
1797
|
+
* {@link RemoteWorkflowExecutor}. It wires the standard broker executor — a `RemoteWorkflowExecutor`
|
|
1798
|
+
* over the engine's own transport, dispatching to `opts.group` — for you, so you write
|
|
1799
|
+
* `engine.remote('processing', { group: 'processing' })` instead of
|
|
1800
|
+
* `engine.registerRemote('processing', '1', { group: 'processing', executor: new RemoteWorkflowExecutor(transport, 'processing') })`.
|
|
1801
|
+
*
|
|
1802
|
+
* Defaults the version to `'1'`. All the other {@link registerRemote} options
|
|
1803
|
+
* (`tags` / `singleton` / `executionTimeout` / `validateInput`) pass straight through. Reach for the
|
|
1804
|
+
* low-level {@link registerRemote} when you need a custom {@link WorkflowExecutor} (a different
|
|
1805
|
+
* transport, a fixed `timeoutMs`, a polyglot stub) — `remote()` is the standard wiring on the rails.
|
|
1806
|
+
*/
|
|
1807
|
+
remote(name, opts) {
|
|
1808
|
+
const { group, version, ...rest } = opts;
|
|
1809
|
+
const executor = new RemoteWorkflowExecutor(this.pool.primary, group);
|
|
1810
|
+
this.registerRemote(name, version ?? "1", {
|
|
1811
|
+
group,
|
|
1812
|
+
executor,
|
|
1813
|
+
...rest
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1723
1817
|
* Register a flow-control queue referenced by `ctx.call(step, input, { queue })`. Caps concurrent
|
|
1724
1818
|
* in-flight steps and/or the admission rate; blocked calls re-suspend and retry, so the limit is
|
|
1725
1819
|
* durable. Per engine instance (see {@link QueueConfig}). Registering the same name replaces it.
|
|
@@ -2182,7 +2276,7 @@ var WorkflowEngine = class {
|
|
|
2182
2276
|
* `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
|
|
2183
2277
|
*/
|
|
2184
2278
|
waitForRun(runId, opts) {
|
|
2185
|
-
const isSettled = /* @__PURE__ */ __name((
|
|
2279
|
+
const isSettled = /* @__PURE__ */ __name((run) => run.status !== "pending" && run.status !== "running" && run.status !== "cancelling" && !(run.status === "suspended" && run.awaitingDecisionTaskId !== void 0), "isSettled");
|
|
2186
2280
|
const toResult = /* @__PURE__ */ __name((run) => ({
|
|
2187
2281
|
runId,
|
|
2188
2282
|
status: run.status,
|
|
@@ -2208,7 +2302,7 @@ var WorkflowEngine = class {
|
|
|
2208
2302
|
}, "finish");
|
|
2209
2303
|
const check = /* @__PURE__ */ __name(() => {
|
|
2210
2304
|
void this.store.getRun(runId).then((run) => {
|
|
2211
|
-
if (run && isSettled(run
|
|
2305
|
+
if (run && isSettled(run)) finish(run);
|
|
2212
2306
|
});
|
|
2213
2307
|
}, "check");
|
|
2214
2308
|
off = this.subscribe((ev) => {
|
|
@@ -2815,10 +2909,10 @@ var WorkflowEngine = class {
|
|
|
2815
2909
|
});
|
|
2816
2910
|
}
|
|
2817
2911
|
if (registered.singleton && !await this.singletons.admit(run, registered.singleton)) {
|
|
2818
|
-
const
|
|
2912
|
+
const wakeAt = this.singletons.retryWakeAt();
|
|
2819
2913
|
await this.store.updateRun(run.id, {
|
|
2820
2914
|
status: "suspended",
|
|
2821
|
-
wakeAt
|
|
2915
|
+
wakeAt,
|
|
2822
2916
|
updatedAt: /* @__PURE__ */ new Date()
|
|
2823
2917
|
});
|
|
2824
2918
|
this.emit({
|
|
@@ -2833,10 +2927,45 @@ var WorkflowEngine = class {
|
|
|
2833
2927
|
};
|
|
2834
2928
|
}
|
|
2835
2929
|
const history = await this.remoteHistory(run.id);
|
|
2930
|
+
if (remote.executor.dispatch) {
|
|
2931
|
+
const { taskId } = await remote.executor.dispatch(run, history);
|
|
2932
|
+
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
2933
|
+
const wakeAt = silenceMs != null ? this.clock() + silenceMs : void 0;
|
|
2934
|
+
const fresh = await this.store.getRun(run.id);
|
|
2935
|
+
if (fresh && (fresh.status === "cancelled" || fresh.status === "completed" || fresh.status === "dead")) {
|
|
2936
|
+
await this.store.releaseRunLock(run.id);
|
|
2937
|
+
return {
|
|
2938
|
+
runId: run.id,
|
|
2939
|
+
status: fresh.status,
|
|
2940
|
+
output: fresh.output,
|
|
2941
|
+
error: fresh.error
|
|
2942
|
+
};
|
|
2943
|
+
}
|
|
2944
|
+
await this.store.updateRun(run.id, {
|
|
2945
|
+
status: "suspended",
|
|
2946
|
+
awaitingDecisionTaskId: taskId,
|
|
2947
|
+
wakeAt,
|
|
2948
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
2949
|
+
});
|
|
2950
|
+
this.emit({
|
|
2951
|
+
type: "run.suspended",
|
|
2952
|
+
runId: run.id,
|
|
2953
|
+
workflow: run.workflow
|
|
2954
|
+
});
|
|
2955
|
+
await this.store.releaseRunLock(run.id);
|
|
2956
|
+
return {
|
|
2957
|
+
runId: run.id,
|
|
2958
|
+
status: "suspended"
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
const advanceExecutor = remote.executor.advance;
|
|
2962
|
+
if (!advanceExecutor) {
|
|
2963
|
+
throw new Error(`workflow ${run.workflow}@${run.workflowVersion}: executor implements neither advance nor dispatch`);
|
|
2964
|
+
}
|
|
2836
2965
|
let decision;
|
|
2837
2966
|
try {
|
|
2838
2967
|
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
2839
|
-
const advance = remote.executor
|
|
2968
|
+
const advance = advanceExecutor.call(remote.executor, run, history);
|
|
2840
2969
|
decision = silenceMs != null ? await this.awaitWithLivenessDeadline(run.id, advance, silenceMs, () => new RemoteWorkflowTimeout(run.id, silenceMs)) : await advance;
|
|
2841
2970
|
} catch (err) {
|
|
2842
2971
|
if (err instanceof RemoteWorkflowTimeout) {
|
|
@@ -2867,6 +2996,16 @@ var WorkflowEngine = class {
|
|
|
2867
2996
|
error
|
|
2868
2997
|
};
|
|
2869
2998
|
}
|
|
2999
|
+
return this.applyDecision(run, decision);
|
|
3000
|
+
}
|
|
3001
|
+
/**
|
|
3002
|
+
* Apply a remote workflow turn's {@link WorkflowDecision}: persist the local steps it ran, then
|
|
3003
|
+
* settle (completed/failed/cancelled) or dispatch its blocking ops and suspend (continue). Shared by
|
|
3004
|
+
* the INLINE-advance path ({@link runRemoteExecution}, under the held lease) and the durable
|
|
3005
|
+
* DISPATCH path ({@link completeRemoteDecision}, which holds the lease itself). Idempotent transitions
|
|
3006
|
+
* — the caller has already ensured this is the decision for the currently-awaited turn.
|
|
3007
|
+
*/
|
|
3008
|
+
async applyDecision(run, decision) {
|
|
2870
3009
|
if (decision.status === "completed") {
|
|
2871
3010
|
await this.applyCommands(run, decision.commands);
|
|
2872
3011
|
return this.settleRun(run, {
|
|
@@ -3464,6 +3603,60 @@ var WorkflowEngine = class {
|
|
|
3464
3603
|
});
|
|
3465
3604
|
}
|
|
3466
3605
|
/**
|
|
3606
|
+
* Apply a remote workflow-turn {@link WorkflowDecision} durably — runs on WHATEVER instance receives
|
|
3607
|
+
* it over the broker (the dispatching one may be gone, or a point-to-point broker handed it to a
|
|
3608
|
+
* non-dispatcher). The durable, multi-instance-safe twin of {@link completeRemoteResult} for the
|
|
3609
|
+
* workflow TURN: look the run up by `decision.runId`, verify the decision matches the run's
|
|
3610
|
+
* CURRENTLY-awaited turn (`awaitingDecisionTaskId`) — so a stale / duplicate / foreign / re-driven
|
|
3611
|
+
* decision is dropped (applied AT MOST ONCE) — lease it, clear the marker, and apply the decision.
|
|
3612
|
+
*/
|
|
3613
|
+
async completeRemoteDecision(decision) {
|
|
3614
|
+
const run = await this.store.getRun(decision.runId);
|
|
3615
|
+
if (!run || run.awaitingDecisionTaskId !== decision.taskId) return;
|
|
3616
|
+
if (run.status === "cancelled" || run.status === "completed" || run.status === "dead") return;
|
|
3617
|
+
if (run.namespace !== void 0 && run.namespace !== this.namespace) return;
|
|
3618
|
+
const nowMs = this.clock();
|
|
3619
|
+
if (!await this.store.tryLockRun(decision.runId, this.instanceId, nowMs + this.leaseMs, nowMs)) {
|
|
3620
|
+
return;
|
|
3621
|
+
}
|
|
3622
|
+
const renew = setInterval(() => {
|
|
3623
|
+
void this.store.renewRunLock(decision.runId, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
|
|
3624
|
+
}, Math.max(50, Math.floor(this.leaseMs / 2)));
|
|
3625
|
+
renew.unref?.();
|
|
3626
|
+
try {
|
|
3627
|
+
const fresh = await this.store.getRun(decision.runId);
|
|
3628
|
+
if (!fresh || fresh.awaitingDecisionTaskId !== decision.taskId) return;
|
|
3629
|
+
if (fresh.status === "cancelled" || fresh.status === "completed" || fresh.status === "dead") {
|
|
3630
|
+
return;
|
|
3631
|
+
}
|
|
3632
|
+
await this.store.updateRun(decision.runId, {
|
|
3633
|
+
awaitingDecisionTaskId: void 0,
|
|
3634
|
+
wakeAt: void 0,
|
|
3635
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
3636
|
+
});
|
|
3637
|
+
fresh.awaitingDecisionTaskId = void 0;
|
|
3638
|
+
await this.applyDecision(fresh, decision);
|
|
3639
|
+
} finally {
|
|
3640
|
+
clearInterval(renew);
|
|
3641
|
+
await this.store.releaseRunLock(decision.runId);
|
|
3642
|
+
}
|
|
3643
|
+
}
|
|
3644
|
+
/**
|
|
3645
|
+
* A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
|
|
3646
|
+
* push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
|
|
3647
|
+
* re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
|
|
3648
|
+
*/
|
|
3649
|
+
async rearmDecisionDeadline(runId) {
|
|
3650
|
+
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
3651
|
+
if (silenceMs == null) return;
|
|
3652
|
+
const run = await this.store.getRun(runId);
|
|
3653
|
+
if (!run || run.status !== "suspended" || run.awaitingDecisionTaskId === void 0) return;
|
|
3654
|
+
await this.store.updateRun(runId, {
|
|
3655
|
+
wakeAt: this.clock() + silenceMs,
|
|
3656
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
3657
|
+
});
|
|
3658
|
+
}
|
|
3659
|
+
/**
|
|
3467
3660
|
* A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
|
|
3468
3661
|
* re-contend now instead of at their retry tick. Snapshot-and-clear — a run still blocked after the
|
|
3469
3662
|
* retry re-registers itself, and one that's gone (cancelled/admitted) is dropped. Best-effort.
|
|
@@ -3615,70 +3808,6 @@ function remoteStep(config) {
|
|
|
3615
3808
|
}
|
|
3616
3809
|
__name(remoteStep, "remoteStep");
|
|
3617
3810
|
|
|
3618
|
-
// src/remote-workflow-executor.ts
|
|
3619
|
-
var taskCounter = 0;
|
|
3620
|
-
var RemoteWorkflowExecutor = class {
|
|
3621
|
-
static {
|
|
3622
|
-
__name(this, "RemoteWorkflowExecutor");
|
|
3623
|
-
}
|
|
3624
|
-
transport;
|
|
3625
|
-
group;
|
|
3626
|
-
opts;
|
|
3627
|
-
pending = /* @__PURE__ */ new Map();
|
|
3628
|
-
subscribed = false;
|
|
3629
|
-
constructor(transport, group, opts = {}) {
|
|
3630
|
-
this.transport = transport;
|
|
3631
|
-
this.group = group;
|
|
3632
|
-
this.opts = opts;
|
|
3633
|
-
}
|
|
3634
|
-
ensureSubscribed() {
|
|
3635
|
-
if (this.subscribed) return;
|
|
3636
|
-
if (!this.transport.onDecision) {
|
|
3637
|
-
throw new Error("transport does not support workflow decisions (onDecision)");
|
|
3638
|
-
}
|
|
3639
|
-
this.subscribed = true;
|
|
3640
|
-
this.transport.onDecision(async (decision) => {
|
|
3641
|
-
const resolve = this.pending.get(decision.taskId);
|
|
3642
|
-
if (resolve) {
|
|
3643
|
-
this.pending.delete(decision.taskId);
|
|
3644
|
-
resolve(decision);
|
|
3645
|
-
}
|
|
3646
|
-
});
|
|
3647
|
-
}
|
|
3648
|
-
async advance(run, history) {
|
|
3649
|
-
if (!this.transport.dispatchWorkflowTask) {
|
|
3650
|
-
throw new Error("transport does not support workflow tasks (dispatchWorkflowTask)");
|
|
3651
|
-
}
|
|
3652
|
-
this.ensureSubscribed();
|
|
3653
|
-
taskCounter += 1;
|
|
3654
|
-
const taskId = `${run.id}:wf:${taskCounter}`;
|
|
3655
|
-
const task = {
|
|
3656
|
-
taskId,
|
|
3657
|
-
runId: run.id,
|
|
3658
|
-
workflow: run.workflow,
|
|
3659
|
-
workflowVersion: run.workflowVersion,
|
|
3660
|
-
input: run.input,
|
|
3661
|
-
history,
|
|
3662
|
-
group: this.group,
|
|
3663
|
-
priority: run.priority,
|
|
3664
|
-
attempt: 1
|
|
3665
|
-
};
|
|
3666
|
-
const decision = new Promise((resolve, reject) => {
|
|
3667
|
-
this.pending.set(taskId, resolve);
|
|
3668
|
-
const { timeoutMs } = this.opts;
|
|
3669
|
-
if (timeoutMs) {
|
|
3670
|
-
const timer = setTimeout(() => {
|
|
3671
|
-
this.pending.delete(taskId);
|
|
3672
|
-
reject(new RemoteWorkflowTimeout(taskId, timeoutMs));
|
|
3673
|
-
}, timeoutMs);
|
|
3674
|
-
timer.unref?.();
|
|
3675
|
-
}
|
|
3676
|
-
});
|
|
3677
|
-
await this.transport.dispatchWorkflowTask(task);
|
|
3678
|
-
return decision;
|
|
3679
|
-
}
|
|
3680
|
-
};
|
|
3681
|
-
|
|
3682
3811
|
// src/codec-state-store.ts
|
|
3683
3812
|
var CodecStateStore = class {
|
|
3684
3813
|
static {
|