@pikku/core 0.12.133 → 0.12.135
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/CHANGELOG.md +13 -0
- package/dist/function/compensation-name.d.ts +4 -0
- package/dist/function/compensation-name.js +6 -0
- package/dist/function/function-meta.types.d.ts +2 -0
- package/dist/function/function-runner.js +17 -0
- package/dist/function/functions.types.d.ts +7 -0
- package/dist/testing/service-tests/queued-workflow-harness.d.ts +61 -0
- package/dist/testing/service-tests/queued-workflow-harness.js +151 -0
- package/dist/testing/service-tests/workflow-compensation-queued-tests.d.ts +11 -0
- package/dist/testing/service-tests/workflow-compensation-queued-tests.js +683 -0
- package/dist/testing/service-tests.d.ts +4 -0
- package/dist/testing/service-tests.js +4 -0
- package/dist/utils/hmac.d.ts +0 -33
- package/dist/utils/hmac.js +0 -61
- package/dist/wirings/rpc/rpc-runner.js +21 -1
- package/dist/wirings/trigger/webhook-source-runner.js +17 -10
- package/dist/wirings/trigger/webhook-source.types.d.ts +4 -3
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +38 -6
- package/dist/wirings/workflow/graph/graph-node.d.ts +2 -1
- package/dist/wirings/workflow/graph/graph-node.js +2 -1
- package/dist/wirings/workflow/graph/graph-runner.d.ts +1 -1
- package/dist/wirings/workflow/graph/graph-runner.js +104 -67
- package/dist/wirings/workflow/graph/graph-validation.js +2 -2
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +20 -1
- package/dist/wirings/workflow/index.d.ts +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +13 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +113 -143
- package/dist/wirings/workflow/run-timeline.d.ts +2 -0
- package/dist/wirings/workflow/run-timeline.js +3 -0
- package/dist/wirings/workflow/workflow-child-step.d.ts +17 -0
- package/dist/wirings/workflow/workflow-child-step.js +31 -0
- package/dist/wirings/workflow/workflow-compensation.d.ts +68 -0
- package/dist/wirings/workflow/workflow-compensation.js +282 -0
- package/dist/wirings/workflow/workflow-constants.d.ts +5 -0
- package/dist/wirings/workflow/workflow-constants.js +9 -0
- package/dist/wirings/workflow/workflow-dsl-pass.d.ts +12 -0
- package/dist/wirings/workflow/workflow-dsl-pass.js +75 -0
- package/dist/wirings/workflow/workflow-queue-routing.js +3 -3
- package/dist/wirings/workflow/workflow-run-status.js +13 -1
- package/dist/wirings/workflow/workflow-status-stream.js +2 -0
- package/dist/wirings/workflow/workflow-unwind-plan.d.ts +38 -0
- package/dist/wirings/workflow/workflow-unwind-plan.js +120 -0
- package/dist/wirings/workflow/workflow.types.d.ts +9 -2
- package/knowledge/decisions/internals/workflow-step-compensation-runs-as-its-own-durable-step.md +21 -18
- package/package.json +1 -1
- package/src/public-surface.json +0 -1
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import { describe, test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { QueuedWorkflowHarness } from './queued-workflow-harness.js';
|
|
4
|
+
export const defineWorkflowCompensationQueuedTests = (name, create, options = {}) => {
|
|
5
|
+
const childWorkflows = options.childWorkflows !== false;
|
|
6
|
+
const setup = async () => new QueuedWorkflowHarness(await create());
|
|
7
|
+
describe('queued saga compensation — DSL', () => {
|
|
8
|
+
test('a successful run compensates nothing', async () => {
|
|
9
|
+
const h = await setup();
|
|
10
|
+
h.register('a', h.undoable());
|
|
11
|
+
h.defineDsl('ok', async (w) => {
|
|
12
|
+
await w.do('A', 'a', {});
|
|
13
|
+
});
|
|
14
|
+
const runId = await h.start('ok');
|
|
15
|
+
assert.equal((await h.run(runId)).status, 'completed');
|
|
16
|
+
assert.deepEqual(h.undone, []);
|
|
17
|
+
});
|
|
18
|
+
test('a failure unwinds in reverse order, each compensation on the step queue', async () => {
|
|
19
|
+
const h = await setup();
|
|
20
|
+
h.register('a', h.undoable());
|
|
21
|
+
h.register('b', h.undoable());
|
|
22
|
+
h.register('c', h.boom());
|
|
23
|
+
h.defineDsl('chain', async (w) => {
|
|
24
|
+
await w.do('A', 'a', {});
|
|
25
|
+
await w.do('B', 'b', {});
|
|
26
|
+
await w.do('C', 'c', {}, { retries: 0 });
|
|
27
|
+
});
|
|
28
|
+
const runId = await h.start('chain');
|
|
29
|
+
assert.equal((await h.run(runId)).status, 'compensated');
|
|
30
|
+
assert.deepEqual(h.undone, ['undo:b', 'undo:a']);
|
|
31
|
+
const queuedSteps = h.jobsRun.map((j) => j.step).filter(Boolean);
|
|
32
|
+
assert.ok(queuedSteps.includes('B:compensate'));
|
|
33
|
+
assert.ok(queuedSteps.includes('A:compensate'));
|
|
34
|
+
});
|
|
35
|
+
test('compensatingFor carries ok, output and the original input', async () => {
|
|
36
|
+
const h = await setup();
|
|
37
|
+
h.register('a', h.undoable({ id: 7 }));
|
|
38
|
+
h.register('b', h.boom('declined'));
|
|
39
|
+
h.defineDsl('ctx', async (w) => {
|
|
40
|
+
await w.do('A', 'a', { sku: 'x' });
|
|
41
|
+
await w.do('B', 'b', {}, { retries: 0 });
|
|
42
|
+
});
|
|
43
|
+
await h.start('ctx');
|
|
44
|
+
const undoA = h.contexts.find((c) => c.rpc === 'a:compensate');
|
|
45
|
+
assert.equal(undoA.context?.ok, true);
|
|
46
|
+
assert.deepEqual(undoA.context?.output, { id: 7 });
|
|
47
|
+
assert.equal(h.contexts.find((c) => c.rpc === 'a').context, undefined);
|
|
48
|
+
});
|
|
49
|
+
test('the failed step compensates itself with ok:false', async () => {
|
|
50
|
+
const h = await setup();
|
|
51
|
+
h.register('b', {
|
|
52
|
+
...h.boom('declined'),
|
|
53
|
+
compensate: async () => { },
|
|
54
|
+
});
|
|
55
|
+
h.defineDsl('self', async (w) => {
|
|
56
|
+
await w.do('B', 'b', {}, { retries: 0 });
|
|
57
|
+
});
|
|
58
|
+
const runId = await h.start('self');
|
|
59
|
+
assert.equal((await h.run(runId)).status, 'compensated');
|
|
60
|
+
const undo = h.contexts.find((c) => c.rpc === 'b:compensate');
|
|
61
|
+
assert.equal(undo.context?.ok, false);
|
|
62
|
+
});
|
|
63
|
+
test('nothing to compensate leaves the run failed', async () => {
|
|
64
|
+
const h = await setup();
|
|
65
|
+
h.register('a', h.ok());
|
|
66
|
+
h.register('b', h.boom());
|
|
67
|
+
h.defineDsl('plain', async (w) => {
|
|
68
|
+
await w.do('A', 'a', {});
|
|
69
|
+
await w.do('B', 'b', {}, { retries: 0 });
|
|
70
|
+
});
|
|
71
|
+
const runId = await h.start('plain');
|
|
72
|
+
assert.equal((await h.run(runId)).status, 'failed');
|
|
73
|
+
});
|
|
74
|
+
test('{ compensate: false } leaves a step out', async () => {
|
|
75
|
+
const h = await setup();
|
|
76
|
+
h.register('a', h.undoable());
|
|
77
|
+
h.register('b', h.undoable());
|
|
78
|
+
h.register('c', h.boom());
|
|
79
|
+
h.defineDsl('skip', async (w) => {
|
|
80
|
+
await w.do('A', 'a', {});
|
|
81
|
+
await w.do('B', 'b', {}, { compensate: false });
|
|
82
|
+
await w.do('C', 'c', {}, { retries: 0 });
|
|
83
|
+
});
|
|
84
|
+
await h.start('skip');
|
|
85
|
+
assert.deepEqual(h.undone, ['undo:a']);
|
|
86
|
+
});
|
|
87
|
+
test('a non-step error thrown by the body still unwinds', async () => {
|
|
88
|
+
const h = await setup();
|
|
89
|
+
h.register('a', h.undoable());
|
|
90
|
+
h.defineDsl('bodyThrow', async (w) => {
|
|
91
|
+
await w.do('A', 'a', {});
|
|
92
|
+
throw new Error('body broke');
|
|
93
|
+
});
|
|
94
|
+
const runId = await h.start('bodyThrow');
|
|
95
|
+
assert.equal((await h.run(runId)).status, 'compensated');
|
|
96
|
+
assert.deepEqual(h.undone, ['undo:a']);
|
|
97
|
+
});
|
|
98
|
+
test('the original error is kept on the compensated run', async () => {
|
|
99
|
+
const h = await setup();
|
|
100
|
+
h.register('a', h.undoable());
|
|
101
|
+
h.register('b', h.boom('original'));
|
|
102
|
+
h.defineDsl('err', async (w) => {
|
|
103
|
+
await w.do('A', 'a', {});
|
|
104
|
+
await w.do('B', 'b', {}, { retries: 0 });
|
|
105
|
+
});
|
|
106
|
+
const runId = await h.start('err');
|
|
107
|
+
assert.match((await h.run(runId)).error?.message ?? '', /original/);
|
|
108
|
+
});
|
|
109
|
+
test('compensation rows are named <step>:compensate and flagged', async () => {
|
|
110
|
+
const h = await setup();
|
|
111
|
+
h.register('a', h.undoable());
|
|
112
|
+
h.register('b', h.boom());
|
|
113
|
+
h.defineDsl('rows', async (w) => {
|
|
114
|
+
await w.do('A', 'a', {});
|
|
115
|
+
await w.do('B', 'b', {}, { retries: 0 });
|
|
116
|
+
});
|
|
117
|
+
const runId = await h.start('rows');
|
|
118
|
+
const row = (await h.steps(runId)).find((s) => s.stepName === 'A:compensate');
|
|
119
|
+
assert.ok(row);
|
|
120
|
+
assert.equal(row?.status, 'succeeded');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe('queued saga compensation — retries and stuck steps', () => {
|
|
124
|
+
test('a compensation retries before it gives up', async () => {
|
|
125
|
+
const h = await setup();
|
|
126
|
+
let attempts = 0;
|
|
127
|
+
h.register('a', {
|
|
128
|
+
...h.ok(),
|
|
129
|
+
compensate: async () => {
|
|
130
|
+
if (++attempts < 3)
|
|
131
|
+
throw new Error('flaky');
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
h.register('b', h.boom());
|
|
135
|
+
h.defineDsl('retry', async (w) => {
|
|
136
|
+
await w.do('A', 'a', {});
|
|
137
|
+
await w.do('B', 'b', {}, { retries: 0 });
|
|
138
|
+
});
|
|
139
|
+
const runId = await h.start('retry');
|
|
140
|
+
assert.equal((await h.run(runId)).status, 'compensated');
|
|
141
|
+
assert.equal(attempts, 3);
|
|
142
|
+
});
|
|
143
|
+
test('a compensation that exhausts retries parks the run as compensation_failed', async () => {
|
|
144
|
+
const h = await setup();
|
|
145
|
+
h.register('first', h.undoable());
|
|
146
|
+
h.register('a', {
|
|
147
|
+
...h.ok(),
|
|
148
|
+
compensate: async () => {
|
|
149
|
+
throw new Error('stuck');
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
h.register('b', h.boom());
|
|
153
|
+
h.defineDsl('stuck', async (w) => {
|
|
154
|
+
await w.do('First', 'first', {});
|
|
155
|
+
await w.do('A', 'a', {});
|
|
156
|
+
await w.do('B', 'b', {}, { retries: 0 });
|
|
157
|
+
});
|
|
158
|
+
const runId = await h.start('stuck');
|
|
159
|
+
const run = await h.run(runId);
|
|
160
|
+
assert.equal(run.status, 'compensation_failed');
|
|
161
|
+
assert.ok(!h.log.includes('undo:first'), 'earlier steps stay protected');
|
|
162
|
+
});
|
|
163
|
+
test('a stuck compensation blocks only earlier steps, not parallel siblings', async () => {
|
|
164
|
+
const h = await setup();
|
|
165
|
+
h.register('first', h.undoable());
|
|
166
|
+
h.register('left', {
|
|
167
|
+
...h.ok(),
|
|
168
|
+
compensate: async () => {
|
|
169
|
+
throw new Error('left stuck');
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
h.register('right', h.undoable());
|
|
173
|
+
h.defineDsl('par', async (w) => {
|
|
174
|
+
await w.do('First', 'first', {});
|
|
175
|
+
await Promise.all([
|
|
176
|
+
w.do('Left', 'left', {}, { retries: 0 }),
|
|
177
|
+
w.do('Right', 'right', {}, { retries: 0 }),
|
|
178
|
+
]);
|
|
179
|
+
throw new Error('x');
|
|
180
|
+
});
|
|
181
|
+
const runId = await h.start('par');
|
|
182
|
+
assert.equal((await h.run(runId)).status, 'compensation_failed');
|
|
183
|
+
assert.ok(h.log.includes('undo:right'));
|
|
184
|
+
assert.ok(!h.log.includes('undo:first'));
|
|
185
|
+
});
|
|
186
|
+
test('forward retries on the queue run before the unwind starts', async () => {
|
|
187
|
+
const h = await setup();
|
|
188
|
+
let tries = 0;
|
|
189
|
+
h.register('a', h.undoable());
|
|
190
|
+
h.register('b', {
|
|
191
|
+
forward: async () => {
|
|
192
|
+
tries++;
|
|
193
|
+
throw new Error('always');
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
h.defineDsl('fwdRetry', async (w) => {
|
|
197
|
+
await w.do('A', 'a', {});
|
|
198
|
+
await w.do('B', 'b', {}, { retries: 2 });
|
|
199
|
+
});
|
|
200
|
+
const runId = await h.start('fwdRetry');
|
|
201
|
+
assert.equal((await h.run(runId)).status, 'compensated');
|
|
202
|
+
assert.equal(tries, 3);
|
|
203
|
+
assert.equal(h.log.indexOf('undo:a'), h.log.length - 1);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
describe('queued saga compensation — milestones', () => {
|
|
207
|
+
test('the unwind stops at the last milestone', async () => {
|
|
208
|
+
const h = await setup();
|
|
209
|
+
h.register('a', h.undoable());
|
|
210
|
+
h.register('b', h.undoable());
|
|
211
|
+
h.register('c', h.boom());
|
|
212
|
+
h.defineDsl('ms', async (w) => {
|
|
213
|
+
await w.do('A', 'a', {});
|
|
214
|
+
await w.milestone('approved');
|
|
215
|
+
await w.do('B', 'b', {});
|
|
216
|
+
await w.do('C', 'c', {}, { retries: 0 });
|
|
217
|
+
});
|
|
218
|
+
const runId = await h.start('ms');
|
|
219
|
+
const run = await h.run(runId);
|
|
220
|
+
assert.deepEqual(h.undone, ['undo:b']);
|
|
221
|
+
assert.equal(run.status, 'compensated');
|
|
222
|
+
assert.equal(run.output?.restedAt, 'approved');
|
|
223
|
+
});
|
|
224
|
+
test('a failure before any milestone unwinds everything', async () => {
|
|
225
|
+
const h = await setup();
|
|
226
|
+
h.register('a', h.undoable());
|
|
227
|
+
h.register('b', h.boom());
|
|
228
|
+
h.defineDsl('noms', async (w) => {
|
|
229
|
+
await w.do('A', 'a', {});
|
|
230
|
+
await w.do('B', 'b', {}, { retries: 0 });
|
|
231
|
+
});
|
|
232
|
+
await h.start('noms');
|
|
233
|
+
assert.deepEqual(h.undone, ['undo:a']);
|
|
234
|
+
});
|
|
235
|
+
test('a milestone does not change a successful run', async () => {
|
|
236
|
+
const h = await setup();
|
|
237
|
+
h.register('a', h.undoable());
|
|
238
|
+
h.defineDsl('msok', async (w) => {
|
|
239
|
+
await w.do('A', 'a', {});
|
|
240
|
+
await w.milestone('m');
|
|
241
|
+
});
|
|
242
|
+
const runId = await h.start('msok');
|
|
243
|
+
assert.equal((await h.run(runId)).status, 'completed');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
describe('queued saga compensation — never twice and recovery', () => {
|
|
247
|
+
test('a duplicate orchestration job does not rerun a finished unwind', async () => {
|
|
248
|
+
const h = await setup();
|
|
249
|
+
h.register('a', h.undoable());
|
|
250
|
+
h.register('b', h.boom());
|
|
251
|
+
h.defineDsl('dup', async (w) => {
|
|
252
|
+
await w.do('A', 'a', {});
|
|
253
|
+
await w.do('B', 'b', {}, { retries: 0 });
|
|
254
|
+
});
|
|
255
|
+
const runId = await h.start('dup');
|
|
256
|
+
h.queue.push({ queue: 'pikku-workflow-orchestrator', data: { runId } });
|
|
257
|
+
h.queue.push({ queue: 'pikku-workflow-orchestrator', data: { runId } });
|
|
258
|
+
await h.pump();
|
|
259
|
+
assert.deepEqual(h.undone, ['undo:a']);
|
|
260
|
+
assert.equal((await h.run(runId)).status, 'compensated');
|
|
261
|
+
});
|
|
262
|
+
test('a redelivered compensation job does not run the compensation twice', async () => {
|
|
263
|
+
const h = await setup();
|
|
264
|
+
h.register('a', h.undoable());
|
|
265
|
+
h.register('b', h.boom());
|
|
266
|
+
h.defineDsl('redeliver', async (w) => {
|
|
267
|
+
await w.do('A', 'a', {});
|
|
268
|
+
await w.do('B', 'b', {}, { retries: 0 });
|
|
269
|
+
});
|
|
270
|
+
const runId = await h.start('redeliver');
|
|
271
|
+
h.queue.push({
|
|
272
|
+
queue: 'pikku-workflow-step-worker',
|
|
273
|
+
data: {
|
|
274
|
+
runId,
|
|
275
|
+
stepName: 'A:compensate',
|
|
276
|
+
rpcName: 'a:compensate',
|
|
277
|
+
data: {},
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
await h.pump();
|
|
281
|
+
assert.deepEqual(h.undone, ['undo:a']);
|
|
282
|
+
});
|
|
283
|
+
test('a compensation whose dispatch failed is still run, exactly once', async () => {
|
|
284
|
+
const h = await setup();
|
|
285
|
+
h.register('a', h.undoable());
|
|
286
|
+
h.register('b', h.undoable());
|
|
287
|
+
h.register('c', h.boom());
|
|
288
|
+
h.defineDsl('undispatched', async (w) => {
|
|
289
|
+
await w.do('A', 'a', {});
|
|
290
|
+
await w.do('B', 'b', {});
|
|
291
|
+
await w.do('C', 'c', {}, { retries: 0 });
|
|
292
|
+
});
|
|
293
|
+
let rejected = false;
|
|
294
|
+
h.rejectAddWhen = (_queue, data) => {
|
|
295
|
+
if (!rejected && data.stepName === 'B:compensate') {
|
|
296
|
+
rejected = true;
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
return false;
|
|
300
|
+
};
|
|
301
|
+
const runId = await h.start('undispatched');
|
|
302
|
+
assert.equal(rejected, true);
|
|
303
|
+
await h.ws.resumeWorkflow(runId);
|
|
304
|
+
await h.pump();
|
|
305
|
+
assert.equal((await h.run(runId)).status, 'compensated');
|
|
306
|
+
assert.deepEqual(h.undone, ['undo:b', 'undo:a']);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
describe('queued saga compensation — nested workflows', { skip: !childWorkflows }, () => {
|
|
310
|
+
test('a completed child is undone as a unit when the parent fails', async () => {
|
|
311
|
+
const h = await setup();
|
|
312
|
+
h.register('inner', h.undoable());
|
|
313
|
+
h.register('outer', h.undoable());
|
|
314
|
+
h.defineDsl('child', async (w) => {
|
|
315
|
+
await w.do('Inner', 'inner', {});
|
|
316
|
+
return { ok: true };
|
|
317
|
+
});
|
|
318
|
+
h.defineDsl('parent', async (w) => {
|
|
319
|
+
await w.do('Outer', 'outer', {});
|
|
320
|
+
await w.do('Child', 'child', {});
|
|
321
|
+
throw new Error('x');
|
|
322
|
+
});
|
|
323
|
+
const runId = await h.start('parent');
|
|
324
|
+
assert.equal((await h.run(runId)).status, 'compensated');
|
|
325
|
+
assert.deepEqual(h.undone, ['undo:inner', 'undo:outer']);
|
|
326
|
+
});
|
|
327
|
+
test('a child that fails unwinds itself and then the parent', async () => {
|
|
328
|
+
const h = await setup();
|
|
329
|
+
h.register('inner', h.undoable());
|
|
330
|
+
h.register('bad', h.boom());
|
|
331
|
+
h.register('outer', h.undoable());
|
|
332
|
+
h.defineDsl('child3', async (w) => {
|
|
333
|
+
await w.do('Inner', 'inner', {});
|
|
334
|
+
await w.do('Bad', 'bad', {}, { retries: 0 });
|
|
335
|
+
});
|
|
336
|
+
h.defineDsl('parent3', async (w) => {
|
|
337
|
+
await w.do('Outer', 'outer', {});
|
|
338
|
+
await w.do('Child', 'child3', {}, { retries: 0 });
|
|
339
|
+
});
|
|
340
|
+
const runId = await h.start('parent3');
|
|
341
|
+
assert.equal((await h.run(runId)).status, 'compensated');
|
|
342
|
+
assert.deepEqual(h.undone, ['undo:inner', 'undo:outer']);
|
|
343
|
+
});
|
|
344
|
+
test('{ compensate: false } on the parent step keeps the child', async () => {
|
|
345
|
+
const h = await setup();
|
|
346
|
+
h.register('inner', h.undoable());
|
|
347
|
+
h.register('outer', h.undoable());
|
|
348
|
+
h.defineDsl('child2', async (w) => {
|
|
349
|
+
await w.do('Inner', 'inner', {});
|
|
350
|
+
});
|
|
351
|
+
h.defineDsl('parent2', async (w) => {
|
|
352
|
+
await w.do('Outer', 'outer', {});
|
|
353
|
+
await w.do('Child', 'child2', {}, { compensate: false });
|
|
354
|
+
throw new Error('x');
|
|
355
|
+
});
|
|
356
|
+
await h.start('parent2');
|
|
357
|
+
assert.deepEqual(h.undone, ['undo:outer']);
|
|
358
|
+
});
|
|
359
|
+
test('a stuck child leaves the parent compensation_failed', async () => {
|
|
360
|
+
const h = await setup();
|
|
361
|
+
h.register('inner', {
|
|
362
|
+
...h.ok(),
|
|
363
|
+
compensate: async () => {
|
|
364
|
+
throw new Error('inner stuck');
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
h.register('bad', h.boom());
|
|
368
|
+
h.register('outer', h.undoable());
|
|
369
|
+
h.defineDsl('child4', async (w) => {
|
|
370
|
+
await w.do('Inner', 'inner', {});
|
|
371
|
+
await w.do('Bad', 'bad', {}, { retries: 0 });
|
|
372
|
+
});
|
|
373
|
+
h.defineDsl('parent4', async (w) => {
|
|
374
|
+
await w.do('Outer', 'outer', {});
|
|
375
|
+
await w.do('Child', 'child4', {}, { retries: 0 });
|
|
376
|
+
});
|
|
377
|
+
const runId = await h.start('parent4');
|
|
378
|
+
assert.equal((await h.run(runId)).status, 'compensation_failed');
|
|
379
|
+
assert.ok(!h.log.includes('undo:outer'));
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
describe('queued saga compensation — child outcomes survive the step boundary', { skip: !childWorkflows }, () => {
|
|
383
|
+
test('a child resting at its own milestone leaves the parent compensated there', async () => {
|
|
384
|
+
const h = await setup();
|
|
385
|
+
h.register('safe', h.undoable());
|
|
386
|
+
h.register('risky', h.undoable());
|
|
387
|
+
h.register('bad', h.boom());
|
|
388
|
+
h.register('outer', h.undoable());
|
|
389
|
+
h.defineDsl('childRest', async (w) => {
|
|
390
|
+
await w.do('Safe', 'safe', {});
|
|
391
|
+
await w.milestone('childSafe');
|
|
392
|
+
await w.do('Risky', 'risky', {});
|
|
393
|
+
await w.do('Bad', 'bad', {}, { retries: 0 });
|
|
394
|
+
});
|
|
395
|
+
h.defineDsl('parentRest', async (w) => {
|
|
396
|
+
await w.do('Outer', 'outer', {});
|
|
397
|
+
await w.do('Child', 'childRest', {}, { retries: 0 });
|
|
398
|
+
});
|
|
399
|
+
const runId = await h.start('parentRest');
|
|
400
|
+
const run = await h.run(runId);
|
|
401
|
+
assert.equal(run.status, 'compensated');
|
|
402
|
+
assert.equal(run.output?.restedAt, 'childSafe');
|
|
403
|
+
assert.deepEqual(h.undone, ['undo:risky']);
|
|
404
|
+
});
|
|
405
|
+
test('a child that simply failed (nothing to undo) fails the parent normally', async () => {
|
|
406
|
+
const h = await setup();
|
|
407
|
+
h.register('bad', h.boom());
|
|
408
|
+
h.defineDsl('plainChild', async (w) => {
|
|
409
|
+
await w.do('Bad', 'bad', {}, { retries: 0 });
|
|
410
|
+
});
|
|
411
|
+
h.defineDsl('plainParent', async (w) => {
|
|
412
|
+
await w.do('Child', 'plainChild', {}, { retries: 0 });
|
|
413
|
+
});
|
|
414
|
+
const runId = await h.start('plainParent');
|
|
415
|
+
assert.equal((await h.run(runId)).status, 'failed');
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
describe('queued saga compensation — cancellation', () => {
|
|
419
|
+
test('cancelling a suspended run unwinds what it completed', async () => {
|
|
420
|
+
const h = await setup();
|
|
421
|
+
h.register('reserve', h.undoable({ id: 1 }));
|
|
422
|
+
h.defineDsl('parked', async (w) => {
|
|
423
|
+
await w.do('Reserve', 'reserve', {});
|
|
424
|
+
await w.suspend('wait');
|
|
425
|
+
});
|
|
426
|
+
const runId = await h.start('parked');
|
|
427
|
+
assert.equal((await h.run(runId)).status, 'suspended');
|
|
428
|
+
assert.equal(await h.ws.cancelRun(runId, h.rpc), 'compensating');
|
|
429
|
+
await h.pump();
|
|
430
|
+
const run = await h.run(runId);
|
|
431
|
+
assert.equal(run.status, 'compensated');
|
|
432
|
+
assert.equal(run.error?.code, 'WORKFLOW_CANCELLED');
|
|
433
|
+
assert.deepEqual(h.undone, ['undo:reserve']);
|
|
434
|
+
});
|
|
435
|
+
test('cancelling twice undoes once', async () => {
|
|
436
|
+
const h = await setup();
|
|
437
|
+
h.register('reserve', h.undoable());
|
|
438
|
+
h.defineDsl('parked2', async (w) => {
|
|
439
|
+
await w.do('Reserve', 'reserve', {});
|
|
440
|
+
await w.suspend('wait');
|
|
441
|
+
});
|
|
442
|
+
const runId = await h.start('parked2');
|
|
443
|
+
await h.ws.cancelRun(runId, h.rpc);
|
|
444
|
+
await h.ws.cancelRun(runId, h.rpc);
|
|
445
|
+
await h.pump();
|
|
446
|
+
assert.deepEqual(h.undone, ['undo:reserve']);
|
|
447
|
+
});
|
|
448
|
+
test('a finished run cannot be cancelled', async () => {
|
|
449
|
+
const h = await setup();
|
|
450
|
+
h.register('a', h.undoable());
|
|
451
|
+
h.defineDsl('done', async (w) => {
|
|
452
|
+
await w.do('A', 'a', {});
|
|
453
|
+
});
|
|
454
|
+
const runId = await h.start('done');
|
|
455
|
+
assert.equal(await h.ws.cancelRun(runId, h.rpc), 'completed');
|
|
456
|
+
assert.deepEqual(h.undone, []);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
describe('queued saga compensation — graph', () => {
|
|
460
|
+
test('a failing node unwinds earlier nodes newest first', async () => {
|
|
461
|
+
const h = await setup();
|
|
462
|
+
h.register('a', h.undoable());
|
|
463
|
+
h.register('b', h.undoable());
|
|
464
|
+
h.register('c', h.boom());
|
|
465
|
+
h.defineGraph('chain', 'a', {
|
|
466
|
+
a: { next: 'b' },
|
|
467
|
+
b: { next: 'c' },
|
|
468
|
+
c: {},
|
|
469
|
+
});
|
|
470
|
+
const runId = await h.start('chain');
|
|
471
|
+
assert.equal((await h.run(runId)).status, 'compensated');
|
|
472
|
+
assert.deepEqual(h.undone, ['undo:b', 'undo:a']);
|
|
473
|
+
});
|
|
474
|
+
test('compensatingFor on a graph node carries ok and output', async () => {
|
|
475
|
+
const h = await setup();
|
|
476
|
+
h.register('a', h.undoable({ id: 3 }));
|
|
477
|
+
h.register('b', {
|
|
478
|
+
forward: async () => {
|
|
479
|
+
throw new Error('no');
|
|
480
|
+
},
|
|
481
|
+
compensate: async () => { },
|
|
482
|
+
});
|
|
483
|
+
h.defineGraph('gctx', 'a', { a: { next: 'b' }, b: {} });
|
|
484
|
+
await h.start('gctx');
|
|
485
|
+
const a = h.contexts.find((c) => c.rpc === 'a:compensate');
|
|
486
|
+
const b = h.contexts.find((c) => c.rpc === 'b:compensate');
|
|
487
|
+
assert.equal(a.context?.ok, true);
|
|
488
|
+
assert.deepEqual(a.context?.output, { id: 3 });
|
|
489
|
+
assert.equal(b.context?.ok, false);
|
|
490
|
+
});
|
|
491
|
+
test('{ compensate: false } on a node leaves it out', async () => {
|
|
492
|
+
const h = await setup();
|
|
493
|
+
h.register('a', h.undoable());
|
|
494
|
+
h.register('b', h.undoable());
|
|
495
|
+
h.register('c', h.boom());
|
|
496
|
+
h.defineGraph('gskip', 'a', {
|
|
497
|
+
a: { next: 'b' },
|
|
498
|
+
b: { next: 'c', compensate: false },
|
|
499
|
+
c: {},
|
|
500
|
+
});
|
|
501
|
+
await h.start('gskip');
|
|
502
|
+
assert.deepEqual(h.undone, ['undo:a']);
|
|
503
|
+
});
|
|
504
|
+
test('nothing to compensate fails the run', async () => {
|
|
505
|
+
const h = await setup();
|
|
506
|
+
h.register('a', h.ok());
|
|
507
|
+
h.register('b', h.boom());
|
|
508
|
+
h.defineGraph('gplain', 'a', { a: { next: 'b' }, b: {} });
|
|
509
|
+
const runId = await h.start('gplain');
|
|
510
|
+
assert.equal((await h.run(runId)).status, 'failed');
|
|
511
|
+
});
|
|
512
|
+
test('a successful graph compensates nothing', async () => {
|
|
513
|
+
const h = await setup();
|
|
514
|
+
h.register('a', h.undoable());
|
|
515
|
+
h.register('b', h.undoable());
|
|
516
|
+
h.defineGraph('gok', 'a', { a: { next: 'b' }, b: {} });
|
|
517
|
+
const runId = await h.start('gok');
|
|
518
|
+
assert.equal((await h.run(runId)).status, 'completed');
|
|
519
|
+
assert.deepEqual(h.undone, []);
|
|
520
|
+
});
|
|
521
|
+
test('a stuck compensation parks the run and protects earlier nodes', async () => {
|
|
522
|
+
const h = await setup();
|
|
523
|
+
h.register('a', h.undoable());
|
|
524
|
+
h.register('b', {
|
|
525
|
+
...h.ok(),
|
|
526
|
+
compensate: async () => {
|
|
527
|
+
throw new Error('stuck');
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
h.register('c', h.boom());
|
|
531
|
+
h.defineGraph('gstuck', 'a', {
|
|
532
|
+
a: { next: 'b' },
|
|
533
|
+
b: { next: 'c' },
|
|
534
|
+
c: {},
|
|
535
|
+
});
|
|
536
|
+
const runId = await h.start('gstuck');
|
|
537
|
+
assert.equal((await h.run(runId)).status, 'compensation_failed');
|
|
538
|
+
assert.ok(!h.log.includes('undo:a'));
|
|
539
|
+
});
|
|
540
|
+
test('parallel branches: a stuck one does not stop its sibling', async () => {
|
|
541
|
+
const h = await setup();
|
|
542
|
+
h.register('start', h.undoable());
|
|
543
|
+
h.register('left', {
|
|
544
|
+
...h.ok(),
|
|
545
|
+
compensate: async () => {
|
|
546
|
+
throw new Error('left stuck');
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
h.register('right', h.undoable());
|
|
550
|
+
h.register('join', h.boom());
|
|
551
|
+
h.defineGraph('gpar', 'start', {
|
|
552
|
+
start: { next: ['left', 'right'] },
|
|
553
|
+
left: { next: 'join' },
|
|
554
|
+
right: { next: 'join' },
|
|
555
|
+
join: {},
|
|
556
|
+
});
|
|
557
|
+
const runId = await h.start('gpar');
|
|
558
|
+
assert.equal((await h.run(runId)).status, 'compensation_failed');
|
|
559
|
+
assert.ok(h.log.includes('undo:right'));
|
|
560
|
+
});
|
|
561
|
+
test('a duplicate orchestration after the unwind changes nothing', async () => {
|
|
562
|
+
const h = await setup();
|
|
563
|
+
h.register('a', h.undoable());
|
|
564
|
+
h.register('b', h.boom());
|
|
565
|
+
h.defineGraph('gdup', 'a', { a: { next: 'b' }, b: {} });
|
|
566
|
+
const runId = await h.start('gdup');
|
|
567
|
+
h.queue.push({ queue: 'pikku-workflow-orchestrator', data: { runId } });
|
|
568
|
+
await h.pump();
|
|
569
|
+
assert.deepEqual(h.undone, ['undo:a']);
|
|
570
|
+
assert.equal((await h.run(runId)).status, 'compensated');
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
describe('queued graph recover', () => {
|
|
574
|
+
test('a failing node runs its recover node with the error', async () => {
|
|
575
|
+
const h = await setup();
|
|
576
|
+
h.register('a', h.boom('payment down'));
|
|
577
|
+
h.register('fallback', h.ok());
|
|
578
|
+
h.defineGraph('rec', 'a', { a: { recover: 'fallback' }, fallback: {} });
|
|
579
|
+
const runId = await h.start('rec');
|
|
580
|
+
assert.equal((await h.run(runId)).status, 'completed');
|
|
581
|
+
assert.equal(h.recovering.length, 1);
|
|
582
|
+
assert.equal(h.recovering[0].rpc, 'fallback');
|
|
583
|
+
assert.equal(h.recovering[0].from.nodeId, 'a');
|
|
584
|
+
assert.equal(h.recovering[0].from.error.message, 'payment down');
|
|
585
|
+
});
|
|
586
|
+
test("recover: 'ignore' carries on to next", async () => {
|
|
587
|
+
const h = await setup();
|
|
588
|
+
h.register('a', h.boom());
|
|
589
|
+
h.register('b', h.ok());
|
|
590
|
+
h.defineGraph('ign', 'a', { a: { recover: 'ignore', next: 'b' }, b: {} });
|
|
591
|
+
const runId = await h.start('ign');
|
|
592
|
+
assert.equal((await h.run(runId)).status, 'completed');
|
|
593
|
+
assert.ok(h.log.includes('do:b'));
|
|
594
|
+
});
|
|
595
|
+
test('recover nodes chain', async () => {
|
|
596
|
+
const h = await setup();
|
|
597
|
+
h.register('a', h.boom('first'));
|
|
598
|
+
h.register('second', h.boom('second'));
|
|
599
|
+
h.register('third', h.ok());
|
|
600
|
+
h.defineGraph('chainrec', 'a', {
|
|
601
|
+
a: { recover: 'second' },
|
|
602
|
+
second: { recover: 'third' },
|
|
603
|
+
third: {},
|
|
604
|
+
});
|
|
605
|
+
const runId = await h.start('chainrec');
|
|
606
|
+
assert.equal((await h.run(runId)).status, 'completed');
|
|
607
|
+
assert.deepEqual(h.recovering.map((r) => [r.rpc, r.from.nodeId, r.from.error.message]), [
|
|
608
|
+
['second', 'a', 'first'],
|
|
609
|
+
['third', 'second', 'second'],
|
|
610
|
+
]);
|
|
611
|
+
});
|
|
612
|
+
test('a recovered failure takes precedence over compensation', async () => {
|
|
613
|
+
const h = await setup();
|
|
614
|
+
h.register('earlier', h.undoable());
|
|
615
|
+
h.register('a', { ...h.boom(), compensate: async () => { } });
|
|
616
|
+
h.register('fallback', h.ok());
|
|
617
|
+
h.defineGraph('prec', 'earlier', {
|
|
618
|
+
earlier: { next: 'a' },
|
|
619
|
+
a: { recover: 'fallback' },
|
|
620
|
+
fallback: {},
|
|
621
|
+
});
|
|
622
|
+
const runId = await h.start('prec');
|
|
623
|
+
assert.equal((await h.run(runId)).status, 'completed');
|
|
624
|
+
assert.deepEqual(h.undone, []);
|
|
625
|
+
});
|
|
626
|
+
test('a failing recover node falls through to compensation', async () => {
|
|
627
|
+
const h = await setup();
|
|
628
|
+
h.register('earlier', h.undoable());
|
|
629
|
+
h.register('a', h.boom('first'));
|
|
630
|
+
h.register('fallback', h.boom('second'));
|
|
631
|
+
h.defineGraph('fall', 'earlier', {
|
|
632
|
+
earlier: { next: 'a' },
|
|
633
|
+
a: { recover: 'fallback' },
|
|
634
|
+
fallback: {},
|
|
635
|
+
});
|
|
636
|
+
const runId = await h.start('fall');
|
|
637
|
+
assert.equal((await h.run(runId)).status, 'compensated');
|
|
638
|
+
assert.deepEqual(h.undone, ['undo:earlier']);
|
|
639
|
+
});
|
|
640
|
+
test('a node reading a recovered failure is skipped, not waited on', async () => {
|
|
641
|
+
const h = await setup();
|
|
642
|
+
h.register('a', h.boom());
|
|
643
|
+
h.register('fallback', h.ok());
|
|
644
|
+
h.register('reader', h.ok());
|
|
645
|
+
h.defineGraph('dead', 'a', {
|
|
646
|
+
a: { recover: 'fallback', next: 'reader' },
|
|
647
|
+
fallback: {},
|
|
648
|
+
reader: { input: { v: { $ref: 'a', path: 'v' } } },
|
|
649
|
+
});
|
|
650
|
+
const runId = await h.start('dead');
|
|
651
|
+
const run = await h.run(runId);
|
|
652
|
+
assert.equal(run.status, 'completed');
|
|
653
|
+
assert.ok(!h.log.includes('do:reader'));
|
|
654
|
+
});
|
|
655
|
+
test('recovering is undefined on a node reached normally', async () => {
|
|
656
|
+
const h = await setup();
|
|
657
|
+
h.register('a', h.ok());
|
|
658
|
+
h.register('b', h.ok());
|
|
659
|
+
h.defineGraph('normal', 'a', { a: { next: 'b' }, b: {} });
|
|
660
|
+
await h.start('normal');
|
|
661
|
+
assert.equal(h.recovering.length, 0);
|
|
662
|
+
});
|
|
663
|
+
test('an unrecovered failure elsewhere still compensates', async () => {
|
|
664
|
+
const h = await setup();
|
|
665
|
+
h.register('earlier', h.undoable());
|
|
666
|
+
h.register('a', h.boom());
|
|
667
|
+
h.defineGraph('unrec', 'earlier', { earlier: { next: 'a' }, a: {} });
|
|
668
|
+
const runId = await h.start('unrec');
|
|
669
|
+
assert.equal((await h.run(runId)).status, 'compensated');
|
|
670
|
+
});
|
|
671
|
+
test('a recovery survives a duplicate orchestration job', async () => {
|
|
672
|
+
const h = await setup();
|
|
673
|
+
h.register('a', h.boom());
|
|
674
|
+
h.register('fallback', h.ok());
|
|
675
|
+
h.defineGraph('recdup', 'a', { a: { recover: 'fallback' }, fallback: {} });
|
|
676
|
+
const runId = await h.start('recdup');
|
|
677
|
+
h.queue.push({ queue: 'pikku-workflow-orchestrator', data: { runId } });
|
|
678
|
+
await h.pump();
|
|
679
|
+
assert.equal((await h.run(runId)).status, 'completed');
|
|
680
|
+
assert.equal(h.log.filter((l) => l === 'do:fallback').length, 1);
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
};
|