@dudousxd/nestjs-durable-core 0.43.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
@@ -780,33 +780,18 @@ var RemoteWorkflowExecutor = class {
780
780
  }
781
781
  transport;
782
782
  group;
783
- opts;
784
- pending = /* @__PURE__ */ new Map();
785
- subscribed = false;
786
- constructor(transport, group, opts = {}) {
783
+ constructor(transport, group, _opts = {}) {
787
784
  this.transport = transport;
788
785
  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
786
  }
805
- async advance(run, history) {
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) {
806
792
  if (!this.transport.dispatchWorkflowTask) {
807
793
  throw new Error("transport does not support workflow tasks (dispatchWorkflowTask)");
808
794
  }
809
- this.ensureSubscribed();
810
795
  taskCounter += 1;
811
796
  const taskId = `${run.id}:wf:${taskCounter}`;
812
797
  const task = {
@@ -816,23 +801,17 @@ var RemoteWorkflowExecutor = class {
816
801
  workflowVersion: run.workflowVersion,
817
802
  input: run.input,
818
803
  history,
804
+ ...pendingSignals ? {
805
+ pendingSignals
806
+ } : {},
819
807
  group: this.group,
820
808
  priority: run.priority,
821
809
  attempt: 1
822
810
  };
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
811
  await this.transport.dispatchWorkflowTask(task);
835
- return decision;
812
+ return {
813
+ taskId
814
+ };
836
815
  }
837
816
  };
838
817
 
@@ -953,13 +932,16 @@ var TransportPool = class {
953
932
  /** Register the engine's result/heartbeat/step-event handlers on EVERY transport — a result can come
954
933
  * back on whichever one delivered the task, so failover stays symmetric. `onStepEvent` is wired only
955
934
  * on transports that carry streamed workflow step lifecycle (BullMQ); others simply skip it. */
956
- bind(onResult, onHeartbeat, onStepEvent) {
935
+ bind(onResult, onHeartbeat, onStepEvent, onDecision) {
957
936
  for (const { transport } of this.transports) {
958
937
  transport.onResult(onResult);
959
938
  transport.onHeartbeat(onHeartbeat);
960
939
  if (onStepEvent && transport.onStepEvent) {
961
940
  transport.onStepEvent(onStepEvent);
962
941
  }
942
+ if (onDecision && transport.onDecision) {
943
+ transport.onDecision(onDecision);
944
+ }
963
945
  }
964
946
  }
965
947
  /**
@@ -1717,14 +1699,27 @@ var WorkflowEngine = class {
1717
1699
  },
1718
1700
  // A heartbeat resets the liveness window for whatever it targets: an in-flight long STEP (keyed
1719
1701
  // by stepId, see callRemote) or — when stepId is absent — the in-flight workflow TURN (keyed by
1720
- // 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.
1721
1705
  async (beat) => {
1722
- 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);
1723
1712
  },
1724
1713
  // A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
1725
1714
  // it's checkpointed live, not all-at-once when the long turn ends.
1726
1715
  async (event) => {
1727
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);
1728
1723
  }
1729
1724
  );
1730
1725
  this.controlPlane?.onControl((msg) => {
@@ -2353,7 +2348,7 @@ var WorkflowEngine = class {
2353
2348
  * `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
2354
2349
  */
2355
2350
  waitForRun(runId, opts) {
2356
- 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");
2357
2352
  const toResult = /* @__PURE__ */ __name((run) => ({
2358
2353
  runId,
2359
2354
  status: run.status,
@@ -2379,7 +2374,7 @@ var WorkflowEngine = class {
2379
2374
  }, "finish");
2380
2375
  const check = /* @__PURE__ */ __name(() => {
2381
2376
  void this.store.getRun(runId).then((run) => {
2382
- if (run && isSettled(run.status)) finish(run);
2377
+ if (run && isSettled(run)) finish(run);
2383
2378
  });
2384
2379
  }, "check");
2385
2380
  off = this.subscribe((ev) => {
@@ -2986,10 +2981,10 @@ var WorkflowEngine = class {
2986
2981
  });
2987
2982
  }
2988
2983
  if (registered.singleton && !await this.singletons.admit(run, registered.singleton)) {
2989
- const wakeAt2 = this.singletons.retryWakeAt();
2984
+ const wakeAt = this.singletons.retryWakeAt();
2990
2985
  await this.store.updateRun(run.id, {
2991
2986
  status: "suspended",
2992
- wakeAt: wakeAt2,
2987
+ wakeAt,
2993
2988
  updatedAt: /* @__PURE__ */ new Date()
2994
2989
  });
2995
2990
  this.emit({
@@ -3004,10 +2999,45 @@ var WorkflowEngine = class {
3004
2999
  };
3005
3000
  }
3006
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
+ }
3007
3037
  let decision;
3008
3038
  try {
3009
3039
  const silenceMs = this.remoteAdvanceSilenceMs;
3010
- const advance = remote.executor.advance(run, history);
3040
+ const advance = advanceExecutor.call(remote.executor, run, history);
3011
3041
  decision = silenceMs != null ? await this.awaitWithLivenessDeadline(run.id, advance, silenceMs, () => new RemoteWorkflowTimeout(run.id, silenceMs)) : await advance;
3012
3042
  } catch (err) {
3013
3043
  if (err instanceof RemoteWorkflowTimeout) {
@@ -3038,6 +3068,16 @@ var WorkflowEngine = class {
3038
3068
  error
3039
3069
  };
3040
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) {
3041
3081
  if (decision.status === "completed") {
3042
3082
  await this.applyCommands(run, decision.commands);
3043
3083
  return this.settleRun(run, {
@@ -3635,6 +3675,60 @@ var WorkflowEngine = class {
3635
3675
  });
3636
3676
  }
3637
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
+ /**
3638
3732
  * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
3639
3733
  * re-contend now instead of at their retry tick. Snapshot-and-clear — a run still blocked after the
3640
3734
  * retry re-registers itself, and one that's gone (cancelled/admitted) is dropped. Best-effort.