bunqueue 2.8.46 → 2.8.47

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.
Files changed (36) hide show
  1. package/dist/client/workflow/admission.d.ts +45 -0
  2. package/dist/client/workflow/admission.js +50 -0
  3. package/dist/client/workflow/clock.d.ts +58 -0
  4. package/dist/client/workflow/clock.js +104 -0
  5. package/dist/client/workflow/compensator.d.ts +56 -3
  6. package/dist/client/workflow/compensator.js +406 -27
  7. package/dist/client/workflow/emitter.d.ts +1 -1
  8. package/dist/client/workflow/emitter.js +4 -3
  9. package/dist/client/workflow/engine.d.ts +17 -0
  10. package/dist/client/workflow/engine.js +25 -0
  11. package/dist/client/workflow/executor.d.ts +40 -5
  12. package/dist/client/workflow/executor.js +227 -83
  13. package/dist/client/workflow/identity.d.ts +46 -0
  14. package/dist/client/workflow/identity.js +93 -0
  15. package/dist/client/workflow/index.d.ts +1 -1
  16. package/dist/client/workflow/loops.js +147 -14
  17. package/dist/client/workflow/recovery.d.ts +10 -1
  18. package/dist/client/workflow/recovery.js +36 -9
  19. package/dist/client/workflow/rollbackControl.d.ts +36 -0
  20. package/dist/client/workflow/rollbackControl.js +51 -0
  21. package/dist/client/workflow/runner.d.ts +22 -2
  22. package/dist/client/workflow/runner.js +125 -27
  23. package/dist/client/workflow/store.d.ts +64 -4
  24. package/dist/client/workflow/store.js +123 -30
  25. package/dist/client/workflow/storeCodec.d.ts +7 -0
  26. package/dist/client/workflow/storeCodec.js +16 -0
  27. package/dist/client/workflow/storeSignals.d.ts +61 -0
  28. package/dist/client/workflow/storeSignals.js +118 -0
  29. package/dist/client/workflow/types.d.ts +147 -4
  30. package/dist/client/workflow/unwindPlan.d.ts +87 -0
  31. package/dist/client/workflow/unwindPlan.js +142 -0
  32. package/dist/client/workflow/waitFor.d.ts +52 -0
  33. package/dist/client/workflow/waitFor.js +137 -0
  34. package/dist/client/workflow/workflow.d.ts +71 -1
  35. package/dist/client/workflow/workflow.js +184 -14
  36. package/package.json +16 -6
@@ -1,7 +1,29 @@
1
1
  /**
2
- * Compensation logic - runs compensate handlers in reverse order on failure
2
+ * Saga rollback runs compensate handlers in reverse start order.
3
+ *
4
+ * Four properties this file is responsible for, each of which used to be violated:
5
+ *
6
+ * - EXACTLY ONE OUTCOME per eligible step. Success is recorded as loudly as
7
+ * failure, and a step the unwind never reached is recorded as skipped rather
8
+ * than left blank. "Never zero, never two" is not checkable otherwise.
9
+ * - NEVER TWICE. A step that already carries an outcome is not re-run, so an
10
+ * unwind interrupted by a crash resumes where it stopped instead of replaying
11
+ * reversals from the top.
12
+ * - HALT, NOT PLOUGH ON. A compensation that fails definitively stops the chain:
13
+ * continuing past it would undo things whose dependencies are still standing.
14
+ * The remaining eligible steps are marked skipped so the gap is visible.
15
+ * - PIVOT. Past `.pivot()` the saga is committed; those steps are never eligible.
16
+ *
17
+ * `rollbackStatus` on the execution is a separate axis from the failure reason. The
18
+ * rollback is what the engine did *after* the failure, not why it failed, and an
19
+ * operator needs to alert on "the refund never went through" independently of "the
20
+ * payment failed".
3
21
  */
4
- import { findStepDef, buildContext } from './runner';
22
+ import { findStepDef, buildContext, runWithTimeout } from './runner';
23
+ import { idempotencyKey, loopBaseName, describeError } from './identity';
24
+ import { clock } from './clock';
25
+ import { decideUnwindAction, owesOutcome } from './unwindPlan';
26
+ import { isLive } from './admission';
5
27
  /** Sentinel error thrown when execution must pause for a signal */
6
28
  export class WaitForSignalError extends Error {
7
29
  event;
@@ -10,36 +32,393 @@ export class WaitForSignalError extends Error {
10
32
  this.event = event;
11
33
  }
12
34
  }
