bunqueue 2.8.46 → 2.8.48
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/client/workflow/admission.d.ts +45 -0
- package/dist/client/workflow/admission.js +50 -0
- package/dist/client/workflow/clock.d.ts +58 -0
- package/dist/client/workflow/clock.js +104 -0
- package/dist/client/workflow/compensator.d.ts +56 -3
- package/dist/client/workflow/compensator.js +406 -27
- package/dist/client/workflow/emitter.d.ts +1 -1
- package/dist/client/workflow/emitter.js +4 -3
- package/dist/client/workflow/engine.d.ts +17 -0
- package/dist/client/workflow/engine.js +25 -0
- package/dist/client/workflow/executor.d.ts +40 -5
- package/dist/client/workflow/executor.js +227 -83
- package/dist/client/workflow/identity.d.ts +46 -0
- package/dist/client/workflow/identity.js +93 -0
- package/dist/client/workflow/index.d.ts +1 -1
- package/dist/client/workflow/loops.js +147 -14
- package/dist/client/workflow/recovery.d.ts +10 -1
- package/dist/client/workflow/recovery.js +36 -9
- package/dist/client/workflow/rollbackControl.d.ts +36 -0
- package/dist/client/workflow/rollbackControl.js +51 -0
- package/dist/client/workflow/runner.d.ts +22 -2
- package/dist/client/workflow/runner.js +125 -27
- package/dist/client/workflow/store.d.ts +64 -4
- package/dist/client/workflow/store.js +123 -30
- package/dist/client/workflow/storeCodec.d.ts +7 -0
- package/dist/client/workflow/storeCodec.js +16 -0
- package/dist/client/workflow/storeSignals.d.ts +61 -0
- package/dist/client/workflow/storeSignals.js +118 -0
- package/dist/client/workflow/types.d.ts +147 -4
- package/dist/client/workflow/unwindPlan.d.ts +87 -0
- package/dist/client/workflow/unwindPlan.js +142 -0
- package/dist/client/workflow/waitFor.d.ts +52 -0
- package/dist/client/workflow/waitFor.js +137 -0
- package/dist/client/workflow/workflow.d.ts +71 -1
- package/dist/client/workflow/workflow.js +184 -14
- package/package.json +18 -7
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 */
|