@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 +265 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +89 -1
- package/dist/index.d.ts +89 -1
- package/dist/index.js +265 -15
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
/**
|
package/dist/index.js
CHANGED
|
@@ -706,6 +706,17 @@ async function runStepHandler(task, handler) {
|
|
|
706
706
|
__name(runStepHandler, "runStepHandler");
|
|
707
707
|
|
|
708
708
|
// src/checkpoints.ts
|
|
709
|
+
var REDISPATCHED_STEP_EVENT = "step.redispatched";
|
|
710
|
+
var LOST_STEP_EVENT = "step.lost";
|
|
711
|
+
function lostStepEvent(at, attempts, outcome) {
|
|
712
|
+
return {
|
|
713
|
+
at,
|
|
714
|
+
level: "warn",
|
|
715
|
+
name: outcome === "lost" ? LOST_STEP_EVENT : REDISPATCHED_STEP_EVENT,
|
|
716
|
+
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`
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
__name(lostStepEvent, "lostStepEvent");
|
|
709
720
|
function nonEmptyEvents(events) {
|
|
710
721
|
return events && events.length > 0 ? events : void 0;
|
|
711
722
|
}
|
|
@@ -2846,16 +2857,21 @@ var WorkflowEngine = class {
|
|
|
2846
2857
|
},
|
|
2847
2858
|
// A heartbeat resets the liveness window for whatever it targets: an in-flight long STEP (keyed
|
|
2848
2859
|
// by stepId, see callRemote) or — when stepId is absent — the in-flight workflow TURN (keyed by
|
|
2849
|
-
// runId). For an INLINE-advance turn the reset is an in-memory callback;
|
|
2850
|
-
// turn
|
|
2851
|
-
//
|
|
2860
|
+
// runId). For an INLINE-advance turn the reset is an in-memory callback; without one (the step
|
|
2861
|
+
// or turn suspended durably, possibly on another instance) the beat rearms the DURABLE deadline
|
|
2862
|
+
// instead: the suspended turn's `wakeAt` for a run-scoped beat, the step's lease for a
|
|
2863
|
+
// step-scoped one — either way keeping a live worker's work from being presumed lost.
|
|
2852
2864
|
async (beat) => {
|
|
2853
2865
|
const reset = this.heartbeatResets.get(beat.stepId ?? beat.runId);
|
|
2854
2866
|
if (reset) {
|
|
2855
2867
|
reset();
|
|
2856
2868
|
return;
|
|
2857
2869
|
}
|
|
2858
|
-
if (beat.stepId === void 0)
|
|
2870
|
+
if (beat.stepId === void 0) {
|
|
2871
|
+
await this.rearmDecisionDeadline(beat.runId);
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
2874
|
+
await this.rearmStepLease(beat.runId, beat.seq);
|
|
2859
2875
|
},
|
|
2860
2876
|
// A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
|
|
2861
2877
|
// it's checkpointed live, not all-at-once when the long turn ends.
|
|
@@ -4835,7 +4851,7 @@ var WorkflowEngine = class {
|
|
|
4835
4851
|
error: fresh.error
|
|
4836
4852
|
};
|
|
4837
4853
|
}
|
|
4838
|
-
const wakeAt = await this.applyCommands(run, decision.commands);
|
|
4854
|
+
const wakeAt = await this.applyCommands(run, decision.commands, true);
|
|
4839
4855
|
const suspended = await this.settleRun(run, {
|
|
4840
4856
|
kind: "suspended",
|
|
4841
4857
|
wakeAt
|
|
@@ -4873,6 +4889,182 @@ var WorkflowEngine = class {
|
|
|
4873
4889
|
}
|
|
4874
4890
|
}
|
|
4875
4891
|
}
|
|
4892
|
+
/**
|
|
4893
|
+
* A polyglot workflow's re-emitted `call` whose checkpoint already exists: decide whether the step
|
|
4894
|
+
* is still out for delivery, or whether its dispatch was LOST and has to be re-driven. Returns the
|
|
4895
|
+
* lease deadline to park the run on (so the next decision lands exactly when it's due), or
|
|
4896
|
+
* `undefined` when there is nothing to wait for.
|
|
4897
|
+
*
|
|
4898
|
+
* WHY THIS EXISTS. A worker process killed mid-step (OOM, exit 137 — a pod's memory ceiling, not a
|
|
4899
|
+
* bug in anyone's code) takes its in-flight job with it: BullMQ's stalled-check eventually fails the
|
|
4900
|
+
* job, and for a consumer that can't bridge a terminal job failure back into a `StepResult` (any
|
|
4901
|
+
* non-TS SDK) NOTHING settles the checkpoint. It stays `pending` — "out for delivery" — and the
|
|
4902
|
+
* `applyCommands` idempotency guard, which exists so a `ctx.gather_calls` fan-out doesn't
|
|
4903
|
+
* double-dispatch its still-in-flight siblings on every partial resume, skips it on every later turn.
|
|
4904
|
+
* The run then wakes on the reconcile sweep, dispatches a turn, has the call re-emitted, skips it
|
|
4905
|
+
* again, and sleeps. Forever. Observed in the wild on a six-run `gather_calls` fan-out: `pending`,
|
|
4906
|
+
* `attempts = 1`, `wakeAt` NULL, over an hour, no retry.
|
|
4907
|
+
*
|
|
4908
|
+
* THE POLICY (mirrors {@link callRemote}'s `ctx.step` self-heal, which this path silently did not
|
|
4909
|
+
* share — so `remoteRedispatchMs` simply did not apply to a fan-out):
|
|
4910
|
+
* - a SETTLED checkpoint always wins: a step that completed just before the crash is never re-run;
|
|
4911
|
+
* - `remoteRedispatchMs` unset (the default) keeps the by-design "re-suspend, never re-dispatch";
|
|
4912
|
+
* - otherwise the pending checkpoint carries a LEASE in `wakeAt` — a result or a step heartbeat
|
|
4913
|
+
* ({@link rearmStepLease}) renews it, so a live worker still holding the step is never
|
|
4914
|
+
* double-dispatched — and only a LAPSED lease re-dispatches, bounded by `remoteRedispatchMax`;
|
|
4915
|
+
* - past the bound the step is failed `remote_step_lost` and the failure enters the run's history,
|
|
4916
|
+
* so the workflow's own error path surfaces it instead of the engine looping.
|
|
4917
|
+
*/
|
|
4918
|
+
async redriveLostCall(run, cp) {
|
|
4919
|
+
if (cp.kind !== "remote" || cp.status !== "pending") return void 0;
|
|
4920
|
+
if (this.remoteRedispatchMs == null) return void 0;
|
|
4921
|
+
if (cp.wakeAt == null) {
|
|
4922
|
+
const lease2 = this.clock() + this.remoteRedispatchMs;
|
|
4923
|
+
await this.store.saveCheckpoint({
|
|
4924
|
+
...cp,
|
|
4925
|
+
wakeAt: lease2
|
|
4926
|
+
});
|
|
4927
|
+
return this.stepLeaseWakeAt(lease2);
|
|
4928
|
+
}
|
|
4929
|
+
if (this.clock() < cp.wakeAt) return this.stepLeaseWakeAt(cp.wakeAt);
|
|
4930
|
+
if (cp.attempts >= this.remoteRedispatchMax) {
|
|
4931
|
+
const error2 = {
|
|
4932
|
+
message: `remote step "${cp.name}" lost \u2014 no result after ${cp.attempts} dispatch(es)`,
|
|
4933
|
+
code: "remote_step_lost",
|
|
4934
|
+
retryable: false
|
|
4935
|
+
};
|
|
4936
|
+
const now = /* @__PURE__ */ new Date();
|
|
4937
|
+
await this.store.saveCheckpoint({
|
|
4938
|
+
...cp,
|
|
4939
|
+
status: "failed",
|
|
4940
|
+
error: error2,
|
|
4941
|
+
wakeAt: void 0,
|
|
4942
|
+
finishedAt: now,
|
|
4943
|
+
events: [
|
|
4944
|
+
...cp.events ?? [],
|
|
4945
|
+
lostStepEvent(this.clock(), cp.attempts, "lost")
|
|
4946
|
+
]
|
|
4947
|
+
});
|
|
4948
|
+
this.emit({
|
|
4949
|
+
type: "step.failed",
|
|
4950
|
+
runId: run.id,
|
|
4951
|
+
seq: cp.seq,
|
|
4952
|
+
name: cp.name,
|
|
4953
|
+
kind: "remote",
|
|
4954
|
+
error: error2
|
|
4955
|
+
});
|
|
4956
|
+
return void 0;
|
|
4957
|
+
}
|
|
4958
|
+
const attempts = cp.attempts + 1;
|
|
4959
|
+
const lease = this.clock() + this.remoteRedispatchMs;
|
|
4960
|
+
const at = /* @__PURE__ */ new Date();
|
|
4961
|
+
await this.store.saveCheckpoint({
|
|
4962
|
+
...cp,
|
|
4963
|
+
attempts,
|
|
4964
|
+
wakeAt: lease,
|
|
4965
|
+
// The re-dispatch restarts the step's wall-clock window; the old one measured a worker that died.
|
|
4966
|
+
enqueuedAt: at,
|
|
4967
|
+
startedAt: at,
|
|
4968
|
+
finishedAt: at,
|
|
4969
|
+
// Observability: the step's own trail says WHY it ran again — a lost worker, not a failure retry.
|
|
4970
|
+
events: [
|
|
4971
|
+
...cp.events ?? [],
|
|
4972
|
+
lostStepEvent(this.clock(), attempts, "redispatched")
|
|
4973
|
+
]
|
|
4974
|
+
});
|
|
4975
|
+
await this.dispatchRemoteTask({
|
|
4976
|
+
runId: run.id,
|
|
4977
|
+
seq: cp.seq,
|
|
4978
|
+
name: cp.name,
|
|
4979
|
+
stepId: cp.stepId ?? stepId(run.id, cp.seq),
|
|
4980
|
+
group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
|
|
4981
|
+
input: cp.input,
|
|
4982
|
+
attempt: attempts
|
|
4983
|
+
});
|
|
4984
|
+
this.emit({
|
|
4985
|
+
type: "step.started",
|
|
4986
|
+
runId: run.id,
|
|
4987
|
+
seq: cp.seq,
|
|
4988
|
+
name: cp.name,
|
|
4989
|
+
kind: "remote",
|
|
4990
|
+
redispatched: true
|
|
4991
|
+
});
|
|
4992
|
+
return this.stepLeaseWakeAt(lease);
|
|
4993
|
+
}
|
|
4994
|
+
/**
|
|
4995
|
+
* The run `wakeAt` a step lease should park on: the lease itself, but never LATER than the
|
|
4996
|
+
* `reconcileMs` safety net would have woken the run anyway. A long lease (a fan-out step that
|
|
4997
|
+
* legitimately runs for half an hour) must not stretch the orphan sweep's cadence for this run —
|
|
4998
|
+
* an early wake just re-parks on the un-lapsed lease, which is cheap and convergent.
|
|
4999
|
+
*/
|
|
5000
|
+
stepLeaseWakeAt(lease) {
|
|
5001
|
+
const reconcile = this.reconcileMs;
|
|
5002
|
+
return reconcile == null ? lease : Math.min(lease, this.clock() + reconcile);
|
|
5003
|
+
}
|
|
5004
|
+
/** Earliest clock time a step's lease is worth LOOKING at again (`stepId` → clock ms) — the read
|
|
5005
|
+
* throttle for {@link rearmStepLease}, the step-scoped sibling of {@link decisionDeadlineRearms}. */
|
|
5006
|
+
stepLeaseRearms = /* @__PURE__ */ new Map();
|
|
5007
|
+
/**
|
|
5008
|
+
* A step-scoped heartbeat for a durably-suspended remote step: push its LEASE forward so a worker
|
|
5009
|
+
* that is still working on it is never presumed dead and double-dispatched. The durable counterpart
|
|
5010
|
+
* of {@link callRemoteInMemory}'s in-memory `heartbeatResets` rearm (which only exists on the
|
|
5011
|
+
* instance that dispatched a `timeoutMs` step), and the step-scoped sibling of
|
|
5012
|
+
* {@link rearmDecisionDeadline}. No-op unless the lost-dispatch self-heal is on — without
|
|
5013
|
+
* `remoteRedispatchMs` nothing ever re-dispatches, so there is no lease to defend.
|
|
5014
|
+
*
|
|
5015
|
+
* THROTTLED BY THE LEASE ITSELF, not a fixed interval: a renewal buys at most ONE window, so it is
|
|
5016
|
+
* only WORTH a write once the window is half spent. Beats arrive every couple of seconds from a hot
|
|
5017
|
+
* worker loop — and a `gather_calls` fan-out multiplies that by its in-flight steps — so an
|
|
5018
|
+
* unthrottled rearm is a per-beat SELECT + UPDATE storm (plus a `getRun` + `updateRun` whenever the
|
|
5019
|
+
* run is parked exactly on this step's lease). The `cp.wakeAt >= renewed` guard it replaces never
|
|
5020
|
+
* short-circuited anything, because `renewed` advances with the clock. So:
|
|
5021
|
+
* - a beat that finds more than half the window still on the clock writes nothing, and records the
|
|
5022
|
+
* half-spent mark so subsequent beats skip even the `getCheckpoint` READ until then;
|
|
5023
|
+
* - a beat at or past the mark renews, leaving a full half-window of headroom — the lease can never
|
|
5024
|
+
* lapse under a worker whose beats are anywhere near their normal cadence.
|
|
5025
|
+
*
|
|
5026
|
+
* That bounds the cost at ONE read + ONE write per half `remoteRedispatchMs` per in-flight step,
|
|
5027
|
+
* independent of beat frequency (with a 30-minute window: two writes an hour). The in-memory mark is
|
|
5028
|
+
* a pure optimisation — cold on a fresh instance, or on the one that didn't dispatch the step — and
|
|
5029
|
+
* the durable half-spent check below re-derives the same answer from the checkpoint, so nothing
|
|
5030
|
+
* depends on it surviving. Cleared wholesale past 1024 entries: a rare extra read, never a leak.
|
|
5031
|
+
*/
|
|
5032
|
+
async rearmStepLease(runId, seq) {
|
|
5033
|
+
if (this.remoteRedispatchMs == null) return;
|
|
5034
|
+
const id = stepId(runId, seq);
|
|
5035
|
+
const now = this.clock();
|
|
5036
|
+
const lookAgainAt = this.stepLeaseRearms.get(id);
|
|
5037
|
+
if (lookAgainAt !== void 0 && now < lookAgainAt) return;
|
|
5038
|
+
try {
|
|
5039
|
+
const cp = await this.store.getCheckpoint(runId, seq);
|
|
5040
|
+
if (!cp || cp.kind !== "remote" || cp.status !== "pending") {
|
|
5041
|
+
this.stepLeaseRearms.delete(id);
|
|
5042
|
+
return;
|
|
5043
|
+
}
|
|
5044
|
+
const halfWindow = Math.ceil(this.remoteRedispatchMs / 2);
|
|
5045
|
+
if (cp.wakeAt != null && cp.wakeAt > now + halfWindow) {
|
|
5046
|
+
this.stepLeaseRearms.set(id, cp.wakeAt - halfWindow);
|
|
5047
|
+
return;
|
|
5048
|
+
}
|
|
5049
|
+
const renewed = now + this.remoteRedispatchMs;
|
|
5050
|
+
const previous = cp.wakeAt;
|
|
5051
|
+
await this.store.saveCheckpoint({
|
|
5052
|
+
...cp,
|
|
5053
|
+
wakeAt: renewed
|
|
5054
|
+
});
|
|
5055
|
+
if (this.stepLeaseRearms.size > 1024) this.stepLeaseRearms.clear();
|
|
5056
|
+
this.stepLeaseRearms.set(id, renewed - halfWindow);
|
|
5057
|
+
if (previous == null) return;
|
|
5058
|
+
const run = await this.store.getRun(runId);
|
|
5059
|
+
if (run?.status === "suspended" && run.wakeAt === previous) {
|
|
5060
|
+
await this.store.updateRun(runId, {
|
|
5061
|
+
wakeAt: renewed,
|
|
5062
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
5063
|
+
});
|
|
5064
|
+
}
|
|
5065
|
+
} catch {
|
|
5066
|
+
}
|
|
5067
|
+
}
|
|
4876
5068
|
/** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
|
|
4877
5069
|
async remoteHistory(runId) {
|
|
4878
5070
|
const checkpoints = await this.store.listCheckpoints(runId);
|
|
@@ -4917,8 +5109,13 @@ var WorkflowEngine = class {
|
|
|
4917
5109
|
return events.sort((a, b) => a.seq - b.seq);
|
|
4918
5110
|
}
|
|
4919
5111
|
/** Apply a turn's commands: persist recorded local steps, dispatch remote calls, schedule timers.
|
|
4920
|
-
* Returns the earliest timer deadline to suspend on (or undefined — suspended on a result).
|
|
4921
|
-
|
|
5112
|
+
* Returns the earliest timer deadline to suspend on (or undefined — suspended on a result).
|
|
5113
|
+
*
|
|
5114
|
+
* `redriveLost` is set only by the `continue` path: a turn that PARKS on its blocking ops is the
|
|
5115
|
+
* one that may find a re-emitted `call` whose dispatch was lost and needs re-driving (see
|
|
5116
|
+
* {@link redriveLostCall}). A terminal turn (completed/failed/cancelled) applies its commands only
|
|
5117
|
+
* to record what already ran, and must never dispatch anything new. */
|
|
5118
|
+
async applyCommands(run, commands, redriveLost = false) {
|
|
4922
5119
|
let wakeAt;
|
|
4923
5120
|
for (const cmd of commands) {
|
|
4924
5121
|
const at = /* @__PURE__ */ new Date();
|
|
@@ -4954,7 +5151,13 @@ var WorkflowEngine = class {
|
|
|
4954
5151
|
error: cmd.error
|
|
4955
5152
|
});
|
|
4956
5153
|
} else if (cmd.kind === "call") {
|
|
4957
|
-
|
|
5154
|
+
const existing = await this.store.getCheckpoint(run.id, cmd.seq);
|
|
5155
|
+
if (existing) {
|
|
5156
|
+
const leaseAt = redriveLost ? await this.redriveLostCall(run, existing) : void 0;
|
|
5157
|
+
if (leaseAt != null) wakeAt = wakeAt == null ? leaseAt : Math.min(wakeAt, leaseAt);
|
|
5158
|
+
continue;
|
|
5159
|
+
}
|
|
5160
|
+
const lease = this.remoteRedispatchMs == null ? void 0 : this.clock() + this.remoteRedispatchMs;
|
|
4958
5161
|
await this.store.saveCheckpoint(stepCheckpoint({
|
|
4959
5162
|
runId: run.id,
|
|
4960
5163
|
seq: cmd.seq,
|
|
@@ -4963,6 +5166,7 @@ var WorkflowEngine = class {
|
|
|
4963
5166
|
status: "pending",
|
|
4964
5167
|
input: cmd.input,
|
|
4965
5168
|
attempts: 1,
|
|
5169
|
+
wakeAt: lease,
|
|
4966
5170
|
// `cmd.group` is the routing declaration the worker's replay emitted (cross-SDK, or a
|
|
4967
5171
|
// group-served TS body) — treated as an isolation partition on top of the step's own
|
|
4968
5172
|
// name, mirroring the StepDef formula below (identical expression everywhere).
|
|
@@ -4993,6 +5197,10 @@ var WorkflowEngine = class {
|
|
|
4993
5197
|
name: cmd.name,
|
|
4994
5198
|
kind: "remote"
|
|
4995
5199
|
});
|
|
5200
|
+
if (lease != null) {
|
|
5201
|
+
const leaseWake = this.stepLeaseWakeAt(lease);
|
|
5202
|
+
wakeAt = wakeAt == null ? leaseWake : Math.min(wakeAt, leaseWake);
|
|
5203
|
+
}
|
|
4996
5204
|
} else if (cmd.kind === "sleep") {
|
|
4997
5205
|
const deadline = this.clock() + cmd.ms;
|
|
4998
5206
|
await this.store.saveCheckpoint(stepCheckpoint({
|
|
@@ -5681,7 +5889,13 @@ var WorkflowEngine = class {
|
|
|
5681
5889
|
wakeAt: nextDeadline,
|
|
5682
5890
|
enqueuedAt: reEnqueuedAt,
|
|
5683
5891
|
startedAt: reEnqueuedAt,
|
|
5684
|
-
finishedAt: reEnqueuedAt
|
|
5892
|
+
finishedAt: reEnqueuedAt,
|
|
5893
|
+
// Same observable marker the polyglot `call` path stamps (see redriveLostCall): the step's own
|
|
5894
|
+
// trail distinguishes a lost-dispatch re-drive from a failure retry.
|
|
5895
|
+
events: [
|
|
5896
|
+
...existing.events ?? [],
|
|
5897
|
+
lostStepEvent(this.clock(), reAttempt, "redispatched")
|
|
5898
|
+
]
|
|
5685
5899
|
});
|
|
5686
5900
|
await this.dispatchRemoteTask({
|
|
5687
5901
|
runId,
|
|
@@ -5694,6 +5908,14 @@ var WorkflowEngine = class {
|
|
|
5694
5908
|
attempt: reAttempt,
|
|
5695
5909
|
transport
|
|
5696
5910
|
});
|
|
5911
|
+
this.emit({
|
|
5912
|
+
type: "step.started",
|
|
5913
|
+
runId,
|
|
5914
|
+
seq,
|
|
5915
|
+
name: step.name,
|
|
5916
|
+
kind: "remote",
|
|
5917
|
+
redispatched: true
|
|
5918
|
+
});
|
|
5697
5919
|
throw new WorkflowSuspended(nextDeadline);
|
|
5698
5920
|
}
|
|
5699
5921
|
let attempt = 1;
|
|
@@ -5860,20 +6082,48 @@ var WorkflowEngine = class {
|
|
|
5860
6082
|
await this.store.releaseRunLock(decision.runId);
|
|
5861
6083
|
}
|
|
5862
6084
|
}
|
|
6085
|
+
/** Earliest clock time a suspended turn's deadline is worth LOOKING at again (`runId` → clock ms) —
|
|
6086
|
+
* the read throttle for {@link rearmDecisionDeadline}, the run-scoped sibling of
|
|
6087
|
+
* {@link stepLeaseRearms}. */
|
|
6088
|
+
decisionDeadlineRearms = /* @__PURE__ */ new Map();
|
|
5863
6089
|
/**
|
|
5864
6090
|
* A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
|
|
5865
6091
|
* push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
|
|
5866
6092
|
* re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
|
|
6093
|
+
*
|
|
6094
|
+
* THROTTLED exactly as {@link rearmStepLease} is, and for the same reason: a long workflow turn's
|
|
6095
|
+
* worker beats every couple of seconds, and each beat otherwise cost a `getRun` + `updateRun`.
|
|
6096
|
+
* The window here is `remoteAdvanceSilenceMs`, and a renewal buys at most one of them — so it is
|
|
6097
|
+
* only worth a write once the window is half spent, which leaves a full half-window of headroom
|
|
6098
|
+
* before the timer poller would re-drive the turn. The in-memory mark is a pure optimisation: a
|
|
6099
|
+
* cold (or different) instance re-derives the same answer from the run's own `wakeAt`.
|
|
5867
6100
|
*/
|
|
5868
6101
|
async rearmDecisionDeadline(runId) {
|
|
5869
6102
|
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
5870
6103
|
if (silenceMs == null) return;
|
|
5871
|
-
const
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
6104
|
+
const now = this.clock();
|
|
6105
|
+
const lookAgainAt = this.decisionDeadlineRearms.get(runId);
|
|
6106
|
+
if (lookAgainAt !== void 0 && now < lookAgainAt) return;
|
|
6107
|
+
try {
|
|
6108
|
+
const run = await this.store.getRun(runId);
|
|
6109
|
+
if (!run || run.status !== "suspended" || run.awaitingDecisionTaskId === void 0) {
|
|
6110
|
+
this.decisionDeadlineRearms.delete(runId);
|
|
6111
|
+
return;
|
|
6112
|
+
}
|
|
6113
|
+
const halfWindow = Math.ceil(silenceMs / 2);
|
|
6114
|
+
if (run.wakeAt != null && run.wakeAt > now + halfWindow) {
|
|
6115
|
+
this.decisionDeadlineRearms.set(runId, run.wakeAt - halfWindow);
|
|
6116
|
+
return;
|
|
6117
|
+
}
|
|
6118
|
+
const renewed = now + silenceMs;
|
|
6119
|
+
await this.store.updateRun(runId, {
|
|
6120
|
+
wakeAt: renewed,
|
|
6121
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
6122
|
+
});
|
|
6123
|
+
if (this.decisionDeadlineRearms.size > 1024) this.decisionDeadlineRearms.clear();
|
|
6124
|
+
this.decisionDeadlineRearms.set(runId, renewed - halfWindow);
|
|
6125
|
+
} catch {
|
|
6126
|
+
}
|
|
5877
6127
|
}
|
|
5878
6128
|
/**
|
|
5879
6129
|
* A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
|