@dudousxd/nestjs-durable-core 0.70.2 → 0.71.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
@@ -847,6 +847,17 @@ async function runStepHandler(task, handler) {
847
847
  __name(runStepHandler, "runStepHandler");
848
848
 
849
849
  // src/checkpoints.ts
850
+ var REDISPATCHED_STEP_EVENT = "step.redispatched";
851
+ var LOST_STEP_EVENT = "step.lost";
852
+ function lostStepEvent(at, attempts, outcome) {
853
+ return {
854
+ at,
855
+ level: "warn",
856
+ name: outcome === "lost" ? LOST_STEP_EVENT : REDISPATCHED_STEP_EVENT,
857
+ message: outcome === "lost" ? `dispatch lost and the re-drive bound is spent after ${attempts} dispatch(es) \u2014 failing the step` : `re-dispatched after a lost dispatch (attempt ${attempts}): the step's lease lapsed with no result and no heartbeat`
858
+ };
859
+ }
860
+ __name(lostStepEvent, "lostStepEvent");
850
861
  function nonEmptyEvents(events) {
851
862
  return events && events.length > 0 ? events : void 0;
852
863
  }
@@ -2987,16 +2998,21 @@ var WorkflowEngine = class {
2987
2998
  },
2988
2999
  // A heartbeat resets the liveness window for whatever it targets: an in-flight long STEP (keyed
2989
3000
  // by stepId, see callRemote) or — when stepId is absent — the in-flight workflow TURN (keyed by
2990
- // runId). For an INLINE-advance turn the reset is an in-memory callback; for a DISPATCH-suspend
2991
- // turn there is no in-memory waiter, so a run-scoped beat rearms the suspended turn's durable
2992
- // re-drive deadline (`wakeAt`) instead, keeping a live worker's turn from being re-dispatched.
3001
+ // runId). For an INLINE-advance turn the reset is an in-memory callback; without one (the step
3002
+ // or turn suspended durably, possibly on another instance) the beat rearms the DURABLE deadline
3003
+ // instead: the suspended turn's `wakeAt` for a run-scoped beat, the step's lease for a
3004
+ // step-scoped one — either way keeping a live worker's work from being presumed lost.
2993
3005
  async (beat) => {
2994
3006
  const reset = this.heartbeatResets.get(beat.stepId ?? beat.runId);
2995
3007
  if (reset) {
2996
3008
  reset();
2997
3009
  return;
2998
3010
  }
2999
- if (beat.stepId === void 0) await this.rearmDecisionDeadline(beat.runId);
3011
+ if (beat.stepId === void 0) {
3012
+ await this.rearmDecisionDeadline(beat.runId);
3013
+ return;
3014
+ }
3015
+ await this.rearmStepLease(beat.runId, beat.seq);
3000
3016
  },
3001
3017
  // A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
3002
3018
  // it's checkpointed live, not all-at-once when the long turn ends.
@@ -4976,7 +4992,7 @@ var WorkflowEngine = class {
4976
4992
  error: fresh.error
4977
4993
  };
4978
4994
  }
4979
- const wakeAt = await this.applyCommands(run, decision.commands);
4995
+ const wakeAt = await this.applyCommands(run, decision.commands, true);
4980
4996
  const suspended = await this.settleRun(run, {
4981
4997
  kind: "suspended",
4982
4998
  wakeAt
@@ -5014,6 +5030,182 @@ var WorkflowEngine = class {
5014
5030
  }
5015
5031
  }
5016
5032
  }
