@dudousxd/nestjs-durable-core 0.70.3 → 0.71.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.d.cts CHANGED
@@ -2326,6 +2326,15 @@ interface EngineEvent {
2326
2326
  /** The live step event carried by a `step.progress` (the single log line / sub-process outcome a
2327
2327
  * running step just emitted). Absent on lifecycle events. */
2328
2328
  event?: StepEvent | undefined;
2329
+ /**
2330
+ * On a `step.started`: this dispatch is a lost-dispatch RE-DRIVE, not a first attempt — the step's
2331
+ * lease lapsed with no result and no heartbeat (the worker holding it is presumed gone), so the
2332
+ * engine re-enqueued it (see `WorkflowEngineDeps.remoteRedispatchMs`). Absent on a normal dispatch
2333
+ * and on a failure retry, so a dashboard/alert can tell "re-driven after a lost worker" apart from
2334
+ * "retried after a failure". The step's persisted event trail carries the same fact durably
2335
+ * (`step.redispatched`).
2336
+ */
2337
+ redispatched?: boolean | undefined;
2329
2338
  /** Capabilities the blocked dispatch required (on a `run.blocked` event). */
2330
2339
  requires?: string[] | undefined;
2331
2340
  /** The structured routing delta on a `run.blocked` event (design §7.6): which capability/protocol
@@ -4022,10 +4031,78 @@ declare class WorkflowEngine {
4022
4031
  * Scheduled off the current stack so it runs after the caller released the run lease.
4023
4032
  */
4024
4033
  private redriveIfTurnSawStaleHistory;
4034
+ /**
4035
+ * A polyglot workflow's re-emitted `call` whose checkpoint already exists: decide whether the step
4036
+ * is still out for delivery, or whether its dispatch was LOST and has to be re-driven. Returns the
4037
+ * lease deadline to park the run on (so the next decision lands exactly when it's due), or
4038
+ * `undefined` when there is nothing to wait for.
4039
+ *
4040
+ * WHY THIS EXISTS. A worker process killed mid-step (OOM, exit 137 — a pod's memory ceiling, not a
4041
+ * bug in anyone's code) takes its in-flight job with it: BullMQ's stalled-check eventually fails the
4042
+ * job, and for a consumer that can't bridge a terminal job failure back into a `StepResult` (any
4043
+ * non-TS SDK) NOTHING settles the checkpoint. It stays `pending` — "out for delivery" — and the
4044
+ * `applyCommands` idempotency guard, which exists so a `ctx.gather_calls` fan-out doesn't
4045
+ * double-dispatch its still-in-flight siblings on every partial resume, skips it on every later turn.
4046
+ * The run then wakes on the reconcile sweep, dispatches a turn, has the call re-emitted, skips it
4047
+ * again, and sleeps. Forever. Observed in the wild on a six-run `gather_calls` fan-out: `pending`,
4048
+ * `attempts = 1`, `wakeAt` NULL, over an hour, no retry.
4049
+ *
4050
+ * THE POLICY (mirrors {@link callRemote}'s `ctx.step` self-heal, which this path silently did not
4051
+ * share — so `remoteRedispatchMs` simply did not apply to a fan-out):
4052
+ * - a SETTLED checkpoint always wins: a step that completed just before the crash is never re-run;
4053
+ * - `remoteRedispatchMs` unset (the default) keeps the by-design "re-suspend, never re-dispatch";
4054
+ * - otherwise the pending checkpoint carries a LEASE in `wakeAt` — a result or a step heartbeat
4055
+ * ({@link rearmStepLease}) renews it, so a live worker still holding the step is never
4056
+ * double-dispatched — and only a LAPSED lease re-dispatches, bounded by `remoteRedispatchMax`;
4057
+ * - past the bound the step is failed `remote_step_lost` and the failure enters the run's history,
4058
+ * so the workflow's own error path surfaces it instead of the engine looping.
4059
+ */
4060
+ private redriveLostCall;
4061
+ /**
4062
+ * The run `wakeAt` a step lease should park on: the lease itself, but never LATER than the
4063
+ * `reconcileMs` safety net would have woken the run anyway. A long lease (a fan-out step that
4064
+ * legitimately runs for half an hour) must not stretch the orphan sweep's cadence for this run —
4065
+ * an early wake just re-parks on the un-lapsed lease, which is cheap and convergent.
4066
+ */
4067
+ private stepLeaseWakeAt;
4068
+ /** Earliest clock time a step's lease is worth LOOKING at again (`stepId` → clock ms) — the read
4069
+ * throttle for {@link rearmStepLease}, the step-scoped sibling of {@link decisionDeadlineRearms}. */
4070
+ private readonly stepLeaseRearms;
4071
+ /**
4072
+ * A step-scoped heartbeat for a durably-suspended remote step: push its LEASE forward so a worker
4073
+ * that is still working on it is never presumed dead and double-dispatched. The durable counterpart
4074
+ * of {@link callRemoteInMemory}'s in-memory `heartbeatResets` rearm (which only exists on the
4075
+ * instance that dispatched a `timeoutMs` step), and the step-scoped sibling of
4076
+ * {@link rearmDecisionDeadline}. No-op unless the lost-dispatch self-heal is on — without
4077
+ * `remoteRedispatchMs` nothing ever re-dispatches, so there is no lease to defend.
4078
+ *
4079
+ * THROTTLED BY THE LEASE ITSELF, not a fixed interval: a renewal buys at most ONE window, so it is
4080
+ * only WORTH a write once the window is half spent. Beats arrive every couple of seconds from a hot
4081
+ * worker loop — and a `gather_calls` fan-out multiplies that by its in-flight steps — so an
4082
+ * unthrottled rearm is a per-beat SELECT + UPDATE storm (plus a `getRun` + `updateRun` whenever the
4083
+ * run is parked exactly on this step's lease). The `cp.wakeAt >= renewed` guard it replaces never
4084
+ * short-circuited anything, because `renewed` advances with the clock. So:
4085
+ * - a beat that finds more than half the window still on the clock writes nothing, and records the
4086
+ * half-spent mark so subsequent beats skip even the `getCheckpoint` READ until then;
4087
+ * - a beat at or past the mark renews, leaving a full half-window of headroom — the lease can never
4088
+ * lapse under a worker whose beats are anywhere near their normal cadence.
4089
+ *
4090
+ * That bounds the cost at ONE read + ONE write per half `remoteRedispatchMs` per in-flight step,
4091
+ * independent of beat frequency (with a 30-minute window: two writes an hour). The in-memory mark is
4092
+ * a pure optimisation — cold on a fresh instance, or on the one that didn't dispatch the step — and
4093
+ * the durable half-spent check below re-derives the same answer from the checkpoint, so nothing
4094
+ * depends on it surviving. Cleared wholesale past 1024 entries: a rare extra read, never a leak.
4095
+ */
4096
+ private rearmStepLease;
4025
4097
  /** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
4026
4098
  private remoteHistory;
4027
4099
  /** Apply a turn's commands: persist recorded local steps, dispatch remote calls, schedule timers.
4028
- * Returns the earliest timer deadline to suspend on (or undefined — suspended on a result). */
4100
+ * Returns the earliest timer deadline to suspend on (or undefined — suspended on a result).
4101
+ *
4102
+ * `redriveLost` is set only by the `continue` path: a turn that PARKS on its blocking ops is the
4103
+ * one that may find a re-emitted `call` whose dispatch was lost and needs re-driving (see
4104
+ * {@link redriveLostCall}). A terminal turn (completed/failed/cancelled) applies its commands only
4105
+ * to record what already ran, and must never dispatch anything new. */
4029
4106
  private applyCommands;
4030
4107
  /**
4031
4108
  * Undo the registered saga compensations in reverse and settle the run `cancelled`. Shared by EVERY
@@ -4196,10 +4273,21 @@ declare class WorkflowEngine {
4196
4273
  * decision is dropped (applied AT MOST ONCE) — lease it, clear the marker, and apply the decision.
4197
4274
  */
4198
4275
  private completeRemoteDecision;
4276
+ /** Earliest clock time a suspended turn's deadline is worth LOOKING at again (`runId` → clock ms) —
4277
+ * the read throttle for {@link rearmDecisionDeadline}, the run-scoped sibling of
4278
+ * {@link stepLeaseRearms}. */
4279
+ private readonly decisionDeadlineRearms;
4199
4280
  /**
4200
4281
  * A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
4201
4282
  * push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
4202
4283
  * re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
4284
+ *
4285
+ * THROTTLED exactly as {@link rearmStepLease} is, and for the same reason: a long workflow turn's
4286
+ * worker beats every couple of seconds, and each beat otherwise cost a `getRun` + `updateRun`.
4287
+ * The window here is `remoteAdvanceSilenceMs`, and a renewal buys at most one of them — so it is
4288
+ * only worth a write once the window is half spent, which leaves a full half-window of headroom
4289
+ * before the timer poller would re-drive the turn. The in-memory mark is a pure optimisation: a
4290
+ * cold (or different) instance re-derives the same answer from the run's own `wakeAt`.
4203
4291
  */
4204
4292
  private rearmDecisionDeadline;
4205
4293
  /**
package/dist/index.d.ts CHANGED
@@ -2326,6 +2326,15 @@ interface EngineEvent {
2326
2326
  /** The live step event carried by a `step.progress` (the single log line / sub-process outcome a
2327
2327
  * running step just emitted). Absent on lifecycle events. */
2328
2328
  event?: StepEvent | undefined;
2329
+ /**
2330
+ * On a `step.started`: this dispatch is a lost-dispatch RE-DRIVE, not a first attempt — the step's
2331
+ * lease lapsed with no result and no heartbeat (the worker holding it is presumed gone), so the
2332
+ * engine re-enqueued it (see `WorkflowEngineDeps.remoteRedispatchMs`). Absent on a normal dispatch
2333
+ * and on a failure retry, so a dashboard/alert can tell "re-driven after a lost worker" apart from
2334
+ * "retried after a failure". The step's persisted event trail carries the same fact durably
2335
+ * (`step.redispatched`).
2336
+ */
2337
+ redispatched?: boolean | undefined;
2329
2338
  /** Capabilities the blocked dispatch required (on a `run.blocked` event). */
2330
2339
  requires?: string[] | undefined;
2331
2340
  /** The structured routing delta on a `run.blocked` event (design §7.6): which capability/protocol
@@ -4022,10 +4031,78 @@ declare class WorkflowEngine {
4022
4031
  * Scheduled off the current stack so it runs after the caller released the run lease.
4023
4032
  */
4024
4033
  private redriveIfTurnSawStaleHistory;
4034
+ /**
4035
+ * A polyglot workflow's re-emitted `call` whose checkpoint already exists: decide whether the step
4036
+ * is still out for delivery, or whether its dispatch was LOST and has to be re-driven. Returns the
4037
+ * lease deadline to park the run on (so the next decision lands exactly when it's due), or
4038
+ * `undefined` when there is nothing to wait for.
4039
+ *
4040
+ * WHY THIS EXISTS. A worker process killed mid-step (OOM, exit 137 — a pod's memory ceiling, not a
4041
+ * bug in anyone's code) takes its in-flight job with it: BullMQ's stalled-check eventually fails the
4042
+ * job, and for a consumer that can't bridge a terminal job failure back into a `StepResult` (any
4043
+ * non-TS SDK) NOTHING settles the checkpoint. It stays `pending` — "out for delivery" — and the
4044
+ * `applyCommands` idempotency guard, which exists so a `ctx.gather_calls` fan-out doesn't
4045
+ * double-dispatch its still-in-flight siblings on every partial resume, skips it on every later turn.
4046
+ * The run then wakes on the reconcile sweep, dispatches a turn, has the call re-emitted, skips it
4047
+ * again, and sleeps. Forever. Observed in the wild on a six-run `gather_calls` fan-out: `pending`,
4048
+ * `attempts = 1`, `wakeAt` NULL, over an hour, no retry.
4049
+ *
4050
+ * THE POLICY (mirrors {@link callRemote}'s `ctx.step` self-heal, which this path silently did not
4051
+ * share — so `remoteRedispatchMs` simply did not apply to a fan-out):
4052
+ * - a SETTLED checkpoint always wins: a step that completed just before the crash is never re-run;
4053
+ * - `remoteRedispatchMs` unset (the default) keeps the by-design "re-suspend, never re-dispatch";
4054
+ * - otherwise the pending checkpoint carries a LEASE in `wakeAt` — a result or a step heartbeat
4055
+ * ({@link rearmStepLease}) renews it, so a live worker still holding the step is never
4056
+ * double-dispatched — and only a LAPSED lease re-dispatches, bounded by `remoteRedispatchMax`;
4057
+ * - past the bound the step is failed `remote_step_lost` and the failure enters the run's history,
4058
+ * so the workflow's own error path surfaces it instead of the engine looping.
4059
+ */
4060
+ private redriveLostCall;
4061
+ /**
4062
+ * The run `wakeAt` a step lease should park on: the lease itself, but never LATER than the
4063
+ * `reconcileMs` safety net would have woken the run anyway. A long lease (a fan-out step that
4064
+ * legitimately runs for half an hour) must not stretch the orphan sweep's cadence for this run —
4065
+ * an early wake just re-parks on the un-lapsed lease, which is cheap and convergent.
4066
+ */
4067
+ private stepLeaseWakeAt;
4068
+ /** Earliest clock time a step's lease is worth LOOKING at again (`stepId` → clock ms) — the read
4069
+ * throttle for {@link rearmStepLease}, the step-scoped sibling of {@link decisionDeadlineRearms}. */
4070
+ private readonly stepLeaseRearms;
4071
+ /**
4072
+ * A step-scoped heartbeat for a durably-suspended remote step: push its LEASE forward so a worker
4073
+ * that is still working on it is never presumed dead and double-dispatched. The durable counterpart
4074
+ * of {@link callRemoteInMemory}'s in-memory `heartbeatResets` rearm (which only exists on the
4075
+ * instance that dispatched a `timeoutMs` step), and the step-scoped sibling of
4076
+ * {@link rearmDecisionDeadline}. No-op unless the lost-dispatch self-heal is on — without
4077
+ * `remoteRedispatchMs` nothing ever re-dispatches, so there is no lease to defend.
4078
+ *
4079
+ * THROTTLED BY THE LEASE ITSELF, not a fixed interval: a renewal buys at most ONE window, so it is
4080
+ * only WORTH a write once the window is half spent. Beats arrive every couple of seconds from a hot
4081
+ * worker loop — and a `gather_calls` fan-out multiplies that by its in-flight steps — so an
4082
+ * unthrottled rearm is a per-beat SELECT + UPDATE storm (plus a `getRun` + `updateRun` whenever the
4083
+ * run is parked exactly on this step's lease). The `cp.wakeAt >= renewed` guard it replaces never
4084
+ * short-circuited anything, because `renewed` advances with the clock. So:
4085
+ * - a beat that finds more than half the window still on the clock writes nothing, and records the
4086
+ * half-spent mark so subsequent beats skip even the `getCheckpoint` READ until then;
4087
+ * - a beat at or past the mark renews, leaving a full half-window of headroom — the lease can never
4088
+ * lapse under a worker whose beats are anywhere near their normal cadence.
4089
+ *
4090
+ * That bounds the cost at ONE read + ONE write per half `remoteRedispatchMs` per in-flight step,
4091
+ * independent of beat frequency (with a 30-minute window: two writes an hour). The in-memory mark is
4092
+ * a pure optimisation — cold on a fresh instance, or on the one that didn't dispatch the step — and
4093
+ * the durable half-spent check below re-derives the same answer from the checkpoint, so nothing
4094
+ * depends on it surviving. Cleared wholesale past 1024 entries: a rare extra read, never a leak.
4095
+ */
4096
+ private rearmStepLease;
4025
4097
  /** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
4026
4098
  private remoteHistory;
4027
4099
  /** Apply a turn's commands: persist recorded local steps, dispatch remote calls, schedule timers.
4028
- * Returns the earliest timer deadline to suspend on (or undefined — suspended on a result). */
4100
+ * Returns the earliest timer deadline to suspend on (or undefined — suspended on a result).
4101
+ *
4102
+ * `redriveLost` is set only by the `continue` path: a turn that PARKS on its blocking ops is the
4103
+ * one that may find a re-emitted `call` whose dispatch was lost and needs re-driving (see
4104
+ * {@link redriveLostCall}). A terminal turn (completed/failed/cancelled) applies its commands only
4105
+ * to record what already ran, and must never dispatch anything new. */
4029
4106
  private applyCommands;
4030
4107
  /**
4031
4108
  * Undo the registered saga compensations in reverse and settle the run `cancelled`. Shared by EVERY
@@ -4196,10 +4273,21 @@ declare class WorkflowEngine {
4196
4273
  * decision is dropped (applied AT MOST ONCE) — lease it, clear the marker, and apply the decision.
4197
4274
  */
4198
4275
  private completeRemoteDecision;
4276
+ /** Earliest clock time a suspended turn's deadline is worth LOOKING at again (`runId` → clock ms) —
4277
+ * the read throttle for {@link rearmDecisionDeadline}, the run-scoped sibling of
4278
+ * {@link stepLeaseRearms}. */
4279
+ private readonly decisionDeadlineRearms;
4199
4280
  /**
4200
4281
  * A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
4201
4282
  * push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
4202
4283
  * re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
4284
+ *
4285
+ * THROTTLED exactly as {@link rearmStepLease} is, and for the same reason: a long workflow turn's
4286
+ * worker beats every couple of seconds, and each beat otherwise cost a `getRun` + `updateRun`.
4287
+ * The window here is `remoteAdvanceSilenceMs`, and a renewal buys at most one of them — so it is
4288
+ * only worth a write once the window is half spent, which leaves a full half-window of headroom
4289
+ * before the timer poller would re-drive the turn. The in-memory mark is a pure optimisation: a
4290
+ * cold (or different) instance re-derives the same answer from the run's own `wakeAt`.
4203
4291
  */
4204
4292
  private rearmDecisionDeadline;
4205
4293
  /**