@redflow/client 0.0.1 → 0.0.2
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 +280 -0
- package/package.json +1 -1
- package/src/client.ts +35 -96
- package/src/internal/keys.ts +1 -2
- package/src/types.ts +28 -36
- package/src/worker.ts +18 -37
- package/src/workflow.ts +12 -7
- package/tests/bugfixes.test.ts +21 -32
- package/tests/fixtures/worker-crash.ts +1 -1
- package/tests/fixtures/worker-recover.ts +1 -1
- package/tests/redflow.e2e.test.ts +136 -640
|
@@ -42,10 +42,7 @@ test("manual run: succeeds and records steps", async () => {
|
|
|
42
42
|
const client = createClient({ redis, prefix });
|
|
43
43
|
setDefaultClient(client);
|
|
44
44
|
|
|
45
|
-
const wf = defineWorkflow(
|
|
46
|
-
{
|
|
47
|
-
name: "manual-success",
|
|
48
|
-
queue: "q1",
|
|
45
|
+
const wf = defineWorkflow("manual-success", {queue: "q1",
|
|
49
46
|
schema: z.object({ n: z.number().int() }),
|
|
50
47
|
},
|
|
51
48
|
async ({ input, step }) => {
|
|
@@ -63,7 +60,7 @@ test("manual run: succeeds and records steps", async () => {
|
|
|
63
60
|
});
|
|
64
61
|
|
|
65
62
|
const handle = await wf.run({ n: 1 });
|
|
66
|
-
const result = await handle.result({ timeoutMs: 3000
|
|
63
|
+
const result = await handle.result({ timeoutMs: 3000 });
|
|
67
64
|
expect(result).toEqual({ ok: true, b: 3 });
|
|
68
65
|
|
|
69
66
|
const steps = await client.getRunSteps(handle.id);
|
|
@@ -74,13 +71,13 @@ test("manual run: succeeds and records steps", async () => {
|
|
|
74
71
|
redis.close();
|
|
75
72
|
});
|
|
76
73
|
|
|
77
|
-
test("
|
|
74
|
+
test("runAt: scheduled -> promoted -> executed", async () => {
|
|
78
75
|
const prefix = testPrefix();
|
|
79
76
|
const redis = new RedisClient(redisServer.url);
|
|
80
77
|
const client = createClient({ redis, prefix });
|
|
81
78
|
setDefaultClient(client);
|
|
82
79
|
|
|
83
|
-
const wf = defineWorkflow(
|
|
80
|
+
const wf = defineWorkflow("delayed", {queue: "q2" }, async ({ step }) => {
|
|
84
81
|
await step.run({ name: "do" }, async () => true);
|
|
85
82
|
return { ok: true };
|
|
86
83
|
});
|
|
@@ -93,11 +90,11 @@ test("availableAt: scheduled -> promoted -> executed", async () => {
|
|
|
93
90
|
});
|
|
94
91
|
|
|
95
92
|
const runAt = new Date(Date.now() + 1200);
|
|
96
|
-
const handle = await wf.run({}, {
|
|
93
|
+
const handle = await wf.run({}, { runAt });
|
|
97
94
|
const state1 = await client.getRun(handle.id);
|
|
98
95
|
expect(state1?.status).toBe("scheduled");
|
|
99
96
|
|
|
100
|
-
const result = await handle.result({ timeoutMs: 4000
|
|
97
|
+
const result = await handle.result({ timeoutMs: 4000 });
|
|
101
98
|
expect(result).toEqual({ ok: true });
|
|
102
99
|
|
|
103
100
|
const state2 = await client.getRun(handle.id);
|
|
@@ -109,16 +106,13 @@ test("availableAt: scheduled -> promoted -> executed", async () => {
|
|
|
109
106
|
redis.close();
|
|
110
107
|
});
|
|
111
108
|
|
|
112
|
-
test("
|
|
109
|
+
test("runAt: cleared after retry exhaustion final failure", async () => {
|
|
113
110
|
const prefix = testPrefix();
|
|
114
111
|
const redis = new RedisClient(redisServer.url);
|
|
115
112
|
const client = createClient({ redis, prefix });
|
|
116
113
|
setDefaultClient(client);
|
|
117
114
|
|
|
118
|
-
const wf = defineWorkflow(
|
|
119
|
-
{
|
|
120
|
-
name: "retry-failure-clears-available-at",
|
|
121
|
-
queue: "q2_retry_fail",
|
|
115
|
+
const wf = defineWorkflow("retry-failure-clears-available-at", {queue: "q2_retry_fail",
|
|
122
116
|
retries: { maxAttempts: 2 },
|
|
123
117
|
},
|
|
124
118
|
async () => {
|
|
@@ -134,7 +128,7 @@ test("availableAt: cleared after retry exhaustion final failure", async () => {
|
|
|
134
128
|
});
|
|
135
129
|
|
|
136
130
|
const handle = await wf.run({});
|
|
137
|
-
await expect(handle.result({ timeoutMs: 8000
|
|
131
|
+
await expect(handle.result({ timeoutMs: 8000 })).rejects.toThrow("Run failed");
|
|
138
132
|
|
|
139
133
|
const state = await client.getRun(handle.id);
|
|
140
134
|
expect(state?.status).toBe("failed");
|
|
@@ -151,10 +145,7 @@ test("NonRetriableError: skips retries and fails immediately on attempt 1", asyn
|
|
|
151
145
|
const client = createClient({ redis, prefix });
|
|
152
146
|
setDefaultClient(client);
|
|
153
147
|
|
|
154
|
-
const wf = defineWorkflow(
|
|
155
|
-
{
|
|
156
|
-
name: "non-retriable-error-wf",
|
|
157
|
-
queue: "q_non_retriable",
|
|
148
|
+
const wf = defineWorkflow("non-retriable-error-wf", {queue: "q_non_retriable",
|
|
158
149
|
retries: { maxAttempts: 3 },
|
|
159
150
|
},
|
|
160
151
|
async () => {
|
|
@@ -170,7 +161,7 @@ test("NonRetriableError: skips retries and fails immediately on attempt 1", asyn
|
|
|
170
161
|
});
|
|
171
162
|
|
|
172
163
|
const handle = await wf.run({});
|
|
173
|
-
await expect(handle.result({ timeoutMs: 8000
|
|
164
|
+
await expect(handle.result({ timeoutMs: 8000 })).rejects.toThrow("Run failed");
|
|
174
165
|
|
|
175
166
|
const state = await client.getRun(handle.id);
|
|
176
167
|
expect(state?.status).toBe("failed");
|
|
@@ -194,10 +185,7 @@ test("onFailure: called after retry exhaustion with correct context", async () =
|
|
|
194
185
|
|
|
195
186
|
let failureCtx: any = null;
|
|
196
187
|
|
|
197
|
-
const wf = defineWorkflow(
|
|
198
|
-
{
|
|
199
|
-
name: "on-failure-retry-exhaustion",
|
|
200
|
-
queue: "q_on_failure_1",
|
|
188
|
+
const wf = defineWorkflow("on-failure-retry-exhaustion", {queue: "q_on_failure_1",
|
|
201
189
|
retries: { maxAttempts: 2 },
|
|
202
190
|
onFailure: (ctx) => {
|
|
203
191
|
failureCtx = ctx;
|
|
@@ -216,7 +204,7 @@ test("onFailure: called after retry exhaustion with correct context", async () =
|
|
|
216
204
|
});
|
|
217
205
|
|
|
218
206
|
const handle = await wf.run({ some: "data" });
|
|
219
|
-
await expect(handle.result({ timeoutMs: 8000
|
|
207
|
+
await expect(handle.result({ timeoutMs: 8000 })).rejects.toThrow("Run failed");
|
|
220
208
|
|
|
221
209
|
expect(failureCtx).not.toBeNull();
|
|
222
210
|
expect(failureCtx.error).toBeInstanceOf(Error);
|
|
@@ -240,10 +228,7 @@ test("onFailure: called immediately with NonRetriableError", async () => {
|
|
|
240
228
|
|
|
241
229
|
let failureCtx: any = null;
|
|
242
230
|
|
|
243
|
-
const wf = defineWorkflow(
|
|
244
|
-
{
|
|
245
|
-
name: "on-failure-non-retriable",
|
|
246
|
-
queue: "q_on_failure_2",
|
|
231
|
+
const wf = defineWorkflow("on-failure-non-retriable", {queue: "q_on_failure_2",
|
|
247
232
|
retries: { maxAttempts: 5 },
|
|
248
233
|
onFailure: (ctx) => {
|
|
249
234
|
failureCtx = ctx;
|
|
@@ -262,7 +247,7 @@ test("onFailure: called immediately with NonRetriableError", async () => {
|
|
|
262
247
|
});
|
|
263
248
|
|
|
264
249
|
const handle = await wf.run({});
|
|
265
|
-
await expect(handle.result({ timeoutMs: 8000
|
|
250
|
+
await expect(handle.result({ timeoutMs: 8000 })).rejects.toThrow("Run failed");
|
|
266
251
|
|
|
267
252
|
expect(failureCtx).not.toBeNull();
|
|
268
253
|
expect(failureCtx.error).toBeInstanceOf(NonRetriableError);
|
|
@@ -280,10 +265,7 @@ test("onFailure: NOT called on cancellation", async () => {
|
|
|
280
265
|
|
|
281
266
|
let onFailureCalled = false;
|
|
282
267
|
|
|
283
|
-
const wf = defineWorkflow(
|
|
284
|
-
{
|
|
285
|
-
name: "on-failure-not-on-cancel",
|
|
286
|
-
queue: "q_on_failure_3",
|
|
268
|
+
const wf = defineWorkflow("on-failure-not-on-cancel", {queue: "q_on_failure_3",
|
|
287
269
|
onFailure: () => {
|
|
288
270
|
onFailureCalled = true;
|
|
289
271
|
},
|
|
@@ -339,11 +321,8 @@ test("cron: creates runs and executes workflow", async () => {
|
|
|
339
321
|
|
|
340
322
|
const counterKey = `${prefix}:t:cronCount`;
|
|
341
323
|
|
|
342
|
-
defineWorkflow(
|
|
343
|
-
|
|
344
|
-
name: "cron-wf",
|
|
345
|
-
queue: "q4",
|
|
346
|
-
triggers: { cron: [{ expression: "*/1 * * * * *", input: { x: 1 } }] },
|
|
324
|
+
defineWorkflow("cron-wf", {queue: "q4",
|
|
325
|
+
cron: [{ expression: "*/1 * * * * *", input: { x: 1 } }],
|
|
347
326
|
},
|
|
348
327
|
async ({ step }) => {
|
|
349
328
|
await step.run({ name: "tick" }, async () => {
|
|
@@ -394,12 +373,12 @@ test("step.runWorkflow: auto idempotency and override are forwarded to child run
|
|
|
394
373
|
return await originalRunByName.call(this, workflowName, input, options);
|
|
395
374
|
}) as RedflowClient["runByName"];
|
|
396
375
|
|
|
397
|
-
const child = defineWorkflow(
|
|
376
|
+
const child = defineWorkflow("child-rw-forward", {queue: "q_rw_child" }, async ({ input }) => input);
|
|
398
377
|
|
|
399
378
|
const encodePart = (value: string) => `${value.length}:${value}`;
|
|
400
379
|
let expectedAutoIdempotencyKey = "";
|
|
401
380
|
|
|
402
|
-
const parent = defineWorkflow(
|
|
381
|
+
const parent = defineWorkflow("parent-rw-forward", {queue: "q_rw_parent" }, async ({ run, step }) => {
|
|
403
382
|
expectedAutoIdempotencyKey =
|
|
404
383
|
`stepwf:${encodePart(run.id)}:${encodePart("child-auto")}:${encodePart(child.name)}`;
|
|
405
384
|
|
|
@@ -419,7 +398,7 @@ test("step.runWorkflow: auto idempotency and override are forwarded to child run
|
|
|
419
398
|
|
|
420
399
|
try {
|
|
421
400
|
const handle = await parent.run({});
|
|
422
|
-
const out = await handle.result({ timeoutMs: 5000
|
|
401
|
+
const out = await handle.result({ timeoutMs: 5000 });
|
|
423
402
|
expect(out).toEqual({
|
|
424
403
|
auto: { n: 1 },
|
|
425
404
|
custom: { n: 2 },
|
|
@@ -444,7 +423,7 @@ test("step.runWorkflow: child workflow executes once across parent retries", asy
|
|
|
444
423
|
const childCountKey = `${prefix}:t:child-runWorkflow-count`;
|
|
445
424
|
const failOnceKey = `${prefix}:t:parent-runWorkflow-fail-once`;
|
|
446
425
|
|
|
447
|
-
const child = defineWorkflow(
|
|
426
|
+
const child = defineWorkflow("child-rw-retry", {queue: "q_rw_retry_child" }, async ({ step }) => {
|
|
448
427
|
await step.run({ name: "count-child" }, async () => {
|
|
449
428
|
await redis.incr(childCountKey);
|
|
450
429
|
return true;
|
|
@@ -453,10 +432,7 @@ test("step.runWorkflow: child workflow executes once across parent retries", asy
|
|
|
453
432
|
return { ok: true };
|
|
454
433
|
});
|
|
455
434
|
|
|
456
|
-
const parent = defineWorkflow(
|
|
457
|
-
{
|
|
458
|
-
name: "parent-rw-retry",
|
|
459
|
-
queue: "q_rw_retry_parent",
|
|
435
|
+
const parent = defineWorkflow("parent-rw-retry", {queue: "q_rw_retry_parent",
|
|
460
436
|
retries: { maxAttempts: 2 },
|
|
461
437
|
},
|
|
462
438
|
async ({ step }) => {
|
|
@@ -484,7 +460,7 @@ test("step.runWorkflow: child workflow executes once across parent retries", asy
|
|
|
484
460
|
});
|
|
485
461
|
|
|
486
462
|
const handle = await parent.run({});
|
|
487
|
-
const out = await handle.result({ timeoutMs: 8000
|
|
463
|
+
const out = await handle.result({ timeoutMs: 8000 });
|
|
488
464
|
expect(out).toEqual({ ok: true });
|
|
489
465
|
|
|
490
466
|
const childCount = Number((await redis.get(childCountKey)) ?? "0");
|
|
@@ -510,10 +486,7 @@ test("step.runWorkflow: same queue avoids self-deadlock with concurrency 1", asy
|
|
|
510
486
|
const childFailOnceKey = `${prefix}:t:child-rw-fail-once`;
|
|
511
487
|
const childCountKey = `${prefix}:t:child-rw-count`;
|
|
512
488
|
|
|
513
|
-
const child = defineWorkflow(
|
|
514
|
-
{
|
|
515
|
-
name: "child-rw-single-worker",
|
|
516
|
-
queue: "q_rw_single",
|
|
489
|
+
const child = defineWorkflow("child-rw-single-worker", {queue: "q_rw_single",
|
|
517
490
|
retries: { maxAttempts: 2 },
|
|
518
491
|
},
|
|
519
492
|
async ({ step }) => {
|
|
@@ -531,7 +504,7 @@ test("step.runWorkflow: same queue avoids self-deadlock with concurrency 1", asy
|
|
|
531
504
|
},
|
|
532
505
|
);
|
|
533
506
|
|
|
534
|
-
const parent = defineWorkflow(
|
|
507
|
+
const parent = defineWorkflow("parent-rw-single-worker", {queue: "q_rw_single" }, async ({ step }) => {
|
|
535
508
|
return await step.runWorkflow({ name: "call-child" }, child, {});
|
|
536
509
|
});
|
|
537
510
|
|
|
@@ -544,7 +517,7 @@ test("step.runWorkflow: same queue avoids self-deadlock with concurrency 1", asy
|
|
|
544
517
|
});
|
|
545
518
|
|
|
546
519
|
const handle = await parent.run({});
|
|
547
|
-
const out = await handle.result({ timeoutMs: 10_000
|
|
520
|
+
const out = await handle.result({ timeoutMs: 10_000 });
|
|
548
521
|
expect(out).toEqual({ ok: true });
|
|
549
522
|
|
|
550
523
|
const childCount = Number((await redis.get(childCountKey)) ?? "0");
|
|
@@ -559,241 +532,7 @@ test("step.runWorkflow: same queue avoids self-deadlock with concurrency 1", asy
|
|
|
559
532
|
redis.close();
|
|
560
533
|
}, { timeout: 20_000 });
|
|
561
534
|
|
|
562
|
-
test("step.emitEvent: auto idempotency and override are forwarded", async () => {
|
|
563
|
-
const prefix = testPrefix();
|
|
564
|
-
const redis = new RedisClient(redisServer.url);
|
|
565
|
-
const client = createClient({ redis, prefix });
|
|
566
|
-
setDefaultClient(client);
|
|
567
|
-
|
|
568
|
-
const observedIdempotencyKeys: string[] = [];
|
|
569
|
-
const originalEmitEvent: RedflowClient["emitEvent"] = RedflowClient.prototype.emitEvent;
|
|
570
|
-
|
|
571
|
-
RedflowClient.prototype.emitEvent = (async function (
|
|
572
|
-
this: RedflowClient,
|
|
573
|
-
name: string,
|
|
574
|
-
payload: unknown,
|
|
575
|
-
options?: Parameters<RedflowClient["emitEvent"]>[2],
|
|
576
|
-
): Promise<Awaited<ReturnType<RedflowClient["emitEvent"]>>> {
|
|
577
|
-
if (name === "step.emit.forward" || name === "step.emit.forward.custom") {
|
|
578
|
-
observedIdempotencyKeys.push(options?.idempotencyKey ?? "");
|
|
579
|
-
}
|
|
580
|
-
return await originalEmitEvent.call(this, name, payload, options);
|
|
581
|
-
}) as RedflowClient["emitEvent"];
|
|
582
|
-
|
|
583
|
-
defineWorkflow(
|
|
584
|
-
{ name: "step-emit-consumer-a", queue: "q_emit_a", triggers: { events: ["step.emit.forward"] } },
|
|
585
|
-
async () => ({ ok: true }),
|
|
586
|
-
);
|
|
587
|
-
|
|
588
|
-
defineWorkflow(
|
|
589
|
-
{ name: "step-emit-consumer-b", queue: "q_emit_b", triggers: { events: ["step.emit.forward.custom"] } },
|
|
590
|
-
async () => ({ ok: true }),
|
|
591
|
-
);
|
|
592
|
-
|
|
593
|
-
const encodePart = (value: string) => `${value.length}:${value}`;
|
|
594
|
-
let expectedAutoIdempotencyKey = "";
|
|
595
|
-
|
|
596
|
-
const parent = defineWorkflow({ name: "step-emit-parent-forward", queue: "q_emit_parent" }, async ({ run, step }) => {
|
|
597
|
-
expectedAutoIdempotencyKey =
|
|
598
|
-
`stepev:${encodePart(run.id)}:${encodePart("emit-auto")}:${encodePart("step.emit.forward")}`;
|
|
599
|
-
|
|
600
|
-
const auto = await step.emitEvent({ name: "emit-auto", event: "step.emit.forward" }, { ok: 1 });
|
|
601
|
-
const custom = await step.emitEvent(
|
|
602
|
-
{ name: "emit-custom", event: "step.emit.forward.custom", idempotencyKey: "evt-custom" },
|
|
603
|
-
{ ok: 2 },
|
|
604
|
-
);
|
|
605
|
-
|
|
606
|
-
return {
|
|
607
|
-
autoCount: auto.length,
|
|
608
|
-
customCount: custom.length,
|
|
609
|
-
};
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
const worker = await startWorker({
|
|
613
|
-
redis,
|
|
614
|
-
prefix,
|
|
615
|
-
queues: ["q_emit_parent", "q_emit_a", "q_emit_b"],
|
|
616
|
-
concurrency: 2,
|
|
617
|
-
runtime: { leaseMs: 500 },
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
try {
|
|
621
|
-
const handle = await parent.run({});
|
|
622
|
-
const out = await handle.result({ timeoutMs: 5000, pollMs: 50 });
|
|
623
|
-
expect(out).toEqual({ autoCount: 1, customCount: 1 });
|
|
624
|
-
|
|
625
|
-
expect(observedIdempotencyKeys.length).toBe(2);
|
|
626
|
-
expect(observedIdempotencyKeys[0]).toBe(expectedAutoIdempotencyKey);
|
|
627
|
-
expect(observedIdempotencyKeys[1]).toBe("evt-custom");
|
|
628
|
-
} finally {
|
|
629
|
-
RedflowClient.prototype.emitEvent = originalEmitEvent;
|
|
630
|
-
await worker.stop();
|
|
631
|
-
redis.close();
|
|
632
|
-
}
|
|
633
|
-
}, { timeout: 15_000 });
|
|
634
|
-
|
|
635
|
-
test("step.emitEvent: emitted workflows execute once across parent retries", async () => {
|
|
636
|
-
const prefix = testPrefix();
|
|
637
|
-
const redis = new RedisClient(redisServer.url);
|
|
638
|
-
const client = createClient({ redis, prefix });
|
|
639
|
-
setDefaultClient(client);
|
|
640
|
-
|
|
641
|
-
const counterKey = `${prefix}:t:step-emit-once`;
|
|
642
|
-
const failOnceKey = `${prefix}:t:step-emit-parent-fail`;
|
|
643
|
-
|
|
644
|
-
defineWorkflow(
|
|
645
|
-
{
|
|
646
|
-
name: "step-emit-consumer-once",
|
|
647
|
-
queue: "q_emit_retry_consumer",
|
|
648
|
-
triggers: { events: ["step.emit.once"] },
|
|
649
|
-
},
|
|
650
|
-
async ({ step }) => {
|
|
651
|
-
await step.run({ name: "count" }, async () => {
|
|
652
|
-
await redis.incr(counterKey);
|
|
653
|
-
return true;
|
|
654
|
-
});
|
|
655
|
-
return { ok: true };
|
|
656
|
-
},
|
|
657
|
-
);
|
|
658
|
-
|
|
659
|
-
const parent = defineWorkflow(
|
|
660
|
-
{
|
|
661
|
-
name: "step-emit-parent-retry",
|
|
662
|
-
queue: "q_emit_retry_parent",
|
|
663
|
-
retries: { maxAttempts: 2 },
|
|
664
|
-
},
|
|
665
|
-
async ({ step }) => {
|
|
666
|
-
const runIds = await step.emitEvent({ name: "emit-once", event: "step.emit.once" }, { v: 1 });
|
|
667
|
-
|
|
668
|
-
await step.run({ name: "fail-once" }, async () => {
|
|
669
|
-
const seen = await redis.get(failOnceKey);
|
|
670
|
-
if (!seen) {
|
|
671
|
-
await redis.set(failOnceKey, "1");
|
|
672
|
-
throw new Error("fail once");
|
|
673
|
-
}
|
|
674
|
-
return true;
|
|
675
|
-
});
|
|
676
|
-
|
|
677
|
-
return { emitted: runIds.length };
|
|
678
|
-
},
|
|
679
|
-
);
|
|
680
|
-
|
|
681
|
-
const worker = await startWorker({
|
|
682
|
-
redis,
|
|
683
|
-
prefix,
|
|
684
|
-
queues: ["q_emit_retry_parent", "q_emit_retry_consumer"],
|
|
685
|
-
concurrency: 2,
|
|
686
|
-
runtime: { leaseMs: 500 },
|
|
687
|
-
});
|
|
688
|
-
|
|
689
|
-
const handle = await parent.run({});
|
|
690
|
-
const out = await handle.result({ timeoutMs: 8000, pollMs: 50 });
|
|
691
|
-
expect(out).toEqual({ emitted: 1 });
|
|
692
|
-
|
|
693
|
-
await waitFor(
|
|
694
|
-
async () => Number((await redis.get(counterKey)) ?? "0") >= 1,
|
|
695
|
-
{ timeoutMs: 5000, label: "step.emitEvent consumer run" },
|
|
696
|
-
);
|
|
697
|
-
|
|
698
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
699
|
-
const counter = Number((await redis.get(counterKey)) ?? "0");
|
|
700
|
-
expect(counter).toBe(1);
|
|
701
|
-
|
|
702
|
-
const consumerRuns = await client.listRuns({ workflow: "step-emit-consumer-once", limit: 10 });
|
|
703
|
-
expect(consumerRuns.length).toBe(1);
|
|
704
|
-
|
|
705
|
-
const parentState = await client.getRun(handle.id);
|
|
706
|
-
expect(parentState?.status).toBe("succeeded");
|
|
707
|
-
expect(parentState?.attempt).toBe(2);
|
|
708
535
|
|
|
709
|
-
await worker.stop();
|
|
710
|
-
redis.close();
|
|
711
|
-
}, { timeout: 15_000 });
|
|
712
|
-
|
|
713
|
-
test("step.scheduleEvent: scheduled workflows execute once across parent retries", async () => {
|
|
714
|
-
const prefix = testPrefix();
|
|
715
|
-
const redis = new RedisClient(redisServer.url);
|
|
716
|
-
const client = createClient({ redis, prefix });
|
|
717
|
-
setDefaultClient(client);
|
|
718
|
-
|
|
719
|
-
const counterKey = `${prefix}:t:step-schedule-once`;
|
|
720
|
-
const failOnceKey = `${prefix}:t:step-schedule-parent-fail`;
|
|
721
|
-
|
|
722
|
-
defineWorkflow(
|
|
723
|
-
{
|
|
724
|
-
name: "step-schedule-consumer-once",
|
|
725
|
-
queue: "q_schedule_retry_consumer",
|
|
726
|
-
triggers: { events: ["step.schedule.once"] },
|
|
727
|
-
},
|
|
728
|
-
async ({ step }) => {
|
|
729
|
-
await step.run({ name: "count" }, async () => {
|
|
730
|
-
await redis.incr(counterKey);
|
|
731
|
-
return true;
|
|
732
|
-
});
|
|
733
|
-
return { ok: true };
|
|
734
|
-
},
|
|
735
|
-
);
|
|
736
|
-
|
|
737
|
-
const parent = defineWorkflow(
|
|
738
|
-
{
|
|
739
|
-
name: "step-schedule-parent-retry",
|
|
740
|
-
queue: "q_schedule_retry_parent",
|
|
741
|
-
retries: { maxAttempts: 2 },
|
|
742
|
-
},
|
|
743
|
-
async ({ step }) => {
|
|
744
|
-
const runIds = await step.scheduleEvent(
|
|
745
|
-
{
|
|
746
|
-
name: "schedule-once",
|
|
747
|
-
event: "step.schedule.once",
|
|
748
|
-
schedule: { availableAt: new Date(Date.now() + 250) },
|
|
749
|
-
},
|
|
750
|
-
{ v: 1 },
|
|
751
|
-
);
|
|
752
|
-
|
|
753
|
-
await step.run({ name: "fail-once" }, async () => {
|
|
754
|
-
const seen = await redis.get(failOnceKey);
|
|
755
|
-
if (!seen) {
|
|
756
|
-
await redis.set(failOnceKey, "1");
|
|
757
|
-
throw new Error("fail once");
|
|
758
|
-
}
|
|
759
|
-
return true;
|
|
760
|
-
});
|
|
761
|
-
|
|
762
|
-
return { emitted: runIds.length };
|
|
763
|
-
},
|
|
764
|
-
);
|
|
765
|
-
|
|
766
|
-
const worker = await startWorker({
|
|
767
|
-
redis,
|
|
768
|
-
prefix,
|
|
769
|
-
queues: ["q_schedule_retry_parent", "q_schedule_retry_consumer"],
|
|
770
|
-
concurrency: 2,
|
|
771
|
-
runtime: { leaseMs: 500 },
|
|
772
|
-
});
|
|
773
|
-
|
|
774
|
-
const handle = await parent.run({});
|
|
775
|
-
const out = await handle.result({ timeoutMs: 8000, pollMs: 50 });
|
|
776
|
-
expect(out).toEqual({ emitted: 1 });
|
|
777
|
-
|
|
778
|
-
await waitFor(
|
|
779
|
-
async () => Number((await redis.get(counterKey)) ?? "0") >= 1,
|
|
780
|
-
{ timeoutMs: 6000, label: "step.scheduleEvent consumer run" },
|
|
781
|
-
);
|
|
782
|
-
|
|
783
|
-
await new Promise((r) => setTimeout(r, 250));
|
|
784
|
-
const counter = Number((await redis.get(counterKey)) ?? "0");
|
|
785
|
-
expect(counter).toBe(1);
|
|
786
|
-
|
|
787
|
-
const consumerRuns = await client.listRuns({ workflow: "step-schedule-consumer-once", limit: 10 });
|
|
788
|
-
expect(consumerRuns.length).toBe(1);
|
|
789
|
-
|
|
790
|
-
const parentState = await client.getRun(handle.id);
|
|
791
|
-
expect(parentState?.status).toBe("succeeded");
|
|
792
|
-
expect(parentState?.attempt).toBe(2);
|
|
793
|
-
|
|
794
|
-
await worker.stop();
|
|
795
|
-
redis.close();
|
|
796
|
-
}, { timeout: 15_000 });
|
|
797
536
|
|
|
798
537
|
test("retries: step results are cached across attempts", async () => {
|
|
799
538
|
const prefix = testPrefix();
|
|
@@ -804,10 +543,7 @@ test("retries: step results are cached across attempts", async () => {
|
|
|
804
543
|
const step1CountKey = `${prefix}:t:step1Count`;
|
|
805
544
|
const failOnceKey = `${prefix}:t:failOnce`;
|
|
806
545
|
|
|
807
|
-
const wf = defineWorkflow(
|
|
808
|
-
{
|
|
809
|
-
name: "retry-wf",
|
|
810
|
-
queue: "q5",
|
|
546
|
+
const wf = defineWorkflow("retry-wf", {queue: "q5",
|
|
811
547
|
retries: { maxAttempts: 2 },
|
|
812
548
|
},
|
|
813
549
|
async ({ step }) => {
|
|
@@ -837,7 +573,7 @@ test("retries: step results are cached across attempts", async () => {
|
|
|
837
573
|
});
|
|
838
574
|
|
|
839
575
|
const handle = await wf.run({});
|
|
840
|
-
const out = await handle.result({ timeoutMs: 8000
|
|
576
|
+
const out = await handle.result({ timeoutMs: 8000 });
|
|
841
577
|
expect(out).toEqual({ ok: true });
|
|
842
578
|
|
|
843
579
|
const step1Count = Number((await redis.get(step1CountKey)) ?? "0");
|
|
@@ -859,10 +595,7 @@ test("retries: run queued before worker start keeps workflow maxAttempts", async
|
|
|
859
595
|
|
|
860
596
|
const failOnceKey = `${prefix}:t:retry-before-sync`;
|
|
861
597
|
|
|
862
|
-
const wf = defineWorkflow(
|
|
863
|
-
{
|
|
864
|
-
name: "retry-before-sync",
|
|
865
|
-
queue: "q5_before_sync",
|
|
598
|
+
const wf = defineWorkflow("retry-before-sync", {queue: "q5_before_sync",
|
|
866
599
|
retries: { maxAttempts: 2 },
|
|
867
600
|
},
|
|
868
601
|
async () => {
|
|
@@ -887,7 +620,7 @@ test("retries: run queued before worker start keeps workflow maxAttempts", async
|
|
|
887
620
|
runtime: { leaseMs: 500 },
|
|
888
621
|
});
|
|
889
622
|
|
|
890
|
-
const out = await handle.result({ timeoutMs: 8000
|
|
623
|
+
const out = await handle.result({ timeoutMs: 8000 });
|
|
891
624
|
expect(out).toEqual({ ok: true });
|
|
892
625
|
|
|
893
626
|
const state = await client.getRun(handle.id);
|
|
@@ -905,7 +638,7 @@ test("step timeout: run fails and error is recorded", async () => {
|
|
|
905
638
|
const client = createClient({ redis, prefix });
|
|
906
639
|
setDefaultClient(client);
|
|
907
640
|
|
|
908
|
-
const wf = defineWorkflow(
|
|
641
|
+
const wf = defineWorkflow("timeout-wf", {queue: "q6" }, async ({ step }) => {
|
|
909
642
|
await step.run({ name: "slow", timeoutMs: 80 }, async () => {
|
|
910
643
|
await new Promise((r) => setTimeout(r, 250));
|
|
911
644
|
return true;
|
|
@@ -949,7 +682,7 @@ test("cancellation: scheduled/queued/running", async () => {
|
|
|
949
682
|
|
|
950
683
|
const touchedKey = `${prefix}:t:touched`;
|
|
951
684
|
|
|
952
|
-
const wf = defineWorkflow(
|
|
685
|
+
const wf = defineWorkflow("cancel-wf", {queue: "q7" }, async ({ step }) => {
|
|
953
686
|
await step.run({ name: "hold" }, async () => {
|
|
954
687
|
await new Promise((r) => setTimeout(r, 600));
|
|
955
688
|
return true;
|
|
@@ -970,7 +703,7 @@ test("cancellation: scheduled/queued/running", async () => {
|
|
|
970
703
|
runtime: { leaseMs: 300 },
|
|
971
704
|
});
|
|
972
705
|
|
|
973
|
-
const handle = await wf.run({}, {
|
|
706
|
+
const handle = await wf.run({}, { runAt: new Date(Date.now() + 300) });
|
|
974
707
|
const canceled = await client.cancelRun(handle.id, { reason: "test" });
|
|
975
708
|
expect(canceled).toBe(true);
|
|
976
709
|
|
|
@@ -1042,7 +775,7 @@ test("cancel race: queued cancel before start does not execute handler", async (
|
|
|
1042
775
|
const queue = "q7_race";
|
|
1043
776
|
const sideEffectsKey = `${prefix}:t:cancelRaceSideEffects`;
|
|
1044
777
|
|
|
1045
|
-
const wf = defineWorkflow(
|
|
778
|
+
const wf = defineWorkflow("cancel-race-wf", {queue }, async () => {
|
|
1046
779
|
await redis.incr(sideEffectsKey);
|
|
1047
780
|
return { ok: true };
|
|
1048
781
|
});
|
|
@@ -1195,7 +928,7 @@ test("idempotencyKey: same key returns same run id and executes once", async ()
|
|
|
1195
928
|
|
|
1196
929
|
const countKey = `${prefix}:t:idemCount`;
|
|
1197
930
|
|
|
1198
|
-
const wf = defineWorkflow(
|
|
931
|
+
const wf = defineWorkflow("idem-wf", {queue: "q9" }, async ({ step }) => {
|
|
1199
932
|
await step.run({ name: "do" }, async () => {
|
|
1200
933
|
await redis.incr(countKey);
|
|
1201
934
|
return true;
|
|
@@ -1213,7 +946,7 @@ test("idempotencyKey: same key returns same run id and executes once", async ()
|
|
|
1213
946
|
const [h1, h2] = await Promise.all([wf.run({}, { idempotencyKey: "k" }), wf.run({}, { idempotencyKey: "k" })]);
|
|
1214
947
|
expect(h1.id).toBe(h2.id);
|
|
1215
948
|
|
|
1216
|
-
const out = await h1.result({ timeoutMs: 4000
|
|
949
|
+
const out = await h1.result({ timeoutMs: 4000 });
|
|
1217
950
|
expect(out).toEqual({ ok: true });
|
|
1218
951
|
|
|
1219
952
|
// Give the worker a moment to potentially pick duplicates.
|
|
@@ -1233,7 +966,7 @@ test("idempotencyKey: delayed TTL refresh cannot fork duplicate runs", async ()
|
|
|
1233
966
|
|
|
1234
967
|
const countKey = `${prefix}:t:idem-race-fix-count`;
|
|
1235
968
|
|
|
1236
|
-
const wf = defineWorkflow(
|
|
969
|
+
const wf = defineWorkflow("idem-race-fix", {queue: "q9_race_fix" }, async ({ step }) => {
|
|
1237
970
|
await step.run({ name: "do" }, async () => {
|
|
1238
971
|
await redis.incr(countKey);
|
|
1239
972
|
return true;
|
|
@@ -1267,8 +1000,8 @@ test("idempotencyKey: delayed TTL refresh cannot fork duplicate runs", async ()
|
|
|
1267
1000
|
expect(h1.id).toBe(h2.id);
|
|
1268
1001
|
|
|
1269
1002
|
const [out1, out2] = await Promise.all([
|
|
1270
|
-
h1.result({ timeoutMs: 5000
|
|
1271
|
-
h2.result({ timeoutMs: 5000
|
|
1003
|
+
h1.result({ timeoutMs: 5000 }),
|
|
1004
|
+
h2.result({ timeoutMs: 5000 }),
|
|
1272
1005
|
]);
|
|
1273
1006
|
expect(out1).toEqual({ ok: true });
|
|
1274
1007
|
expect(out2).toEqual({ ok: true });
|
|
@@ -1288,7 +1021,7 @@ test("enqueue: producer-side zadd failure does not leave runs orphaned", async (
|
|
|
1288
1021
|
const client = createClient({ redis, prefix });
|
|
1289
1022
|
setDefaultClient(client);
|
|
1290
1023
|
|
|
1291
|
-
const wf = defineWorkflow(
|
|
1024
|
+
const wf = defineWorkflow("enqueue-atomic", {queue: "q_enqueue_atomic" }, async () => ({ ok: true }));
|
|
1292
1025
|
await client.syncRegistry(getDefaultRegistry());
|
|
1293
1026
|
|
|
1294
1027
|
const originalZadd = redis.zadd.bind(redis);
|
|
@@ -1316,7 +1049,7 @@ test("enqueue: producer-side zadd failure does not leave runs orphaned", async (
|
|
|
1316
1049
|
});
|
|
1317
1050
|
|
|
1318
1051
|
try {
|
|
1319
|
-
const out = await handle.result({ timeoutMs: 6000
|
|
1052
|
+
const out = await handle.result({ timeoutMs: 6000 });
|
|
1320
1053
|
expect(out).toEqual({ ok: true });
|
|
1321
1054
|
} finally {
|
|
1322
1055
|
await worker.stop();
|
|
@@ -1330,8 +1063,8 @@ test("idempotencyKey: delimiter-like workflow/key pairs do not collide", async (
|
|
|
1330
1063
|
const client = createClient({ redis, prefix });
|
|
1331
1064
|
setDefaultClient(client);
|
|
1332
1065
|
|
|
1333
|
-
const wfA = defineWorkflow(
|
|
1334
|
-
const wfB = defineWorkflow(
|
|
1066
|
+
const wfA = defineWorkflow("idem:a", {queue: "q9a" }, async () => ({ workflow: "idem:a" }));
|
|
1067
|
+
const wfB = defineWorkflow("idem:a:b", {queue: "q9b" }, async () => ({ workflow: "idem:a:b" }));
|
|
1335
1068
|
|
|
1336
1069
|
const worker = await startWorker({
|
|
1337
1070
|
redis,
|
|
@@ -1349,8 +1082,8 @@ test("idempotencyKey: delimiter-like workflow/key pairs do not collide", async (
|
|
|
1349
1082
|
expect(h1.id).not.toBe(h2.id);
|
|
1350
1083
|
|
|
1351
1084
|
const [out1, out2] = await Promise.all([
|
|
1352
|
-
h1.result({ timeoutMs: 5000
|
|
1353
|
-
h2.result({ timeoutMs: 5000
|
|
1085
|
+
h1.result({ timeoutMs: 5000 }),
|
|
1086
|
+
h2.result({ timeoutMs: 5000 }),
|
|
1354
1087
|
]);
|
|
1355
1088
|
|
|
1356
1089
|
expect(out1).toEqual({ workflow: "idem:a" });
|
|
@@ -1368,7 +1101,7 @@ test("idempotencyTtl: short TTL expires and allows a new run with same key", asy
|
|
|
1368
1101
|
|
|
1369
1102
|
const countKey = `${prefix}:t:idemTtlCount`;
|
|
1370
1103
|
|
|
1371
|
-
const wf = defineWorkflow(
|
|
1104
|
+
const wf = defineWorkflow("idem-ttl-wf", {queue: "q_idem_ttl" }, async ({ step }) => {
|
|
1372
1105
|
await step.run({ name: "count" }, async () => {
|
|
1373
1106
|
await redis.incr(countKey);
|
|
1374
1107
|
return true;
|
|
@@ -1385,7 +1118,7 @@ test("idempotencyTtl: short TTL expires and allows a new run with same key", asy
|
|
|
1385
1118
|
|
|
1386
1119
|
// First run with 1-second TTL
|
|
1387
1120
|
const h1 = await wf.run({}, { idempotencyKey: "ttl-key", idempotencyTtl: 1 });
|
|
1388
|
-
const out1 = await h1.result({ timeoutMs: 5000
|
|
1121
|
+
const out1 = await h1.result({ timeoutMs: 5000 });
|
|
1389
1122
|
expect(out1).toEqual({ ok: true });
|
|
1390
1123
|
|
|
1391
1124
|
// Same key within TTL — returns existing run
|
|
@@ -1399,7 +1132,7 @@ test("idempotencyTtl: short TTL expires and allows a new run with same key", asy
|
|
|
1399
1132
|
const h3 = await wf.run({}, { idempotencyKey: "ttl-key", idempotencyTtl: 1 });
|
|
1400
1133
|
expect(h3.id).not.toBe(h1.id);
|
|
1401
1134
|
|
|
1402
|
-
const out3 = await h3.result({ timeoutMs: 5000
|
|
1135
|
+
const out3 = await h3.result({ timeoutMs: 5000 });
|
|
1403
1136
|
expect(out3).toEqual({ ok: true });
|
|
1404
1137
|
|
|
1405
1138
|
// Handler executed twice (once per unique run)
|
|
@@ -1410,157 +1143,13 @@ test("idempotencyTtl: short TTL expires and allows a new run with same key", asy
|
|
|
1410
1143
|
redis.close();
|
|
1411
1144
|
});
|
|
1412
1145
|
|
|
1413
|
-
test("emitEvent idempotency: same key across different events does not collide", async () => {
|
|
1414
|
-
const prefix = testPrefix();
|
|
1415
|
-
const redis = new RedisClient(redisServer.url);
|
|
1416
|
-
const client = createClient({ redis, prefix });
|
|
1417
|
-
setDefaultClient(client);
|
|
1418
|
-
|
|
1419
|
-
const countKey = `${prefix}:t:evtScopeCount`;
|
|
1420
|
-
|
|
1421
|
-
defineWorkflow(
|
|
1422
|
-
{
|
|
1423
|
-
name: "evt-idem-scope",
|
|
1424
|
-
queue: "q10_scope",
|
|
1425
|
-
triggers: { events: ["dup.event.a", "dup.event.b"] },
|
|
1426
|
-
},
|
|
1427
|
-
async ({ step }) => {
|
|
1428
|
-
await step.run({ name: "count" }, async () => {
|
|
1429
|
-
await redis.incr(countKey);
|
|
1430
|
-
return true;
|
|
1431
|
-
});
|
|
1432
|
-
return { ok: true };
|
|
1433
|
-
},
|
|
1434
|
-
);
|
|
1435
|
-
|
|
1436
|
-
const worker = await startWorker({
|
|
1437
|
-
redis,
|
|
1438
|
-
prefix,
|
|
1439
|
-
queues: ["q10_scope"],
|
|
1440
|
-
runtime: { leaseMs: 500 },
|
|
1441
|
-
});
|
|
1442
|
-
|
|
1443
|
-
const runIdsA = await client.emitEvent("dup.event.a", { a: 1 }, { idempotencyKey: "evt_shared" });
|
|
1444
|
-
const runIdsB = await client.emitEvent("dup.event.b", { b: 1 }, { idempotencyKey: "evt_shared" });
|
|
1445
|
-
|
|
1446
|
-
expect(runIdsA.length).toBe(1);
|
|
1447
|
-
expect(runIdsB.length).toBe(1);
|
|
1448
|
-
expect(runIdsA[0]).not.toBe(runIdsB[0]);
|
|
1449
|
-
|
|
1450
|
-
await Promise.all([
|
|
1451
|
-
client.makeRunHandle(runIdsA[0]!).result({ timeoutMs: 5000, pollMs: 50 }),
|
|
1452
|
-
client.makeRunHandle(runIdsB[0]!).result({ timeoutMs: 5000, pollMs: 50 }),
|
|
1453
|
-
]);
|
|
1454
|
-
|
|
1455
|
-
const cnt = Number((await redis.get(countKey)) ?? "0");
|
|
1456
|
-
expect(cnt).toBe(2);
|
|
1457
|
-
|
|
1458
|
-
await worker.stop();
|
|
1459
|
-
redis.close();
|
|
1460
|
-
});
|
|
1461
|
-
|
|
1462
|
-
test("scheduleEvent: delayed fan-out is idempotent", async () => {
|
|
1463
|
-
const prefix = testPrefix();
|
|
1464
|
-
const redis = new RedisClient(redisServer.url);
|
|
1465
|
-
const client = createClient({ redis, prefix });
|
|
1466
|
-
setDefaultClient(client);
|
|
1467
|
-
|
|
1468
|
-
const event = "schedule.event";
|
|
1469
|
-
const countKey = `${prefix}:t:schedule-event-count`;
|
|
1470
|
-
|
|
1471
|
-
defineWorkflow(
|
|
1472
|
-
{ name: "schedule-consumer", queue: "q10_schedule", triggers: { events: [event] } },
|
|
1473
|
-
async ({ step }) => {
|
|
1474
|
-
await step.run({ name: "count" }, async () => {
|
|
1475
|
-
await redis.incr(countKey);
|
|
1476
|
-
return true;
|
|
1477
|
-
});
|
|
1478
|
-
return { ok: true };
|
|
1479
|
-
},
|
|
1480
|
-
);
|
|
1481
|
-
|
|
1482
|
-
const worker = await startWorker({
|
|
1483
|
-
redis,
|
|
1484
|
-
prefix,
|
|
1485
|
-
queues: ["q10_schedule"],
|
|
1486
|
-
runtime: { leaseMs: 500 },
|
|
1487
|
-
});
|
|
1488
|
-
|
|
1489
|
-
try {
|
|
1490
|
-
const availableAt = new Date(Date.now() + 1200);
|
|
1491
|
-
const [runIds1, runIds2] = await Promise.all([
|
|
1492
|
-
client.scheduleEvent(event, { x: 1 }, { availableAt, idempotencyKey: "sched-shared" }),
|
|
1493
|
-
client.scheduleEvent(event, { x: 1 }, { availableAt, idempotencyKey: "sched-shared" }),
|
|
1494
|
-
]);
|
|
1495
|
-
|
|
1496
|
-
expect(runIds1.length).toBe(1);
|
|
1497
|
-
expect(runIds2.length).toBe(1);
|
|
1498
|
-
expect(runIds1[0]).toBe(runIds2[0]);
|
|
1499
|
-
|
|
1500
|
-
const runId = runIds1[0]!;
|
|
1501
|
-
const stateBefore = await client.getRun(runId);
|
|
1502
|
-
expect(stateBefore?.status).toBe("scheduled");
|
|
1503
|
-
|
|
1504
|
-
const out = await client.makeRunHandle(runId).result({ timeoutMs: 7000, pollMs: 50 });
|
|
1505
|
-
expect(out).toEqual({ ok: true });
|
|
1506
|
-
|
|
1507
|
-
const stateAfter = await client.getRun(runId);
|
|
1508
|
-
expect((stateAfter?.startedAt ?? 0) + 40).toBeGreaterThanOrEqual(availableAt.getTime());
|
|
1509
|
-
|
|
1510
|
-
const count = Number((await redis.get(countKey)) ?? "0");
|
|
1511
|
-
expect(count).toBe(1);
|
|
1512
|
-
} finally {
|
|
1513
|
-
await worker.stop();
|
|
1514
|
-
redis.close();
|
|
1515
|
-
}
|
|
1516
|
-
});
|
|
1517
|
-
|
|
1518
|
-
test("emitEvent: stale subscribers are pruned and do not break fan-out", async () => {
|
|
1519
|
-
const prefix = testPrefix();
|
|
1520
|
-
const redis = new RedisClient(redisServer.url);
|
|
1521
|
-
const client = createClient({ redis, prefix });
|
|
1522
|
-
setDefaultClient(client);
|
|
1523
|
-
|
|
1524
|
-
const event = "stale.subscribers.event";
|
|
1525
|
-
defineWorkflow(
|
|
1526
|
-
{ name: "stale-subscriber-live", queue: "q10_stale_subs", triggers: { events: [event] } },
|
|
1527
|
-
async () => ({ ok: true }),
|
|
1528
|
-
);
|
|
1529
|
-
|
|
1530
|
-
const worker = await startWorker({
|
|
1531
|
-
redis,
|
|
1532
|
-
prefix,
|
|
1533
|
-
queues: ["q10_stale_subs"],
|
|
1534
|
-
runtime: { leaseMs: 500 },
|
|
1535
|
-
});
|
|
1536
|
-
|
|
1537
|
-
try {
|
|
1538
|
-
await redis.sadd(keys.eventWorkflows(prefix, event), "stale-subscriber-ghost");
|
|
1539
|
-
|
|
1540
|
-
const runIds = await client.emitEvent(event, { v: 1 });
|
|
1541
|
-
expect(runIds.length).toBe(1);
|
|
1542
|
-
|
|
1543
|
-
const subscribers = await redis.smembers(keys.eventWorkflows(prefix, event));
|
|
1544
|
-
expect(subscribers.includes("stale-subscriber-ghost")).toBe(false);
|
|
1545
|
-
|
|
1546
|
-
const out = await client.makeRunHandle(runIds[0]!).result({ timeoutMs: 5000, pollMs: 50 });
|
|
1547
|
-
expect(out).toEqual({ ok: true });
|
|
1548
|
-
} finally {
|
|
1549
|
-
await worker.stop();
|
|
1550
|
-
redis.close();
|
|
1551
|
-
}
|
|
1552
|
-
});
|
|
1553
|
-
|
|
1554
1146
|
test("input validation: invalid input fails once and is not retried", async () => {
|
|
1555
1147
|
const prefix = testPrefix();
|
|
1556
1148
|
const redis = new RedisClient(redisServer.url);
|
|
1557
1149
|
const client = createClient({ redis, prefix });
|
|
1558
1150
|
setDefaultClient(client);
|
|
1559
1151
|
|
|
1560
|
-
defineWorkflow(
|
|
1561
|
-
{
|
|
1562
|
-
name: "validate-wf",
|
|
1563
|
-
queue: "q11",
|
|
1152
|
+
defineWorkflow("validate-wf", {queue: "q11",
|
|
1564
1153
|
schema: z.object({ x: z.number() }),
|
|
1565
1154
|
retries: { maxAttempts: 3 },
|
|
1566
1155
|
},
|
|
@@ -1649,7 +1238,7 @@ test("cancel during step: run becomes canceled and step error kind is canceled",
|
|
|
1649
1238
|
const client = createClient({ redis, prefix });
|
|
1650
1239
|
setDefaultClient(client);
|
|
1651
1240
|
|
|
1652
|
-
const wf = defineWorkflow(
|
|
1241
|
+
const wf = defineWorkflow("cancel-mid-step", {queue: "q12" }, async ({ step }) => {
|
|
1653
1242
|
await step.run({ name: "slow" }, async () => {
|
|
1654
1243
|
await new Promise((r) => setTimeout(r, 2000));
|
|
1655
1244
|
return true;
|
|
@@ -1692,7 +1281,7 @@ test("cancel during step: run becomes canceled and step error kind is canceled",
|
|
|
1692
1281
|
expect((steps[0]?.error as any)?.kind).toBe("canceled");
|
|
1693
1282
|
|
|
1694
1283
|
// `result()` should reject with CanceledError.
|
|
1695
|
-
await expect(handle.result({ timeoutMs: 1000
|
|
1284
|
+
await expect(handle.result({ timeoutMs: 1000 })).rejects.toBeInstanceOf(CanceledError);
|
|
1696
1285
|
|
|
1697
1286
|
await worker.stop();
|
|
1698
1287
|
redis.close();
|
|
@@ -1707,7 +1296,7 @@ test("terminal run re-queued is ignored (no re-execution)", async () => {
|
|
|
1707
1296
|
const queue = "q13";
|
|
1708
1297
|
const countKey = `${prefix}:t:termCount`;
|
|
1709
1298
|
|
|
1710
|
-
const wf = defineWorkflow(
|
|
1299
|
+
const wf = defineWorkflow("term-wf", {queue }, async ({ step }) => {
|
|
1711
1300
|
await step.run({ name: "do" }, async () => {
|
|
1712
1301
|
await redis.incr(countKey);
|
|
1713
1302
|
return true;
|
|
@@ -1724,7 +1313,7 @@ test("terminal run re-queued is ignored (no re-execution)", async () => {
|
|
|
1724
1313
|
});
|
|
1725
1314
|
|
|
1726
1315
|
const handle = await wf.run({});
|
|
1727
|
-
const out = await handle.result({ timeoutMs: 4000
|
|
1316
|
+
const out = await handle.result({ timeoutMs: 4000 });
|
|
1728
1317
|
expect(out).toEqual({ ok: true });
|
|
1729
1318
|
|
|
1730
1319
|
const cnt1 = Number((await redis.get(countKey)) ?? "0");
|
|
@@ -1754,7 +1343,7 @@ test("lease+reaper: long running step is not duplicated", async () => {
|
|
|
1754
1343
|
const queue = "q14";
|
|
1755
1344
|
const countKey = `${prefix}:t:leaseCount`;
|
|
1756
1345
|
|
|
1757
|
-
const wf = defineWorkflow(
|
|
1346
|
+
const wf = defineWorkflow("lease-wf", {queue }, async ({ step }) => {
|
|
1758
1347
|
await step.run({ name: "long" }, async () => {
|
|
1759
1348
|
await redis.incr(countKey);
|
|
1760
1349
|
await new Promise((r) => setTimeout(r, 700));
|
|
@@ -1772,7 +1361,7 @@ test("lease+reaper: long running step is not duplicated", async () => {
|
|
|
1772
1361
|
});
|
|
1773
1362
|
|
|
1774
1363
|
const handle = await wf.run({});
|
|
1775
|
-
const out = await handle.result({ timeoutMs: 8000
|
|
1364
|
+
const out = await handle.result({ timeoutMs: 8000 });
|
|
1776
1365
|
expect(out).toEqual({ ok: true });
|
|
1777
1366
|
|
|
1778
1367
|
const cnt = Number((await redis.get(countKey)) ?? "0");
|
|
@@ -1796,11 +1385,8 @@ test(
|
|
|
1796
1385
|
const queue = "q15";
|
|
1797
1386
|
const counterKey = `${prefix}:t:cronLockCount`;
|
|
1798
1387
|
|
|
1799
|
-
defineWorkflow(
|
|
1800
|
-
|
|
1801
|
-
name: "cron-lock-wf",
|
|
1802
|
-
queue,
|
|
1803
|
-
triggers: { cron: [{ expression: "*/1 * * * * *" }] },
|
|
1388
|
+
defineWorkflow("cron-lock-wf", {queue,
|
|
1389
|
+
cron: [{ expression: "*/1 * * * * *" }],
|
|
1804
1390
|
},
|
|
1805
1391
|
async ({ step }) => {
|
|
1806
1392
|
await step.run({ name: "tick" }, async () => {
|
|
@@ -1839,10 +1425,7 @@ test(
|
|
|
1839
1425
|
const childFailOnceKey = `${prefix}:t:childFailOnce`;
|
|
1840
1426
|
const parentBeforeKey = `${prefix}:t:parentBefore`;
|
|
1841
1427
|
|
|
1842
|
-
const child = defineWorkflow(
|
|
1843
|
-
{
|
|
1844
|
-
name: "child-wf",
|
|
1845
|
-
queue: "q_child",
|
|
1428
|
+
const child = defineWorkflow("child-wf", {queue: "q_child",
|
|
1846
1429
|
retries: { maxAttempts: 2 },
|
|
1847
1430
|
},
|
|
1848
1431
|
async ({ step, run }) => {
|
|
@@ -1864,10 +1447,7 @@ test(
|
|
|
1864
1447
|
},
|
|
1865
1448
|
);
|
|
1866
1449
|
|
|
1867
|
-
const parent = defineWorkflow(
|
|
1868
|
-
{
|
|
1869
|
-
name: "parent-wf",
|
|
1870
|
-
queue: "q_parent",
|
|
1450
|
+
const parent = defineWorkflow("parent-wf", {queue: "q_parent",
|
|
1871
1451
|
},
|
|
1872
1452
|
async ({ step }) => {
|
|
1873
1453
|
await step.run({ name: "before" }, async () => {
|
|
@@ -1877,7 +1457,7 @@ test(
|
|
|
1877
1457
|
|
|
1878
1458
|
const childOut = await step.run({ name: "child" }, async () => {
|
|
1879
1459
|
const h = await child.run({});
|
|
1880
|
-
return await h.result({ timeoutMs: 10_000
|
|
1460
|
+
return await h.result({ timeoutMs: 10_000 });
|
|
1881
1461
|
});
|
|
1882
1462
|
|
|
1883
1463
|
return { ok: true, child: childOut };
|
|
@@ -1893,7 +1473,7 @@ test(
|
|
|
1893
1473
|
});
|
|
1894
1474
|
|
|
1895
1475
|
const handle = await parent.run({});
|
|
1896
|
-
const out = await handle.result({ timeoutMs: 15_000
|
|
1476
|
+
const out = await handle.result({ timeoutMs: 15_000 });
|
|
1897
1477
|
expect(out.ok).toBe(true);
|
|
1898
1478
|
expect(out.child.ok).toBe(true);
|
|
1899
1479
|
expect(out.child.attempt).toBe(2);
|
|
@@ -1925,7 +1505,7 @@ test(
|
|
|
1925
1505
|
setDefaultClient(client);
|
|
1926
1506
|
|
|
1927
1507
|
const queue = "q_status";
|
|
1928
|
-
const wf = defineWorkflow(
|
|
1508
|
+
const wf = defineWorkflow("status-wf", {queue }, async ({ step }) => {
|
|
1929
1509
|
await step.run({ name: "sleep" }, async () => {
|
|
1930
1510
|
await new Promise((r) => setTimeout(r, 350));
|
|
1931
1511
|
return true;
|
|
@@ -1956,7 +1536,7 @@ test(
|
|
|
1956
1536
|
const queuedMembers2 = await redis.zrange(queuedIndex, 0, -1);
|
|
1957
1537
|
expect(queuedMembers2.includes(handle.id)).toBe(false);
|
|
1958
1538
|
|
|
1959
|
-
const out = await handle.result({ timeoutMs: 8000
|
|
1539
|
+
const out = await handle.result({ timeoutMs: 8000 });
|
|
1960
1540
|
expect(out).toEqual({ ok: true });
|
|
1961
1541
|
|
|
1962
1542
|
const succeededMembers = await redis.zrange(`${prefix}:runs:status:succeeded`, 0, -1);
|
|
@@ -1971,7 +1551,7 @@ test(
|
|
|
1971
1551
|
);
|
|
1972
1552
|
|
|
1973
1553
|
test(
|
|
1974
|
-
"production: registry sync updates
|
|
1554
|
+
"production: registry sync updates cron definitions",
|
|
1975
1555
|
async () => {
|
|
1976
1556
|
const prefix = testPrefix();
|
|
1977
1557
|
const redis = new RedisClient(redisServer.url);
|
|
@@ -1979,27 +1559,13 @@ test(
|
|
|
1979
1559
|
setDefaultClient(client);
|
|
1980
1560
|
|
|
1981
1561
|
const name = "update-wf";
|
|
1982
|
-
const eventA = "evt.a";
|
|
1983
|
-
const eventB = "evt.b";
|
|
1984
|
-
|
|
1985
|
-
defineWorkflow(
|
|
1986
|
-
{
|
|
1987
|
-
name,
|
|
1988
|
-
queue: "q_update",
|
|
1989
|
-
triggers: {
|
|
1990
|
-
events: [eventA],
|
|
1991
|
-
cron: [{ expression: "*/1 * * * * *" }],
|
|
1992
|
-
},
|
|
1993
|
-
},
|
|
1994
|
-
async () => ({ ok: true }),
|
|
1995
|
-
);
|
|
1996
1562
|
|
|
1997
|
-
|
|
1563
|
+
defineWorkflow(name, {
|
|
1564
|
+
queue: "q_update",
|
|
1565
|
+
cron: [{ expression: "*/1 * * * * *" }],
|
|
1566
|
+
}, async () => ({ ok: true }));
|
|
1998
1567
|
|
|
1999
|
-
|
|
2000
|
-
const subsB1 = await redis.smembers(`${prefix}:event:${eventB}:workflows`);
|
|
2001
|
-
expect(subsA1.includes(name)).toBe(true);
|
|
2002
|
-
expect(subsB1.includes(name)).toBe(false);
|
|
1568
|
+
await client.syncRegistry(getDefaultRegistry());
|
|
2003
1569
|
|
|
2004
1570
|
const workflowKey = `${prefix}:workflow:${name}`;
|
|
2005
1571
|
const cronIdsJson = await redis.hget(workflowKey, "cronIdsJson");
|
|
@@ -2011,25 +1577,14 @@ test(
|
|
|
2011
1577
|
const cronNext = await redis.zrange(`${prefix}:cron:next`, 0, -1);
|
|
2012
1578
|
expect(cronNext.includes(cronId)).toBe(true);
|
|
2013
1579
|
|
|
2014
|
-
// Simulate a new deployment: same workflow name,
|
|
1580
|
+
// Simulate a new deployment: same workflow name, cron removed.
|
|
2015
1581
|
__unstableResetDefaultRegistryForTests();
|
|
2016
|
-
defineWorkflow(
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
queue: "q_update",
|
|
2020
|
-
triggers: { events: [eventB] },
|
|
2021
|
-
},
|
|
2022
|
-
async () => ({ ok: true }),
|
|
2023
|
-
);
|
|
1582
|
+
defineWorkflow(name, {
|
|
1583
|
+
queue: "q_update",
|
|
1584
|
+
}, async () => ({ ok: true }));
|
|
2024
1585
|
|
|
2025
1586
|
await client.syncRegistry(getDefaultRegistry());
|
|
2026
1587
|
|
|
2027
|
-
const subsA2 = await redis.smembers(`${prefix}:event:${eventA}:workflows`);
|
|
2028
|
-
const subsB2 = await redis.smembers(`${prefix}:event:${eventB}:workflows`);
|
|
2029
|
-
expect(subsA2.includes(name)).toBe(false);
|
|
2030
|
-
expect(subsB2.includes(name)).toBe(true);
|
|
2031
|
-
|
|
2032
|
-
// Old cron removed.
|
|
2033
1588
|
expect(await redis.hget(`${prefix}:cron:def`, cronId)).toBeNull();
|
|
2034
1589
|
const cronNext2 = await redis.zrange(`${prefix}:cron:next`, 0, -1);
|
|
2035
1590
|
expect(cronNext2.includes(cronId)).toBe(false);
|
|
@@ -2051,10 +1606,7 @@ test(
|
|
|
2051
1606
|
const t1Key = `${prefix}:t:attempt1`;
|
|
2052
1607
|
const t2Key = `${prefix}:t:attempt2`;
|
|
2053
1608
|
|
|
2054
|
-
const wf = defineWorkflow(
|
|
2055
|
-
{
|
|
2056
|
-
name: "backoff-wf",
|
|
2057
|
-
queue,
|
|
1609
|
+
const wf = defineWorkflow("backoff-wf", {queue,
|
|
2058
1610
|
retries: { maxAttempts: 2 },
|
|
2059
1611
|
},
|
|
2060
1612
|
async ({ run }) => {
|
|
@@ -2073,7 +1625,7 @@ test(
|
|
|
2073
1625
|
});
|
|
2074
1626
|
|
|
2075
1627
|
const handle = await wf.run({});
|
|
2076
|
-
const out = await handle.result({ timeoutMs: 15_000
|
|
1628
|
+
const out = await handle.result({ timeoutMs: 15_000 });
|
|
2077
1629
|
expect(out).toEqual({ ok: true });
|
|
2078
1630
|
|
|
2079
1631
|
const t1 = Number((await redis.get(t1Key)) ?? "0");
|
|
@@ -2103,11 +1655,8 @@ test(
|
|
|
2103
1655
|
const queue = "q_failover";
|
|
2104
1656
|
const counterKey = `${prefix}:t:cronFailoverCount`;
|
|
2105
1657
|
|
|
2106
|
-
defineWorkflow(
|
|
2107
|
-
|
|
2108
|
-
name: "cron-failover-wf",
|
|
2109
|
-
queue,
|
|
2110
|
-
triggers: { cron: [{ expression: "*/1 * * * * *" }] },
|
|
1658
|
+
defineWorkflow("cron-failover-wf", {queue,
|
|
1659
|
+
cron: [{ expression: "*/1 * * * * *" }],
|
|
2111
1660
|
},
|
|
2112
1661
|
async ({ step }) => {
|
|
2113
1662
|
await step.run({ name: "tick" }, async () => {
|
|
@@ -2149,8 +1698,8 @@ test("listRuns: status + workflow filters are applied together", async () => {
|
|
|
2149
1698
|
|
|
2150
1699
|
const queue = "q_filters";
|
|
2151
1700
|
|
|
2152
|
-
const succeeds = defineWorkflow(
|
|
2153
|
-
const fails = defineWorkflow(
|
|
1701
|
+
const succeeds = defineWorkflow("filters-success", {queue }, async () => ({ ok: true }));
|
|
1702
|
+
const fails = defineWorkflow("filters-fail", {queue }, async () => {
|
|
2154
1703
|
throw new Error("expected failure");
|
|
2155
1704
|
});
|
|
2156
1705
|
|
|
@@ -2159,7 +1708,7 @@ test("listRuns: status + workflow filters are applied together", async () => {
|
|
|
2159
1708
|
const successHandle = await succeeds.run({});
|
|
2160
1709
|
const failHandle = await fails.run({});
|
|
2161
1710
|
|
|
2162
|
-
await successHandle.result({ timeoutMs: 4000
|
|
1711
|
+
await successHandle.result({ timeoutMs: 4000 });
|
|
2163
1712
|
await waitFor(
|
|
2164
1713
|
async () => (await client.getRun(failHandle.id))?.status === "failed",
|
|
2165
1714
|
{ timeoutMs: 6000, label: "failed run" },
|
|
@@ -2193,20 +1742,14 @@ test("cron trigger ids: same custom id in two workflows does not collide", async
|
|
|
2193
1742
|
const redis = new RedisClient(redisServer.url);
|
|
2194
1743
|
const client = createClient({ redis, prefix });
|
|
2195
1744
|
|
|
2196
|
-
defineWorkflow(
|
|
2197
|
-
|
|
2198
|
-
name: "cron-id-a",
|
|
2199
|
-
queue: "qa",
|
|
2200
|
-
triggers: { cron: [{ id: "shared", expression: "*/5 * * * * *" }] },
|
|
1745
|
+
defineWorkflow("cron-id-a", {queue: "qa",
|
|
1746
|
+
cron: [{ id: "shared", expression: "*/5 * * * * *" }],
|
|
2201
1747
|
},
|
|
2202
1748
|
async () => ({ ok: true }),
|
|
2203
1749
|
);
|
|
2204
1750
|
|
|
2205
|
-
defineWorkflow(
|
|
2206
|
-
|
|
2207
|
-
name: "cron-id-b",
|
|
2208
|
-
queue: "qb",
|
|
2209
|
-
triggers: { cron: [{ id: "shared", expression: "*/5 * * * * *" }] },
|
|
1751
|
+
defineWorkflow("cron-id-b", {queue: "qb",
|
|
1752
|
+
cron: [{ id: "shared", expression: "*/5 * * * * *" }],
|
|
2210
1753
|
},
|
|
2211
1754
|
async () => ({ ok: true }),
|
|
2212
1755
|
);
|
|
@@ -2233,11 +1776,8 @@ test("syncRegistry: cron trigger with explicit null input preserves null payload
|
|
|
2233
1776
|
|
|
2234
1777
|
const workflowName = "cron-null-input";
|
|
2235
1778
|
|
|
2236
|
-
defineWorkflow(
|
|
2237
|
-
|
|
2238
|
-
name: workflowName,
|
|
2239
|
-
queue: "q-cron-null",
|
|
2240
|
-
triggers: { cron: [{ id: "null-input", expression: "*/5 * * * * *", input: null }] },
|
|
1779
|
+
defineWorkflow(workflowName, {queue: "q-cron-null",
|
|
1780
|
+
cron: [{ id: "null-input", expression: "*/5 * * * * *", input: null }],
|
|
2241
1781
|
},
|
|
2242
1782
|
async () => ({ ok: true }),
|
|
2243
1783
|
);
|
|
@@ -2267,11 +1807,8 @@ test("syncRegistry: invalid cron expression removes existing next schedule for s
|
|
|
2267
1807
|
const workflowKey = `${prefix}:workflow:${workflowName}`;
|
|
2268
1808
|
const cronNextKey = `${prefix}:cron:next`;
|
|
2269
1809
|
|
|
2270
|
-
defineWorkflow(
|
|
2271
|
-
|
|
2272
|
-
name: workflowName,
|
|
2273
|
-
queue: "q-cron-invalid",
|
|
2274
|
-
triggers: { cron: [{ id: "stable", expression: "*/5 * * * * *" }] },
|
|
1810
|
+
defineWorkflow(workflowName, {queue: "q-cron-invalid",
|
|
1811
|
+
cron: [{ id: "stable", expression: "*/5 * * * * *" }],
|
|
2275
1812
|
},
|
|
2276
1813
|
async () => ({ ok: true }),
|
|
2277
1814
|
);
|
|
@@ -2286,11 +1823,8 @@ test("syncRegistry: invalid cron expression removes existing next schedule for s
|
|
|
2286
1823
|
expect(before.includes(cronId)).toBe(true);
|
|
2287
1824
|
|
|
2288
1825
|
__unstableResetDefaultRegistryForTests();
|
|
2289
|
-
defineWorkflow(
|
|
2290
|
-
|
|
2291
|
-
name: workflowName,
|
|
2292
|
-
queue: "q-cron-invalid",
|
|
2293
|
-
triggers: { cron: [{ id: "stable", expression: "not a valid cron" }] },
|
|
1826
|
+
defineWorkflow(workflowName, {queue: "q-cron-invalid",
|
|
1827
|
+
cron: [{ id: "stable", expression: "not a valid cron" }],
|
|
2294
1828
|
},
|
|
2295
1829
|
async () => ({ ok: true }),
|
|
2296
1830
|
);
|
|
@@ -2303,19 +1837,15 @@ test("syncRegistry: invalid cron expression removes existing next schedule for s
|
|
|
2303
1837
|
redis.close();
|
|
2304
1838
|
});
|
|
2305
1839
|
|
|
2306
|
-
test("syncRegistry: removes stale workflow metadata (
|
|
1840
|
+
test("syncRegistry: removes stale workflow metadata (cron)", async () => {
|
|
2307
1841
|
const prefix = testPrefix();
|
|
2308
1842
|
const redis = new RedisClient(redisServer.url);
|
|
2309
1843
|
const client = createClient({ redis, prefix });
|
|
2310
1844
|
|
|
2311
1845
|
const workflowName = "stale-workflow";
|
|
2312
|
-
const eventName = "stale.event";
|
|
2313
1846
|
|
|
2314
|
-
defineWorkflow(
|
|
2315
|
-
|
|
2316
|
-
name: workflowName,
|
|
2317
|
-
queue: "q-stale",
|
|
2318
|
-
triggers: { events: [eventName], cron: [{ id: "stale", expression: "*/10 * * * * *" }] },
|
|
1847
|
+
defineWorkflow(workflowName, {queue: "q-stale",
|
|
1848
|
+
cron: [{ id: "stale", expression: "*/10 * * * * *" }],
|
|
2319
1849
|
},
|
|
2320
1850
|
async () => ({ ok: true }),
|
|
2321
1851
|
);
|
|
@@ -2327,7 +1857,6 @@ test("syncRegistry: removes stale workflow metadata (events + cron)", async () =
|
|
|
2327
1857
|
expect(cronIds.length).toBe(1);
|
|
2328
1858
|
const cronId = cronIds[0]!;
|
|
2329
1859
|
|
|
2330
|
-
expect((await redis.smembers(`${prefix}:event:${eventName}:workflows`)).includes(workflowName)).toBe(true);
|
|
2331
1860
|
expect(await redis.hget(`${prefix}:cron:def`, cronId)).not.toBeNull();
|
|
2332
1861
|
|
|
2333
1862
|
// Force stale age beyond grace period and sync an empty registry.
|
|
@@ -2336,7 +1865,6 @@ test("syncRegistry: removes stale workflow metadata (events + cron)", async () =
|
|
|
2336
1865
|
await client.syncRegistry(getDefaultRegistry());
|
|
2337
1866
|
|
|
2338
1867
|
expect((await redis.smembers(`${prefix}:workflows`)).includes(workflowName)).toBe(false);
|
|
2339
|
-
expect((await redis.smembers(`${prefix}:event:${eventName}:workflows`)).includes(workflowName)).toBe(false);
|
|
2340
1868
|
expect(await redis.hget(workflowKey, "queue")).toBeNull();
|
|
2341
1869
|
expect(await redis.hget(`${prefix}:cron:def`, cronId)).toBeNull();
|
|
2342
1870
|
const cronNext = await redis.zrange(`${prefix}:cron:next`, 0, -1);
|
|
@@ -2351,22 +1879,16 @@ test("syncRegistry: stale cleanup is isolated by owner", async () => {
|
|
|
2351
1879
|
const client = createClient({ redis, prefix });
|
|
2352
1880
|
|
|
2353
1881
|
__unstableResetDefaultRegistryForTests();
|
|
2354
|
-
defineWorkflow(
|
|
2355
|
-
|
|
2356
|
-
name: "owner-a-wf",
|
|
2357
|
-
queue: "qa",
|
|
2358
|
-
triggers: { events: ["owner.a"], cron: [{ id: "a", expression: "*/30 * * * * *" }] },
|
|
1882
|
+
defineWorkflow("owner-a-wf", {queue: "qa",
|
|
1883
|
+
cron: [{ id: "a", expression: "*/30 * * * * *" }],
|
|
2359
1884
|
},
|
|
2360
1885
|
async () => ({ ok: true }),
|
|
2361
1886
|
);
|
|
2362
1887
|
await client.syncRegistry(getDefaultRegistry(), { owner: "svc-a" });
|
|
2363
1888
|
|
|
2364
1889
|
__unstableResetDefaultRegistryForTests();
|
|
2365
|
-
defineWorkflow(
|
|
2366
|
-
|
|
2367
|
-
name: "owner-b-wf",
|
|
2368
|
-
queue: "qb",
|
|
2369
|
-
triggers: { events: ["owner.b"], cron: [{ id: "b", expression: "*/30 * * * * *" }] },
|
|
1890
|
+
defineWorkflow("owner-b-wf", {queue: "qb",
|
|
1891
|
+
cron: [{ id: "b", expression: "*/30 * * * * *" }],
|
|
2370
1892
|
},
|
|
2371
1893
|
async () => ({ ok: true }),
|
|
2372
1894
|
);
|
|
@@ -2392,7 +1914,7 @@ test("cancelRun: terminal runs are unchanged", async () => {
|
|
|
2392
1914
|
const client = createClient({ redis, prefix });
|
|
2393
1915
|
setDefaultClient(client);
|
|
2394
1916
|
|
|
2395
|
-
const wf = defineWorkflow(
|
|
1917
|
+
const wf = defineWorkflow("cancel-terminal", {queue: "q_cancel_terminal" }, async () => ({ ok: true }));
|
|
2396
1918
|
|
|
2397
1919
|
const worker = await startWorker({
|
|
2398
1920
|
redis,
|
|
@@ -2402,7 +1924,7 @@ test("cancelRun: terminal runs are unchanged", async () => {
|
|
|
2402
1924
|
});
|
|
2403
1925
|
|
|
2404
1926
|
const handle = await wf.run({});
|
|
2405
|
-
await handle.result({ timeoutMs: 4000
|
|
1927
|
+
await handle.result({ timeoutMs: 4000 });
|
|
2406
1928
|
|
|
2407
1929
|
const before = await client.getRun(handle.id);
|
|
2408
1930
|
const canceled = await client.cancelRun(handle.id, { reason: "late" });
|
|
@@ -2423,10 +1945,7 @@ test("output serialization: non-JSON output fails once and is not stuck", async
|
|
|
2423
1945
|
const client = createClient({ redis, prefix });
|
|
2424
1946
|
setDefaultClient(client);
|
|
2425
1947
|
|
|
2426
|
-
const wf = defineWorkflow(
|
|
2427
|
-
{
|
|
2428
|
-
name: "serialization-fail",
|
|
2429
|
-
queue: "q_serial",
|
|
1948
|
+
const wf = defineWorkflow("serialization-fail", {queue: "q_serial",
|
|
2430
1949
|
retries: { maxAttempts: 3 },
|
|
2431
1950
|
},
|
|
2432
1951
|
async () => ({ bad: 1n }),
|
|
@@ -2467,10 +1986,7 @@ test("retry step sequence: new steps keep monotonic order after cached steps", a
|
|
|
2467
1986
|
|
|
2468
1987
|
const failOnceKey = `${prefix}:t:seq-fail-once`;
|
|
2469
1988
|
|
|
2470
|
-
const wf = defineWorkflow(
|
|
2471
|
-
{
|
|
2472
|
-
name: "seq-retry",
|
|
2473
|
-
queue: "q_seq",
|
|
1989
|
+
const wf = defineWorkflow("seq-retry", {queue: "q_seq",
|
|
2474
1990
|
retries: { maxAttempts: 2 },
|
|
2475
1991
|
},
|
|
2476
1992
|
async ({ step }) => {
|
|
@@ -2498,7 +2014,7 @@ test("retry step sequence: new steps keep monotonic order after cached steps", a
|
|
|
2498
2014
|
});
|
|
2499
2015
|
|
|
2500
2016
|
const handle = await wf.run({});
|
|
2501
|
-
await handle.result({ timeoutMs: 12_000
|
|
2017
|
+
await handle.result({ timeoutMs: 12_000 });
|
|
2502
2018
|
|
|
2503
2019
|
const rawSteps = await redis.hgetall(`${prefix}:run:${handle.id}:steps`);
|
|
2504
2020
|
const seq1 = safeJsonParse<any>(rawSteps.step1 ?? null).seq;
|
|
@@ -2520,7 +2036,7 @@ test("listRuns: negative offset is clamped to zero", async () => {
|
|
|
2520
2036
|
const client = createClient({ redis, prefix });
|
|
2521
2037
|
setDefaultClient(client);
|
|
2522
2038
|
|
|
2523
|
-
const wf = defineWorkflow(
|
|
2039
|
+
const wf = defineWorkflow("offset-wf", {queue: "q_offset" }, async () => ({ ok: true }));
|
|
2524
2040
|
const worker = await startWorker({
|
|
2525
2041
|
redis,
|
|
2526
2042
|
prefix,
|
|
@@ -2529,13 +2045,13 @@ test("listRuns: negative offset is clamped to zero", async () => {
|
|
|
2529
2045
|
});
|
|
2530
2046
|
|
|
2531
2047
|
const h1 = await wf.run({});
|
|
2532
|
-
await h1.result({ timeoutMs: 4000
|
|
2048
|
+
await h1.result({ timeoutMs: 4000 });
|
|
2533
2049
|
await new Promise((r) => setTimeout(r, 5));
|
|
2534
2050
|
const h2 = await wf.run({});
|
|
2535
|
-
await h2.result({ timeoutMs: 4000
|
|
2051
|
+
await h2.result({ timeoutMs: 4000 });
|
|
2536
2052
|
await new Promise((r) => setTimeout(r, 5));
|
|
2537
2053
|
const h3 = await wf.run({});
|
|
2538
|
-
await h3.result({ timeoutMs: 4000
|
|
2054
|
+
await h3.result({ timeoutMs: 4000 });
|
|
2539
2055
|
|
|
2540
2056
|
const fromZero = await client.listRuns({ workflow: "offset-wf", limit: 1, offset: 0 });
|
|
2541
2057
|
const fromNegative = await client.listRuns({ workflow: "offset-wf", limit: 1, offset: -1 });
|
|
@@ -2577,10 +2093,7 @@ test("error serialization: non-serializable thrown values do not wedge a run", a
|
|
|
2577
2093
|
const client = createClient({ redis, prefix });
|
|
2578
2094
|
setDefaultClient(client);
|
|
2579
2095
|
|
|
2580
|
-
const wf = defineWorkflow(
|
|
2581
|
-
{
|
|
2582
|
-
name: "throw-bigint",
|
|
2583
|
-
queue: "q_bigint",
|
|
2096
|
+
const wf = defineWorkflow("throw-bigint", {queue: "q_bigint",
|
|
2584
2097
|
retries: { maxAttempts: 2 },
|
|
2585
2098
|
},
|
|
2586
2099
|
async () => {
|
|
@@ -2619,20 +2132,14 @@ test("cron trigger ids: delimiter-like custom ids do not collide", async () => {
|
|
|
2619
2132
|
const redis = new RedisClient(redisServer.url);
|
|
2620
2133
|
const client = createClient({ redis, prefix });
|
|
2621
2134
|
|
|
2622
|
-
defineWorkflow(
|
|
2623
|
-
|
|
2624
|
-
name: "wf:a",
|
|
2625
|
-
queue: "qa",
|
|
2626
|
-
triggers: { cron: [{ id: "b:c", expression: "*/5 * * * * *" }] },
|
|
2135
|
+
defineWorkflow("wf:a", {queue: "qa",
|
|
2136
|
+
cron: [{ id: "b:c", expression: "*/5 * * * * *" }],
|
|
2627
2137
|
},
|
|
2628
2138
|
async () => ({ ok: true }),
|
|
2629
2139
|
);
|
|
2630
2140
|
|
|
2631
|
-
defineWorkflow(
|
|
2632
|
-
|
|
2633
|
-
name: "wf:a:b",
|
|
2634
|
-
queue: "qb",
|
|
2635
|
-
triggers: { cron: [{ id: "c", expression: "*/5 * * * * *" }] },
|
|
2141
|
+
defineWorkflow("wf:a:b", {queue: "qb",
|
|
2142
|
+
cron: [{ id: "c", expression: "*/5 * * * * *" }],
|
|
2636
2143
|
},
|
|
2637
2144
|
async () => ({ ok: true }),
|
|
2638
2145
|
);
|
|
@@ -2669,7 +2176,7 @@ test("idempotencyKey: stale pointer is recovered instead of returning missing ru
|
|
|
2669
2176
|
const workflowName = "idem-recover";
|
|
2670
2177
|
const idem = "stale-key";
|
|
2671
2178
|
|
|
2672
|
-
const wf = defineWorkflow(
|
|
2179
|
+
const wf = defineWorkflow(workflowName, {queue }, async () => ({ ok: true }));
|
|
2673
2180
|
|
|
2674
2181
|
const worker = await startWorker({
|
|
2675
2182
|
redis,
|
|
@@ -2685,7 +2192,7 @@ test("idempotencyKey: stale pointer is recovered instead of returning missing ru
|
|
|
2685
2192
|
const handle = await wf.run({}, { idempotencyKey: idem });
|
|
2686
2193
|
expect(handle.id).not.toBe(staleRunId);
|
|
2687
2194
|
|
|
2688
|
-
const out = await handle.result({ timeoutMs: 5000
|
|
2195
|
+
const out = await handle.result({ timeoutMs: 5000 });
|
|
2689
2196
|
expect(out).toEqual({ ok: true });
|
|
2690
2197
|
|
|
2691
2198
|
const resolved = await redis.get(idempotencyRedisKey);
|
|
@@ -2706,7 +2213,7 @@ test("idempotencyKey: partial existing run is repaired and executed", async () =
|
|
|
2706
2213
|
const idem = "partial-key";
|
|
2707
2214
|
const countKey = `${prefix}:t:idem-partial-count`;
|
|
2708
2215
|
|
|
2709
|
-
const wf = defineWorkflow(
|
|
2216
|
+
const wf = defineWorkflow(workflowName, {queue }, async ({ step }) => {
|
|
2710
2217
|
await step.run({ name: "do" }, async () => {
|
|
2711
2218
|
await redis.incr(countKey);
|
|
2712
2219
|
return true;
|
|
@@ -2742,7 +2249,7 @@ test("idempotencyKey: partial existing run is repaired and executed", async () =
|
|
|
2742
2249
|
const handle = await wf.run({}, { idempotencyKey: idem });
|
|
2743
2250
|
expect(handle.id).toBe(runId);
|
|
2744
2251
|
|
|
2745
|
-
const out = await handle.result({ timeoutMs: 6000
|
|
2252
|
+
const out = await handle.result({ timeoutMs: 6000 });
|
|
2746
2253
|
expect(out).toEqual({ ok: true });
|
|
2747
2254
|
|
|
2748
2255
|
const count = Number((await redis.get(countKey)) ?? "0");
|
|
@@ -2757,16 +2264,11 @@ test("syncRegistry: duplicate custom cron ids in one workflow are rejected", asy
|
|
|
2757
2264
|
const redis = new RedisClient(redisServer.url);
|
|
2758
2265
|
const client = createClient({ redis, prefix });
|
|
2759
2266
|
|
|
2760
|
-
defineWorkflow(
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
cron: [
|
|
2766
|
-
{ id: "same", expression: "*/5 * * * * *", input: { n: 1 } },
|
|
2767
|
-
{ id: "same", expression: "*/7 * * * * *", input: { n: 2 } },
|
|
2768
|
-
],
|
|
2769
|
-
},
|
|
2267
|
+
defineWorkflow("dup-custom-cron-id", {queue: "q_dup_cron_id",
|
|
2268
|
+
cron: [
|
|
2269
|
+
{ id: "same", expression: "*/5 * * * * *", input: { n: 1 } },
|
|
2270
|
+
{ id: "same", expression: "*/7 * * * * *", input: { n: 2 } },
|
|
2271
|
+
],
|
|
2770
2272
|
},
|
|
2771
2273
|
async () => ({ ok: true }),
|
|
2772
2274
|
);
|
|
@@ -2783,7 +2285,7 @@ test("scheduled promoter: stale scheduled entry does not resurrect terminal run"
|
|
|
2783
2285
|
setDefaultClient(client);
|
|
2784
2286
|
|
|
2785
2287
|
const queue = "q_sched_guard";
|
|
2786
|
-
const wf = defineWorkflow(
|
|
2288
|
+
const wf = defineWorkflow("sched-guard", {queue }, async ({ step }) => {
|
|
2787
2289
|
await step.run({ name: "once" }, async () => true);
|
|
2788
2290
|
return { ok: true };
|
|
2789
2291
|
});
|
|
@@ -2796,7 +2298,7 @@ test("scheduled promoter: stale scheduled entry does not resurrect terminal run"
|
|
|
2796
2298
|
});
|
|
2797
2299
|
|
|
2798
2300
|
const handle = await wf.run({});
|
|
2799
|
-
const out = await handle.result({ timeoutMs: 5000
|
|
2301
|
+
const out = await handle.result({ timeoutMs: 5000 });
|
|
2800
2302
|
expect(out).toEqual({ ok: true });
|
|
2801
2303
|
|
|
2802
2304
|
const firstState = await client.getRun(handle.id);
|
|
@@ -2824,11 +2326,8 @@ test(
|
|
|
2824
2326
|
const workflowName = "cron-retry";
|
|
2825
2327
|
const counterKey = `${prefix}:t:cron-retry-count`;
|
|
2826
2328
|
|
|
2827
|
-
defineWorkflow(
|
|
2828
|
-
|
|
2829
|
-
name: workflowName,
|
|
2830
|
-
queue,
|
|
2831
|
-
triggers: { cron: [{ expression: "*/1 * * * * *" }] },
|
|
2329
|
+
defineWorkflow(workflowName, {queue,
|
|
2330
|
+
cron: [{ expression: "*/1 * * * * *" }],
|
|
2832
2331
|
},
|
|
2833
2332
|
async ({ step }) => {
|
|
2834
2333
|
await step.run({ name: "tick" }, async () => {
|
|
@@ -2893,11 +2392,8 @@ test(
|
|
|
2893
2392
|
const counterKey = `${prefix}:t:cronStaleCount`;
|
|
2894
2393
|
const cronLockKey = keys.lockCron(prefix);
|
|
2895
2394
|
|
|
2896
|
-
defineWorkflow(
|
|
2897
|
-
|
|
2898
|
-
name: workflowName,
|
|
2899
|
-
queue,
|
|
2900
|
-
triggers: { cron: [{ expression: "*/1 * * * * *" }] },
|
|
2395
|
+
defineWorkflow(workflowName, {queue,
|
|
2396
|
+
cron: [{ expression: "*/1 * * * * *" }],
|
|
2901
2397
|
},
|
|
2902
2398
|
async ({ step }) => {
|
|
2903
2399
|
await step.run({ name: "tick" }, async () => {
|
|
@@ -2950,8 +2446,8 @@ test("workflow names ending with ':runs' do not collide with workflow run indexe
|
|
|
2950
2446
|
setDefaultClient(client);
|
|
2951
2447
|
|
|
2952
2448
|
const queue = "q_keyspace";
|
|
2953
|
-
const base = defineWorkflow(
|
|
2954
|
-
defineWorkflow(
|
|
2449
|
+
const base = defineWorkflow("keyspace-base", {queue }, async () => ({ ok: "base" }));
|
|
2450
|
+
defineWorkflow("keyspace-base:runs", {queue }, async () => ({ ok: "suffix" }));
|
|
2955
2451
|
|
|
2956
2452
|
expect(keys.workflow(prefix, "keyspace-base:runs")).not.toBe(keys.workflowRuns(prefix, "keyspace-base"));
|
|
2957
2453
|
|
|
@@ -2963,7 +2459,7 @@ test("workflow names ending with ':runs' do not collide with workflow run indexe
|
|
|
2963
2459
|
});
|
|
2964
2460
|
|
|
2965
2461
|
const handle = await base.run({});
|
|
2966
|
-
const out = await handle.result({ timeoutMs: 5000
|
|
2462
|
+
const out = await handle.result({ timeoutMs: 5000 });
|
|
2967
2463
|
expect(out).toEqual({ ok: "base" });
|
|
2968
2464
|
|
|
2969
2465
|
const suffixMeta = await client.getWorkflowMeta("keyspace-base:runs");
|
|
@@ -2980,7 +2476,7 @@ test("cancelRun race: near-finish cancellation settles as canceled", async () =>
|
|
|
2980
2476
|
setDefaultClient(client);
|
|
2981
2477
|
|
|
2982
2478
|
const queue = "q_cancel_late";
|
|
2983
|
-
const wf = defineWorkflow(
|
|
2479
|
+
const wf = defineWorkflow("cancel-late", {queue }, async ({ step }) => {
|
|
2984
2480
|
await step.run({ name: "short" }, async () => {
|
|
2985
2481
|
await new Promise((r) => setTimeout(r, 100));
|
|
2986
2482
|
return true;
|
|
@@ -3025,7 +2521,7 @@ test("cancelRun race: near-finish cancellation settles as canceled", async () =>
|
|
|
3025
2521
|
const canceled = await cancelPromise;
|
|
3026
2522
|
expect(canceled).toBe(true);
|
|
3027
2523
|
|
|
3028
|
-
await expect(handle.result({ timeoutMs: 8000
|
|
2524
|
+
await expect(handle.result({ timeoutMs: 8000 })).rejects.toBeInstanceOf(CanceledError);
|
|
3029
2525
|
|
|
3030
2526
|
const state = await client.getRun(handle.id);
|
|
3031
2527
|
expect(state?.status).toBe("canceled");
|
|
@@ -3044,7 +2540,7 @@ test("cancelRun race: cancellation requested during finalize wins over success",
|
|
|
3044
2540
|
setDefaultClient(client);
|
|
3045
2541
|
|
|
3046
2542
|
const queue = "q_cancel_finalize_race";
|
|
3047
|
-
const wf = defineWorkflow(
|
|
2543
|
+
const wf = defineWorkflow("cancel-finalize-race", {queue }, async () => {
|
|
3048
2544
|
return { ok: true };
|
|
3049
2545
|
});
|
|
3050
2546
|
|
|
@@ -3089,7 +2585,7 @@ test("cancelRun race: cancellation requested during finalize wins over success",
|
|
|
3089
2585
|
{ timeoutMs: 6000, label: "run canceled in finalize race" },
|
|
3090
2586
|
);
|
|
3091
2587
|
|
|
3092
|
-
await expect(handle.result({ timeoutMs: 1000
|
|
2588
|
+
await expect(handle.result({ timeoutMs: 1000 })).rejects.toBeInstanceOf(CanceledError);
|
|
3093
2589
|
|
|
3094
2590
|
const state = await client.getRun(handle.id);
|
|
3095
2591
|
expect(state?.status).toBe("canceled");
|
|
@@ -3109,7 +2605,7 @@ test(
|
|
|
3109
2605
|
setDefaultClient(client);
|
|
3110
2606
|
|
|
3111
2607
|
const queue = "q_stop_abort";
|
|
3112
|
-
const wf = defineWorkflow(
|
|
2608
|
+
const wf = defineWorkflow("stop-abort", {queue }, async () => {
|
|
3113
2609
|
await new Promise<never>(() => {});
|
|
3114
2610
|
});
|
|
3115
2611
|
|
|
@@ -3151,4 +2647,4 @@ test(
|
|
|
3151
2647
|
}
|
|
3152
2648
|
},
|
|
3153
2649
|
{ timeout: 20_000 },
|
|
3154
|
-
);
|
|
2650
|
+
);
|