5033
+ /**
5034
+ * A polyglot workflow's re-emitted `call` whose checkpoint already exists: decide whether the step
5035
+ * is still out for delivery, or whether its dispatch was LOST and has to be re-driven. Returns the
5036
+ * lease deadline to park the run on (so the next decision lands exactly when it's due), or
5037
+ * `undefined` when there is nothing to wait for.
5038
+ *
5039
+ * WHY THIS EXISTS. A worker process killed mid-step (OOM, exit 137 — a pod's memory ceiling, not a
5040
+ * bug in anyone's code) takes its in-flight job with it: BullMQ's stalled-check eventually fails the
5041
+ * job, and for a consumer that can't bridge a terminal job failure back into a `StepResult` (any
5042
+ * non-TS SDK) NOTHING settles the checkpoint. It stays `pending` — "out for delivery" — and the
5043
+ * `applyCommands` idempotency guard, which exists so a `ctx.gather_calls` fan-out doesn't
5044
+ * double-dispatch its still-in-flight siblings on every partial resume, skips it on every later turn.
5045
+ * The run then wakes on the reconcile sweep, dispatches a turn, has the call re-emitted, skips it
5046
+ * again, and sleeps. Forever. Observed in the wild on a six-run `gather_calls` fan-out: `pending`,
5047
+ * `attempts = 1`, `wakeAt` NULL, over an hour, no retry.
5048
+ *
5049
+ * THE POLICY (mirrors {@link callRemote}'s `ctx.step` self-heal, which this path silently did not
5050
+ * share — so `remoteRedispatchMs` simply did not apply to a fan-out):
5051
+ * - a SETTLED checkpoint always wins: a step that completed just before the crash is never re-run;
5052
+ * - `remoteRedispatchMs` unset (the default) keeps the by-design "re-suspend, never re-dispatch";
5053
+ * - otherwise the pending checkpoint carries a LEASE in `wakeAt` — a result or a step heartbeat
5054
+ * ({@link rearmStepLease}) renews it, so a live worker still holding the step is never
5055
+ * double-dispatched — and only a LAPSED lease re-dispatches, bounded by `remoteRedispatchMax`;
5056
+ * - past the bound the step is failed `remote_step_lost` and the failure enters the run's history,
5057
+ * so the workflow's own error path surfaces it instead of the engine looping.
5058
+ */
5059
+ async redriveLostCall(run, cp) {
5060
+ if (cp.kind !== "remote" || cp.status !== "pending") return void 0;
5061
+ if (this.remoteRedispatchMs == null) return void 0;
5062
+ if (cp.wakeAt == null) {
5063
+ const lease2 = this.clock() + this.remoteRedispatchMs;
5064
+ await this.store.saveCheckpoint({
5065
+ ...cp,
5066
+ wakeAt: lease2
5067
+ });
5068
+ return this.stepLeaseWakeAt(lease2);
5069
+ }
5070
+ if (this.clock() < cp.wakeAt) return this.stepLeaseWakeAt(cp.wakeAt);
5071
+ if (cp.attempts >= this.remoteRedispatchMax) {
5072
+ const error2 = {
5073
+ message: `remote step "${cp.name}" lost \u2014 no result after ${cp.attempts} dispatch(es)`,
5074
+ code: "remote_step_lost",
5075
+ retryable: false
5076
+ };
5077
+ const now = /* @__PURE__ */ new Date();
5078
+ await this.store.saveCheckpoint({
5079
+ ...cp,
5080
+ status: "failed",
5081
+ error: error2,
5082
+ wakeAt: void 0,
5083
+ finishedAt: now,
5084
+ events: [
5085
+ ...cp.events ?? [],
5086
+ lostStepEvent(this.clock(), cp.attempts, "lost")
5087
+ ]
5088
+ });
5089
+ this.emit({
5090
+ type: "step.failed",
5091
+ runId: run.id,
5092
+ seq: cp.seq,
5093
+ name: cp.name,
5094
+ kind: "remote",
5095
+ error: error2
5096
+ });
5097
+ return void 0;
5098
+ }
5099
+ const attempts = cp.attempts + 1;
5100
+ const lease = this.clock() + this.remoteRedispatchMs;
5101
+ const at = /* @__PURE__ */ new Date();
5102
+ await this.store.saveCheckpoint({
5103
+ ...cp,
5104
+ attempts,
5105
+ wakeAt: lease,
5106
+ // The re-dispatch restarts the step's wall-clock window; the old one measured a worker that died.
5107
+ enqueuedAt: at,
5108
+ startedAt: at,
5109
+ finishedAt: at,
5110
+ // Observability: the step's own trail says WHY it ran again — a lost worker, not a failure retry.
5111
+ events: [
5112
+ ...cp.events ?? [],
5113
+ lostStepEvent(this.clock(), attempts, "redispatched")
5114
+ ]
5115
+ });
5116
+ await this.dispatchRemoteTask({
5117
+ runId: run.id,
5118
+ seq: cp.seq,
5119
+ name: cp.name,
5120
+ stepId: cp.stepId ?? stepId(run.id, cp.seq),
5121
+ group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
5122
+ input: cp.input,
5123
+ attempt: attempts
5124
+ });
5125
+ this.emit({
5126
+ type: "step.started",
5127
+ runId: run.id,
5128
+ seq: cp.seq,
5129
+ name: cp.name,
5130
+ kind: "remote",
5131
+ redispatched: true
5132
+ });
5133
+ return this.stepLeaseWakeAt(lease);
5134
+ }
5135
+ /**
5136
+ * The run `wakeAt` a step lease should park on: the lease itself, but never LATER than the
5137
+ * `reconcileMs` safety net would have woken the run anyway. A long lease (a fan-out step that
5138
+ * legitimately runs for half an hour) must not stretch the orphan sweep's cadence for this run —
5139
+ * an early wake just re-parks on the un-lapsed lease, which is cheap and convergent.
5140
+ */
5141
+ stepLeaseWakeAt(lease) {
5142
+ const reconcile = this.reconcileMs;
5143
+ return reconcile == null ? lease : Math.min(lease, this.clock() + reconcile);
5144
+ }
5145
+ /** Earliest clock time a step's lease is worth LOOKING at again (`stepId` → clock ms) — the read
5146
+ * throttle for {@link rearmStepLease}, the step-scoped sibling of {@link decisionDeadlineRearms}. */
5147
+ stepLeaseRearms = /* @__PURE__ */ new Map();
5148
+ /**
5149
+ * A step-scoped heartbeat for a durably-suspended remote step: push its LEASE forward so a worker
5150
+ * that is still working on it is never presumed dead and double-dispatched. The durable counterpart
5151
+ * of {@link callRemoteInMemory}'s in-memory `heartbeatResets` rearm (which only exists on the
5152
+ * instance that dispatched a `timeoutMs` step), and the step-scoped sibling of
5153
+ * {@link rearmDecisionDeadline}. No-op unless the lost-dispatch self-heal is on — without
5154
+ * `remoteRedispatchMs` nothing ever re-dispatches, so there is no lease to defend.
5155
+ *
5156
+ * THROTTLED BY THE LEASE ITSELF, not a fixed interval: a renewal buys at most ONE window, so it is
5157
+ * only WORTH a write once the window is half spent. Beats arrive every couple of seconds from a hot
5158
+ * worker loop — and a `gather_calls` fan-out multiplies that by its in-flight steps — so an
5159
+ * unthrottled rearm is a per-beat SELECT + UPDATE storm (plus a `getRun` + `updateRun` whenever the
5160
+ * run is parked exactly on this step's lease). The `cp.wakeAt >= renewed` guard it replaces never
5161
+ * short-circuited anything, because `renewed` advances with the clock. So:
5162
+ * - a beat that finds more than half the window still on the clock writes nothing, and records the
5163
+ * half-spent mark so subsequent beats skip even the `getCheckpoint` READ until then;
5164
+ * - a beat at or past the mark renews, leaving a full half-window of headroom — the lease can never
5165
+ * lapse under a worker whose beats are anywhere near their normal cadence.
5166
+ *
5167
+ * That bounds the cost at ONE read + ONE write per half `remoteRedispatchMs` per in-flight step,
5168
+ * independent of beat frequency (with a 30-minute window: two writes an hour). The in-memory mark is
5169
+ * a pure optimisation — cold on a fresh instance, or on the one that didn't dispatch the step — and
5170
+ * the durable half-spent check below re-derives the same answer from the checkpoint, so nothing
5171
+ * depends on it surviving. Cleared wholesale past 1024 entries: a rare extra read, never a leak.
5172
+ */
5173
+ async rearmStepLease(runId, seq) {
5174
+ if (this.remoteRedispatchMs == null) return;
5175
+ const id = stepId(runId, seq);
5176
+ const now = this.clock();
5177
+ const lookAgainAt = this.stepLeaseRearms.get(id);
5178
+ if (lookAgainAt !== void 0 && now < lookAgainAt) return;
5179
+ try {
5180
+ const cp = await this.store.getCheckpoint(runId, seq);
5181
+ if (!cp || cp.kind !== "remote" || cp.status !== "pending") {
5182
+ this.stepLeaseRearms.delete(id);
5183
+ return;
5184
+ }
5185
+ const halfWindow = Math.ceil(this.remoteRedispatchMs / 2);
5186
+ if (cp.wakeAt != null && cp.wakeAt > now + halfWindow) {
5187
+ this.stepLeaseRearms.set(id, cp.wakeAt - halfWindow);
5188
+ return;
5189
+ }
5190
+ const renewed = now + this.remoteRedispatchMs;
5191
+ const previous = cp.wakeAt;
5192
+ await this.store.saveCheckpoint({
5193
+ ...cp,
5194
+ wakeAt: renewed
5195
+ });
5196
+ if (this.stepLeaseRearms.size > 1024) this.stepLeaseRearms.clear();
5197
+ this.stepLeaseRearms.set(id, renewed - halfWindow);
5198
+ if (previous == null) return;
5199
+ const run = await this.store.getRun(runId);
5200
+ if (run?.status === "suspended" && run.wakeAt === previous) {
5201
+ await this.store.updateRun(runId, {
5202
+ wakeAt: renewed,
5203
+ updatedAt: /* @__PURE__ */ new Date()
5204
+ });
5205
+ }
5206
+ } catch {
5207
+ }
5208
+ }
5017
5209
  /** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
5018
5210
  async remoteHistory(runId) {
5019
5211
  const checkpoints = await this.store.listCheckpoints(runId);
@@ -5058,8 +5250,13 @@ var WorkflowEngine = class {
5058
5250
  return events.sort((a, b) => a.seq - b.seq);
5059
5251
  }
5060
5252
  /** Apply a turn's commands: persist recorded local steps, dispatch remote calls, schedule timers.
5061
- * Returns the earliest timer deadline to suspend on (or undefined — suspended on a result). */
5062
- async applyCommands(run, commands) {
5253
+ * Returns the earliest timer deadline to suspend on (or undefined — suspended on a result).
5254
+ *
5255
+ * `redriveLost` is set only by the `continue` path: a turn that PARKS on its blocking ops is the
5256
+ * one that may find a re-emitted `call` whose dispatch was lost and needs re-driving (see
5257
+ * {@link redriveLostCall}). A terminal turn (completed/failed/cancelled) applies its commands only
5258
+ * to record what already ran, and must never dispatch anything new. */
5259
+ async applyCommands(run, commands, redriveLost = false) {
5063
5260
  let wakeAt;
5064
5261
  for (const cmd of commands) {
5065
5262
  const at = /* @__PURE__ */ new Date();
@@ -5095,7 +5292,13 @@ var WorkflowEngine = class {
5095
5292
  error: cmd.error
5096
5293
  });
5097
5294
  } else if (cmd.kind === "call") {
5098
- if (await this.store.getCheckpoint(run.id, cmd.seq)) continue;
5295
+ const existing = await this.store.getCheckpoint(run.id, cmd.seq);
5296
+ if (existing) {
5297
+ const leaseAt = redriveLost ? await this.redriveLostCall(run, existing) : void 0;
5298
+ if (leaseAt != null) wakeAt = wakeAt == null ? leaseAt : Math.min(wakeAt, leaseAt);
5299
+ continue;
5300
+ }
5301
+ const lease = this.remoteRedispatchMs == null ? void 0 : this.clock() + this.remoteRedispatchMs;
5099
5302
  await this.store.saveCheckpoint(stepCheckpoint({
5100
5303
  runId: run.id,
5101
5304
  seq: cmd.seq,
@@ -5104,6 +5307,7 @@ var WorkflowEngine = class {
5104
5307
  status: "pending",
5105
5308
  input: cmd.input,
5106
5309
  attempts: 1,
5310
+ wakeAt: lease,
5107
5311
  // `cmd.group` is the routing declaration the worker's replay emitted (cross-SDK, or a
5108
5312
  // group-served TS body) — treated as an isolation partition on top of the step's own
5109
5313
  // name, mirroring the StepDef formula below (identical expression everywhere).
@@ -5134,6 +5338,10 @@ var WorkflowEngine = class {
5134
5338
  name: cmd.name,
5135
5339
  kind: "remote"
5136
5340
  });
5341
+ if (lease != null) {
5342
+ const leaseWake = this.stepLeaseWakeAt(lease);
5343
+ wakeAt = wakeAt == null ? leaseWake : Math.min(wakeAt, leaseWake);
5344
+ }
5137
5345
  } else if (cmd.kind === "sleep") {
5138
5346
  const deadline = this.clock() + cmd.ms;
5139
5347
  await this.store.saveCheckpoint(stepCheckpoint({
@@ -5822,7 +6030,13 @@ var WorkflowEngine = class {
5822
6030
  wakeAt: nextDeadline,
5823
6031
  enqueuedAt: reEnqueuedAt,
5824
6032
  startedAt: reEnqueuedAt,
5825
- finishedAt: reEnqueuedAt
6033
+ finishedAt: reEnqueuedAt,
6034
+ // Same observable marker the polyglot `call` path stamps (see redriveLostCall): the step's own
6035
+ // trail distinguishes a lost-dispatch re-drive from a failure retry.
6036
+ events: [
6037
+ ...existing.events ?? [],
6038
+ lostStepEvent(this.clock(), reAttempt, "redispatched")
6039
+ ]
5826
6040
  });
5827
6041
  await this.dispatchRemoteTask({
5828
6042
  runId,
@@ -5835,6 +6049,14 @@ var WorkflowEngine = class {
5835
6049
  attempt: reAttempt,
5836
6050
  transport
5837
6051
  });
6052
+ this.emit({
6053
+ type: "step.started",
6054
+ runId,
6055
+ seq,
6056
+ name: step.name,
6057
+ kind: "remote",
6058
+ redispatched: true
6059
+ });
5838
6060
  throw new WorkflowSuspended(nextDeadline);
5839
6061
  }
5840
6062
  let attempt = 1;
@@ -6001,20 +6223,48 @@ var WorkflowEngine = class {
6001
6223
  await this.store.releaseRunLock(decision.runId);
6002
6224
  }
6003
6225
  }
6226
+ /** Earliest clock time a suspended turn's deadline is worth LOOKING at again (`runId` → clock ms) —
6227
+ * the read throttle for {@link rearmDecisionDeadline}, the run-scoped sibling of
6228
+ * {@link stepLeaseRearms}. */
6229
+ decisionDeadlineRearms = /* @__PURE__ */ new Map();
6004
6230
  /**
6005
6231
  * A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
6006
6232
  * push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
6007
6233
  * re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
6234
+ *
6235
+ * THROTTLED exactly as {@link rearmStepLease} is, and for the same reason: a long workflow turn's
6236
+ * worker beats every couple of seconds, and each beat otherwise cost a `getRun` + `updateRun`.
6237
+ * The window here is `remoteAdvanceSilenceMs`, and a renewal buys at most one of them — so it is
6238
+ * only worth a write once the window is half spent, which leaves a full half-window of headroom
6239
+ * before the timer poller would re-drive the turn. The in-memory mark is a pure optimisation: a
6240
+ * cold (or different) instance re-derives the same answer from the run's own `wakeAt`.
6008
6241
  */
6009
6242
  async rearmDecisionDeadline(runId) {
6010
6243
  const silenceMs = this.remoteAdvanceSilenceMs;
6011
6244
  if (silenceMs == null) return;
6012
- const run = await this.store.getRun(runId);
6013
- if (!run || run.status !== "suspended" || run.awaitingDecisionTaskId === void 0) return;
6014
- await this.store.updateRun(runId, {
6015
- wakeAt: this.clock() + silenceMs,
6016
- updatedAt: /* @__PURE__ */ new Date()
6017
- });
6245
+ const now = this.clock();
6246
+ const lookAgainAt = this.decisionDeadlineRearms.get(runId);
6247
+ if (lookAgainAt !== void 0 && now < lookAgainAt) return;
6248
+ try {
6249
+ const run = await this.store.getRun(runId);
6250
+ if (!run || run.status !== "suspended" || run.awaitingDecisionTaskId === void 0) {
6251
+ this.decisionDeadlineRearms.delete(runId);
6252
+ return;
6253
+ }
6254
+ const halfWindow = Math.ceil(silenceMs / 2);
6255
+ if (run.wakeAt != null && run.wakeAt > now + halfWindow) {
6256
+ this.decisionDeadlineRearms.set(runId, run.wakeAt - halfWindow);
6257
+ return;
6258
+ }
6259
+ const renewed = now + silenceMs;
6260
+ await this.store.updateRun(runId, {
6261
+ wakeAt: renewed,
6262
+ updatedAt: /* @__PURE__ */ new Date()
6263
+ });
6264
+ if (this.decisionDeadlineRearms.size > 1024) this.decisionDeadlineRearms.clear();
6265
+ this.decisionDeadlineRearms.set(runId, renewed - halfWindow);
6266
+ } catch {
6267
+ }
6018
6268
  }
6019
6269
  /**
6020
6270
  * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they