@iterativeflow/conformance 2.7.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +541 -25
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
1
|
+
import { assert, describe, expect, it } from "vitest";
|
|
2
|
+
import { cancelRun, defineFlow, reconcile, registry, runTick, serverlessTick, signalRun, submit, tickOnce } from "@iterativeflow/core";
|
|
3
3
|
import { isTerminal } from "@iterativeflow/core/backend";
|
|
4
4
|
//#region src/store.ts
|
|
5
5
|
/**
|
|
@@ -234,10 +234,23 @@ const storeConformance = (label, makeStore) => {
|
|
|
234
234
|
result: { at },
|
|
235
235
|
attempts: 1
|
|
236
236
|
});
|
|
237
|
-
|
|
238
|
-
expect(memo.at).toBe("2030-01-01T00:00:00.000Z");
|
|
237
|
+
expect((await s.loadRun(runId))?.steps.get("a")?.result).toEqual({ at: "2030-01-01T00:00:00.000Z" });
|
|
239
238
|
});
|
|
240
|
-
it("
|
|
239
|
+
it("a run's output round-trips through JSON — a Date comes back as a string on every backend", async () => {
|
|
240
|
+
const s = await makeStore();
|
|
241
|
+
const { runId } = await s.startRun({
|
|
242
|
+
name: "f",
|
|
243
|
+
version: 1,
|
|
244
|
+
input: {}
|
|
245
|
+
});
|
|
246
|
+
const at = /* @__PURE__ */ new Date("2030-01-01T00:00:00.000Z");
|
|
247
|
+
await s.markTerminal(runId, {
|
|
248
|
+
status: "done",
|
|
249
|
+
output: { at }
|
|
250
|
+
});
|
|
251
|
+
expect((await s.loadRunRow(runId))?.output).toEqual({ at: "2030-01-01T00:00:00.000Z" });
|
|
252
|
+
});
|
|
253
|
+
it("checkpointStep round-trips the call that wrote it (drift-guard evidence)", async () => {
|
|
241
254
|
const s = await makeStore();
|
|
242
255
|
const { runId } = await s.startRun({
|
|
243
256
|
name: "f",
|
|
@@ -250,9 +263,9 @@ const storeConformance = (label, makeStore) => {
|
|
|
250
263
|
status: "ok",
|
|
251
264
|
result: 1,
|
|
252
265
|
attempts: 1,
|
|
253
|
-
|
|
266
|
+
call: "step:charge"
|
|
254
267
|
});
|
|
255
|
-
expect((await s.loadRun(runId))?.steps.get("a")?.
|
|
268
|
+
expect((await s.loadRun(runId))?.steps.get("a")?.call).toBe("step:charge");
|
|
256
269
|
});
|
|
257
270
|
it("checkpointStep is first-writer-wins — a second write does NOT overwrite (exactly-once memo)", async () => {
|
|
258
271
|
const s = await makeStore();
|
|
@@ -443,8 +456,9 @@ const storeConformance = (label, makeStore) => {
|
|
|
443
456
|
otherName,
|
|
444
457
|
live
|
|
445
458
|
]) expect(await s.loadRunRow(spared)).toBeDefined();
|
|
459
|
+
const fromTheWire = JSON.parse(`{"status":"pending"}`);
|
|
446
460
|
expect(await s.deleteRuns({
|
|
447
|
-
|
|
461
|
+
...fromTheWire,
|
|
448
462
|
before: /* @__PURE__ */ new Date()
|
|
449
463
|
}, 100)).toBe(0);
|
|
450
464
|
expect((await s.loadRunRow(live))?.status).toBe("pending");
|
|
@@ -506,12 +520,17 @@ const storeConformance = (label, makeStore) => {
|
|
|
506
520
|
attempts: 1
|
|
507
521
|
});
|
|
508
522
|
const snap = await s.loadRun(runId);
|
|
509
|
-
snap
|
|
510
|
-
snap
|
|
523
|
+
const input = snap?.run.input;
|
|
524
|
+
const result = snap?.steps.get("a")?.result;
|
|
525
|
+
assert(input instanceof Object && "n" in input);
|
|
526
|
+
assert(result instanceof Object && "v" in result);
|
|
527
|
+
assert(snap?.steps instanceof Map);
|
|
528
|
+
input.n = 999;
|
|
529
|
+
result.v = 999;
|
|
511
530
|
snap.steps.clear();
|
|
512
531
|
const again = await s.loadRun(runId);
|
|
513
|
-
expect(again
|
|
514
|
-
expect(again
|
|
532
|
+
expect(again?.run.input).toEqual({ n: 1 });
|
|
533
|
+
expect(again?.steps.get("a")?.result).toEqual({ v: 1 });
|
|
515
534
|
});
|
|
516
535
|
it("caller mutating the input after startRun can't reach the stored run", async () => {
|
|
517
536
|
const s = await makeStore();
|
|
@@ -654,6 +673,44 @@ const storeConformance = (label, makeStore) => {
|
|
|
654
673
|
expect((await s.loadRunRow(runId))?.attempts).toBe(0);
|
|
655
674
|
expect(await s.markRunning(runId)).toBe(1);
|
|
656
675
|
});
|
|
676
|
+
it("markTerminal never overwrites a terminal run — a late cancel keeps the outcome", async () => {
|
|
677
|
+
const s = await makeStore();
|
|
678
|
+
const done = (await s.startRun({
|
|
679
|
+
name: "f",
|
|
680
|
+
version: 1,
|
|
681
|
+
input: {}
|
|
682
|
+
})).runId;
|
|
683
|
+
const failed = (await s.startRun({
|
|
684
|
+
name: "f",
|
|
685
|
+
version: 1,
|
|
686
|
+
input: {}
|
|
687
|
+
})).runId;
|
|
688
|
+
await s.markTerminal(done, {
|
|
689
|
+
status: "done",
|
|
690
|
+
output: { total: 42 }
|
|
691
|
+
});
|
|
692
|
+
await s.markTerminal(failed, {
|
|
693
|
+
status: "failed",
|
|
694
|
+
error: {
|
|
695
|
+
code: "X",
|
|
696
|
+
message: "boom"
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
await s.markTerminal(done, { status: "canceled" });
|
|
700
|
+
await s.markTerminal(failed, { status: "canceled" });
|
|
701
|
+
expect(await s.loadRunRow(done)).toMatchObject({
|
|
702
|
+
status: "done",
|
|
703
|
+
output: { total: 42 }
|
|
704
|
+
});
|
|
705
|
+
expect(await s.loadRunRow(failed)).toMatchObject({
|
|
706
|
+
status: "failed",
|
|
707
|
+
error: {
|
|
708
|
+
code: "X",
|
|
709
|
+
message: "boom"
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
expect((await s.retryRun(failed)).retried).toBe(true);
|
|
713
|
+
});
|
|
657
714
|
it("cancelRuns cancels the live matched set and never a terminal run", async () => {
|
|
658
715
|
const s = await makeStore();
|
|
659
716
|
const mk = async (name) => (await s.startRun({
|
|
@@ -677,6 +734,34 @@ const storeConformance = (label, makeStore) => {
|
|
|
677
734
|
expect(await s.cancelRuns({ name: "f" }, 100)).toBe(0);
|
|
678
735
|
await expect(s.cancelRuns({}, 100)).rejects.toThrow();
|
|
679
736
|
});
|
|
737
|
+
it("cancelRuns and retryRuns narrow by tag, never widening to untagged runs", async () => {
|
|
738
|
+
const s = await makeStore();
|
|
739
|
+
const mk = async (tags) => (await s.startRun({
|
|
740
|
+
name: "f",
|
|
741
|
+
version: 1,
|
|
742
|
+
input: {},
|
|
743
|
+
tags
|
|
744
|
+
})).runId;
|
|
745
|
+
const tenant = await mk(["tenant:42"]);
|
|
746
|
+
const neighbour = await mk(["tenant:7"]);
|
|
747
|
+
const untagged = await mk();
|
|
748
|
+
expect(await s.cancelRuns({ tag: "tenant:42" }, 100)).toBe(1);
|
|
749
|
+
expect((await s.loadRunRow(tenant))?.status).toBe("canceled");
|
|
750
|
+
expect((await s.loadRunRow(neighbour))?.status).toBe("pending");
|
|
751
|
+
expect((await s.loadRunRow(untagged))?.status).toBe("pending");
|
|
752
|
+
const failedTenant = await mk(["tenant:42"]);
|
|
753
|
+
const failedNeighbour = await mk(["tenant:7"]);
|
|
754
|
+
for (const id of [failedTenant, failedNeighbour]) await s.markTerminal(id, {
|
|
755
|
+
status: "failed",
|
|
756
|
+
error: {
|
|
757
|
+
code: "X",
|
|
758
|
+
message: "x"
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
expect(await s.retryRuns({ tag: "tenant:42" }, 100)).toBe(1);
|
|
762
|
+
expect((await s.loadRunRow(failedTenant))?.status).toBe("pending");
|
|
763
|
+
expect((await s.loadRunRow(failedNeighbour))?.status).toBe("failed");
|
|
764
|
+
});
|
|
680
765
|
it("retryRuns re-drives the failed matched set, clearing the spent attempt budget", async () => {
|
|
681
766
|
const s = await makeStore();
|
|
682
767
|
const mk = async (name) => (await s.startRun({
|
|
@@ -1021,6 +1106,26 @@ const queueConformance = (label, makeQueue) => {
|
|
|
1021
1106
|
});
|
|
1022
1107
|
expect(first.runId).toBe("high");
|
|
1023
1108
|
});
|
|
1109
|
+
it("negative priorities order numerically, before zero", async () => {
|
|
1110
|
+
const q = await makeQueue();
|
|
1111
|
+
await q.enqueue("zero", { priority: 0 });
|
|
1112
|
+
await q.enqueue("minus3", { priority: -3 });
|
|
1113
|
+
await q.enqueue("minus5", { priority: -5 });
|
|
1114
|
+
const order = [];
|
|
1115
|
+
for (let i = 0; i < 3; i++) {
|
|
1116
|
+
const [l] = await q.claim({
|
|
1117
|
+
limit: 1,
|
|
1118
|
+
leaseMs: 6e4,
|
|
1119
|
+
now: at(0)
|
|
1120
|
+
});
|
|
1121
|
+
order.push(l.runId);
|
|
1122
|
+
}
|
|
1123
|
+
expect(order).toEqual([
|
|
1124
|
+
"minus5",
|
|
1125
|
+
"minus3",
|
|
1126
|
+
"zero"
|
|
1127
|
+
]);
|
|
1128
|
+
});
|
|
1024
1129
|
it("re-enqueue is an upsert keyed by runId — claimed once", async () => {
|
|
1025
1130
|
const q = await makeQueue();
|
|
1026
1131
|
await q.enqueue("r1");
|
|
@@ -1032,6 +1137,98 @@ const queueConformance = (label, makeQueue) => {
|
|
|
1032
1137
|
});
|
|
1033
1138
|
expect(leases.map((l) => l.runId)).toEqual(["r1"]);
|
|
1034
1139
|
});
|
|
1140
|
+
it("enqueueMany enqueues every run, honouring per-request runAt and priority", async () => {
|
|
1141
|
+
const q = await makeQueue();
|
|
1142
|
+
await q.enqueueMany([
|
|
1143
|
+
{
|
|
1144
|
+
runId: "low",
|
|
1145
|
+
opts: { priority: 10 }
|
|
1146
|
+
},
|
|
1147
|
+
{
|
|
1148
|
+
runId: "high",
|
|
1149
|
+
opts: { priority: 1 }
|
|
1150
|
+
},
|
|
1151
|
+
{
|
|
1152
|
+
runId: "later",
|
|
1153
|
+
opts: { runAt: at(5e3) }
|
|
1154
|
+
}
|
|
1155
|
+
]);
|
|
1156
|
+
expect(await q.depth(at(0))).toMatchObject({
|
|
1157
|
+
claimable: 2,
|
|
1158
|
+
leased: 0
|
|
1159
|
+
});
|
|
1160
|
+
const [first] = await q.claim({
|
|
1161
|
+
limit: 1,
|
|
1162
|
+
leaseMs: 1e3,
|
|
1163
|
+
now: at(0)
|
|
1164
|
+
});
|
|
1165
|
+
expect(first.runId).toBe("high");
|
|
1166
|
+
const rest = await q.claim({
|
|
1167
|
+
limit: 10,
|
|
1168
|
+
leaseMs: 1e3,
|
|
1169
|
+
now: at(0)
|
|
1170
|
+
});
|
|
1171
|
+
expect(rest.map((l) => l.runId)).toEqual(["low"]);
|
|
1172
|
+
});
|
|
1173
|
+
it("enqueueMany on an empty list is a no-op", async () => {
|
|
1174
|
+
const q = await makeQueue();
|
|
1175
|
+
await q.enqueueMany([]);
|
|
1176
|
+
expect(await q.claim({
|
|
1177
|
+
limit: 10,
|
|
1178
|
+
leaseMs: 1e3,
|
|
1179
|
+
now: at(0)
|
|
1180
|
+
})).toEqual([]);
|
|
1181
|
+
});
|
|
1182
|
+
it("enqueueMany collapses duplicate runIds into one upsert, bumping version once", async () => {
|
|
1183
|
+
const q = await makeQueue();
|
|
1184
|
+
await q.enqueueMany([{
|
|
1185
|
+
runId: "r1",
|
|
1186
|
+
opts: { priority: 9 }
|
|
1187
|
+
}, {
|
|
1188
|
+
runId: "r1",
|
|
1189
|
+
opts: { priority: 2 }
|
|
1190
|
+
}]);
|
|
1191
|
+
const leases = await q.claim({
|
|
1192
|
+
limit: 10,
|
|
1193
|
+
leaseMs: 1e3,
|
|
1194
|
+
now: at(0)
|
|
1195
|
+
});
|
|
1196
|
+
expect(leases.map((l) => l.runId)).toEqual(["r1"]);
|
|
1197
|
+
expect(leases[0].version).toBe(1);
|
|
1198
|
+
});
|
|
1199
|
+
it("enqueueMany does not yank an active lease, and its wake survives the ack", async () => {
|
|
1200
|
+
const q = await makeQueue();
|
|
1201
|
+
await q.enqueue("r1");
|
|
1202
|
+
const [lease] = await q.claim({
|
|
1203
|
+
limit: 10,
|
|
1204
|
+
leaseMs: 1e3,
|
|
1205
|
+
now: at(0)
|
|
1206
|
+
});
|
|
1207
|
+
await q.enqueueMany([{ runId: "r1" }]);
|
|
1208
|
+
expect(await q.claim({
|
|
1209
|
+
limit: 10,
|
|
1210
|
+
leaseMs: 1e3,
|
|
1211
|
+
now: at(500)
|
|
1212
|
+
})).toEqual([]);
|
|
1213
|
+
await q.ack(lease, { now: at(600) });
|
|
1214
|
+
const again = await q.claim({
|
|
1215
|
+
limit: 10,
|
|
1216
|
+
leaseMs: 1e3,
|
|
1217
|
+
now: at(601)
|
|
1218
|
+
});
|
|
1219
|
+
expect(again.map((l) => l.runId)).toEqual(["r1"]);
|
|
1220
|
+
});
|
|
1221
|
+
it("enqueueMany re-enqueues an existing job rather than duplicating it", async () => {
|
|
1222
|
+
const q = await makeQueue();
|
|
1223
|
+
await q.enqueue("r1", { runAt: at(9e3) });
|
|
1224
|
+
await q.enqueueMany([{ runId: "r1" }]);
|
|
1225
|
+
const leases = await q.claim({
|
|
1226
|
+
limit: 10,
|
|
1227
|
+
leaseMs: 1e3,
|
|
1228
|
+
now: at(0)
|
|
1229
|
+
});
|
|
1230
|
+
expect(leases.map((l) => l.runId)).toEqual(["r1"]);
|
|
1231
|
+
});
|
|
1035
1232
|
});
|
|
1036
1233
|
};
|
|
1037
1234
|
//#endregion
|
|
@@ -1805,6 +2002,16 @@ const cronConformance = (label, makeStore) => {
|
|
|
1805
2002
|
};
|
|
1806
2003
|
//#endregion
|
|
1807
2004
|
//#region src/engine.ts
|
|
2005
|
+
const okSchema = { "~standard": {
|
|
2006
|
+
version: 1,
|
|
2007
|
+
vendor: "conformance",
|
|
2008
|
+
validate: (v) => v instanceof Object && "ok" in v ? { value: { ok: v.ok === true } } : { issues: [{ message: "ok is required" }] }
|
|
2009
|
+
} };
|
|
2010
|
+
const textSchema = { "~standard": {
|
|
2011
|
+
version: 1,
|
|
2012
|
+
vendor: "conformance",
|
|
2013
|
+
validate: (v) => v === String(v) ? { value: String(v) } : { issues: [{ message: "not text" }] }
|
|
2014
|
+
} };
|
|
1808
2015
|
/**
|
|
1809
2016
|
* Engine-behavior conformance: the composed durable behaviors (retry/dead-letter, signal
|
|
1810
2017
|
* resume, cancel cascade, drift) run against every backend, not just memory — so a real
|
|
@@ -1852,15 +2059,20 @@ const engineConformance = (label, makeBackend) => {
|
|
|
1852
2059
|
output: "settled"
|
|
1853
2060
|
});
|
|
1854
2061
|
});
|
|
1855
|
-
it("
|
|
2062
|
+
it("a flow with a durable sleep between steps resumes to completion", async () => {
|
|
1856
2063
|
const backend = await makeBackend();
|
|
1857
|
-
const flow =
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
2064
|
+
const flow = defineFlow({
|
|
2065
|
+
name: `${label}-sleep`,
|
|
2066
|
+
version: 1,
|
|
2067
|
+
run: async (ctx, input) => {
|
|
2068
|
+
const doubled = await ctx.step("doubled", () => input.x * 2);
|
|
2069
|
+
await ctx.sleep(5e3);
|
|
2070
|
+
return {
|
|
2071
|
+
doubled,
|
|
2072
|
+
nap: await ctx.step("nap", () => "rested")
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
});
|
|
1864
2076
|
const runId = await submit(backend, flow, { x: 21 });
|
|
1865
2077
|
const run = await drive(backend, registry([flow]), runId);
|
|
1866
2078
|
expect(run).toMatchObject({
|
|
@@ -1915,7 +2127,7 @@ const engineConformance = (label, makeBackend) => {
|
|
|
1915
2127
|
const flow = defineFlow({
|
|
1916
2128
|
name: "approve",
|
|
1917
2129
|
version: 1,
|
|
1918
|
-
signals: { go:
|
|
2130
|
+
signals: { go: okSchema },
|
|
1919
2131
|
run: async (ctx) => {
|
|
1920
2132
|
return (await ctx.signal("go")).ok ? "shipped" : "held";
|
|
1921
2133
|
}
|
|
@@ -2209,6 +2421,310 @@ const engineConformance = (label, makeBackend) => {
|
|
|
2209
2421
|
expect(kids.every((k) => isTerminal(k.status))).toBe(true);
|
|
2210
2422
|
expect(kids.some((k) => k.status === "canceled")).toBe(true);
|
|
2211
2423
|
});
|
|
2424
|
+
it("a settled fan-out returns every child's result and cancels no sibling", async () => {
|
|
2425
|
+
const backend = await makeBackend();
|
|
2426
|
+
const ok = defineFlow({
|
|
2427
|
+
name: "settle-ok",
|
|
2428
|
+
version: 1,
|
|
2429
|
+
run: async (_ctx, n) => n
|
|
2430
|
+
});
|
|
2431
|
+
const boom = defineFlow({
|
|
2432
|
+
name: "settle-boom",
|
|
2433
|
+
version: 1,
|
|
2434
|
+
run: async () => {
|
|
2435
|
+
throw new Error("boom");
|
|
2436
|
+
}
|
|
2437
|
+
});
|
|
2438
|
+
const parent = defineFlow({
|
|
2439
|
+
name: "settle-fan",
|
|
2440
|
+
version: 1,
|
|
2441
|
+
run: async (ctx) => ctx.invoke([
|
|
2442
|
+
{
|
|
2443
|
+
flow: ok,
|
|
2444
|
+
input: 1
|
|
2445
|
+
},
|
|
2446
|
+
{
|
|
2447
|
+
flow: boom,
|
|
2448
|
+
input: {}
|
|
2449
|
+
},
|
|
2450
|
+
{
|
|
2451
|
+
flow: ok,
|
|
2452
|
+
input: 2
|
|
2453
|
+
}
|
|
2454
|
+
], { onChildFailure: "settle" })
|
|
2455
|
+
});
|
|
2456
|
+
const runId = await submit(backend, parent, {});
|
|
2457
|
+
const run = await drive(backend, registry([
|
|
2458
|
+
parent,
|
|
2459
|
+
ok,
|
|
2460
|
+
boom
|
|
2461
|
+
]), runId, {
|
|
2462
|
+
maxAttempts: 1,
|
|
2463
|
+
baseDelayMs: 1,
|
|
2464
|
+
maxDelayMs: 1
|
|
2465
|
+
});
|
|
2466
|
+
expect(run).toMatchObject({
|
|
2467
|
+
status: "done",
|
|
2468
|
+
output: [
|
|
2469
|
+
{
|
|
2470
|
+
status: "done",
|
|
2471
|
+
output: 1
|
|
2472
|
+
},
|
|
2473
|
+
{
|
|
2474
|
+
status: "failed",
|
|
2475
|
+
error: { message: "boom" }
|
|
2476
|
+
},
|
|
2477
|
+
{
|
|
2478
|
+
status: "done",
|
|
2479
|
+
output: 2
|
|
2480
|
+
}
|
|
2481
|
+
]
|
|
2482
|
+
});
|
|
2483
|
+
const kids = await backend.store.childrenOf(runId);
|
|
2484
|
+
expect(kids.filter((k) => k.status === "canceled")).toHaveLength(0);
|
|
2485
|
+
});
|
|
2486
|
+
const waiter = defineFlow({
|
|
2487
|
+
name: "waits-forever",
|
|
2488
|
+
version: 1,
|
|
2489
|
+
run: async (ctx) => {
|
|
2490
|
+
await ctx.signal("never");
|
|
2491
|
+
return 1;
|
|
2492
|
+
}
|
|
2493
|
+
});
|
|
2494
|
+
const parkOnChild = async (backend, parent) => {
|
|
2495
|
+
const flows = registry([parent, waiter]);
|
|
2496
|
+
const runId = await submit(backend, parent, {});
|
|
2497
|
+
const now = () => /* @__PURE__ */ new Date("2030-01-01T00:00:00Z");
|
|
2498
|
+
await tickOnce(backend, flows, {
|
|
2499
|
+
...base,
|
|
2500
|
+
now
|
|
2501
|
+
});
|
|
2502
|
+
await tickOnce(backend, flows, {
|
|
2503
|
+
...base,
|
|
2504
|
+
now
|
|
2505
|
+
});
|
|
2506
|
+
expect((await backend.store.loadRunRow(runId))?.status).toBe("awaiting_child");
|
|
2507
|
+
const [child] = await backend.store.childrenOf(runId);
|
|
2508
|
+
return {
|
|
2509
|
+
runId,
|
|
2510
|
+
childId: child.id,
|
|
2511
|
+
tick: () => tickOnce(backend, flows, {
|
|
2512
|
+
...base,
|
|
2513
|
+
now
|
|
2514
|
+
})
|
|
2515
|
+
};
|
|
2516
|
+
};
|
|
2517
|
+
it("canceling a child wakes its fail-fast parent without waiting for reconcile", async () => {
|
|
2518
|
+
const backend = await makeBackend();
|
|
2519
|
+
const parent = defineFlow({
|
|
2520
|
+
name: "waits-on-child",
|
|
2521
|
+
version: 1,
|
|
2522
|
+
run: async (ctx) => ctx.invoke(waiter, {})
|
|
2523
|
+
});
|
|
2524
|
+
const { runId, childId, tick } = await parkOnChild(backend, parent);
|
|
2525
|
+
await cancelRun(backend, childId);
|
|
2526
|
+
await tick();
|
|
2527
|
+
expect((await backend.store.loadRunRow(runId))?.status).toBe("failed");
|
|
2528
|
+
});
|
|
2529
|
+
it("a settled parent reports a canceled child as canceled and completes", async () => {
|
|
2530
|
+
const backend = await makeBackend();
|
|
2531
|
+
const parent = defineFlow({
|
|
2532
|
+
name: "settles-on-child",
|
|
2533
|
+
version: 1,
|
|
2534
|
+
run: async (ctx) => ctx.invoke(waiter, {}, { onChildFailure: "settle" })
|
|
2535
|
+
});
|
|
2536
|
+
const { runId, childId, tick } = await parkOnChild(backend, parent);
|
|
2537
|
+
await cancelRun(backend, childId);
|
|
2538
|
+
await tick();
|
|
2539
|
+
expect(await backend.store.loadRunRow(runId)).toMatchObject({
|
|
2540
|
+
status: "done",
|
|
2541
|
+
output: { status: "canceled" }
|
|
2542
|
+
});
|
|
2543
|
+
});
|
|
2544
|
+
const wide = (inc) => defineFlow({
|
|
2545
|
+
name: "wide-chunks",
|
|
2546
|
+
version: 1,
|
|
2547
|
+
run: async (ctx) => ctx.invoke(Array.from({ length: 45 }, (_v, n) => ({
|
|
2548
|
+
flow: inc,
|
|
2549
|
+
input: n
|
|
2550
|
+
})))
|
|
2551
|
+
});
|
|
2552
|
+
const inc = defineFlow({
|
|
2553
|
+
name: "inc-chunk",
|
|
2554
|
+
version: 1,
|
|
2555
|
+
run: async (_ctx, n) => n + 1
|
|
2556
|
+
});
|
|
2557
|
+
const secondChunk = (call) => call?.startsWith("invokeAll:1:") ?? false;
|
|
2558
|
+
it("a crash right after a spawn chunk commits spawns no child twice", async () => {
|
|
2559
|
+
const real = await makeBackend();
|
|
2560
|
+
let crashed = false;
|
|
2561
|
+
const backend = {
|
|
2562
|
+
...real,
|
|
2563
|
+
store: {
|
|
2564
|
+
...real.store,
|
|
2565
|
+
checkpointStep: async (c, fx) => {
|
|
2566
|
+
const stored = await real.store.checkpointStep(c, fx);
|
|
2567
|
+
if (!crashed && secondChunk(c.call)) {
|
|
2568
|
+
crashed = true;
|
|
2569
|
+
throw new Error("worker died after the commit");
|
|
2570
|
+
}
|
|
2571
|
+
return stored;
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
};
|
|
2575
|
+
const parent = wide(inc);
|
|
2576
|
+
const runId = await submit(backend, parent, {});
|
|
2577
|
+
const run = await drive(backend, registry([parent, inc]), runId);
|
|
2578
|
+
expect(crashed).toBe(true);
|
|
2579
|
+
expect(run.output).toEqual(Array.from({ length: 45 }, (_v, n) => n + 1));
|
|
2580
|
+
expect(await backend.store.childrenOf(runId)).toHaveLength(45);
|
|
2581
|
+
});
|
|
2582
|
+
it("a parent canceled between spawn chunks leaves no child running", async () => {
|
|
2583
|
+
const real = await makeBackend();
|
|
2584
|
+
const parent = wide(inc);
|
|
2585
|
+
const runId = await submit(real, parent, {});
|
|
2586
|
+
let canceled = false;
|
|
2587
|
+
const backend = {
|
|
2588
|
+
...real,
|
|
2589
|
+
store: {
|
|
2590
|
+
...real.store,
|
|
2591
|
+
checkpointStep: async (c, fx) => {
|
|
2592
|
+
if (!canceled && secondChunk(c.call)) {
|
|
2593
|
+
canceled = true;
|
|
2594
|
+
await cancelRun(real, runId);
|
|
2595
|
+
}
|
|
2596
|
+
return real.store.checkpointStep(c, fx);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
};
|
|
2600
|
+
const flows = registry([parent, inc]);
|
|
2601
|
+
const now = () => /* @__PURE__ */ new Date("2030-01-01T00:00:00Z");
|
|
2602
|
+
for (let i = 0; i < 10; i++) await tickOnce(backend, flows, {
|
|
2603
|
+
...base,
|
|
2604
|
+
now
|
|
2605
|
+
});
|
|
2606
|
+
expect(canceled).toBe(true);
|
|
2607
|
+
expect((await backend.store.loadRunRow(runId))?.status).toBe("canceled");
|
|
2608
|
+
const kids = await backend.store.childrenOf(runId);
|
|
2609
|
+
expect(kids.length).toBeGreaterThan(0);
|
|
2610
|
+
expect(kids.every((k) => k.status === "canceled")).toBe(true);
|
|
2611
|
+
});
|
|
2612
|
+
it("a child that finishes while its parent is mid-replay still wakes the parent", async () => {
|
|
2613
|
+
const real = await makeBackend();
|
|
2614
|
+
const quick = defineFlow({
|
|
2615
|
+
name: "quick-child",
|
|
2616
|
+
version: 1,
|
|
2617
|
+
run: async () => 7
|
|
2618
|
+
});
|
|
2619
|
+
const parent = defineFlow({
|
|
2620
|
+
name: "replaying-parent",
|
|
2621
|
+
version: 1,
|
|
2622
|
+
run: async (ctx) => ctx.invoke(quick, {})
|
|
2623
|
+
});
|
|
2624
|
+
const flows = registry([parent, quick]);
|
|
2625
|
+
const now = () => /* @__PURE__ */ new Date("2030-01-01T00:00:00Z");
|
|
2626
|
+
let raced = false;
|
|
2627
|
+
const backend = {
|
|
2628
|
+
...real,
|
|
2629
|
+
store: {
|
|
2630
|
+
...real.store,
|
|
2631
|
+
loadRunRow: async (id) => {
|
|
2632
|
+
const row = await real.store.loadRunRow(id);
|
|
2633
|
+
if (!raced && row?.name === "quick-child") {
|
|
2634
|
+
raced = true;
|
|
2635
|
+
await tickOnce(real, flows, {
|
|
2636
|
+
...base,
|
|
2637
|
+
now,
|
|
2638
|
+
names: ["quick-child"]
|
|
2639
|
+
});
|
|
2640
|
+
}
|
|
2641
|
+
return row;
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
};
|
|
2645
|
+
const runId = await submit(backend, parent, {});
|
|
2646
|
+
await tickOnce(backend, flows, {
|
|
2647
|
+
...base,
|
|
2648
|
+
now,
|
|
2649
|
+
names: ["replaying-parent"]
|
|
2650
|
+
});
|
|
2651
|
+
expect(raced).toBe(true);
|
|
2652
|
+
expect((await real.store.loadRunRow(runId))?.status).toBe("awaiting_child");
|
|
2653
|
+
const run = await drive(real, flows, runId);
|
|
2654
|
+
expect(run).toMatchObject({
|
|
2655
|
+
status: "done",
|
|
2656
|
+
output: 7
|
|
2657
|
+
});
|
|
2658
|
+
});
|
|
2659
|
+
it("a re-enqueue with no priority keeps the run's own priority", async () => {
|
|
2660
|
+
const backend = await makeBackend();
|
|
2661
|
+
const at = (ms) => new Date(Date.UTC(2030, 0, 1) + ms);
|
|
2662
|
+
const low = (await backend.store.startRun({
|
|
2663
|
+
name: "p",
|
|
2664
|
+
version: 1,
|
|
2665
|
+
input: {},
|
|
2666
|
+
priority: 10
|
|
2667
|
+
})).runId;
|
|
2668
|
+
const urgent = (await backend.store.startRun({
|
|
2669
|
+
name: "p",
|
|
2670
|
+
version: 1,
|
|
2671
|
+
input: {},
|
|
2672
|
+
priority: -10
|
|
2673
|
+
})).runId;
|
|
2674
|
+
await backend.queue.enqueueMany([{
|
|
2675
|
+
runId: low,
|
|
2676
|
+
opts: { runAt: at(0) }
|
|
2677
|
+
}, {
|
|
2678
|
+
runId: urgent,
|
|
2679
|
+
opts: { runAt: at(1e3) }
|
|
2680
|
+
}]);
|
|
2681
|
+
const [first] = await backend.queue.claim({
|
|
2682
|
+
limit: 1,
|
|
2683
|
+
leaseMs: 6e4,
|
|
2684
|
+
now: at(2e3)
|
|
2685
|
+
});
|
|
2686
|
+
expect(first.runId).toBe(urgent);
|
|
2687
|
+
});
|
|
2688
|
+
it("a signal delivery, a retry and a bulk retry keep the run's own priority", async () => {
|
|
2689
|
+
const backend = await makeBackend();
|
|
2690
|
+
const start = async (priority) => (await backend.store.startRun({
|
|
2691
|
+
name: "p",
|
|
2692
|
+
version: 1,
|
|
2693
|
+
input: {},
|
|
2694
|
+
priority
|
|
2695
|
+
})).runId;
|
|
2696
|
+
const claimFirst = async () => (await backend.queue.claim({
|
|
2697
|
+
limit: 1,
|
|
2698
|
+
leaseMs: 6e4
|
|
2699
|
+
}))[0]?.runId;
|
|
2700
|
+
const rival = async () => backend.queue.enqueue(await start(), { priority: -5 });
|
|
2701
|
+
const signalled = await start(-10);
|
|
2702
|
+
await rival();
|
|
2703
|
+
await backend.store.postSignal(signalled, "go", {});
|
|
2704
|
+
expect(await claimFirst()).toBe(signalled);
|
|
2705
|
+
const retried = await start(-10);
|
|
2706
|
+
await backend.store.markTerminal(retried, {
|
|
2707
|
+
status: "failed",
|
|
2708
|
+
error: {
|
|
2709
|
+
code: "X",
|
|
2710
|
+
message: "x"
|
|
2711
|
+
}
|
|
2712
|
+
});
|
|
2713
|
+
await rival();
|
|
2714
|
+
await backend.store.retryRun(retried);
|
|
2715
|
+
expect(await claimFirst()).toBe(retried);
|
|
2716
|
+
const bulk = await start(-10);
|
|
2717
|
+
await backend.store.markTerminal(bulk, {
|
|
2718
|
+
status: "failed",
|
|
2719
|
+
error: {
|
|
2720
|
+
code: "X",
|
|
2721
|
+
message: "x"
|
|
2722
|
+
}
|
|
2723
|
+
});
|
|
2724
|
+
await rival();
|
|
2725
|
+
await backend.store.retryRuns({ status: "failed" }, 100);
|
|
2726
|
+
expect(await claimFirst()).toBe(bulk);
|
|
2727
|
+
});
|
|
2212
2728
|
it("a claimed run for an unregistered flow version parks and resumes once that version deploys", async () => {
|
|
2213
2729
|
const backend = await makeBackend();
|
|
2214
2730
|
const v1 = defineFlow({
|
|
@@ -2234,7 +2750,7 @@ const engineConformance = (label, makeBackend) => {
|
|
|
2234
2750
|
now,
|
|
2235
2751
|
retry: fast
|
|
2236
2752
|
});
|
|
2237
|
-
expect((await backend.store.loadRunRow(runId))?.status).toBe("
|
|
2753
|
+
expect((await backend.store.loadRunRow(runId))?.status).toBe("parked");
|
|
2238
2754
|
for (let i = 0; i < 4; i++) {
|
|
2239
2755
|
clock = new Date(clock.getTime() + 6e4);
|
|
2240
2756
|
await tickOnce(backend, registry([v1]), {
|
|
@@ -2242,7 +2758,7 @@ const engineConformance = (label, makeBackend) => {
|
|
|
2242
2758
|
now,
|
|
2243
2759
|
retry: fast
|
|
2244
2760
|
});
|
|
2245
|
-
expect((await backend.store.loadRunRow(runId))?.status).toBe("
|
|
2761
|
+
expect((await backend.store.loadRunRow(runId))?.status).toBe("parked");
|
|
2246
2762
|
}
|
|
2247
2763
|
const run = await drive(backend, registry([v2]), runId, fast, clock);
|
|
2248
2764
|
expect(run).toMatchObject({
|
|
@@ -2255,7 +2771,7 @@ const engineConformance = (label, makeBackend) => {
|
|
|
2255
2771
|
const flow = defineFlow({
|
|
2256
2772
|
name: "raced",
|
|
2257
2773
|
version: 1,
|
|
2258
|
-
signals: { go:
|
|
2774
|
+
signals: { go: textSchema },
|
|
2259
2775
|
run: async (ctx) => {
|
|
2260
2776
|
const r = await ctx.signal("go", { timeoutMs: 5e3 });
|
|
2261
2777
|
return r.received ? `signal:${r.payload}` : "timeout";
|
|
@@ -2282,7 +2798,7 @@ const engineConformance = (label, makeBackend) => {
|
|
|
2282
2798
|
const flow = defineFlow({
|
|
2283
2799
|
name: "waits-out",
|
|
2284
2800
|
version: 1,
|
|
2285
|
-
signals: { go:
|
|
2801
|
+
signals: { go: textSchema },
|
|
2286
2802
|
run: async (ctx) => {
|
|
2287
2803
|
return (await ctx.signal("go", { timeoutMs: 5e3 })).received ? "signal" : "timeout";
|
|
2288
2804
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iterativeflow/conformance",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"access": "public"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@iterativeflow/core": "
|
|
28
|
+
"@iterativeflow/core": "3.0.0"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"vitest": ">=3"
|