@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.cjs
CHANGED
|
@@ -772,6 +772,49 @@ function eventMatches(payload, match) {
|
|
|
772
772
|
}
|
|
773
773
|
__name(eventMatches, "eventMatches");
|
|
774
774
|
|
|
775
|
+
// src/remote-workflow-executor.ts
|
|
776
|
+
var taskCounter = 0;
|
|
777
|
+
var RemoteWorkflowExecutor = class {
|
|
778
|
+
static {
|
|
779
|
+
__name(this, "RemoteWorkflowExecutor");
|
|
780
|
+
}
|
|
781
|
+
transport;
|
|
782
|
+
group;
|
|
783
|
+
constructor(transport, group, _opts = {}) {
|
|
784
|
+
this.transport = transport;
|
|
785
|
+
this.group = group;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Dispatch one workflow turn and return its `taskId`. Does NOT await the decision — the engine
|
|
789
|
+
* suspends the run on the returned `taskId` and applies the decision durably via `onDecision`.
|
|
790
|
+
*/
|
|
791
|
+
async dispatch(run, history, pendingSignals) {
|
|
792
|
+
if (!this.transport.dispatchWorkflowTask) {
|
|
793
|
+
throw new Error("transport does not support workflow tasks (dispatchWorkflowTask)");
|
|
794
|
+
}
|
|
795
|
+
taskCounter += 1;
|
|
796
|
+
const taskId = `${run.id}:wf:${taskCounter}`;
|
|
797
|
+
const task = {
|
|
798
|
+
taskId,
|
|
799
|
+
runId: run.id,
|
|
800
|
+
workflow: run.workflow,
|
|
801
|
+
workflowVersion: run.workflowVersion,
|
|
802
|
+
input: run.input,
|
|
803
|
+
history,
|
|
804
|
+
...pendingSignals ? {
|
|
805
|
+
pendingSignals
|
|
806
|
+
} : {},
|
|
807
|
+
group: this.group,
|
|
808
|
+
priority: run.priority,
|
|
809
|
+
attempt: 1
|
|
810
|
+
};
|
|
811
|
+
await this.transport.dispatchWorkflowTask(task);
|
|
812
|
+
return {
|
|
813
|
+
taskId
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
|
|
775
818
|
// src/singleton-gate.ts
|
|
776
819
|
var SINGLETON_RETRY_MS = 1e3;
|
|
777
820
|
var SINGLETON_RETRY_JITTER_MS = 250;
|
|
@@ -873,16 +916,32 @@ var TransportPool = class {
|
|
|
873
916
|
get size() {
|
|
874
917
|
return this.transports.length;
|
|
875
918
|
}
|
|
919
|
+
/**
|
|
920
|
+
* The primary (first) transport — the one `dispatch` prefers when no `preferId` is pinned. A
|
|
921
|
+
* {@link RemoteWorkflowExecutor} built by `engine.remote()` rides it (it needs a single
|
|
922
|
+
* `dispatchWorkflowTask`/`onDecision` Transport, not the whole failover pool). Throws if the pool
|
|
923
|
+
* is empty — there is nothing to dispatch a remote workflow over.
|
|
924
|
+
*/
|
|
925
|
+
get primary() {
|
|
926
|
+
const first = this.transports[0];
|
|
927
|
+
if (!first) {
|
|
928
|
+
throw new Error("no transport configured \u2014 engine.remote() needs a transport (or transports)");
|
|
929
|
+
}
|
|
930
|
+
return first.transport;
|
|
931
|
+
}
|
|
876
932
|
/** Register the engine's result/heartbeat/step-event handlers on EVERY transport — a result can come
|
|
877
933
|
* back on whichever one delivered the task, so failover stays symmetric. `onStepEvent` is wired only
|
|
878
934
|
* on transports that carry streamed workflow step lifecycle (BullMQ); others simply skip it. */
|
|
879
|
-
bind(onResult, onHeartbeat, onStepEvent) {
|
|
935
|
+
bind(onResult, onHeartbeat, onStepEvent, onDecision) {
|
|
880
936
|
for (const { transport } of this.transports) {
|
|
881
937
|
transport.onResult(onResult);
|
|
882
938
|
transport.onHeartbeat(onHeartbeat);
|
|
883
939
|
if (onStepEvent && transport.onStepEvent) {
|
|
884
940
|
transport.onStepEvent(onStepEvent);
|
|
885
941
|
}
|
|
942
|
+
if (onDecision && transport.onDecision) {
|
|
943
|
+
transport.onDecision(onDecision);
|
|
944
|
+
}
|
|
886
945
|
}
|
|
887
946
|
}
|
|
888
947
|
/**
|
|
@@ -1640,14 +1699,27 @@ var WorkflowEngine = class {
|
|
|
1640
1699
|
},
|
|
1641
1700
|
// A heartbeat resets the liveness window for whatever it targets: an in-flight long STEP (keyed
|
|
1642
1701
|
// by stepId, see callRemote) or — when stepId is absent — the in-flight workflow TURN (keyed by
|
|
1643
|
-
// runId
|
|
1702
|
+
// runId). For an INLINE-advance turn the reset is an in-memory callback; for a DISPATCH-suspend
|
|
1703
|
+
// turn there is no in-memory waiter, so a run-scoped beat rearms the suspended turn's durable
|
|
1704
|
+
// re-drive deadline (`wakeAt`) instead, keeping a live worker's turn from being re-dispatched.
|
|
1644
1705
|
async (beat) => {
|
|
1645
|
-
this.heartbeatResets.get(beat.stepId ?? beat.runId)
|
|
1706
|
+
const reset = this.heartbeatResets.get(beat.stepId ?? beat.runId);
|
|
1707
|
+
if (reset) {
|
|
1708
|
+
reset();
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
if (beat.stepId === void 0) await this.rearmDecisionDeadline(beat.runId);
|
|
1646
1712
|
},
|
|
1647
1713
|
// A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
|
|
1648
1714
|
// it's checkpointed live, not all-at-once when the long turn ends.
|
|
1649
1715
|
async (event) => {
|
|
1650
1716
|
await this.persistStepEvent(event);
|
|
1717
|
+
},
|
|
1718
|
+
// A workflow-turn DECISION delivered over the broker — applied durably (by run id) on whatever
|
|
1719
|
+
// instance consumes it, NOT correlated to an in-memory promise on the dispatcher. This is the
|
|
1720
|
+
// multi-instance fix: a point-to-point broker may hand the decision to a non-dispatching instance.
|
|
1721
|
+
async (decision) => {
|
|
1722
|
+
await this.completeRemoteDecision(decision);
|
|
1651
1723
|
}
|
|
1652
1724
|
);
|
|
1653
1725
|
this.controlPlane?.onControl((msg) => {
|
|
@@ -1792,6 +1864,28 @@ var WorkflowEngine = class {
|
|
|
1792
1864
|
if (!current || isNewerVersion(version, current.version)) this.latest.set(name, registered);
|
|
1793
1865
|
}
|
|
1794
1866
|
/**
|
|
1867
|
+
* The convenience form of {@link registerRemote}: register a workflow whose body runs on a worker
|
|
1868
|
+
* `group` (e.g. the Python `durable-worker`, or a separate TS worker), without hand-building a
|
|
1869
|
+
* {@link RemoteWorkflowExecutor}. It wires the standard broker executor — a `RemoteWorkflowExecutor`
|
|
1870
|
+
* over the engine's own transport, dispatching to `opts.group` — for you, so you write
|
|
1871
|
+
* `engine.remote('processing', { group: 'processing' })` instead of
|
|
1872
|
+
* `engine.registerRemote('processing', '1', { group: 'processing', executor: new RemoteWorkflowExecutor(transport, 'processing') })`.
|
|
1873
|
+
*
|
|
1874
|
+
* Defaults the version to `'1'`. All the other {@link registerRemote} options
|
|
1875
|
+
* (`tags` / `singleton` / `executionTimeout` / `validateInput`) pass straight through. Reach for the
|
|
1876
|
+
* low-level {@link registerRemote} when you need a custom {@link WorkflowExecutor} (a different
|
|
1877
|
+
* transport, a fixed `timeoutMs`, a polyglot stub) — `remote()` is the standard wiring on the rails.
|
|
1878
|
+
*/
|
|
1879
|
+
remote(name, opts) {
|
|
1880
|
+
const { group, version, ...rest } = opts;
|
|
1881
|
+
const executor = new RemoteWorkflowExecutor(this.pool.primary, group);
|
|
1882
|
+
this.registerRemote(name, version ?? "1", {
|
|
1883
|
+
group,
|
|
1884
|
+
executor,
|
|
1885
|
+
...rest
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
/**
|
|
1795
1889
|
* Register a flow-control queue referenced by `ctx.call(step, input, { queue })`. Caps concurrent
|
|
1796
1890
|
* in-flight steps and/or the admission rate; blocked calls re-suspend and retry, so the limit is
|
|
1797
1891
|
* durable. Per engine instance (see {@link QueueConfig}). Registering the same name replaces it.
|
|
@@ -2254,7 +2348,7 @@ var WorkflowEngine = class {
|
|
|
2254
2348
|
* `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
|
|
2255
2349
|
*/
|
|
2256
2350
|
waitForRun(runId, opts) {
|
|
2257
|
-
const isSettled = /* @__PURE__ */ __name((
|
|
2351
|
+
const isSettled = /* @__PURE__ */ __name((run) => run.status !== "pending" && run.status !== "running" && run.status !== "cancelling" && !(run.status === "suspended" && run.awaitingDecisionTaskId !== void 0), "isSettled");
|
|
2258
2352
|
const toResult = /* @__PURE__ */ __name((run) => ({
|
|
2259
2353
|
runId,
|
|
2260
2354
|
status: run.status,
|
|
@@ -2280,7 +2374,7 @@ var WorkflowEngine = class {
|
|
|
2280
2374
|
}, "finish");
|
|
2281
2375
|
const check = /* @__PURE__ */ __name(() => {
|
|
2282
2376
|
void this.store.getRun(runId).then((run) => {
|
|
2283
|
-
if (run && isSettled(run
|
|
2377
|
+
if (run && isSettled(run)) finish(run);
|
|
2284
2378
|
});
|
|
2285
2379
|
}, "check");
|
|
2286
2380
|
off = this.subscribe((ev) => {
|
|
@@ -2887,10 +2981,10 @@ var WorkflowEngine = class {
|
|
|
2887
2981
|
});
|
|
2888
2982
|
}
|
|
2889
2983
|
if (registered.singleton && !await this.singletons.admit(run, registered.singleton)) {
|
|
2890
|
-
const
|
|
2984
|
+
const wakeAt = this.singletons.retryWakeAt();
|
|
2891
2985
|
await this.store.updateRun(run.id, {
|
|
2892
2986
|
status: "suspended",
|
|
2893
|
-
wakeAt
|
|
2987
|
+
wakeAt,
|
|
2894
2988
|
updatedAt: /* @__PURE__ */ new Date()
|
|
2895
2989
|
});
|
|
2896
2990
|
this.emit({
|
|
@@ -2905,10 +2999,45 @@ var WorkflowEngine = class {
|
|
|
2905
2999
|
};
|
|
2906
3000
|
}
|
|
2907
3001
|
const history = await this.remoteHistory(run.id);
|
|
3002
|
+
if (remote.executor.dispatch) {
|
|
3003
|
+
const { taskId } = await remote.executor.dispatch(run, history);
|
|
3004
|
+
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
3005
|
+
const wakeAt = silenceMs != null ? this.clock() + silenceMs : void 0;
|
|
3006
|
+
const fresh = await this.store.getRun(run.id);
|
|
3007
|
+
if (fresh && (fresh.status === "cancelled" || fresh.status === "completed" || fresh.status === "dead")) {
|
|
3008
|
+
await this.store.releaseRunLock(run.id);
|
|
3009
|
+
return {
|
|
3010
|
+
runId: run.id,
|
|
3011
|
+
status: fresh.status,
|
|
3012
|
+
output: fresh.output,
|
|
3013
|
+
error: fresh.error
|
|
3014
|
+
};
|
|
3015
|
+
}
|
|
3016
|
+
await this.store.updateRun(run.id, {
|
|
3017
|
+
status: "suspended",
|
|
3018
|
+
awaitingDecisionTaskId: taskId,
|
|
3019
|
+
wakeAt,
|
|
3020
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
3021
|
+
});
|
|
3022
|
+
this.emit({
|
|
3023
|
+
type: "run.suspended",
|
|
3024
|
+
runId: run.id,
|
|
3025
|
+
workflow: run.workflow
|
|
3026
|
+
});
|
|
3027
|
+
await this.store.releaseRunLock(run.id);
|
|
3028
|
+
return {
|
|
3029
|
+
runId: run.id,
|
|
3030
|
+
status: "suspended"
|
|
3031
|
+
};
|
|
3032
|
+
}
|
|
3033
|
+
const advanceExecutor = remote.executor.advance;
|
|
3034
|
+
if (!advanceExecutor) {
|
|
3035
|
+
throw new Error(`workflow ${run.workflow}@${run.workflowVersion}: executor implements neither advance nor dispatch`);
|
|
3036
|
+
}
|
|
2908
3037
|
let decision;
|
|
2909
3038
|
try {
|
|
2910
3039
|
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
2911
|
-
const advance = remote.executor
|
|
3040
|
+
const advance = advanceExecutor.call(remote.executor, run, history);
|
|
2912
3041
|
decision = silenceMs != null ? await this.awaitWithLivenessDeadline(run.id, advance, silenceMs, () => new RemoteWorkflowTimeout(run.id, silenceMs)) : await advance;
|
|
2913
3042
|
} catch (err) {
|
|
2914
3043
|
if (err instanceof RemoteWorkflowTimeout) {
|
|
@@ -2939,6 +3068,16 @@ var WorkflowEngine = class {
|
|
|
2939
3068
|
error
|
|
2940
3069
|
};
|
|
2941
3070
|
}
|
|
3071
|
+
return this.applyDecision(run, decision);
|
|
3072
|
+
}
|
|
3073
|
+
/**
|
|
3074
|
+
* Apply a remote workflow turn's {@link WorkflowDecision}: persist the local steps it ran, then
|
|
3075
|
+
* settle (completed/failed/cancelled) or dispatch its blocking ops and suspend (continue). Shared by
|
|
3076
|
+
* the INLINE-advance path ({@link runRemoteExecution}, under the held lease) and the durable
|
|
3077
|
+
* DISPATCH path ({@link completeRemoteDecision}, which holds the lease itself). Idempotent transitions
|
|
3078
|
+
* — the caller has already ensured this is the decision for the currently-awaited turn.
|
|
3079
|
+
*/
|
|
3080
|
+
async applyDecision(run, decision) {
|
|
2942
3081
|
if (decision.status === "completed") {
|
|
2943
3082
|
await this.applyCommands(run, decision.commands);
|
|
2944
3083
|
return this.settleRun(run, {
|
|
@@ -3536,6 +3675,60 @@ var WorkflowEngine = class {
|
|
|
3536
3675
|
});
|
|
3537
3676
|
}
|
|
3538
3677
|
/**
|
|
3678
|
+
* Apply a remote workflow-turn {@link WorkflowDecision} durably — runs on WHATEVER instance receives
|
|
3679
|
+
* it over the broker (the dispatching one may be gone, or a point-to-point broker handed it to a
|
|
3680
|
+
* non-dispatcher). The durable, multi-instance-safe twin of {@link completeRemoteResult} for the
|
|
3681
|
+
* workflow TURN: look the run up by `decision.runId`, verify the decision matches the run's
|
|
3682
|
+
* CURRENTLY-awaited turn (`awaitingDecisionTaskId`) — so a stale / duplicate / foreign / re-driven
|
|
3683
|
+
* decision is dropped (applied AT MOST ONCE) — lease it, clear the marker, and apply the decision.
|
|
3684
|
+
*/
|
|
3685
|
+
async completeRemoteDecision(decision) {
|
|
3686
|
+
const run = await this.store.getRun(decision.runId);
|
|
3687
|
+
if (!run || run.awaitingDecisionTaskId !== decision.taskId) return;
|
|
3688
|
+
if (run.status === "cancelled" || run.status === "completed" || run.status === "dead") return;
|
|
3689
|
+
if (run.namespace !== void 0 && run.namespace !== this.namespace) return;
|
|
3690
|
+
const nowMs = this.clock();
|
|
3691
|
+
if (!await this.store.tryLockRun(decision.runId, this.instanceId, nowMs + this.leaseMs, nowMs)) {
|
|
3692
|
+
return;
|
|
3693
|
+
}
|
|
3694
|
+
const renew = setInterval(() => {
|
|
3695
|
+
void this.store.renewRunLock(decision.runId, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
|
|
3696
|
+
}, Math.max(50, Math.floor(this.leaseMs / 2)));
|
|
3697
|
+
renew.unref?.();
|
|
3698
|
+
try {
|
|
3699
|
+
const fresh = await this.store.getRun(decision.runId);
|
|
3700
|
+
if (!fresh || fresh.awaitingDecisionTaskId !== decision.taskId) return;
|
|
3701
|
+
if (fresh.status === "cancelled" || fresh.status === "completed" || fresh.status === "dead") {
|
|
3702
|
+
return;
|
|
3703
|
+
}
|
|
3704
|
+
await this.store.updateRun(decision.runId, {
|
|
3705
|
+
awaitingDecisionTaskId: void 0,
|
|
3706
|
+
wakeAt: void 0,
|
|
3707
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
3708
|
+
});
|
|
3709
|
+
fresh.awaitingDecisionTaskId = void 0;
|
|
3710
|
+
await this.applyDecision(fresh, decision);
|
|
3711
|
+
} finally {
|
|
3712
|
+
clearInterval(renew);
|
|
3713
|
+
await this.store.releaseRunLock(decision.runId);
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
/**
|
|
3717
|
+
* A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
|
|
3718
|
+
* push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
|
|
3719
|
+
* re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
|
|
3720
|
+
*/
|
|
3721
|
+
async rearmDecisionDeadline(runId) {
|
|
3722
|
+
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
3723
|
+
if (silenceMs == null) return;
|
|
3724
|
+
const run = await this.store.getRun(runId);
|
|
3725
|
+
if (!run || run.status !== "suspended" || run.awaitingDecisionTaskId === void 0) return;
|
|
3726
|
+
await this.store.updateRun(runId, {
|
|
3727
|
+
wakeAt: this.clock() + silenceMs,
|
|
3728
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
3729
|
+
});
|
|
3730
|
+
}
|
|
3731
|
+
/**
|
|
3539
3732
|
* A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
|
|
3540
3733
|
* re-contend now instead of at their retry tick. Snapshot-and-clear — a run still blocked after the
|
|
3541
3734
|
* retry re-registers itself, and one that's gone (cancelled/admitted) is dropped. Best-effort.
|
|
@@ -3687,70 +3880,6 @@ function remoteStep(config) {
|
|
|
3687
3880
|
}
|
|
3688
3881
|
__name(remoteStep, "remoteStep");
|
|
3689
3882
|
|
|
3690
|
-
// src/remote-workflow-executor.ts
|
|
3691
|
-
var taskCounter = 0;
|
|
3692
|
-
var RemoteWorkflowExecutor = class {
|
|
3693
|
-
static {
|
|
3694
|
-
__name(this, "RemoteWorkflowExecutor");
|
|
3695
|
-
}
|
|
3696
|
-
transport;
|
|
3697
|
-
group;
|
|
3698
|
-
opts;
|
|
3699
|
-
pending = /* @__PURE__ */ new Map();
|
|
3700
|
-
subscribed = false;
|
|
3701
|
-
constructor(transport, group, opts = {}) {
|
|
3702
|
-
this.transport = transport;
|
|
3703
|
-
this.group = group;
|
|
3704
|
-
this.opts = opts;
|
|
3705
|
-
}
|
|
3706
|
-
ensureSubscribed() {
|
|
3707
|
-
if (this.subscribed) return;
|
|
3708
|
-
if (!this.transport.onDecision) {
|
|
3709
|
-
throw new Error("transport does not support workflow decisions (onDecision)");
|
|
3710
|
-
}
|
|
3711
|
-
this.subscribed = true;
|
|
3712
|
-
this.transport.onDecision(async (decision) => {
|
|
3713
|
-
const resolve = this.pending.get(decision.taskId);
|
|
3714
|
-
if (resolve) {
|
|
3715
|
-
this.pending.delete(decision.taskId);
|
|
3716
|
-
resolve(decision);
|
|
3717
|
-
}
|
|
3718
|
-
});
|
|
3719
|
-
}
|
|
3720
|
-
async advance(run, history) {
|
|
3721
|
-
if (!this.transport.dispatchWorkflowTask) {
|
|
3722
|
-
throw new Error("transport does not support workflow tasks (dispatchWorkflowTask)");
|
|
3723
|
-
}
|
|
3724
|
-
this.ensureSubscribed();
|
|
3725
|
-
taskCounter += 1;
|
|
3726
|
-
const taskId = `${run.id}:wf:${taskCounter}`;
|
|
3727
|
-
const task = {
|
|
3728
|
-
taskId,
|
|
3729
|
-
runId: run.id,
|
|
3730
|
-
workflow: run.workflow,
|
|
3731
|
-
workflowVersion: run.workflowVersion,
|
|
3732
|
-
input: run.input,
|
|
3733
|
-
history,
|
|
3734
|
-
group: this.group,
|
|
3735
|
-
priority: run.priority,
|
|
3736
|
-
attempt: 1
|
|
3737
|
-
};
|
|
3738
|
-
const decision = new Promise((resolve, reject) => {
|
|
3739
|
-
this.pending.set(taskId, resolve);
|
|
3740
|
-
const { timeoutMs } = this.opts;
|
|
3741
|
-
if (timeoutMs) {
|
|
3742
|
-
const timer = setTimeout(() => {
|
|
3743
|
-
this.pending.delete(taskId);
|
|
3744
|
-
reject(new RemoteWorkflowTimeout(taskId, timeoutMs));
|
|
3745
|
-
}, timeoutMs);
|
|
3746
|
-
timer.unref?.();
|
|
3747
|
-
}
|
|
3748
|
-
});
|
|
3749
|
-
await this.transport.dispatchWorkflowTask(task);
|
|
3750
|
-
return decision;
|
|
3751
|
-
}
|
|
3752
|
-
};
|
|
3753
|
-
|
|
3754
3883
|
// src/codec-state-store.ts
|
|
3755
3884
|
var CodecStateStore = class {
|
|
3756
3885
|
static {
|