@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.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, see runRemoteExecution's heartbeat-rearmed advance).
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((s) => s !== "pending" && s !== "running" && s !== "cancelling", "isSettled");
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.status)) finish(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 wakeAt2 = this.singletons.retryWakeAt();
2912
+ const wakeAt = this.singletons.retryWakeAt();
2819
2913
  await this.store.updateRun(run.id, {
2820
2914
  status: "suspended",
2821
- wakeAt: wakeAt2,
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.advance(run, history);
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 {