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
|
@@ -3,6 +3,99 @@
|
|
|
3
3
|
* Handles doUntil, doWhile, forEach, and map node types.
|
|
4
4
|
*/
|
|
5
5
|
import { executeStepWithRetry, buildContext } from './runner';
|
|
6
|
+
import { clock } from './clock';
|
|
7
|
+
/**
|
|
8
|
+
* Run one iteration of a loop body step, unless it already ran.
|
|
9
|
+
*
|
|
10
|
+
* Two properties depend on this, and both were broken:
|
|
11
|
+
*
|
|
12
|
+
* TRANSCRIPT. `exec.steps[name]` is overwritten every iteration, so on its own a
|
|
13
|
+
* loop keeps a sliding window of exactly one turn — by turn 3 the transcript of
|
|
14
|
+
* turn 1 is gone and an agent is asked to reason without its own history
|
|
15
|
+
* (test/workflow-ai-sdk-agent.test.ts measured prompt sizes 1, 3, 3 where they must
|
|
16
|
+
* be 1, 3, 5). Each iteration is therefore also kept under `name:iteration`.
|
|
17
|
+
*
|
|
18
|
+
* RESUME. A loop is a single job, so re-entering the node used to replay every
|
|
19
|
+
* iteration from zero: an agent killed at turn 7 re-paid for turns 1-6, and the
|
|
20
|
+
* restarted in-memory counter overwrote `turn:0` and `turn:1` with turn 2's content,
|
|
21
|
+
* corrupting the very transcript above. Memoising against the persisted per-iteration
|
|
22
|
+
* record fixes both at once — the counter realigns naturally, because iteration 0
|
|
23
|
+
* finds `turn:0` already complete and skips it.
|
|
24
|
+
*
|
|
25
|
+
* Only `completed` is skipped. An iteration left `running` by a crash is genuinely
|
|
26
|
+
* unfinished and must run again.
|
|
27
|
+
*
|
|
28
|
+
* The base name keeps holding the LAST iteration, which is the documented contract
|
|
29
|
+
* for downstream steps.
|
|
30
|
+
*
|
|
31
|
+
* The indexed copies ARE the unwind set: `findStepDef` resolves `turn:0` through a
|
|
32
|
+
* second, iteration-only pass, and `unwindSet` excludes the bare `turn` mirror so the
|
|
33
|
+
* last iteration is not compensated twice. An earlier version of this comment said
|
|
34
|
+
* the opposite, which was true only while a loop compensated its last iteration
|
|
35
|
+
* alone.
|
|
36
|
+
*/
|
|
37
|
+
async function runIteration(step, exec, iteration, emitter, updateFn) {
|
|
38
|
+
const indexed = `${step.name}:${iteration}`;
|
|
39
|
+
const already = exec.steps[indexed];
|
|
40
|
+
if (already?.status === 'completed') {
|
|
41
|
+
// Restore the base name so the loop condition and downstream steps see this
|
|
42
|
+
// iteration's result without the body running a second time.
|
|
43
|
+
exec.steps[step.name] = { ...already };
|
|
44
|
+
updateFn(exec);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let thrown;
|
|
48
|
+
let threw = false;
|
|
49
|
+
try {
|
|
50
|
+
await executeStepWithRetry(step, buildContext(exec), exec, { emitter, updateFn }, iteration);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
// Caught rather than left to a `finally`, because the mirror write below must not be
|
|
54
|
+
// able to REPLACE this error. An exception thrown from a `finally` supersedes the one
|
|
55
|
+
// in flight, so a store that refused the mirror write turned "provider timeout after
|
|
56
|
+
// the charge settled" into "SQLITE_BUSY": that message is what `failureReason`
|
|
57
|
+
// records, so the operator lost the only account of what actually went wrong
|
|
58
|
+
// (`test/repro-workflow-loop-failed-iteration.test.ts`).
|
|
59
|
+
thrown = error;
|
|
60
|
+
threw = true;
|
|
61
|
+
}
|
|
62
|
+
{
|
|
63
|
+
// Writing the mirror on BOTH paths is load-bearing, for the same reason
|
|
64
|
+
// `executeForEach` writes its own in a `finally`.
|
|
65
|
+
//
|
|
66
|
+
// The indexed copies ARE the unwind set, and `unwindSet` drops the bare mirror
|
|
67
|
+
// whenever a `${name}:0` sibling exists, because that mirror is the LAST iteration
|
|
68
|
+
// and compensating it as well would undo that iteration twice. Written only on
|
|
69
|
+
// success, the iteration that THREW existed under the bare name alone, so the two
|
|
70
|
+
// rules combined to lose it: a `doUntil` that charged on every turn and failed on
|
|
71
|
+
// turn 2 refunded turns 0 and 1, left turn 2's charge standing, and still reported
|
|
72
|
+
// `rollbackStatus: 'completed'`. That record was unreachable even by
|
|
73
|
+
// `abandonCompensation`, which walks the same set
|
|
74
|
+
// (`test/repro-workflow-loop-failed-iteration.test.ts`).
|
|
75
|
+
//
|
|
76
|
+
// A failed iteration is the one MOST likely to need undoing: a charge that reached
|
|
77
|
+
// the provider and then lost its response is recorded failed while the money has
|
|
78
|
+
// already moved.
|
|
79
|
+
const record = exec.steps[step.name];
|
|
80
|
+
if (record) {
|
|
81
|
+
// The in-memory copy is what the unwind reads, so it happens regardless. Only the
|
|
82
|
+
// PERSIST can fail, and when the step itself failed, that step's error is the cause
|
|
83
|
+
// and a write that fails while reporting it is downstream. With no step error to
|
|
84
|
+
// protect, a write that cannot be persisted is the failure and must surface: an
|
|
85
|
+
// iteration whose record never reached disk is one whose reversal runs again.
|
|
86
|
+
exec.steps[indexed] = { ...record };
|
|
87
|
+
try {
|
|
88
|
+
updateFn(exec);
|
|
89
|
+
}
|
|
90
|
+
catch (writeError) {
|
|
91
|
+
if (!threw)
|
|
92
|
+
throw writeError;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (threw)
|
|
97
|
+
throw thrown;
|
|
98
|
+
}
|
|
6
99
|
/** Execute a doUntil loop: run steps, then check condition. Repeat until condition returns true. */
|
|
7
100
|
export async function executeDoUntil(def, exec, emitter, updateFn) {
|
|
8
101
|
let iteration = 0;
|
|
@@ -12,8 +105,7 @@ export async function executeDoUntil(def, exec, emitter, updateFn) {
|
|
|
12
105
|
throw new Error(`doUntil exceeded maxIterations (${def.maxIterations})`);
|
|
13
106
|
}
|
|
14
107
|
for (const step of def.steps) {
|
|
15
|
-
|
|
16
|
-
await executeStepWithRetry(step, ctx, exec, emitter, updateFn);
|
|
108
|
+
await runIteration(step, exec, iteration, emitter, updateFn);
|
|
17
109
|
}
|
|
18
110
|
iteration++;
|
|
19
111
|
const ctx = buildContext(exec);
|
|
@@ -31,8 +123,7 @@ export async function executeDoWhile(def, exec, emitter, updateFn) {
|
|
|
31
123
|
throw new Error(`doWhile exceeded maxIterations (${def.maxIterations})`);
|
|
32
124
|
}
|
|
33
125
|
for (const step of def.steps) {
|
|
34
|
-
|
|
35
|
-
await executeStepWithRetry(step, stepCtx, exec, emitter, updateFn);
|
|
126
|
+
await runIteration(step, exec, iteration, emitter, updateFn);
|
|
36
127
|
}
|
|
37
128
|
}
|
|
38
129
|
}
|
|
@@ -40,6 +131,16 @@ export async function executeDoWhile(def, exec, emitter, updateFn) {
|
|
|
40
131
|
export async function executeForEach(def, exec, emitter, updateFn) {
|
|
41
132
|
const ctx = buildContext(exec);
|
|
42
133
|
const items = def.items(ctx);
|
|
134
|
+
// Anything with a `length` used to be accepted, and JavaScript is generous about
|
|
135
|
+
// what has one. A number iterated ZERO times and the run reported `completed`, so a
|
|
136
|
+
// batch that processed nothing was indistinguishable from a batch with nothing to
|
|
137
|
+
// do. A string iterated its CHARACTERS, so an id list that arrived as `'u1,u2'`
|
|
138
|
+
// silently processed five "items" nobody passed. `null`/`undefined` were caught only
|
|
139
|
+
// by accident, because reading `.length` throws. The likely production shapes were
|
|
140
|
+
// exactly the ones that passed (`test/repro-workflow-foreach-non-array.test.ts`).
|
|
141
|
+
if (!Array.isArray(items)) {
|
|
142
|
+
throw new Error(`forEach items must be an array, got ${items === null ? 'null' : typeof items}`);
|
|
143
|
+
}
|
|
43
144
|
if (items.length > def.maxIterations) {
|
|
44
145
|
throw new Error(`forEach items (${items.length}) exceeds maxIterations (${def.maxIterations})`);
|
|
45
146
|
}
|
|
@@ -57,15 +158,47 @@ export async function executeForEach(def, exec, emitter, updateFn) {
|
|
|
57
158
|
return def.step.handler(enrichedCtx);
|
|
58
159
|
},
|
|
59
160
|
};
|
|
161
|
+
// Memoised the same way as doUntil/doWhile: an item already provisioned before a
|
|
162
|
+
// crash must not be provisioned again when the node is re-entered.
|
|
163
|
+
if (exec.steps[indexedName]?.status === 'completed')
|
|
164
|
+
continue;
|
|
60
165
|
const stepCtx = buildContext(exec);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
166
|
+
let thrown;
|
|
167
|
+
let threw = false;
|
|
168
|
+
try {
|
|
169
|
+
await executeStepWithRetry(indexedStep, stepCtx, exec, { emitter, updateFn }, i);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
// Same reason `runIteration` catches instead of using a `finally`: a write that
|
|
173
|
+
// fails below must not REPLACE the step's own error, which is what `failureReason`
|
|
174
|
+
// records and the only account of what went wrong.
|
|
175
|
+
thrown = error;
|
|
176
|
+
threw = true;
|
|
177
|
+
}
|
|
178
|
+
{
|
|
179
|
+
// Persist this iteration's __item/__index alongside the step record so saga
|
|
180
|
+
// compensation can restore the correct per-iteration context later.
|
|
181
|
+
//
|
|
182
|
+
// Recording on BOTH paths is load-bearing: the iteration that THROWS is itself
|
|
183
|
+
// eligible for compensation, and without its item recorded its compensate handler
|
|
184
|
+
// is handed an undefined `__item` and silently releases nothing — the reservation
|
|
185
|
+
// it had already taken at the warehouse leaks
|
|
186
|
+
// (test/workflow-saga-extreme.test.ts).
|
|
187
|
+
const record = exec.steps[indexedName];
|
|
188
|
+
if (record) {
|
|
189
|
+
record.loopItem = item;
|
|
190
|
+
record.loopIndex = i;
|
|
191
|
+
try {
|
|
192
|
+
updateFn(exec);
|
|
193
|
+
}
|
|
194
|
+
catch (writeError) {
|
|
195
|
+
if (!threw)
|
|
196
|
+
throw writeError;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (threw)
|
|
201
|
+
throw thrown;
|
|
69
202
|
}
|
|
70
203
|
}
|
|
71
204
|
/** Execute a map node: transform step results into a new value */
|
|
@@ -76,8 +209,8 @@ export async function executeMap(def, exec, emitter, updateFn) {
|
|
|
76
209
|
exec.steps[def.name] = {
|
|
77
210
|
status: 'completed',
|
|
78
211
|
result,
|
|
79
|
-
startedAt:
|
|
80
|
-
completedAt:
|
|
212
|
+
startedAt: clock().now(),
|
|
213
|
+
completedAt: clock().now(),
|
|
81
214
|
};
|
|
82
215
|
updateFn(exec);
|
|
83
216
|
emitter?.emitStep('step:completed', exec.id, exec.workflowName, def.name, { result });
|
|
@@ -11,12 +11,21 @@ import type { Workflow } from './workflow';
|
|
|
11
11
|
import type { WorkflowStore } from './store';
|
|
12
12
|
import type { WorkflowEmitter } from './emitter';
|
|
13
13
|
import type { RecoverResult } from './types';
|
|
14
|
+
import { type TimerHandle } from './clock';
|
|
14
15
|
export interface RecoverDeps {
|
|
15
16
|
store: WorkflowStore;
|
|
16
17
|
queue: Queue;
|
|
17
18
|
workflows: Map<string, Workflow>;
|
|
18
19
|
emitter: WorkflowEmitter | null;
|
|
19
|
-
timeoutTimers: Map<string,
|
|
20
|
+
timeoutTimers: Map<string, TimerHandle>;
|
|
20
21
|
scheduleTimeoutCheck: (id: string, wfName: string, nodeIdx: number, ms: number) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Nodes this engine is executing right now. Recovery consults it for the same reason
|
|
24
|
+
* the `compensating` branch consults the unwind claim: re-enqueueing a node that is
|
|
25
|
+
* already in flight produces a job the admission check will reject, and counting it
|
|
26
|
+
* as a recovery reports work that did not happen. A step running without a bound is
|
|
27
|
+
* the ordinary way to reach this.
|
|
28
|
+
*/
|
|
29
|
+
nodesInFlight: ReadonlySet<string>;
|
|
21
30
|
}
|
|
22
31
|
export declare function recoverExecutions(deps: RecoverDeps): Promise<RecoverResult>;
|
|
@@ -7,25 +7,47 @@
|
|
|
7
7
|
* - 'compensating': re-run compensation from the beginning (must be idempotent)
|
|
8
8
|
*/
|
|
9
9
|
import { runCompensation } from './compensator';
|
|
10
|
+
import { hasSignal } from './storeSignals';
|
|
11
|
+
import { clock } from './clock';
|
|
12
|
+
import { decideAdmission } from './admission';
|
|
10
13
|
export async function recoverExecutions(deps) {
|
|
11
14
|
const { store, workflows } = deps;
|
|
12
15
|
const executions = store.listRecoverable();
|
|
13
16
|
const result = { running: 0, waiting: 0, compensating: 0, total: 0 };
|
|
14
|
-
for (const
|
|
15
|
-
const wf = workflows.get(
|
|
17
|
+
for (const snapshot of executions) {
|
|
18
|
+
const wf = workflows.get(snapshot.workflowName);
|
|
16
19
|
if (!wf)
|
|
17
20
|
continue;
|
|
21
|
+
// Re-read before driving it. The list above is a single snapshot taken before the
|
|
22
|
+
// loop, and each iteration awaits: driving a parent runs its sub-workflow's unwind
|
|
23
|
+
// and settles the CHILD's records in the store, but this loop still holds the
|
|
24
|
+
// child's pre-unwind copy. Using that copy, every `if (record.compensation)`
|
|
25
|
+
// guard reads an unsettled snapshot and dispatches the same handlers a second
|
|
26
|
+
// time, issuing a duplicate refund and leaving no trace: both rows end `failed`
|
|
27
|
+
// with `rollbackStatus: 'completed'`. The in-flight `inFlight` claim does not
|
|
28
|
+
// help here, because the two unwinds are sequential, not concurrent.
|
|
29
|
+
const exec = store.get(snapshot.id);
|
|
30
|
+
if (!exec)
|
|
31
|
+
continue;
|
|
18
32
|
if (exec.state === 'running') {
|
|
19
|
-
|
|
20
|
-
|
|
33
|
+
const admission = decideAdmission(exec, exec.currentNodeIndex, deps.nodesInFlight);
|
|
34
|
+
// Only `already-in-flight` is reachable here: the state is `running` by the
|
|
35
|
+
// branch and the index is the execution's own cursor.
|
|
36
|
+
if (admission.kind === 'run') {
|
|
37
|
+
await enqueueExecution(exec, deps.queue);
|
|
38
|
+
result.running++;
|
|
39
|
+
}
|
|
21
40
|
}
|
|
22
41
|
else if (exec.state === 'waiting') {
|
|
23
42
|
await recoverWaiting(exec, wf, deps);
|
|
24
43
|
result.waiting++;
|
|
25
44
|
}
|
|
26
45
|
else if (exec.state === 'compensating') {
|
|
27
|
-
|
|
28
|
-
|
|
46
|
+
// A lost claim means another driver owns this unwind, so nothing happened here
|
|
47
|
+
// and counting it as a recovery would overstate what recover() did.
|
|
48
|
+
const outcome = await runCompensation(exec, wf, store, deps.emitter, deps.workflows);
|
|
49
|
+
if (outcome === 'ran')
|
|
50
|
+
result.compensating++;
|
|
29
51
|
}
|
|
30
52
|
}
|
|
31
53
|
result.total = result.running + result.waiting + result.compensating;
|
|
@@ -37,6 +59,10 @@ async function enqueueExecution(exec, queue) {
|
|
|
37
59
|
workflowName: exec.workflowName,
|
|
38
60
|
nodeIndex: exec.currentNodeIndex,
|
|
39
61
|
};
|
|
62
|
+
// No deterministic jobId, for the reason documented in WorkflowExecutor.enqueue:
|
|
63
|
+
// the duplicate this path can create is neutralised by the cursor guard in
|
|
64
|
+
// processStep, and dedup here risks swallowing a legitimate re-enqueue after a
|
|
65
|
+
// restart, which wedges the run for good.
|
|
40
66
|
await queue.add('wf:step', jobData);
|
|
41
67
|
}
|
|
42
68
|
async function recoverWaiting(exec, wf, deps) {
|
|
@@ -45,8 +71,9 @@ async function recoverWaiting(exec, wf, deps) {
|
|
|
45
71
|
await enqueueExecution(exec, deps.queue);
|
|
46
72
|
return;
|
|
47
73
|
}
|
|
48
|
-
// Check if signal already arrived while we were down
|
|
49
|
-
|
|
74
|
+
// Check if signal already arrived while we were down. Key presence, not value:
|
|
75
|
+
// a payload-less signal records the key with an `undefined` value (see hasSignal).
|
|
76
|
+
if (hasSignal(exec.signals, node.event)) {
|
|
50
77
|
exec.state = 'running';
|
|
51
78
|
deps.store.update(exec);
|
|
52
79
|
await enqueueExecution(exec, deps.queue);
|
|
@@ -59,7 +86,7 @@ async function recoverWaiting(exec, wf, deps) {
|
|
|
59
86
|
const waitKey = `__waitFor:${node.event}`;
|
|
60
87
|
const waitRecord = exec.steps[waitKey];
|
|
61
88
|
const waitingSince = waitRecord?.startedAt ?? exec.updatedAt;
|
|
62
|
-
const elapsed =
|
|
89
|
+
const elapsed = clock().now() - waitingSince;
|
|
63
90
|
const remaining = node.timeout - elapsed;
|
|
64
91
|
if (remaining <= 0) {
|
|
65
92
|
exec.state = 'running';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator actions on a parked unwind.
|
|
3
|
+
*
|
|
4
|
+
* A `compensation-stuck` run is waiting for a human decision, which is why the state
|
|
5
|
+
* is not terminal: the engine has done everything it safely can, and the remaining
|
|
6
|
+
* choice, was the reversal fixed or do we accept a partial rollback, is not one it can
|
|
7
|
+
* make. Both exits are explicit, and both leave the execution record saying exactly
|
|
8
|
+
* what happened.
|
|
9
|
+
*/
|
|
10
|
+
import type { Workflow } from './workflow';
|
|
11
|
+
import type { WorkflowStore } from './store';
|
|
12
|
+
import type { WorkflowEmitter } from './emitter';
|
|
13
|
+
export interface RollbackDeps {
|
|
14
|
+
store: WorkflowStore;
|
|
15
|
+
emitter: WorkflowEmitter | null;
|
|
16
|
+
workflows: Map<string, Workflow>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Retry the compensation that parked the run, then carry on with the rest.
|
|
20
|
+
*
|
|
21
|
+
* The retry is asked for with a FLAG rather than by clearing the failed outcome first.
|
|
22
|
+
* Clearing worked, and it cost more than it bought: it wiped the operator's
|
|
23
|
+
* `compensation-failed` record in memory and persisted that wipe BEFORE running
|
|
24
|
+
* anything, so a resume that then met a failing store left a run whose durable row had
|
|
25
|
+
* lost the very diagnostic the operator was acting on, sitting in `compensating` and
|
|
26
|
+
* therefore handed back by `listRecoverable()` at every subsequent startup. Guarding
|
|
27
|
+
* that needed a deep snapshot, a restore path, and a second write which could itself
|
|
28
|
+
* throw and mask the original error.
|
|
29
|
+
*
|
|
30
|
+
* None of that machinery is needed if nothing is destroyed in the first place. The
|
|
31
|
+
* record stays until a real outcome replaces it, and a store that fails simply leaves
|
|
32
|
+
* the parked run exactly as the operator found it.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resumeCompensation(deps: RollbackDeps, executionId: string): Promise<void>;
|
|
35
|
+
/** Accept a partial rollback: the outstanding steps are recorded as skipped. */
|
|
36
|
+
export declare function abandonParkedCompensation(deps: RollbackDeps, executionId: string): void;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator actions on a parked unwind.
|
|
3
|
+
*
|
|
4
|
+
* A `compensation-stuck` run is waiting for a human decision, which is why the state
|
|
5
|
+
* is not terminal: the engine has done everything it safely can, and the remaining
|
|
6
|
+
* choice, was the reversal fixed or do we accept a partial rollback, is not one it can
|
|
7
|
+
* make. Both exits are explicit, and both leave the execution record saying exactly
|
|
8
|
+
* what happened.
|
|
9
|
+
*/
|
|
10
|
+
import { abandonCompensation, runCompensation } from './compensator';
|
|
11
|
+
/**
|
|
12
|
+
* Retry the compensation that parked the run, then carry on with the rest.
|
|
13
|
+
*
|
|
14
|
+
* The retry is asked for with a FLAG rather than by clearing the failed outcome first.
|
|
15
|
+
* Clearing worked, and it cost more than it bought: it wiped the operator's
|
|
16
|
+
* `compensation-failed` record in memory and persisted that wipe BEFORE running
|
|
17
|
+
* anything, so a resume that then met a failing store left a run whose durable row had
|
|
18
|
+
* lost the very diagnostic the operator was acting on, sitting in `compensating` and
|
|
19
|
+
* therefore handed back by `listRecoverable()` at every subsequent startup. Guarding
|
|
20
|
+
* that needed a deep snapshot, a restore path, and a second write which could itself
|
|
21
|
+
* throw and mask the original error.
|
|
22
|
+
*
|
|
23
|
+
* None of that machinery is needed if nothing is destroyed in the first place. The
|
|
24
|
+
* record stays until a real outcome replaces it, and a store that fails simply leaves
|
|
25
|
+
* the parked run exactly as the operator found it.
|
|
26
|
+
*/
|
|
27
|
+
export async function resumeCompensation(deps, executionId) {
|
|
28
|
+
const { exec, wf } = parked(deps, executionId);
|
|
29
|
+
const outcome = await runCompensation(exec, wf, deps.store, deps.emitter, deps.workflows, { retryFailed: true });
|
|
30
|
+
if (outcome === 'claim-lost') {
|
|
31
|
+
// Nothing was mutated: `runCompensation` returns this before the unwind begins.
|
|
32
|
+
throw new Error(`execution "${executionId}" is already being rolled back by another driver`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Accept a partial rollback: the outstanding steps are recorded as skipped. */
|
|
36
|
+
export function abandonParkedCompensation(deps, executionId) {
|
|
37
|
+
const { exec, wf } = parked(deps, executionId);
|
|
38
|
+
abandonCompensation(exec, wf, deps.store, deps.emitter);
|
|
39
|
+
}
|
|
40
|
+
function parked(deps, executionId) {
|
|
41
|
+
const exec = deps.store.get(executionId);
|
|
42
|
+
if (!exec)
|
|
43
|
+
throw new Error(`Execution "${executionId}" not found`);
|
|
44
|
+
if (exec.state !== 'compensation-stuck') {
|
|
45
|
+
throw new Error(`Execution "${executionId}" is "${exec.state}", not a parked unwind ("compensation-stuck")`);
|
|
46
|
+
}
|
|
47
|
+
const wf = deps.workflows.get(exec.workflowName);
|
|
48
|
+
if (!wf)
|
|
49
|
+
throw new Error(`Workflow "${exec.workflowName}" not registered`);
|
|
50
|
+
return { exec, wf };
|
|
51
|
+
}
|
|
@@ -6,14 +6,34 @@ import type { Workflow } from './workflow';
|
|
|
6
6
|
import type { WorkflowEmitter } from './emitter';
|
|
7
7
|
/** Run a promise with a timeout */
|
|
8
8
|
export declare function runWithTimeout<T>(promise: Promise<T> | T, timeoutMs: number): Promise<T>;
|
|
9
|
+
/** Engine hooks a step needs; they always travel together. */
|
|
10
|
+
export interface StepHooks {
|
|
11
|
+
emitter: WorkflowEmitter | null;
|
|
12
|
+
updateFn: (exec: Execution) => void;
|
|
13
|
+
}
|
|
9
14
|
/** Execute a step with retry logic and exponential backoff */
|
|
10
|
-
export declare function executeStepWithRetry(def: StepDefinition, ctx: StepContext, exec: Execution,
|
|
15
|
+
export declare function executeStepWithRetry(def: StepDefinition, ctx: StepContext, exec: Execution, hooks: StepHooks, occurrence?: number): Promise<void>;
|
|
11
16
|
/** Execute multiple steps in parallel via Promise.allSettled */
|
|
12
17
|
export declare function executeParallelSteps(steps: StepDefinition[], ctx: StepContext, exec: Execution, emitter: WorkflowEmitter | null, updateFn: (exec: Execution) => void): Promise<void>;
|
|
13
18
|
/** Execute a sub-workflow by starting it and polling for completion */
|
|
14
19
|
export declare function executeSubWorkflow(workflowName: string, input: unknown, startFn: (name: string, input: unknown) => Promise<{
|
|
15
20
|
id: string;
|
|
16
|
-
}>, getFn: (id: string) => Execution | null, pollIntervalMs?: number
|
|
21
|
+
}>, getFn: (id: string) => Execution | null, pollIntervalMs?: number,
|
|
22
|
+
/**
|
|
23
|
+
* A child this node already started, from an earlier entry into the same node.
|
|
24
|
+
*
|
|
25
|
+
* Without it the node started a BRAND NEW child every time it was re-entered, and
|
|
26
|
+
* re-entry is routine: a restart followed by `recover()` re-enqueues the parent's
|
|
27
|
+
* current node. Measured across one restart, the child ran twice and both rows were
|
|
28
|
+
* left `running` forever, since a child is excluded from recovery while its parent
|
|
29
|
+
* exists and `cleanup`/`archive` only reap terminal states. Duplicated work, not just
|
|
30
|
+
* a leaked row: a child that provisions a resource provisioned it twice
|
|
31
|
+
* (`test/repro-workflow-orphan-child.test.ts`).
|
|
32
|
+
*/
|
|
33
|
+
existingChildId?: string): Promise<{
|
|
34
|
+
results: Record<string, unknown>;
|
|
35
|
+
executionId: string;
|
|
36
|
+
}>;
|
|
17
37
|
/** Find a step definition by name across all node types */
|
|
18
38
|
export declare function findStepDef(wf: Workflow, name: string): StepDefinition | null;
|
|
19
39
|
/** Build a StepContext from the current execution state */
|