@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 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
- opts;
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
- async advance(run, history) {
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, see runRemoteExecution's heartbeat-rearmed advance).
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((s) => s !== "pending" && s !== "running" && s !== "cancelling", "isSettled");
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.status)) finish(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 wakeAt2 = this.singletons.retryWakeAt();
2978
+ const wakeAt = this.singletons.retryWakeAt();
2990
2979
  await this.store.updateRun(run.id, {
2991
2980
  status: "suspended",
2992
- wakeAt: wakeAt2,
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.advance(run, history);
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.