automategraph 1.5.1 → 1.5.3
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 +18 -5
- package/UPGRADE-PROPOSAL.md +141 -0
- package/docs/diagrams/c19-resilience-scenario.md +61 -0
- package/docs/diagrams/c19-resilience-scenario.mmd +28 -0
- package/docs/diagrams/c19-resilience-scenario.svg +181 -0
- package/examples/README.md +206 -0
- package/examples/branching-edges.ts +192 -0
- package/examples/branching-graph.ts +333 -0
- package/examples/branching-router.ts +475 -0
- package/examples/json-graph.ts +231 -0
- package/examples/json-scripts.ts +152 -0
- package/examples/order-flow.functions.cjs +32 -0
- package/examples/order-flow.graph.json +27 -0
- package/examples/order-flow.scripted.graph.json +68 -0
- package/js/examples/branching-edges.js +133 -0
- package/js/examples/branching-edges.js.map +1 -0
- package/js/examples/branching-graph.js +232 -0
- package/js/examples/branching-graph.js.map +1 -0
- package/js/examples/branching-router.js +305 -0
- package/js/examples/branching-router.js.map +1 -0
- package/js/examples/json-graph.js +197 -0
- package/js/examples/json-graph.js.map +1 -0
- package/js/examples/json-scripts.js +131 -0
- package/js/examples/json-scripts.js.map +1 -0
- package/js/tests/async.test.js +592 -0
- package/js/tests/async.test.js.map +1 -0
- package/js/tests/complex.test.js +972 -0
- package/js/tests/complex.test.js.map +1 -0
- package/js/tests/conformance.test.js +365 -0
- package/js/tests/conformance.test.js.map +1 -0
- package/js/tests/edges.test.js +702 -0
- package/js/tests/edges.test.js.map +1 -0
- package/js/tests/graphjson.test.js +504 -0
- package/js/tests/graphjson.test.js.map +1 -0
- package/js/tests/harness.js +192 -0
- package/js/tests/harness.js.map +1 -0
- package/js/tests/helpers.js +136 -0
- package/js/tests/helpers.js.map +1 -0
- package/js/tests/lint.test.js +317 -0
- package/js/tests/lint.test.js.map +1 -0
- package/js/tests/registry.test.js +705 -0
- package/js/tests/registry.test.js.map +1 -0
- package/js/tests/report.js +422 -0
- package/js/tests/report.js.map +1 -0
- package/js/tests/runner.js +67 -0
- package/js/tests/runner.js.map +1 -0
- package/js/tests/scripts.test.js +552 -0
- package/js/tests/scripts.test.js.map +1 -0
- package/js/tests/unit.test.js +1239 -0
- package/js/tests/unit.test.js.map +1 -0
- package/js/tests/upgrade.test.js +618 -0
- package/js/tests/upgrade.test.js.map +1 -0
- package/package.json +2 -9
- package/report/index.html +3727 -0
- package/report/report.json +3053 -0
- package/tests/async.test.ts +668 -0
- package/tests/complex.test.ts +1123 -0
- package/tests/conformance.test.ts +442 -0
- package/tests/edges.test.ts +848 -0
- package/tests/fixtures/scripts/bad-registration.cjs +4 -0
- package/tests/fixtures/scripts/broken.cjs +2 -0
- package/tests/fixtures/scripts/cjs-default.js +4 -0
- package/tests/fixtures/scripts/collide-a.cjs +4 -0
- package/tests/fixtures/scripts/collide-b.cjs +4 -0
- package/tests/fixtures/scripts/empty.cjs +2 -0
- package/tests/fixtures/scripts/esm-functions.mjs +10 -0
- package/tests/fixtures/scripts/esm-syntax.js +3 -0
- package/tests/fixtures/scripts/global-channel.cjs +6 -0
- package/tests/fixtures/scripts/global-channel.mjs +7 -0
- package/tests/fixtures/scripts/mismatch.cjs +4 -0
- package/tests/fixtures/scripts/needs-sibling.cjs +7 -0
- package/tests/fixtures/scripts/order.cjs +14 -0
- package/tests/fixtures/scripts/sibling-helper.cjs +3 -0
- package/tests/fixtures/scripts/throwing-global.mjs +6 -0
- package/tests/graphjson.test.ts +737 -0
- package/tests/harness.ts +291 -0
- package/tests/helpers.ts +216 -0
- package/tests/lint.test.ts +381 -0
- package/tests/registry.test.ts +1134 -0
- package/tests/report.ts +452 -0
- package/tests/runner.ts +58 -0
- package/tests/scripts.test.ts +970 -0
- package/tests/unit.test.ts +1192 -0
- package/tests/upgrade.test.ts +725 -0
- package/tools/server.mjs +152 -0
- package/tsconfig.browser.json +19 -0
- package/tsconfig.json +18 -0
- package/tsconfig.publish.json +13 -0
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* 20 COMPLEX / INTEGRATION TESTS
|
|
3
|
+
* ------------------------------------------------------------
|
|
4
|
+
* These exercise the whole engine end-to-end: multi-branch
|
|
5
|
+
* pipelines, failure semantics, concurrency & scale, events and
|
|
6
|
+
* listener isolation, plugin lifecycle, cancellation and
|
|
7
|
+
* checkpoint integrity — several of them documenting observed
|
|
8
|
+
* (and in some cases surprising) engine semantics.
|
|
9
|
+
* ============================================================ */
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
GraphExecutionEngine,
|
|
13
|
+
NodeContext,
|
|
14
|
+
TimeoutError
|
|
15
|
+
} from "../src/automategraph";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
capture,
|
|
19
|
+
constNode,
|
|
20
|
+
g,
|
|
21
|
+
linear,
|
|
22
|
+
logCollector,
|
|
23
|
+
node,
|
|
24
|
+
plugin,
|
|
25
|
+
sleep,
|
|
26
|
+
SpyStorage,
|
|
27
|
+
tick
|
|
28
|
+
} from "./helpers";
|
|
29
|
+
|
|
30
|
+
import { suite } from "./harness";
|
|
31
|
+
|
|
32
|
+
const e2e = suite("integration", "E2E pipelines");
|
|
33
|
+
const sem = suite("integration", "Failure semantics");
|
|
34
|
+
const con = suite("integration", "Concurrency & scale");
|
|
35
|
+
const evt = suite("integration", "Events & listeners");
|
|
36
|
+
const lif = suite("integration", "Lifecycle & plugins");
|
|
37
|
+
const can = suite("integration", "Cancellation & cycles");
|
|
38
|
+
|
|
39
|
+
const BIG = { timeoutMs: 20_000 };
|
|
40
|
+
|
|
41
|
+
/* =========================================================
|
|
42
|
+
* C-01 — multi-branch order pipeline, end to end
|
|
43
|
+
* ========================================================= */
|
|
44
|
+
|
|
45
|
+
e2e.t(
|
|
46
|
+
"multi-branch order pipeline completes with a checkpoint per successful node",
|
|
47
|
+
async t => {
|
|
48
|
+
const storage = new SpyStorage();
|
|
49
|
+
const order: string[] = [];
|
|
50
|
+
|
|
51
|
+
const definition = g({
|
|
52
|
+
id: "order-pipeline",
|
|
53
|
+
version: "2.1.0",
|
|
54
|
+
nodes: [
|
|
55
|
+
node("validate", () => { order.push("validate"); return { orderId: 7, amount: 120 }; }, {
|
|
56
|
+
next: ["fraud", "audit"]
|
|
57
|
+
}),
|
|
58
|
+
node("fraud", context => {
|
|
59
|
+
const input = context.outputs.validate as { amount: number };
|
|
60
|
+
order.push("fraud");
|
|
61
|
+
return input.amount < 1000 ? "clean" : "suspicious";
|
|
62
|
+
}, { next: ["charge"] }),
|
|
63
|
+
node("charge", context => {
|
|
64
|
+
order.push("charge");
|
|
65
|
+
return { charged: (context.outputs.validate as { amount: number }).amount };
|
|
66
|
+
}, { next: ["ship"] }),
|
|
67
|
+
node("ship", () => { order.push("ship"); return "SHIPPED"; }, { next: ["notify"] }),
|
|
68
|
+
constNode("notify", "EMAIL_SENT"),
|
|
69
|
+
constNode("audit", "AUDITED")
|
|
70
|
+
],
|
|
71
|
+
entryNodes: ["validate"],
|
|
72
|
+
terminalNodes: ["notify"]
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const engine = new GraphExecutionEngine(definition, { storage });
|
|
76
|
+
const events = capture(engine, ["graph.ready", "node.succeeded", "graph.logicallyCompleted", "graph.finished"]);
|
|
77
|
+
const report = await engine.run();
|
|
78
|
+
|
|
79
|
+
if (report.status !== "COMPLETED") throw new Error(report.status);
|
|
80
|
+
if (!report.logicallyCompleted) throw new Error("not logically completed");
|
|
81
|
+
if (report.graphId !== "order-pipeline" || report.graphVersion !== "2.1.0") throw new Error("metadata wrong");
|
|
82
|
+
if (report.errors.length !== 0) throw new Error(`errors=${report.errors.length}`);
|
|
83
|
+
|
|
84
|
+
const ran = report.results.map(result => result.nodeId);
|
|
85
|
+
for (const expected of ["validate", "fraud", "audit", "charge", "ship", "notify"]) {
|
|
86
|
+
if (!ran.includes(expected)) throw new Error(`missing node ${expected}`);
|
|
87
|
+
}
|
|
88
|
+
for (const expected of ["charge", "ship", "notify"]) {
|
|
89
|
+
const count = ran.filter(id => id === expected).length;
|
|
90
|
+
if (count !== 1) throw new Error(`${expected} ran ${count} times (expected exactly once)`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// required-order subsequence on the critical path
|
|
94
|
+
const criticalPath = ran.filter(id => ["validate", "fraud", "charge", "ship", "notify"].includes(id));
|
|
95
|
+
if (criticalPath.join(",") !== "validate,fraud,charge,ship,notify") {
|
|
96
|
+
throw new Error(`critical path=${criticalPath.join(",")}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const logical = events.find(entry => entry.event === "graph.logicallyCompleted");
|
|
100
|
+
if (!logical) throw new Error("logicallyCompleted event missing");
|
|
101
|
+
if ((logical.payload as { terminalNodeId: string }).terminalNodeId !== "notify") {
|
|
102
|
+
throw new Error("wrong terminal node in event");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const finished = events.find(entry => entry.event === "graph.finished");
|
|
106
|
+
if ((finished!.payload as { status: string }).status !== "COMPLETED") throw new Error("finished status");
|
|
107
|
+
|
|
108
|
+
// exactly one checkpoint per successful node, no extras, no gaps
|
|
109
|
+
const succeeded = report.results.filter(result => result.status === "SUCCEEDED").length;
|
|
110
|
+
if (storage.calls !== succeeded) throw new Error(`checkpoints=${storage.calls} vs succeeded=${succeeded}`);
|
|
111
|
+
const last = storage.snapshots[storage.snapshots.length - 1];
|
|
112
|
+
if (Object.keys(last.outputs).length !== succeeded) throw new Error("snapshot outputs incomplete");
|
|
113
|
+
if (last.outputs.ship !== "SHIPPED") throw new Error("ship output lost");
|
|
114
|
+
|
|
115
|
+
t.log("checkpoints written", storage.calls);
|
|
116
|
+
t.log("critical path", criticalPath.join(" -> "));
|
|
117
|
+
},
|
|
118
|
+
BIG
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
/* =========================================================
|
|
122
|
+
* C-02 — chaos: flaky nodes + ONTHROW repair inside one graph
|
|
123
|
+
* ========================================================= */
|
|
124
|
+
|
|
125
|
+
e2e.t(
|
|
126
|
+
"chaos graph: retry recovery plus ONTHROW repair still completes",
|
|
127
|
+
async t => {
|
|
128
|
+
let riskyAttempts = 0;
|
|
129
|
+
// Gate: the steady branch must not reach the terminal before the
|
|
130
|
+
// repair path has run, otherwise "logically completed" would stop
|
|
131
|
+
// the repair activation from being scheduled (see C-15 scenario 4).
|
|
132
|
+
let releaseSteady!: () => void;
|
|
133
|
+
const repairDone = new Promise<void>(resolve => { releaseSteady = resolve; });
|
|
134
|
+
|
|
135
|
+
const definition = g({
|
|
136
|
+
nodes: [
|
|
137
|
+
node("root", () => "go", { next: ["risky", "steady"] }),
|
|
138
|
+
node("risky", () => {
|
|
139
|
+
riskyAttempts += 1;
|
|
140
|
+
if (riskyAttempts === 1) throw new Error("transient-1");
|
|
141
|
+
if (riskyAttempts === 2) throw new Error("transient-2");
|
|
142
|
+
return "risky-ok";
|
|
143
|
+
}, { retry: { maxAttempts: 3, delayMs: 5 }, errorPolicy: "RETRY", next: ["check"] }),
|
|
144
|
+
node("check", () => { throw new Error("data-corrupt"); }, { onThrow: "repair" }),
|
|
145
|
+
node("repair", () => { releaseSteady(); return "repaired"; }, { next: ["done"] }),
|
|
146
|
+
node("steady", async () => {
|
|
147
|
+
await Promise.race([repairDone, sleep(2000)]);
|
|
148
|
+
return "steady-ok";
|
|
149
|
+
}, { next: ["done"] }),
|
|
150
|
+
constNode("done", "DONE")
|
|
151
|
+
],
|
|
152
|
+
entryNodes: ["root"],
|
|
153
|
+
terminalNodes: ["done"]
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
157
|
+
|
|
158
|
+
if (riskyAttempts !== 3) throw new Error(`riskyAttempts=${riskyAttempts}`);
|
|
159
|
+
if (report.status !== "COMPLETED") throw new Error(report.status);
|
|
160
|
+
|
|
161
|
+
const risky = report.results.find(result => result.nodeId === "risky")!;
|
|
162
|
+
if (risky.attempt !== 3 || risky.status !== "SUCCEEDED") throw new Error("risky outcome wrong");
|
|
163
|
+
|
|
164
|
+
const check = report.results.find(result => result.nodeId === "check")!;
|
|
165
|
+
if (check.status !== "FAILED") throw new Error(check.status);
|
|
166
|
+
|
|
167
|
+
if (report.errors.length !== 1) throw new Error(`errors=${report.errors.length}`);
|
|
168
|
+
const error = report.errors[0];
|
|
169
|
+
if (!error.handled) throw new Error("repair error should be handled");
|
|
170
|
+
if (error.nodeId !== "check") throw new Error(error.nodeId);
|
|
171
|
+
if (error.message !== "data-corrupt") throw new Error(error.message);
|
|
172
|
+
if (error.path.join("/") !== "root/risky/check") throw new Error(error.path.join("/"));
|
|
173
|
+
|
|
174
|
+
if (report.outputs.repair !== "repaired") throw new Error("repair output missing");
|
|
175
|
+
if (!report.results.some(result => result.nodeId === "done")) throw new Error("done never ran");
|
|
176
|
+
|
|
177
|
+
t.log("error path", error.path);
|
|
178
|
+
t.log("handled", error.handled);
|
|
179
|
+
},
|
|
180
|
+
BIG
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
/* =========================================================
|
|
184
|
+
* C-20 — full resilience scenario (mixed outcomes)
|
|
185
|
+
* ========================================================= */
|
|
186
|
+
|
|
187
|
+
e2e.t(
|
|
188
|
+
"resilience scenario: timeout is routed to a fallback so the run completes",
|
|
189
|
+
async t => {
|
|
190
|
+
const storage = new SpyStorage();
|
|
191
|
+
const custom: unknown[] = [];
|
|
192
|
+
// The resilient path must finish before another branch can reach the
|
|
193
|
+
// terminal, otherwise logical completion drops the fallback activation.
|
|
194
|
+
let releaseFast!: () => void;
|
|
195
|
+
const fallbackDone = new Promise<void>(resolve => { releaseFast = resolve; });
|
|
196
|
+
|
|
197
|
+
const definition = g({
|
|
198
|
+
nodes: [
|
|
199
|
+
node("start", () => 1, { next: ["slow-vendor", "fast-vendor"] }),
|
|
200
|
+
node("slow-vendor", async () => { await sleep(80); return "vendor-result"; }, {
|
|
201
|
+
timeoutMs: 25,
|
|
202
|
+
onThrow: "vendor-fallback"
|
|
203
|
+
}),
|
|
204
|
+
node("vendor-fallback", () => { releaseFast(); return "cached-price"; }, { next: ["decide"] }),
|
|
205
|
+
node("fast-vendor", async () => {
|
|
206
|
+
await Promise.race([fallbackDone, sleep(2000)]);
|
|
207
|
+
return "fresh-price";
|
|
208
|
+
}, { next: ["decide"] }),
|
|
209
|
+
node("decide", async context => {
|
|
210
|
+
await context.emit("pricing.decided", { fallback: context.outputs["vendor-fallback"] });
|
|
211
|
+
return "decided";
|
|
212
|
+
}, { next: ["finish"] }),
|
|
213
|
+
constNode("finish", "DONE")
|
|
214
|
+
],
|
|
215
|
+
entryNodes: ["start"],
|
|
216
|
+
terminalNodes: ["finish"]
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const engine = new GraphExecutionEngine(definition, { storage });
|
|
220
|
+
engine.events.on("pricing.decided", payload => { custom.push(payload); });
|
|
221
|
+
const report = await engine.run();
|
|
222
|
+
await sleep(20);
|
|
223
|
+
|
|
224
|
+
if (report.status !== "COMPLETED") throw new Error(report.status);
|
|
225
|
+
if (report.errors.length !== 1) throw new Error(`errors=${report.errors.length}`);
|
|
226
|
+
|
|
227
|
+
const timeoutError = report.errors[0];
|
|
228
|
+
if (timeoutError.code !== "NODE_TIMEOUT") throw new Error(timeoutError.code);
|
|
229
|
+
if (!timeoutError.handled) throw new Error("timeout error not handled by fallback");
|
|
230
|
+
if (timeoutError.nodeId !== "slow-vendor") throw new Error(timeoutError.nodeId);
|
|
231
|
+
|
|
232
|
+
const slow = report.results.find(result => result.nodeId === "slow-vendor")!;
|
|
233
|
+
if (slow.status !== "TIMED_OUT") throw new Error(slow.status);
|
|
234
|
+
|
|
235
|
+
if (report.outputs["vendor-fallback"] !== "cached-price") throw new Error("fallback output missing");
|
|
236
|
+
if (!report.results.some(result => result.nodeId === "finish")) throw new Error("finish never ran");
|
|
237
|
+
|
|
238
|
+
const decideRuns = report.results.filter(result => result.nodeId === "decide").length;
|
|
239
|
+
if (decideRuns < 1) throw new Error("decide never ran");
|
|
240
|
+
if (custom.length !== decideRuns) {
|
|
241
|
+
throw new Error(`custom events=${custom.length} vs decide runs=${decideRuns}`);
|
|
242
|
+
}
|
|
243
|
+
if ((custom[0] as { fallback: string }).fallback !== "cached-price") {
|
|
244
|
+
throw new Error("decide did not observe the fallback output");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// every successful node produced exactly one checkpoint
|
|
248
|
+
const succeeded = report.results.filter(result => result.status === "SUCCEEDED").length;
|
|
249
|
+
if (storage.calls !== succeeded) throw new Error(`checkpoints=${storage.calls} vs succeeded=${succeeded}`);
|
|
250
|
+
|
|
251
|
+
t.log("timeout details", timeoutError.details);
|
|
252
|
+
t.log("succeeded nodes", succeeded);
|
|
253
|
+
t.log("finding: fan-in runs the join node once per incoming edge (decide runs = " + decideRuns + ")");
|
|
254
|
+
},
|
|
255
|
+
BIG
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
/* =========================================================
|
|
259
|
+
* C-03 — FAIL_FAST at graph level: unscheduled work is dropped
|
|
260
|
+
* ========================================================= */
|
|
261
|
+
|
|
262
|
+
sem.t(
|
|
263
|
+
"graph-level FAIL_FAST stops not-yet-scheduled successors on independent branches",
|
|
264
|
+
async t => {
|
|
265
|
+
const definition = g({
|
|
266
|
+
errorPolicy: "FAIL_FAST",
|
|
267
|
+
nodes: [
|
|
268
|
+
node("root", () => "go", { next: ["failing", "slow-branch"] }),
|
|
269
|
+
node("failing", () => { throw new Error("policy-violation"); }),
|
|
270
|
+
node("slow-branch", async () => { await sleep(30); return "still-here"; }, { next: ["slow-child"] }),
|
|
271
|
+
constNode("slow-child", "should-not-run")
|
|
272
|
+
],
|
|
273
|
+
entryNodes: ["root"],
|
|
274
|
+
terminalNodes: ["slow-child"]
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
278
|
+
const ran = report.results.map(result => result.nodeId);
|
|
279
|
+
|
|
280
|
+
if (report.status !== "FAILED") throw new Error(report.status);
|
|
281
|
+
if (!ran.includes("failing")) throw new Error("failing node did not run");
|
|
282
|
+
if (!ran.includes("slow-branch")) throw new Error("in-flight sibling was wrongly aborted");
|
|
283
|
+
if (ran.includes("slow-child")) throw new Error("FAIL_FAST failed to stop pending work");
|
|
284
|
+
|
|
285
|
+
const failures = report.errors.filter(error => error.code === "NODE_EXECUTION_ERROR");
|
|
286
|
+
if (failures.length !== 1) throw new Error(`errors=${failures.length}`);
|
|
287
|
+
if (failures[0].message !== "policy-violation") throw new Error(failures[0].message);
|
|
288
|
+
|
|
289
|
+
t.log(
|
|
290
|
+
"finding: an already-started sibling still observes its own completion " +
|
|
291
|
+
"before the graph settles as FAILED"
|
|
292
|
+
);
|
|
293
|
+
},
|
|
294
|
+
BIG
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
/* =========================================================
|
|
298
|
+
* C-04 — ONTHROW is a transition, not a retry
|
|
299
|
+
* ========================================================= */
|
|
300
|
+
|
|
301
|
+
sem.t(
|
|
302
|
+
"ONTHROW routes to a repair node and the failing node is never re-attempted",
|
|
303
|
+
async t => {
|
|
304
|
+
let failures = 0;
|
|
305
|
+
|
|
306
|
+
const definition = g({
|
|
307
|
+
nodes: [
|
|
308
|
+
node("root", () => "go", { next: ["risky"] }),
|
|
309
|
+
node("risky", () => { failures += 1; throw new Error("explode"); }, { onThrow: "repair" }),
|
|
310
|
+
node("repair", () => "fixed", { next: ["verify"] }),
|
|
311
|
+
node("verify", () => "verified", { next: ["finish"] }),
|
|
312
|
+
constNode("finish", "END")
|
|
313
|
+
],
|
|
314
|
+
entryNodes: ["root"],
|
|
315
|
+
terminalNodes: ["finish"]
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
319
|
+
|
|
320
|
+
if (failures !== 1) throw new Error(`risky executed ${failures} times (ONTHROW must not retry)`);
|
|
321
|
+
if (report.status !== "COMPLETED") throw new Error(report.status);
|
|
322
|
+
|
|
323
|
+
const risky = report.results.find(result => result.nodeId === "risky")!;
|
|
324
|
+
if (risky.attempt !== 1 || risky.status !== "FAILED") throw new Error("risky outcome wrong");
|
|
325
|
+
|
|
326
|
+
if (report.errors[0].path.join("/") !== "root/risky") throw new Error(report.errors[0].path.join("/"));
|
|
327
|
+
if (!report.errors[0].handled) throw new Error("error should be marked handled");
|
|
328
|
+
|
|
329
|
+
const order = report.results.map(result => result.nodeId).join(",");
|
|
330
|
+
if (!/root,risky,repair,verify,finish/.test(order)) throw new Error(order);
|
|
331
|
+
|
|
332
|
+
t.log("activation order", order);
|
|
333
|
+
},
|
|
334
|
+
BIG
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
/* =========================================================
|
|
338
|
+
* C-05 — diamond fan-out/fan-in: activations are per-edge
|
|
339
|
+
* ========================================================= */
|
|
340
|
+
|
|
341
|
+
con.t(
|
|
342
|
+
"diamond fan-in executes the join node once per incoming activation (no dedup)",
|
|
343
|
+
async t => {
|
|
344
|
+
let joinActivations = 0;
|
|
345
|
+
let releaseJoin!: () => void;
|
|
346
|
+
const bothArrived = new Promise<void>(resolve => { releaseJoin = resolve; });
|
|
347
|
+
|
|
348
|
+
const definition = g({
|
|
349
|
+
nodes: [
|
|
350
|
+
node("a", () => "A", { next: ["b", "c"] }),
|
|
351
|
+
node("b", () => "B", { next: ["d"] }),
|
|
352
|
+
node("c", () => "C", { next: ["d"] }),
|
|
353
|
+
node("d", async context => {
|
|
354
|
+
joinActivations += 1;
|
|
355
|
+
if (joinActivations === 2) releaseJoin();
|
|
356
|
+
// Hold until both incoming activations exist, so the count is
|
|
357
|
+
// deterministic instead of racing logical completion.
|
|
358
|
+
await Promise.race([bothArrived, sleep(2000)]);
|
|
359
|
+
return `d(seen b=${String(context.outputs.b)}, c=${String(context.outputs.c)})`;
|
|
360
|
+
}, { next: ["e"] }),
|
|
361
|
+
constNode("e", "E")
|
|
362
|
+
],
|
|
363
|
+
entryNodes: ["a"],
|
|
364
|
+
terminalNodes: ["e"]
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
368
|
+
const dResults = report.results.filter(result => result.nodeId === "d");
|
|
369
|
+
|
|
370
|
+
if (report.status !== "COMPLETED") throw new Error(report.status);
|
|
371
|
+
if (joinActivations !== 2) throw new Error(`join activated ${joinActivations} times (expected 2)`);
|
|
372
|
+
if (dResults.length !== 2) throw new Error(`join results=${dResults.length}`);
|
|
373
|
+
if (new Set(dResults.map(result => result.activationId)).size !== 2) {
|
|
374
|
+
throw new Error("activationIds not distinct");
|
|
375
|
+
}
|
|
376
|
+
if (dResults.some(result => result.status !== "SUCCEEDED")) throw new Error("join activation failed");
|
|
377
|
+
if (!report.results.some(result => result.nodeId === "e")) throw new Error("terminal never ran");
|
|
378
|
+
|
|
379
|
+
const finalValue = String(report.outputs.d);
|
|
380
|
+
if (!finalValue.startsWith("d(seen b=B, c=C)")) throw new Error(finalValue);
|
|
381
|
+
|
|
382
|
+
t.log("finding: no join/dedup semantics — the join node ran once per incoming edge");
|
|
383
|
+
t.log("final outputs.d (last writer wins)", finalValue);
|
|
384
|
+
},
|
|
385
|
+
BIG
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
/* =========================================================
|
|
389
|
+
* C-06 — cycles terminate through ONTHROW (the only static branch)
|
|
390
|
+
* ========================================================= */
|
|
391
|
+
|
|
392
|
+
can.t(
|
|
393
|
+
"cyclic graph iterates via next + ONTHROW exit and keeps a growing path",
|
|
394
|
+
async t => {
|
|
395
|
+
const definition = g({
|
|
396
|
+
nodes: [
|
|
397
|
+
node("loop", (context: NodeContext) => {
|
|
398
|
+
const iteration = (context.outputs.loop as number | undefined) ?? 0;
|
|
399
|
+
if (iteration >= 5) throw new Error("loop-exit");
|
|
400
|
+
return iteration + 1;
|
|
401
|
+
}, { next: ["loop"], onThrow: "done" }),
|
|
402
|
+
constNode("done", "LOOP_FINISHED")
|
|
403
|
+
],
|
|
404
|
+
entryNodes: ["loop"],
|
|
405
|
+
terminalNodes: ["done"]
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
409
|
+
|
|
410
|
+
if (report.status !== "COMPLETED") throw new Error(report.status);
|
|
411
|
+
|
|
412
|
+
const loopRuns = report.results.filter(result => result.nodeId === "loop");
|
|
413
|
+
if (loopRuns.length !== 6) throw new Error(`loop ran ${loopRuns.length} times`);
|
|
414
|
+
if (report.outputs.loop !== 5) throw new Error(String(report.outputs.loop));
|
|
415
|
+
|
|
416
|
+
const exitError = report.errors[0];
|
|
417
|
+
if (exitError.message !== "loop-exit" || !exitError.handled) throw new Error("exit not handled");
|
|
418
|
+
if (exitError.path.length !== 6) throw new Error(`path length ${exitError.path.length}`);
|
|
419
|
+
if (exitError.path.some(step => step !== "loop")) throw new Error(exitError.path.join(","));
|
|
420
|
+
|
|
421
|
+
t.log("finding: cycles are legal (no validation) and terminate only via ONTHROW");
|
|
422
|
+
t.log("loop error path", exitError.path.join(" -> "));
|
|
423
|
+
},
|
|
424
|
+
BIG
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
/* =========================================================
|
|
428
|
+
* C-07 — wide fan-out with a hard concurrency barrier
|
|
429
|
+
* ========================================================= */
|
|
430
|
+
|
|
431
|
+
con.t(
|
|
432
|
+
"250-node fan-out satisfies a full-concurrency barrier (no hidden cap)",
|
|
433
|
+
async t => {
|
|
434
|
+
const LEAVES = 250;
|
|
435
|
+
let started = 0;
|
|
436
|
+
let releaseBarrier!: () => void;
|
|
437
|
+
const barrier = new Promise<void>(resolve => { releaseBarrier = resolve; });
|
|
438
|
+
|
|
439
|
+
const leaves = Array.from({ length: LEAVES }, (_, index) =>
|
|
440
|
+
node(`leaf-${index}`, async () => {
|
|
441
|
+
started += 1;
|
|
442
|
+
if (started === LEAVES) releaseBarrier();
|
|
443
|
+
await barrier;
|
|
444
|
+
return index;
|
|
445
|
+
})
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
const definition = g({
|
|
449
|
+
nodes: [node("root", () => "root", { next: leaves.map(leaf => leaf.id) }), ...leaves],
|
|
450
|
+
entryNodes: ["root"],
|
|
451
|
+
terminalNodes: [`leaf-${LEAVES - 1}`]
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const startedAt = Date.now();
|
|
455
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
456
|
+
const durationMs = Date.now() - startedAt;
|
|
457
|
+
|
|
458
|
+
if (report.status !== "COMPLETED") throw new Error(report.status);
|
|
459
|
+
if (started !== LEAVES) throw new Error(`barrier deadlock: only ${started} leaves started`);
|
|
460
|
+
if (report.results.length !== LEAVES + 1) throw new Error(`results=${report.results.length}`);
|
|
461
|
+
if (report.results.some(result => result.status !== "SUCCEEDED")) throw new Error("some leaf failed");
|
|
462
|
+
if (report.outputs[`leaf-${LEAVES - 1}`] !== LEAVES - 1) throw new Error("leaf output wrong");
|
|
463
|
+
|
|
464
|
+
t.log("finding: scheduler concurrency is unbounded (MAX_SAFE_INTEGER)");
|
|
465
|
+
t.log(
|
|
466
|
+
"throughput proof: all 250 nodes were simultaneously in flight (the barrier could only be " +
|
|
467
|
+
"released once every leaf had started), so wall time is ~1 node, not 250"
|
|
468
|
+
);
|
|
469
|
+
t.log("wall time", `${durationMs}ms (serial lower bound would be >= 1250ms if capped)`);
|
|
470
|
+
},
|
|
471
|
+
BIG
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
/* =========================================================
|
|
475
|
+
* C-19 — long chain: checkpoint monotonicity and ordering
|
|
476
|
+
* ========================================================= */
|
|
477
|
+
|
|
478
|
+
con.t(
|
|
479
|
+
"40-node chain produces monotone, gap-free checkpoints in execution order",
|
|
480
|
+
async t => {
|
|
481
|
+
const LENGTH = 40;
|
|
482
|
+
const ids = Array.from({ length: LENGTH }, (_, index) => `s${index}`);
|
|
483
|
+
const storage = new SpyStorage();
|
|
484
|
+
const report = await new GraphExecutionEngine(linear(ids, id => `${id}-out`), { storage }).run();
|
|
485
|
+
|
|
486
|
+
if (report.status !== "COMPLETED") throw new Error(report.status);
|
|
487
|
+
if (storage.calls !== LENGTH) throw new Error(`checkpoints=${storage.calls}`);
|
|
488
|
+
|
|
489
|
+
const order = report.results.map(result => result.nodeId).join(",");
|
|
490
|
+
if (order !== ids.join(",")) throw new Error("chain ran out of order");
|
|
491
|
+
|
|
492
|
+
let previousCount = 0;
|
|
493
|
+
let previousTime = 0;
|
|
494
|
+
for (const [index, snapshot] of storage.snapshots.entries()) {
|
|
495
|
+
const count = Object.keys(snapshot.outputs).length;
|
|
496
|
+
if (count !== index + 1) throw new Error(`snapshot ${index} has ${count} outputs`);
|
|
497
|
+
if (count <= previousCount) throw new Error("outputs not monotonically increasing");
|
|
498
|
+
previousCount = count;
|
|
499
|
+
|
|
500
|
+
const time = Date.parse(snapshot.timestamp);
|
|
501
|
+
if (time < previousTime) throw new Error("snapshot timestamps went backwards");
|
|
502
|
+
previousTime = time;
|
|
503
|
+
|
|
504
|
+
if (snapshot.status !== "RUNNING") throw new Error(`snapshot status ${snapshot.status}`);
|
|
505
|
+
if (snapshot.executionId !== report.executionId) throw new Error("snapshot executionId mismatch");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
t.log("snapshots", storage.calls);
|
|
509
|
+
t.log("final outputs", previousCount);
|
|
510
|
+
},
|
|
511
|
+
BIG
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
/* =========================================================
|
|
515
|
+
* C-13 — event ordering with required listeners
|
|
516
|
+
* ========================================================= */
|
|
517
|
+
|
|
518
|
+
evt.t(
|
|
519
|
+
"required listeners observe a deterministic lifecycle event order",
|
|
520
|
+
async t => {
|
|
521
|
+
const engine = new GraphExecutionEngine(linear(["a", "b"]));
|
|
522
|
+
const sequence: string[] = [];
|
|
523
|
+
const succeededListeners: string[] = [];
|
|
524
|
+
|
|
525
|
+
for (const name of [
|
|
526
|
+
"graph.ready",
|
|
527
|
+
"graph.cancellationRequested",
|
|
528
|
+
"node.started",
|
|
529
|
+
"node.succeeded",
|
|
530
|
+
"node.failed",
|
|
531
|
+
"graph.logicallyCompleted",
|
|
532
|
+
"graph.finished"
|
|
533
|
+
]) {
|
|
534
|
+
engine.events.on(name, (payload: unknown) => {
|
|
535
|
+
const info = payload as { nodeId?: string };
|
|
536
|
+
sequence.push(info.nodeId ? `${name}:${info.nodeId}` : name);
|
|
537
|
+
}, { required: true });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
engine.events.on("node.succeeded", () => { succeededListeners.push("first"); }, { required: true });
|
|
541
|
+
engine.events.on("node.succeeded", async () => {
|
|
542
|
+
await sleep(1);
|
|
543
|
+
succeededListeners.push("second");
|
|
544
|
+
}, { required: true });
|
|
545
|
+
engine.events.on("node.succeeded", () => { succeededListeners.push("third"); }, { required: true });
|
|
546
|
+
|
|
547
|
+
const startedAt = Date.now();
|
|
548
|
+
const report = await engine.run();
|
|
549
|
+
const runMs = Date.now() - startedAt;
|
|
550
|
+
|
|
551
|
+
const expected = [
|
|
552
|
+
"graph.ready",
|
|
553
|
+
"node.started:a",
|
|
554
|
+
"node.succeeded:a",
|
|
555
|
+
"node.started:b",
|
|
556
|
+
"node.succeeded:b",
|
|
557
|
+
"graph.logicallyCompleted",
|
|
558
|
+
"graph.finished"
|
|
559
|
+
];
|
|
560
|
+
if (sequence.join("|") !== expected.join("|")) {
|
|
561
|
+
throw new Error(`sequence=${sequence.join("|")}`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (succeededListeners.join(",") !== "first,second,third,first,second,third") {
|
|
565
|
+
throw new Error(`required listener FIFO broken: ${succeededListeners.join(",")}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (report.status !== "COMPLETED") throw new Error(report.status);
|
|
569
|
+
|
|
570
|
+
t.log("event sequence", sequence.join(" -> "));
|
|
571
|
+
t.log("run duration", `${runMs}ms`);
|
|
572
|
+
},
|
|
573
|
+
BIG
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
/* =========================================================
|
|
577
|
+
* C-17 — listener isolation & resilience
|
|
578
|
+
* ========================================================= */
|
|
579
|
+
|
|
580
|
+
evt.t(
|
|
581
|
+
"faulty non-required listeners are isolated; a failing required listener is contained",
|
|
582
|
+
async t => {
|
|
583
|
+
const { lines, onLog } = logCollector();
|
|
584
|
+
const engine = new GraphExecutionEngine(linear(["a", "b"]), { onLog });
|
|
585
|
+
|
|
586
|
+
engine.events.on("node.succeeded", () => { throw new Error("sync-listener-boom"); });
|
|
587
|
+
engine.events.on("node.started", async () => { throw new Error("async-listener-boom"); });
|
|
588
|
+
engine.events.on("graph.finished", () => { throw new Error("required-finished-boom"); }, { required: true });
|
|
589
|
+
|
|
590
|
+
const report = await engine.run();
|
|
591
|
+
await tick();
|
|
592
|
+
await sleep(20);
|
|
593
|
+
|
|
594
|
+
if (report.status !== "COMPLETED") throw new Error(report.status);
|
|
595
|
+
if (report.errors.length !== 0) {
|
|
596
|
+
throw new Error(`listener faults leaked into node errors: ${report.errors.length}`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const isolated = lines.filter(line => /^Non-critical event listener failed: /.test(line.message));
|
|
600
|
+
const eventsLogged = new Set(isolated.map(line => line.message.split(": ")[1]));
|
|
601
|
+
if (!eventsLogged.has("node.succeeded") || !eventsLogged.has("node.started")) {
|
|
602
|
+
throw new Error(`isolation logging incomplete: ${[...eventsLogged].join(",")}`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const contained = lines.find(line => line.message === "graph.finished listener failed");
|
|
606
|
+
if (!contained) throw new Error("required graph.finished failure not contained");
|
|
607
|
+
|
|
608
|
+
t.log("isolated listener failures", isolated.length);
|
|
609
|
+
t.log(
|
|
610
|
+
"finding: graph.finished is the only emit whose rejection is caught by the engine; " +
|
|
611
|
+
"a failing required listener elsewhere (e.g. node.failed) escapes into the node error path"
|
|
612
|
+
);
|
|
613
|
+
},
|
|
614
|
+
BIG
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
/* =========================================================
|
|
618
|
+
* C-08 — plugin lifecycle & shutdown error collection
|
|
619
|
+
* ========================================================= */
|
|
620
|
+
|
|
621
|
+
lif.t(
|
|
622
|
+
"plugins close in reverse order and shutdown failures are collected, not fatal",
|
|
623
|
+
async t => {
|
|
624
|
+
const closes: string[] = [];
|
|
625
|
+
|
|
626
|
+
const engine = new GraphExecutionEngine(linear(["a"]), {
|
|
627
|
+
plugins: [
|
|
628
|
+
plugin({ id: "audit", onClose: async () => { await sleep(5); closes.push("audit"); } }),
|
|
629
|
+
plugin({
|
|
630
|
+
id: "telemetry",
|
|
631
|
+
onClose: () => { throw new Error("socket already closed"); }
|
|
632
|
+
}),
|
|
633
|
+
plugin({ id: "cache", onClose: () => { closes.push("cache"); } })
|
|
634
|
+
]
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
const report = await engine.run();
|
|
638
|
+
|
|
639
|
+
if (report.status !== "COMPLETED") throw new Error(report.status);
|
|
640
|
+
if (closes.join(",") !== "cache,audit") throw new Error(`close order=${closes.join(",")}`);
|
|
641
|
+
if (report.pluginShutdownErrors.length !== 1) {
|
|
642
|
+
throw new Error(`shutdown errors=${report.pluginShutdownErrors.length}`);
|
|
643
|
+
}
|
|
644
|
+
if (!report.pluginShutdownErrors[0].includes('Plugin "telemetry" shutdown failed')) {
|
|
645
|
+
throw new Error(report.pluginShutdownErrors[0]);
|
|
646
|
+
}
|
|
647
|
+
if (!report.pluginShutdownErrors[0].includes("socket already closed")) {
|
|
648
|
+
throw new Error("cause message missing");
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
t.log("shutdown errors", report.pluginShutdownErrors);
|
|
652
|
+
},
|
|
653
|
+
BIG
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
lif.t(
|
|
657
|
+
"plugin shutdown is time-boxed by cancellationTimeoutMs",
|
|
658
|
+
async t => {
|
|
659
|
+
const closes: string[] = [];
|
|
660
|
+
const engine = new GraphExecutionEngine(linear(["a"]), {
|
|
661
|
+
cancellationTimeoutMs: 30,
|
|
662
|
+
plugins: [
|
|
663
|
+
plugin({ id: "slow-close", onClose: async () => { await sleep(400); closes.push("slow"); } }),
|
|
664
|
+
plugin({ id: "fast-close", onClose: () => { closes.push("fast"); } })
|
|
665
|
+
]
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
const startedAt = Date.now();
|
|
669
|
+
const report = await engine.run();
|
|
670
|
+
const durationMs = Date.now() - startedAt;
|
|
671
|
+
|
|
672
|
+
if (durationMs > 3000) throw new Error(`shutdown hung for ${durationMs}ms`);
|
|
673
|
+
if (!report.pluginShutdownErrors.some(message => message.includes('Plugin "slow-close"'))) {
|
|
674
|
+
throw new Error("slow close was not flagged");
|
|
675
|
+
}
|
|
676
|
+
// fast-close still ran: a failure/timeout in one plugin does not skip the rest
|
|
677
|
+
if (!closes.includes("fast")) throw new Error("remaining plugin skipped after timeout");
|
|
678
|
+
|
|
679
|
+
t.log("run duration", `${durationMs}ms`);
|
|
680
|
+
t.log("shutdown errors", report.pluginShutdownErrors.length);
|
|
681
|
+
},
|
|
682
|
+
BIG
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
/* =========================================================
|
|
686
|
+
* C-14 — metadata isolation between concurrent executions
|
|
687
|
+
* ========================================================= */
|
|
688
|
+
|
|
689
|
+
lif.t(
|
|
690
|
+
"two concurrent executions keep ids, snapshots and results isolated",
|
|
691
|
+
async t => {
|
|
692
|
+
const makeDefinition = () =>
|
|
693
|
+
g({
|
|
694
|
+
nodes: [
|
|
695
|
+
node("a", async () => { await sleep(15); return "A"; }, { next: ["b"] }),
|
|
696
|
+
node("b", () => { throw new Error("branch-boom"); }, { onThrow: "c" }),
|
|
697
|
+
constNode("c", "C")
|
|
698
|
+
],
|
|
699
|
+
entryNodes: ["a"],
|
|
700
|
+
terminalNodes: ["c"]
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const storageOne = new SpyStorage();
|
|
704
|
+
const storageTwo = new SpyStorage();
|
|
705
|
+
const one = new GraphExecutionEngine(makeDefinition(), { storage: storageOne });
|
|
706
|
+
const two = new GraphExecutionEngine(makeDefinition(), { storage: storageTwo });
|
|
707
|
+
|
|
708
|
+
const [reportOne, reportTwo] = await Promise.all([one.run(), two.run()]);
|
|
709
|
+
|
|
710
|
+
if (reportOne.executionId === reportTwo.executionId) throw new Error("executionIds collided");
|
|
711
|
+
if (reportOne.status !== "COMPLETED" || reportTwo.status !== "COMPLETED") throw new Error("status mismatch");
|
|
712
|
+
|
|
713
|
+
for (const [report, storage] of [
|
|
714
|
+
[reportOne, storageOne] as const,
|
|
715
|
+
[reportTwo, storageTwo] as const
|
|
716
|
+
]) {
|
|
717
|
+
if (storage.calls === 0) throw new Error("no checkpoints captured");
|
|
718
|
+
if (storage.snapshots.some(snapshot => snapshot.executionId !== report.executionId)) {
|
|
719
|
+
throw new Error("snapshot crossed executions");
|
|
720
|
+
}
|
|
721
|
+
if (report.errors.some(error => error.executionId !== report.executionId)) {
|
|
722
|
+
throw new Error("error crossed executions");
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const activationIds = new Set([
|
|
727
|
+
...reportOne.results.map(result => result.activationId),
|
|
728
|
+
...reportTwo.results.map(result => result.activationId)
|
|
729
|
+
]);
|
|
730
|
+
if (activationIds.size !== reportOne.results.length + reportTwo.results.length) {
|
|
731
|
+
throw new Error("activationIds leaked between executions");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (JSON.stringify(reportOne.outputs) !== JSON.stringify(reportTwo.outputs)) {
|
|
735
|
+
throw new Error("outputs diverged between identical runs");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
t.log("execution ids", [
|
|
739
|
+
reportOne.executionId.slice(0, 8),
|
|
740
|
+
reportTwo.executionId.slice(0, 8)
|
|
741
|
+
]);
|
|
742
|
+
},
|
|
743
|
+
BIG
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
/* =========================================================
|
|
747
|
+
* C-09 — checkpoint persistence failure mid-run
|
|
748
|
+
* ========================================================= */
|
|
749
|
+
|
|
750
|
+
sem.t(
|
|
751
|
+
"checkpoint failure on the 2nd save blocks scheduling and freezes the last valid snapshot",
|
|
752
|
+
async t => {
|
|
753
|
+
const storage = new SpyStorage();
|
|
754
|
+
storage.failOn = callIndex => callIndex === 2;
|
|
755
|
+
|
|
756
|
+
const definition = g({
|
|
757
|
+
nodes: [
|
|
758
|
+
node("entry", () => "E", { next: ["b1", "b2"] }),
|
|
759
|
+
node("b1", async () => { await sleep(10); return "B1"; }, { next: ["tail"] }),
|
|
760
|
+
node("b2", async () => { await sleep(15); return "B2"; }, { next: ["tail"] }),
|
|
761
|
+
constNode("tail", "TAIL")
|
|
762
|
+
],
|
|
763
|
+
entryNodes: ["entry"],
|
|
764
|
+
terminalNodes: ["tail"]
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const report = await new GraphExecutionEngine(definition, { storage }).run();
|
|
768
|
+
|
|
769
|
+
if (report.status !== "FAILED") throw new Error(report.status);
|
|
770
|
+
if (storage.snapshots.length !== 1) throw new Error(`valid snapshots=${storage.snapshots.length}`);
|
|
771
|
+
|
|
772
|
+
const valid = storage.snapshots[0];
|
|
773
|
+
if (valid.outputs.entry !== "E") throw new Error("last valid snapshot lost entry output");
|
|
774
|
+
|
|
775
|
+
const checkpointErrors = report.errors.filter(error => error.code === "CHECKPOINT_SAVE_FAILED");
|
|
776
|
+
if (checkpointErrors.length === 0) throw new Error("CHECKPOINT_SAVE_FAILED never recorded");
|
|
777
|
+
|
|
778
|
+
if (report.results.some(result => result.nodeId === "tail")) {
|
|
779
|
+
throw new Error("successors scheduled after checkpoint failure");
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const nodeFailures = report.results.filter(result => result.status === "FAILED").map(result => result.nodeId);
|
|
783
|
+
if (!nodeFailures.includes("b1") || !nodeFailures.includes("b2")) {
|
|
784
|
+
throw new Error(`node failures=${nodeFailures.join(",")}`);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
t.log("checkpoint errors", checkpointErrors.length);
|
|
788
|
+
t.log(
|
|
789
|
+
"finding: the checkpoint failure surfaces twice per node (CHECKPOINT_SAVE_FAILED plus a " +
|
|
790
|
+
"NODE_EXECUTION_ERROR for the raw persistence error) and marks those nodes FAILED"
|
|
791
|
+
);
|
|
792
|
+
},
|
|
793
|
+
BIG
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
/* =========================================================
|
|
797
|
+
* C-11 — node timeout is cooperative, not preemptive
|
|
798
|
+
* ========================================================= */
|
|
799
|
+
|
|
800
|
+
sem.t(
|
|
801
|
+
"node timeout settles the graph while the abandoned node keeps running (cooperative abort)",
|
|
802
|
+
async t => {
|
|
803
|
+
let sawAbort = false;
|
|
804
|
+
let abortReason: unknown;
|
|
805
|
+
|
|
806
|
+
const definition = g({
|
|
807
|
+
nodes: [
|
|
808
|
+
node("stubborn", async context => {
|
|
809
|
+
await sleep(150);
|
|
810
|
+
sawAbort = context.signal.aborted;
|
|
811
|
+
abortReason = context.signal.reason;
|
|
812
|
+
return "late-arrival";
|
|
813
|
+
}, { timeoutMs: 40 })
|
|
814
|
+
],
|
|
815
|
+
entryNodes: ["stubborn"],
|
|
816
|
+
terminalNodes: ["stubborn"]
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
const engine = new GraphExecutionEngine(definition);
|
|
820
|
+
const startedAt = Date.now();
|
|
821
|
+
const report = await engine.run();
|
|
822
|
+
const durationMs = Date.now() - startedAt;
|
|
823
|
+
|
|
824
|
+
if (report.status !== "FAILED") throw new Error(report.status);
|
|
825
|
+
if (report.results[0].status !== "TIMED_OUT") throw new Error(report.results[0].status);
|
|
826
|
+
if (durationMs >= 140) throw new Error(`timeout did not preempt the wait (${durationMs}ms)`);
|
|
827
|
+
|
|
828
|
+
const error = report.errors[0];
|
|
829
|
+
if (error.code !== "NODE_TIMEOUT") throw new Error(error.code);
|
|
830
|
+
if (!error.stack) throw new Error("missing stack trace");
|
|
831
|
+
const details = error.details as { nodeId: string; timeoutMs: number };
|
|
832
|
+
if (details.nodeId !== "stubborn" || details.timeoutMs !== 40) throw new Error("bad details");
|
|
833
|
+
|
|
834
|
+
// Wait for the abandoned node to finish on its own.
|
|
835
|
+
await sleep(200);
|
|
836
|
+
if (!sawAbort) throw new Error("signal never aborted");
|
|
837
|
+
if (!(abortReason instanceof TimeoutError)) throw new Error("abort reason is not a TimeoutError");
|
|
838
|
+
if (engine.getState() !== "FAILED") throw new Error("state mutated after settlement");
|
|
839
|
+
|
|
840
|
+
t.log("run duration", `${durationMs}ms (node keeps running for 150ms)`);
|
|
841
|
+
t.log("abort reason", (abortReason as Error).name);
|
|
842
|
+
t.log("finding: JavaScript cannot kill a running node; the engine reports TIMED_OUT and moves on");
|
|
843
|
+
},
|
|
844
|
+
BIG
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
/* =========================================================
|
|
848
|
+
* C-12 — retry & error-policy semantics matrix
|
|
849
|
+
* ========================================================= */
|
|
850
|
+
|
|
851
|
+
sem.t(
|
|
852
|
+
"policy matrix: RETRY loops, CONTINUE ignores maxAttempts, FALLBACK has no distinct path",
|
|
853
|
+
async t => {
|
|
854
|
+
// (a) RETRY loops until success
|
|
855
|
+
let attemptsA = 0;
|
|
856
|
+
const a = g({
|
|
857
|
+
nodes: [
|
|
858
|
+
node("flaky", () => { attemptsA += 1; if (attemptsA < 3) throw new Error(`f${attemptsA}`); return "ok"; },
|
|
859
|
+
{ retry: { maxAttempts: 3, delayMs: 10 }, errorPolicy: "RETRY" })
|
|
860
|
+
],
|
|
861
|
+
entryNodes: ["flaky"],
|
|
862
|
+
terminalNodes: ["flaky"]
|
|
863
|
+
});
|
|
864
|
+
const reportA = await new GraphExecutionEngine(a).run();
|
|
865
|
+
if (attemptsA !== 3) throw new Error(`(a) attempts=${attemptsA}`);
|
|
866
|
+
if (reportA.status !== "COMPLETED") throw new Error(`(a) ${reportA.status}`);
|
|
867
|
+
|
|
868
|
+
// (b) CONTINUE (default): retry policy present but policy does not loop
|
|
869
|
+
let attemptsB = 0;
|
|
870
|
+
const b = g({
|
|
871
|
+
nodes: [
|
|
872
|
+
node("once", () => { attemptsB += 1; throw new Error("single-shot"); },
|
|
873
|
+
{ retry: { maxAttempts: 5 } })
|
|
874
|
+
],
|
|
875
|
+
entryNodes: ["once"],
|
|
876
|
+
terminalNodes: ["once"]
|
|
877
|
+
});
|
|
878
|
+
const reportB = await new GraphExecutionEngine(b).run();
|
|
879
|
+
if (attemptsB !== 1) throw new Error(`(b) attempts=${attemptsB} — retry ignored without RETRY policy`);
|
|
880
|
+
if (reportB.results[0].attempt !== 1) throw new Error("(b) attempt recorded wrong");
|
|
881
|
+
if (reportB.status !== "FAILED") throw new Error(`(b) ${reportB.status}`);
|
|
882
|
+
|
|
883
|
+
// (c) FALLBACK: no fallback routing exists; behaves like CONTINUE
|
|
884
|
+
let attemptsC = 0;
|
|
885
|
+
const c = g({
|
|
886
|
+
nodes: [
|
|
887
|
+
node("primary", () => { attemptsC += 1; throw new Error("no-fallback-wired"); },
|
|
888
|
+
{ errorPolicy: "FALLBACK", retry: { maxAttempts: 2 } })
|
|
889
|
+
],
|
|
890
|
+
entryNodes: ["primary"],
|
|
891
|
+
terminalNodes: ["primary"]
|
|
892
|
+
});
|
|
893
|
+
const reportC = await new GraphExecutionEngine(c).run();
|
|
894
|
+
if (attemptsC !== 1) throw new Error(`(c) attempts=${attemptsC}`);
|
|
895
|
+
if (reportC.status !== "FAILED") throw new Error(`(c) ${reportC.status}`);
|
|
896
|
+
|
|
897
|
+
t.log("(a) RETRY attempts", attemptsA);
|
|
898
|
+
t.log("(b) CONTINUE attempts", attemptsB);
|
|
899
|
+
t.log("(c) FALLBACK attempts", attemptsC);
|
|
900
|
+
t.log("finding: FALLBACK is accepted but treated as CONTINUE — no alternative-route mechanism exists");
|
|
901
|
+
},
|
|
902
|
+
BIG
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
/* =========================================================
|
|
906
|
+
* C-15 — final-state precedence matrix
|
|
907
|
+
* ========================================================= */
|
|
908
|
+
|
|
909
|
+
sem.t(
|
|
910
|
+
"final-state precedence: terminal reached + unhandled errors still yields FAILED",
|
|
911
|
+
async t => {
|
|
912
|
+
// (1) work exhausted, terminal never reached
|
|
913
|
+
const dead = g({
|
|
914
|
+
nodes: [constNode("a", "dead-end"), constNode("sink", "never")],
|
|
915
|
+
entryNodes: ["a"],
|
|
916
|
+
terminalNodes: ["sink"]
|
|
917
|
+
});
|
|
918
|
+
const reportDead = await new GraphExecutionEngine(dead).run();
|
|
919
|
+
if (reportDead.status !== "FAILED") throw new Error(`(1) ${reportDead.status}`);
|
|
920
|
+
if (reportDead.logicallyCompleted) throw new Error("(1) wrongly logicallyCompleted");
|
|
921
|
+
if (reportDead.errors[0].code !== "GRAPH_TERMINAL_NOT_REACHED") throw new Error("(1) code");
|
|
922
|
+
|
|
923
|
+
// (2) terminal reached on one branch, other branch failed unhandled
|
|
924
|
+
const mixed = g({
|
|
925
|
+
nodes: [
|
|
926
|
+
node("entry", () => "E", { next: ["bad", "good-terminal"] }),
|
|
927
|
+
node("bad", () => { throw new Error("branch-failed"); }),
|
|
928
|
+
constNode("good-terminal", "REACHED")
|
|
929
|
+
],
|
|
930
|
+
entryNodes: ["entry"],
|
|
931
|
+
terminalNodes: ["good-terminal"]
|
|
932
|
+
});
|
|
933
|
+
const reportMixed = await new GraphExecutionEngine(mixed).run();
|
|
934
|
+
if (!reportMixed.logicallyCompleted) throw new Error("(2) terminal not acknowledged");
|
|
935
|
+
if (reportMixed.status !== "FAILED") {
|
|
936
|
+
throw new Error(`(2) ${reportMixed.status} — unhandled errors must outweigh logical completion`);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// (3) clean run reaching the terminal
|
|
940
|
+
const clean = g({
|
|
941
|
+
nodes: [node("entry", () => "E", { next: ["finish"] }), constNode("finish", "F")],
|
|
942
|
+
entryNodes: ["entry"],
|
|
943
|
+
terminalNodes: ["finish"]
|
|
944
|
+
});
|
|
945
|
+
const reportClean = await new GraphExecutionEngine(clean).run();
|
|
946
|
+
if (reportClean.status !== "COMPLETED" || !reportClean.logicallyCompleted) {
|
|
947
|
+
throw new Error(`(3) ${reportClean.status}`);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// (4) another branch reaches the terminal while this branch is failing:
|
|
951
|
+
// the ONTHROW recovery activation is pushed but silently dropped.
|
|
952
|
+
const dropped = g({
|
|
953
|
+
nodes: [
|
|
954
|
+
node("entry", () => "E", { next: ["fast-branch", "slow-branch"] }),
|
|
955
|
+
node("fast-branch", () => "FAST", { next: ["finish"] }),
|
|
956
|
+
node("slow-branch", async () => { await sleep(35); throw new Error("late-failure"); }, {
|
|
957
|
+
onThrow: "recovery"
|
|
958
|
+
}),
|
|
959
|
+
node("recovery", () => "RECOVERED", { next: ["finish"] }),
|
|
960
|
+
constNode("finish", "F")
|
|
961
|
+
],
|
|
962
|
+
entryNodes: ["entry"],
|
|
963
|
+
terminalNodes: ["finish"]
|
|
964
|
+
});
|
|
965
|
+
const reportDropped = await new GraphExecutionEngine(dropped).run();
|
|
966
|
+
const ranDropped = reportDropped.results.map(result => result.nodeId);
|
|
967
|
+
|
|
968
|
+
if (reportDropped.status !== "COMPLETED") throw new Error(`(4) ${reportDropped.status}`);
|
|
969
|
+
if (ranDropped.includes("recovery")) {
|
|
970
|
+
throw new Error("(4) recovery ran — expected it to be dropped after logical completion");
|
|
971
|
+
}
|
|
972
|
+
if (reportDropped.outputs.recovery !== undefined) throw new Error("(4) recovery output present");
|
|
973
|
+
const lateError = reportDropped.errors.find(error => error.nodeId === "slow-branch");
|
|
974
|
+
if (!lateError) throw new Error("(4) late failure not recorded");
|
|
975
|
+
if (!lateError.handled) throw new Error("(4) late failure should be marked handled");
|
|
976
|
+
|
|
977
|
+
t.log("(1) dead-end status", reportDead.status);
|
|
978
|
+
t.log("(2) logical completion", reportMixed.logicallyCompleted, "status", reportMixed.status);
|
|
979
|
+
t.log("(4) recovery dropped but error marked handled", lateError.handled);
|
|
980
|
+
t.log(
|
|
981
|
+
"finding: settlement order is cancellation > checkpoint failure > unhandled errors > " +
|
|
982
|
+
"logical completion; FAILED silently wins over a completed terminal"
|
|
983
|
+
);
|
|
984
|
+
t.log(
|
|
985
|
+
"finding: once any branch reaches a terminal node, pending activations (including ONTHROW " +
|
|
986
|
+
"recovery paths) are cleared and never executed, yet their errors stay marked handled"
|
|
987
|
+
);
|
|
988
|
+
},
|
|
989
|
+
BIG
|
|
990
|
+
);
|
|
991
|
+
|
|
992
|
+
/* =========================================================
|
|
993
|
+
* C-16 — policy resolution: node override vs graph default
|
|
994
|
+
* ========================================================= */
|
|
995
|
+
|
|
996
|
+
sem.t(
|
|
997
|
+
"policy resolution: every FAIL_FAST is sticky and halts the scheduler",
|
|
998
|
+
async t => {
|
|
999
|
+
// (a) graph CONTINUE + node FAIL_FAST: v1.1 (A4) made node-level
|
|
1000
|
+
// FAIL_FAST sticky, so it halts scheduling like the graph-level policy.
|
|
1001
|
+
const a = g({
|
|
1002
|
+
nodes: [
|
|
1003
|
+
node("root", () => "R", { next: ["b1", "b2"] }),
|
|
1004
|
+
node("b1", async () => { await sleep(25); return "B1"; }, { next: ["b1-child"] }),
|
|
1005
|
+
node("b2", () => { throw new Error("node-level-fast-fail"); }, { errorPolicy: "FAIL_FAST" }),
|
|
1006
|
+
constNode("b1-child", "RAN_AFTER_OVERRIDE")
|
|
1007
|
+
],
|
|
1008
|
+
entryNodes: ["root"],
|
|
1009
|
+
terminalNodes: ["b1-child"]
|
|
1010
|
+
});
|
|
1011
|
+
const reportA = await new GraphExecutionEngine(a).run();
|
|
1012
|
+
const ranA = reportA.results.map(result => result.nodeId);
|
|
1013
|
+
if (reportA.status !== "FAILED") throw new Error(`(a) ${reportA.status}`);
|
|
1014
|
+
if (ranA.includes("b1-child")) {
|
|
1015
|
+
throw new Error("(a) node-level FAIL_FAST must stop the scheduler (sticky since v1.1)");
|
|
1016
|
+
}
|
|
1017
|
+
if (!ranA.includes("b1")) throw new Error("(a) in-flight sibling lost");
|
|
1018
|
+
|
|
1019
|
+
// (b) graph FAIL_FAST + node CONTINUE: the graph policy still halts the other branch
|
|
1020
|
+
const b = g({
|
|
1021
|
+
errorPolicy: "FAIL_FAST",
|
|
1022
|
+
nodes: [
|
|
1023
|
+
node("root", () => "R", { next: ["b1", "b2"] }),
|
|
1024
|
+
node("b1", async () => { await sleep(25); return "B1"; }, { next: ["b1-child"] }),
|
|
1025
|
+
node("b2", () => { throw new Error("node-continue"); }, { errorPolicy: "CONTINUE" }),
|
|
1026
|
+
constNode("b1-child", "SHOULD_NOT_RUN")
|
|
1027
|
+
],
|
|
1028
|
+
entryNodes: ["root"],
|
|
1029
|
+
terminalNodes: ["b1-child"]
|
|
1030
|
+
});
|
|
1031
|
+
const reportB = await new GraphExecutionEngine(b).run();
|
|
1032
|
+
const ranB = reportB.results.map(result => result.nodeId);
|
|
1033
|
+
if (ranB.includes("b1-child")) {
|
|
1034
|
+
throw new Error("(b) graph-level FAIL_FAST failed to halt the sibling branch");
|
|
1035
|
+
}
|
|
1036
|
+
if (!ranB.includes("b1")) throw new Error("(b) in-flight sibling lost");
|
|
1037
|
+
|
|
1038
|
+
// (c) node RETRY overrides graph CONTINUE
|
|
1039
|
+
let attempts = 0;
|
|
1040
|
+
const c = g({
|
|
1041
|
+
errorPolicy: "CONTINUE",
|
|
1042
|
+
nodes: [
|
|
1043
|
+
node("flaky", () => { attempts += 1; if (attempts === 1) throw new Error("once"); return "ok"; },
|
|
1044
|
+
{ retry: { maxAttempts: 2 }, errorPolicy: "RETRY" })
|
|
1045
|
+
],
|
|
1046
|
+
entryNodes: ["flaky"],
|
|
1047
|
+
terminalNodes: ["flaky"]
|
|
1048
|
+
});
|
|
1049
|
+
const reportC = await new GraphExecutionEngine(c).run();
|
|
1050
|
+
if (attempts !== 2) throw new Error(`(c) attempts=${attempts}`);
|
|
1051
|
+
if (reportC.status !== "COMPLETED") throw new Error(`(c) ${reportC.status}`);
|
|
1052
|
+
|
|
1053
|
+
t.log(
|
|
1054
|
+
"finding: FAIL_FAST is sticky at both levels since v1.1 (A4) — the old node-level behaviour " +
|
|
1055
|
+
"was a one-off queue flush that did nothing under unbounded concurrency; a node-level CONTINUE " +
|
|
1056
|
+
"still cannot shield other branches from a graph-level FAIL_FAST"
|
|
1057
|
+
);
|
|
1058
|
+
t.log("(a) node override FAIL_FAST -> sibling successor dropped", !ranA.includes("b1-child"));
|
|
1059
|
+
t.log("(b) graph FAIL_FAST -> sibling successor dropped", !ranB.includes("b1-child"));
|
|
1060
|
+
t.log("(c) retry attempts under node override", attempts);
|
|
1061
|
+
},
|
|
1062
|
+
BIG
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
/* =========================================================
|
|
1066
|
+
* C-10 — cancellation of an in-flight fan-out
|
|
1067
|
+
* ========================================================= */
|
|
1068
|
+
|
|
1069
|
+
can.t(
|
|
1070
|
+
"cancelling a fan-out aborts in-flight nodes and never starts their successors",
|
|
1071
|
+
async t => {
|
|
1072
|
+
const workers = Array.from({ length: 10 }, (_, index) =>
|
|
1073
|
+
node(`worker-${index}`, async () => { await sleep(200); return index; }, { next: [`leaf-${index}`] })
|
|
1074
|
+
);
|
|
1075
|
+
const leaves = Array.from({ length: 10 }, (_, index) => constNode(`leaf-${index}`, index));
|
|
1076
|
+
|
|
1077
|
+
const definition = g({
|
|
1078
|
+
nodes: [node("root", () => "R", { next: workers.map(worker => worker.id) }), ...workers, ...leaves],
|
|
1079
|
+
entryNodes: ["root"],
|
|
1080
|
+
terminalNodes: ["leaf-9"]
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
const engine = new GraphExecutionEngine(definition);
|
|
1084
|
+
const captured = capture(engine, ["graph.cancellationRequested", "graph.finished"]);
|
|
1085
|
+
const startedAt = Date.now();
|
|
1086
|
+
const running = engine.run();
|
|
1087
|
+
await sleep(20);
|
|
1088
|
+
|
|
1089
|
+
const cancelStarted = Date.now();
|
|
1090
|
+
await engine.cancel();
|
|
1091
|
+
const cancelMs = Date.now() - cancelStarted;
|
|
1092
|
+
|
|
1093
|
+
const report = await running;
|
|
1094
|
+
const totalMs = Date.now() - startedAt;
|
|
1095
|
+
|
|
1096
|
+
if (report.status !== "CANCELLED") throw new Error(report.status);
|
|
1097
|
+
if (engine.getState() !== "CANCELLED") throw new Error("engine state mismatch");
|
|
1098
|
+
|
|
1099
|
+
const ran = report.results.map(result => result.nodeId);
|
|
1100
|
+
if (!ran.includes("root")) throw new Error("root should have completed");
|
|
1101
|
+
if (ran.some(id => id.startsWith("leaf-"))) throw new Error("successors started after cancel");
|
|
1102
|
+
if (report.results.filter(result => result.nodeId.startsWith("worker-")).length !== 10) {
|
|
1103
|
+
throw new Error("expected all 10 in-flight workers to be recorded");
|
|
1104
|
+
}
|
|
1105
|
+
if (report.results.some(result => result.nodeId.startsWith("worker-") && result.status !== "CANCELLED")) {
|
|
1106
|
+
throw new Error("workers not marked CANCELLED");
|
|
1107
|
+
}
|
|
1108
|
+
if (cancelMs > 100) throw new Error(`cancel() blocked for ${cancelMs}ms`);
|
|
1109
|
+
if (totalMs < 150) throw new Error(`run() settled before in-flight work drained (${totalMs}ms)`);
|
|
1110
|
+
|
|
1111
|
+
const requestEvent = captured.find(entry => entry.event === "graph.cancellationRequested");
|
|
1112
|
+
const finishedEvent = captured.find(entry => entry.event === "graph.finished");
|
|
1113
|
+
if (!requestEvent) throw new Error("cancellation event missing");
|
|
1114
|
+
if ((finishedEvent!.payload as { status: string }).status !== "CANCELLED") {
|
|
1115
|
+
throw new Error("graph.finished status wrong");
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
t.log("cancel() latency", `${cancelMs}ms`);
|
|
1119
|
+
t.log("run() drain time", `${totalMs}ms (workers sleep 200ms)`);
|
|
1120
|
+
t.log("finding: cancel() returns immediately but the report waits for cooperative drain");
|
|
1121
|
+
},
|
|
1122
|
+
BIG
|
|
1123
|
+
);
|