@yieldstar/runtime-conformance 0.0.0-stage → 0.6.1
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/README.md +103 -2
- package/dist/effect.d.ts +33 -0
- package/dist/effect.d.ts.map +1 -0
- package/dist/execution.d.ts +36 -0
- package/dist/execution.d.ts.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3852 -0
- package/package.json +35 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,3852 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_LEASE_DURATION as DEFAULT_LEASE_DURATION3,
|
|
4
|
+
defineStore as defineStore2
|
|
5
|
+
} from "@yieldstar/core";
|
|
6
|
+
import { describe as describe3, expect as expect3, test as test3 } from "vitest";
|
|
7
|
+
|
|
8
|
+
// src/effect.ts
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_LEASE_DURATION,
|
|
11
|
+
EffectClientInvariantError,
|
|
12
|
+
redeliveryDelay,
|
|
13
|
+
registerErrorClass
|
|
14
|
+
} from "@yieldstar/core";
|
|
15
|
+
import { describe, expect, test } from "vitest";
|
|
16
|
+
var START = 1e6;
|
|
17
|
+
var LEASE = DEFAULT_LEASE_DURATION;
|
|
18
|
+
var REDELIVER_AFTER = LEASE + redeliveryDelay(1);
|
|
19
|
+
var OPERATION = "charge-v1";
|
|
20
|
+
|
|
21
|
+
class CardDeclinedError extends Error {
|
|
22
|
+
code = "CARD_DECLINED";
|
|
23
|
+
declineCode;
|
|
24
|
+
constructor(declineCode) {
|
|
25
|
+
super(`Card declined: ${declineCode}`);
|
|
26
|
+
this.name = "ConformanceCardDeclinedError";
|
|
27
|
+
this.declineCode = declineCode;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
registerErrorClass("ConformanceCardDeclinedError", CardDeclinedError);
|
|
31
|
+
function registerEffectClientConformance({
|
|
32
|
+
name,
|
|
33
|
+
create
|
|
34
|
+
}) {
|
|
35
|
+
async function setup() {
|
|
36
|
+
const clock = { now: START };
|
|
37
|
+
const harness = await create({ clock: () => clock.now });
|
|
38
|
+
const peer = await harness.createPeer?.() ?? harness.runtime;
|
|
39
|
+
return {
|
|
40
|
+
runtime: harness.runtime,
|
|
41
|
+
peer,
|
|
42
|
+
harness,
|
|
43
|
+
clock,
|
|
44
|
+
advance(ms) {
|
|
45
|
+
clock.now += ms;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function it(title, fn) {
|
|
50
|
+
test(title, async (testContext) => {
|
|
51
|
+
const ctx = await setup();
|
|
52
|
+
try {
|
|
53
|
+
await fn(ctx, testContext);
|
|
54
|
+
} finally {
|
|
55
|
+
await ctx.harness.dispose?.();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function itAfterRestart(title, fn) {
|
|
60
|
+
test(title, async (testContext) => {
|
|
61
|
+
const ctx = await setup();
|
|
62
|
+
try {
|
|
63
|
+
if (!ctx.harness.restart) {
|
|
64
|
+
testContext.skip();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await fn({ ...ctx, restart: async () => ctx.harness.restart() });
|
|
68
|
+
} finally {
|
|
69
|
+
await ctx.harness.dispose?.();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function claim(runtime) {
|
|
74
|
+
return runtime.executions.claimDue({ limit: 100, duration: LEASE });
|
|
75
|
+
}
|
|
76
|
+
async function claimOne(runtime, executionId) {
|
|
77
|
+
const leases = await claim(runtime);
|
|
78
|
+
expect(leases.map((lease) => lease.executionId)).toEqual([executionId]);
|
|
79
|
+
return leases[0];
|
|
80
|
+
}
|
|
81
|
+
async function claimedIds(runtime) {
|
|
82
|
+
return (await claim(runtime)).map((lease) => lease.executionId).sort();
|
|
83
|
+
}
|
|
84
|
+
async function admitSuspended(runtime, executionId) {
|
|
85
|
+
const admitted = await runtime.executions.admit({
|
|
86
|
+
executionId,
|
|
87
|
+
workflowId: "charge-order",
|
|
88
|
+
params: { executionId },
|
|
89
|
+
context: new Map
|
|
90
|
+
});
|
|
91
|
+
expect(admitted).toEqual({ status: "accepted", executionId });
|
|
92
|
+
const lease = await claimOne(runtime, executionId);
|
|
93
|
+
await runtime.executions.acknowledge({ lease, outcome: { status: "suspended" } });
|
|
94
|
+
return executionId;
|
|
95
|
+
}
|
|
96
|
+
async function reclaim(ctx, executionId) {
|
|
97
|
+
const stale = await claimOne(ctx.runtime, executionId);
|
|
98
|
+
ctx.advance(REDELIVER_AFTER);
|
|
99
|
+
const current = await claimOne(ctx.peer, executionId);
|
|
100
|
+
expect(current.token).toBeGreaterThan(stale.token);
|
|
101
|
+
return { stale, current };
|
|
102
|
+
}
|
|
103
|
+
function begin(runtime, effectId, options = {}) {
|
|
104
|
+
return runtime.effects.begin({
|
|
105
|
+
effectId,
|
|
106
|
+
operation: options.operation ?? OPERATION,
|
|
107
|
+
input: "input" in options ? options.input : { amount: 4999, currency: "usd" },
|
|
108
|
+
executionId: options.executionId ?? "order-42",
|
|
109
|
+
stepKey: options.stepKey ?? "charge"
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
function settle(runtime, effectId, result, operation = OPERATION) {
|
|
113
|
+
return runtime.effects.settle({ effectId, operation, result });
|
|
114
|
+
}
|
|
115
|
+
function watch(runtime, effectId, executionId, stepKey = "charge") {
|
|
116
|
+
return runtime.effects.watch({ effectId, executionId, stepKey });
|
|
117
|
+
}
|
|
118
|
+
function recordStep(runtime, lease, options = {}) {
|
|
119
|
+
return runtime.heap.writeStep({
|
|
120
|
+
lease,
|
|
121
|
+
stepKey: options.stepKey ?? "charge",
|
|
122
|
+
stepAttempt: options.attempt ?? 1,
|
|
123
|
+
done: options.done ?? true,
|
|
124
|
+
stepResponseJson: JSON.stringify({ paymentId: "pay_1" })
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async function expectInvariant(promise) {
|
|
128
|
+
const error = await promise.then(() => {
|
|
129
|
+
throw new Error("expected an EffectClientInvariantError");
|
|
130
|
+
}, (err) => err);
|
|
131
|
+
expect(error).toBeInstanceOf(EffectClientInvariantError);
|
|
132
|
+
expect(error.code).toBe("EFFECT_RUNTIME_INVARIANT");
|
|
133
|
+
}
|
|
134
|
+
describe(`${name} effect client conformance`, () => {
|
|
135
|
+
describe("begin", () => {
|
|
136
|
+
it("creates one pending effect and returns its current state to repeated matching calls", async ({
|
|
137
|
+
runtime,
|
|
138
|
+
peer
|
|
139
|
+
}) => {
|
|
140
|
+
expect(await runtime.effects.getEffect("pay:42")).toBeUndefined();
|
|
141
|
+
expect(await begin(runtime, "pay:42")).toEqual({ status: "new" });
|
|
142
|
+
expect(await runtime.effects.getEffect("pay:42")).toEqual({
|
|
143
|
+
effectId: "pay:42",
|
|
144
|
+
operation: OPERATION,
|
|
145
|
+
input: { amount: 4999, currency: "usd" },
|
|
146
|
+
status: "pending",
|
|
147
|
+
conflicts: []
|
|
148
|
+
});
|
|
149
|
+
expect(await begin(runtime, "pay:42")).toEqual({ status: "pending" });
|
|
150
|
+
expect(await begin(peer, "pay:42")).toEqual({ status: "pending" });
|
|
151
|
+
expect(await begin(runtime, "pay:42", { executionId: "support", stepKey: "check" })).toEqual({ status: "pending" });
|
|
152
|
+
await settle(runtime, "pay:42", { paymentId: "pay_1" });
|
|
153
|
+
expect(await begin(peer, "pay:42")).toEqual({
|
|
154
|
+
status: "settled",
|
|
155
|
+
result: { paymentId: "pay_1" }
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
it("creates one effect for concurrent begins with one effect ID", async ({
|
|
159
|
+
runtime,
|
|
160
|
+
peer
|
|
161
|
+
}) => {
|
|
162
|
+
const results = await Promise.all([
|
|
163
|
+
begin(runtime, "pay:race"),
|
|
164
|
+
begin(peer, "pay:race"),
|
|
165
|
+
begin(runtime, "pay:race")
|
|
166
|
+
]);
|
|
167
|
+
expect(results.map((result) => result.status).sort()).toEqual([
|
|
168
|
+
"new",
|
|
169
|
+
"pending",
|
|
170
|
+
"pending"
|
|
171
|
+
]);
|
|
172
|
+
expect(await runtime.effects.getEffect("pay:race")).toMatchObject({
|
|
173
|
+
status: "pending"
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
it("matches input whose keys are in a different order", async ({ runtime }) => {
|
|
177
|
+
await begin(runtime, "pay:keys", { input: { a: 1, b: { c: 2, d: 3 } } });
|
|
178
|
+
expect(await begin(runtime, "pay:keys", { input: { b: { d: 3, c: 2 }, a: 1 } })).toEqual({ status: "pending" });
|
|
179
|
+
});
|
|
180
|
+
it("rejects a changed operation or input without changing the effect", async ({
|
|
181
|
+
runtime,
|
|
182
|
+
peer
|
|
183
|
+
}) => {
|
|
184
|
+
const mismatch = {
|
|
185
|
+
status: "rejected",
|
|
186
|
+
code: "EFFECT_REQUEST_MISMATCH"
|
|
187
|
+
};
|
|
188
|
+
await begin(runtime, "pay:changed");
|
|
189
|
+
const before = await runtime.effects.getEffect("pay:changed");
|
|
190
|
+
expect(await begin(runtime, "pay:changed", { operation: "charge-v2" })).toEqual(mismatch);
|
|
191
|
+
expect(await begin(peer, "pay:changed", { input: { amount: 5999, currency: "usd" } })).toEqual(mismatch);
|
|
192
|
+
expect(await begin(runtime, "pay:changed", {
|
|
193
|
+
input: { amount: 4999, currency: "usd", note: undefined }
|
|
194
|
+
})).toEqual(mismatch);
|
|
195
|
+
expect(await runtime.effects.getEffect("pay:changed")).toEqual(before);
|
|
196
|
+
await settle(runtime, "pay:changed", { paymentId: "pay_1" });
|
|
197
|
+
const settled = await runtime.effects.getEffect("pay:changed");
|
|
198
|
+
expect(await begin(runtime, "pay:changed", { operation: "charge-v2" })).toEqual(mismatch);
|
|
199
|
+
expect(await runtime.effects.getEffect("pay:changed")).toEqual(settled);
|
|
200
|
+
});
|
|
201
|
+
test.each([
|
|
202
|
+
["an empty effect ID", { effectId: "" }],
|
|
203
|
+
["an empty operation", { operation: "" }],
|
|
204
|
+
["array input", { input: [] }],
|
|
205
|
+
["a Date in the input", { input: { at: new Date(0) } }],
|
|
206
|
+
["a non-finite number in the input", { input: { amount: Number.NaN } }],
|
|
207
|
+
["an empty execution ID", { executionId: "" }],
|
|
208
|
+
["an empty step key", { stepKey: "" }]
|
|
209
|
+
])("throws EffectClientInvariantError for %s and creates nothing", async (_label, override) => {
|
|
210
|
+
const ctx = await setup();
|
|
211
|
+
try {
|
|
212
|
+
const request = {
|
|
213
|
+
effectId: "pay:invalid",
|
|
214
|
+
operation: OPERATION,
|
|
215
|
+
input: { amount: 4999 },
|
|
216
|
+
executionId: "order-42",
|
|
217
|
+
stepKey: "charge",
|
|
218
|
+
...override
|
|
219
|
+
};
|
|
220
|
+
await expectInvariant(ctx.runtime.effects.begin(request));
|
|
221
|
+
expect(await ctx.runtime.effects.getEffect("pay:invalid")).toBeUndefined();
|
|
222
|
+
} finally {
|
|
223
|
+
await ctx.harness.dispose?.();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
describe("settle", () => {
|
|
228
|
+
it("settles a pending effect and returns its result", async ({ runtime }) => {
|
|
229
|
+
await begin(runtime, "pay:settle");
|
|
230
|
+
expect(await settle(runtime, "pay:settle", { paymentId: "pay_1" })).toEqual({
|
|
231
|
+
status: "settled",
|
|
232
|
+
result: { paymentId: "pay_1" }
|
|
233
|
+
});
|
|
234
|
+
expect(await runtime.effects.getEffect("pay:settle")).toEqual({
|
|
235
|
+
effectId: "pay:settle",
|
|
236
|
+
operation: OPERATION,
|
|
237
|
+
input: { amount: 4999, currency: "usd" },
|
|
238
|
+
status: "settled",
|
|
239
|
+
result: { paymentId: "pay_1" },
|
|
240
|
+
conflicts: []
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
it("keeps one settled result when equal results settle concurrently", async ({
|
|
244
|
+
runtime,
|
|
245
|
+
peer
|
|
246
|
+
}) => {
|
|
247
|
+
await begin(runtime, "pay:equal");
|
|
248
|
+
const results = await Promise.all([
|
|
249
|
+
settle(runtime, "pay:equal", { paymentId: "pay_1", fee: 30 }),
|
|
250
|
+
settle(peer, "pay:equal", { fee: 30, paymentId: "pay_1" }),
|
|
251
|
+
settle(runtime, "pay:equal", { paymentId: "pay_1", fee: 30 })
|
|
252
|
+
]);
|
|
253
|
+
for (const result of results) {
|
|
254
|
+
expect(result).toEqual({
|
|
255
|
+
status: "settled",
|
|
256
|
+
result: { paymentId: "pay_1", fee: 30 }
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
expect(await runtime.effects.getEffect("pay:equal")).toMatchObject({
|
|
260
|
+
status: "settled",
|
|
261
|
+
result: { paymentId: "pay_1", fee: 30 },
|
|
262
|
+
conflicts: []
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
it("keeps the first result and one conflict report when a different result settles", async ({
|
|
266
|
+
runtime,
|
|
267
|
+
peer,
|
|
268
|
+
clock,
|
|
269
|
+
advance
|
|
270
|
+
}) => {
|
|
271
|
+
await begin(runtime, "pay:conflict");
|
|
272
|
+
await settle(runtime, "pay:conflict", { paymentId: "pay_1" });
|
|
273
|
+
advance(10);
|
|
274
|
+
const reportedAt = clock.now;
|
|
275
|
+
const conflict = {
|
|
276
|
+
status: "conflict",
|
|
277
|
+
result: { paymentId: "pay_1" },
|
|
278
|
+
conflictingResult: { paymentId: "pay_2" }
|
|
279
|
+
};
|
|
280
|
+
expect(await settle(peer, "pay:conflict", { paymentId: "pay_2" })).toEqual(conflict);
|
|
281
|
+
advance(10);
|
|
282
|
+
expect(await settle(runtime, "pay:conflict", { paymentId: "pay_2" })).toEqual(conflict);
|
|
283
|
+
expect(await settle(runtime, "pay:conflict", { paymentId: "pay_1" })).toEqual({
|
|
284
|
+
status: "settled",
|
|
285
|
+
result: { paymentId: "pay_1" }
|
|
286
|
+
});
|
|
287
|
+
expect(await begin(runtime, "pay:conflict")).toEqual({
|
|
288
|
+
status: "settled",
|
|
289
|
+
result: { paymentId: "pay_1" }
|
|
290
|
+
});
|
|
291
|
+
expect(await runtime.effects.getEffect("pay:conflict")).toMatchObject({
|
|
292
|
+
status: "settled",
|
|
293
|
+
result: { paymentId: "pay_1" },
|
|
294
|
+
conflicts: [
|
|
295
|
+
{
|
|
296
|
+
result: { paymentId: "pay_1" },
|
|
297
|
+
conflictingResult: { paymentId: "pay_2" },
|
|
298
|
+
reportedAt
|
|
299
|
+
}
|
|
300
|
+
]
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
it("settles once when different results settle concurrently", async ({
|
|
304
|
+
runtime,
|
|
305
|
+
peer
|
|
306
|
+
}) => {
|
|
307
|
+
await begin(runtime, "pay:race");
|
|
308
|
+
const results = await Promise.all([
|
|
309
|
+
settle(runtime, "pay:race", "first"),
|
|
310
|
+
settle(peer, "pay:race", "second")
|
|
311
|
+
]);
|
|
312
|
+
const settled = results.filter((result) => result.status === "settled");
|
|
313
|
+
const conflicts = results.filter((result) => result.status === "conflict");
|
|
314
|
+
expect(settled).toHaveLength(1);
|
|
315
|
+
expect(conflicts).toHaveLength(1);
|
|
316
|
+
const winner = settled[0].result;
|
|
317
|
+
const loser = winner === "first" ? "second" : "first";
|
|
318
|
+
expect(conflicts[0]).toEqual({
|
|
319
|
+
status: "conflict",
|
|
320
|
+
result: winner,
|
|
321
|
+
conflictingResult: loser
|
|
322
|
+
});
|
|
323
|
+
const record = await runtime.effects.getEffect("pay:race");
|
|
324
|
+
expect(record?.result).toBe(winner);
|
|
325
|
+
expect(record?.conflicts).toEqual([
|
|
326
|
+
{ result: winner, conflictingResult: loser, reportedAt: START }
|
|
327
|
+
]);
|
|
328
|
+
});
|
|
329
|
+
it("stores undefined, errors and error-shaped objects without changing their type", async ({
|
|
330
|
+
runtime
|
|
331
|
+
}) => {
|
|
332
|
+
await begin(runtime, "delete:1");
|
|
333
|
+
expect(await settle(runtime, "delete:1", undefined)).toEqual({
|
|
334
|
+
status: "settled",
|
|
335
|
+
result: undefined
|
|
336
|
+
});
|
|
337
|
+
const deleted = await runtime.effects.getEffect("delete:1");
|
|
338
|
+
expect(deleted?.status).toBe("settled");
|
|
339
|
+
expect("result" in deleted).toBe(true);
|
|
340
|
+
expect(deleted?.result).toBeUndefined();
|
|
341
|
+
expect(await begin(runtime, "delete:1")).toEqual({
|
|
342
|
+
status: "settled",
|
|
343
|
+
result: undefined
|
|
344
|
+
});
|
|
345
|
+
const result = {
|
|
346
|
+
declined: new CardDeclinedError("insufficient_funds"),
|
|
347
|
+
report: { name: "Error", message: "not an error" },
|
|
348
|
+
tagged: { $type: "undefined" },
|
|
349
|
+
missing: undefined
|
|
350
|
+
};
|
|
351
|
+
await begin(runtime, "pay:values", {
|
|
352
|
+
input: { amount: 4999, note: undefined, shaped: { name: "E", message: "m" } }
|
|
353
|
+
});
|
|
354
|
+
await settle(runtime, "pay:values", result);
|
|
355
|
+
const begun = await begin(runtime, "pay:values", {
|
|
356
|
+
input: { amount: 4999, note: undefined, shaped: { name: "E", message: "m" } }
|
|
357
|
+
});
|
|
358
|
+
const record = await runtime.effects.getEffect("pay:values");
|
|
359
|
+
for (const stored of [
|
|
360
|
+
begun.result,
|
|
361
|
+
record.result
|
|
362
|
+
]) {
|
|
363
|
+
expect(stored.declined).toBeInstanceOf(CardDeclinedError);
|
|
364
|
+
expect(stored.declined.message).toBe("Card declined: insufficient_funds");
|
|
365
|
+
expect(stored.declined.code).toBe("CARD_DECLINED");
|
|
366
|
+
expect(stored.declined.declineCode).toBe("insufficient_funds");
|
|
367
|
+
expect(stored.report).not.toBeInstanceOf(Error);
|
|
368
|
+
expect(stored.report).toEqual({ name: "Error", message: "not an error" });
|
|
369
|
+
expect(stored.tagged).toEqual({ $type: "undefined" });
|
|
370
|
+
expect(Object.keys(stored)).toContain("missing");
|
|
371
|
+
}
|
|
372
|
+
expect(record.input.shaped).not.toBeInstanceOf(Error);
|
|
373
|
+
expect(Object.keys(record.input)).toContain("note");
|
|
374
|
+
});
|
|
375
|
+
it("throws EffectClientInvariantError for an unknown effect, a changed operation or an invalid result", async ({
|
|
376
|
+
runtime
|
|
377
|
+
}) => {
|
|
378
|
+
await expectInvariant(settle(runtime, "pay:unknown", "result"));
|
|
379
|
+
expect(await runtime.effects.getEffect("pay:unknown")).toBeUndefined();
|
|
380
|
+
await begin(runtime, "pay:invariant");
|
|
381
|
+
await expectInvariant(settle(runtime, "pay:invariant", "result", "charge-v2"));
|
|
382
|
+
await expectInvariant(settle(runtime, "pay:invariant", { at: new Date(0) }));
|
|
383
|
+
expect(await runtime.effects.getEffect("pay:invariant")).toMatchObject({
|
|
384
|
+
status: "pending"
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
describe("watch and wakes", () => {
|
|
389
|
+
it("wakes every watching execution when the settlement commits", async ({
|
|
390
|
+
runtime,
|
|
391
|
+
peer
|
|
392
|
+
}) => {
|
|
393
|
+
await admitSuspended(runtime, "order-42");
|
|
394
|
+
await admitSuspended(runtime, "support");
|
|
395
|
+
await begin(runtime, "pay:watched");
|
|
396
|
+
await watch(runtime, "pay:watched", "order-42");
|
|
397
|
+
await watch(peer, "pay:watched", "support", "check");
|
|
398
|
+
expect(await claim(runtime)).toEqual([]);
|
|
399
|
+
await settle(peer, "pay:watched", { paymentId: "pay_1" });
|
|
400
|
+
expect(await claimedIds(runtime)).toEqual(["order-42", "support"]);
|
|
401
|
+
});
|
|
402
|
+
it("registers nothing when a watch fails before it commits, and a repeat succeeds as a first call", async ({ runtime, harness }, testContext) => {
|
|
403
|
+
if (!harness.failEffectWatches) {
|
|
404
|
+
testContext.skip();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
await admitSuspended(runtime, "order-42");
|
|
408
|
+
await begin(runtime, "pay:failed");
|
|
409
|
+
await begin(runtime, "pay:retried");
|
|
410
|
+
const restore = await harness.failEffectWatches();
|
|
411
|
+
try {
|
|
412
|
+
await expect(watch(runtime, "pay:failed", "order-42")).rejects.toThrow();
|
|
413
|
+
await expect(watch(runtime, "pay:retried", "order-42")).rejects.toThrow();
|
|
414
|
+
} finally {
|
|
415
|
+
await restore();
|
|
416
|
+
}
|
|
417
|
+
await settle(runtime, "pay:failed", "done");
|
|
418
|
+
expect(await claim(runtime)).toEqual([]);
|
|
419
|
+
await watch(runtime, "pay:retried", "order-42");
|
|
420
|
+
expect(await claim(runtime)).toEqual([]);
|
|
421
|
+
await settle(runtime, "pay:retried", "done");
|
|
422
|
+
expect(await claimedIds(runtime)).toEqual(["order-42"]);
|
|
423
|
+
});
|
|
424
|
+
it("keeps one registration when a watch is repeated after a lost response", async ({
|
|
425
|
+
runtime,
|
|
426
|
+
peer
|
|
427
|
+
}) => {
|
|
428
|
+
await admitSuspended(runtime, "order-42");
|
|
429
|
+
await begin(runtime, "pay:repeated");
|
|
430
|
+
await watch(runtime, "pay:repeated", "order-42");
|
|
431
|
+
await watch(peer, "pay:repeated", "order-42");
|
|
432
|
+
await watch(runtime, "pay:repeated", "order-42");
|
|
433
|
+
await runtime.executions.requestWake({
|
|
434
|
+
executionId: "order-42",
|
|
435
|
+
wakeAt: START
|
|
436
|
+
});
|
|
437
|
+
const lease = await claimOne(runtime, "order-42");
|
|
438
|
+
await recordStep(runtime, lease);
|
|
439
|
+
await runtime.executions.acknowledge({ lease, outcome: { status: "suspended" } });
|
|
440
|
+
await settle(runtime, "pay:repeated", "done");
|
|
441
|
+
expect(await claim(runtime)).toEqual([]);
|
|
442
|
+
});
|
|
443
|
+
it("wakes the execution at once when the effect is already settled, on every watch", async ({
|
|
444
|
+
runtime
|
|
445
|
+
}) => {
|
|
446
|
+
await admitSuspended(runtime, "order-42");
|
|
447
|
+
await begin(runtime, "pay:settled");
|
|
448
|
+
await settle(runtime, "pay:settled", "done");
|
|
449
|
+
await watch(runtime, "pay:settled", "order-42");
|
|
450
|
+
const lease = await claimOne(runtime, "order-42");
|
|
451
|
+
await runtime.executions.acknowledge({ lease, outcome: { status: "suspended" } });
|
|
452
|
+
expect(await claim(runtime)).toEqual([]);
|
|
453
|
+
await watch(runtime, "pay:settled", "order-42");
|
|
454
|
+
expect(await claimedIds(runtime)).toEqual(["order-42"]);
|
|
455
|
+
});
|
|
456
|
+
it("wakes the watching execution when settlement races with watch", async ({
|
|
457
|
+
runtime,
|
|
458
|
+
peer
|
|
459
|
+
}) => {
|
|
460
|
+
for (let i = 0;i < 10; i++) {
|
|
461
|
+
const executionId = `racer-${i}`;
|
|
462
|
+
const effectId = `pay:race-${i}`;
|
|
463
|
+
await admitSuspended(runtime, executionId);
|
|
464
|
+
await begin(runtime, effectId);
|
|
465
|
+
const watching = watch(i % 2 ? runtime : peer, effectId, executionId);
|
|
466
|
+
const settling = settle(i % 2 ? peer : runtime, effectId, "done");
|
|
467
|
+
await Promise.all(i % 4 < 2 ? [watching, settling] : [settling, watching]);
|
|
468
|
+
expect(await claimedIds(runtime)).toEqual([executionId]);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
it("deletes a waiter in the write that records its step's result, so a later settlement does not wake", async ({
|
|
472
|
+
runtime
|
|
473
|
+
}) => {
|
|
474
|
+
await admitSuspended(runtime, "order-42");
|
|
475
|
+
await begin(runtime, "pay:recorded");
|
|
476
|
+
await begin(runtime, "pay:other");
|
|
477
|
+
await watch(runtime, "pay:recorded", "order-42", "charge");
|
|
478
|
+
await watch(runtime, "pay:other", "order-42", "refund");
|
|
479
|
+
await runtime.executions.requestWake({ executionId: "order-42", wakeAt: START });
|
|
480
|
+
const unfinished = await claimOne(runtime, "order-42");
|
|
481
|
+
await recordStep(runtime, unfinished, { attempt: 1, done: false });
|
|
482
|
+
await runtime.executions.acknowledge({
|
|
483
|
+
lease: unfinished,
|
|
484
|
+
outcome: { status: "suspended" }
|
|
485
|
+
});
|
|
486
|
+
await runtime.executions.requestWake({ executionId: "order-42", wakeAt: START });
|
|
487
|
+
const lease = await claimOne(runtime, "order-42");
|
|
488
|
+
await recordStep(runtime, lease, { attempt: 2 });
|
|
489
|
+
await runtime.executions.acknowledge({ lease, outcome: { status: "suspended" } });
|
|
490
|
+
await settle(runtime, "pay:recorded", "done");
|
|
491
|
+
expect(await claim(runtime)).toEqual([]);
|
|
492
|
+
await settle(runtime, "pay:other", "done");
|
|
493
|
+
expect(await claimedIds(runtime)).toEqual(["order-42"]);
|
|
494
|
+
});
|
|
495
|
+
it("keeps the waiter when a stale holder's step result is rejected", async (ctx) => {
|
|
496
|
+
const { runtime } = ctx;
|
|
497
|
+
await admitSuspended(runtime, "order-42");
|
|
498
|
+
await begin(runtime, "pay:stale");
|
|
499
|
+
await watch(runtime, "pay:stale", "order-42");
|
|
500
|
+
await runtime.executions.requestWake({ executionId: "order-42", wakeAt: START });
|
|
501
|
+
const { stale, current } = await reclaim(ctx, "order-42");
|
|
502
|
+
expect(await recordStep(runtime, stale)).toEqual({ status: "lost" });
|
|
503
|
+
await ctx.peer.executions.acknowledge({
|
|
504
|
+
lease: current,
|
|
505
|
+
outcome: { status: "suspended" }
|
|
506
|
+
});
|
|
507
|
+
await settle(runtime, "pay:stale", "done");
|
|
508
|
+
expect(await claimedIds(runtime)).toEqual(["order-42"]);
|
|
509
|
+
});
|
|
510
|
+
it("settles without waking a terminal execution", async ({ runtime }) => {
|
|
511
|
+
await admitSuspended(runtime, "order-42");
|
|
512
|
+
await begin(runtime, "pay:terminal");
|
|
513
|
+
await watch(runtime, "pay:terminal", "order-42");
|
|
514
|
+
await runtime.executions.requestWake({ executionId: "order-42", wakeAt: START });
|
|
515
|
+
const lease = await claimOne(runtime, "order-42");
|
|
516
|
+
await runtime.executions.acknowledge({
|
|
517
|
+
lease,
|
|
518
|
+
outcome: { status: "completed", result: null }
|
|
519
|
+
});
|
|
520
|
+
expect(await settle(runtime, "pay:terminal", "done")).toEqual({
|
|
521
|
+
status: "settled",
|
|
522
|
+
result: "done"
|
|
523
|
+
});
|
|
524
|
+
expect(await claim(runtime)).toEqual([]);
|
|
525
|
+
expect(await runtime.executions.get("order-42")).toMatchObject({
|
|
526
|
+
status: "completed"
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
it("throws EffectClientInvariantError when watching an unknown effect", async ({
|
|
530
|
+
runtime
|
|
531
|
+
}) => {
|
|
532
|
+
await admitSuspended(runtime, "order-42");
|
|
533
|
+
await expectInvariant(watch(runtime, "pay:unknown", "order-42"));
|
|
534
|
+
await begin(runtime, "pay:unknown");
|
|
535
|
+
await settle(runtime, "pay:unknown", "done");
|
|
536
|
+
expect(await claim(runtime)).toEqual([]);
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
describe("getEffect", () => {
|
|
540
|
+
it("returns the first result and the conflict report after a conflicting settlement", async ({
|
|
541
|
+
runtime,
|
|
542
|
+
peer
|
|
543
|
+
}) => {
|
|
544
|
+
await begin(runtime, "pay:report");
|
|
545
|
+
await settle(runtime, "pay:report", { paymentId: "pay_1" });
|
|
546
|
+
await settle(runtime, "pay:report", { paymentId: "pay_2" });
|
|
547
|
+
await settle(runtime, "pay:report", { paymentId: "pay_3" });
|
|
548
|
+
expect(await peer.effects.getEffect("pay:report")).toEqual({
|
|
549
|
+
effectId: "pay:report",
|
|
550
|
+
operation: OPERATION,
|
|
551
|
+
input: { amount: 4999, currency: "usd" },
|
|
552
|
+
status: "settled",
|
|
553
|
+
result: { paymentId: "pay_1" },
|
|
554
|
+
conflicts: [
|
|
555
|
+
{
|
|
556
|
+
result: { paymentId: "pay_1" },
|
|
557
|
+
conflictingResult: { paymentId: "pay_2" },
|
|
558
|
+
reportedAt: START
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
result: { paymentId: "pay_1" },
|
|
562
|
+
conflictingResult: { paymentId: "pay_3" },
|
|
563
|
+
reportedAt: START
|
|
564
|
+
}
|
|
565
|
+
]
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
it("returns a copy that callers cannot use to change the record", async ({
|
|
569
|
+
runtime
|
|
570
|
+
}) => {
|
|
571
|
+
const input = { amount: 4999, nested: { currency: "usd" } };
|
|
572
|
+
await begin(runtime, "pay:copy", { input });
|
|
573
|
+
input.nested.currency = "eur";
|
|
574
|
+
const record = await runtime.effects.getEffect("pay:copy");
|
|
575
|
+
record.input.nested.currency = "gbp";
|
|
576
|
+
expect((await runtime.effects.getEffect("pay:copy")).input).toEqual({
|
|
577
|
+
amount: 4999,
|
|
578
|
+
nested: { currency: "usd" }
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
describe("durability", () => {
|
|
583
|
+
itAfterRestart("resumes delivery of stored wakes after a restart", async ({
|
|
584
|
+
runtime,
|
|
585
|
+
restart
|
|
586
|
+
}) => {
|
|
587
|
+
await admitSuspended(runtime, "order-42");
|
|
588
|
+
await begin(runtime, "pay:wake");
|
|
589
|
+
await watch(runtime, "pay:wake", "order-42");
|
|
590
|
+
await settle(runtime, "pay:wake", "done");
|
|
591
|
+
const restarted = await restart();
|
|
592
|
+
expect(await claimedIds(restarted)).toEqual(["order-42"]);
|
|
593
|
+
});
|
|
594
|
+
itAfterRestart("keeps effects, conflict reports and waiters across a restart", async ({
|
|
595
|
+
runtime,
|
|
596
|
+
restart
|
|
597
|
+
}) => {
|
|
598
|
+
await admitSuspended(runtime, "order-42");
|
|
599
|
+
await begin(runtime, "pay:pending");
|
|
600
|
+
await watch(runtime, "pay:pending", "order-42");
|
|
601
|
+
await begin(runtime, "pay:conflict");
|
|
602
|
+
await settle(runtime, "pay:conflict", "first");
|
|
603
|
+
await settle(runtime, "pay:conflict", "second");
|
|
604
|
+
const conflicted = await runtime.effects.getEffect("pay:conflict");
|
|
605
|
+
const restarted = await restart();
|
|
606
|
+
expect(await restarted.effects.getEffect("pay:conflict")).toEqual(conflicted);
|
|
607
|
+
expect(await begin(restarted, "pay:pending")).toEqual({ status: "pending" });
|
|
608
|
+
expect(await claim(restarted)).toEqual([]);
|
|
609
|
+
await settle(restarted, "pay:pending", "done");
|
|
610
|
+
expect(await claimedIds(restarted)).toEqual(["order-42"]);
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
// src/execution.ts
|
|
616
|
+
import {
|
|
617
|
+
DEFAULT_LEASE_DURATION as DEFAULT_LEASE_DURATION2,
|
|
618
|
+
defineStore,
|
|
619
|
+
durableValueCodec,
|
|
620
|
+
redeliveryDelay as redeliveryDelay2,
|
|
621
|
+
registerErrorClass as registerErrorClass2
|
|
622
|
+
} from "@yieldstar/core";
|
|
623
|
+
import { describe as describe2, expect as expect2, test as test2 } from "vitest";
|
|
624
|
+
var START2 = 1e6;
|
|
625
|
+
var LEASE2 = DEFAULT_LEASE_DURATION2;
|
|
626
|
+
var REDELIVER_AFTER2 = LEASE2 + redeliveryDelay2(1);
|
|
627
|
+
function schema() {
|
|
628
|
+
return {
|
|
629
|
+
"~standard": {
|
|
630
|
+
version: 1,
|
|
631
|
+
vendor: "yieldstar-execution-conformance",
|
|
632
|
+
validate(value) {
|
|
633
|
+
return { value };
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
class Point {
|
|
640
|
+
x;
|
|
641
|
+
constructor(x) {
|
|
642
|
+
this.x = x;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
class PaymentDeclinedError extends Error {
|
|
647
|
+
code = "CARD_DECLINED";
|
|
648
|
+
constructor(message) {
|
|
649
|
+
super(message);
|
|
650
|
+
this.name = "PaymentDeclinedError";
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
class GatewayTimeoutError extends Error {
|
|
655
|
+
gateway;
|
|
656
|
+
constructor(gateway, options) {
|
|
657
|
+
super(`${gateway} timed out`, options);
|
|
658
|
+
this.name = "ConformanceGatewayTimeoutError";
|
|
659
|
+
this.gateway = gateway;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
registerErrorClass2("ConformanceGatewayTimeoutError", GatewayTimeoutError);
|
|
663
|
+
function registerExecutionClientConformance({
|
|
664
|
+
name,
|
|
665
|
+
create
|
|
666
|
+
}) {
|
|
667
|
+
const Store = defineStore(`${name}-execution-conformance`, schema(), {
|
|
668
|
+
initial: () => ({ messages: [] })
|
|
669
|
+
});
|
|
670
|
+
async function setup() {
|
|
671
|
+
const clock = { now: START2 };
|
|
672
|
+
const harness = await create({
|
|
673
|
+
clock: () => clock.now
|
|
674
|
+
});
|
|
675
|
+
return {
|
|
676
|
+
runtime: harness.runtime,
|
|
677
|
+
harness,
|
|
678
|
+
clock,
|
|
679
|
+
advance(ms) {
|
|
680
|
+
clock.now += ms;
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
function it(title, fn) {
|
|
685
|
+
test2(title, async (testContext) => {
|
|
686
|
+
const ctx = await setup();
|
|
687
|
+
try {
|
|
688
|
+
await fn(ctx, testContext);
|
|
689
|
+
} finally {
|
|
690
|
+
await ctx.harness.dispose?.();
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
function itAfterRestart(title, fn) {
|
|
695
|
+
test2(title, async (testContext) => {
|
|
696
|
+
const ctx = await setup();
|
|
697
|
+
try {
|
|
698
|
+
if (!ctx.harness.restart) {
|
|
699
|
+
testContext.skip();
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
await fn({ ...ctx, restart: async () => ctx.harness.restart() });
|
|
703
|
+
} finally {
|
|
704
|
+
await ctx.harness.dispose?.();
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
async function admit(runtime, executionId, workflowId = "workflow") {
|
|
709
|
+
const admitted = await runtime.executions.admit({
|
|
710
|
+
executionId,
|
|
711
|
+
workflowId,
|
|
712
|
+
params: { executionId },
|
|
713
|
+
context: new Map
|
|
714
|
+
});
|
|
715
|
+
expect2(admitted).toEqual({ status: "accepted", executionId });
|
|
716
|
+
return executionId;
|
|
717
|
+
}
|
|
718
|
+
function startFrom(runtime, callerExecutionId, stepKey, target = {}) {
|
|
719
|
+
return runtime.executions.start({
|
|
720
|
+
requestId: { executionId: callerExecutionId, stepKey },
|
|
721
|
+
workflowId: target.workflowId ?? "ship-order",
|
|
722
|
+
params: "params" in target ? target.params : { callerExecutionId, stepKey }
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
function acceptedId(result) {
|
|
726
|
+
if (result.status !== "accepted") {
|
|
727
|
+
throw new Error(`start rejected: ${result.code}: ${result.message}`);
|
|
728
|
+
}
|
|
729
|
+
return result.executionId;
|
|
730
|
+
}
|
|
731
|
+
function claim(runtime, limit = 100) {
|
|
732
|
+
return runtime.executions.claimDue({ limit, duration: LEASE2 });
|
|
733
|
+
}
|
|
734
|
+
async function claimOne(runtime, executionId) {
|
|
735
|
+
const leases = await claim(runtime);
|
|
736
|
+
expect2(leases.map((d) => d.executionId)).toEqual([executionId]);
|
|
737
|
+
return leases[0];
|
|
738
|
+
}
|
|
739
|
+
async function claimedIds(runtime) {
|
|
740
|
+
return (await claim(runtime)).map((d) => d.executionId).sort();
|
|
741
|
+
}
|
|
742
|
+
async function drain(runtime) {
|
|
743
|
+
const leases = await claim(runtime);
|
|
744
|
+
for (const lease of leases) {
|
|
745
|
+
await runtime.executions.acknowledge({ lease, outcome: { status: "suspended" } });
|
|
746
|
+
}
|
|
747
|
+
return leases.map((d) => d.executionId).sort();
|
|
748
|
+
}
|
|
749
|
+
async function admitSuspended(runtime, executionId) {
|
|
750
|
+
await admit(runtime, executionId);
|
|
751
|
+
const lease = await claimOne(runtime, executionId);
|
|
752
|
+
expect2(await runtime.executions.acknowledge({ lease, outcome: { status: "suspended" } })).toEqual({ status: "suspended" });
|
|
753
|
+
return executionId;
|
|
754
|
+
}
|
|
755
|
+
async function admitCompleted(runtime, executionId, result) {
|
|
756
|
+
await admit(runtime, executionId);
|
|
757
|
+
const lease = await claimOne(runtime, executionId);
|
|
758
|
+
await runtime.executions.acknowledge({
|
|
759
|
+
lease,
|
|
760
|
+
outcome: { status: "completed", result }
|
|
761
|
+
});
|
|
762
|
+
return lease;
|
|
763
|
+
}
|
|
764
|
+
function waiter(executionId, instanceId, sinceVersion) {
|
|
765
|
+
return {
|
|
766
|
+
workflowId: "workflow",
|
|
767
|
+
executionId,
|
|
768
|
+
stepKey: "wait-for-message",
|
|
769
|
+
storeName: Store.name,
|
|
770
|
+
storeId: executionId,
|
|
771
|
+
instanceId,
|
|
772
|
+
sinceVersion,
|
|
773
|
+
readPaths: [["messages"]]
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
async function appendMessage(runtime, storeId, id) {
|
|
777
|
+
return runtime.stores.updateStore({
|
|
778
|
+
definition: Store,
|
|
779
|
+
id: storeId,
|
|
780
|
+
updater(draft) {
|
|
781
|
+
draft.messages.push({ id });
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
describe2(`${name} execution client conformance`, () => {
|
|
786
|
+
describe2("admission", () => {
|
|
787
|
+
it("reserves and returns an unused caller-supplied ID", async ({ runtime }) => {
|
|
788
|
+
expect2(await runtime.executions.admit({
|
|
789
|
+
executionId: "order-42",
|
|
790
|
+
workflowId: "charge-order",
|
|
791
|
+
params: { orderId: 42 },
|
|
792
|
+
context: new Map
|
|
793
|
+
})).toEqual({ status: "accepted", executionId: "order-42" });
|
|
794
|
+
expect2(await runtime.executions.get("order-42")).toMatchObject({
|
|
795
|
+
executionId: "order-42",
|
|
796
|
+
workflowId: "charge-order"
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
it("rejects an ID in use and leaves the existing record unchanged", async ({
|
|
800
|
+
runtime,
|
|
801
|
+
advance
|
|
802
|
+
}) => {
|
|
803
|
+
await runtime.executions.admit({
|
|
804
|
+
executionId: "order-42",
|
|
805
|
+
workflowId: "charge-order",
|
|
806
|
+
params: { orderId: 42 },
|
|
807
|
+
context: new Map([["tenant", "acme"]])
|
|
808
|
+
});
|
|
809
|
+
const before = await runtime.executions.get("order-42");
|
|
810
|
+
advance(10);
|
|
811
|
+
const conflict = await runtime.executions.admit({
|
|
812
|
+
executionId: "order-42",
|
|
813
|
+
workflowId: "refund-order",
|
|
814
|
+
params: { orderId: 43 },
|
|
815
|
+
context: new Map([["tenant", "other"]])
|
|
816
|
+
});
|
|
817
|
+
expect2(conflict).toMatchObject({
|
|
818
|
+
status: "rejected",
|
|
819
|
+
code: "EXECUTION_ID_CONFLICT"
|
|
820
|
+
});
|
|
821
|
+
expect2(conflict.status === "rejected" && typeof conflict.message).toBe("string");
|
|
822
|
+
const after = await runtime.executions.get("order-42");
|
|
823
|
+
expect2(after).toEqual(before);
|
|
824
|
+
expect2([...after.context.entries()]).toEqual([["tenant", "acme"]]);
|
|
825
|
+
expect2(await claimedIds(runtime)).toEqual(["order-42"]);
|
|
826
|
+
});
|
|
827
|
+
it("keeps the ID reserved after a delivery fails", async ({ runtime, advance }) => {
|
|
828
|
+
await admit(runtime, "fails");
|
|
829
|
+
const lease = await claimOne(runtime, "fails");
|
|
830
|
+
await runtime.executions.acknowledge({
|
|
831
|
+
lease,
|
|
832
|
+
outcome: { status: "failed", error: new Error("boom") }
|
|
833
|
+
});
|
|
834
|
+
expect2(await runtime.executions.admit({
|
|
835
|
+
executionId: "fails",
|
|
836
|
+
workflowId: "workflow",
|
|
837
|
+
params: undefined,
|
|
838
|
+
context: new Map
|
|
839
|
+
})).toMatchObject({ status: "rejected", code: "EXECUTION_ID_CONFLICT" });
|
|
840
|
+
await admit(runtime, "crashes");
|
|
841
|
+
await claimOne(runtime, "crashes");
|
|
842
|
+
advance(REDELIVER_AFTER2);
|
|
843
|
+
expect2(await runtime.executions.admit({
|
|
844
|
+
executionId: "crashes",
|
|
845
|
+
workflowId: "workflow",
|
|
846
|
+
params: undefined,
|
|
847
|
+
context: new Map
|
|
848
|
+
})).toMatchObject({ status: "rejected", code: "EXECUTION_ID_CONFLICT" });
|
|
849
|
+
});
|
|
850
|
+
it("chooses a new ID when none is supplied", async ({ runtime }) => {
|
|
851
|
+
await admit(runtime, "existing");
|
|
852
|
+
const ids = new Set(["existing"]);
|
|
853
|
+
for (let i = 0;i < 20; i++) {
|
|
854
|
+
const admitted = await runtime.executions.admit({
|
|
855
|
+
workflowId: "workflow",
|
|
856
|
+
params: { i },
|
|
857
|
+
context: new Map
|
|
858
|
+
});
|
|
859
|
+
if (admitted.status !== "accepted")
|
|
860
|
+
throw new Error("admission rejected");
|
|
861
|
+
expect2(typeof admitted.executionId).toBe("string");
|
|
862
|
+
expect2(admitted.executionId).not.toBe("");
|
|
863
|
+
expect2(ids.has(admitted.executionId)).toBe(false);
|
|
864
|
+
ids.add(admitted.executionId);
|
|
865
|
+
expect2(await runtime.executions.get(admitted.executionId)).toMatchObject({
|
|
866
|
+
params: { i }
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
it("stores the event once and get returns it exactly as admitted", async ({
|
|
871
|
+
runtime
|
|
872
|
+
}) => {
|
|
873
|
+
const params = {
|
|
874
|
+
text: "é\uD83D\uDE80",
|
|
875
|
+
int: 42,
|
|
876
|
+
float: 1.5,
|
|
877
|
+
negative: -3,
|
|
878
|
+
yes: true,
|
|
879
|
+
no: false,
|
|
880
|
+
nothing: null,
|
|
881
|
+
list: [1, "two", { three: [null] }],
|
|
882
|
+
nested: { a: { b: { c: "d" } } },
|
|
883
|
+
empty: {},
|
|
884
|
+
missing: undefined,
|
|
885
|
+
holes: [1, undefined],
|
|
886
|
+
tagged: { $type: "undefined" },
|
|
887
|
+
report: { name: "launch", message: "Ship it", stack: "web" }
|
|
888
|
+
};
|
|
889
|
+
const context = new Map([
|
|
890
|
+
["tenant", "acme"],
|
|
891
|
+
["count", 2],
|
|
892
|
+
["7", { roles: ["admin"] }],
|
|
893
|
+
["nothing", undefined]
|
|
894
|
+
]);
|
|
895
|
+
await runtime.executions.admit({
|
|
896
|
+
executionId: "event",
|
|
897
|
+
workflowId: "charge-order",
|
|
898
|
+
params,
|
|
899
|
+
context
|
|
900
|
+
});
|
|
901
|
+
params.nested.a.b.c = "mutated";
|
|
902
|
+
context.set("tenant", "mutated");
|
|
903
|
+
const record = await runtime.executions.get("event");
|
|
904
|
+
expect2(record).toBeDefined();
|
|
905
|
+
expect2(record.workflowId).toBe("charge-order");
|
|
906
|
+
expect2(record.params).toEqual({
|
|
907
|
+
...params,
|
|
908
|
+
nested: { a: { b: { c: "d" } } }
|
|
909
|
+
});
|
|
910
|
+
const ctx = record.context;
|
|
911
|
+
expect2(typeof ctx.get).toBe("function");
|
|
912
|
+
expect2(typeof ctx.has).toBe("function");
|
|
913
|
+
expect2(Object.keys(record.params)).toContain("missing");
|
|
914
|
+
expect2(record.params.report).not.toBeInstanceOf(Error);
|
|
915
|
+
expect2(ctx.size).toBe(4);
|
|
916
|
+
expect2(ctx.get("tenant")).toBe("acme");
|
|
917
|
+
expect2(ctx.get("7")).toEqual({ roles: ["admin"] });
|
|
918
|
+
expect2([...ctx.entries()]).toEqual([
|
|
919
|
+
["tenant", "acme"],
|
|
920
|
+
["count", 2],
|
|
921
|
+
["7", { roles: ["admin"] }],
|
|
922
|
+
["nothing", undefined]
|
|
923
|
+
]);
|
|
924
|
+
await runtime.executions.admit({
|
|
925
|
+
executionId: "no-params",
|
|
926
|
+
workflowId: "workflow",
|
|
927
|
+
params: undefined,
|
|
928
|
+
context: new Map
|
|
929
|
+
});
|
|
930
|
+
const bare = await runtime.executions.get("no-params");
|
|
931
|
+
expect2(bare.params).toBeUndefined();
|
|
932
|
+
expect2(bare.context.size).toBe(0);
|
|
933
|
+
});
|
|
934
|
+
it("hands out leases that name the execution, not a copy of its event", async ({
|
|
935
|
+
runtime,
|
|
936
|
+
clock
|
|
937
|
+
}) => {
|
|
938
|
+
await runtime.executions.admit({
|
|
939
|
+
executionId: "delivery",
|
|
940
|
+
workflowId: "charge-order",
|
|
941
|
+
params: { orderId: 42 },
|
|
942
|
+
context: new Map([["tenant", "acme"]])
|
|
943
|
+
});
|
|
944
|
+
const [lease] = await claim(runtime);
|
|
945
|
+
expect2(Object.keys(lease).sort()).toEqual(["executionId", "expiresAt", "token"]);
|
|
946
|
+
expect2(lease.executionId).toBe("delivery");
|
|
947
|
+
expect2(typeof lease.token).toBe("number");
|
|
948
|
+
expect2(lease.expiresAt).toBe(clock.now + LEASE2);
|
|
949
|
+
const record = await runtime.executions.get(lease.executionId);
|
|
950
|
+
expect2(record).toMatchObject({
|
|
951
|
+
workflowId: "charge-order",
|
|
952
|
+
params: { orderId: 42 }
|
|
953
|
+
});
|
|
954
|
+
expect2(record.context.get("tenant")).toBe("acme");
|
|
955
|
+
});
|
|
956
|
+
const cyclic = {};
|
|
957
|
+
cyclic.self = cyclic;
|
|
958
|
+
const invalidEvents = [
|
|
959
|
+
["a Date", { params: { at: new Date(0) }, context: new Map }],
|
|
960
|
+
["a class instance", { params: { point: new Point(1) }, context: new Map }],
|
|
961
|
+
["NaN", { params: { n: NaN }, context: new Map }],
|
|
962
|
+
["Infinity", { params: { n: Infinity }, context: new Map }],
|
|
963
|
+
["a bigint", { params: { n: 10n }, context: new Map }],
|
|
964
|
+
["a function", { params: { fn: () => 1 }, context: new Map }],
|
|
965
|
+
["a Map", { params: { map: new Map([["a", 1]]) }, context: new Map }],
|
|
966
|
+
["a sparse array", { params: { list: [1, , 3] }, context: new Map }],
|
|
967
|
+
["a cycle", { params: cyclic, context: new Map }],
|
|
968
|
+
["a Date in context", { params: {}, context: new Map([["at", new Date(0)]]) }],
|
|
969
|
+
["an object context key", { params: {}, context: new Map([[{}, 1]]) }],
|
|
970
|
+
["a number context key", { params: {}, context: new Map([[7, 1]]) }]
|
|
971
|
+
];
|
|
972
|
+
for (const [label, event] of invalidEvents) {
|
|
973
|
+
it(`rejects ${label} with INVALID_EXECUTION_EVENT and stores nothing`, async ({
|
|
974
|
+
runtime
|
|
975
|
+
}) => {
|
|
976
|
+
const rejected = await runtime.executions.admit({
|
|
977
|
+
executionId: "invalid",
|
|
978
|
+
workflowId: "workflow",
|
|
979
|
+
...event
|
|
980
|
+
});
|
|
981
|
+
expect2(rejected).toMatchObject({
|
|
982
|
+
status: "rejected",
|
|
983
|
+
code: "INVALID_EXECUTION_EVENT"
|
|
984
|
+
});
|
|
985
|
+
expect2(await runtime.executions.get("invalid")).toBeUndefined();
|
|
986
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
987
|
+
await admit(runtime, "invalid");
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
it("creates an admitted record that claimDue returns", async ({ runtime, clock }) => {
|
|
991
|
+
await admit(runtime, "admitted");
|
|
992
|
+
expect2(await runtime.executions.get("admitted")).toMatchObject({
|
|
993
|
+
status: "admitted",
|
|
994
|
+
admittedAt: clock.now,
|
|
995
|
+
updatedAt: clock.now
|
|
996
|
+
});
|
|
997
|
+
expect2((await runtime.executions.get("admitted")).outcome).toBeUndefined();
|
|
998
|
+
await claimOne(runtime, "admitted");
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
describe2("wakes", () => {
|
|
1002
|
+
it("returns an execution once when two wakes are requested before a delivery", async ({
|
|
1003
|
+
runtime,
|
|
1004
|
+
clock
|
|
1005
|
+
}) => {
|
|
1006
|
+
await admitSuspended(runtime, "twice");
|
|
1007
|
+
expect2(await runtime.executions.requestWake({ executionId: "twice", wakeAt: clock.now })).toEqual({ status: "requested" });
|
|
1008
|
+
expect2(await runtime.executions.requestWake({ executionId: "twice", wakeAt: clock.now })).toEqual({ status: "requested" });
|
|
1009
|
+
expect2(await claimedIds(runtime)).toEqual(["twice"]);
|
|
1010
|
+
await admit(runtime, "admitted-and-woken");
|
|
1011
|
+
await runtime.executions.requestWake({
|
|
1012
|
+
executionId: "admitted-and-woken",
|
|
1013
|
+
wakeAt: clock.now
|
|
1014
|
+
});
|
|
1015
|
+
expect2(await claimedIds(runtime)).toEqual(["admitted-and-woken"]);
|
|
1016
|
+
});
|
|
1017
|
+
it("merges wakes into the earliest requested time", async ({ runtime, clock, advance }) => {
|
|
1018
|
+
await admitSuspended(runtime, "earliest");
|
|
1019
|
+
await runtime.executions.requestWake({ executionId: "earliest", wakeAt: clock.now + 200 });
|
|
1020
|
+
await runtime.executions.requestWake({ executionId: "earliest", wakeAt: clock.now + 100 });
|
|
1021
|
+
await runtime.executions.requestWake({ executionId: "earliest", wakeAt: clock.now + 300 });
|
|
1022
|
+
advance(99);
|
|
1023
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1024
|
+
advance(1);
|
|
1025
|
+
expect2(await claimedIds(runtime)).toEqual(["earliest"]);
|
|
1026
|
+
});
|
|
1027
|
+
it("keeps a wake requested during a delivery after it acknowledges", async ({
|
|
1028
|
+
runtime,
|
|
1029
|
+
clock,
|
|
1030
|
+
advance
|
|
1031
|
+
}) => {
|
|
1032
|
+
await admit(runtime, "during");
|
|
1033
|
+
const lease = await claimOne(runtime, "during");
|
|
1034
|
+
await runtime.executions.requestWake({ executionId: "during", wakeAt: clock.now + 50 });
|
|
1035
|
+
await runtime.executions.acknowledge({ lease, outcome: { status: "suspended" } });
|
|
1036
|
+
advance(49);
|
|
1037
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1038
|
+
advance(1);
|
|
1039
|
+
expect2(await claimedIds(runtime)).toEqual(["during"]);
|
|
1040
|
+
});
|
|
1041
|
+
it("makes a suspended delivery due at its wakeAt", async ({ runtime, clock, advance }) => {
|
|
1042
|
+
await admit(runtime, "delay");
|
|
1043
|
+
const lease = await claimOne(runtime, "delay");
|
|
1044
|
+
await runtime.executions.acknowledge({
|
|
1045
|
+
lease,
|
|
1046
|
+
outcome: { status: "suspended", wakeAt: clock.now + 500 }
|
|
1047
|
+
});
|
|
1048
|
+
advance(499);
|
|
1049
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1050
|
+
advance(1);
|
|
1051
|
+
expect2(await claimedIds(runtime)).toEqual(["delay"]);
|
|
1052
|
+
});
|
|
1053
|
+
it("prefers an earlier wake requested during a delivery over its wakeAt", async ({
|
|
1054
|
+
runtime,
|
|
1055
|
+
clock,
|
|
1056
|
+
advance
|
|
1057
|
+
}) => {
|
|
1058
|
+
await admit(runtime, "race");
|
|
1059
|
+
const lease = await claimOne(runtime, "race");
|
|
1060
|
+
await runtime.executions.requestWake({ executionId: "race", wakeAt: clock.now + 100 });
|
|
1061
|
+
await runtime.executions.acknowledge({
|
|
1062
|
+
lease,
|
|
1063
|
+
outcome: { status: "suspended", wakeAt: clock.now + 500 }
|
|
1064
|
+
});
|
|
1065
|
+
advance(99);
|
|
1066
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1067
|
+
advance(1);
|
|
1068
|
+
expect2(await claimedIds(runtime)).toEqual(["race"]);
|
|
1069
|
+
});
|
|
1070
|
+
it("does not make a suspended delivery due without wakeAt or a new wake", async ({
|
|
1071
|
+
runtime,
|
|
1072
|
+
advance
|
|
1073
|
+
}) => {
|
|
1074
|
+
await admitSuspended(runtime, "idle");
|
|
1075
|
+
advance(REDELIVER_AFTER2 * 10);
|
|
1076
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1077
|
+
expect2(await runtime.executions.get("idle")).toMatchObject({ status: "suspended" });
|
|
1078
|
+
});
|
|
1079
|
+
it("redelivers an unacknowledged delivery whose paused step reached wakeAt", async ({
|
|
1080
|
+
runtime,
|
|
1081
|
+
clock,
|
|
1082
|
+
advance
|
|
1083
|
+
}) => {
|
|
1084
|
+
await admit(runtime, "paused");
|
|
1085
|
+
const first = await claimOne(runtime, "paused");
|
|
1086
|
+
await runtime.executions.acknowledge({
|
|
1087
|
+
lease: first,
|
|
1088
|
+
outcome: { status: "suspended", wakeAt: clock.now + 100 }
|
|
1089
|
+
});
|
|
1090
|
+
advance(100);
|
|
1091
|
+
const second = await claimOne(runtime, "paused");
|
|
1092
|
+
advance(REDELIVER_AFTER2 - 1);
|
|
1093
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1094
|
+
advance(1);
|
|
1095
|
+
const third = await claimOne(runtime, "paused");
|
|
1096
|
+
expect2(third.token).toBeGreaterThan(second.token);
|
|
1097
|
+
expect2(await runtime.executions.acknowledge({
|
|
1098
|
+
lease: third,
|
|
1099
|
+
outcome: { status: "completed", result: "done" }
|
|
1100
|
+
})).toEqual({ status: "completed", result: "done" });
|
|
1101
|
+
});
|
|
1102
|
+
it("does not let a future wake requested during a past-due delivery inherit its due time", async ({
|
|
1103
|
+
runtime,
|
|
1104
|
+
clock,
|
|
1105
|
+
advance
|
|
1106
|
+
}) => {
|
|
1107
|
+
await admitSuspended(runtime, "past-due");
|
|
1108
|
+
await runtime.executions.requestWake({ executionId: "past-due", wakeAt: clock.now - 500 });
|
|
1109
|
+
const lease = await claimOne(runtime, "past-due");
|
|
1110
|
+
await runtime.executions.requestWake({ executionId: "past-due", wakeAt: clock.now + 300 });
|
|
1111
|
+
await runtime.executions.acknowledge({ lease, outcome: { status: "suspended" } });
|
|
1112
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1113
|
+
advance(299);
|
|
1114
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1115
|
+
advance(1);
|
|
1116
|
+
expect2(await claimedIds(runtime)).toEqual(["past-due"]);
|
|
1117
|
+
});
|
|
1118
|
+
it("rejects a wake for an unknown execution without creating a record", async ({
|
|
1119
|
+
runtime,
|
|
1120
|
+
clock
|
|
1121
|
+
}) => {
|
|
1122
|
+
const result = await runtime.executions.requestWake({
|
|
1123
|
+
executionId: "ghost",
|
|
1124
|
+
wakeAt: clock.now
|
|
1125
|
+
});
|
|
1126
|
+
expect2(result).toMatchObject({ status: "rejected", code: "EXECUTION_NOT_FOUND" });
|
|
1127
|
+
expect2(result.status === "rejected" && typeof result.message).toBe("string");
|
|
1128
|
+
expect2(await runtime.executions.get("ghost")).toBeUndefined();
|
|
1129
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1130
|
+
await admit(runtime, "ghost");
|
|
1131
|
+
});
|
|
1132
|
+
it("does not return a leased execution again before its lease expires", async ({
|
|
1133
|
+
runtime,
|
|
1134
|
+
clock,
|
|
1135
|
+
advance
|
|
1136
|
+
}) => {
|
|
1137
|
+
await admit(runtime, "claimed");
|
|
1138
|
+
await claimOne(runtime, "claimed");
|
|
1139
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1140
|
+
await runtime.executions.requestWake({ executionId: "claimed", wakeAt: clock.now });
|
|
1141
|
+
advance(REDELIVER_AFTER2 - 1);
|
|
1142
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1143
|
+
advance(1);
|
|
1144
|
+
expect2(await claimedIds(runtime)).toEqual(["claimed"]);
|
|
1145
|
+
});
|
|
1146
|
+
it("keeps the wakes of a never-acknowledged delivery pending and redelivers it", async ({
|
|
1147
|
+
runtime,
|
|
1148
|
+
advance
|
|
1149
|
+
}) => {
|
|
1150
|
+
await admit(runtime, "lost");
|
|
1151
|
+
const first = await claimOne(runtime, "lost");
|
|
1152
|
+
advance(REDELIVER_AFTER2);
|
|
1153
|
+
expect2(await runtime.executions.get("lost")).toMatchObject({ status: "admitted" });
|
|
1154
|
+
const second = await claimOne(runtime, "lost");
|
|
1155
|
+
expect2(second.token).toBeGreaterThan(first.token);
|
|
1156
|
+
await runtime.executions.acknowledge({
|
|
1157
|
+
lease: second,
|
|
1158
|
+
outcome: { status: "suspended" }
|
|
1159
|
+
});
|
|
1160
|
+
advance(REDELIVER_AFTER2 * 10);
|
|
1161
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1162
|
+
});
|
|
1163
|
+
it("ignores a wake for a terminal execution", async ({ runtime, clock, advance }) => {
|
|
1164
|
+
await admitCompleted(runtime, "done", 1);
|
|
1165
|
+
await admit(runtime, "failed");
|
|
1166
|
+
const lease = await claimOne(runtime, "failed");
|
|
1167
|
+
await runtime.executions.acknowledge({
|
|
1168
|
+
lease,
|
|
1169
|
+
outcome: { status: "failed", error: new Error("boom") }
|
|
1170
|
+
});
|
|
1171
|
+
for (const executionId of ["done", "failed"]) {
|
|
1172
|
+
expect2(await runtime.executions.requestWake({ executionId, wakeAt: clock.now })).toEqual({ status: "ignored" });
|
|
1173
|
+
}
|
|
1174
|
+
advance(REDELIVER_AFTER2 * 10);
|
|
1175
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1176
|
+
});
|
|
1177
|
+
it("clears pending wakes when an execution reaches a terminal status", async ({
|
|
1178
|
+
runtime,
|
|
1179
|
+
clock,
|
|
1180
|
+
advance
|
|
1181
|
+
}) => {
|
|
1182
|
+
await admit(runtime, "finishing");
|
|
1183
|
+
const lease = await claimOne(runtime, "finishing");
|
|
1184
|
+
await runtime.executions.requestWake({ executionId: "finishing", wakeAt: clock.now });
|
|
1185
|
+
await runtime.executions.acknowledge({
|
|
1186
|
+
lease,
|
|
1187
|
+
outcome: { status: "completed", result: null }
|
|
1188
|
+
});
|
|
1189
|
+
advance(REDELIVER_AFTER2 * 10);
|
|
1190
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1191
|
+
});
|
|
1192
|
+
it("returns each due execution at most once per claim", async ({ runtime, clock }) => {
|
|
1193
|
+
for (const id of ["a", "b", "c"])
|
|
1194
|
+
await admit(runtime, id);
|
|
1195
|
+
await runtime.executions.requestWake({ executionId: "b", wakeAt: clock.now - 1 });
|
|
1196
|
+
await runtime.executions.requestWake({ executionId: "b", wakeAt: clock.now });
|
|
1197
|
+
expect2(await claimedIds(runtime)).toEqual(["a", "b", "c"]);
|
|
1198
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1199
|
+
});
|
|
1200
|
+
});
|
|
1201
|
+
describe2("store waiters", () => {
|
|
1202
|
+
it("wakes a waiting execution when a store commit satisfies its waiter", async ({
|
|
1203
|
+
runtime
|
|
1204
|
+
}) => {
|
|
1205
|
+
await admitSuspended(runtime, "consumer");
|
|
1206
|
+
const snapshot = await runtime.stores.getStore({
|
|
1207
|
+
definition: Store,
|
|
1208
|
+
id: "consumer"
|
|
1209
|
+
});
|
|
1210
|
+
await runtime.stores.registerWaiter(waiter("consumer", snapshot.instanceId, snapshot.version));
|
|
1211
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1212
|
+
await appendMessage(runtime, "consumer", "m1");
|
|
1213
|
+
expect2(await claimedIds(runtime)).toEqual(["consumer"]);
|
|
1214
|
+
});
|
|
1215
|
+
it("deletes a waiter with the write that records its step's result", async ({
|
|
1216
|
+
runtime,
|
|
1217
|
+
clock
|
|
1218
|
+
}) => {
|
|
1219
|
+
await admitSuspended(runtime, "recorded");
|
|
1220
|
+
const snapshot = await runtime.stores.getStore({
|
|
1221
|
+
definition: Store,
|
|
1222
|
+
id: "recorded"
|
|
1223
|
+
});
|
|
1224
|
+
const registered = waiter("recorded", snapshot.instanceId, snapshot.version);
|
|
1225
|
+
await runtime.stores.registerWaiter(registered);
|
|
1226
|
+
await runtime.executions.requestWake({ executionId: "recorded", wakeAt: clock.now });
|
|
1227
|
+
const waiting = await claimOne(runtime, "recorded");
|
|
1228
|
+
await runtime.heap.writeStep({
|
|
1229
|
+
lease: waiting,
|
|
1230
|
+
stepKey: registered.stepKey,
|
|
1231
|
+
stepAttempt: 1,
|
|
1232
|
+
done: false,
|
|
1233
|
+
stepResponseJson: JSON.stringify({ waiting: true })
|
|
1234
|
+
});
|
|
1235
|
+
await runtime.executions.acknowledge({
|
|
1236
|
+
lease: waiting,
|
|
1237
|
+
outcome: { status: "suspended" }
|
|
1238
|
+
});
|
|
1239
|
+
await appendMessage(runtime, "recorded", "m1");
|
|
1240
|
+
const lease = await claimOne(runtime, "recorded");
|
|
1241
|
+
await runtime.heap.writeStep({
|
|
1242
|
+
lease,
|
|
1243
|
+
stepKey: registered.stepKey,
|
|
1244
|
+
stepAttempt: 2,
|
|
1245
|
+
done: true,
|
|
1246
|
+
stepResponseJson: JSON.stringify({ id: "m1" })
|
|
1247
|
+
});
|
|
1248
|
+
await runtime.executions.acknowledge({ lease, outcome: { status: "suspended" } });
|
|
1249
|
+
await appendMessage(runtime, "recorded", "m2");
|
|
1250
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1251
|
+
});
|
|
1252
|
+
});
|
|
1253
|
+
describe2("clock", () => {
|
|
1254
|
+
it("reads time from the runtime clock, not the wall clock", async ({
|
|
1255
|
+
runtime,
|
|
1256
|
+
clock,
|
|
1257
|
+
advance
|
|
1258
|
+
}) => {
|
|
1259
|
+
clock.now = 5000;
|
|
1260
|
+
await admit(runtime, "clocked");
|
|
1261
|
+
expect2(await runtime.executions.get("clocked")).toMatchObject({
|
|
1262
|
+
admittedAt: 5000,
|
|
1263
|
+
updatedAt: 5000
|
|
1264
|
+
});
|
|
1265
|
+
const lease = await claimOne(runtime, "clocked");
|
|
1266
|
+
advance(7);
|
|
1267
|
+
await runtime.executions.acknowledge({
|
|
1268
|
+
lease,
|
|
1269
|
+
outcome: { status: "suspended", wakeAt: Date.now() }
|
|
1270
|
+
});
|
|
1271
|
+
expect2(await runtime.executions.get("clocked")).toMatchObject({
|
|
1272
|
+
status: "suspended",
|
|
1273
|
+
updatedAt: 5007
|
|
1274
|
+
});
|
|
1275
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1276
|
+
await runtime.executions.requestWake({ executionId: "clocked", wakeAt: 5100 });
|
|
1277
|
+
advance(92);
|
|
1278
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1279
|
+
advance(1);
|
|
1280
|
+
await claimOne(runtime, "clocked");
|
|
1281
|
+
advance(REDELIVER_AFTER2 - 1);
|
|
1282
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1283
|
+
advance(1);
|
|
1284
|
+
await claimOne(runtime, "clocked");
|
|
1285
|
+
});
|
|
1286
|
+
});
|
|
1287
|
+
describe2("outcomes", () => {
|
|
1288
|
+
it("records a suspended acknowledgement without an outcome", async ({
|
|
1289
|
+
runtime,
|
|
1290
|
+
advance,
|
|
1291
|
+
clock
|
|
1292
|
+
}) => {
|
|
1293
|
+
await admit(runtime, "suspends");
|
|
1294
|
+
const lease = await claimOne(runtime, "suspends");
|
|
1295
|
+
advance(5);
|
|
1296
|
+
expect2(await runtime.executions.acknowledge({
|
|
1297
|
+
lease,
|
|
1298
|
+
outcome: { status: "suspended", wakeAt: clock.now + 1000 }
|
|
1299
|
+
})).toEqual({ status: "suspended" });
|
|
1300
|
+
const record = await runtime.executions.get("suspends");
|
|
1301
|
+
expect2(record).toMatchObject({
|
|
1302
|
+
status: "suspended",
|
|
1303
|
+
admittedAt: START2,
|
|
1304
|
+
updatedAt: START2 + 5
|
|
1305
|
+
});
|
|
1306
|
+
expect2(record.outcome).toBeUndefined();
|
|
1307
|
+
});
|
|
1308
|
+
it("records a completed acknowledgement and its result", async ({ runtime }) => {
|
|
1309
|
+
const result = { receipt: "r-1", amount: 12.5, lines: [{ sku: "a" }] };
|
|
1310
|
+
await admit(runtime, "completes");
|
|
1311
|
+
const lease = await claimOne(runtime, "completes");
|
|
1312
|
+
expect2(await runtime.executions.acknowledge({
|
|
1313
|
+
lease,
|
|
1314
|
+
outcome: { status: "completed", result }
|
|
1315
|
+
})).toEqual({ status: "completed", result });
|
|
1316
|
+
expect2(await runtime.executions.get("completes")).toMatchObject({
|
|
1317
|
+
status: "completed",
|
|
1318
|
+
outcome: { status: "completed", result }
|
|
1319
|
+
});
|
|
1320
|
+
await admitCompleted(runtime, "void", undefined);
|
|
1321
|
+
const voidRecord = await runtime.executions.get("void");
|
|
1322
|
+
expect2(voidRecord.status).toBe("completed");
|
|
1323
|
+
expect2(voidRecord.outcome).toEqual({ status: "completed", result: undefined });
|
|
1324
|
+
});
|
|
1325
|
+
it("records a failed acknowledgement with the error name and message", async ({
|
|
1326
|
+
runtime
|
|
1327
|
+
}) => {
|
|
1328
|
+
await admit(runtime, "fails");
|
|
1329
|
+
const lease = await claimOne(runtime, "fails");
|
|
1330
|
+
const acknowledged = await runtime.executions.acknowledge({
|
|
1331
|
+
lease,
|
|
1332
|
+
outcome: { status: "failed", error: new PaymentDeclinedError("card declined") }
|
|
1333
|
+
});
|
|
1334
|
+
expect2(acknowledged.status).toBe("failed");
|
|
1335
|
+
const record = await runtime.executions.get("fails");
|
|
1336
|
+
expect2(record.status).toBe("failed");
|
|
1337
|
+
for (const outcome of [acknowledged, record.outcome]) {
|
|
1338
|
+
if (outcome.status !== "failed")
|
|
1339
|
+
throw new Error("expected a failed outcome");
|
|
1340
|
+
expect2(outcome.error).toBeInstanceOf(Error);
|
|
1341
|
+
expect2(outcome.error.name).toBe("PaymentDeclinedError");
|
|
1342
|
+
expect2(outcome.error.message).toBe("card declined");
|
|
1343
|
+
expect2(Object.getPrototypeOf(outcome.error)).toBe(Error.prototype);
|
|
1344
|
+
expect2(outcome.error.code).toBe("CARD_DECLINED");
|
|
1345
|
+
}
|
|
1346
|
+
const again = await runtime.executions.get("fails");
|
|
1347
|
+
expect2(again.outcome).toMatchObject({ status: "failed" });
|
|
1348
|
+
const error = again.outcome.error;
|
|
1349
|
+
expect2([error.name, error.message]).toEqual(["PaymentDeclinedError", "card declined"]);
|
|
1350
|
+
});
|
|
1351
|
+
it("records a failed outcome's registered error class, code, cause and fields", async ({
|
|
1352
|
+
runtime
|
|
1353
|
+
}) => {
|
|
1354
|
+
await admit(runtime, "fails-registered");
|
|
1355
|
+
const lease = await claimOne(runtime, "fails-registered");
|
|
1356
|
+
const error = new GatewayTimeoutError("stripe", {
|
|
1357
|
+
cause: new PaymentDeclinedError("card declined")
|
|
1358
|
+
});
|
|
1359
|
+
const acknowledged = await runtime.executions.acknowledge({
|
|
1360
|
+
lease,
|
|
1361
|
+
outcome: { status: "failed", error }
|
|
1362
|
+
});
|
|
1363
|
+
const record = await runtime.executions.get("fails-registered");
|
|
1364
|
+
for (const outcome of [acknowledged, record.outcome]) {
|
|
1365
|
+
if (outcome.status !== "failed")
|
|
1366
|
+
throw new Error("expected a failed outcome");
|
|
1367
|
+
expect2(outcome.error).toBeInstanceOf(GatewayTimeoutError);
|
|
1368
|
+
expect2(outcome.error).toMatchObject({
|
|
1369
|
+
name: "ConformanceGatewayTimeoutError",
|
|
1370
|
+
message: "stripe timed out",
|
|
1371
|
+
gateway: "stripe"
|
|
1372
|
+
});
|
|
1373
|
+
expect2(outcome.error.stack).toBe(error.stack);
|
|
1374
|
+
const cause = outcome.error.cause;
|
|
1375
|
+
expect2([cause.name, cause.message, cause.code]).toEqual([
|
|
1376
|
+
"PaymentDeclinedError",
|
|
1377
|
+
"card declined",
|
|
1378
|
+
"CARD_DECLINED"
|
|
1379
|
+
]);
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
it("records a completed result's undefined values, errors and tag-shaped objects", async ({
|
|
1383
|
+
runtime
|
|
1384
|
+
}) => {
|
|
1385
|
+
const result = {
|
|
1386
|
+
ok: false,
|
|
1387
|
+
error: new TypeError("bad input"),
|
|
1388
|
+
missing: undefined,
|
|
1389
|
+
tagged: { $type: "error", name: "E", message: "m" }
|
|
1390
|
+
};
|
|
1391
|
+
await admitCompleted(runtime, "completes-tagged", result);
|
|
1392
|
+
const outcome = (await runtime.executions.get("completes-tagged")).outcome;
|
|
1393
|
+
if (outcome.status !== "completed")
|
|
1394
|
+
throw new Error("expected completed");
|
|
1395
|
+
const recorded = outcome.result;
|
|
1396
|
+
expect2(recorded).toEqual(result);
|
|
1397
|
+
expect2(Object.keys(recorded)).toContain("missing");
|
|
1398
|
+
expect2(recorded.error).toBeInstanceOf(TypeError);
|
|
1399
|
+
expect2(recorded.tagged).not.toBeInstanceOf(Error);
|
|
1400
|
+
});
|
|
1401
|
+
it("records a non-durable completed result as failed with INVALID_STEP_VALUE", async ({
|
|
1402
|
+
runtime
|
|
1403
|
+
}) => {
|
|
1404
|
+
await admit(runtime, "invalid-result");
|
|
1405
|
+
const lease = await claimOne(runtime, "invalid-result");
|
|
1406
|
+
const acknowledged = await runtime.executions.acknowledge({
|
|
1407
|
+
lease,
|
|
1408
|
+
outcome: { status: "completed", result: { at: new Date(0) } }
|
|
1409
|
+
});
|
|
1410
|
+
const record = await runtime.executions.get("invalid-result");
|
|
1411
|
+
expect2(record.status).toBe("failed");
|
|
1412
|
+
for (const outcome of [acknowledged, record.outcome]) {
|
|
1413
|
+
expect2(outcome).toMatchObject({
|
|
1414
|
+
status: "failed",
|
|
1415
|
+
error: { code: "INVALID_STEP_VALUE", stepKey: "$$workflow-result$$" }
|
|
1416
|
+
});
|
|
1417
|
+
expect2(outcome.error.name).toBe("WorkflowValueError");
|
|
1418
|
+
}
|
|
1419
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1420
|
+
});
|
|
1421
|
+
it("keeps the first terminal outcome: a later acknowledgement under the ended lease is lost", async ({
|
|
1422
|
+
runtime,
|
|
1423
|
+
clock,
|
|
1424
|
+
advance
|
|
1425
|
+
}) => {
|
|
1426
|
+
const lease = await admitCompleted(runtime, "first-wins", "first");
|
|
1427
|
+
const recorded = await runtime.executions.get("first-wins");
|
|
1428
|
+
advance(10);
|
|
1429
|
+
for (const outcome of [
|
|
1430
|
+
{ status: "failed", error: new Error("second") },
|
|
1431
|
+
{ status: "completed", result: "second" },
|
|
1432
|
+
{ status: "suspended", wakeAt: clock.now }
|
|
1433
|
+
]) {
|
|
1434
|
+
expect2(await runtime.executions.acknowledge({ lease, outcome })).toEqual({
|
|
1435
|
+
status: "lost"
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
expect2(await runtime.executions.get("first-wins")).toEqual(recorded);
|
|
1439
|
+
advance(REDELIVER_AFTER2 * 10);
|
|
1440
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1441
|
+
});
|
|
1442
|
+
it("keeps a terminal outcome when a stale lease acknowledges after a reclaim", async ({
|
|
1443
|
+
runtime,
|
|
1444
|
+
advance
|
|
1445
|
+
}) => {
|
|
1446
|
+
await admit(runtime, "duplicate");
|
|
1447
|
+
const slow = await claimOne(runtime, "duplicate");
|
|
1448
|
+
advance(REDELIVER_AFTER2);
|
|
1449
|
+
const redelivered = await claimOne(runtime, "duplicate");
|
|
1450
|
+
expect2(await runtime.executions.acknowledge({
|
|
1451
|
+
lease: redelivered,
|
|
1452
|
+
outcome: { status: "completed", result: "fast" }
|
|
1453
|
+
})).toEqual({ status: "completed", result: "fast" });
|
|
1454
|
+
const recorded = await runtime.executions.get("duplicate");
|
|
1455
|
+
expect2(await runtime.executions.acknowledge({
|
|
1456
|
+
lease: slow,
|
|
1457
|
+
outcome: { status: "completed", result: "slow" }
|
|
1458
|
+
})).toEqual({ status: "lost" });
|
|
1459
|
+
expect2(await runtime.executions.get("duplicate")).toEqual(recorded);
|
|
1460
|
+
advance(REDELIVER_AFTER2 * 10);
|
|
1461
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1462
|
+
});
|
|
1463
|
+
it("leaves the status unchanged after an infrastructure error and redelivers", async ({
|
|
1464
|
+
runtime,
|
|
1465
|
+
clock,
|
|
1466
|
+
advance
|
|
1467
|
+
}) => {
|
|
1468
|
+
await admitSuspended(runtime, "infra");
|
|
1469
|
+
const before = await runtime.executions.get("infra");
|
|
1470
|
+
await runtime.executions.requestWake({ executionId: "infra", wakeAt: clock.now });
|
|
1471
|
+
await claimOne(runtime, "infra");
|
|
1472
|
+
advance(REDELIVER_AFTER2);
|
|
1473
|
+
expect2(await runtime.executions.get("infra")).toMatchObject({
|
|
1474
|
+
status: "suspended",
|
|
1475
|
+
updatedAt: before.updatedAt
|
|
1476
|
+
});
|
|
1477
|
+
expect2((await runtime.executions.get("infra")).outcome).toBeUndefined();
|
|
1478
|
+
await claimOne(runtime, "infra");
|
|
1479
|
+
});
|
|
1480
|
+
});
|
|
1481
|
+
describe2("leases", () => {
|
|
1482
|
+
it("grants exactly one lease when two clients claim one due execution concurrently", async ({
|
|
1483
|
+
runtime,
|
|
1484
|
+
harness
|
|
1485
|
+
}) => {
|
|
1486
|
+
await admit(runtime, "contended");
|
|
1487
|
+
const peer = await harness.createPeer?.() ?? runtime;
|
|
1488
|
+
const claims = await Promise.all([claim(runtime), claim(peer), claim(runtime)]);
|
|
1489
|
+
const leases = claims.flat();
|
|
1490
|
+
expect2(leases.map((lease) => lease.executionId)).toEqual(["contended"]);
|
|
1491
|
+
});
|
|
1492
|
+
it("never grants a second live lease to concurrent claimers of many executions", async ({
|
|
1493
|
+
runtime,
|
|
1494
|
+
harness
|
|
1495
|
+
}) => {
|
|
1496
|
+
const ids = Array.from({ length: 20 }, (_, i) => `many-${String(i).padStart(2, "0")}`);
|
|
1497
|
+
for (const id of ids)
|
|
1498
|
+
await admit(runtime, id);
|
|
1499
|
+
const peer = await harness.createPeer?.() ?? runtime;
|
|
1500
|
+
const claims = await Promise.all(Array.from({ length: 6 }, (_, i) => claim(i % 2 ? peer : runtime, 4)));
|
|
1501
|
+
const claimed = claims.flat().map((lease) => lease.executionId).sort();
|
|
1502
|
+
expect2(new Set(claimed).size).toBe(claimed.length);
|
|
1503
|
+
expect2(claimed).toEqual(ids);
|
|
1504
|
+
});
|
|
1505
|
+
it("returns at most `limit` leases, earliest due first", async ({
|
|
1506
|
+
runtime,
|
|
1507
|
+
clock,
|
|
1508
|
+
advance
|
|
1509
|
+
}) => {
|
|
1510
|
+
for (const id of ["late", "early", "middle"])
|
|
1511
|
+
await admitSuspended(runtime, id);
|
|
1512
|
+
await runtime.executions.requestWake({ executionId: "late", wakeAt: clock.now + 30 });
|
|
1513
|
+
await runtime.executions.requestWake({ executionId: "early", wakeAt: clock.now + 10 });
|
|
1514
|
+
await runtime.executions.requestWake({ executionId: "middle", wakeAt: clock.now + 20 });
|
|
1515
|
+
advance(30);
|
|
1516
|
+
expect2(await claim(runtime, 0)).toEqual([]);
|
|
1517
|
+
expect2((await claim(runtime, 2)).map((lease) => lease.executionId)).toEqual([
|
|
1518
|
+
"early",
|
|
1519
|
+
"middle"
|
|
1520
|
+
]);
|
|
1521
|
+
expect2((await claim(runtime, 2)).map((lease) => lease.executionId)).toEqual(["late"]);
|
|
1522
|
+
});
|
|
1523
|
+
it("claims only the requested executions and leaves the others due and unclaimed", async ({
|
|
1524
|
+
runtime
|
|
1525
|
+
}) => {
|
|
1526
|
+
for (const id of ["a", "b", "c", "d"])
|
|
1527
|
+
await admit(runtime, id);
|
|
1528
|
+
const claimIds = (executionIds) => runtime.executions.claimDue({ limit: 100, duration: LEASE2, filter: { executionIds } });
|
|
1529
|
+
expect2(await claimIds([])).toEqual([]);
|
|
1530
|
+
const leases = await claimIds(["d", "b", "unknown"]);
|
|
1531
|
+
expect2(leases.map((lease) => lease.executionId).sort()).toEqual(["b", "d"]);
|
|
1532
|
+
for (const id of ["a", "c"]) {
|
|
1533
|
+
expect2(await runtime.executions.get(id)).toMatchObject({
|
|
1534
|
+
status: "admitted",
|
|
1535
|
+
unacknowledgedDeliveryCount: 0
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
expect2(await claimedIds(runtime)).toEqual(["a", "c"]);
|
|
1539
|
+
});
|
|
1540
|
+
it("marks a claimed execution running", async ({ runtime }) => {
|
|
1541
|
+
await admit(runtime, "running");
|
|
1542
|
+
expect2(await runtime.executions.get("running")).toMatchObject({ status: "admitted" });
|
|
1543
|
+
await claimOne(runtime, "running");
|
|
1544
|
+
expect2(await runtime.executions.get("running")).toMatchObject({
|
|
1545
|
+
status: "running",
|
|
1546
|
+
unacknowledgedDeliveryCount: 0
|
|
1547
|
+
});
|
|
1548
|
+
});
|
|
1549
|
+
it("makes an execution claimable with a higher token once its lease expires", async ({
|
|
1550
|
+
runtime,
|
|
1551
|
+
advance
|
|
1552
|
+
}) => {
|
|
1553
|
+
await admit(runtime, "expires");
|
|
1554
|
+
const first = await claimOne(runtime, "expires");
|
|
1555
|
+
advance(LEASE2 - 1);
|
|
1556
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1557
|
+
expect2(await runtime.executions.get("expires")).toMatchObject({ status: "running" });
|
|
1558
|
+
advance(REDELIVER_AFTER2 - LEASE2 + 1);
|
|
1559
|
+
const second = await claimOne(runtime, "expires");
|
|
1560
|
+
expect2(second.token).toBeGreaterThan(first.token);
|
|
1561
|
+
expect2(await runtime.executions.get("expires")).toMatchObject({
|
|
1562
|
+
status: "running",
|
|
1563
|
+
unacknowledgedDeliveryCount: 1
|
|
1564
|
+
});
|
|
1565
|
+
});
|
|
1566
|
+
it("returns an expired lease's execution to admitted before any acknowledgement", async ({
|
|
1567
|
+
runtime,
|
|
1568
|
+
advance
|
|
1569
|
+
}) => {
|
|
1570
|
+
await admit(runtime, "never-acked");
|
|
1571
|
+
await claimOne(runtime, "never-acked");
|
|
1572
|
+
advance(LEASE2);
|
|
1573
|
+
expect2(await runtime.executions.get("never-acked")).toMatchObject({
|
|
1574
|
+
status: "admitted",
|
|
1575
|
+
unacknowledgedDeliveryCount: 1
|
|
1576
|
+
});
|
|
1577
|
+
});
|
|
1578
|
+
it("returns an expired lease's execution to suspended after an earlier suspended delivery", async ({
|
|
1579
|
+
runtime,
|
|
1580
|
+
clock,
|
|
1581
|
+
advance
|
|
1582
|
+
}) => {
|
|
1583
|
+
await admitSuspended(runtime, "was-suspended");
|
|
1584
|
+
await runtime.executions.requestWake({ executionId: "was-suspended", wakeAt: clock.now });
|
|
1585
|
+
await claimOne(runtime, "was-suspended");
|
|
1586
|
+
advance(LEASE2);
|
|
1587
|
+
expect2(await runtime.executions.get("was-suspended")).toMatchObject({
|
|
1588
|
+
status: "suspended",
|
|
1589
|
+
unacknowledgedDeliveryCount: 1
|
|
1590
|
+
});
|
|
1591
|
+
});
|
|
1592
|
+
it("keeps a renewed lease past its original expiry with one claim", async ({
|
|
1593
|
+
runtime,
|
|
1594
|
+
clock,
|
|
1595
|
+
advance
|
|
1596
|
+
}) => {
|
|
1597
|
+
await admit(runtime, "long");
|
|
1598
|
+
let lease = await claimOne(runtime, "long");
|
|
1599
|
+
for (let i = 0;i < 5; i++) {
|
|
1600
|
+
advance(LEASE2 - 1);
|
|
1601
|
+
const renewed = await runtime.executions.renew({ lease, duration: LEASE2 });
|
|
1602
|
+
expect2(renewed).toEqual({
|
|
1603
|
+
status: "renewed",
|
|
1604
|
+
lease: { executionId: "long", token: lease.token, expiresAt: clock.now + LEASE2 }
|
|
1605
|
+
});
|
|
1606
|
+
if (renewed.status !== "renewed")
|
|
1607
|
+
throw new Error("renewal lost");
|
|
1608
|
+
lease = renewed.lease;
|
|
1609
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1610
|
+
expect2(await runtime.executions.get("long")).toMatchObject({ status: "running" });
|
|
1611
|
+
}
|
|
1612
|
+
expect2(await runtime.executions.acknowledge({
|
|
1613
|
+
lease,
|
|
1614
|
+
outcome: { status: "completed", result: "done" }
|
|
1615
|
+
})).toEqual({ status: "completed", result: "done" });
|
|
1616
|
+
expect2(await runtime.executions.get("long")).toMatchObject({
|
|
1617
|
+
status: "completed",
|
|
1618
|
+
unacknowledgedDeliveryCount: 0
|
|
1619
|
+
});
|
|
1620
|
+
});
|
|
1621
|
+
it("returns lost when renewing with a stale token", async ({ runtime, advance }) => {
|
|
1622
|
+
await admit(runtime, "stale-renew");
|
|
1623
|
+
const stale = await claimOne(runtime, "stale-renew");
|
|
1624
|
+
advance(REDELIVER_AFTER2);
|
|
1625
|
+
const current = await claimOne(runtime, "stale-renew");
|
|
1626
|
+
expect2(await runtime.executions.renew({ lease: stale, duration: LEASE2 })).toEqual({
|
|
1627
|
+
status: "lost"
|
|
1628
|
+
});
|
|
1629
|
+
advance(LEASE2);
|
|
1630
|
+
expect2(await runtime.executions.get("stale-renew")).toMatchObject({
|
|
1631
|
+
status: "admitted"
|
|
1632
|
+
});
|
|
1633
|
+
expect2(await runtime.executions.renew({ lease: current, duration: LEASE2 })).toMatchObject({
|
|
1634
|
+
status: "renewed"
|
|
1635
|
+
});
|
|
1636
|
+
});
|
|
1637
|
+
it("keeps an expired lease current until another claim issues a higher token", async ({
|
|
1638
|
+
runtime,
|
|
1639
|
+
clock,
|
|
1640
|
+
advance
|
|
1641
|
+
}) => {
|
|
1642
|
+
await admit(runtime, "expired-unclaimed");
|
|
1643
|
+
const lease = await claimOne(runtime, "expired-unclaimed");
|
|
1644
|
+
advance(REDELIVER_AFTER2 * 3);
|
|
1645
|
+
expect2(await runtime.heap.writeStep({
|
|
1646
|
+
lease,
|
|
1647
|
+
stepKey: "step",
|
|
1648
|
+
stepAttempt: 0,
|
|
1649
|
+
done: true,
|
|
1650
|
+
stepResponseJson: JSON.stringify({ value: 1 })
|
|
1651
|
+
})).toEqual({ stepResponseJson: JSON.stringify({ value: 1 }), meta: { attempt: 0, done: true } });
|
|
1652
|
+
const renewed = await runtime.executions.renew({ lease, duration: LEASE2 });
|
|
1653
|
+
expect2(renewed).toEqual({
|
|
1654
|
+
status: "renewed",
|
|
1655
|
+
lease: { ...lease, expiresAt: clock.now + LEASE2 }
|
|
1656
|
+
});
|
|
1657
|
+
expect2(await runtime.executions.get("expired-unclaimed")).toMatchObject({
|
|
1658
|
+
status: "running",
|
|
1659
|
+
unacknowledgedDeliveryCount: 0
|
|
1660
|
+
});
|
|
1661
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1662
|
+
expect2(await runtime.executions.acknowledge({ lease, outcome: { status: "suspended" } })).toEqual({ status: "suspended" });
|
|
1663
|
+
});
|
|
1664
|
+
it("makes a released execution claimable after the redelivery delay with a higher token", async ({
|
|
1665
|
+
runtime,
|
|
1666
|
+
advance
|
|
1667
|
+
}) => {
|
|
1668
|
+
await admitSuspended(runtime, "released");
|
|
1669
|
+
const before = await runtime.executions.get("released");
|
|
1670
|
+
await runtime.executions.requestWake({ executionId: "released", wakeAt: START2 });
|
|
1671
|
+
const first = await claimOne(runtime, "released");
|
|
1672
|
+
expect2(await runtime.executions.release({ lease: first })).toEqual({
|
|
1673
|
+
status: "released"
|
|
1674
|
+
});
|
|
1675
|
+
const released = await runtime.executions.get("released");
|
|
1676
|
+
expect2(released).toMatchObject({ status: "suspended", unacknowledgedDeliveryCount: 1 });
|
|
1677
|
+
expect2(released.outcome).toBeUndefined();
|
|
1678
|
+
expect2(released.updatedAt).toBe(before.updatedAt);
|
|
1679
|
+
expect2(await runtime.executions.release({ lease: first })).toEqual({ status: "lost" });
|
|
1680
|
+
expect2(await runtime.executions.renew({ lease: first, duration: LEASE2 })).toEqual({
|
|
1681
|
+
status: "lost"
|
|
1682
|
+
});
|
|
1683
|
+
expect2(await runtime.executions.acknowledge({ lease: first, outcome: { status: "suspended" } })).toEqual({ status: "lost" });
|
|
1684
|
+
advance(redeliveryDelay2(1) - 1);
|
|
1685
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1686
|
+
advance(1);
|
|
1687
|
+
const second = await claimOne(runtime, "released");
|
|
1688
|
+
expect2(second.token).toBeGreaterThan(first.token);
|
|
1689
|
+
});
|
|
1690
|
+
it("ends the lease on a suspended acknowledgement and makes the execution due at a wake requested during it", async ({
|
|
1691
|
+
runtime,
|
|
1692
|
+
clock,
|
|
1693
|
+
advance
|
|
1694
|
+
}) => {
|
|
1695
|
+
await admit(runtime, "woken-during");
|
|
1696
|
+
const lease = await claimOne(runtime, "woken-during");
|
|
1697
|
+
await runtime.executions.requestWake({ executionId: "woken-during", wakeAt: clock.now + 50 });
|
|
1698
|
+
await runtime.executions.acknowledge({ lease, outcome: { status: "suspended" } });
|
|
1699
|
+
expect2(await runtime.executions.get("woken-during")).toMatchObject({
|
|
1700
|
+
status: "suspended"
|
|
1701
|
+
});
|
|
1702
|
+
expect2(await runtime.executions.renew({ lease, duration: LEASE2 })).toEqual({
|
|
1703
|
+
status: "lost"
|
|
1704
|
+
});
|
|
1705
|
+
advance(49);
|
|
1706
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1707
|
+
advance(1);
|
|
1708
|
+
const next = await claimOne(runtime, "woken-during");
|
|
1709
|
+
expect2(next.token).toBeGreaterThan(lease.token);
|
|
1710
|
+
});
|
|
1711
|
+
it("ends the lease on a suspended acknowledgement and makes the execution due at wakeAt", async ({
|
|
1712
|
+
runtime,
|
|
1713
|
+
clock,
|
|
1714
|
+
advance
|
|
1715
|
+
}) => {
|
|
1716
|
+
await admit(runtime, "wake-at");
|
|
1717
|
+
const lease = await claimOne(runtime, "wake-at");
|
|
1718
|
+
await runtime.executions.acknowledge({
|
|
1719
|
+
lease,
|
|
1720
|
+
outcome: { status: "suspended", wakeAt: clock.now + 70 }
|
|
1721
|
+
});
|
|
1722
|
+
expect2(await runtime.executions.renew({ lease, duration: LEASE2 })).toEqual({
|
|
1723
|
+
status: "lost"
|
|
1724
|
+
});
|
|
1725
|
+
advance(69);
|
|
1726
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1727
|
+
advance(1);
|
|
1728
|
+
await claimOne(runtime, "wake-at");
|
|
1729
|
+
});
|
|
1730
|
+
it("ends the lease on a suspended acknowledgement and leaves the execution not due without a wake", async ({
|
|
1731
|
+
runtime,
|
|
1732
|
+
advance
|
|
1733
|
+
}) => {
|
|
1734
|
+
await admit(runtime, "not-due");
|
|
1735
|
+
const lease = await claimOne(runtime, "not-due");
|
|
1736
|
+
await runtime.executions.acknowledge({ lease, outcome: { status: "suspended" } });
|
|
1737
|
+
expect2(await runtime.executions.release({ lease })).toEqual({ status: "lost" });
|
|
1738
|
+
advance(REDELIVER_AFTER2 * 10);
|
|
1739
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1740
|
+
expect2(await runtime.executions.get("not-due")).toMatchObject({
|
|
1741
|
+
status: "suspended",
|
|
1742
|
+
unacknowledgedDeliveryCount: 0
|
|
1743
|
+
});
|
|
1744
|
+
});
|
|
1745
|
+
it("returns lost for a lease on an unknown execution", async ({ runtime }) => {
|
|
1746
|
+
const ghost = { executionId: "ghost", token: 1, expiresAt: START2 + LEASE2 };
|
|
1747
|
+
expect2(await runtime.executions.renew({ lease: ghost, duration: LEASE2 })).toEqual({
|
|
1748
|
+
status: "lost"
|
|
1749
|
+
});
|
|
1750
|
+
expect2(await runtime.executions.release({ lease: ghost })).toEqual({ status: "lost" });
|
|
1751
|
+
expect2(await runtime.executions.acknowledge({ lease: ghost, outcome: { status: "suspended" } })).toEqual({ status: "lost" });
|
|
1752
|
+
expect2(await runtime.heap.writeStep({
|
|
1753
|
+
lease: ghost,
|
|
1754
|
+
stepKey: "step",
|
|
1755
|
+
stepAttempt: 0,
|
|
1756
|
+
done: true,
|
|
1757
|
+
stepResponseJson: "{}"
|
|
1758
|
+
})).toEqual({ status: "lost" });
|
|
1759
|
+
expect2(await runtime.executions.get("ghost")).toBeUndefined();
|
|
1760
|
+
});
|
|
1761
|
+
itAfterRestart("keeps leases and tokens across a restart", async ({
|
|
1762
|
+
runtime,
|
|
1763
|
+
advance,
|
|
1764
|
+
restart
|
|
1765
|
+
}) => {
|
|
1766
|
+
await admit(runtime, "held");
|
|
1767
|
+
const held = await claimOne(runtime, "held");
|
|
1768
|
+
const restarted = await restart();
|
|
1769
|
+
expect2(await restarted.executions.get("held")).toMatchObject({ status: "running" });
|
|
1770
|
+
expect2(await claim(restarted)).toEqual([]);
|
|
1771
|
+
expect2(await restarted.executions.renew({ lease: held, duration: LEASE2 })).toMatchObject({
|
|
1772
|
+
status: "renewed"
|
|
1773
|
+
});
|
|
1774
|
+
advance(REDELIVER_AFTER2);
|
|
1775
|
+
const next = await claimOne(restarted, "held");
|
|
1776
|
+
expect2(next.token).toBeGreaterThan(held.token);
|
|
1777
|
+
expect2(await restarted.executions.release({ lease: held })).toEqual({ status: "lost" });
|
|
1778
|
+
});
|
|
1779
|
+
});
|
|
1780
|
+
describe2("redelivery delay", () => {
|
|
1781
|
+
it("delays each redelivery after a release by a non-zero delay no shorter than the last", async ({
|
|
1782
|
+
runtime,
|
|
1783
|
+
clock,
|
|
1784
|
+
advance
|
|
1785
|
+
}) => {
|
|
1786
|
+
await admit(runtime, "retrying");
|
|
1787
|
+
let lease = await claimOne(runtime, "retrying");
|
|
1788
|
+
let previousDelay = 0;
|
|
1789
|
+
for (let attempt = 1;attempt <= 6; attempt++) {
|
|
1790
|
+
await runtime.executions.release({ lease });
|
|
1791
|
+
expect2(await runtime.executions.get("retrying")).toMatchObject({
|
|
1792
|
+
unacknowledgedDeliveryCount: attempt
|
|
1793
|
+
});
|
|
1794
|
+
const releasedAt = clock.now;
|
|
1795
|
+
let next;
|
|
1796
|
+
while ((next = await claim(runtime)).length === 0) {
|
|
1797
|
+
advance(100);
|
|
1798
|
+
if (clock.now - releasedAt > 60 * 60000)
|
|
1799
|
+
throw new Error("never redelivered");
|
|
1800
|
+
}
|
|
1801
|
+
const delay = clock.now - releasedAt;
|
|
1802
|
+
expect2(delay).toBeGreaterThan(0);
|
|
1803
|
+
expect2(delay).toBeGreaterThanOrEqual(previousDelay);
|
|
1804
|
+
previousDelay = delay;
|
|
1805
|
+
lease = next[0];
|
|
1806
|
+
}
|
|
1807
|
+
});
|
|
1808
|
+
it("counts both a release and a later expiry", async ({ runtime, advance }) => {
|
|
1809
|
+
await admit(runtime, "both");
|
|
1810
|
+
const released = await claimOne(runtime, "both");
|
|
1811
|
+
await runtime.executions.release({ lease: released });
|
|
1812
|
+
advance(redeliveryDelay2(1));
|
|
1813
|
+
await claimOne(runtime, "both");
|
|
1814
|
+
advance(LEASE2);
|
|
1815
|
+
expect2(await runtime.executions.get("both")).toMatchObject({
|
|
1816
|
+
unacknowledgedDeliveryCount: 2
|
|
1817
|
+
});
|
|
1818
|
+
advance(redeliveryDelay2(2) - 1);
|
|
1819
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1820
|
+
advance(1);
|
|
1821
|
+
await claimOne(runtime, "both");
|
|
1822
|
+
expect2(await runtime.executions.get("both")).toMatchObject({
|
|
1823
|
+
unacknowledgedDeliveryCount: 2
|
|
1824
|
+
});
|
|
1825
|
+
});
|
|
1826
|
+
it("resets the count on acknowledgement, so the next wake is not delayed", async ({
|
|
1827
|
+
runtime,
|
|
1828
|
+
clock,
|
|
1829
|
+
advance
|
|
1830
|
+
}) => {
|
|
1831
|
+
await admit(runtime, "recovers");
|
|
1832
|
+
for (let i = 1;i <= 3; i++) {
|
|
1833
|
+
const lease = await claimOne(runtime, "recovers");
|
|
1834
|
+
await runtime.executions.release({ lease });
|
|
1835
|
+
advance(redeliveryDelay2(i));
|
|
1836
|
+
}
|
|
1837
|
+
expect2(await runtime.executions.get("recovers")).toMatchObject({
|
|
1838
|
+
unacknowledgedDeliveryCount: 3
|
|
1839
|
+
});
|
|
1840
|
+
const lease = await claimOne(runtime, "recovers");
|
|
1841
|
+
await runtime.executions.acknowledge({ lease, outcome: { status: "suspended" } });
|
|
1842
|
+
expect2(await runtime.executions.get("recovers")).toMatchObject({
|
|
1843
|
+
status: "suspended",
|
|
1844
|
+
unacknowledgedDeliveryCount: 0
|
|
1845
|
+
});
|
|
1846
|
+
await runtime.executions.requestWake({ executionId: "recovers", wakeAt: clock.now });
|
|
1847
|
+
await claimOne(runtime, "recovers");
|
|
1848
|
+
});
|
|
1849
|
+
it("holds wakes requested during the delay until it passes", async ({
|
|
1850
|
+
runtime,
|
|
1851
|
+
clock,
|
|
1852
|
+
advance
|
|
1853
|
+
}) => {
|
|
1854
|
+
await admit(runtime, "held-back");
|
|
1855
|
+
const lease = await claimOne(runtime, "held-back");
|
|
1856
|
+
await runtime.executions.release({ lease });
|
|
1857
|
+
await runtime.executions.requestWake({ executionId: "held-back", wakeAt: clock.now });
|
|
1858
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
1859
|
+
advance(redeliveryDelay2(1));
|
|
1860
|
+
await claimOne(runtime, "held-back");
|
|
1861
|
+
});
|
|
1862
|
+
});
|
|
1863
|
+
describe2("fencing", () => {
|
|
1864
|
+
async function reclaim(ctx, executionId) {
|
|
1865
|
+
const stale = await claimOne(ctx.runtime, executionId);
|
|
1866
|
+
ctx.advance(REDELIVER_AFTER2);
|
|
1867
|
+
const current = await claimOne(ctx.runtime, executionId);
|
|
1868
|
+
expect2(current.token).toBeGreaterThan(stale.token);
|
|
1869
|
+
return { stale, current };
|
|
1870
|
+
}
|
|
1871
|
+
function write(runtime, lease, stepKey, value, options = {}) {
|
|
1872
|
+
return runtime.heap.writeStep({
|
|
1873
|
+
lease,
|
|
1874
|
+
stepKey,
|
|
1875
|
+
stepAttempt: options.attempt ?? 0,
|
|
1876
|
+
done: options.done ?? true,
|
|
1877
|
+
stepResponseJson: JSON.stringify(value)
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
it("rejects a stale holder's heap write after a reclaim and keeps the current records", async (ctx) => {
|
|
1881
|
+
const { runtime } = ctx;
|
|
1882
|
+
await admit(runtime, "stale-write");
|
|
1883
|
+
const { stale, current } = await reclaim(ctx, "stale-write");
|
|
1884
|
+
expect2(await write(runtime, current, "charge", "current")).toMatchObject({
|
|
1885
|
+
stepResponseJson: JSON.stringify("current")
|
|
1886
|
+
});
|
|
1887
|
+
expect2(await write(runtime, stale, "charge", "stale")).toEqual({ status: "lost" });
|
|
1888
|
+
expect2(await write(runtime, stale, "charge", "stale", { attempt: 1 })).toEqual({
|
|
1889
|
+
status: "lost"
|
|
1890
|
+
});
|
|
1891
|
+
expect2(await write(runtime, stale, "other", "stale")).toEqual({ status: "lost" });
|
|
1892
|
+
expect2(await runtime.heap.readStep({ executionId: "stale-write", stepKey: "charge" })).toEqual({
|
|
1893
|
+
stepResponseJson: JSON.stringify("current"),
|
|
1894
|
+
meta: { attempt: 0, done: true }
|
|
1895
|
+
});
|
|
1896
|
+
expect2(await runtime.heap.readStep({ executionId: "stale-write", stepKey: "other" })).toBeNull();
|
|
1897
|
+
});
|
|
1898
|
+
it("rejects a stale holder's acknowledgement after a reclaim and leaves the record unchanged", async (ctx) => {
|
|
1899
|
+
const { runtime } = ctx;
|
|
1900
|
+
await admitSuspended(runtime, "stale-ack");
|
|
1901
|
+
await runtime.executions.requestWake({ executionId: "stale-ack", wakeAt: ctx.clock.now });
|
|
1902
|
+
const { stale, current } = await reclaim(ctx, "stale-ack");
|
|
1903
|
+
const before = await runtime.executions.get("stale-ack");
|
|
1904
|
+
for (const outcome of [
|
|
1905
|
+
{ status: "completed", result: "stale" },
|
|
1906
|
+
{ status: "failed", error: new Error("stale") },
|
|
1907
|
+
{ status: "suspended", wakeAt: ctx.clock.now }
|
|
1908
|
+
]) {
|
|
1909
|
+
expect2(await runtime.executions.acknowledge({ lease: stale, outcome })).toEqual({
|
|
1910
|
+
status: "lost"
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
expect2(await runtime.executions.get("stale-ack")).toEqual(before);
|
|
1914
|
+
expect2(before).toMatchObject({ status: "running" });
|
|
1915
|
+
expect2(await runtime.executions.acknowledge({
|
|
1916
|
+
lease: current,
|
|
1917
|
+
outcome: { status: "completed", result: "current" }
|
|
1918
|
+
})).toEqual({ status: "completed", result: "current" });
|
|
1919
|
+
});
|
|
1920
|
+
it("rejects every fenced write from a holder that pauses past expiry after another claims", async (ctx) => {
|
|
1921
|
+
const { runtime } = ctx;
|
|
1922
|
+
await admit(runtime, "paused");
|
|
1923
|
+
const paused = await claimOne(runtime, "paused");
|
|
1924
|
+
expect2(await write(runtime, paused, "before-pause", 1)).toMatchObject({
|
|
1925
|
+
meta: { attempt: 0, done: true }
|
|
1926
|
+
});
|
|
1927
|
+
ctx.advance(REDELIVER_AFTER2);
|
|
1928
|
+
const peer = await ctx.harness.createPeer?.() ?? runtime;
|
|
1929
|
+
const current = await claimOne(peer, "paused");
|
|
1930
|
+
expect2(await write(runtime, paused, "after-pause", 2)).toEqual({ status: "lost" });
|
|
1931
|
+
expect2(await runtime.executions.renew({ lease: paused, duration: LEASE2 })).toEqual({
|
|
1932
|
+
status: "lost"
|
|
1933
|
+
});
|
|
1934
|
+
expect2(await runtime.executions.release({ lease: paused })).toEqual({ status: "lost" });
|
|
1935
|
+
expect2(await runtime.executions.acknowledge({
|
|
1936
|
+
lease: paused,
|
|
1937
|
+
outcome: { status: "completed", result: "paused" }
|
|
1938
|
+
})).toEqual({ status: "lost" });
|
|
1939
|
+
expect2(await write(peer, current, "after-pause", 3)).toMatchObject({
|
|
1940
|
+
stepResponseJson: "3"
|
|
1941
|
+
});
|
|
1942
|
+
expect2(await peer.executions.get("paused")).toMatchObject({ status: "running" });
|
|
1943
|
+
});
|
|
1944
|
+
it("accepts the current holder's rewrite of an existing step record without changing it", async ({
|
|
1945
|
+
runtime
|
|
1946
|
+
}) => {
|
|
1947
|
+
await admit(runtime, "rewrite");
|
|
1948
|
+
const lease = await claimOne(runtime, "rewrite");
|
|
1949
|
+
const first = await write(runtime, lease, "charge", { receipt: "r-1" });
|
|
1950
|
+
const again = await write(runtime, lease, "charge", { receipt: "r-1" });
|
|
1951
|
+
expect2(again).toEqual(first);
|
|
1952
|
+
expect2(await runtime.heap.readStep({ executionId: "rewrite", stepKey: "charge" })).toEqual(first);
|
|
1953
|
+
});
|
|
1954
|
+
it("stores scalar step values as the exact JSON written, so rewrites match and falsy values replay", async ({
|
|
1955
|
+
runtime
|
|
1956
|
+
}) => {
|
|
1957
|
+
await admit(runtime, "scalars");
|
|
1958
|
+
const lease = await claimOne(runtime, "scalars");
|
|
1959
|
+
for (const [stepKey, value] of [
|
|
1960
|
+
["zero", 0],
|
|
1961
|
+
["one", 1],
|
|
1962
|
+
["float", 1.5],
|
|
1963
|
+
["false", false],
|
|
1964
|
+
["null", null],
|
|
1965
|
+
["numeric-string", "1"]
|
|
1966
|
+
]) {
|
|
1967
|
+
const record = {
|
|
1968
|
+
stepResponseJson: JSON.stringify(value),
|
|
1969
|
+
meta: { attempt: 0, done: true }
|
|
1970
|
+
};
|
|
1971
|
+
expect2(await write(runtime, lease, stepKey, value)).toEqual(record);
|
|
1972
|
+
expect2(await write(runtime, lease, stepKey, value)).toEqual(record);
|
|
1973
|
+
expect2(await runtime.heap.readStep({ executionId: "scalars", stepKey })).toEqual(record);
|
|
1974
|
+
}
|
|
1975
|
+
});
|
|
1976
|
+
it("keeps an existing step record when the current holder writes a different value, and returns the recorded one", async ({
|
|
1977
|
+
runtime
|
|
1978
|
+
}) => {
|
|
1979
|
+
await admit(runtime, "conflict");
|
|
1980
|
+
const lease = await claimOne(runtime, "conflict");
|
|
1981
|
+
await write(runtime, lease, "charge", { receipt: "r-1" });
|
|
1982
|
+
expect2(await write(runtime, lease, "charge", { receipt: "r-2" }, { done: false })).toEqual({
|
|
1983
|
+
stepResponseJson: JSON.stringify({ receipt: "r-1" }),
|
|
1984
|
+
meta: { attempt: 0, done: true }
|
|
1985
|
+
});
|
|
1986
|
+
expect2(await runtime.heap.readStep({ executionId: "conflict", stepKey: "charge" })).toEqual({
|
|
1987
|
+
stepResponseJson: JSON.stringify({ receipt: "r-1" }),
|
|
1988
|
+
meta: { attempt: 0, done: true }
|
|
1989
|
+
});
|
|
1990
|
+
await runtime.executions.release({ lease });
|
|
1991
|
+
expect2(await runtime.heap.readStep({ executionId: "conflict", stepKey: "charge" })).toEqual({
|
|
1992
|
+
stepResponseJson: JSON.stringify({ receipt: "r-1" }),
|
|
1993
|
+
meta: { attempt: 0, done: true }
|
|
1994
|
+
});
|
|
1995
|
+
});
|
|
1996
|
+
it("keeps a waiter when a stale holder's step result would delete it", async (ctx) => {
|
|
1997
|
+
const { runtime } = ctx;
|
|
1998
|
+
await admitSuspended(runtime, "stale-waiter");
|
|
1999
|
+
const snapshot = await runtime.stores.getStore({
|
|
2000
|
+
definition: Store,
|
|
2001
|
+
id: "stale-waiter"
|
|
2002
|
+
});
|
|
2003
|
+
const registered = waiter("stale-waiter", snapshot.instanceId, snapshot.version);
|
|
2004
|
+
await runtime.stores.registerWaiter(registered);
|
|
2005
|
+
await runtime.executions.requestWake({ executionId: "stale-waiter", wakeAt: ctx.clock.now });
|
|
2006
|
+
const { stale, current } = await reclaim(ctx, "stale-waiter");
|
|
2007
|
+
expect2(await write(runtime, stale, registered.stepKey, { id: "m1" }, { attempt: 1 })).toEqual({ status: "lost" });
|
|
2008
|
+
await runtime.executions.acknowledge({ lease: current, outcome: { status: "suspended" } });
|
|
2009
|
+
await appendMessage(runtime, "stale-waiter", "m1");
|
|
2010
|
+
expect2(await claimedIds(runtime)).toEqual(["stale-waiter"]);
|
|
2011
|
+
});
|
|
2012
|
+
it("applies a stale holder's idempotent store update once and returns the recorded result to the current holder", async (ctx) => {
|
|
2013
|
+
const { runtime } = ctx;
|
|
2014
|
+
await admit(runtime, "store-receipt");
|
|
2015
|
+
await runtime.stores.getStore({
|
|
2016
|
+
definition: Store,
|
|
2017
|
+
id: "store-receipt"
|
|
2018
|
+
});
|
|
2019
|
+
const { stale, current } = await reclaim(ctx, "store-receipt");
|
|
2020
|
+
const stepId = { executionId: "store-receipt", stepKey: "append" };
|
|
2021
|
+
const staleResult = await runtime.stores.updateStore({
|
|
2022
|
+
definition: Store,
|
|
2023
|
+
id: "store-receipt",
|
|
2024
|
+
stepId,
|
|
2025
|
+
updater(draft) {
|
|
2026
|
+
draft.messages.push({ id: "stale" });
|
|
2027
|
+
}
|
|
2028
|
+
});
|
|
2029
|
+
const currentResult = await runtime.stores.updateStore({
|
|
2030
|
+
definition: Store,
|
|
2031
|
+
id: "store-receipt",
|
|
2032
|
+
stepId,
|
|
2033
|
+
updater(draft) {
|
|
2034
|
+
draft.messages.push({ id: "current" });
|
|
2035
|
+
}
|
|
2036
|
+
});
|
|
2037
|
+
expect2(currentResult).toEqual(staleResult);
|
|
2038
|
+
expect2(await runtime.stores.getStore({ definition: Store, id: "store-receipt" })).toMatchObject({ state: { messages: [{ id: "stale" }] }, version: 1 });
|
|
2039
|
+
expect2(await write(runtime, stale, "append", staleResult)).toEqual({ status: "lost" });
|
|
2040
|
+
expect2(await write(runtime, current, "append", currentResult)).toMatchObject({
|
|
2041
|
+
meta: { done: true }
|
|
2042
|
+
});
|
|
2043
|
+
});
|
|
2044
|
+
it("lets a stale holder's wake cause at most one extra delivery and leaves the outcome unchanged", async (ctx) => {
|
|
2045
|
+
const { runtime } = ctx;
|
|
2046
|
+
await admit(runtime, "stale-wake");
|
|
2047
|
+
const { current } = await reclaim(ctx, "stale-wake");
|
|
2048
|
+
await runtime.executions.acknowledge({ lease: current, outcome: { status: "suspended" } });
|
|
2049
|
+
await runtime.executions.requestWake({ executionId: "stale-wake", wakeAt: ctx.clock.now });
|
|
2050
|
+
const extra = await claimOne(runtime, "stale-wake");
|
|
2051
|
+
await runtime.executions.acknowledge({ lease: extra, outcome: { status: "suspended" } });
|
|
2052
|
+
ctx.advance(REDELIVER_AFTER2 * 10);
|
|
2053
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
2054
|
+
expect2(await runtime.executions.get("stale-wake")).toMatchObject({
|
|
2055
|
+
status: "suspended",
|
|
2056
|
+
unacknowledgedDeliveryCount: 0
|
|
2057
|
+
});
|
|
2058
|
+
await admit(runtime, "stale-wake-done");
|
|
2059
|
+
const done = await reclaim(ctx, "stale-wake-done");
|
|
2060
|
+
await runtime.executions.acknowledge({
|
|
2061
|
+
lease: done.current,
|
|
2062
|
+
outcome: { status: "completed", result: "ok" }
|
|
2063
|
+
});
|
|
2064
|
+
expect2(await runtime.executions.requestWake({
|
|
2065
|
+
executionId: "stale-wake-done",
|
|
2066
|
+
wakeAt: ctx.clock.now
|
|
2067
|
+
})).toEqual({ status: "ignored" });
|
|
2068
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
2069
|
+
});
|
|
2070
|
+
});
|
|
2071
|
+
describe2("start", () => {
|
|
2072
|
+
it("leaves no trace when a new start fails before it commits", async ({
|
|
2073
|
+
runtime,
|
|
2074
|
+
harness
|
|
2075
|
+
}, testContext) => {
|
|
2076
|
+
if (!harness.failStartCommits) {
|
|
2077
|
+
testContext.skip();
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
await admitSuspended(runtime, "caller");
|
|
2081
|
+
const restore = await harness.failStartCommits();
|
|
2082
|
+
try {
|
|
2083
|
+
await expect2(startFrom(runtime, "caller", "ship")).rejects.toThrow();
|
|
2084
|
+
} finally {
|
|
2085
|
+
await restore();
|
|
2086
|
+
}
|
|
2087
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
2088
|
+
const started = await startFrom(runtime, "caller", "ship");
|
|
2089
|
+
expect2(started.status).toBe("accepted");
|
|
2090
|
+
const targetId = acceptedId(started);
|
|
2091
|
+
expect2(await runtime.executions.get(targetId)).toMatchObject({
|
|
2092
|
+
status: "admitted",
|
|
2093
|
+
parent: { executionId: "caller", stepKey: "ship" }
|
|
2094
|
+
});
|
|
2095
|
+
expect2(await claimedIds(runtime)).toEqual([targetId]);
|
|
2096
|
+
});
|
|
2097
|
+
it("returns the committed target when a start is repeated after a lost response", async ({
|
|
2098
|
+
runtime,
|
|
2099
|
+
harness
|
|
2100
|
+
}) => {
|
|
2101
|
+
await admitSuspended(runtime, "caller");
|
|
2102
|
+
const first = acceptedId(await startFrom(runtime, "caller", "ship"));
|
|
2103
|
+
const peer = await harness.createPeer?.() ?? runtime;
|
|
2104
|
+
expect2(await startFrom(peer, "caller", "ship")).toEqual({
|
|
2105
|
+
status: "accepted",
|
|
2106
|
+
executionId: first
|
|
2107
|
+
});
|
|
2108
|
+
expect2(await startFrom(runtime, "caller", "ship")).toEqual({
|
|
2109
|
+
status: "accepted",
|
|
2110
|
+
executionId: first
|
|
2111
|
+
});
|
|
2112
|
+
expect2(await claimedIds(runtime)).toEqual([first]);
|
|
2113
|
+
});
|
|
2114
|
+
it("admits one target for two concurrent calls with one request ID", async ({
|
|
2115
|
+
runtime,
|
|
2116
|
+
harness
|
|
2117
|
+
}) => {
|
|
2118
|
+
await admitSuspended(runtime, "caller");
|
|
2119
|
+
const peer = await harness.createPeer?.() ?? runtime;
|
|
2120
|
+
const [a, b] = await Promise.all([
|
|
2121
|
+
startFrom(runtime, "caller", "ship"),
|
|
2122
|
+
startFrom(peer, "caller", "ship")
|
|
2123
|
+
]);
|
|
2124
|
+
expect2(a.status).toBe("accepted");
|
|
2125
|
+
expect2(b).toEqual(a);
|
|
2126
|
+
expect2(await claimedIds(runtime)).toEqual([acceptedId(a)]);
|
|
2127
|
+
});
|
|
2128
|
+
it("gives different request IDs different execution IDs", async ({ runtime }) => {
|
|
2129
|
+
await admitSuspended(runtime, "caller-a");
|
|
2130
|
+
await admitSuspended(runtime, "caller-b");
|
|
2131
|
+
const ids = [
|
|
2132
|
+
acceptedId(await startFrom(runtime, "caller-a", "ship")),
|
|
2133
|
+
acceptedId(await startFrom(runtime, "caller-a", "bill")),
|
|
2134
|
+
acceptedId(await startFrom(runtime, "caller-b", "ship"))
|
|
2135
|
+
];
|
|
2136
|
+
expect2(new Set([...ids, "caller-a", "caller-b"]).size).toBe(5);
|
|
2137
|
+
expect2(await claimedIds(runtime)).toEqual([...ids].sort());
|
|
2138
|
+
});
|
|
2139
|
+
it("admits the target due, with the caller as its parent", async ({ runtime, clock }) => {
|
|
2140
|
+
await admitSuspended(runtime, "caller");
|
|
2141
|
+
const targetId = acceptedId(await runtime.executions.start({
|
|
2142
|
+
requestId: { executionId: "caller", stepKey: "ship" },
|
|
2143
|
+
workflowId: "ship-order",
|
|
2144
|
+
params: { orderId: 42, items: [{ sku: "a", qty: 1 }] }
|
|
2145
|
+
}));
|
|
2146
|
+
const target = await runtime.executions.get(targetId);
|
|
2147
|
+
expect2(target).toMatchObject({
|
|
2148
|
+
executionId: targetId,
|
|
2149
|
+
workflowId: "ship-order",
|
|
2150
|
+
params: { orderId: 42, items: [{ sku: "a", qty: 1 }] },
|
|
2151
|
+
status: "admitted",
|
|
2152
|
+
admittedAt: clock.now,
|
|
2153
|
+
parent: { executionId: "caller", stepKey: "ship" }
|
|
2154
|
+
});
|
|
2155
|
+
expect2(await claimedIds(runtime)).toEqual([targetId]);
|
|
2156
|
+
const undefinedParams = acceptedId(await startFrom(runtime, "caller", "ship-undefined", {
|
|
2157
|
+
params: { orderId: 42, note: undefined }
|
|
2158
|
+
}));
|
|
2159
|
+
const withUndefined = (await runtime.executions.get(undefinedParams)).params;
|
|
2160
|
+
expect2(withUndefined).toEqual({ orderId: 42, note: undefined });
|
|
2161
|
+
expect2(Object.keys(withUndefined)).toContain("note");
|
|
2162
|
+
expect2(await runtime.executions.get("caller")).not.toHaveProperty("parent");
|
|
2163
|
+
const bare = acceptedId(await startFrom(runtime, "caller", "bare", { params: undefined }));
|
|
2164
|
+
expect2((await runtime.executions.get(bare)).params).toBeUndefined();
|
|
2165
|
+
});
|
|
2166
|
+
it("admits the target with the caller's context", async ({ runtime }) => {
|
|
2167
|
+
await runtime.executions.admit({
|
|
2168
|
+
executionId: "caller",
|
|
2169
|
+
workflowId: "charge-order",
|
|
2170
|
+
params: undefined,
|
|
2171
|
+
context: new Map([
|
|
2172
|
+
["tenant", "acme"],
|
|
2173
|
+
["7", { roles: ["admin"] }],
|
|
2174
|
+
["nothing", undefined]
|
|
2175
|
+
])
|
|
2176
|
+
});
|
|
2177
|
+
const targetId = acceptedId(await startFrom(runtime, "caller", "ship"));
|
|
2178
|
+
const target = await runtime.executions.get(targetId);
|
|
2179
|
+
expect2([...target.context.entries()]).toEqual([
|
|
2180
|
+
["tenant", "acme"],
|
|
2181
|
+
["7", { roles: ["admin"] }],
|
|
2182
|
+
["nothing", undefined]
|
|
2183
|
+
]);
|
|
2184
|
+
});
|
|
2185
|
+
it("returns the original target when a repeat differs only in context", async ({
|
|
2186
|
+
runtime
|
|
2187
|
+
}) => {
|
|
2188
|
+
await runtime.executions.admit({
|
|
2189
|
+
executionId: "caller",
|
|
2190
|
+
workflowId: "charge-order",
|
|
2191
|
+
params: undefined,
|
|
2192
|
+
context: new Map([["tenant", "acme"]])
|
|
2193
|
+
});
|
|
2194
|
+
const request = {
|
|
2195
|
+
requestId: { executionId: "caller", stepKey: "ship" },
|
|
2196
|
+
workflowId: "ship-order",
|
|
2197
|
+
params: { orderId: 42 }
|
|
2198
|
+
};
|
|
2199
|
+
const first = acceptedId(await runtime.executions.start(request));
|
|
2200
|
+
expect2(await runtime.executions.start({
|
|
2201
|
+
...request,
|
|
2202
|
+
context: new Map([["tenant", "other"]])
|
|
2203
|
+
})).toEqual({ status: "accepted", executionId: first });
|
|
2204
|
+
expect2([...(await runtime.executions.get(first)).context.entries()]).toEqual([["tenant", "acme"]]);
|
|
2205
|
+
});
|
|
2206
|
+
const cyclic = {};
|
|
2207
|
+
cyclic.self = cyclic;
|
|
2208
|
+
const accessor = Object.defineProperty({}, "value", {
|
|
2209
|
+
get: () => 1,
|
|
2210
|
+
enumerable: true
|
|
2211
|
+
});
|
|
2212
|
+
const hidden = Object.defineProperty({}, "value", { value: 1, enumerable: false });
|
|
2213
|
+
const invalidParams = [
|
|
2214
|
+
["a Date", { at: new Date(0) }],
|
|
2215
|
+
["a class instance", { point: new Point(1) }],
|
|
2216
|
+
["NaN", { n: NaN }],
|
|
2217
|
+
["Infinity", { n: Infinity }],
|
|
2218
|
+
["a bigint", { n: 10n }],
|
|
2219
|
+
["a function", { fn: () => 1 }],
|
|
2220
|
+
["a symbol", { s: Symbol("s") }],
|
|
2221
|
+
["a symbol key", { [Symbol("s")]: 1 }],
|
|
2222
|
+
["an accessor", { nested: accessor }],
|
|
2223
|
+
["a non-enumerable property", { nested: hidden }],
|
|
2224
|
+
["a Map", { map: new Map([["a", 1]]) }],
|
|
2225
|
+
["a sparse array", { list: [1, , 3] }],
|
|
2226
|
+
["a cycle", cyclic],
|
|
2227
|
+
["an array as params", [1, 2]],
|
|
2228
|
+
["null as params", null],
|
|
2229
|
+
["a string as params", "params"],
|
|
2230
|
+
["a class instance as params", new Point(1)]
|
|
2231
|
+
];
|
|
2232
|
+
for (const [label, params] of invalidParams) {
|
|
2233
|
+
it(`rejects ${label} with INVALID_START_PARAMETERS and admits nothing`, async ({
|
|
2234
|
+
runtime
|
|
2235
|
+
}) => {
|
|
2236
|
+
await admitSuspended(runtime, "caller");
|
|
2237
|
+
const rejected = await runtime.executions.start({
|
|
2238
|
+
requestId: { executionId: "caller", stepKey: "ship" },
|
|
2239
|
+
workflowId: "ship-order",
|
|
2240
|
+
params
|
|
2241
|
+
});
|
|
2242
|
+
expect2(rejected).toMatchObject({
|
|
2243
|
+
status: "rejected",
|
|
2244
|
+
code: "INVALID_START_PARAMETERS"
|
|
2245
|
+
});
|
|
2246
|
+
expect2(rejected.status === "rejected" && typeof rejected.message).toBe("string");
|
|
2247
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
2248
|
+
expect2((await startFrom(runtime, "caller", "ship")).status).toBe("accepted");
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
it("rejects an empty workflow ID with INVALID_START_PARAMETERS and admits nothing", async ({
|
|
2252
|
+
runtime
|
|
2253
|
+
}) => {
|
|
2254
|
+
await admitSuspended(runtime, "caller");
|
|
2255
|
+
expect2(await startFrom(runtime, "caller", "ship", { workflowId: "" })).toMatchObject({
|
|
2256
|
+
status: "rejected",
|
|
2257
|
+
code: "INVALID_START_PARAMETERS"
|
|
2258
|
+
});
|
|
2259
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
2260
|
+
expect2((await startFrom(runtime, "caller", "ship")).status).toBe("accepted");
|
|
2261
|
+
});
|
|
2262
|
+
it("matches parameters whose keys are in a different order", async ({ runtime }) => {
|
|
2263
|
+
const a = { orderId: 42, address: { line1: "1 High St", city: "Leeds" }, zero: 0 };
|
|
2264
|
+
const b = { zero: -0, address: { city: "Leeds", line1: "1 High St" }, orderId: 42 };
|
|
2265
|
+
const encodedA = durableValueCodec.encode(a);
|
|
2266
|
+
expect2(encodedA.status).toBe("encoded");
|
|
2267
|
+
expect2(durableValueCodec.encode(b)).toEqual(encodedA);
|
|
2268
|
+
await admitSuspended(runtime, "caller");
|
|
2269
|
+
const first = acceptedId(await startFrom(runtime, "caller", "ship", { params: a }));
|
|
2270
|
+
expect2(await startFrom(runtime, "caller", "ship", { params: b })).toEqual({
|
|
2271
|
+
status: "accepted",
|
|
2272
|
+
executionId: first
|
|
2273
|
+
});
|
|
2274
|
+
expect2(await claimedIds(runtime)).toEqual([first]);
|
|
2275
|
+
});
|
|
2276
|
+
it("rejects a repeat that changes the workflow ID or parameters, leaving the original unchanged", async ({
|
|
2277
|
+
runtime,
|
|
2278
|
+
advance
|
|
2279
|
+
}) => {
|
|
2280
|
+
await admitSuspended(runtime, "caller");
|
|
2281
|
+
await admitSuspended(runtime, "caller-without-params");
|
|
2282
|
+
const first = acceptedId(await startFrom(runtime, "caller", "ship", { params: { orderId: 42 } }));
|
|
2283
|
+
const bare = acceptedId(await startFrom(runtime, "caller-without-params", "ship", { params: undefined }));
|
|
2284
|
+
const before = await runtime.executions.get(first);
|
|
2285
|
+
advance(10);
|
|
2286
|
+
const repeats = [
|
|
2287
|
+
["caller", { workflowId: "ship-express", params: { orderId: 42 } }],
|
|
2288
|
+
["caller", { params: { orderId: 43 } }],
|
|
2289
|
+
["caller", { params: { orderId: 42, express: true } }],
|
|
2290
|
+
["caller", { params: undefined }],
|
|
2291
|
+
["caller", { params: { orderId: 42, express: undefined } }],
|
|
2292
|
+
["caller-without-params", { params: {} }]
|
|
2293
|
+
];
|
|
2294
|
+
for (const [caller, target] of repeats) {
|
|
2295
|
+
const mismatch = await startFrom(runtime, caller, "ship", target);
|
|
2296
|
+
expect2(mismatch).toMatchObject({
|
|
2297
|
+
status: "rejected",
|
|
2298
|
+
code: "START_REQUEST_MISMATCH"
|
|
2299
|
+
});
|
|
2300
|
+
expect2(mismatch.status === "rejected" && typeof mismatch.message).toBe("string");
|
|
2301
|
+
}
|
|
2302
|
+
expect2(await runtime.executions.get(first)).toEqual(before);
|
|
2303
|
+
expect2(await claimedIds(runtime)).toEqual([first, bare].sort());
|
|
2304
|
+
expect2(await startFrom(runtime, "caller", "ship", { params: { orderId: 42 } })).toEqual({ status: "accepted", executionId: first });
|
|
2305
|
+
});
|
|
2306
|
+
it("returns the original target after it is terminal, and still rejects a mismatch", async ({
|
|
2307
|
+
runtime
|
|
2308
|
+
}) => {
|
|
2309
|
+
await admitSuspended(runtime, "caller");
|
|
2310
|
+
const first = acceptedId(await startFrom(runtime, "caller", "ship"));
|
|
2311
|
+
const lease = await claimOne(runtime, first);
|
|
2312
|
+
await runtime.executions.acknowledge({
|
|
2313
|
+
lease,
|
|
2314
|
+
outcome: { status: "completed", result: "shipped" }
|
|
2315
|
+
});
|
|
2316
|
+
expect2((await runtime.executions.get(first)).status).toBe("completed");
|
|
2317
|
+
expect2(await startFrom(runtime, "caller", "ship")).toEqual({
|
|
2318
|
+
status: "accepted",
|
|
2319
|
+
executionId: first
|
|
2320
|
+
});
|
|
2321
|
+
expect2(await startFrom(runtime, "caller", "ship", { workflowId: "ship-express" })).toMatchObject({
|
|
2322
|
+
status: "rejected",
|
|
2323
|
+
code: "START_REQUEST_MISMATCH"
|
|
2324
|
+
});
|
|
2325
|
+
expect2(await claim(runtime)).toEqual([]);
|
|
2326
|
+
});
|
|
2327
|
+
itAfterRestart("keeps start requests and parent links across a restart", async ({
|
|
2328
|
+
runtime,
|
|
2329
|
+
restart
|
|
2330
|
+
}) => {
|
|
2331
|
+
await admitSuspended(runtime, "caller");
|
|
2332
|
+
const first = acceptedId(await startFrom(runtime, "caller", "ship"));
|
|
2333
|
+
const record = await runtime.executions.get(first);
|
|
2334
|
+
const restarted = await restart();
|
|
2335
|
+
expect2(await restarted.executions.get(first)).toEqual(record);
|
|
2336
|
+
expect2(record.parent).toEqual({ executionId: "caller", stepKey: "ship" });
|
|
2337
|
+
expect2(await startFrom(restarted, "caller", "ship")).toEqual({
|
|
2338
|
+
status: "accepted",
|
|
2339
|
+
executionId: first
|
|
2340
|
+
});
|
|
2341
|
+
expect2(await startFrom(restarted, "caller", "ship", { workflowId: "ship-express" })).toMatchObject({
|
|
2342
|
+
status: "rejected",
|
|
2343
|
+
code: "START_REQUEST_MISMATCH"
|
|
2344
|
+
});
|
|
2345
|
+
expect2(await claimedIds(restarted)).toEqual([first]);
|
|
2346
|
+
});
|
|
2347
|
+
});
|
|
2348
|
+
describe2("durability", () => {
|
|
2349
|
+
itAfterRestart("preserves admitted and suspended records and their pending wakes across a restart", async ({ runtime, clock, advance, restart }) => {
|
|
2350
|
+
await admit(runtime, "delayed");
|
|
2351
|
+
const delayed = await claimOne(runtime, "delayed");
|
|
2352
|
+
await runtime.executions.acknowledge({
|
|
2353
|
+
lease: delayed,
|
|
2354
|
+
outcome: { status: "suspended", wakeAt: clock.now + 500 }
|
|
2355
|
+
});
|
|
2356
|
+
await admitSuspended(runtime, "woken");
|
|
2357
|
+
await runtime.executions.requestWake({ executionId: "woken", wakeAt: clock.now + 200 });
|
|
2358
|
+
await admitSuspended(runtime, "idle");
|
|
2359
|
+
await admit(runtime, "in-flight");
|
|
2360
|
+
await claimOne(runtime, "in-flight");
|
|
2361
|
+
await admitCompleted(runtime, "finished", { receipt: "r-1" });
|
|
2362
|
+
await runtime.executions.admit({
|
|
2363
|
+
executionId: "admitted",
|
|
2364
|
+
workflowId: "charge-order",
|
|
2365
|
+
params: { orderId: 42 },
|
|
2366
|
+
context: new Map([["tenant", "acme"]])
|
|
2367
|
+
});
|
|
2368
|
+
const records = await Promise.all(["admitted", "delayed", "woken", "idle", "in-flight", "finished"].map((id) => runtime.executions.get(id)));
|
|
2369
|
+
const restarted = await restart();
|
|
2370
|
+
expect2(await Promise.all(["admitted", "delayed", "woken", "idle", "in-flight", "finished"].map((id) => restarted.executions.get(id)))).toEqual(records);
|
|
2371
|
+
expect2([...(await restarted.executions.get("admitted")).context.entries()]).toEqual([["tenant", "acme"]]);
|
|
2372
|
+
expect2(await drain(restarted)).toEqual(["admitted"]);
|
|
2373
|
+
advance(200);
|
|
2374
|
+
expect2(await drain(restarted)).toEqual(["woken"]);
|
|
2375
|
+
advance(300);
|
|
2376
|
+
expect2(await drain(restarted)).toEqual(["delayed"]);
|
|
2377
|
+
advance(REDELIVER_AFTER2 - 500 - 1);
|
|
2378
|
+
expect2(await drain(restarted)).toEqual([]);
|
|
2379
|
+
advance(1);
|
|
2380
|
+
expect2(await drain(restarted)).toEqual(["in-flight"]);
|
|
2381
|
+
expect2(await restarted.executions.admit({
|
|
2382
|
+
executionId: "finished",
|
|
2383
|
+
workflowId: "workflow",
|
|
2384
|
+
params: undefined,
|
|
2385
|
+
context: new Map
|
|
2386
|
+
})).toMatchObject({ status: "rejected", code: "EXECUTION_ID_CONFLICT" });
|
|
2387
|
+
});
|
|
2388
|
+
itAfterRestart("makes a store commit and the wakes it requires durable together", async ({ runtime, restart }) => {
|
|
2389
|
+
await admitSuspended(runtime, "committed");
|
|
2390
|
+
const snapshot = await runtime.stores.getStore({
|
|
2391
|
+
definition: Store,
|
|
2392
|
+
id: "committed"
|
|
2393
|
+
});
|
|
2394
|
+
await runtime.stores.registerWaiter(waiter("committed", snapshot.instanceId, snapshot.version));
|
|
2395
|
+
await appendMessage(runtime, "committed", "m1");
|
|
2396
|
+
const restarted = await restart();
|
|
2397
|
+
expect2(await restarted.stores.getStore({ definition: Store, id: "committed" })).toMatchObject({ state: { messages: [{ id: "m1" }] }, version: 1 });
|
|
2398
|
+
expect2(await claimedIds(restarted)).toEqual(["committed"]);
|
|
2399
|
+
});
|
|
2400
|
+
itAfterRestart("keeps a registered waiter across a restart", async ({ runtime, restart }) => {
|
|
2401
|
+
await admitSuspended(runtime, "durable-waiter");
|
|
2402
|
+
const snapshot = await runtime.stores.getStore({
|
|
2403
|
+
definition: Store,
|
|
2404
|
+
id: "durable-waiter"
|
|
2405
|
+
});
|
|
2406
|
+
await runtime.stores.registerWaiter(waiter("durable-waiter", snapshot.instanceId, snapshot.version));
|
|
2407
|
+
const restarted = await restart();
|
|
2408
|
+
await appendMessage(restarted, "durable-waiter", "m1");
|
|
2409
|
+
expect2(await claimedIds(restarted)).toEqual(["durable-waiter"]);
|
|
2410
|
+
});
|
|
2411
|
+
});
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
// src/index.ts
|
|
2416
|
+
var WORKFLOW_ID = "workflow";
|
|
2417
|
+
var STEP_KEY = "wait";
|
|
2418
|
+
function schema2() {
|
|
2419
|
+
return {
|
|
2420
|
+
"~standard": {
|
|
2421
|
+
version: 1,
|
|
2422
|
+
vendor: "yieldstar-conformance",
|
|
2423
|
+
validate(value) {
|
|
2424
|
+
return { value };
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
};
|
|
2428
|
+
}
|
|
2429
|
+
function waiter(storeName, storeId, instanceId, sinceVersion, readPaths = [["messages"]], executionId = "execution") {
|
|
2430
|
+
return {
|
|
2431
|
+
workflowId: WORKFLOW_ID,
|
|
2432
|
+
executionId,
|
|
2433
|
+
stepKey: STEP_KEY,
|
|
2434
|
+
storeName,
|
|
2435
|
+
storeId,
|
|
2436
|
+
instanceId,
|
|
2437
|
+
sinceVersion,
|
|
2438
|
+
readPaths
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
var seeds = new Map;
|
|
2442
|
+
function seeded(storeName, fallback) {
|
|
2443
|
+
return (id) => structuredClone(seeds.get(JSON.stringify([storeName, id])) ?? fallback);
|
|
2444
|
+
}
|
|
2445
|
+
function seed(definition, id, state) {
|
|
2446
|
+
seeds.set(JSON.stringify([definition.name, id]), state);
|
|
2447
|
+
}
|
|
2448
|
+
async function admitWaiting(runtime, ...executionIds) {
|
|
2449
|
+
for (const executionId of executionIds) {
|
|
2450
|
+
const admitted = await runtime.executions.admit({
|
|
2451
|
+
executionId,
|
|
2452
|
+
workflowId: WORKFLOW_ID,
|
|
2453
|
+
params: undefined,
|
|
2454
|
+
context: new Map
|
|
2455
|
+
});
|
|
2456
|
+
if (admitted.status !== "accepted") {
|
|
2457
|
+
throw new Error(`admission of "${executionId}" was rejected: ${admitted.message}`);
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
const woken = await drainWakes(runtime);
|
|
2461
|
+
expect3(woken).toEqual([...new Set(executionIds)].sort());
|
|
2462
|
+
}
|
|
2463
|
+
async function drainWakes(runtime) {
|
|
2464
|
+
const leases = await claimAll(runtime);
|
|
2465
|
+
for (const lease of leases) {
|
|
2466
|
+
await runtime.executions.acknowledge({ lease, outcome: { status: "suspended" } });
|
|
2467
|
+
}
|
|
2468
|
+
return leases.map((lease) => lease.executionId).sort();
|
|
2469
|
+
}
|
|
2470
|
+
function claimAll(runtime) {
|
|
2471
|
+
return runtime.executions.claimDue({ limit: 1000, duration: DEFAULT_LEASE_DURATION3 });
|
|
2472
|
+
}
|
|
2473
|
+
async function expectWoken(runtime, expected) {
|
|
2474
|
+
const woken = [];
|
|
2475
|
+
const want = [...expected].sort();
|
|
2476
|
+
for (let attempt = 0;attempt < 100; attempt++) {
|
|
2477
|
+
woken.push(...await drainWakes(runtime));
|
|
2478
|
+
if (woken.length >= want.length)
|
|
2479
|
+
break;
|
|
2480
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2481
|
+
}
|
|
2482
|
+
expect3(woken.sort()).toEqual(want);
|
|
2483
|
+
}
|
|
2484
|
+
async function expectNotWoken(runtime) {
|
|
2485
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
2486
|
+
expect3(await drainWakes(runtime)).toEqual([]);
|
|
2487
|
+
}
|
|
2488
|
+
async function recordStep(runtime, executionId, stepKey = STEP_KEY) {
|
|
2489
|
+
await runtime.executions.requestWake({ executionId, wakeAt: runtime.clock() });
|
|
2490
|
+
const leases = await claimAll(runtime);
|
|
2491
|
+
const lease = leases.find((lease) => lease.executionId === executionId);
|
|
2492
|
+
if (!lease)
|
|
2493
|
+
throw new Error(`could not lease "${executionId}" to record a step`);
|
|
2494
|
+
const written = await runtime.heap.writeStep({
|
|
2495
|
+
lease,
|
|
2496
|
+
stepKey,
|
|
2497
|
+
stepAttempt: 1,
|
|
2498
|
+
done: true,
|
|
2499
|
+
stepResponseJson: JSON.stringify({ done: true })
|
|
2500
|
+
});
|
|
2501
|
+
expect3(written).toMatchObject({ meta: { done: true } });
|
|
2502
|
+
for (const claimed of leases) {
|
|
2503
|
+
await runtime.executions.acknowledge({
|
|
2504
|
+
lease: claimed,
|
|
2505
|
+
outcome: { status: "suspended" }
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
function registerStoreClientConformance({
|
|
2510
|
+
name,
|
|
2511
|
+
create
|
|
2512
|
+
}) {
|
|
2513
|
+
const Store = defineStore2(`${name}-store-conformance`, schema2(), {
|
|
2514
|
+
initial: seeded(`${name}-store-conformance`, { messages: [] })
|
|
2515
|
+
});
|
|
2516
|
+
const TakeStore = defineStore2(`${name}-take-conformance`, schema2(), {
|
|
2517
|
+
initial: seeded(`${name}-take-conformance`, { messages: [] })
|
|
2518
|
+
});
|
|
2519
|
+
describe3(`${name} store connector conformance`, () => {
|
|
2520
|
+
test3("creates, reads, lists, deletes, and recreates logical stores", async () => {
|
|
2521
|
+
const harness = await create();
|
|
2522
|
+
const OtherStore = defineStore2(`${name}-other-conformance`, schema2(), {
|
|
2523
|
+
initial: () => ({ messages: [] })
|
|
2524
|
+
});
|
|
2525
|
+
try {
|
|
2526
|
+
const first = await harness.runtime.stores.getStore({
|
|
2527
|
+
definition: Store,
|
|
2528
|
+
id: "b"
|
|
2529
|
+
});
|
|
2530
|
+
const second = await harness.runtime.stores.getStore({
|
|
2531
|
+
definition: Store,
|
|
2532
|
+
id: "a"
|
|
2533
|
+
});
|
|
2534
|
+
await harness.runtime.stores.getStore({
|
|
2535
|
+
definition: OtherStore,
|
|
2536
|
+
id: "a"
|
|
2537
|
+
});
|
|
2538
|
+
expect3(first.instanceId).toMatch(/^[0-9a-f-]+$/);
|
|
2539
|
+
expect3(second.instanceId).not.toBe(first.instanceId);
|
|
2540
|
+
expect3(await harness.runtime.stores.listStores(Store)).toEqual(["a", "b"]);
|
|
2541
|
+
expect3(await harness.runtime.stores.getStore({ definition: Store, id: "b" })).toEqual(first);
|
|
2542
|
+
await harness.runtime.stores.deleteStore({ definition: Store, id: "b" });
|
|
2543
|
+
expect3(await harness.runtime.stores.listStores(Store)).toEqual(["a"]);
|
|
2544
|
+
const recreated = await harness.runtime.stores.getStore({
|
|
2545
|
+
definition: Store,
|
|
2546
|
+
id: "b"
|
|
2547
|
+
});
|
|
2548
|
+
expect3(recreated).toMatchObject({ state: { messages: [] }, version: 0 });
|
|
2549
|
+
expect3(recreated.instanceId).not.toBe(first.instanceId);
|
|
2550
|
+
await harness.runtime.stores.deleteStore({ definition: Store, id: "missing" });
|
|
2551
|
+
expect3(await harness.runtime.stores.listStores(Store)).toEqual(["a", "b"]);
|
|
2552
|
+
} finally {
|
|
2553
|
+
await harness.dispose?.();
|
|
2554
|
+
}
|
|
2555
|
+
});
|
|
2556
|
+
test3("rejects non-durable state with INVALID_STEP_VALUE and leaves the store unchanged", async () => {
|
|
2557
|
+
const harness = await create();
|
|
2558
|
+
const { stores } = harness.runtime;
|
|
2559
|
+
const invalid = { code: "INVALID_STEP_VALUE", name: "WorkflowValueError" };
|
|
2560
|
+
try {
|
|
2561
|
+
seed(Store, "never-created", { messages: [], unrelated: new Date(0) });
|
|
2562
|
+
await expect3(stores.getStore({ definition: Store, id: "never-created" })).rejects.toMatchObject(invalid);
|
|
2563
|
+
await expect3(stores.updateStore({
|
|
2564
|
+
definition: Store,
|
|
2565
|
+
id: "never-created",
|
|
2566
|
+
stepId: { executionId: "never-created", stepKey: "update" },
|
|
2567
|
+
updater() {}
|
|
2568
|
+
})).rejects.toMatchObject({ ...invalid, stepKey: "update" });
|
|
2569
|
+
expect3(await stores.listStores(Store)).toEqual([]);
|
|
2570
|
+
seed(Store, "durable", { messages: [{ id: "one" }] });
|
|
2571
|
+
const created = await stores.getStore({
|
|
2572
|
+
definition: Store,
|
|
2573
|
+
id: "durable"
|
|
2574
|
+
});
|
|
2575
|
+
const stepId = { executionId: "durable-execution", stepKey: "update" };
|
|
2576
|
+
await expect3(stores.updateStore({
|
|
2577
|
+
definition: Store,
|
|
2578
|
+
id: "durable",
|
|
2579
|
+
stepId,
|
|
2580
|
+
updater(draft) {
|
|
2581
|
+
draft.messages.push({ id: "two" });
|
|
2582
|
+
draft.at = new Map;
|
|
2583
|
+
}
|
|
2584
|
+
})).rejects.toMatchObject({ ...invalid, stepKey: "update" });
|
|
2585
|
+
await expect3(stores.updateStoreFrom({
|
|
2586
|
+
definition: Store,
|
|
2587
|
+
id: "durable",
|
|
2588
|
+
snapshot: created,
|
|
2589
|
+
updater: () => ({ messages: [], unrelated: 1n })
|
|
2590
|
+
})).rejects.toMatchObject(invalid);
|
|
2591
|
+
expect3(await stores.getStore({ definition: Store, id: "durable" })).toMatchObject({
|
|
2592
|
+
state: { messages: [{ id: "one" }] },
|
|
2593
|
+
version: 0
|
|
2594
|
+
});
|
|
2595
|
+
seed(TakeStore, "durable-take", { messages: [{ id: "one" }] });
|
|
2596
|
+
await stores.getStore({
|
|
2597
|
+
definition: TakeStore,
|
|
2598
|
+
id: "durable-take"
|
|
2599
|
+
});
|
|
2600
|
+
await expect3(stores.takeFromStore({
|
|
2601
|
+
definition: TakeStore,
|
|
2602
|
+
id: "durable-take",
|
|
2603
|
+
selector: (state) => state.messages.length > 0 && new Set(state.messages),
|
|
2604
|
+
claim(draft) {
|
|
2605
|
+
draft.messages = [];
|
|
2606
|
+
}
|
|
2607
|
+
})).rejects.toMatchObject(invalid);
|
|
2608
|
+
expect3(await stores.getStore({ definition: TakeStore, id: "durable-take" })).toMatchObject({ state: { messages: [{ id: "one" }] }, version: 0 });
|
|
2609
|
+
const retried = await stores.updateStore({
|
|
2610
|
+
definition: Store,
|
|
2611
|
+
id: "durable",
|
|
2612
|
+
stepId,
|
|
2613
|
+
updater(draft) {
|
|
2614
|
+
draft.messages.push({ id: "two" });
|
|
2615
|
+
}
|
|
2616
|
+
});
|
|
2617
|
+
expect3(retried).toMatchObject({ version: 1, state: { messages: [{ id: "one" }, { id: "two" }] } });
|
|
2618
|
+
} finally {
|
|
2619
|
+
await harness.dispose?.();
|
|
2620
|
+
}
|
|
2621
|
+
});
|
|
2622
|
+
test3("stores state and receipts with the durable value codec", async () => {
|
|
2623
|
+
const harness = await create();
|
|
2624
|
+
const { stores } = harness.runtime;
|
|
2625
|
+
try {
|
|
2626
|
+
seed(Store, "codec", { messages: [], unrelated: undefined });
|
|
2627
|
+
await stores.getStore({
|
|
2628
|
+
definition: Store,
|
|
2629
|
+
id: "codec"
|
|
2630
|
+
});
|
|
2631
|
+
const stepId = { executionId: "codec-execution", stepKey: "update" };
|
|
2632
|
+
const update = () => stores.updateStore({
|
|
2633
|
+
definition: Store,
|
|
2634
|
+
id: "codec",
|
|
2635
|
+
stepId,
|
|
2636
|
+
updater(draft) {
|
|
2637
|
+
draft.messages.push({ id: "one", $type: "undefined" });
|
|
2638
|
+
draft.error = new RangeError("r");
|
|
2639
|
+
}
|
|
2640
|
+
});
|
|
2641
|
+
const first = await update();
|
|
2642
|
+
const replayed = await update();
|
|
2643
|
+
const read = await stores.getStore({ definition: Store, id: "codec" });
|
|
2644
|
+
for (const state of [first.state, replayed.state, read.state]) {
|
|
2645
|
+
expect3(Object.keys(state).sort()).toEqual(["error", "messages", "unrelated"]);
|
|
2646
|
+
expect3(state.messages).toEqual([{ id: "one", $type: "undefined" }]);
|
|
2647
|
+
expect3(state.error).toBeInstanceOf(RangeError);
|
|
2648
|
+
}
|
|
2649
|
+
expect3(replayed.version).toBe(1);
|
|
2650
|
+
if (harness.restart) {
|
|
2651
|
+
const restarted = await harness.restart();
|
|
2652
|
+
const reopened = await restarted.stores.getStore({ definition: Store, id: "codec" });
|
|
2653
|
+
expect3(Object.keys(reopened.state).sort()).toEqual(["error", "messages", "unrelated"]);
|
|
2654
|
+
expect3(reopened.state.error).toBeInstanceOf(RangeError);
|
|
2655
|
+
}
|
|
2656
|
+
} finally {
|
|
2657
|
+
await harness.dispose?.();
|
|
2658
|
+
}
|
|
2659
|
+
});
|
|
2660
|
+
test3("rejects asynchronous updaters and claims without committing", async () => {
|
|
2661
|
+
const harness = await create();
|
|
2662
|
+
try {
|
|
2663
|
+
seed(TakeStore, "sync-callbacks", { messages: [{ id: "one" }] });
|
|
2664
|
+
await harness.runtime.stores.getStore({
|
|
2665
|
+
definition: TakeStore,
|
|
2666
|
+
id: "sync-callbacks"
|
|
2667
|
+
});
|
|
2668
|
+
await expect3(harness.runtime.stores.updateStore({
|
|
2669
|
+
definition: TakeStore,
|
|
2670
|
+
id: "sync-callbacks",
|
|
2671
|
+
updater: async (draft) => {
|
|
2672
|
+
draft.messages.push({ id: "two" });
|
|
2673
|
+
}
|
|
2674
|
+
})).rejects.toThrow(/synchronous/);
|
|
2675
|
+
await expect3(harness.runtime.stores.takeFromStore({
|
|
2676
|
+
definition: TakeStore,
|
|
2677
|
+
id: "sync-callbacks",
|
|
2678
|
+
selector: (state) => state.messages[0],
|
|
2679
|
+
claim: async (_draft, selected) => {
|
|
2680
|
+
selected.claimedBy = "worker";
|
|
2681
|
+
}
|
|
2682
|
+
})).rejects.toThrow(/synchronous/);
|
|
2683
|
+
expect3(await harness.runtime.stores.getStore({ definition: TakeStore, id: "sync-callbacks" })).toMatchObject({ state: { messages: [{ id: "one" }] }, version: 0 });
|
|
2684
|
+
} finally {
|
|
2685
|
+
await harness.dispose?.();
|
|
2686
|
+
}
|
|
2687
|
+
});
|
|
2688
|
+
test3("retries an updater after an interleaved CAS conflict", async () => {
|
|
2689
|
+
let validations = 0;
|
|
2690
|
+
let racing = false;
|
|
2691
|
+
let release;
|
|
2692
|
+
const bothPrepared = new Promise((resolve) => {
|
|
2693
|
+
release = resolve;
|
|
2694
|
+
});
|
|
2695
|
+
const raceSchema = {
|
|
2696
|
+
"~standard": {
|
|
2697
|
+
version: 1,
|
|
2698
|
+
vendor: "yieldstar-conformance",
|
|
2699
|
+
async validate(value) {
|
|
2700
|
+
if (!racing)
|
|
2701
|
+
return { value };
|
|
2702
|
+
validations++;
|
|
2703
|
+
if (validations === 2)
|
|
2704
|
+
release();
|
|
2705
|
+
if (validations <= 2)
|
|
2706
|
+
await bothPrepared;
|
|
2707
|
+
return { value };
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
};
|
|
2711
|
+
const RaceStore = defineStore2(`${name}-cas-race-conformance`, raceSchema, {
|
|
2712
|
+
initial: () => ({ messages: [] })
|
|
2713
|
+
});
|
|
2714
|
+
const harness = await create();
|
|
2715
|
+
let updaterRuns = 0;
|
|
2716
|
+
try {
|
|
2717
|
+
await harness.runtime.stores.getStore({
|
|
2718
|
+
definition: RaceStore,
|
|
2719
|
+
id: "race"
|
|
2720
|
+
});
|
|
2721
|
+
racing = true;
|
|
2722
|
+
validations = 0;
|
|
2723
|
+
const results = await Promise.all(["a", "b"].map((id) => harness.runtime.stores.updateStore({
|
|
2724
|
+
definition: RaceStore,
|
|
2725
|
+
id: "race",
|
|
2726
|
+
updater(draft) {
|
|
2727
|
+
updaterRuns++;
|
|
2728
|
+
draft.messages.push(id);
|
|
2729
|
+
}
|
|
2730
|
+
})));
|
|
2731
|
+
expect3(updaterRuns).toBe(3);
|
|
2732
|
+
expect3(results.map((result) => result.version).sort()).toEqual([1, 2]);
|
|
2733
|
+
const final = await harness.runtime.stores.getStore({ definition: RaceStore, id: "race" });
|
|
2734
|
+
expect3(final).toMatchObject({ state: { messages: ["a", "b"] }, version: 2 });
|
|
2735
|
+
} finally {
|
|
2736
|
+
await harness.dispose?.();
|
|
2737
|
+
}
|
|
2738
|
+
});
|
|
2739
|
+
test3("makes a step receipt and mutation exactly-once under concurrent replay", async () => {
|
|
2740
|
+
const harness = await create();
|
|
2741
|
+
let updaterRuns = 0;
|
|
2742
|
+
try {
|
|
2743
|
+
await harness.runtime.stores.getStore({
|
|
2744
|
+
definition: Store,
|
|
2745
|
+
id: "receipt-race"
|
|
2746
|
+
});
|
|
2747
|
+
const results = await Promise.all(["a", "b"].map((id) => harness.runtime.stores.updateStore({
|
|
2748
|
+
definition: Store,
|
|
2749
|
+
id: "receipt-race",
|
|
2750
|
+
stepId: { executionId: "producer", stepKey: "append" },
|
|
2751
|
+
updater(draft) {
|
|
2752
|
+
updaterRuns++;
|
|
2753
|
+
draft.messages.push({ id });
|
|
2754
|
+
}
|
|
2755
|
+
})));
|
|
2756
|
+
expect3(results[1]).toEqual(results[0]);
|
|
2757
|
+
expect3(updaterRuns).toBeGreaterThanOrEqual(1);
|
|
2758
|
+
expect3(updaterRuns).toBeLessThanOrEqual(2);
|
|
2759
|
+
expect3(await harness.runtime.stores.getStore({ definition: Store, id: "receipt-race" })).toMatchObject({ state: { messages: [{ id: results[0].state.messages[0].id }] }, version: 1 });
|
|
2760
|
+
let replayRuns = 0;
|
|
2761
|
+
expect3(await harness.runtime.stores.updateStore({
|
|
2762
|
+
definition: Store,
|
|
2763
|
+
id: "receipt-race",
|
|
2764
|
+
stepId: { executionId: "producer", stepKey: "append" },
|
|
2765
|
+
updater() {
|
|
2766
|
+
replayRuns++;
|
|
2767
|
+
}
|
|
2768
|
+
})).toEqual(results[0]);
|
|
2769
|
+
expect3(replayRuns).toBe(0);
|
|
2770
|
+
await harness.runtime.stores.deleteStore({ definition: Store, id: "receipt-race" });
|
|
2771
|
+
const recreated = await harness.runtime.stores.getStore({
|
|
2772
|
+
definition: Store,
|
|
2773
|
+
id: "receipt-race"
|
|
2774
|
+
});
|
|
2775
|
+
expect3(await harness.runtime.stores.updateStore({
|
|
2776
|
+
definition: Store,
|
|
2777
|
+
id: "receipt-race",
|
|
2778
|
+
stepId: { executionId: "producer", stepKey: "append" },
|
|
2779
|
+
updater() {
|
|
2780
|
+
replayRuns++;
|
|
2781
|
+
}
|
|
2782
|
+
})).toEqual(results[0]);
|
|
2783
|
+
expect3(replayRuns).toBe(0);
|
|
2784
|
+
expect3(await harness.runtime.stores.getStore({ definition: Store, id: "receipt-race" })).toEqual(recreated);
|
|
2785
|
+
} finally {
|
|
2786
|
+
await harness.dispose?.();
|
|
2787
|
+
}
|
|
2788
|
+
});
|
|
2789
|
+
test3("scopes receipts by execution and step key", async () => {
|
|
2790
|
+
const harness = await create();
|
|
2791
|
+
try {
|
|
2792
|
+
await harness.runtime.stores.getStore({
|
|
2793
|
+
definition: Store,
|
|
2794
|
+
id: "receipt-scope"
|
|
2795
|
+
});
|
|
2796
|
+
for (const [executionId, stepKey] of [["one", "a"], ["one", "b"], ["two", "a"]]) {
|
|
2797
|
+
await harness.runtime.stores.updateStore({
|
|
2798
|
+
definition: Store,
|
|
2799
|
+
id: "receipt-scope",
|
|
2800
|
+
stepId: { executionId, stepKey },
|
|
2801
|
+
updater(draft) {
|
|
2802
|
+
draft.messages.push({ id: `${executionId}:${stepKey}` });
|
|
2803
|
+
}
|
|
2804
|
+
});
|
|
2805
|
+
}
|
|
2806
|
+
expect3(await harness.runtime.stores.getStore({ definition: Store, id: "receipt-scope" })).toMatchObject({
|
|
2807
|
+
state: { messages: [{ id: "one:a" }, { id: "one:b" }, { id: "two:a" }] },
|
|
2808
|
+
version: 3
|
|
2809
|
+
});
|
|
2810
|
+
} finally {
|
|
2811
|
+
await harness.dispose?.();
|
|
2812
|
+
}
|
|
2813
|
+
});
|
|
2814
|
+
test3("applies calls without step ids independently and returns detached state", async () => {
|
|
2815
|
+
const harness = await create();
|
|
2816
|
+
try {
|
|
2817
|
+
const initial = await harness.runtime.stores.getStore({
|
|
2818
|
+
definition: Store,
|
|
2819
|
+
id: "external-update"
|
|
2820
|
+
});
|
|
2821
|
+
initial.state.messages.push({ id: "caller-only" });
|
|
2822
|
+
for (const id of ["one", "two"]) {
|
|
2823
|
+
const result = await harness.runtime.stores.updateStore({
|
|
2824
|
+
definition: Store,
|
|
2825
|
+
id: "external-update",
|
|
2826
|
+
updater(draft) {
|
|
2827
|
+
draft.messages.push({ id });
|
|
2828
|
+
}
|
|
2829
|
+
});
|
|
2830
|
+
result.state.messages.push({ id: "result-only" });
|
|
2831
|
+
}
|
|
2832
|
+
expect3(await harness.runtime.stores.getStore({ definition: Store, id: "external-update" })).toMatchObject({ state: { messages: [{ id: "one" }, { id: "two" }] }, version: 2 });
|
|
2833
|
+
} finally {
|
|
2834
|
+
await harness.dispose?.();
|
|
2835
|
+
}
|
|
2836
|
+
});
|
|
2837
|
+
test3("committed state contains no tracking proxies", async () => {
|
|
2838
|
+
const harness = await create();
|
|
2839
|
+
try {
|
|
2840
|
+
seed(Store, "plain-state", { messages: [{ id: "one" }] });
|
|
2841
|
+
await harness.runtime.stores.getStore({
|
|
2842
|
+
definition: Store,
|
|
2843
|
+
id: "plain-state"
|
|
2844
|
+
});
|
|
2845
|
+
await harness.runtime.stores.updateStore({
|
|
2846
|
+
definition: Store,
|
|
2847
|
+
id: "plain-state",
|
|
2848
|
+
updater(draft) {
|
|
2849
|
+
draft.lastMessage = draft.messages[0];
|
|
2850
|
+
draft.wrapped = { inner: draft.messages };
|
|
2851
|
+
}
|
|
2852
|
+
});
|
|
2853
|
+
const snapshot = await harness.runtime.stores.getStore({ definition: Store, id: "plain-state" });
|
|
2854
|
+
expect3(() => structuredClone(snapshot.state)).not.toThrow();
|
|
2855
|
+
expect3(snapshot.state.lastMessage).toEqual({ id: "one" });
|
|
2856
|
+
expect3(snapshot.state.wrapped).toEqual({
|
|
2857
|
+
inner: [{ id: "one" }]
|
|
2858
|
+
});
|
|
2859
|
+
} finally {
|
|
2860
|
+
await harness.dispose?.();
|
|
2861
|
+
}
|
|
2862
|
+
});
|
|
2863
|
+
test3("mutating updates do not deep-diff the full state", async () => {
|
|
2864
|
+
const PerformanceStore = defineStore2(`${name}-bounded-update-conformance`, schema2(), { initial: () => ({ changed: 0, large: {} }) });
|
|
2865
|
+
const harness = await create();
|
|
2866
|
+
const size = 1000;
|
|
2867
|
+
const large = Object.fromEntries(Array.from({ length: size }, (_, index) => [`key-${index}`, index]));
|
|
2868
|
+
const instrument = () => {
|
|
2869
|
+
let reads = 0;
|
|
2870
|
+
const value = new Proxy(large, {
|
|
2871
|
+
get(target, property, receiver) {
|
|
2872
|
+
if (typeof property === "string" && property.startsWith("key-"))
|
|
2873
|
+
reads++;
|
|
2874
|
+
return Reflect.get(target, property, receiver);
|
|
2875
|
+
}
|
|
2876
|
+
});
|
|
2877
|
+
return { value, reads: () => reads };
|
|
2878
|
+
};
|
|
2879
|
+
try {
|
|
2880
|
+
await harness.runtime.stores.getStore({
|
|
2881
|
+
definition: PerformanceStore,
|
|
2882
|
+
id: "mutating"
|
|
2883
|
+
});
|
|
2884
|
+
const mutating = instrument();
|
|
2885
|
+
await harness.runtime.stores.updateStore({
|
|
2886
|
+
definition: PerformanceStore,
|
|
2887
|
+
id: "mutating",
|
|
2888
|
+
updater(draft) {
|
|
2889
|
+
draft.changed = 1;
|
|
2890
|
+
draft.large = mutating.value;
|
|
2891
|
+
}
|
|
2892
|
+
});
|
|
2893
|
+
await harness.runtime.stores.getStore({
|
|
2894
|
+
definition: PerformanceStore,
|
|
2895
|
+
id: "replacement"
|
|
2896
|
+
});
|
|
2897
|
+
const replacement = instrument();
|
|
2898
|
+
await harness.runtime.stores.updateStore({
|
|
2899
|
+
definition: PerformanceStore,
|
|
2900
|
+
id: "replacement",
|
|
2901
|
+
updater: () => ({ changed: 1, large: replacement.value })
|
|
2902
|
+
});
|
|
2903
|
+
expect3(replacement.reads() - mutating.reads()).toBeGreaterThanOrEqual(size);
|
|
2904
|
+
} finally {
|
|
2905
|
+
await harness.dispose?.();
|
|
2906
|
+
}
|
|
2907
|
+
});
|
|
2908
|
+
test3("conditional updates conflict and replay ledger-first", async () => {
|
|
2909
|
+
const harness = await create();
|
|
2910
|
+
let updaterRuns = 0;
|
|
2911
|
+
try {
|
|
2912
|
+
const snapshot = await harness.runtime.stores.getStore({
|
|
2913
|
+
definition: Store,
|
|
2914
|
+
id: "conditional"
|
|
2915
|
+
});
|
|
2916
|
+
const stepId = { executionId: "execution", stepKey: "conditional" };
|
|
2917
|
+
const committed = await harness.runtime.stores.updateStoreFrom({
|
|
2918
|
+
definition: Store,
|
|
2919
|
+
id: "conditional",
|
|
2920
|
+
snapshot,
|
|
2921
|
+
stepId,
|
|
2922
|
+
updater(draft) {
|
|
2923
|
+
updaterRuns++;
|
|
2924
|
+
draft.messages.push({ id: "first" });
|
|
2925
|
+
}
|
|
2926
|
+
});
|
|
2927
|
+
await harness.runtime.stores.updateStore({
|
|
2928
|
+
definition: Store,
|
|
2929
|
+
id: "conditional",
|
|
2930
|
+
updater(draft) {
|
|
2931
|
+
draft.messages.push({ id: "second" });
|
|
2932
|
+
}
|
|
2933
|
+
});
|
|
2934
|
+
expect3(await harness.runtime.stores.updateStoreFrom({
|
|
2935
|
+
definition: Store,
|
|
2936
|
+
id: "conditional",
|
|
2937
|
+
snapshot: { ...snapshot, instanceId: "" },
|
|
2938
|
+
stepId,
|
|
2939
|
+
updater() {
|
|
2940
|
+
updaterRuns++;
|
|
2941
|
+
}
|
|
2942
|
+
})).toEqual(committed);
|
|
2943
|
+
expect3(updaterRuns).toBe(1);
|
|
2944
|
+
expect3(await harness.runtime.stores.updateStoreFrom({
|
|
2945
|
+
definition: Store,
|
|
2946
|
+
id: "conditional",
|
|
2947
|
+
snapshot,
|
|
2948
|
+
updater() {
|
|
2949
|
+
updaterRuns++;
|
|
2950
|
+
}
|
|
2951
|
+
})).toMatchObject({ updated: false, expectedVersion: 0, actualVersion: 2 });
|
|
2952
|
+
expect3(updaterRuns).toBe(1);
|
|
2953
|
+
await expect3(harness.runtime.stores.updateStoreFrom({
|
|
2954
|
+
definition: Store,
|
|
2955
|
+
id: "conditional",
|
|
2956
|
+
snapshot: { ...snapshot, instanceId: "" },
|
|
2957
|
+
updater() {}
|
|
2958
|
+
})).rejects.toThrow(/missing instanceId/);
|
|
2959
|
+
} finally {
|
|
2960
|
+
await harness.dispose?.();
|
|
2961
|
+
}
|
|
2962
|
+
});
|
|
2963
|
+
test3("claims distinct values and records only matched takes", async () => {
|
|
2964
|
+
const harness = await create();
|
|
2965
|
+
let selectorRuns = 0;
|
|
2966
|
+
let claimRuns = 0;
|
|
2967
|
+
try {
|
|
2968
|
+
seed(TakeStore, "take", { messages: [{ id: "one" }, { id: "two" }] });
|
|
2969
|
+
await harness.runtime.stores.getStore({
|
|
2970
|
+
definition: TakeStore,
|
|
2971
|
+
id: "take"
|
|
2972
|
+
});
|
|
2973
|
+
const take = (worker, stepKey) => harness.runtime.stores.takeFromStore({
|
|
2974
|
+
definition: TakeStore,
|
|
2975
|
+
id: "take",
|
|
2976
|
+
stepId: { executionId: "execution", stepKey },
|
|
2977
|
+
selector: (state) => {
|
|
2978
|
+
selectorRuns++;
|
|
2979
|
+
return state.messages.find((message) => !message.claimedBy);
|
|
2980
|
+
},
|
|
2981
|
+
claim: (_draft, selected) => {
|
|
2982
|
+
claimRuns++;
|
|
2983
|
+
selected.claimedBy = worker;
|
|
2984
|
+
}
|
|
2985
|
+
});
|
|
2986
|
+
const [first, second] = await Promise.all([take("a", "a"), take("b", "b")]);
|
|
2987
|
+
if (!first.matched || !second.matched)
|
|
2988
|
+
throw new Error("both takes must match");
|
|
2989
|
+
expect3(first.selected.id).not.toBe(second.selected.id);
|
|
2990
|
+
expect3([first.version, second.version].sort()).toEqual([1, 2]);
|
|
2991
|
+
const selectorRunsAfterCommit = selectorRuns;
|
|
2992
|
+
const claimRunsAfterCommit = claimRuns;
|
|
2993
|
+
expect3(await take("ignored", "a")).toEqual(first);
|
|
2994
|
+
expect3(selectorRuns).toBe(selectorRunsAfterCommit);
|
|
2995
|
+
expect3(claimRuns).toBe(claimRunsAfterCommit);
|
|
2996
|
+
expect3(await harness.runtime.stores.getStore({ definition: TakeStore, id: "take" })).toMatchObject({ version: 2 });
|
|
2997
|
+
const miss = await harness.runtime.stores.takeFromStore({
|
|
2998
|
+
definition: TakeStore,
|
|
2999
|
+
id: "take",
|
|
3000
|
+
stepId: { executionId: "execution", stepKey: "later" },
|
|
3001
|
+
selector: (state) => state.messages.find((message) => !message.claimedBy),
|
|
3002
|
+
claim() {}
|
|
3003
|
+
});
|
|
3004
|
+
if (miss.matched)
|
|
3005
|
+
throw new Error("take must not match");
|
|
3006
|
+
expect3(miss.version).toBe(2);
|
|
3007
|
+
expect3(miss.readPaths).toContainEqual(["messages"]);
|
|
3008
|
+
await harness.runtime.stores.updateStore({
|
|
3009
|
+
definition: TakeStore,
|
|
3010
|
+
id: "take",
|
|
3011
|
+
updater(draft) {
|
|
3012
|
+
draft.messages.push({ id: "three" });
|
|
3013
|
+
}
|
|
3014
|
+
});
|
|
3015
|
+
expect3(await harness.runtime.stores.takeFromStore({
|
|
3016
|
+
definition: TakeStore,
|
|
3017
|
+
id: "take",
|
|
3018
|
+
stepId: { executionId: "execution", stepKey: "later" },
|
|
3019
|
+
selector: (state) => state.messages.find((message) => !message.claimedBy),
|
|
3020
|
+
claim: (_draft, selected) => {
|
|
3021
|
+
selected.claimedBy = "c";
|
|
3022
|
+
}
|
|
3023
|
+
})).toMatchObject({ matched: true });
|
|
3024
|
+
} finally {
|
|
3025
|
+
await harness.dispose?.();
|
|
3026
|
+
}
|
|
3027
|
+
});
|
|
3028
|
+
test3("protects recreated stores during conditional deletion and replays the receipt", async () => {
|
|
3029
|
+
const harness = await create();
|
|
3030
|
+
try {
|
|
3031
|
+
const original = await harness.runtime.stores.getStore({
|
|
3032
|
+
definition: Store,
|
|
3033
|
+
id: "delete"
|
|
3034
|
+
});
|
|
3035
|
+
await harness.runtime.stores.updateStore({
|
|
3036
|
+
definition: Store,
|
|
3037
|
+
id: "delete",
|
|
3038
|
+
updater(draft) {
|
|
3039
|
+
draft.messages.push({ id: "changed" });
|
|
3040
|
+
}
|
|
3041
|
+
});
|
|
3042
|
+
expect3(await harness.runtime.stores.deleteStoreFrom({ definition: Store, id: "delete", snapshot: original })).toMatchObject({ deleted: false, reason: "conflict" });
|
|
3043
|
+
const current = await harness.runtime.stores.getStore({ definition: Store, id: "delete" });
|
|
3044
|
+
const stepId = { executionId: "execution", stepKey: "delete" };
|
|
3045
|
+
expect3(await harness.runtime.stores.deleteStoreFrom({ definition: Store, id: "delete", snapshot: current, stepId })).toEqual({ deleted: true });
|
|
3046
|
+
const recreated = await harness.runtime.stores.getStore({
|
|
3047
|
+
definition: Store,
|
|
3048
|
+
id: "delete"
|
|
3049
|
+
});
|
|
3050
|
+
expect3(recreated.instanceId).not.toBe(original.instanceId);
|
|
3051
|
+
expect3(await harness.runtime.stores.deleteStoreFrom({ definition: Store, id: "delete", snapshot: current, stepId })).toEqual({ deleted: true });
|
|
3052
|
+
expect3(await harness.runtime.stores.getStore({ definition: Store, id: "delete" })).toEqual(recreated);
|
|
3053
|
+
} finally {
|
|
3054
|
+
await harness.dispose?.();
|
|
3055
|
+
}
|
|
3056
|
+
});
|
|
3057
|
+
describe3("missing stores (RFC 006)", () => {
|
|
3058
|
+
function countedStore(suffix, validate) {
|
|
3059
|
+
const evaluations = [];
|
|
3060
|
+
const countedSchema = {
|
|
3061
|
+
"~standard": {
|
|
3062
|
+
version: 1,
|
|
3063
|
+
vendor: "yieldstar-conformance",
|
|
3064
|
+
async validate(value) {
|
|
3065
|
+
await validate?.();
|
|
3066
|
+
return { value };
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
};
|
|
3070
|
+
const definition = defineStore2(`${name}-${suffix}-missing-conformance`, countedSchema, {
|
|
3071
|
+
initial(id) {
|
|
3072
|
+
evaluations.push(id);
|
|
3073
|
+
return { createdBy: `initial-${evaluations.length}`, messages: [] };
|
|
3074
|
+
}
|
|
3075
|
+
});
|
|
3076
|
+
return { definition, evaluations };
|
|
3077
|
+
}
|
|
3078
|
+
test3("every operation on a missing store creates it from initial in the same write", async () => {
|
|
3079
|
+
const harness = await create();
|
|
3080
|
+
const { stores } = harness.runtime;
|
|
3081
|
+
const { definition, evaluations } = countedStore("operations");
|
|
3082
|
+
const stepId = (stepKey) => ({ executionId: "operations", stepKey });
|
|
3083
|
+
try {
|
|
3084
|
+
expect3(await stores.getStore({ definition, id: "get" })).toMatchObject({
|
|
3085
|
+
state: { createdBy: "initial-1", messages: [] },
|
|
3086
|
+
version: 0
|
|
3087
|
+
});
|
|
3088
|
+
const updated = await stores.updateStore({
|
|
3089
|
+
definition,
|
|
3090
|
+
id: "update",
|
|
3091
|
+
stepId: stepId("update"),
|
|
3092
|
+
updater(draft) {
|
|
3093
|
+
draft.messages.push({ id: "one" });
|
|
3094
|
+
}
|
|
3095
|
+
});
|
|
3096
|
+
expect3(updated).toEqual({
|
|
3097
|
+
state: { createdBy: "initial-2", messages: [{ id: "one" }] },
|
|
3098
|
+
previousVersion: 0,
|
|
3099
|
+
version: 1
|
|
3100
|
+
});
|
|
3101
|
+
const taken = await stores.takeFromStore({
|
|
3102
|
+
definition,
|
|
3103
|
+
id: "take",
|
|
3104
|
+
stepId: stepId("take"),
|
|
3105
|
+
selector: (state) => state.createdBy,
|
|
3106
|
+
claim(draft) {
|
|
3107
|
+
draft.messages.push({ id: "claimed" });
|
|
3108
|
+
}
|
|
3109
|
+
});
|
|
3110
|
+
expect3(taken).toMatchObject({ matched: true, selected: "initial-3", version: 1 });
|
|
3111
|
+
const unmatched = await stores.takeFromStore({
|
|
3112
|
+
definition,
|
|
3113
|
+
id: "unmatched-take",
|
|
3114
|
+
selector: (state) => state.messages[0],
|
|
3115
|
+
claim() {}
|
|
3116
|
+
});
|
|
3117
|
+
expect3(unmatched).toMatchObject({ matched: false, version: 0 });
|
|
3118
|
+
const stale = { state: undefined, instanceId: "stale", version: 0 };
|
|
3119
|
+
expect3(await stores.updateStoreFrom({
|
|
3120
|
+
definition,
|
|
3121
|
+
id: "update-from",
|
|
3122
|
+
snapshot: stale,
|
|
3123
|
+
updater() {}
|
|
3124
|
+
})).toMatchObject({ updated: false, expectedInstanceId: "stale", actualVersion: 0 });
|
|
3125
|
+
expect3(await stores.deleteStoreFrom({ definition, id: "delete-from", snapshot: stale })).toMatchObject({ deleted: false, reason: "conflict", actualVersion: 0 });
|
|
3126
|
+
expect3(evaluations).toEqual([
|
|
3127
|
+
"get",
|
|
3128
|
+
"update",
|
|
3129
|
+
"take",
|
|
3130
|
+
"unmatched-take",
|
|
3131
|
+
"update-from",
|
|
3132
|
+
"delete-from"
|
|
3133
|
+
]);
|
|
3134
|
+
expect3(await stores.listStores(definition)).toEqual([
|
|
3135
|
+
"delete-from",
|
|
3136
|
+
"get",
|
|
3137
|
+
"take",
|
|
3138
|
+
"unmatched-take",
|
|
3139
|
+
"update",
|
|
3140
|
+
"update-from"
|
|
3141
|
+
]);
|
|
3142
|
+
expect3(await stores.getStore({ definition, id: "update" })).toMatchObject({
|
|
3143
|
+
state: updated.state,
|
|
3144
|
+
version: 1
|
|
3145
|
+
});
|
|
3146
|
+
expect3(await stores.getStore({ definition, id: "take" })).toMatchObject({
|
|
3147
|
+
state: { createdBy: "initial-3", messages: [{ id: "claimed" }] },
|
|
3148
|
+
instanceId: taken.instanceId,
|
|
3149
|
+
version: 1
|
|
3150
|
+
});
|
|
3151
|
+
expect3((await stores.getStore({ definition, id: "unmatched-take" })).instanceId).toBe(unmatched.instanceId);
|
|
3152
|
+
expect3(evaluations).toHaveLength(6);
|
|
3153
|
+
} finally {
|
|
3154
|
+
await harness.dispose?.();
|
|
3155
|
+
}
|
|
3156
|
+
});
|
|
3157
|
+
test3("evaluates initial only when the store is missing", async () => {
|
|
3158
|
+
const harness = await create();
|
|
3159
|
+
const { stores } = harness.runtime;
|
|
3160
|
+
const { definition, evaluations } = countedStore("existing");
|
|
3161
|
+
try {
|
|
3162
|
+
const created = await stores.getStore({ definition, id: "existing" });
|
|
3163
|
+
await stores.updateStore({
|
|
3164
|
+
definition,
|
|
3165
|
+
id: "existing",
|
|
3166
|
+
updater(draft) {
|
|
3167
|
+
draft.messages.push({ id: "one" });
|
|
3168
|
+
}
|
|
3169
|
+
});
|
|
3170
|
+
const current = await stores.getStore({ definition, id: "existing" });
|
|
3171
|
+
await stores.takeFromStore({
|
|
3172
|
+
definition,
|
|
3173
|
+
id: "existing",
|
|
3174
|
+
selector: (state) => state.messages[0],
|
|
3175
|
+
claim() {}
|
|
3176
|
+
});
|
|
3177
|
+
await stores.updateStoreFrom({ definition, id: "existing", snapshot: created, updater() {} });
|
|
3178
|
+
await stores.deleteStoreFrom({ definition, id: "existing", snapshot: created });
|
|
3179
|
+
expect3(evaluations).toEqual(["existing"]);
|
|
3180
|
+
await stores.deleteStoreFrom({ definition, id: "existing", snapshot: current });
|
|
3181
|
+
await stores.deleteStore({ definition, id: "existing" });
|
|
3182
|
+
await stores.deleteStore({ definition, id: "never-read" });
|
|
3183
|
+
expect3(await stores.listStores(definition)).toEqual([]);
|
|
3184
|
+
expect3(evaluations).toEqual(["existing"]);
|
|
3185
|
+
} finally {
|
|
3186
|
+
await harness.dispose?.();
|
|
3187
|
+
}
|
|
3188
|
+
});
|
|
3189
|
+
test3("a falsy initial state is a stored state, not a missing store", async () => {
|
|
3190
|
+
const harness = await create();
|
|
3191
|
+
const { stores } = harness.runtime;
|
|
3192
|
+
try {
|
|
3193
|
+
for (const [index, falsy] of [0, "", false, null].entries()) {
|
|
3194
|
+
let evaluations = 0;
|
|
3195
|
+
const Falsy = defineStore2(`${name}-falsy-${index}-missing-conformance`, schema2(), {
|
|
3196
|
+
initial() {
|
|
3197
|
+
evaluations += 1;
|
|
3198
|
+
return falsy;
|
|
3199
|
+
}
|
|
3200
|
+
});
|
|
3201
|
+
expect3(await stores.getStore({ definition: Falsy, id: "falsy" })).toMatchObject({
|
|
3202
|
+
state: falsy,
|
|
3203
|
+
version: 0
|
|
3204
|
+
});
|
|
3205
|
+
expect3(await stores.getStore({ definition: Falsy, id: "falsy" })).toMatchObject({
|
|
3206
|
+
state: falsy,
|
|
3207
|
+
version: 0
|
|
3208
|
+
});
|
|
3209
|
+
expect3(evaluations).toBe(1);
|
|
3210
|
+
}
|
|
3211
|
+
} finally {
|
|
3212
|
+
await harness.dispose?.();
|
|
3213
|
+
}
|
|
3214
|
+
});
|
|
3215
|
+
test3("creates nothing when initial or the operation fails", async () => {
|
|
3216
|
+
const harness = await create();
|
|
3217
|
+
const { stores } = harness.runtime;
|
|
3218
|
+
const Throwing = defineStore2(`${name}-throwing-missing-conformance`, schema2(), {
|
|
3219
|
+
initial() {
|
|
3220
|
+
throw new RangeError("no initial state");
|
|
3221
|
+
}
|
|
3222
|
+
});
|
|
3223
|
+
const { definition } = countedStore("failing");
|
|
3224
|
+
try {
|
|
3225
|
+
await expect3(stores.getStore({ definition: Throwing, id: "throws" })).rejects.toThrow(RangeError);
|
|
3226
|
+
await expect3(stores.updateStore({ definition: Throwing, id: "throws", updater() {} })).rejects.toThrow(RangeError);
|
|
3227
|
+
expect3(await stores.listStores(Throwing)).toEqual([]);
|
|
3228
|
+
await expect3(stores.updateStore({
|
|
3229
|
+
definition,
|
|
3230
|
+
id: "updater-throws",
|
|
3231
|
+
updater() {
|
|
3232
|
+
throw new RangeError("no update");
|
|
3233
|
+
}
|
|
3234
|
+
})).rejects.toThrow(RangeError);
|
|
3235
|
+
await expect3(stores.takeFromStore({
|
|
3236
|
+
definition,
|
|
3237
|
+
id: "claim-throws",
|
|
3238
|
+
selector: (state) => state.createdBy,
|
|
3239
|
+
claim() {
|
|
3240
|
+
throw new RangeError("no claim");
|
|
3241
|
+
}
|
|
3242
|
+
})).rejects.toThrow(RangeError);
|
|
3243
|
+
expect3(await stores.listStores(definition)).toEqual([]);
|
|
3244
|
+
} finally {
|
|
3245
|
+
await harness.dispose?.();
|
|
3246
|
+
}
|
|
3247
|
+
});
|
|
3248
|
+
test3("records exactly one initial state when executions update a missing store at once", async () => {
|
|
3249
|
+
let racing = false;
|
|
3250
|
+
let arrived = 0;
|
|
3251
|
+
let release;
|
|
3252
|
+
const bothRead = new Promise((resolve) => {
|
|
3253
|
+
release = resolve;
|
|
3254
|
+
});
|
|
3255
|
+
const { definition, evaluations } = countedStore("concurrent", async () => {
|
|
3256
|
+
if (!racing || ++arrived > 2)
|
|
3257
|
+
return;
|
|
3258
|
+
if (arrived === 2)
|
|
3259
|
+
release();
|
|
3260
|
+
await bothRead;
|
|
3261
|
+
});
|
|
3262
|
+
const harness = await create();
|
|
3263
|
+
const peer = await harness.createPeer?.() ?? harness.runtime;
|
|
3264
|
+
try {
|
|
3265
|
+
for (const id of ["get", "update"]) {
|
|
3266
|
+
racing = true;
|
|
3267
|
+
arrived = 0;
|
|
3268
|
+
if (id === "get") {
|
|
3269
|
+
const [first, second] = await Promise.all([
|
|
3270
|
+
harness.runtime.stores.getStore({ definition, id }),
|
|
3271
|
+
peer.stores.getStore({ definition, id })
|
|
3272
|
+
]);
|
|
3273
|
+
expect3(second).toEqual(first);
|
|
3274
|
+
continue;
|
|
3275
|
+
}
|
|
3276
|
+
const results = await Promise.all([harness.runtime, peer].map((runtime, index) => runtime.stores.updateStore({
|
|
3277
|
+
definition,
|
|
3278
|
+
id,
|
|
3279
|
+
stepId: { executionId: `execution-${index}`, stepKey: "append" },
|
|
3280
|
+
updater(draft) {
|
|
3281
|
+
draft.messages.push({ id: `execution-${index}` });
|
|
3282
|
+
}
|
|
3283
|
+
})));
|
|
3284
|
+
expect3(results.map((result) => result.version).sort()).toEqual([1, 2]);
|
|
3285
|
+
}
|
|
3286
|
+
racing = false;
|
|
3287
|
+
expect3(evaluations).toEqual(["get", "get", "update", "update"]);
|
|
3288
|
+
const got = await harness.runtime.stores.getStore({ definition, id: "get" });
|
|
3289
|
+
expect3(got).toMatchObject({ version: 0, state: { messages: [] } });
|
|
3290
|
+
expect3(["initial-1", "initial-2"]).toContain(got.state.createdBy);
|
|
3291
|
+
const updated = await peer.stores.getStore({ definition, id: "update" });
|
|
3292
|
+
expect3(updated.version).toBe(2);
|
|
3293
|
+
expect3(["initial-3", "initial-4"]).toContain(updated.state.createdBy);
|
|
3294
|
+
expect3(updated.state.messages.map((message) => message.id).sort()).toEqual([
|
|
3295
|
+
"execution-0",
|
|
3296
|
+
"execution-1"
|
|
3297
|
+
]);
|
|
3298
|
+
} finally {
|
|
3299
|
+
await harness.dispose?.();
|
|
3300
|
+
}
|
|
3301
|
+
});
|
|
3302
|
+
test3("replays a committed operation from its receipt without creating the store again", async () => {
|
|
3303
|
+
const harness = await create();
|
|
3304
|
+
const { definition, evaluations } = countedStore("receipt");
|
|
3305
|
+
const stepId = { executionId: "crashed", stepKey: "append" };
|
|
3306
|
+
let updaterRuns = 0;
|
|
3307
|
+
const append = (runtime) => runtime.stores.updateStore({
|
|
3308
|
+
definition,
|
|
3309
|
+
id: "receipt",
|
|
3310
|
+
stepId,
|
|
3311
|
+
updater(draft) {
|
|
3312
|
+
updaterRuns++;
|
|
3313
|
+
draft.messages.push({ id: "once" });
|
|
3314
|
+
}
|
|
3315
|
+
});
|
|
3316
|
+
try {
|
|
3317
|
+
const committed = await append(harness.runtime);
|
|
3318
|
+
const created = await harness.runtime.stores.getStore({ definition, id: "receipt" });
|
|
3319
|
+
const runtime = await harness.restart?.() ?? harness.runtime;
|
|
3320
|
+
expect3(await append(runtime)).toEqual(committed);
|
|
3321
|
+
expect3(await runtime.stores.getStore({ definition, id: "receipt" })).toEqual(created);
|
|
3322
|
+
expect3(created).toMatchObject({ state: { messages: [{ id: "once" }] }, version: 1 });
|
|
3323
|
+
await runtime.stores.deleteStore({ definition, id: "receipt" });
|
|
3324
|
+
expect3(await append(runtime)).toEqual(committed);
|
|
3325
|
+
expect3(await runtime.stores.listStores(definition)).toEqual([]);
|
|
3326
|
+
expect3(updaterRuns).toBe(1);
|
|
3327
|
+
expect3(evaluations).toEqual(["receipt"]);
|
|
3328
|
+
} finally {
|
|
3329
|
+
await harness.dispose?.();
|
|
3330
|
+
}
|
|
3331
|
+
});
|
|
3332
|
+
test3("creates a store deleted between operations again from initial", async () => {
|
|
3333
|
+
const harness = await create();
|
|
3334
|
+
const { definition, evaluations } = countedStore("recreate");
|
|
3335
|
+
const append = (runtime, id) => runtime.stores.updateStore({
|
|
3336
|
+
definition,
|
|
3337
|
+
id: "recreate",
|
|
3338
|
+
stepId: { executionId: "recreate", stepKey: id },
|
|
3339
|
+
updater(draft) {
|
|
3340
|
+
draft.messages.push({ id });
|
|
3341
|
+
}
|
|
3342
|
+
});
|
|
3343
|
+
try {
|
|
3344
|
+
await append(harness.runtime, "first");
|
|
3345
|
+
const original = await harness.runtime.stores.getStore({ definition, id: "recreate" });
|
|
3346
|
+
await harness.runtime.stores.deleteStoreFrom({
|
|
3347
|
+
definition,
|
|
3348
|
+
id: "recreate",
|
|
3349
|
+
snapshot: original,
|
|
3350
|
+
stepId: { executionId: "recreate", stepKey: "delete" }
|
|
3351
|
+
});
|
|
3352
|
+
const runtime = await harness.restart?.() ?? harness.runtime;
|
|
3353
|
+
expect3(await append(runtime, "second")).toEqual({
|
|
3354
|
+
state: { createdBy: "initial-2", messages: [{ id: "second" }] },
|
|
3355
|
+
previousVersion: 0,
|
|
3356
|
+
version: 1
|
|
3357
|
+
});
|
|
3358
|
+
const recreated = await runtime.stores.getStore({ definition, id: "recreate" });
|
|
3359
|
+
expect3(recreated.instanceId).not.toBe(original.instanceId);
|
|
3360
|
+
expect3(evaluations).toEqual(["recreate", "recreate"]);
|
|
3361
|
+
} finally {
|
|
3362
|
+
await harness.dispose?.();
|
|
3363
|
+
}
|
|
3364
|
+
});
|
|
3365
|
+
test3("an unmatched take on a missing store lets its waiter wait", async () => {
|
|
3366
|
+
const harness = await create();
|
|
3367
|
+
const { runtime } = harness;
|
|
3368
|
+
const { definition } = countedStore("waiting-take");
|
|
3369
|
+
try {
|
|
3370
|
+
await admitWaiting(runtime, "taker");
|
|
3371
|
+
const miss = await runtime.stores.takeFromStore({
|
|
3372
|
+
definition,
|
|
3373
|
+
id: "waiting-take",
|
|
3374
|
+
selector: (state) => state.messages[0],
|
|
3375
|
+
claim() {}
|
|
3376
|
+
});
|
|
3377
|
+
if (miss.matched)
|
|
3378
|
+
throw new Error("take must not match");
|
|
3379
|
+
await runtime.stores.registerWaiter(waiter(definition.name, "waiting-take", miss.instanceId, miss.version, miss.readPaths, "taker"));
|
|
3380
|
+
await expectNotWoken(runtime);
|
|
3381
|
+
await runtime.stores.updateStore({
|
|
3382
|
+
definition,
|
|
3383
|
+
id: "waiting-take",
|
|
3384
|
+
updater(draft) {
|
|
3385
|
+
draft.messages.push({ id: "ready" });
|
|
3386
|
+
}
|
|
3387
|
+
});
|
|
3388
|
+
await expectWoken(runtime, ["taker"]);
|
|
3389
|
+
} finally {
|
|
3390
|
+
await harness.dispose?.();
|
|
3391
|
+
}
|
|
3392
|
+
});
|
|
3393
|
+
});
|
|
3394
|
+
test3("wakes an intersecting waiter's execution and keeps the waiter until its step is recorded", async () => {
|
|
3395
|
+
const harness = await create();
|
|
3396
|
+
const { runtime } = harness;
|
|
3397
|
+
try {
|
|
3398
|
+
await admitWaiting(runtime, "execution");
|
|
3399
|
+
const initial = await runtime.stores.getStore({
|
|
3400
|
+
definition: Store,
|
|
3401
|
+
id: "selective-wake"
|
|
3402
|
+
});
|
|
3403
|
+
await runtime.stores.registerWaiter(waiter(Store.name, "selective-wake", initial.instanceId, initial.version));
|
|
3404
|
+
await expectNotWoken(runtime);
|
|
3405
|
+
await runtime.stores.updateStore({
|
|
3406
|
+
definition: Store,
|
|
3407
|
+
id: "selective-wake",
|
|
3408
|
+
updater(draft) {
|
|
3409
|
+
draft.unrelated = 1;
|
|
3410
|
+
}
|
|
3411
|
+
});
|
|
3412
|
+
await expectNotWoken(runtime);
|
|
3413
|
+
await runtime.stores.updateStore({
|
|
3414
|
+
definition: Store,
|
|
3415
|
+
id: "selective-wake",
|
|
3416
|
+
updater(draft) {
|
|
3417
|
+
draft.messages.push({ id: "ready" });
|
|
3418
|
+
}
|
|
3419
|
+
});
|
|
3420
|
+
await expectWoken(runtime, ["execution"]);
|
|
3421
|
+
await runtime.stores.updateStore({
|
|
3422
|
+
definition: Store,
|
|
3423
|
+
id: "selective-wake",
|
|
3424
|
+
updater(draft) {
|
|
3425
|
+
draft.messages.push({ id: "again" });
|
|
3426
|
+
}
|
|
3427
|
+
});
|
|
3428
|
+
await expectWoken(runtime, ["execution"]);
|
|
3429
|
+
await recordStep(runtime, "execution", "some-other-step");
|
|
3430
|
+
await runtime.stores.updateStore({
|
|
3431
|
+
definition: Store,
|
|
3432
|
+
id: "selective-wake",
|
|
3433
|
+
updater(draft) {
|
|
3434
|
+
draft.messages.push({ id: "third" });
|
|
3435
|
+
}
|
|
3436
|
+
});
|
|
3437
|
+
await expectWoken(runtime, ["execution"]);
|
|
3438
|
+
await recordStep(runtime, "execution");
|
|
3439
|
+
await runtime.stores.updateStore({
|
|
3440
|
+
definition: Store,
|
|
3441
|
+
id: "selective-wake",
|
|
3442
|
+
updater(draft) {
|
|
3443
|
+
draft.messages.push({ id: "after-step" });
|
|
3444
|
+
}
|
|
3445
|
+
});
|
|
3446
|
+
await expectNotWoken(runtime);
|
|
3447
|
+
} finally {
|
|
3448
|
+
await harness.dispose?.();
|
|
3449
|
+
}
|
|
3450
|
+
});
|
|
3451
|
+
test3("collapses the wakes of many commits and waiters into one due execution", async () => {
|
|
3452
|
+
const harness = await create();
|
|
3453
|
+
const { runtime } = harness;
|
|
3454
|
+
try {
|
|
3455
|
+
await admitWaiting(runtime, "collapsed", "bystander");
|
|
3456
|
+
for (const id of ["collapse-a", "collapse-b"]) {
|
|
3457
|
+
const snapshot = await runtime.stores.getStore({
|
|
3458
|
+
definition: Store,
|
|
3459
|
+
id
|
|
3460
|
+
});
|
|
3461
|
+
await runtime.stores.registerWaiter({
|
|
3462
|
+
...waiter(Store.name, id, snapshot.instanceId, snapshot.version),
|
|
3463
|
+
executionId: "collapsed",
|
|
3464
|
+
stepKey: `wait-${id}`
|
|
3465
|
+
});
|
|
3466
|
+
}
|
|
3467
|
+
const shared = await runtime.stores.getStore({
|
|
3468
|
+
definition: Store,
|
|
3469
|
+
id: "collapse-shared"
|
|
3470
|
+
});
|
|
3471
|
+
for (const executionId of ["collapsed", "bystander"]) {
|
|
3472
|
+
await runtime.stores.registerWaiter(waiter(Store.name, "collapse-shared", shared.instanceId, shared.version, [["messages"]], executionId));
|
|
3473
|
+
}
|
|
3474
|
+
for (const id of ["collapse-a", "collapse-b", "collapse-a", "collapse-shared"]) {
|
|
3475
|
+
await runtime.stores.updateStore({
|
|
3476
|
+
definition: Store,
|
|
3477
|
+
id,
|
|
3478
|
+
updater(draft) {
|
|
3479
|
+
draft.messages.push({ id: "ready" });
|
|
3480
|
+
}
|
|
3481
|
+
});
|
|
3482
|
+
}
|
|
3483
|
+
await expectWoken(runtime, ["bystander", "collapsed"]);
|
|
3484
|
+
await expectNotWoken(runtime);
|
|
3485
|
+
} finally {
|
|
3486
|
+
await harness.dispose?.();
|
|
3487
|
+
}
|
|
3488
|
+
});
|
|
3489
|
+
test3("ignores wakes for executions without a record or with a terminal outcome", async () => {
|
|
3490
|
+
const harness = await create();
|
|
3491
|
+
const { runtime } = harness;
|
|
3492
|
+
try {
|
|
3493
|
+
await runtime.executions.admit({
|
|
3494
|
+
executionId: "finished",
|
|
3495
|
+
workflowId: WORKFLOW_ID,
|
|
3496
|
+
params: undefined,
|
|
3497
|
+
context: new Map
|
|
3498
|
+
});
|
|
3499
|
+
const [lease] = await claimAll(runtime);
|
|
3500
|
+
await runtime.executions.acknowledge({
|
|
3501
|
+
lease,
|
|
3502
|
+
outcome: { status: "completed", result: "done" }
|
|
3503
|
+
});
|
|
3504
|
+
const initial = await runtime.stores.getStore({
|
|
3505
|
+
definition: Store,
|
|
3506
|
+
id: "orphan-wake"
|
|
3507
|
+
});
|
|
3508
|
+
for (const executionId of ["unknown", "finished"]) {
|
|
3509
|
+
await runtime.stores.registerWaiter(waiter(Store.name, "orphan-wake", initial.instanceId, initial.version, [["messages"]], executionId));
|
|
3510
|
+
}
|
|
3511
|
+
const committed = await runtime.stores.updateStore({
|
|
3512
|
+
definition: Store,
|
|
3513
|
+
id: "orphan-wake",
|
|
3514
|
+
updater(draft) {
|
|
3515
|
+
draft.messages.push({ id: "ready" });
|
|
3516
|
+
}
|
|
3517
|
+
});
|
|
3518
|
+
expect3(committed.version).toBe(1);
|
|
3519
|
+
await expectNotWoken(runtime);
|
|
3520
|
+
expect3(await runtime.executions.get("unknown")).toBeUndefined();
|
|
3521
|
+
expect3(await runtime.executions.get("finished")).toMatchObject({
|
|
3522
|
+
status: "completed",
|
|
3523
|
+
outcome: { status: "completed", result: "done" }
|
|
3524
|
+
});
|
|
3525
|
+
} finally {
|
|
3526
|
+
await harness.dispose?.();
|
|
3527
|
+
}
|
|
3528
|
+
});
|
|
3529
|
+
test3("deleteStore purges the store's waiters", async () => {
|
|
3530
|
+
const harness = await create();
|
|
3531
|
+
const { runtime } = harness;
|
|
3532
|
+
try {
|
|
3533
|
+
await admitWaiting(runtime, "execution");
|
|
3534
|
+
const original = await runtime.stores.getStore({
|
|
3535
|
+
definition: Store,
|
|
3536
|
+
id: "deleted-waiters"
|
|
3537
|
+
});
|
|
3538
|
+
await runtime.stores.registerWaiter(waiter(Store.name, "deleted-waiters", original.instanceId, original.version));
|
|
3539
|
+
await runtime.stores.deleteStore({ definition: Store, id: "deleted-waiters" });
|
|
3540
|
+
const recreated = await runtime.stores.getStore({
|
|
3541
|
+
definition: Store,
|
|
3542
|
+
id: "deleted-waiters"
|
|
3543
|
+
});
|
|
3544
|
+
expect3(recreated.instanceId).not.toBe(original.instanceId);
|
|
3545
|
+
await runtime.stores.updateStore({
|
|
3546
|
+
definition: Store,
|
|
3547
|
+
id: "deleted-waiters",
|
|
3548
|
+
updater(draft) {
|
|
3549
|
+
draft.messages.push({ id: "new-instance" });
|
|
3550
|
+
}
|
|
3551
|
+
});
|
|
3552
|
+
await expectNotWoken(runtime);
|
|
3553
|
+
const current = await runtime.stores.getStore({ definition: Store, id: "deleted-waiters" });
|
|
3554
|
+
await runtime.stores.registerWaiter(waiter(Store.name, "deleted-waiters", current.instanceId, current.version));
|
|
3555
|
+
await runtime.stores.updateStore({
|
|
3556
|
+
definition: Store,
|
|
3557
|
+
id: "deleted-waiters",
|
|
3558
|
+
updater(draft) {
|
|
3559
|
+
draft.messages.push({ id: "again" });
|
|
3560
|
+
}
|
|
3561
|
+
});
|
|
3562
|
+
await expectWoken(runtime, ["execution"]);
|
|
3563
|
+
} finally {
|
|
3564
|
+
await harness.dispose?.();
|
|
3565
|
+
}
|
|
3566
|
+
});
|
|
3567
|
+
test3("conditional deletion purges the store's waiters", async () => {
|
|
3568
|
+
const harness = await create();
|
|
3569
|
+
const { runtime } = harness;
|
|
3570
|
+
try {
|
|
3571
|
+
await admitWaiting(runtime, "execution");
|
|
3572
|
+
const initial = await runtime.stores.getStore({
|
|
3573
|
+
definition: Store,
|
|
3574
|
+
id: "conditional-delete-wake"
|
|
3575
|
+
});
|
|
3576
|
+
await runtime.stores.registerWaiter(waiter(Store.name, "conditional-delete-wake", initial.instanceId, initial.version));
|
|
3577
|
+
await runtime.stores.updateStore({
|
|
3578
|
+
definition: Store,
|
|
3579
|
+
id: "conditional-delete-wake",
|
|
3580
|
+
updater(draft) {
|
|
3581
|
+
draft.messages.push({ id: "old" });
|
|
3582
|
+
}
|
|
3583
|
+
});
|
|
3584
|
+
await expectWoken(runtime, ["execution"]);
|
|
3585
|
+
const current = await runtime.stores.getStore({
|
|
3586
|
+
definition: Store,
|
|
3587
|
+
id: "conditional-delete-wake"
|
|
3588
|
+
});
|
|
3589
|
+
expect3(await runtime.stores.deleteStoreFrom({
|
|
3590
|
+
definition: Store,
|
|
3591
|
+
id: "conditional-delete-wake",
|
|
3592
|
+
snapshot: current
|
|
3593
|
+
})).toEqual({ deleted: true });
|
|
3594
|
+
await runtime.stores.getStore({
|
|
3595
|
+
definition: Store,
|
|
3596
|
+
id: "conditional-delete-wake"
|
|
3597
|
+
});
|
|
3598
|
+
await runtime.stores.updateStore({
|
|
3599
|
+
definition: Store,
|
|
3600
|
+
id: "conditional-delete-wake",
|
|
3601
|
+
updater(draft) {
|
|
3602
|
+
draft.messages.push({ id: "new" });
|
|
3603
|
+
}
|
|
3604
|
+
});
|
|
3605
|
+
await expectNotWoken(runtime);
|
|
3606
|
+
} finally {
|
|
3607
|
+
await harness.dispose?.();
|
|
3608
|
+
}
|
|
3609
|
+
});
|
|
3610
|
+
test3("wakes for replacement, take-claim, and shifted-array paths", async () => {
|
|
3611
|
+
const QueueStore = defineStore2(`${name}-wake-paths-conformance`, schema2(), {
|
|
3612
|
+
initial: seeded(`${name}-wake-paths-conformance`, { messages: [] })
|
|
3613
|
+
});
|
|
3614
|
+
const harness = await create();
|
|
3615
|
+
const { runtime } = harness;
|
|
3616
|
+
try {
|
|
3617
|
+
await admitWaiting(runtime, "replacement", "claim", "splice");
|
|
3618
|
+
const replacement = await runtime.stores.getStore({
|
|
3619
|
+
definition: QueueStore,
|
|
3620
|
+
id: "replacement"
|
|
3621
|
+
});
|
|
3622
|
+
await runtime.stores.registerWaiter(waiter(QueueStore.name, "replacement", replacement.instanceId, replacement.version, [["messages"]], "replacement"));
|
|
3623
|
+
await runtime.stores.updateStore({
|
|
3624
|
+
definition: QueueStore,
|
|
3625
|
+
id: "replacement",
|
|
3626
|
+
updater: () => ({ messages: [{ id: "ready" }] })
|
|
3627
|
+
});
|
|
3628
|
+
await expectWoken(runtime, ["replacement"]);
|
|
3629
|
+
seed(QueueStore, "claim", { messages: [{ id: "one" }] });
|
|
3630
|
+
const claim = await runtime.stores.getStore({
|
|
3631
|
+
definition: QueueStore,
|
|
3632
|
+
id: "claim"
|
|
3633
|
+
});
|
|
3634
|
+
await runtime.stores.registerWaiter(waiter(QueueStore.name, "claim", claim.instanceId, claim.version, [["messages", 0, "claimedBy"]], "claim"));
|
|
3635
|
+
const claimed = await runtime.stores.takeFromStore({
|
|
3636
|
+
definition: QueueStore,
|
|
3637
|
+
id: "claim",
|
|
3638
|
+
selector: (state) => state.messages[0],
|
|
3639
|
+
claim: (_draft, selected) => {
|
|
3640
|
+
selected.claimedBy = "worker";
|
|
3641
|
+
}
|
|
3642
|
+
});
|
|
3643
|
+
if (!claimed.matched)
|
|
3644
|
+
throw new Error("take must match");
|
|
3645
|
+
expect3(claimed.selected).toEqual({ id: "one", claimedBy: "worker" });
|
|
3646
|
+
await expectWoken(runtime, ["claim"]);
|
|
3647
|
+
seed(QueueStore, "splice", { messages: [{ id: "one" }, { id: "two" }] });
|
|
3648
|
+
const splice = await runtime.stores.getStore({
|
|
3649
|
+
definition: QueueStore,
|
|
3650
|
+
id: "splice"
|
|
3651
|
+
});
|
|
3652
|
+
await runtime.stores.registerWaiter(waiter(QueueStore.name, "splice", splice.instanceId, splice.version, [["messages", 0, "id"]], "splice"));
|
|
3653
|
+
await runtime.stores.takeFromStore({
|
|
3654
|
+
definition: QueueStore,
|
|
3655
|
+
id: "splice",
|
|
3656
|
+
selector: (state) => state.messages[0],
|
|
3657
|
+
claim: (draft) => {
|
|
3658
|
+
draft.messages.splice(0, 1);
|
|
3659
|
+
}
|
|
3660
|
+
});
|
|
3661
|
+
await expectWoken(runtime, ["splice"]);
|
|
3662
|
+
expect3(await runtime.stores.getStore({ definition: QueueStore, id: "splice" })).toMatchObject({ state: { messages: [{ id: "two" }] }, version: 1 });
|
|
3663
|
+
} finally {
|
|
3664
|
+
await harness.dispose?.();
|
|
3665
|
+
}
|
|
3666
|
+
});
|
|
3667
|
+
test3("immediately wakes a stale registration", async () => {
|
|
3668
|
+
const harness = await create();
|
|
3669
|
+
const { runtime } = harness;
|
|
3670
|
+
try {
|
|
3671
|
+
await admitWaiting(runtime, "behind", "replaced", "missing");
|
|
3672
|
+
const initial = await runtime.stores.getStore({
|
|
3673
|
+
definition: Store,
|
|
3674
|
+
id: "stale-registration"
|
|
3675
|
+
});
|
|
3676
|
+
await runtime.stores.updateStore({
|
|
3677
|
+
definition: Store,
|
|
3678
|
+
id: "stale-registration",
|
|
3679
|
+
updater(draft) {
|
|
3680
|
+
draft.messages.push({ id: "first" });
|
|
3681
|
+
}
|
|
3682
|
+
});
|
|
3683
|
+
await runtime.stores.registerWaiter(waiter(Store.name, "stale-registration", initial.instanceId, initial.version, [["messages"]], "behind"));
|
|
3684
|
+
await expectWoken(runtime, ["behind"]);
|
|
3685
|
+
const original = await runtime.stores.getStore({
|
|
3686
|
+
definition: Store,
|
|
3687
|
+
id: "stale-instance"
|
|
3688
|
+
});
|
|
3689
|
+
await runtime.stores.deleteStore({ definition: Store, id: "stale-instance" });
|
|
3690
|
+
await runtime.stores.getStore({
|
|
3691
|
+
definition: Store,
|
|
3692
|
+
id: "stale-instance"
|
|
3693
|
+
});
|
|
3694
|
+
await runtime.stores.registerWaiter(waiter(Store.name, "stale-instance", original.instanceId, original.version, [["messages"]], "replaced"));
|
|
3695
|
+
await expectWoken(runtime, ["replaced"]);
|
|
3696
|
+
const gone = await runtime.stores.getStore({
|
|
3697
|
+
definition: Store,
|
|
3698
|
+
id: "stale-missing"
|
|
3699
|
+
});
|
|
3700
|
+
await runtime.stores.deleteStore({ definition: Store, id: "stale-missing" });
|
|
3701
|
+
await runtime.stores.registerWaiter(waiter(Store.name, "stale-missing", gone.instanceId, gone.version, [["messages"]], "missing"));
|
|
3702
|
+
await expectWoken(runtime, ["missing"]);
|
|
3703
|
+
} finally {
|
|
3704
|
+
await harness.dispose?.();
|
|
3705
|
+
}
|
|
3706
|
+
});
|
|
3707
|
+
test3("does not lose a waiter registration racing a commit", async () => {
|
|
3708
|
+
const harness = await create();
|
|
3709
|
+
const { runtime } = harness;
|
|
3710
|
+
const executionIds = Array.from({ length: 10 }, (_, index) => `registration-race-${index}`);
|
|
3711
|
+
try {
|
|
3712
|
+
await admitWaiting(runtime, ...executionIds);
|
|
3713
|
+
for (const [index, id] of executionIds.entries()) {
|
|
3714
|
+
const initial = await runtime.stores.getStore({
|
|
3715
|
+
definition: Store,
|
|
3716
|
+
id
|
|
3717
|
+
});
|
|
3718
|
+
const register = () => runtime.stores.registerWaiter(waiter(Store.name, id, initial.instanceId, initial.version, [["messages"]], id));
|
|
3719
|
+
const update = () => runtime.stores.updateStore({
|
|
3720
|
+
definition: Store,
|
|
3721
|
+
id,
|
|
3722
|
+
updater(draft) {
|
|
3723
|
+
draft.messages.push({ id: "ready" });
|
|
3724
|
+
}
|
|
3725
|
+
});
|
|
3726
|
+
await Promise.all(index % 2 === 0 ? [register(), update()] : [update(), register()]);
|
|
3727
|
+
}
|
|
3728
|
+
await expectWoken(runtime, executionIds);
|
|
3729
|
+
} finally {
|
|
3730
|
+
await harness.dispose?.();
|
|
3731
|
+
}
|
|
3732
|
+
});
|
|
3733
|
+
test3("qualifies declared shared-client capabilities", async () => {
|
|
3734
|
+
const harness = await create();
|
|
3735
|
+
try {
|
|
3736
|
+
if (!harness.createPeer)
|
|
3737
|
+
return;
|
|
3738
|
+
const primary = harness.runtime;
|
|
3739
|
+
const peer = await harness.createPeer();
|
|
3740
|
+
await primary.stores.getStore({
|
|
3741
|
+
definition: Store,
|
|
3742
|
+
id: "shared-client"
|
|
3743
|
+
});
|
|
3744
|
+
const results = await Promise.all([
|
|
3745
|
+
primary.stores.updateStore({
|
|
3746
|
+
definition: Store,
|
|
3747
|
+
id: "shared-client",
|
|
3748
|
+
updater(draft) {
|
|
3749
|
+
draft.messages.push({ id: "a" });
|
|
3750
|
+
}
|
|
3751
|
+
}),
|
|
3752
|
+
peer.stores.updateStore({
|
|
3753
|
+
definition: Store,
|
|
3754
|
+
id: "shared-client",
|
|
3755
|
+
updater(draft) {
|
|
3756
|
+
draft.messages.push({ id: "b" });
|
|
3757
|
+
}
|
|
3758
|
+
})
|
|
3759
|
+
]);
|
|
3760
|
+
expect3(results.map((result) => result.version).sort()).toEqual([1, 2]);
|
|
3761
|
+
await admitWaiting(primary, "shared-wake");
|
|
3762
|
+
const wakeInitial = await peer.stores.getStore({
|
|
3763
|
+
definition: Store,
|
|
3764
|
+
id: "shared-wake"
|
|
3765
|
+
});
|
|
3766
|
+
await peer.stores.registerWaiter(waiter(Store.name, "shared-wake", wakeInitial.instanceId, wakeInitial.version, [["messages"]], "shared-wake"));
|
|
3767
|
+
await primary.stores.updateStore({
|
|
3768
|
+
definition: Store,
|
|
3769
|
+
id: "shared-wake",
|
|
3770
|
+
updater(draft) {
|
|
3771
|
+
draft.messages.push({ id: "ready" });
|
|
3772
|
+
}
|
|
3773
|
+
});
|
|
3774
|
+
await expectWoken(peer, ["shared-wake"]);
|
|
3775
|
+
await recordStep(primary, "shared-wake");
|
|
3776
|
+
await peer.stores.updateStore({
|
|
3777
|
+
definition: Store,
|
|
3778
|
+
id: "shared-wake",
|
|
3779
|
+
updater(draft) {
|
|
3780
|
+
draft.messages.push({ id: "after-step" });
|
|
3781
|
+
}
|
|
3782
|
+
});
|
|
3783
|
+
await expectNotWoken(primary);
|
|
3784
|
+
const registrar = await harness.createPeer();
|
|
3785
|
+
const committer = await harness.createPeer();
|
|
3786
|
+
const executionIds = Array.from({ length: 10 }, (_, index) => `shared-registration-race-${index}`);
|
|
3787
|
+
await admitWaiting(primary, ...executionIds);
|
|
3788
|
+
for (const [index, id] of executionIds.entries()) {
|
|
3789
|
+
const snapshot = await committer.stores.getStore({
|
|
3790
|
+
definition: Store,
|
|
3791
|
+
id
|
|
3792
|
+
});
|
|
3793
|
+
const register = () => registrar.stores.registerWaiter(waiter(Store.name, id, snapshot.instanceId, snapshot.version, [["messages"]], id));
|
|
3794
|
+
const update = () => committer.stores.updateStore({
|
|
3795
|
+
definition: Store,
|
|
3796
|
+
id,
|
|
3797
|
+
updater(draft) {
|
|
3798
|
+
draft.messages.push({ id: "ready" });
|
|
3799
|
+
}
|
|
3800
|
+
});
|
|
3801
|
+
await Promise.all(index % 2 === 0 ? [register(), update()] : [update(), register()]);
|
|
3802
|
+
}
|
|
3803
|
+
await expectWoken(primary, executionIds);
|
|
3804
|
+
} finally {
|
|
3805
|
+
await harness.dispose?.();
|
|
3806
|
+
}
|
|
3807
|
+
});
|
|
3808
|
+
test3("qualifies declared durable-wake capabilities", async () => {
|
|
3809
|
+
const harness = await create();
|
|
3810
|
+
try {
|
|
3811
|
+
if (!harness.restart)
|
|
3812
|
+
return;
|
|
3813
|
+
const { runtime } = harness;
|
|
3814
|
+
await admitWaiting(runtime, "durable-wake", "durable-waiter");
|
|
3815
|
+
for (const executionId of ["durable-wake", "durable-waiter"]) {
|
|
3816
|
+
const initial = await runtime.stores.getStore({
|
|
3817
|
+
definition: Store,
|
|
3818
|
+
id: executionId
|
|
3819
|
+
});
|
|
3820
|
+
await runtime.stores.registerWaiter(waiter(Store.name, executionId, initial.instanceId, initial.version, [["messages"]], executionId));
|
|
3821
|
+
}
|
|
3822
|
+
await runtime.stores.updateStore({
|
|
3823
|
+
definition: Store,
|
|
3824
|
+
id: "durable-wake",
|
|
3825
|
+
updater(draft) {
|
|
3826
|
+
draft.messages.push({ id: "ready" });
|
|
3827
|
+
}
|
|
3828
|
+
});
|
|
3829
|
+
const restarted = await harness.restart();
|
|
3830
|
+
expect3(await restarted.stores.getStore({ definition: Store, id: "durable-wake" })).toMatchObject({ state: { messages: [{ id: "ready" }] }, version: 1 });
|
|
3831
|
+
await expectWoken(restarted, ["durable-wake"]);
|
|
3832
|
+
for (const id of ["durable-wake", "durable-waiter"]) {
|
|
3833
|
+
await restarted.stores.updateStore({
|
|
3834
|
+
definition: Store,
|
|
3835
|
+
id,
|
|
3836
|
+
updater(draft) {
|
|
3837
|
+
draft.messages.push({ id: "after-restart" });
|
|
3838
|
+
}
|
|
3839
|
+
});
|
|
3840
|
+
}
|
|
3841
|
+
await expectWoken(restarted, ["durable-wake", "durable-waiter"]);
|
|
3842
|
+
} finally {
|
|
3843
|
+
await harness.dispose?.();
|
|
3844
|
+
}
|
|
3845
|
+
});
|
|
3846
|
+
});
|
|
3847
|
+
}
|
|
3848
|
+
export {
|
|
3849
|
+
registerEffectClientConformance,
|
|
3850
|
+
registerExecutionClientConformance,
|
|
3851
|
+
registerStoreClientConformance
|
|
3852
|
+
};
|