@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 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, see runRemoteExecution's heartbeat-rearmed advance).
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((s) => s !== "pending" && s !== "running" && s !== "cancelling", "isSettled");
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.status)) finish(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 wakeAt2 = this.singletons.retryWakeAt();
2984
+ const wakeAt = this.singletons.retryWakeAt();
2891
2985
  await this.store.updateRun(run.id, {
2892
2986
  status: "suspended",
2893
- wakeAt: wakeAt2,
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.advance(run, history);
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 {