13
- /** Run compensation handlers in reverse order for all completed steps */
14
- export async function runCompensation(exec, wf, store, emitter) {
15
- const completed = Object.entries(exec.steps)
16
- .filter(([name, s]) => s.status === 'completed' && !name.startsWith('__'))
17
- .reverse();
18
- if (completed.length === 0)
19
- return;
20
- exec.state = 'compensating';
21
- store.update(exec);
22
- emitter?.emitWorkflow('workflow:compensating', exec.id, exec.workflowName, 'compensating');
35
+ /**
36
+ * Executions with an unwind in flight in THIS process.
37
+ *
38
+ * The "never compensate twice" guard inside the loop reads `record.compensation`
39
+ * from the caller's own snapshot, which was loaded before any concurrent unwind
40
+ * settled anything. Two overlapping unwinds of the same execution therefore both see
41
+ * an unsettled record and both run the handler: a refund issued twice, stock released
42
+ * twice. `recover()` reaches this legitimately (it lists `compensating` runs) and can
43
+ * be called on a live engine that is already driving one.
44
+ *
45
+ * Scope is the process, which is what the set can actually enforce. It is NOT a
46
+ * distributed lock: two processes on one database can still overlap, and so can two
47
+ * Engine instances that somehow reach the same execution. An earlier version of this
48
+ * comment justified the scope by citing a single-instance guarantee in
49
+ * `docs/features/workflow-engine.md`; that statement is not in that file and the
50
+ * citation was wrong.
51
+ */
52
+ const inFlight = new Set();
53
+ /** Run compensation handlers in reverse start order for every eligible step. */
54
+ // 6 params, one over the limit. The registry is needed to unwind a nested saga through the
55
+ // child's own workflow, and `opts` carries the operator retry; folding them into one bag
56
+ // would hide which of the two a call site is actually asking for, across four call sites
57
+ // that each pass a different combination.
58
+ // biome-ignore lint/complexity/useMaxParams: see above
59
+ export async function runCompensation(exec, wf, store, emitter, workflows, opts = {}) {
60
+ const eligible = unwindSet(exec, wf);
61
+ if (eligible.length === 0) {
62
+ exec.rollbackStatus = 'not-applicable';
63
+ // The run must also become terminal here. On the `runNode` path the caller has
64
+ // already set `failed`, which hid this; `recoverExecutions` has no such caller, so
65
+ // a persisted `compensating` run whose steps no longer resolve (a deploy renamed a
66
+ // step) stayed `compensating` forever, was returned by `listRecoverable()` again,
67
+ // and was re-driven and re-counted at every single startup.
68
+ if (exec.state !== 'failed' && exec.state !== 'completed')
69
+ exec.state = 'failed';
70
+ // Unguarded: a throw here replaces the caller's node error, which is the masking this
71
+ // module fixed elsewhere, but nothing was eligible so there is no rollback to lose, and
72
+ // a `failureReason` from the original failure is already on disk. (On the `runNode`
73
+ // path its caller wrote it; the `recovery.ts` and `rollbackControl.ts` entries have no
74
+ // such caller, and reach here on a row that already carries one.)
75
+ //
76
+ // Unlike the other unguarded writes, this one does NOT self-heal: on the `runNode` path
77
+ // disk ends `failed`, which `listRecoverable()` does not cover, so `rollbackStatus`
78
+ // stays absent instead of `'not-applicable'` for good. That residue is the honest
79
+ // reading anyway, since absent means no unwind was attempted, and guarding the write
80
+ // would swallow a store failure with nothing left to report it.
81
+ store.update(exec);
82
+ return 'ran';
83
+ }
84
+ // Claim before the first await. Losing the claim means another unwind of this same
85
+ // execution is already running, and the caller MUST be told: a parent rolling back
86
+ // a sub-workflow cannot tell "the child finished" from "someone else is driving the
87
+ // child" if both look like a plain return, and it would settle the child's record
88
+ // as `compensated` while that other unwind was still free to fail.
89
+ if (inFlight.has(exec.id))
90
+ return 'claim-lost';
91
+ inFlight.add(exec.id);
92
+ try {
93
+ await unwind({ exec, wf, store, emitter, eligible, workflows, ...opts });
94
+ }
95
+ finally {
96
+ inFlight.delete(exec.id);
97
+ }
98
+ return 'ran';
99
+ }
100
+ async function unwind(pass) {
101
+ const { exec, wf, store, emitter, eligible, workflows, retryFailed } = pass;
23
102
  const baseCtx = buildContext(exec);
24
- for (const [name, record] of completed) {
25
- const def = findStepDef(wf, name);
26
- if (def?.compensate) {
27
- // For forEach iterations, restore that iteration's __item/__index so the
28
- // compensate handler knows exactly which item it is rolling back.
29
- const ctx = record.loopIndex !== undefined
30
- ? {
31
- ...baseCtx,
32
- steps: { ...baseCtx.steps, __item: record.loopItem, __index: record.loopIndex },
33
- }
34
- : baseCtx;
35
- try {
36
- await def.compensate(ctx);
103
+ let haltedAt = null;
104
+ /** A store write that failed, kept so the caller still learns why the pass stopped. */
105
+ let writeFailure;
106
+ exec.state = 'compensating';
107
+ try {
108
+ store.update(exec);
109
+ }
110
+ catch (err) {
111
+ // The write that happens on EVERY unwind, and it was the last one left unguarded.
112
+ //
113
+ // Unguarded, the throw escaped with the run left `compensating` in memory and, since
114
+ // the caller on the `runNode` path has already persisted `failed`, `failed` on disk
115
+ // with zero reversals run. `listRecoverable()` covers `running|waiting|compensating`,
116
+ // so recovery never revisited it, and both operator exits require
117
+ // `compensation-stuck`, so `resumeCompensation` and `abandonCompensation` both threw.
118
+ // No reversals, no signal, and no way back in
119
+ // (`test/repro-workflow-unwind-write-failure.test.ts`).
120
+ //
121
+ // Nothing has been undone at this point, so parking is the honest state as well as
122
+ // the actionable one: the unwind is owed and has not happened.
123
+ writeFailure = err;
124
+ haltedAt = '(the compensating transition)';
125
+ }
126
+ if (writeFailure === undefined) {
127
+ emitter?.emitWorkflow('workflow:compensating', exec.id, exec.workflowName, 'compensating');
128
+ }
129
+ for (const [name, record] of eligible) {
130
+ // A pass that could not record its own state transition decides nothing.
131
+ //
132
+ // Setting `haltedAt` above is not enough on its own: `decideUnwindAction` checks the
133
+ // vanished-step case FIRST, ahead of the halted check, deliberately, so a renamed step
134
+ // still had `compensation-failed` written and a `compensation:failed` emitted in a pass
135
+ // where no handler ran and the store had already refused everything. The in-memory
136
+ // outcomes then disagreed with a disk that had received nothing, and the event pointed
137
+ // an operator at the wrong cause
138
+ // (`test/repro-workflow-unwind-write-failure.test.ts`).
139
+ if (writeFailure !== undefined)
140
+ break;
141
+ // The DECISION lives in `unwindPlan.ts` as a pure function; this loop only carries
142
+ // it out. Every rollback defect this engine shipped was in these few lines of
143
+ // reasoning rather than in the I/O around them, and while the two were tangled the
144
+ // only way to test a decision was to stand up a database and infer it from side
145
+ // effects.
146
+ const action = decideUnwindAction(wf, name, record, haltedAt !== null, retryFailed);
147
+ if (action.kind === 'stop')
148
+ break;
149
+ if (action.kind === 'skip')
150
+ continue;
151
+ if (action.kind === 'halt-failed') {
152
+ // Its outcome is already recorded and still stands: nothing to write, everything
153
+ // to stop for.
154
+ haltedAt = name;
155
+ continue;
156
+ }
157
+ if (action.kind === 'halt-vanished') {
158
+ haltedAt = name;
159
+ settle(record, 'compensation-failed', action.error);
160
+ emitter?.emitStep('compensation:failed', exec.id, exec.workflowName, name, {
161
+ error: 'step no longer declared',
162
+ });
163
+ continue;
164
+ }
165
+ emitter?.emitStep('compensation:started', exec.id, exec.workflowName, name);
166
+ try {
167
+ if (action.kind === 'unwind-child') {
168
+ await unwindChild(record, store, emitter, workflows, { retryFailed });
37
169
  }
38
- catch {
39
- // Compensation errors don't stop the chain
170
+ else {
171
+ const def = findStepDef(wf, name);
172
+ await runWithTimeout(def?.compensate?.(compensationContext(exec, baseCtx, name, record)), action.timeoutMs);
40
173
  }
174
+ settle(record, 'compensated');
175
+ emitter?.emitStep('compensation:completed', exec.id, exec.workflowName, name);
176
+ }
177
+ catch (err) {
178
+ const message = describeError(err);
179
+ settle(record, 'compensation-failed', message);
180
+ emitter?.emitStep('compensation:failed', exec.id, exec.workflowName, name, {
181
+ error: message,
182
+ });
183
+ haltedAt = name;
184
+ }
185
+ try {
186
+ store.update(exec);
187
+ }
188
+ catch (err) {
189
+ // The outcome just recorded did not reach disk.
190
+ //
191
+ // This write used to sit outside any catch, so a `SQLITE_BUSY` escaped the whole
192
+ // pass and left the run in `compensating`: `resumeCompensation` and
193
+ // `abandonCompensation` both require `compensation-stuck`, so the operator had no
194
+ // exit at all, while `listRecoverable()` DOES return `compensating` and the next
195
+ // `recover()` re-drove the pass and ran the unpersisted reversal a second time
196
+ // (`test/repro-workflow-unwind-write-failure.test.ts`).
197
+ //
198
+ // Stop here rather than carrying on. Every further reversal would have the same
199
+ // problem persisting its outcome, and an unrecorded reversal is one that runs
200
+ // again: "never twice" lost one handler at a time. The record itself is left
201
+ // exactly as it is — overwriting a reversal that provably succeeded with a write
202
+ // error would destroy the outcome this module works hardest to protect.
203
+ writeFailure = err;
204
+ haltedAt = name;
205
+ break;
41
206
  }
42
207
  }
208
+ if (haltedAt !== null) {
209
+ exec.state = 'compensation-stuck';
210
+ exec.rollbackStatus = 'stuck';
211
+ }
212
+ else {
213
+ exec.state = 'failed';
214
+ exec.rollbackStatus = 'completed';
215
+ }
216
+ try {
217
+ store.update(exec);
218
+ }
219
+ catch (err) {
220
+ // If the store is what broke, parking cannot be persisted either. The in-memory run
221
+ // is still left parked so a caller holding it sees an actionable state, and the
222
+ // ORIGINAL write error is the one that propagates: it is the cause, and this second
223
+ // failure is its consequence.
224
+ if (writeFailure === undefined)
225
+ throw err;
226
+ }
227
+ if (writeFailure !== undefined)
228
+ throw writeFailure;
229
+ }
230
+ /**
231
+ * Give up on a parked unwind: every eligible step still without an outcome is
232
+ * recorded as skipped, and the run becomes terminal. This is where "exactly one
233
+ * outcome per eligible step" is finally discharged.
234
+ */
235
+ export function abandonCompensation(exec, wf, store, emitter) {
236
+ for (const [name, record] of unwindSet(exec, wf)) {
237
+ if (record.compensation)
238
+ continue;
239
+ if (!owesOutcome(wf, name, record))
240
+ continue;
241
+ const reason = 'unwind abandoned by operator';
242
+ settle(record, 'compensation-skipped', reason);
243
+ emitter?.emitStep('compensation:skipped', exec.id, exec.workflowName, name, { error: reason });
244
+ }
43
245
  exec.state = 'failed';
246
+ exec.rollbackStatus = 'stuck';
44
247
  store.update(exec);
45
248
  }
249
+ /**
250
+ * Steps eligible for rollback, in the order they must be undone.
251
+ *
252
+ * `exec.steps` is written when a step STARTS, so reversing insertion order gives
253
+ * reverse start order — deterministic even when parallel steps finish out of
254
+ * sequence, which completion order is not.
255
+ *
256
+ * A `failed` step is included on purpose: it is the one most likely to need undoing,
257
+ * because a charge that reached the provider and then lost its response is recorded
258
+ * failed while the money has already moved.
259
+ */
260
+ function unwindSet(exec, wf) {
261
+ // The saga committed at the pivot. Backward recovery is off for the WHOLE run,
262
+ // including the steps before it — unwinding them now would contradict work the
263
+ // outside world has already been told about.
264
+ if (exec.committedAt !== undefined)
265
+ return [];
266
+ return Object.entries(exec.steps)
267
+ .filter(([name, s]) => {
268
+ if (name.startsWith('__'))
269
+ return false; // engine bookkeeping, not a user step
270
+ if (s.status !== 'completed' && s.status !== 'failed')
271
+ return false;
272
+ // `doUntil`/`doWhile` write BOTH a per-iteration record (`turn:0`, `turn:1`, ...)
273
+ // and a bare `turn` mirroring the last iteration, so downstream steps and the
274
+ // loop condition can read it by name. Compensating both would undo the final
275
+ // iteration twice. The indexed records are the real history; the mirror is not.
276
+ //
277
+ // Gated on the name actually being a loop body. Testing only for a `${name}:0`
278
+ // sibling dropped a plain step called `foo` from the unwind whenever an
279
+ // unrelated step called `foo:0` existed: `foo` finished with NO outcome while
280
+ // the run still reported `rollbackStatus: 'completed'`, which is the one
281
+ // reading an operator alerting on rollback failure must be able to trust.
282
+ if (isLoopBody(wf, name) && exec.steps[`${name}:0`] !== undefined)
283
+ return false;
284
+ // A nested workflow is eligible through its child handle rather than a step
285
+ // definition: rolling it back means running the child's own unwind.
286
+ if (name.startsWith('sub:'))
287
+ return s.childExecutionId !== undefined;
288
+ if (findStepDef(wf, name) !== null)
289
+ return true;
290
+ // The definition is gone. Two kinds of record are still owed a reversal, and
291
+ // dropping either is how a parked run whose step a deploy renamed reported
292
+ // `rollbackStatus: 'completed'` over work nobody undid: nothing was left to halt
293
+ // on, so the unwind reached the end and called itself clean.
294
+ //
295
+ // One already carries an outcome, so it was part of the set when the earlier
296
+ // attempt ran. The other carries none but ran WITH a handler, which is the
297
+ // likelier case, because the unwind simply had not reached it yet
298
+ // (`test/repro-workflow-nested-and-settled.test.ts`).
299
+ return s.compensation !== undefined || s.compensatable === true;
300
+ })
301
+ .reverse();
302
+ }
303
+ /**
304
+ * Roll back a nested workflow by running the CHILD's own unwind.
305
+ *
306
+ * A child that succeeded before its parent failed used to be left untouched: every
307
+ * resource it created stayed live with nothing pointing at it. Its compensation is
308
+ * not a handler the parent can call — it is the child's whole rollback.
309
+ *
310
+ * If the child parks (`compensation-stuck`), the parent inherits it: this throws, so
311
+ * the parent's own unwind halts and parks too. A half-rolled-back child is not
312
+ * something the parent can paper over.
313
+ */
314
+ async function unwindChild(record, store, emitter, workflows,
315
+ /**
316
+ * Forwarded from the parent's own pass.
317
+ *
318
+ * Without it `resumeCompensation(parentId)` on a nested saga was a silent no-op that
319
+ * resolved successfully: the retry stopped at the parent, the child halted on its own
320
+ * `compensation-failed`, `unwindChild` threw, and the parent re-parked. The operator
321
+ * got a clean return for an action that did nothing, which is the same anti-pattern
322
+ * the signal API was just fixed for, and the guide names this exact call as the way
323
+ * out of a parent that inherited its child's park.
324
+ */
325
+ opts = {}) {
326
+ const childId = record.childExecutionId;
327
+ if (!childId)
328
+ throw new Error('sub-workflow record carries no child execution id');
329
+ if (!workflows)
330
+ throw new Error('workflow registry unavailable to unwind a sub-workflow');
331
+ const child = store.get(childId);
332
+ if (!child)
333
+ throw new Error(`child execution "${childId}" not found`);
334
+ const childWf = workflows.get(child.workflowName);
335
+ if (!childWf)
336
+ throw new Error(`child workflow "${child.workflowName}" is not registered`);
337
+ // A child that has NOT stopped is never rolled back. Running its reversals while it is
338
+ // still stepping forward puts two writers on one row: the child's own `advance()`
339
+ // overwrites the compensation from its stale snapshot, compensate handlers interleave
340
+ // with forward steps, and the child can go on to reach `completed` with its reversals
341
+ // already executed.
342
+ //
343
+ // The parent reaches this legitimately. `executeSubWorkflow` gives up after a hardcoded
344
+ // 300 second ceiling, which the guide documents as a supported case, and the parent
345
+ // then settles its `sub:` record `failed` while the child is very much alive. Refusing
346
+ // here rather than at the settle keeps the record truthful: the parent's node DID fail.
347
+ // The parent still does not claim a clean rollback, it parks for an operator, which is
348
+ // the correct outcome for "the child may or may not still be changing the world".
349
+ if (isLive(child.state)) {
350
+ throw new Error(`child "${child.workflowName}" (${childId}) is still ${child.state}; it cannot be rolled back until it stops`);
351
+ }
352
+ const outcome = await runCompensation(child, childWf, store, emitter, workflows, opts);
353
+ // Another driver holds this child's unwind. It may still fail, so recording the
354
+ // parent's `sub:` record as compensated here would claim a rollback whose result is
355
+ // not yet known, and nothing would ever correct it.
356
+ if (outcome === 'claim-lost') {
357
+ throw new Error(`child "${child.workflowName}" (${childId}) is being rolled back by another driver; outcome unknown`);
358
+ }
359
+ if (child.rollbackStatus === 'stuck') {
360
+ throw new Error(`child "${child.workflowName}" (${childId}) parked mid-rollback`);
361
+ }
362
+ // A child past its own .pivot() is committed: nothing of it was undone. Reporting
363
+ // the parent's `sub:` record as 'compensated' would claim a rollback that provably
364
+ // did not happen, and an operator reading the parent would see a clean unwind over
365
+ // a child that still holds every effect it created.
366
+ if (child.rollbackStatus === 'not-applicable' && child.committedAt !== undefined) {
367
+ throw new CommittedChildError(child.workflowName, childId);
368
+ }
369
+ }
370
+ /**
371
+ * A sub-workflow that passed its own pivot cannot be rolled back. Distinct from a
372
+ * handler failure so the parent's record carries the reason rather than a generic
373
+ * error, and so the parent parks (operator decision) instead of silently claiming
374
+ * success.
375
+ */
376
+ export class CommittedChildError extends Error {
377
+ constructor(workflowName, executionId) {
378
+ super(`child "${workflowName}" (${executionId}) is committed past its pivot; nothing was rolled back`);
379
+ this.name = 'CommittedChildError';
380
+ }
381
+ }
382
+ /** Is `name` the body of a doUntil/doWhile loop, i.e. a step that writes a mirror record? */
383
+ function isLoopBody(wf, name) {
384
+ return wf.nodes.some((node) => (node.type === 'doUntil' || node.type === 'doWhile') &&
385
+ node.def.steps.some((s) => s.name === name));
386
+ }
387
+ function settle(record, status, error) {
388
+ record.compensation = { status, at: clock().now(), ...(error ? { error } : {}) };
389
+ }
390
+ /**
391
+ * Context handed to a compensate handler.
392
+ *
393
+ * Beyond the run's own data it carries the identity needed to reconcile: this
394
+ * rollback's key, and the key the forward step used. When the forward outcome is in
395
+ * doubt the handler can ask the provider what actually happened by key rather than
396
+ * depending on an output that may never have been persisted.
397
+ */
398
+ function compensationContext(exec, baseCtx, name, record) {
399
+ const occurrence = record.occurrence ?? 0;
400
+ // Rebind the step's bare name to THIS record's own result.
401
+ //
402
+ // `buildContext` exposes results under bare names, and a loop's bare name mirrors
403
+ // its LAST iteration. Without this, every iteration's compensate handler reads
404
+ // `ctx.steps.charge` and sees the final charge: three charges produced three
405
+ // refunds of the third one. `base` is the name without the `:index` suffix, so a
406
+ // non-loop step rebinds to itself, which is a no-op.
407
+ // Only a numeric suffix is an iteration. Stripping at the last colon rebound
408
+ // `payment:charge` onto `payment`, feeding that handler a sibling step's result.
409
+ const base = loopBaseName(name);
410
+ const ctx = {
411
+ ...baseCtx,
412
+ steps: { ...baseCtx.steps, [base]: record.result, [name]: record.result },
413
+ idempotencyKey: idempotencyKey(exec.id, name, occurrence, 'compensate'),
414
+ forwardIdempotencyKey: record.idempotencyKey,
415
+ };
416
+ // For forEach iterations, restore that iteration's __item/__index so the handler
417
+ // knows exactly which item it is rolling back.
418
+ if (record.loopIndex === undefined)
419
+ return ctx;
420
+ return {
421
+ ...ctx,
422
+ steps: { ...ctx.steps, __item: record.loopItem, __index: record.loopIndex },
423
+ };
424
+ }
@@ -9,7 +9,7 @@ export declare class WorkflowEmitter {
9
9
  onAny(listener: WorkflowEventListener): this;
10
10
  off(type: WorkflowEventType, listener: WorkflowEventListener): this;
11
11
  offAny(listener: WorkflowEventListener): this;
12
- emitStep(type: 'step:started' | 'step:completed' | 'step:failed' | 'step:retry', executionId: string, workflowName: string, stepName: string, extra?: Partial<Omit<StepEvent, 'type' | 'executionId' | 'workflowName' | 'timestamp' | 'stepName'>>): void;
12
+ emitStep(type: 'step:started' | 'step:completed' | 'step:failed' | 'step:retry' | 'compensation:started' | 'compensation:completed' | 'compensation:failed' | 'compensation:skipped', executionId: string, workflowName: string, stepName: string, extra?: Partial<Omit<StepEvent, 'type' | 'executionId' | 'workflowName' | 'timestamp' | 'stepName'>>): void;
13
13
  emitWorkflow(type: 'workflow:started' | 'workflow:completed' | 'workflow:failed' | 'workflow:compensating' | 'workflow:waiting', executionId: string, workflowName: string, state: ExecutionState, extra?: Partial<Omit<WorkflowLifecycleEvent, 'type' | 'executionId' | 'workflowName' | 'timestamp' | 'state'>>): void;
14
14
  emitSignal(type: 'signal:received' | 'signal:timeout', executionId: string, workflowName: string, event: string, payload?: unknown): void;
15
15
  removeAllListeners(): void;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * WorkflowEmitter - Typed event system for workflow observability
3
3
  */
4
+ import { clock } from './clock';
4
5
  export class WorkflowEmitter {
5
6
  listeners = new Map();
6
7
  globalListeners = new Set();
@@ -30,7 +31,7 @@ export class WorkflowEmitter {
30
31
  type,
31
32
  executionId,
32
33
  workflowName,
33
- timestamp: Date.now(),
34
+ timestamp: clock().now(),
34
35
  stepName,
35
36
  ...extra,
36
37
  };
@@ -41,7 +42,7 @@ export class WorkflowEmitter {
41
42
  type,
42
43
  executionId,
43
44
  workflowName,
44
- timestamp: Date.now(),
45
+ timestamp: clock().now(),
45
46
  state,
46
47
  ...extra,
47
48
  };
@@ -52,7 +53,7 @@ export class WorkflowEmitter {
52
53
  type,
53
54
  executionId,
54
55
  workflowName,
55
- timestamp: Date.now(),
56
+ timestamp: clock().now(),
56
57
  event,
57
58
  payload,
58
59
  };
@@ -19,6 +19,23 @@ export declare class Engine {
19
19
  getExecution(id: string): Execution | null;
20
20
  /** List executions with optional filters */
21
21
  listExecutions(workflowName?: string, state?: ExecutionState): Execution[];
22
+ /**
23
+ * Retry the compensation that parked a `compensation-stuck` run and continue the
24
+ * unwind. Use once the cause of the failed reversal has been fixed.
25
+ */
26
+ resumeCompensation(executionId: string): Promise<void>;
27
+ /**
28
+ * Abandon a parked unwind: the steps still un-compensated are recorded as skipped
29
+ * and the run becomes terminal. Partial, but explicitly so.
30
+ */
31
+ /**
32
+ * `async` on purpose, even though the work is synchronous: `resumeCompensation` is
33
+ * async, and an operator writing recovery code under pressure reaches for
34
+ * `Promise.allSettled([resume(id), abandon(id)])`. With a sync throw that expression
35
+ * throws before `allSettled` is ever called, so the defensive form is the one that
36
+ * blows up. Matching the sibling costs nothing while the API is experimental.
37
+ */
38
+ abandonCompensation(executionId: string): Promise<void>;
22
39
  /** Send a signal to a waiting execution */
23
40
  signal(executionId: string, event: string, payload?: unknown): Promise<void>;
24
41
  /**
@@ -54,6 +54,27 @@ export class Engine {
54
54
  listExecutions(workflowName, state) {
55
55
  return this.executor.listExecutions(workflowName, state);
56
56
  }
57
+ /**
58
+ * Retry the compensation that parked a `compensation-stuck` run and continue the
59
+ * unwind. Use once the cause of the failed reversal has been fixed.
60
+ */
61
+ async resumeCompensation(executionId) {
62
+ return this.executor.resumeCompensation(executionId);
63
+ }
64
+ /**
65
+ * Abandon a parked unwind: the steps still un-compensated are recorded as skipped
66
+ * and the run becomes terminal. Partial, but explicitly so.
67
+ */
68
+ /**
69
+ * `async` on purpose, even though the work is synchronous: `resumeCompensation` is
70
+ * async, and an operator writing recovery code under pressure reaches for
71
+ * `Promise.allSettled([resume(id), abandon(id)])`. With a sync throw that expression
72
+ * throws before `allSettled` is ever called, so the defensive form is the one that
73
+ * blows up. Matching the sibling costs nothing while the API is experimental.
74
+ */
75
+ async abandonCompensation(executionId) {
76
+ this.executor.abandonCompensation(executionId);
77
+ }
57
78
  /** Send a signal to a waiting execution */
58
79
  async signal(executionId, event, payload) {
59
80
  return this.executor.signal(executionId, event, payload);
@@ -112,6 +133,10 @@ export class Engine {
112
133
  }
113
134
  /** Shut down the engine */
114
135
  async close(force = false) {
136
+ // Before the worker: a waitFor timer that fires during shutdown enqueues a step
137
+ // job, and a queue closing underneath it turns an orderly shutdown into a rejected
138
+ // add. Releasing the timers first makes the order irrelevant.
139
+ this.executor.close();
115
140
  await this.worker.close(force);
116
141
  this.queue.close();
117
142
  this.store.close();
@@ -10,12 +10,40 @@ export declare class WorkflowExecutor {
10
10
  private readonly emitter;
11
11
  private readonly workflows;
12
12
  private readonly timeoutTimers;
13
+ /**
14
+ * Release every armed waitFor timer. The engine owns the executor's lifetime, so
15
+ * `Engine.close()` must call this: an armed timer would otherwise fire into a
16
+ * closing queue, and a caller that keeps the process alive after closing one engine
17
+ * has no other handle on them.
18
+ */
19
+ close(): void;
13
20
  private readonly updateFn;
14
21
  constructor(store: WorkflowStore, queue: Queue, emitter?: WorkflowEmitter | null);
15
22
  register(workflow: Workflow): void;
16
- start(workflowName: string, input: unknown): Promise<RunHandle>;
23
+ start(workflowName: string, input: unknown, parentExecutionId?: string): Promise<RunHandle>;
24
+ /**
25
+ * Nodes this process is currently executing, keyed `<execution>:<nodeIndex>`.
26
+ *
27
+ * The cursor guard below rejects a job for a node the run has already left, but not
28
+ * a SECOND job for the node it is on right now: both carry the same index. That is
29
+ * the reachable duplicate, because `recover()` re-enqueues the current node of every
30
+ * `running` execution and is documented as callable on a live engine. Without this
31
+ * claim the node runs twice and each copy advances the run independently, doubling
32
+ * every side effect after it while the run still ends `completed`.
33
+ *
34
+ * A claim, not a queue-level dedup: a deterministic `jobId` was tried and could
35
+ * swallow a LEGITIMATE later re-enqueue of the same node, wedging the run forever.
36
+ * This drops only a duplicate that overlaps in time.
37
+ */
38
+ private readonly nodesInFlight;
17
39
  processStep(data: StepJobData): Promise<unknown>;
40
+ private runNode;
18
41
  signal(executionId: string, event: string, payload: unknown): Promise<void>;
42
+ /** Retry the compensation that parked the run, then finish the unwind. */
43
+ resumeCompensation(executionId: string): Promise<void>;
44
+ /** Give up on a parked unwind, recording the outstanding steps as skipped. */
45
+ abandonCompensation(executionId: string): void;
46
+ private get rollbackDeps();
19
47
  getExecution(id: string): Execution | null;
20
48
  listExecutions(wfName?: string, state?: Execution['state']): Execution[];
21
49
  private executeNode;
@@ -23,12 +51,19 @@ export declare class WorkflowExecutor {
23
51
  private runBranch;
24
52
  private runParallel;
25
53
  private runSubWorkflow;
26
- private runWaitFor;
27
- private runLoop;
28
- private runForEach;
29
- private runMap;
54
+ /**
55
+ * Commit the saga. From here the run can still fail, but it can no longer be
56
+ * rolled back: recovery past this point is forward-only.
57
+ */
58
+ private runPivot;
59
+ /**
60
+ * Run a node body that manages its own step records (loops, forEach, map), then
61
+ * move on. These differ only in which executor they delegate to.
62
+ */
63
+ private runBody;
30
64
  private advance;
31
65
  private enqueue;
66
+ private get waitForDeps();
32
67
  private scheduleTimeoutCheck;
33
68
  private compensate;
34
69
  /** Recover orphaned executions after a crash/restart */