@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.cjs +286 -28
- 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 +286 -28
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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;
|
|
2991
|
-
// turn
|
|
2992
|
-
//
|
|
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)
|
|
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.
|
|
@@ -3641,9 +3657,14 @@ var WorkflowEngine = class {
|
|
|
3641
3657
|
async recoverIncomplete(nowMs = this.clock()) {
|
|
3642
3658
|
if (this.draining) return [];
|
|
3643
3659
|
const results = [];
|
|
3644
|
-
for (const
|
|
3645
|
-
const acquired = await this.store.tryLockRun(
|
|
3660
|
+
for (const listed of await this.store.listIncompleteRuns(this.namespace)) {
|
|
3661
|
+
const acquired = await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
|
|
3646
3662
|
if (!acquired) continue;
|
|
3663
|
+
const run = await this.store.getRun(listed.id);
|
|
3664
|
+
if (!run || run.status !== "running" && run.status !== "cancelling") {
|
|
3665
|
+
await this.store.releaseRunLock(listed.id);
|
|
3666
|
+
continue;
|
|
3667
|
+
}
|
|
3647
3668
|
if (run.status === "running") {
|
|
3648
3669
|
const dispatched = await this.inFlightRemoteStep(run.id);
|
|
3649
3670
|
if (dispatched) {
|
|
@@ -4976,7 +4997,7 @@ var WorkflowEngine = class {
|
|
|
4976
4997
|
error: fresh.error
|
|
4977
4998
|
};
|
|
4978
4999
|
}
|
|
4979
|
-
const wakeAt = await this.applyCommands(run, decision.commands);
|
|
5000
|
+
const wakeAt = await this.applyCommands(run, decision.commands, true);
|
|
4980
5001
|
const suspended = await this.settleRun(run, {
|
|
4981
5002
|
kind: "suspended",
|
|
4982
5003
|
wakeAt
|
|
@@ -5014,6 +5035,182 @@ var WorkflowEngine = class {
|
|
|
5014
5035
|
}
|
|
5015
5036
|
}
|
|
5016
5037
|
}
|
|
5038
|
+
/**
|
|
5039
|
+
* A polyglot workflow's re-emitted `call` whose checkpoint already exists: decide whether the step
|
|
5040
|
+
* is still out for delivery, or whether its dispatch was LOST and has to be re-driven. Returns the
|
|
5041
|
+
* lease deadline to park the run on (so the next decision lands exactly when it's due), or
|
|
5042
|
+
* `undefined` when there is nothing to wait for.
|
|
5043
|
+
*
|
|
5044
|
+
* WHY THIS EXISTS. A worker process killed mid-step (OOM, exit 137 — a pod's memory ceiling, not a
|
|
5045
|
+
* bug in anyone's code) takes its in-flight job with it: BullMQ's stalled-check eventually fails the
|
|
5046
|
+
* job, and for a consumer that can't bridge a terminal job failure back into a `StepResult` (any
|
|
5047
|
+
* non-TS SDK) NOTHING settles the checkpoint. It stays `pending` — "out for delivery" — and the
|
|
5048
|
+
* `applyCommands` idempotency guard, which exists so a `ctx.gather_calls` fan-out doesn't
|
|
5049
|
+
* double-dispatch its still-in-flight siblings on every partial resume, skips it on every later turn.
|
|
5050
|
+
* The run then wakes on the reconcile sweep, dispatches a turn, has the call re-emitted, skips it
|
|
5051
|
+
* again, and sleeps. Forever. Observed in the wild on a six-run `gather_calls` fan-out: `pending`,
|
|
5052
|
+
* `attempts = 1`, `wakeAt` NULL, over an hour, no retry.
|
|
5053
|
+
*
|
|
5054
|
+
* THE POLICY (mirrors {@link callRemote}'s `ctx.step` self-heal, which this path silently did not
|
|
5055
|
+
* share — so `remoteRedispatchMs` simply did not apply to a fan-out):
|
|
5056
|
+
* - a SETTLED checkpoint always wins: a step that completed just before the crash is never re-run;
|
|
5057
|
+
* - `remoteRedispatchMs` unset (the default) keeps the by-design "re-suspend, never re-dispatch";
|
|
5058
|
+
* - otherwise the pending checkpoint carries a LEASE in `wakeAt` — a result or a step heartbeat
|
|
5059
|
+
* ({@link rearmStepLease}) renews it, so a live worker still holding the step is never
|
|
5060
|
+
* double-dispatched — and only a LAPSED lease re-dispatches, bounded by `remoteRedispatchMax`;
|
|
5061
|
+
* - past the bound the step is failed `remote_step_lost` and the failure enters the run's history,
|
|
5062
|
+
* so the workflow's own error path surfaces it instead of the engine looping.
|
|
5063
|
+
*/
|
|
5064
|
+
async redriveLostCall(run, cp) {
|
|
5065
|
+
if (cp.kind !== "remote" || cp.status !== "pending") return void 0;
|
|
5066
|
+
if (this.remoteRedispatchMs == null) return void 0;
|
|
5067
|
+
if (cp.wakeAt == null) {
|
|
5068
|
+
const lease2 = this.clock() + this.remoteRedispatchMs;
|
|
5069
|
+
await this.store.saveCheckpoint({
|
|
5070
|
+
...cp,
|
|
5071
|
+
wakeAt: lease2
|
|
5072
|
+
});
|
|
5073
|
+
return this.stepLeaseWakeAt(lease2);
|
|
5074
|
+
}
|
|
5075
|
+
if (this.clock() < cp.wakeAt) return this.stepLeaseWakeAt(cp.wakeAt);
|
|
5076
|
+
if (cp.attempts >= this.remoteRedispatchMax) {
|
|
5077
|
+
const error2 = {
|
|
5078
|
+
message: `remote step "${cp.name}" lost \u2014 no result after ${cp.attempts} dispatch(es)`,
|
|
5079
|
+
code: "remote_step_lost",
|
|
5080
|
+
retryable: false
|
|
5081
|
+
};
|
|
5082
|
+
const now = /* @__PURE__ */ new Date();
|
|
5083
|
+
await this.store.saveCheckpoint({
|
|
5084
|
+
...cp,
|
|
5085
|
+
status: "failed",
|
|
5086
|
+
error: error2,
|
|
5087
|
+
wakeAt: void 0,
|
|
5088
|
+
finishedAt: now,
|
|
5089
|
+
events: [
|
|
5090
|
+
...cp.events ?? [],
|
|
5091
|
+
lostStepEvent(this.clock(), cp.attempts, "lost")
|
|
5092
|
+
]
|
|
5093
|
+
});
|
|
5094
|
+
this.emit({
|
|
5095
|
+
type: "step.failed",
|
|
5096
|
+
runId: run.id,
|
|
5097
|
+
seq: cp.seq,
|
|
5098
|
+
name: cp.name,
|
|
5099
|
+
kind: "remote",
|
|
5100
|
+
error: error2
|
|
5101
|
+
});
|
|
5102
|
+
return void 0;
|
|
5103
|
+
}
|
|
5104
|
+
const attempts = cp.attempts + 1;
|
|
5105
|
+
const lease = this.clock() + this.remoteRedispatchMs;
|
|
5106
|
+
const at = /* @__PURE__ */ new Date();
|
|
5107
|
+
await this.store.saveCheckpoint({
|
|
5108
|
+
...cp,
|
|
5109
|
+
attempts,
|
|
5110
|
+
wakeAt: lease,
|
|
5111
|
+
// The re-dispatch restarts the step's wall-clock window; the old one measured a worker that died.
|
|
5112
|
+
enqueuedAt: at,
|
|
5113
|
+
startedAt: at,
|
|
5114
|
+
finishedAt: at,
|
|
5115
|
+
// Observability: the step's own trail says WHY it ran again — a lost worker, not a failure retry.
|
|
5116
|
+
events: [
|
|
5117
|
+
...cp.events ?? [],
|
|
5118
|
+
lostStepEvent(this.clock(), attempts, "redispatched")
|
|
5119
|
+
]
|
|
5120
|
+
});
|
|
5121
|
+
await this.dispatchRemoteTask({
|
|
5122
|
+
runId: run.id,
|
|
5123
|
+
seq: cp.seq,
|
|
5124
|
+
name: cp.name,
|
|
5125
|
+
stepId: cp.stepId ?? stepId(run.id, cp.seq),
|
|
5126
|
+
group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
|
|
5127
|
+
input: cp.input,
|
|
5128
|
+
attempt: attempts
|
|
5129
|
+
});
|
|
5130
|
+
this.emit({
|
|
5131
|
+
type: "step.started",
|
|
5132
|
+
runId: run.id,
|
|
5133
|
+
seq: cp.seq,
|
|
5134
|
+
name: cp.name,
|
|
5135
|
+
kind: "remote",
|
|
5136
|
+
redispatched: true
|
|
5137
|
+
});
|
|
5138
|
+
return this.stepLeaseWakeAt(lease);
|
|
5139
|
+
}
|
|
5140
|
+
/**
|
|
5141
|
+
* The run `wakeAt` a step lease should park on: the lease itself, but never LATER than the
|
|
5142
|
+
* `reconcileMs` safety net would have woken the run anyway. A long lease (a fan-out step that
|
|
5143
|
+
* legitimately runs for half an hour) must not stretch the orphan sweep's cadence for this run —
|
|
5144
|
+
* an early wake just re-parks on the un-lapsed lease, which is cheap and convergent.
|
|
5145
|
+
*/
|
|
5146
|
+
stepLeaseWakeAt(lease) {
|
|
5147
|
+
const reconcile = this.reconcileMs;
|
|
5148
|
+
return reconcile == null ? lease : Math.min(lease, this.clock() + reconcile);
|
|
5149
|
+
}
|
|
5150
|
+
/** Earliest clock time a step's lease is worth LOOKING at again (`stepId` → clock ms) — the read
|
|
5151
|
+
* throttle for {@link rearmStepLease}, the step-scoped sibling of {@link decisionDeadlineRearms}. */
|
|
5152
|
+
stepLeaseRearms = /* @__PURE__ */ new Map();
|
|
5153
|
+
/**
|
|
5154
|
+
* A step-scoped heartbeat for a durably-suspended remote step: push its LEASE forward so a worker
|
|
5155
|
+
* that is still working on it is never presumed dead and double-dispatched. The durable counterpart
|
|
5156
|
+
* of {@link callRemoteInMemory}'s in-memory `heartbeatResets` rearm (which only exists on the
|
|
5157
|
+
* instance that dispatched a `timeoutMs` step), and the step-scoped sibling of
|
|
5158
|
+
* {@link rearmDecisionDeadline}. No-op unless the lost-dispatch self-heal is on — without
|
|
5159
|
+
* `remoteRedispatchMs` nothing ever re-dispatches, so there is no lease to defend.
|
|
5160
|
+
*
|
|
5161
|
+
* THROTTLED BY THE LEASE ITSELF, not a fixed interval: a renewal buys at most ONE window, so it is
|
|
5162
|
+
* only WORTH a write once the window is half spent. Beats arrive every couple of seconds from a hot
|
|
5163
|
+
* worker loop — and a `gather_calls` fan-out multiplies that by its in-flight steps — so an
|
|
5164
|
+
* unthrottled rearm is a per-beat SELECT + UPDATE storm (plus a `getRun` + `updateRun` whenever the
|
|
5165
|
+
* run is parked exactly on this step's lease). The `cp.wakeAt >= renewed` guard it replaces never
|
|
5166
|
+
* short-circuited anything, because `renewed` advances with the clock. So:
|
|
5167
|
+
* - a beat that finds more than half the window still on the clock writes nothing, and records the
|
|
5168
|
+
* half-spent mark so subsequent beats skip even the `getCheckpoint` READ until then;
|
|
5169
|
+
* - a beat at or past the mark renews, leaving a full half-window of headroom — the lease can never
|
|
5170
|
+
* lapse under a worker whose beats are anywhere near their normal cadence.
|
|
5171
|
+
*
|
|
5172
|
+
* That bounds the cost at ONE read + ONE write per half `remoteRedispatchMs` per in-flight step,
|
|
5173
|
+
* independent of beat frequency (with a 30-minute window: two writes an hour). The in-memory mark is
|
|
5174
|
+
* a pure optimisation — cold on a fresh instance, or on the one that didn't dispatch the step — and
|
|
5175
|
+
* the durable half-spent check below re-derives the same answer from the checkpoint, so nothing
|
|
5176
|
+
* depends on it surviving. Cleared wholesale past 1024 entries: a rare extra read, never a leak.
|
|
5177
|
+
*/
|
|
5178
|
+
async rearmStepLease(runId, seq) {
|
|
5179
|
+
if (this.remoteRedispatchMs == null) return;
|
|
5180
|
+
const id = stepId(runId, seq);
|
|
5181
|
+
const now = this.clock();
|
|
5182
|
+
const lookAgainAt = this.stepLeaseRearms.get(id);
|
|
5183
|
+
if (lookAgainAt !== void 0 && now < lookAgainAt) return;
|
|
5184
|
+
try {
|
|
5185
|
+
const cp = await this.store.getCheckpoint(runId, seq);
|
|
5186
|
+
if (!cp || cp.kind !== "remote" || cp.status !== "pending") {
|
|
5187
|
+
this.stepLeaseRearms.delete(id);
|
|
5188
|
+
return;
|
|
5189
|
+
}
|
|
5190
|
+
const halfWindow = Math.ceil(this.remoteRedispatchMs / 2);
|
|
5191
|
+
if (cp.wakeAt != null && cp.wakeAt > now + halfWindow) {
|
|
5192
|
+
this.stepLeaseRearms.set(id, cp.wakeAt - halfWindow);
|
|
5193
|
+
return;
|
|
5194
|
+
}
|
|
5195
|
+
const renewed = now + this.remoteRedispatchMs;
|
|
5196
|
+
const previous = cp.wakeAt;
|
|
5197
|
+
await this.store.saveCheckpoint({
|
|
5198
|
+
...cp,
|
|
5199
|
+
wakeAt: renewed
|
|
5200
|
+
});
|
|
5201
|
+
if (this.stepLeaseRearms.size > 1024) this.stepLeaseRearms.clear();
|
|
5202
|
+
this.stepLeaseRearms.set(id, renewed - halfWindow);
|
|
5203
|
+
if (previous == null) return;
|
|
5204
|
+
const run = await this.store.getRun(runId);
|
|
5205
|
+
if (run?.status === "suspended" && run.wakeAt === previous) {
|
|
5206
|
+
await this.store.updateRun(runId, {
|
|
5207
|
+
wakeAt: renewed,
|
|
5208
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
5209
|
+
});
|
|
5210
|
+
}
|
|
5211
|
+
} catch {
|
|
5212
|
+
}
|
|
5213
|
+
}
|
|
5017
5214
|
/** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
|
|
5018
5215
|
async remoteHistory(runId) {
|
|
5019
5216
|
const checkpoints = await this.store.listCheckpoints(runId);
|
|
@@ -5058,8 +5255,13 @@ var WorkflowEngine = class {
|
|
|
5058
5255
|
return events.sort((a, b) => a.seq - b.seq);
|
|
5059
5256
|
}
|
|
5060
5257
|
/** 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
|
-
|
|
5258
|
+
* Returns the earliest timer deadline to suspend on (or undefined — suspended on a result).
|
|
5259
|
+
*
|
|
5260
|
+
* `redriveLost` is set only by the `continue` path: a turn that PARKS on its blocking ops is the
|
|
5261
|
+
* one that may find a re-emitted `call` whose dispatch was lost and needs re-driving (see
|
|
5262
|
+
* {@link redriveLostCall}). A terminal turn (completed/failed/cancelled) applies its commands only
|
|
5263
|
+
* to record what already ran, and must never dispatch anything new. */
|
|
5264
|
+
async applyCommands(run, commands, redriveLost = false) {
|
|
5063
5265
|
let wakeAt;
|
|
5064
5266
|
for (const cmd of commands) {
|
|
5065
5267
|
const at = /* @__PURE__ */ new Date();
|
|
@@ -5095,7 +5297,13 @@ var WorkflowEngine = class {
|
|
|
5095
5297
|
error: cmd.error
|
|
5096
5298
|
});
|
|
5097
5299
|
} else if (cmd.kind === "call") {
|
|
5098
|
-
|
|
5300
|
+
const existing = await this.store.getCheckpoint(run.id, cmd.seq);
|
|
5301
|
+
if (existing) {
|
|
5302
|
+
const leaseAt = redriveLost ? await this.redriveLostCall(run, existing) : void 0;
|
|
5303
|
+
if (leaseAt != null) wakeAt = wakeAt == null ? leaseAt : Math.min(wakeAt, leaseAt);
|
|
5304
|
+
continue;
|
|
5305
|
+
}
|
|
5306
|
+
const lease = this.remoteRedispatchMs == null ? void 0 : this.clock() + this.remoteRedispatchMs;
|
|
5099
5307
|
await this.store.saveCheckpoint(stepCheckpoint({
|
|
5100
5308
|
runId: run.id,
|
|
5101
5309
|
seq: cmd.seq,
|
|
@@ -5104,6 +5312,7 @@ var WorkflowEngine = class {
|
|
|
5104
5312
|
status: "pending",
|
|
5105
5313
|
input: cmd.input,
|
|
5106
5314
|
attempts: 1,
|
|
5315
|
+
wakeAt: lease,
|
|
5107
5316
|
// `cmd.group` is the routing declaration the worker's replay emitted (cross-SDK, or a
|
|
5108
5317
|
// group-served TS body) — treated as an isolation partition on top of the step's own
|
|
5109
5318
|
// name, mirroring the StepDef formula below (identical expression everywhere).
|
|
@@ -5134,6 +5343,10 @@ var WorkflowEngine = class {
|
|
|
5134
5343
|
name: cmd.name,
|
|
5135
5344
|
kind: "remote"
|
|
5136
5345
|
});
|
|
5346
|
+
if (lease != null) {
|
|
5347
|
+
const leaseWake = this.stepLeaseWakeAt(lease);
|
|
5348
|
+
wakeAt = wakeAt == null ? leaseWake : Math.min(wakeAt, leaseWake);
|
|
5349
|
+
}
|
|
5137
5350
|
} else if (cmd.kind === "sleep") {
|
|
5138
5351
|
const deadline = this.clock() + cmd.ms;
|
|
5139
5352
|
await this.store.saveCheckpoint(stepCheckpoint({
|
|
@@ -5311,16 +5524,16 @@ var WorkflowEngine = class {
|
|
|
5311
5524
|
try {
|
|
5312
5525
|
const output = await runInWorkflowCtx(ctx, () => fn(ctx, run.input));
|
|
5313
5526
|
if (this.cancelRequested.has(run.id)) {
|
|
5314
|
-
return this.compensateAndCancel(run, compensations, replay);
|
|
5527
|
+
return await this.compensateAndCancel(run, compensations, replay);
|
|
5315
5528
|
}
|
|
5316
|
-
return this.settleRun(run, {
|
|
5529
|
+
return await this.settleRun(run, {
|
|
5317
5530
|
kind: "completed",
|
|
5318
5531
|
output
|
|
5319
5532
|
});
|
|
5320
5533
|
} catch (err) {
|
|
5321
5534
|
if (err instanceof ContinueAsNew) {
|
|
5322
5535
|
if (this.cancelRequested.has(run.id)) {
|
|
5323
|
-
return this.compensateAndCancel(run, compensations, replay);
|
|
5536
|
+
return await this.compensateAndCancel(run, compensations, replay);
|
|
5324
5537
|
}
|
|
5325
5538
|
await this.store.updateRun(run.id, {
|
|
5326
5539
|
status: "completed",
|
|
@@ -5347,35 +5560,35 @@ var WorkflowEngine = class {
|
|
|
5347
5560
|
}
|
|
5348
5561
|
if (err instanceof WorkflowBlocked) {
|
|
5349
5562
|
if (this.cancelRequested.has(run.id)) {
|
|
5350
|
-
return this.compensateAndCancel(run, compensations, replay);
|
|
5563
|
+
return await this.compensateAndCancel(run, compensations, replay);
|
|
5351
5564
|
}
|
|
5352
|
-
return this.settleRun(run, {
|
|
5565
|
+
return await this.settleRun(run, {
|
|
5353
5566
|
kind: "blocked",
|
|
5354
5567
|
blocked: err.plan
|
|
5355
5568
|
});
|
|
5356
5569
|
}
|
|
5357
5570
|
if (err instanceof WorkflowSuspended) {
|
|
5358
5571
|
if (this.cancelRequested.has(run.id)) {
|
|
5359
|
-
return this.compensateAndCancel(run, compensations, replay);
|
|
5572
|
+
return await this.compensateAndCancel(run, compensations, replay);
|
|
5360
5573
|
}
|
|
5361
|
-
return this.settleRun(run, {
|
|
5574
|
+
return await this.settleRun(run, {
|
|
5362
5575
|
kind: "suspended",
|
|
5363
5576
|
wakeAt: err.wakeAt
|
|
5364
5577
|
});
|
|
5365
5578
|
}
|
|
5366
5579
|
if (this.cancelRequested.has(run.id)) {
|
|
5367
|
-
return this.compensateAndCancel(run, compensations, replay);
|
|
5580
|
+
return await this.compensateAndCancel(run, compensations, replay);
|
|
5368
5581
|
}
|
|
5369
5582
|
const error2 = {
|
|
5370
5583
|
message: err instanceof Error ? err.message : String(err),
|
|
5371
5584
|
stack: err instanceof Error ? err.stack : void 0
|
|
5372
5585
|
};
|
|
5373
5586
|
const pending = await this.unwindCompensations(run, compensations, replay);
|
|
5374
|
-
if (pending) return this.settleRun(run, {
|
|
5587
|
+
if (pending) return await this.settleRun(run, {
|
|
5375
5588
|
kind: "suspended",
|
|
5376
5589
|
wakeAt: pending.wakeAt
|
|
5377
5590
|
});
|
|
5378
|
-
return this.settleRun(run, {
|
|
5591
|
+
return await this.settleRun(run, {
|
|
5379
5592
|
kind: "failed",
|
|
5380
5593
|
error: error2
|
|
5381
5594
|
});
|
|
@@ -5789,7 +6002,10 @@ var WorkflowEngine = class {
|
|
|
5789
6002
|
}
|
|
5790
6003
|
async callRemote(runId, seq, step, input, queue, transport, replay, admission, routing) {
|
|
5791
6004
|
const group = stepGroup(step, routing?.namespace);
|
|
5792
|
-
|
|
6005
|
+
let existing = replay?.get(seq) ?? await this.store.getCheckpoint(runId, seq);
|
|
6006
|
+
if (existing?.status === "pending" && replay?.has(seq)) {
|
|
6007
|
+
existing = await this.store.getCheckpoint(runId, seq) ?? existing;
|
|
6008
|
+
}
|
|
5793
6009
|
if (existing && existing.name !== step.name) {
|
|
5794
6010
|
throw new NonDeterminismError(runId, seq, step.name, existing.name);
|
|
5795
6011
|
}
|
|
@@ -5822,7 +6038,13 @@ var WorkflowEngine = class {
|
|
|
5822
6038
|
wakeAt: nextDeadline,
|
|
5823
6039
|
enqueuedAt: reEnqueuedAt,
|
|
5824
6040
|
startedAt: reEnqueuedAt,
|
|
5825
|
-
finishedAt: reEnqueuedAt
|
|
6041
|
+
finishedAt: reEnqueuedAt,
|
|
6042
|
+
// Same observable marker the polyglot `call` path stamps (see redriveLostCall): the step's own
|
|
6043
|
+
// trail distinguishes a lost-dispatch re-drive from a failure retry.
|
|
6044
|
+
events: [
|
|
6045
|
+
...existing.events ?? [],
|
|
6046
|
+
lostStepEvent(this.clock(), reAttempt, "redispatched")
|
|
6047
|
+
]
|
|
5826
6048
|
});
|
|
5827
6049
|
await this.dispatchRemoteTask({
|
|
5828
6050
|
runId,
|
|
@@ -5835,6 +6057,14 @@ var WorkflowEngine = class {
|
|
|
5835
6057
|
attempt: reAttempt,
|
|
5836
6058
|
transport
|
|
5837
6059
|
});
|
|
6060
|
+
this.emit({
|
|
6061
|
+
type: "step.started",
|
|
6062
|
+
runId,
|
|
6063
|
+
seq,
|
|
6064
|
+
name: step.name,
|
|
6065
|
+
kind: "remote",
|
|
6066
|
+
redispatched: true
|
|
6067
|
+
});
|
|
5838
6068
|
throw new WorkflowSuspended(nextDeadline);
|
|
5839
6069
|
}
|
|
5840
6070
|
let attempt = 1;
|
|
@@ -6001,20 +6231,48 @@ var WorkflowEngine = class {
|
|
|
6001
6231
|
await this.store.releaseRunLock(decision.runId);
|
|
6002
6232
|
}
|
|
6003
6233
|
}
|
|
6234
|
+
/** Earliest clock time a suspended turn's deadline is worth LOOKING at again (`runId` → clock ms) —
|
|
6235
|
+
* the read throttle for {@link rearmDecisionDeadline}, the run-scoped sibling of
|
|
6236
|
+
* {@link stepLeaseRearms}. */
|
|
6237
|
+
decisionDeadlineRearms = /* @__PURE__ */ new Map();
|
|
6004
6238
|
/**
|
|
6005
6239
|
* A run-scoped heartbeat for a run suspended awaiting a workflow-turn decision (the dispatch path):
|
|
6006
6240
|
* push its durable re-drive deadline (`wakeAt`) forward so a still-working worker's turn is not
|
|
6007
6241
|
* re-dispatched. The durable counterpart of an inline turn's in-memory `heartbeatResets` rearm.
|
|
6242
|
+
*
|
|
6243
|
+
* THROTTLED exactly as {@link rearmStepLease} is, and for the same reason: a long workflow turn's
|
|
6244
|
+
* worker beats every couple of seconds, and each beat otherwise cost a `getRun` + `updateRun`.
|
|
6245
|
+
* The window here is `remoteAdvanceSilenceMs`, and a renewal buys at most one of them — so it is
|
|
6246
|
+
* only worth a write once the window is half spent, which leaves a full half-window of headroom
|
|
6247
|
+
* before the timer poller would re-drive the turn. The in-memory mark is a pure optimisation: a
|
|
6248
|
+
* cold (or different) instance re-derives the same answer from the run's own `wakeAt`.
|
|
6008
6249
|
*/
|
|
6009
6250
|
async rearmDecisionDeadline(runId) {
|
|
6010
6251
|
const silenceMs = this.remoteAdvanceSilenceMs;
|
|
6011
6252
|
if (silenceMs == null) return;
|
|
6012
|
-
const
|
|
6013
|
-
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
6017
|
-
|
|
6253
|
+
const now = this.clock();
|
|
6254
|
+
const lookAgainAt = this.decisionDeadlineRearms.get(runId);
|
|
6255
|
+
if (lookAgainAt !== void 0 && now < lookAgainAt) return;
|
|
6256
|
+
try {
|
|
6257
|
+
const run = await this.store.getRun(runId);
|
|
6258
|
+
if (!run || run.status !== "suspended" || run.awaitingDecisionTaskId === void 0) {
|
|
6259
|
+
this.decisionDeadlineRearms.delete(runId);
|
|
6260
|
+
return;
|
|
6261
|
+
}
|
|
6262
|
+
const halfWindow = Math.ceil(silenceMs / 2);
|
|
6263
|
+
if (run.wakeAt != null && run.wakeAt > now + halfWindow) {
|
|
6264
|
+
this.decisionDeadlineRearms.set(runId, run.wakeAt - halfWindow);
|
|
6265
|
+
return;
|
|
6266
|
+
}
|
|
6267
|
+
const renewed = now + silenceMs;
|
|
6268
|
+
await this.store.updateRun(runId, {
|
|
6269
|
+
wakeAt: renewed,
|
|
6270
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
6271
|
+
});
|
|
6272
|
+
if (this.decisionDeadlineRearms.size > 1024) this.decisionDeadlineRearms.clear();
|
|
6273
|
+
this.decisionDeadlineRearms.set(runId, renewed - halfWindow);
|
|
6274
|
+
} catch {
|
|
6275
|
+
}
|
|
6018
6276
|
}
|
|
6019
6277
|
/**
|
|
6020
6278
|
* A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
|