@dudousxd/nestjs-durable-core 0.49.3 → 0.49.4

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.d.cts CHANGED
@@ -730,6 +730,9 @@ type RunRequestKind = {
730
730
  kind: 'retryWithInput';
731
731
  runId: string;
732
732
  input: unknown;
733
+ } | {
734
+ kind: 'redispatch';
735
+ runId: string;
733
736
  };
734
737
  /** The control plane's answer to a {@link RunRequest}, correlated by `requestId`. */
735
738
  interface RunReply {
@@ -1818,6 +1821,27 @@ interface WorkflowEngineDeps {
1818
1821
  * for a genuinely-orphaned run. Default 5min; set `0` to disable (the prior wake-forever behavior).
1819
1822
  */
1820
1823
  reconcileMs?: number | undefined;
1824
+ /**
1825
+ * Opt-in self-heal window (ms) for a remote step with NO `timeoutMs` whose dispatched job was LOST —
1826
+ * the worker crashed mid-step leaving no result, or the transport dropped the job (a Redis
1827
+ * flush/eviction, or BullMQ moving a stalled job to `failed` and removing it). The `pending`
1828
+ * checkpoint then has no result to resume on, and — BY DESIGN (see {@link reconcileMs}) — the
1829
+ * reconcile re-drive re-suspends a still-pending step rather than re-dispatching, so a slow-but-live
1830
+ * worker is never double-run. That safety means a genuinely-lost dispatch would otherwise hang
1831
+ * forever. When set, a reconcile re-drive that finds a remote step still `pending` PAST this window
1832
+ * re-dispatches it (bumping `attempts`, bounded by {@link remoteRedispatchMax}). Off by default:
1833
+ * re-dispatch can double-run a step whose original job is merely slow, so the window MUST exceed the
1834
+ * longest such step and the step MUST be idempotent. Prefer a per-step `timeoutMs` (tighter,
1835
+ * heartbeat-aware) where you can; this is the store-driven net for no-timeout steps that must
1836
+ * survive a lost dispatch. {@link WorkflowEngine.redispatchPending} is the manual counterpart.
1837
+ */
1838
+ remoteRedispatchMs?: number | undefined;
1839
+ /**
1840
+ * Max times {@link remoteRedispatchMs} re-dispatches one lost remote step before giving up and
1841
+ * failing it as a {@link RemoteStepError} (`code: 'remote_step_lost'`), so the run fails / dead-letters
1842
+ * instead of re-dispatching forever. Default 10. Ignored when `remoteRedispatchMs` is unset.
1843
+ */
1844
+ remoteRedispatchMax?: number | undefined;
1821
1845
  /**
1822
1846
  * Build the public callback URL for a `ctx.webhook()` token (e.g.
1823
1847
  * ``(t) => `https://api.example.com/durable/webhooks/${t}` ``). Populates
@@ -1894,6 +1918,8 @@ declare class WorkflowEngine {
1894
1918
  private readonly maxRecoveryAttempts?;
1895
1919
  private readonly remoteAdvanceSilenceMs?;
1896
1920
  private readonly reconcileMs;
1921
+ private readonly remoteRedispatchMs;
1922
+ private readonly remoteRedispatchMax;
1897
1923
  private readonly webhookUrl?;
1898
1924
  private readonly traceparent?;
1899
1925
  private readonly context?;
@@ -2186,6 +2212,19 @@ declare class WorkflowEngine {
2186
2212
  * the HTTP request on workflow execution.
2187
2213
  */
2188
2214
  requeue(runId: string): Promise<RunResult | null>;
2215
+ /**
2216
+ * Explicitly re-dispatch every remote step of `runId` stuck `pending` — the OPERATOR escape hatch for
2217
+ * a run whose dispatched step job was LOST (worker crashed with no result, or a transport that dropped
2218
+ * the job). No automatic recovery re-drives these: a reconcile re-drive re-suspends a still-pending
2219
+ * step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete` only reclaims LEASED runs,
2220
+ * and `requeue` just replays back to the pending guard. This re-enqueues the same `stepId` so the
2221
+ * (idempotent) step re-runs and its result resumes the run. Safe to call on a healthy run — it re-runs
2222
+ * only in-flight `pending` remote steps, which are idempotent by the durable contract. Returns the
2223
+ * run's current status (with the count re-dispatched), or null if the run is unknown.
2224
+ */
2225
+ redispatchPending(runId: string): Promise<(RunResult & {
2226
+ redispatched: number;
2227
+ }) | null>;
2189
2228
  /**
2190
2229
  * **Fix-and-replay**: re-run a run (typically a `dead`/`failed` one) with a corrected `input`, as a
2191
2230
  * fresh run with clean history. It's a NEW run — `newRunId` defaults to `<runId>~retry~<uuid>` — so
@@ -2488,6 +2527,10 @@ declare class WorkflowEngine {
2488
2527
  */
2489
2528
  update(runId: string, name: string, arg: unknown): Promise<UpdateResult>;
2490
2529
  private callRemote;
2530
+ /** Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
2531
+ * dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
2532
+ * {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable. */
2533
+ private dispatchRemoteTask;
2491
2534
  /**
2492
2535
  * Complete a durable remote step from its worker result and resume the run — runs on whichever
2493
2536
  * instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.
@@ -2820,6 +2863,12 @@ interface RunGateway {
2820
2863
  }): Promise<RunResult | null>;
2821
2864
  retry(runId: string): Promise<RunResult | null>;
2822
2865
  continue(runId: string): Promise<RunResult | null>;
2866
+ /** Re-dispatch every remote step of a run stuck `pending` — the operator recovery for a LOST step
2867
+ * dispatch (crashed worker / dropped job) that no automatic path re-drives. Returns the run's status
2868
+ * plus how many steps were re-dispatched, or null if the run is unknown. */
2869
+ redispatchPending(runId: string): Promise<(RunResult & {
2870
+ redispatched: number;
2871
+ }) | null>;
2823
2872
  retryWithInput(runId: string, input: unknown): Promise<{
2824
2873
  runId: string;
2825
2874
  } | null>;
package/dist/index.d.ts CHANGED
@@ -730,6 +730,9 @@ type RunRequestKind = {
730
730
  kind: 'retryWithInput';
731
731
  runId: string;
732
732
  input: unknown;
733
+ } | {
734
+ kind: 'redispatch';
735
+ runId: string;
733
736
  };
734
737
  /** The control plane's answer to a {@link RunRequest}, correlated by `requestId`. */
735
738
  interface RunReply {
@@ -1818,6 +1821,27 @@ interface WorkflowEngineDeps {
1818
1821
  * for a genuinely-orphaned run. Default 5min; set `0` to disable (the prior wake-forever behavior).
1819
1822
  */
1820
1823
  reconcileMs?: number | undefined;
1824
+ /**
1825
+ * Opt-in self-heal window (ms) for a remote step with NO `timeoutMs` whose dispatched job was LOST —
1826
+ * the worker crashed mid-step leaving no result, or the transport dropped the job (a Redis
1827
+ * flush/eviction, or BullMQ moving a stalled job to `failed` and removing it). The `pending`
1828
+ * checkpoint then has no result to resume on, and — BY DESIGN (see {@link reconcileMs}) — the
1829
+ * reconcile re-drive re-suspends a still-pending step rather than re-dispatching, so a slow-but-live
1830
+ * worker is never double-run. That safety means a genuinely-lost dispatch would otherwise hang
1831
+ * forever. When set, a reconcile re-drive that finds a remote step still `pending` PAST this window
1832
+ * re-dispatches it (bumping `attempts`, bounded by {@link remoteRedispatchMax}). Off by default:
1833
+ * re-dispatch can double-run a step whose original job is merely slow, so the window MUST exceed the
1834
+ * longest such step and the step MUST be idempotent. Prefer a per-step `timeoutMs` (tighter,
1835
+ * heartbeat-aware) where you can; this is the store-driven net for no-timeout steps that must
1836
+ * survive a lost dispatch. {@link WorkflowEngine.redispatchPending} is the manual counterpart.
1837
+ */
1838
+ remoteRedispatchMs?: number | undefined;
1839
+ /**
1840
+ * Max times {@link remoteRedispatchMs} re-dispatches one lost remote step before giving up and
1841
+ * failing it as a {@link RemoteStepError} (`code: 'remote_step_lost'`), so the run fails / dead-letters
1842
+ * instead of re-dispatching forever. Default 10. Ignored when `remoteRedispatchMs` is unset.
1843
+ */
1844
+ remoteRedispatchMax?: number | undefined;
1821
1845
  /**
1822
1846
  * Build the public callback URL for a `ctx.webhook()` token (e.g.
1823
1847
  * ``(t) => `https://api.example.com/durable/webhooks/${t}` ``). Populates
@@ -1894,6 +1918,8 @@ declare class WorkflowEngine {
1894
1918
  private readonly maxRecoveryAttempts?;
1895
1919
  private readonly remoteAdvanceSilenceMs?;
1896
1920
  private readonly reconcileMs;
1921
+ private readonly remoteRedispatchMs;
1922
+ private readonly remoteRedispatchMax;
1897
1923
  private readonly webhookUrl?;
1898
1924
  private readonly traceparent?;
1899
1925
  private readonly context?;
@@ -2186,6 +2212,19 @@ declare class WorkflowEngine {
2186
2212
  * the HTTP request on workflow execution.
2187
2213
  */
2188
2214
  requeue(runId: string): Promise<RunResult | null>;
2215
+ /**
2216
+ * Explicitly re-dispatch every remote step of `runId` stuck `pending` — the OPERATOR escape hatch for
2217
+ * a run whose dispatched step job was LOST (worker crashed with no result, or a transport that dropped
2218
+ * the job). No automatic recovery re-drives these: a reconcile re-drive re-suspends a still-pending
2219
+ * step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete` only reclaims LEASED runs,
2220
+ * and `requeue` just replays back to the pending guard. This re-enqueues the same `stepId` so the
2221
+ * (idempotent) step re-runs and its result resumes the run. Safe to call on a healthy run — it re-runs
2222
+ * only in-flight `pending` remote steps, which are idempotent by the durable contract. Returns the
2223
+ * run's current status (with the count re-dispatched), or null if the run is unknown.
2224
+ */
2225
+ redispatchPending(runId: string): Promise<(RunResult & {
2226
+ redispatched: number;
2227
+ }) | null>;
2189
2228
  /**
2190
2229
  * **Fix-and-replay**: re-run a run (typically a `dead`/`failed` one) with a corrected `input`, as a
2191
2230
  * fresh run with clean history. It's a NEW run — `newRunId` defaults to `<runId>~retry~<uuid>` — so
@@ -2488,6 +2527,10 @@ declare class WorkflowEngine {
2488
2527
  */
2489
2528
  update(runId: string, name: string, arg: unknown): Promise<UpdateResult>;
2490
2529
  private callRemote;
2530
+ /** Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
2531
+ * dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
2532
+ * {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable. */
2533
+ private dispatchRemoteTask;
2491
2534
  /**
2492
2535
  * Complete a durable remote step from its worker result and resume the run — runs on whichever
2493
2536
  * instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.
@@ -2820,6 +2863,12 @@ interface RunGateway {
2820
2863
  }): Promise<RunResult | null>;
2821
2864
  retry(runId: string): Promise<RunResult | null>;
2822
2865
  continue(runId: string): Promise<RunResult | null>;
2866
+ /** Re-dispatch every remote step of a run stuck `pending` — the operator recovery for a LOST step
2867
+ * dispatch (crashed worker / dropped job) that no automatic path re-drives. Returns the run's status
2868
+ * plus how many steps were re-dispatched, or null if the run is unknown. */
2869
+ redispatchPending(runId: string): Promise<(RunResult & {
2870
+ redispatched: number;
2871
+ }) | null>;
2823
2872
  retryWithInput(runId: string, input: unknown): Promise<{
2824
2873
  runId: string;
2825
2874
  } | null>;
package/dist/index.js CHANGED
@@ -1729,6 +1729,8 @@ var WorkflowEngine = class {
1729
1729
  maxRecoveryAttempts;
1730
1730
  remoteAdvanceSilenceMs;
1731
1731
  reconcileMs;
1732
+ remoteRedispatchMs;
1733
+ remoteRedispatchMax;
1732
1734
  webhookUrl;
1733
1735
  traceparent;
1734
1736
  context;
@@ -1800,6 +1802,8 @@ var WorkflowEngine = class {
1800
1802
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
1801
1803
  this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
1802
1804
  this.reconcileMs = deps.reconcileMs === void 0 ? 3e5 : deps.reconcileMs || void 0;
1805
+ this.remoteRedispatchMs = deps.remoteRedispatchMs || void 0;
1806
+ this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
1803
1807
  this.webhookUrl = deps.webhookUrl;
1804
1808
  this.traceparent = deps.traceparent;
1805
1809
  this.context = deps.context;
@@ -2511,6 +2515,49 @@ var WorkflowEngine = class {
2511
2515
  };
2512
2516
  }
2513
2517
  /**
2518
+ * Explicitly re-dispatch every remote step of `runId` stuck `pending` — the OPERATOR escape hatch for
2519
+ * a run whose dispatched step job was LOST (worker crashed with no result, or a transport that dropped
2520
+ * the job). No automatic recovery re-drives these: a reconcile re-drive re-suspends a still-pending
2521
+ * step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete` only reclaims LEASED runs,
2522
+ * and `requeue` just replays back to the pending guard. This re-enqueues the same `stepId` so the
2523
+ * (idempotent) step re-runs and its result resumes the run. Safe to call on a healthy run — it re-runs
2524
+ * only in-flight `pending` remote steps, which are idempotent by the durable contract. Returns the
2525
+ * run's current status (with the count re-dispatched), or null if the run is unknown.
2526
+ */
2527
+ async redispatchPending(runId) {
2528
+ const run = await this.store.getRun(runId);
2529
+ if (!run) return null;
2530
+ const pending = (await this.store.listCheckpoints(runId)).filter((cp) => cp.kind === "remote" && cp.status === "pending");
2531
+ let redispatched = 0;
2532
+ for (const cp of pending) {
2533
+ if (!cp.stepId) continue;
2534
+ const reAttempt = cp.attempts + 1;
2535
+ const reEnqueuedAt = /* @__PURE__ */ new Date();
2536
+ await this.store.saveCheckpoint({
2537
+ ...cp,
2538
+ attempts: reAttempt,
2539
+ enqueuedAt: reEnqueuedAt,
2540
+ startedAt: reEnqueuedAt,
2541
+ finishedAt: reEnqueuedAt
2542
+ });
2543
+ await this.dispatchRemoteTask({
2544
+ runId,
2545
+ seq: cp.seq,
2546
+ name: cp.name,
2547
+ stepId: cp.stepId,
2548
+ group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
2549
+ input: cp.input,
2550
+ attempt: reAttempt
2551
+ });
2552
+ redispatched += 1;
2553
+ }
2554
+ return {
2555
+ runId,
2556
+ status: run.status,
2557
+ redispatched
2558
+ };
2559
+ }
2560
+ /**
2514
2561
  * **Fix-and-replay**: re-run a run (typically a `dead`/`failed` one) with a corrected `input`, as a
2515
2562
  * fresh run with clean history. It's a NEW run — `newRunId` defaults to `<runId>~retry~<uuid>` — so
2516
2563
  * the original stays inspectable. Returns the new run's id, or null if `runId` is unknown.
@@ -3998,7 +4045,47 @@ var WorkflowEngine = class {
3998
4045
  if (existing?.status === "completed") return existing.output;
3999
4046
  if (this.pool.size === 0) throw new Error("remote steps require a Transport");
4000
4047
  if (step.timeoutMs) return this.callRemoteInMemory(runId, seq, step, input, transport);
4001
- if (existing?.status === "pending") throw new WorkflowSuspended();
4048
+ if (existing?.status === "pending") {
4049
+ if (this.remoteRedispatchMs == null) throw new WorkflowSuspended();
4050
+ if (existing.wakeAt == null) {
4051
+ const wakeAt = this.clock() + this.remoteRedispatchMs;
4052
+ await this.store.saveCheckpoint({
4053
+ ...existing,
4054
+ wakeAt
4055
+ });
4056
+ throw new WorkflowSuspended(wakeAt);
4057
+ }
4058
+ if (this.clock() < existing.wakeAt) throw new WorkflowSuspended(existing.wakeAt);
4059
+ if (existing.attempts >= this.remoteRedispatchMax) {
4060
+ throw new RemoteStepError({
4061
+ message: `remote step "${step.name}" lost \u2014 no result after ${existing.attempts} re-dispatch(es)`,
4062
+ code: "remote_step_lost"
4063
+ });
4064
+ }
4065
+ const reAttempt = existing.attempts + 1;
4066
+ const nextDeadline = this.clock() + this.remoteRedispatchMs;
4067
+ const reEnqueuedAt = /* @__PURE__ */ new Date();
4068
+ await this.store.saveCheckpoint({
4069
+ ...existing,
4070
+ attempts: reAttempt,
4071
+ wakeAt: nextDeadline,
4072
+ enqueuedAt: reEnqueuedAt,
4073
+ startedAt: reEnqueuedAt,
4074
+ finishedAt: reEnqueuedAt
4075
+ });
4076
+ await this.dispatchRemoteTask({
4077
+ runId,
4078
+ seq,
4079
+ name: step.name,
4080
+ stepId: existing.stepId ?? stepId(runId, seq),
4081
+ group: existing.workerGroup ?? tenantGroup(sanitizeQueueToken(step.name), step.partition),
4082
+ input: existing.input,
4083
+ priority: admission?.priority,
4084
+ attempt: reAttempt,
4085
+ transport
4086
+ });
4087
+ throw new WorkflowSuspended(nextDeadline);
4088
+ }
4002
4089
  let attempt = 1;
4003
4090
  if (existing?.status === "failed") {
4004
4091
  const maxAttempts = Math.max(1, step.retries ?? 1);
@@ -4046,18 +4133,17 @@ var WorkflowEngine = class {
4046
4133
  startedAt: enqueuedAt,
4047
4134
  finishedAt: enqueuedAt
4048
4135
  });
4049
- await this.pool.dispatch({
4136
+ await this.dispatchRemoteTask({
4050
4137
  runId,
4051
4138
  seq,
4052
4139
  name: step.name,
4053
4140
  stepId: id,
4054
4141
  group: tenantGroup(sanitizeQueueToken(step.name), step.partition),
4055
4142
  input,
4056
- traceparent: this.traceparent?.(),
4057
- context: this.context?.(),
4058
4143
  priority: admission?.priority,
4059
- attempt
4060
- }, transport);
4144
+ attempt,
4145
+ transport
4146
+ });
4061
4147
  this.emit({
4062
4148
  type: "step.started",
4063
4149
  runId,
@@ -4067,6 +4153,23 @@ var WorkflowEngine = class {
4067
4153
  });
4068
4154
  throw new WorkflowSuspended();
4069
4155
  }
4156
+ /** Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
4157
+ * dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
4158
+ * {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable. */
4159
+ dispatchRemoteTask(task) {
4160
+ return this.pool.dispatch({
4161
+ runId: task.runId,
4162
+ seq: task.seq,
4163
+ name: task.name,
4164
+ stepId: task.stepId,
4165
+ group: task.group,
4166
+ input: task.input,
4167
+ traceparent: this.traceparent?.(),
4168
+ context: this.context?.(),
4169
+ priority: task.priority,
4170
+ attempt: task.attempt
4171
+ }, task.transport);
4172
+ }
4070
4173
  /**
4071
4174
  * Complete a durable remote step from its worker result and resume the run — runs on whichever
4072
4175
  * instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.