@redflow/client 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +290 -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 +29 -39
- 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 +153 -616
|
@@ -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,48 @@ 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.
|
|
535
|
+
test("step.emitWorkflow: supports workflow name strings", async () => {
|
|
563
536
|
const prefix = testPrefix();
|
|
564
537
|
const redis = new RedisClient(redisServer.url);
|
|
565
538
|
const client = createClient({ redis, prefix });
|
|
566
539
|
setDefaultClient(client);
|
|
567
540
|
|
|
568
|
-
|
|
569
|
-
|
|
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")}`;
|
|
541
|
+
defineWorkflow("child-emit-by-name", { queue: "q_emit_name_child" }, async ({ input }) => {
|
|
542
|
+
return { ok: true, input };
|
|
543
|
+
});
|
|
599
544
|
|
|
600
|
-
|
|
601
|
-
const
|
|
602
|
-
{ name: "emit-
|
|
603
|
-
|
|
545
|
+
const parent = defineWorkflow("parent-emit-by-name", { queue: "q_emit_name_parent" }, async ({ step }) => {
|
|
546
|
+
const childRunId = await step.emitWorkflow(
|
|
547
|
+
{ name: "emit-child-by-name" },
|
|
548
|
+
"child-emit-by-name",
|
|
549
|
+
{ value: 1 },
|
|
604
550
|
);
|
|
605
551
|
|
|
606
|
-
return {
|
|
607
|
-
autoCount: auto.length,
|
|
608
|
-
customCount: custom.length,
|
|
609
|
-
};
|
|
552
|
+
return { childRunId };
|
|
610
553
|
});
|
|
611
554
|
|
|
612
555
|
const worker = await startWorker({
|
|
613
556
|
redis,
|
|
614
557
|
prefix,
|
|
615
|
-
queues: ["
|
|
558
|
+
queues: ["q_emit_name_parent", "q_emit_name_child"],
|
|
616
559
|
concurrency: 2,
|
|
617
560
|
runtime: { leaseMs: 500 },
|
|
618
561
|
});
|
|
619
562
|
|
|
620
563
|
try {
|
|
621
|
-
const
|
|
622
|
-
const
|
|
623
|
-
expect(
|
|
564
|
+
const parentHandle = await parent.run({});
|
|
565
|
+
const parentOut = await parentHandle.result({ timeoutMs: 8000 });
|
|
566
|
+
expect(typeof parentOut.childRunId).toBe("string");
|
|
624
567
|
|
|
625
|
-
|
|
626
|
-
expect(
|
|
627
|
-
expect(observedIdempotencyKeys[1]).toBe("evt-custom");
|
|
568
|
+
const childOut = await client.makeRunHandle(parentOut.childRunId).result({ timeoutMs: 8000 });
|
|
569
|
+
expect(childOut).toEqual({ ok: true, input: { value: 1 } });
|
|
628
570
|
} finally {
|
|
629
|
-
RedflowClient.prototype.emitEvent = originalEmitEvent;
|
|
630
571
|
await worker.stop();
|
|
631
572
|
redis.close();
|
|
632
573
|
}
|
|
633
|
-
}, { timeout:
|
|
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
|
-
|
|
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
|
-
);
|
|
574
|
+
}, { timeout: 20_000 });
|
|
782
575
|
|
|
783
|
-
await new Promise((r) => setTimeout(r, 250));
|
|
784
|
-
const counter = Number((await redis.get(counterKey)) ?? "0");
|
|
785
|
-
expect(counter).toBe(1);
|
|
786
576
|
|
|
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
577
|
|
|
798
578
|
test("retries: step results are cached across attempts", async () => {
|
|
799
579
|
const prefix = testPrefix();
|
|
@@ -804,10 +584,7 @@ test("retries: step results are cached across attempts", async () => {
|
|
|
804
584
|
const step1CountKey = `${prefix}:t:step1Count`;
|
|
805
585
|
const failOnceKey = `${prefix}:t:failOnce`;
|
|
806
586
|
|
|
807
|
-
const wf = defineWorkflow(
|
|
808
|
-
{
|
|
809
|
-
name: "retry-wf",
|
|
810
|
-
queue: "q5",
|
|
587
|
+
const wf = defineWorkflow("retry-wf", {queue: "q5",
|
|
811
588
|
retries: { maxAttempts: 2 },
|
|
812
589
|
},
|
|
813
590
|
async ({ step }) => {
|
|
@@ -837,7 +614,7 @@ test("retries: step results are cached across attempts", async () => {
|
|
|
837
614
|
});
|
|
838
615
|
|
|
839
616
|
const handle = await wf.run({});
|
|
840
|
-
const out = await handle.result({ timeoutMs: 8000
|
|
617
|
+
const out = await handle.result({ timeoutMs: 8000 });
|
|
841
618
|
expect(out).toEqual({ ok: true });
|
|
842
619
|
|
|
843
620
|
const step1Count = Number((await redis.get(step1CountKey)) ?? "0");
|
|
@@ -859,10 +636,7 @@ test("retries: run queued before worker start keeps workflow maxAttempts", async
|
|
|
859
636
|
|
|
860
637
|
const failOnceKey = `${prefix}:t:retry-before-sync`;
|
|
861
638
|
|
|
862
|
-
const wf = defineWorkflow(
|
|
863
|
-
{
|
|
864
|
-
name: "retry-before-sync",
|
|
865
|
-
queue: "q5_before_sync",
|
|
639
|
+
const wf = defineWorkflow("retry-before-sync", {queue: "q5_before_sync",
|
|
866
640
|
retries: { maxAttempts: 2 },
|
|
867
641
|
},
|
|
868
642
|
async () => {
|
|
@@ -887,7 +661,7 @@ test("retries: run queued before worker start keeps workflow maxAttempts", async
|
|
|
887
661
|
runtime: { leaseMs: 500 },
|
|
888
662
|
});
|
|
889
663
|
|
|
890
|
-
const out = await handle.result({ timeoutMs: 8000
|
|
664
|
+
const out = await handle.result({ timeoutMs: 8000 });
|
|
891
665
|
expect(out).toEqual({ ok: true });
|
|
892
666
|
|
|
893
667
|
const state = await client.getRun(handle.id);
|
|
@@ -905,7 +679,7 @@ test("step timeout: run fails and error is recorded", async () => {
|
|
|
905
679
|
const client = createClient({ redis, prefix });
|
|
906
680
|
setDefaultClient(client);
|
|
907
681
|
|
|
908
|
-
const wf = defineWorkflow(
|
|
682
|
+
const wf = defineWorkflow("timeout-wf", {queue: "q6" }, async ({ step }) => {
|
|
909
683
|
await step.run({ name: "slow", timeoutMs: 80 }, async () => {
|
|
910
684
|
await new Promise((r) => setTimeout(r, 250));
|
|
911
685
|
return true;
|
|
@@ -949,7 +723,7 @@ test("cancellation: scheduled/queued/running", async () => {
|
|
|
949
723
|
|
|
950
724
|
const touchedKey = `${prefix}:t:touched`;
|
|
951
725
|
|
|
952
|
-
const wf = defineWorkflow(
|
|
726
|
+
const wf = defineWorkflow("cancel-wf", {queue: "q7" }, async ({ step }) => {
|
|
953
727
|
await step.run({ name: "hold" }, async () => {
|
|
954
728
|
await new Promise((r) => setTimeout(r, 600));
|
|
955
729
|
return true;
|
|
@@ -970,7 +744,7 @@ test("cancellation: scheduled/queued/running", async () => {
|
|
|
970
744
|
runtime: { leaseMs: 300 },
|
|
971
745
|
});
|
|
972
746
|
|
|
973
|
-
const handle = await wf.run({}, {
|
|
747
|
+
const handle = await wf.run({}, { runAt: new Date(Date.now() + 300) });
|
|
974
748
|
const canceled = await client.cancelRun(handle.id, { reason: "test" });
|
|
975
749
|
expect(canceled).toBe(true);
|
|
976
750
|
|
|
@@ -1042,7 +816,7 @@ test("cancel race: queued cancel before start does not execute handler", async (
|
|
|
1042
816
|
const queue = "q7_race";
|
|
1043
817
|
const sideEffectsKey = `${prefix}:t:cancelRaceSideEffects`;
|
|
1044
818
|
|
|
1045
|
-
const wf = defineWorkflow(
|
|
819
|
+
const wf = defineWorkflow("cancel-race-wf", {queue }, async () => {
|
|
1046
820
|
await redis.incr(sideEffectsKey);
|
|
1047
821
|
return { ok: true };
|
|
1048
822
|
});
|
|
@@ -1195,7 +969,7 @@ test("idempotencyKey: same key returns same run id and executes once", async ()
|
|
|
1195
969
|
|
|
1196
970
|
const countKey = `${prefix}:t:idemCount`;
|
|
1197
971
|
|
|
1198
|
-
const wf = defineWorkflow(
|
|
972
|
+
const wf = defineWorkflow("idem-wf", {queue: "q9" }, async ({ step }) => {
|
|
1199
973
|
await step.run({ name: "do" }, async () => {
|
|
1200
974
|
await redis.incr(countKey);
|
|
1201
975
|
return true;
|
|
@@ -1213,7 +987,7 @@ test("idempotencyKey: same key returns same run id and executes once", async ()
|
|
|
1213
987
|
const [h1, h2] = await Promise.all([wf.run({}, { idempotencyKey: "k" }), wf.run({}, { idempotencyKey: "k" })]);
|
|
1214
988
|
expect(h1.id).toBe(h2.id);
|
|
1215
989
|
|
|
1216
|
-
const out = await h1.result({ timeoutMs: 4000
|
|
990
|
+
const out = await h1.result({ timeoutMs: 4000 });
|
|
1217
991
|
expect(out).toEqual({ ok: true });
|
|
1218
992
|
|
|
1219
993
|
// Give the worker a moment to potentially pick duplicates.
|
|
@@ -1233,7 +1007,7 @@ test("idempotencyKey: delayed TTL refresh cannot fork duplicate runs", async ()
|
|
|
1233
1007
|
|
|
1234
1008
|
const countKey = `${prefix}:t:idem-race-fix-count`;
|
|
1235
1009
|
|
|
1236
|
-
const wf = defineWorkflow(
|
|
1010
|
+
const wf = defineWorkflow("idem-race-fix", {queue: "q9_race_fix" }, async ({ step }) => {
|
|
1237
1011
|
await step.run({ name: "do" }, async () => {
|
|
1238
1012
|
await redis.incr(countKey);
|
|
1239
1013
|
return true;
|
|
@@ -1267,8 +1041,8 @@ test("idempotencyKey: delayed TTL refresh cannot fork duplicate runs", async ()
|
|
|
1267
1041
|
expect(h1.id).toBe(h2.id);
|
|
1268
1042
|
|
|
1269
1043
|
const [out1, out2] = await Promise.all([
|
|
1270
|
-
h1.result({ timeoutMs: 5000
|
|
1271
|
-
h2.result({ timeoutMs: 5000
|
|
1044
|
+
h1.result({ timeoutMs: 5000 }),
|
|
1045
|
+
h2.result({ timeoutMs: 5000 }),
|
|
1272
1046
|
]);
|
|
1273
1047
|
expect(out1).toEqual({ ok: true });
|
|
1274
1048
|
expect(out2).toEqual({ ok: true });
|
|
@@ -1288,7 +1062,7 @@ test("enqueue: producer-side zadd failure does not leave runs orphaned", async (
|
|
|
1288
1062
|
const client = createClient({ redis, prefix });
|
|
1289
1063
|
setDefaultClient(client);
|
|
1290
1064
|
|
|
1291
|
-
const wf = defineWorkflow(
|
|
1065
|
+
const wf = defineWorkflow("enqueue-atomic", {queue: "q_enqueue_atomic" }, async () => ({ ok: true }));
|
|
1292
1066
|
await client.syncRegistry(getDefaultRegistry());
|
|
1293
1067
|
|
|
1294
1068
|
const originalZadd = redis.zadd.bind(redis);
|
|
@@ -1316,7 +1090,7 @@ test("enqueue: producer-side zadd failure does not leave runs orphaned", async (
|
|
|
1316
1090
|
});
|
|
1317
1091
|
|
|
1318
1092
|
try {
|
|
1319
|
-
const out = await handle.result({ timeoutMs: 6000
|
|
1093
|
+
const out = await handle.result({ timeoutMs: 6000 });
|
|
1320
1094
|
expect(out).toEqual({ ok: true });
|
|
1321
1095
|
} finally {
|
|
1322
1096
|
await worker.stop();
|
|
@@ -1330,8 +1104,8 @@ test("idempotencyKey: delimiter-like workflow/key pairs do not collide", async (
|
|
|
1330
1104
|
const client = createClient({ redis, prefix });
|
|
1331
1105
|
setDefaultClient(client);
|
|
1332
1106
|
|
|
1333
|
-
const wfA = defineWorkflow(
|
|
1334
|
-
const wfB = defineWorkflow(
|
|
1107
|
+
const wfA = defineWorkflow("idem:a", {queue: "q9a" }, async () => ({ workflow: "idem:a" }));
|
|
1108
|
+
const wfB = defineWorkflow("idem:a:b", {queue: "q9b" }, async () => ({ workflow: "idem:a:b" }));
|
|
1335
1109
|
|
|
1336
1110
|
const worker = await startWorker({
|
|
1337
1111
|
redis,
|
|
@@ -1349,8 +1123,8 @@ test("idempotencyKey: delimiter-like workflow/key pairs do not collide", async (
|
|
|
1349
1123
|
expect(h1.id).not.toBe(h2.id);
|
|
1350
1124
|
|
|
1351
1125
|
const [out1, out2] = await Promise.all([
|
|
1352
|
-
h1.result({ timeoutMs: 5000
|
|
1353
|
-
h2.result({ timeoutMs: 5000
|
|
1126
|
+
h1.result({ timeoutMs: 5000 }),
|
|
1127
|
+
h2.result({ timeoutMs: 5000 }),
|
|
1354
1128
|
]);
|
|
1355
1129
|
|
|
1356
1130
|
expect(out1).toEqual({ workflow: "idem:a" });
|
|
@@ -1368,7 +1142,7 @@ test("idempotencyTtl: short TTL expires and allows a new run with same key", asy
|
|
|
1368
1142
|
|
|
1369
1143
|
const countKey = `${prefix}:t:idemTtlCount`;
|
|
1370
1144
|
|
|
1371
|
-
const wf = defineWorkflow(
|
|
1145
|
+
const wf = defineWorkflow("idem-ttl-wf", {queue: "q_idem_ttl" }, async ({ step }) => {
|
|
1372
1146
|
await step.run({ name: "count" }, async () => {
|
|
1373
1147
|
await redis.incr(countKey);
|
|
1374
1148
|
return true;
|
|
@@ -1385,7 +1159,7 @@ test("idempotencyTtl: short TTL expires and allows a new run with same key", asy
|
|
|
1385
1159
|
|
|
1386
1160
|
// First run with 1-second TTL
|
|
1387
1161
|
const h1 = await wf.run({}, { idempotencyKey: "ttl-key", idempotencyTtl: 1 });
|
|
1388
|
-
const out1 = await h1.result({ timeoutMs: 5000
|
|
1162
|
+
const out1 = await h1.result({ timeoutMs: 5000 });
|
|
1389
1163
|
expect(out1).toEqual({ ok: true });
|
|
1390
1164
|
|
|
1391
1165
|
// Same key within TTL — returns existing run
|
|
@@ -1399,7 +1173,7 @@ test("idempotencyTtl: short TTL expires and allows a new run with same key", asy
|
|
|
1399
1173
|
const h3 = await wf.run({}, { idempotencyKey: "ttl-key", idempotencyTtl: 1 });
|
|
1400
1174
|
expect(h3.id).not.toBe(h1.id);
|
|
1401
1175
|
|
|
1402
|
-
const out3 = await h3.result({ timeoutMs: 5000
|
|
1176
|
+
const out3 = await h3.result({ timeoutMs: 5000 });
|
|
1403
1177
|
expect(out3).toEqual({ ok: true });
|
|
1404
1178
|
|
|
1405
1179
|
// Handler executed twice (once per unique run)
|
|
@@ -1410,157 +1184,13 @@ test("idempotencyTtl: short TTL expires and allows a new run with same key", asy
|
|
|
1410
1184
|
redis.close();
|
|
1411
1185
|
});
|
|
1412
1186
|
|
|
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
1187
|
test("input validation: invalid input fails once and is not retried", async () => {
|
|
1555
1188
|
const prefix = testPrefix();
|
|
1556
1189
|
const redis = new RedisClient(redisServer.url);
|
|
1557
1190
|
const client = createClient({ redis, prefix });
|
|
1558
1191
|
setDefaultClient(client);
|
|
1559
1192
|
|
|
1560
|
-
defineWorkflow(
|
|
1561
|
-
{
|
|
1562
|
-
name: "validate-wf",
|
|
1563
|
-
queue: "q11",
|
|
1193
|
+
defineWorkflow("validate-wf", {queue: "q11",
|
|
1564
1194
|
schema: z.object({ x: z.number() }),
|
|
1565
1195
|
retries: { maxAttempts: 3 },
|
|
1566
1196
|
},
|
|
@@ -1649,7 +1279,7 @@ test("cancel during step: run becomes canceled and step error kind is canceled",
|
|
|
1649
1279
|
const client = createClient({ redis, prefix });
|
|
1650
1280
|
setDefaultClient(client);
|
|
1651
1281
|
|
|
1652
|
-
const wf = defineWorkflow(
|
|
1282
|
+
const wf = defineWorkflow("cancel-mid-step", {queue: "q12" }, async ({ step }) => {
|
|
1653
1283
|
await step.run({ name: "slow" }, async () => {
|
|
1654
1284
|
await new Promise((r) => setTimeout(r, 2000));
|
|
1655
1285
|
return true;
|
|
@@ -1692,7 +1322,7 @@ test("cancel during step: run becomes canceled and step error kind is canceled",
|
|
|
1692
1322
|
expect((steps[0]?.error as any)?.kind).toBe("canceled");
|
|
1693
1323
|
|
|
1694
1324
|
// `result()` should reject with CanceledError.
|
|
1695
|
-
await expect(handle.result({ timeoutMs: 1000
|
|
1325
|
+
await expect(handle.result({ timeoutMs: 1000 })).rejects.toBeInstanceOf(CanceledError);
|
|
1696
1326
|
|
|
1697
1327
|
await worker.stop();
|
|
1698
1328
|
redis.close();
|
|
@@ -1707,7 +1337,7 @@ test("terminal run re-queued is ignored (no re-execution)", async () => {
|
|
|
1707
1337
|
const queue = "q13";
|
|
1708
1338
|
const countKey = `${prefix}:t:termCount`;
|
|
1709
1339
|
|
|
1710
|
-
const wf = defineWorkflow(
|
|
1340
|
+
const wf = defineWorkflow("term-wf", {queue }, async ({ step }) => {
|
|
1711
1341
|
await step.run({ name: "do" }, async () => {
|
|
1712
1342
|
await redis.incr(countKey);
|
|
1713
1343
|
return true;
|
|
@@ -1724,7 +1354,7 @@ test("terminal run re-queued is ignored (no re-execution)", async () => {
|
|
|
1724
1354
|
});
|
|
1725
1355
|
|
|
1726
1356
|
const handle = await wf.run({});
|
|
1727
|
-
const out = await handle.result({ timeoutMs: 4000
|
|
1357
|
+
const out = await handle.result({ timeoutMs: 4000 });
|
|
1728
1358
|
expect(out).toEqual({ ok: true });
|
|
1729
1359
|
|
|
1730
1360
|
const cnt1 = Number((await redis.get(countKey)) ?? "0");
|
|
@@ -1754,7 +1384,7 @@ test("lease+reaper: long running step is not duplicated", async () => {
|
|
|
1754
1384
|
const queue = "q14";
|
|
1755
1385
|
const countKey = `${prefix}:t:leaseCount`;
|
|
1756
1386
|
|
|
1757
|
-
const wf = defineWorkflow(
|
|
1387
|
+
const wf = defineWorkflow("lease-wf", {queue }, async ({ step }) => {
|
|
1758
1388
|
await step.run({ name: "long" }, async () => {
|
|
1759
1389
|
await redis.incr(countKey);
|
|
1760
1390
|
await new Promise((r) => setTimeout(r, 700));
|
|
@@ -1772,7 +1402,7 @@ test("lease+reaper: long running step is not duplicated", async () => {
|
|
|
1772
1402
|
});
|
|
1773
1403
|
|
|
1774
1404
|
const handle = await wf.run({});
|
|
1775
|
-
const out = await handle.result({ timeoutMs: 8000
|
|
1405
|
+
const out = await handle.result({ timeoutMs: 8000 });
|
|
1776
1406
|
expect(out).toEqual({ ok: true });
|
|
1777
1407
|
|
|
1778
1408
|
const cnt = Number((await redis.get(countKey)) ?? "0");
|
|
@@ -1796,11 +1426,8 @@ test(
|
|
|
1796
1426
|
const queue = "q15";
|
|
1797
1427
|
const counterKey = `${prefix}:t:cronLockCount`;
|
|
1798
1428
|
|
|
1799
|
-
defineWorkflow(
|
|
1800
|
-
|
|
1801
|
-
name: "cron-lock-wf",
|
|
1802
|
-
queue,
|
|
1803
|
-
triggers: { cron: [{ expression: "*/1 * * * * *" }] },
|
|
1429
|
+
defineWorkflow("cron-lock-wf", {queue,
|
|
1430
|
+
cron: [{ expression: "*/1 * * * * *" }],
|
|
1804
1431
|
},
|
|
1805
1432
|
async ({ step }) => {
|
|
1806
1433
|
await step.run({ name: "tick" }, async () => {
|
|
@@ -1839,10 +1466,7 @@ test(
|
|
|
1839
1466
|
const childFailOnceKey = `${prefix}:t:childFailOnce`;
|
|
1840
1467
|
const parentBeforeKey = `${prefix}:t:parentBefore`;
|
|
1841
1468
|
|
|
1842
|
-
const child = defineWorkflow(
|
|
1843
|
-
{
|
|
1844
|
-
name: "child-wf",
|
|
1845
|
-
queue: "q_child",
|
|
1469
|
+
const child = defineWorkflow("child-wf", {queue: "q_child",
|
|
1846
1470
|
retries: { maxAttempts: 2 },
|
|
1847
1471
|
},
|
|
1848
1472
|
async ({ step, run }) => {
|
|
@@ -1864,10 +1488,7 @@ test(
|
|
|
1864
1488
|
},
|
|
1865
1489
|
);
|
|
1866
1490
|
|
|
1867
|
-
const parent = defineWorkflow(
|
|
1868
|
-
{
|
|
1869
|
-
name: "parent-wf",
|
|
1870
|
-
queue: "q_parent",
|
|
1491
|
+
const parent = defineWorkflow("parent-wf", {queue: "q_parent",
|
|
1871
1492
|
},
|
|
1872
1493
|
async ({ step }) => {
|
|
1873
1494
|
await step.run({ name: "before" }, async () => {
|
|
@@ -1877,7 +1498,7 @@ test(
|
|
|
1877
1498
|
|
|
1878
1499
|
const childOut = await step.run({ name: "child" }, async () => {
|
|
1879
1500
|
const h = await child.run({});
|
|
1880
|
-
return await h.result({ timeoutMs: 10_000
|
|
1501
|
+
return await h.result({ timeoutMs: 10_000 });
|
|
1881
1502
|
});
|
|
1882
1503
|
|
|
1883
1504
|
return { ok: true, child: childOut };
|
|
@@ -1893,7 +1514,7 @@ test(
|
|
|
1893
1514
|
});
|
|
1894
1515
|
|
|
1895
1516
|
const handle = await parent.run({});
|
|
1896
|
-
const out = await handle.result({ timeoutMs: 15_000
|
|
1517
|
+
const out = await handle.result({ timeoutMs: 15_000 });
|
|
1897
1518
|
expect(out.ok).toBe(true);
|
|
1898
1519
|
expect(out.child.ok).toBe(true);
|
|
1899
1520
|
expect(out.child.attempt).toBe(2);
|
|
@@ -1925,7 +1546,7 @@ test(
|
|
|
1925
1546
|
setDefaultClient(client);
|
|
1926
1547
|
|
|
1927
1548
|
const queue = "q_status";
|
|
1928
|
-
const wf = defineWorkflow(
|
|
1549
|
+
const wf = defineWorkflow("status-wf", {queue }, async ({ step }) => {
|
|
1929
1550
|
await step.run({ name: "sleep" }, async () => {
|
|
1930
1551
|
await new Promise((r) => setTimeout(r, 350));
|
|
1931
1552
|
return true;
|
|
@@ -1956,7 +1577,7 @@ test(
|
|
|
1956
1577
|
const queuedMembers2 = await redis.zrange(queuedIndex, 0, -1);
|
|
1957
1578
|
expect(queuedMembers2.includes(handle.id)).toBe(false);
|
|
1958
1579
|
|
|
1959
|
-
const out = await handle.result({ timeoutMs: 8000
|
|
1580
|
+
const out = await handle.result({ timeoutMs: 8000 });
|
|
1960
1581
|
expect(out).toEqual({ ok: true });
|
|
1961
1582
|
|
|
1962
1583
|
const succeededMembers = await redis.zrange(`${prefix}:runs:status:succeeded`, 0, -1);
|
|
@@ -1971,7 +1592,7 @@ test(
|
|
|
1971
1592
|
);
|
|
1972
1593
|
|
|
1973
1594
|
test(
|
|
1974
|
-
"production: registry sync updates
|
|
1595
|
+
"production: registry sync updates cron definitions",
|
|
1975
1596
|
async () => {
|
|
1976
1597
|
const prefix = testPrefix();
|
|
1977
1598
|
const redis = new RedisClient(redisServer.url);
|
|
@@ -1979,27 +1600,13 @@ test(
|
|
|
1979
1600
|
setDefaultClient(client);
|
|
1980
1601
|
|
|
1981
1602
|
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
1603
|
|
|
1997
|
-
|
|
1604
|
+
defineWorkflow(name, {
|
|
1605
|
+
queue: "q_update",
|
|
1606
|
+
cron: [{ expression: "*/1 * * * * *" }],
|
|
1607
|
+
}, async () => ({ ok: true }));
|
|
1998
1608
|
|
|
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);
|
|
1609
|
+
await client.syncRegistry(getDefaultRegistry());
|
|
2003
1610
|
|
|
2004
1611
|
const workflowKey = `${prefix}:workflow:${name}`;
|
|
2005
1612
|
const cronIdsJson = await redis.hget(workflowKey, "cronIdsJson");
|
|
@@ -2011,25 +1618,14 @@ test(
|
|
|
2011
1618
|
const cronNext = await redis.zrange(`${prefix}:cron:next`, 0, -1);
|
|
2012
1619
|
expect(cronNext.includes(cronId)).toBe(true);
|
|
2013
1620
|
|
|
2014
|
-
// Simulate a new deployment: same workflow name,
|
|
1621
|
+
// Simulate a new deployment: same workflow name, cron removed.
|
|
2015
1622
|
__unstableResetDefaultRegistryForTests();
|
|
2016
|
-
defineWorkflow(
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
queue: "q_update",
|
|
2020
|
-
triggers: { events: [eventB] },
|
|
2021
|
-
},
|
|
2022
|
-
async () => ({ ok: true }),
|
|
2023
|
-
);
|
|
1623
|
+
defineWorkflow(name, {
|
|
1624
|
+
queue: "q_update",
|
|
1625
|
+
}, async () => ({ ok: true }));
|
|
2024
1626
|
|
|
2025
1627
|
await client.syncRegistry(getDefaultRegistry());
|
|
2026
1628
|
|
|
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
1629
|
expect(await redis.hget(`${prefix}:cron:def`, cronId)).toBeNull();
|
|
2034
1630
|
const cronNext2 = await redis.zrange(`${prefix}:cron:next`, 0, -1);
|
|
2035
1631
|
expect(cronNext2.includes(cronId)).toBe(false);
|
|
@@ -2051,10 +1647,7 @@ test(
|
|
|
2051
1647
|
const t1Key = `${prefix}:t:attempt1`;
|
|
2052
1648
|
const t2Key = `${prefix}:t:attempt2`;
|
|
2053
1649
|
|
|
2054
|
-
const wf = defineWorkflow(
|
|
2055
|
-
{
|
|
2056
|
-
name: "backoff-wf",
|
|
2057
|
-
queue,
|
|
1650
|
+
const wf = defineWorkflow("backoff-wf", {queue,
|
|
2058
1651
|
retries: { maxAttempts: 2 },
|
|
2059
1652
|
},
|
|
2060
1653
|
async ({ run }) => {
|
|
@@ -2073,7 +1666,7 @@ test(
|
|
|
2073
1666
|
});
|
|
2074
1667
|
|
|
2075
1668
|
const handle = await wf.run({});
|
|
2076
|
-
const out = await handle.result({ timeoutMs: 15_000
|
|
1669
|
+
const out = await handle.result({ timeoutMs: 15_000 });
|
|
2077
1670
|
expect(out).toEqual({ ok: true });
|
|
2078
1671
|
|
|
2079
1672
|
const t1 = Number((await redis.get(t1Key)) ?? "0");
|
|
@@ -2103,11 +1696,8 @@ test(
|
|
|
2103
1696
|
const queue = "q_failover";
|
|
2104
1697
|
const counterKey = `${prefix}:t:cronFailoverCount`;
|
|
2105
1698
|
|
|
2106
|
-
defineWorkflow(
|
|
2107
|
-
|
|
2108
|
-
name: "cron-failover-wf",
|
|
2109
|
-
queue,
|
|
2110
|
-
triggers: { cron: [{ expression: "*/1 * * * * *" }] },
|
|
1699
|
+
defineWorkflow("cron-failover-wf", {queue,
|
|
1700
|
+
cron: [{ expression: "*/1 * * * * *" }],
|
|
2111
1701
|
},
|
|
2112
1702
|
async ({ step }) => {
|
|
2113
1703
|
await step.run({ name: "tick" }, async () => {
|
|
@@ -2149,8 +1739,8 @@ test("listRuns: status + workflow filters are applied together", async () => {
|
|
|
2149
1739
|
|
|
2150
1740
|
const queue = "q_filters";
|
|
2151
1741
|
|
|
2152
|
-
const succeeds = defineWorkflow(
|
|
2153
|
-
const fails = defineWorkflow(
|
|
1742
|
+
const succeeds = defineWorkflow("filters-success", {queue }, async () => ({ ok: true }));
|
|
1743
|
+
const fails = defineWorkflow("filters-fail", {queue }, async () => {
|
|
2154
1744
|
throw new Error("expected failure");
|
|
2155
1745
|
});
|
|
2156
1746
|
|
|
@@ -2159,7 +1749,7 @@ test("listRuns: status + workflow filters are applied together", async () => {
|
|
|
2159
1749
|
const successHandle = await succeeds.run({});
|
|
2160
1750
|
const failHandle = await fails.run({});
|
|
2161
1751
|
|
|
2162
|
-
await successHandle.result({ timeoutMs: 4000
|
|
1752
|
+
await successHandle.result({ timeoutMs: 4000 });
|
|
2163
1753
|
await waitFor(
|
|
2164
1754
|
async () => (await client.getRun(failHandle.id))?.status === "failed",
|
|
2165
1755
|
{ timeoutMs: 6000, label: "failed run" },
|
|
@@ -2193,20 +1783,14 @@ test("cron trigger ids: same custom id in two workflows does not collide", async
|
|
|
2193
1783
|
const redis = new RedisClient(redisServer.url);
|
|
2194
1784
|
const client = createClient({ redis, prefix });
|
|
2195
1785
|
|
|
2196
|
-
defineWorkflow(
|
|
2197
|
-
|
|
2198
|
-
name: "cron-id-a",
|
|
2199
|
-
queue: "qa",
|
|
2200
|
-
triggers: { cron: [{ id: "shared", expression: "*/5 * * * * *" }] },
|
|
1786
|
+
defineWorkflow("cron-id-a", {queue: "qa",
|
|
1787
|
+
cron: [{ id: "shared", expression: "*/5 * * * * *" }],
|
|
2201
1788
|
},
|
|
2202
1789
|
async () => ({ ok: true }),
|
|
2203
1790
|
);
|
|
2204
1791
|
|
|
2205
|
-
defineWorkflow(
|
|
2206
|
-
|
|
2207
|
-
name: "cron-id-b",
|
|
2208
|
-
queue: "qb",
|
|
2209
|
-
triggers: { cron: [{ id: "shared", expression: "*/5 * * * * *" }] },
|
|
1792
|
+
defineWorkflow("cron-id-b", {queue: "qb",
|
|
1793
|
+
cron: [{ id: "shared", expression: "*/5 * * * * *" }],
|
|
2210
1794
|
},
|
|
2211
1795
|
async () => ({ ok: true }),
|
|
2212
1796
|
);
|
|
@@ -2233,11 +1817,8 @@ test("syncRegistry: cron trigger with explicit null input preserves null payload
|
|
|
2233
1817
|
|
|
2234
1818
|
const workflowName = "cron-null-input";
|
|
2235
1819
|
|
|
2236
|
-
defineWorkflow(
|
|
2237
|
-
|
|
2238
|
-
name: workflowName,
|
|
2239
|
-
queue: "q-cron-null",
|
|
2240
|
-
triggers: { cron: [{ id: "null-input", expression: "*/5 * * * * *", input: null }] },
|
|
1820
|
+
defineWorkflow(workflowName, {queue: "q-cron-null",
|
|
1821
|
+
cron: [{ id: "null-input", expression: "*/5 * * * * *", input: null }],
|
|
2241
1822
|
},
|
|
2242
1823
|
async () => ({ ok: true }),
|
|
2243
1824
|
);
|
|
@@ -2267,11 +1848,8 @@ test("syncRegistry: invalid cron expression removes existing next schedule for s
|
|
|
2267
1848
|
const workflowKey = `${prefix}:workflow:${workflowName}`;
|
|
2268
1849
|
const cronNextKey = `${prefix}:cron:next`;
|
|
2269
1850
|
|
|
2270
|
-
defineWorkflow(
|
|
2271
|
-
|
|
2272
|
-
name: workflowName,
|
|
2273
|
-
queue: "q-cron-invalid",
|
|
2274
|
-
triggers: { cron: [{ id: "stable", expression: "*/5 * * * * *" }] },
|
|
1851
|
+
defineWorkflow(workflowName, {queue: "q-cron-invalid",
|
|
1852
|
+
cron: [{ id: "stable", expression: "*/5 * * * * *" }],
|
|
2275
1853
|
},
|
|
2276
1854
|
async () => ({ ok: true }),
|
|
2277
1855
|
);
|
|
@@ -2286,11 +1864,8 @@ test("syncRegistry: invalid cron expression removes existing next schedule for s
|
|
|
2286
1864
|
expect(before.includes(cronId)).toBe(true);
|
|
2287
1865
|
|
|
2288
1866
|
__unstableResetDefaultRegistryForTests();
|
|
2289
|
-
defineWorkflow(
|
|
2290
|
-
|
|
2291
|
-
name: workflowName,
|
|
2292
|
-
queue: "q-cron-invalid",
|
|
2293
|
-
triggers: { cron: [{ id: "stable", expression: "not a valid cron" }] },
|
|
1867
|
+
defineWorkflow(workflowName, {queue: "q-cron-invalid",
|
|
1868
|
+
cron: [{ id: "stable", expression: "not a valid cron" }],
|
|
2294
1869
|
},
|
|
2295
1870
|
async () => ({ ok: true }),
|
|
2296
1871
|
);
|
|
@@ -2303,19 +1878,15 @@ test("syncRegistry: invalid cron expression removes existing next schedule for s
|
|
|
2303
1878
|
redis.close();
|
|
2304
1879
|
});
|
|
2305
1880
|
|
|
2306
|
-
test("syncRegistry: removes stale workflow metadata (
|
|
1881
|
+
test("syncRegistry: removes stale workflow metadata (cron)", async () => {
|
|
2307
1882
|
const prefix = testPrefix();
|
|
2308
1883
|
const redis = new RedisClient(redisServer.url);
|
|
2309
1884
|
const client = createClient({ redis, prefix });
|
|
2310
1885
|
|
|
2311
1886
|
const workflowName = "stale-workflow";
|
|
2312
|
-
const eventName = "stale.event";
|
|
2313
1887
|
|
|
2314
|
-
defineWorkflow(
|
|
2315
|
-
|
|
2316
|
-
name: workflowName,
|
|
2317
|
-
queue: "q-stale",
|
|
2318
|
-
triggers: { events: [eventName], cron: [{ id: "stale", expression: "*/10 * * * * *" }] },
|
|
1888
|
+
defineWorkflow(workflowName, {queue: "q-stale",
|
|
1889
|
+
cron: [{ id: "stale", expression: "*/10 * * * * *" }],
|
|
2319
1890
|
},
|
|
2320
1891
|
async () => ({ ok: true }),
|
|
2321
1892
|
);
|
|
@@ -2327,7 +1898,6 @@ test("syncRegistry: removes stale workflow metadata (events + cron)", async () =
|
|
|
2327
1898
|
expect(cronIds.length).toBe(1);
|
|
2328
1899
|
const cronId = cronIds[0]!;
|
|
2329
1900
|
|
|
2330
|
-
expect((await redis.smembers(`${prefix}:event:${eventName}:workflows`)).includes(workflowName)).toBe(true);
|
|
2331
1901
|
expect(await redis.hget(`${prefix}:cron:def`, cronId)).not.toBeNull();
|
|
2332
1902
|
|
|
2333
1903
|
// Force stale age beyond grace period and sync an empty registry.
|
|
@@ -2336,7 +1906,6 @@ test("syncRegistry: removes stale workflow metadata (events + cron)", async () =
|
|
|
2336
1906
|
await client.syncRegistry(getDefaultRegistry());
|
|
2337
1907
|
|
|
2338
1908
|
expect((await redis.smembers(`${prefix}:workflows`)).includes(workflowName)).toBe(false);
|
|
2339
|
-
expect((await redis.smembers(`${prefix}:event:${eventName}:workflows`)).includes(workflowName)).toBe(false);
|
|
2340
1909
|
expect(await redis.hget(workflowKey, "queue")).toBeNull();
|
|
2341
1910
|
expect(await redis.hget(`${prefix}:cron:def`, cronId)).toBeNull();
|
|
2342
1911
|
const cronNext = await redis.zrange(`${prefix}:cron:next`, 0, -1);
|
|
@@ -2351,22 +1920,16 @@ test("syncRegistry: stale cleanup is isolated by owner", async () => {
|
|
|
2351
1920
|
const client = createClient({ redis, prefix });
|
|
2352
1921
|
|
|
2353
1922
|
__unstableResetDefaultRegistryForTests();
|
|
2354
|
-
defineWorkflow(
|
|
2355
|
-
|
|
2356
|
-
name: "owner-a-wf",
|
|
2357
|
-
queue: "qa",
|
|
2358
|
-
triggers: { events: ["owner.a"], cron: [{ id: "a", expression: "*/30 * * * * *" }] },
|
|
1923
|
+
defineWorkflow("owner-a-wf", {queue: "qa",
|
|
1924
|
+
cron: [{ id: "a", expression: "*/30 * * * * *" }],
|
|
2359
1925
|
},
|
|
2360
1926
|
async () => ({ ok: true }),
|
|
2361
1927
|
);
|
|
2362
1928
|
await client.syncRegistry(getDefaultRegistry(), { owner: "svc-a" });
|
|
2363
1929
|
|
|
2364
1930
|
__unstableResetDefaultRegistryForTests();
|
|
2365
|
-
defineWorkflow(
|
|
2366
|
-
|
|
2367
|
-
name: "owner-b-wf",
|
|
2368
|
-
queue: "qb",
|
|
2369
|
-
triggers: { events: ["owner.b"], cron: [{ id: "b", expression: "*/30 * * * * *" }] },
|
|
1931
|
+
defineWorkflow("owner-b-wf", {queue: "qb",
|
|
1932
|
+
cron: [{ id: "b", expression: "*/30 * * * * *" }],
|
|
2370
1933
|
},
|
|
2371
1934
|
async () => ({ ok: true }),
|
|
2372
1935
|
);
|
|
@@ -2392,7 +1955,7 @@ test("cancelRun: terminal runs are unchanged", async () => {
|
|
|
2392
1955
|
const client = createClient({ redis, prefix });
|
|
2393
1956
|
setDefaultClient(client);
|
|
2394
1957
|
|
|
2395
|
-
const wf = defineWorkflow(
|
|
1958
|
+
const wf = defineWorkflow("cancel-terminal", {queue: "q_cancel_terminal" }, async () => ({ ok: true }));
|
|
2396
1959
|
|
|
2397
1960
|
const worker = await startWorker({
|
|
2398
1961
|
redis,
|
|
@@ -2402,7 +1965,7 @@ test("cancelRun: terminal runs are unchanged", async () => {
|
|
|
2402
1965
|
});
|
|
2403
1966
|
|
|
2404
1967
|
const handle = await wf.run({});
|
|
2405
|
-
await handle.result({ timeoutMs: 4000
|
|
1968
|
+
await handle.result({ timeoutMs: 4000 });
|
|
2406
1969
|
|
|
2407
1970
|
const before = await client.getRun(handle.id);
|
|
2408
1971
|
const canceled = await client.cancelRun(handle.id, { reason: "late" });
|
|
@@ -2423,10 +1986,7 @@ test("output serialization: non-JSON output fails once and is not stuck", async
|
|
|
2423
1986
|
const client = createClient({ redis, prefix });
|
|
2424
1987
|
setDefaultClient(client);
|
|
2425
1988
|
|
|
2426
|
-
const wf = defineWorkflow(
|
|
2427
|
-
{
|
|
2428
|
-
name: "serialization-fail",
|
|
2429
|
-
queue: "q_serial",
|
|
1989
|
+
const wf = defineWorkflow("serialization-fail", {queue: "q_serial",
|
|
2430
1990
|
retries: { maxAttempts: 3 },
|
|
2431
1991
|
},
|
|
2432
1992
|
async () => ({ bad: 1n }),
|
|
@@ -2467,10 +2027,7 @@ test("retry step sequence: new steps keep monotonic order after cached steps", a
|
|
|
2467
2027
|
|
|
2468
2028
|
const failOnceKey = `${prefix}:t:seq-fail-once`;
|
|
2469
2029
|
|
|
2470
|
-
const wf = defineWorkflow(
|
|
2471
|
-
{
|
|
2472
|
-
name: "seq-retry",
|
|
2473
|
-
queue: "q_seq",
|
|
2030
|
+
const wf = defineWorkflow("seq-retry", {queue: "q_seq",
|
|
2474
2031
|
retries: { maxAttempts: 2 },
|
|
2475
2032
|
},
|
|
2476
2033
|
async ({ step }) => {
|
|
@@ -2498,7 +2055,7 @@ test("retry step sequence: new steps keep monotonic order after cached steps", a
|
|
|
2498
2055
|
});
|
|
2499
2056
|
|
|
2500
2057
|
const handle = await wf.run({});
|
|
2501
|
-
await handle.result({ timeoutMs: 12_000
|
|
2058
|
+
await handle.result({ timeoutMs: 12_000 });
|
|
2502
2059
|
|
|
2503
2060
|
const rawSteps = await redis.hgetall(`${prefix}:run:${handle.id}:steps`);
|
|
2504
2061
|
const seq1 = safeJsonParse<any>(rawSteps.step1 ?? null).seq;
|
|
@@ -2520,7 +2077,7 @@ test("listRuns: negative offset is clamped to zero", async () => {
|
|
|
2520
2077
|
const client = createClient({ redis, prefix });
|
|
2521
2078
|
setDefaultClient(client);
|
|
2522
2079
|
|
|
2523
|
-
const wf = defineWorkflow(
|
|
2080
|
+
const wf = defineWorkflow("offset-wf", {queue: "q_offset" }, async () => ({ ok: true }));
|
|
2524
2081
|
const worker = await startWorker({
|
|
2525
2082
|
redis,
|
|
2526
2083
|
prefix,
|
|
@@ -2529,13 +2086,13 @@ test("listRuns: negative offset is clamped to zero", async () => {
|
|
|
2529
2086
|
});
|
|
2530
2087
|
|
|
2531
2088
|
const h1 = await wf.run({});
|
|
2532
|
-
await h1.result({ timeoutMs: 4000
|
|
2089
|
+
await h1.result({ timeoutMs: 4000 });
|
|
2533
2090
|
await new Promise((r) => setTimeout(r, 5));
|
|
2534
2091
|
const h2 = await wf.run({});
|
|
2535
|
-
await h2.result({ timeoutMs: 4000
|
|
2092
|
+
await h2.result({ timeoutMs: 4000 });
|
|
2536
2093
|
await new Promise((r) => setTimeout(r, 5));
|
|
2537
2094
|
const h3 = await wf.run({});
|
|
2538
|
-
await h3.result({ timeoutMs: 4000
|
|
2095
|
+
await h3.result({ timeoutMs: 4000 });
|
|
2539
2096
|
|
|
2540
2097
|
const fromZero = await client.listRuns({ workflow: "offset-wf", limit: 1, offset: 0 });
|
|
2541
2098
|
const fromNegative = await client.listRuns({ workflow: "offset-wf", limit: 1, offset: -1 });
|
|
@@ -2577,10 +2134,7 @@ test("error serialization: non-serializable thrown values do not wedge a run", a
|
|
|
2577
2134
|
const client = createClient({ redis, prefix });
|
|
2578
2135
|
setDefaultClient(client);
|
|
2579
2136
|
|
|
2580
|
-
const wf = defineWorkflow(
|
|
2581
|
-
{
|
|
2582
|
-
name: "throw-bigint",
|
|
2583
|
-
queue: "q_bigint",
|
|
2137
|
+
const wf = defineWorkflow("throw-bigint", {queue: "q_bigint",
|
|
2584
2138
|
retries: { maxAttempts: 2 },
|
|
2585
2139
|
},
|
|
2586
2140
|
async () => {
|
|
@@ -2619,20 +2173,14 @@ test("cron trigger ids: delimiter-like custom ids do not collide", async () => {
|
|
|
2619
2173
|
const redis = new RedisClient(redisServer.url);
|
|
2620
2174
|
const client = createClient({ redis, prefix });
|
|
2621
2175
|
|
|
2622
|
-
defineWorkflow(
|
|
2623
|
-
|
|
2624
|
-
name: "wf:a",
|
|
2625
|
-
queue: "qa",
|
|
2626
|
-
triggers: { cron: [{ id: "b:c", expression: "*/5 * * * * *" }] },
|
|
2176
|
+
defineWorkflow("wf:a", {queue: "qa",
|
|
2177
|
+
cron: [{ id: "b:c", expression: "*/5 * * * * *" }],
|
|
2627
2178
|
},
|
|
2628
2179
|
async () => ({ ok: true }),
|
|
2629
2180
|
);
|
|
2630
2181
|
|
|
2631
|
-
defineWorkflow(
|
|
2632
|
-
|
|
2633
|
-
name: "wf:a:b",
|
|
2634
|
-
queue: "qb",
|
|
2635
|
-
triggers: { cron: [{ id: "c", expression: "*/5 * * * * *" }] },
|
|
2182
|
+
defineWorkflow("wf:a:b", {queue: "qb",
|
|
2183
|
+
cron: [{ id: "c", expression: "*/5 * * * * *" }],
|
|
2636
2184
|
},
|
|
2637
2185
|
async () => ({ ok: true }),
|
|
2638
2186
|
);
|
|
@@ -2669,7 +2217,7 @@ test("idempotencyKey: stale pointer is recovered instead of returning missing ru
|
|
|
2669
2217
|
const workflowName = "idem-recover";
|
|
2670
2218
|
const idem = "stale-key";
|
|
2671
2219
|
|
|
2672
|
-
const wf = defineWorkflow(
|
|
2220
|
+
const wf = defineWorkflow(workflowName, {queue }, async () => ({ ok: true }));
|
|
2673
2221
|
|
|
2674
2222
|
const worker = await startWorker({
|
|
2675
2223
|
redis,
|
|
@@ -2685,7 +2233,7 @@ test("idempotencyKey: stale pointer is recovered instead of returning missing ru
|
|
|
2685
2233
|
const handle = await wf.run({}, { idempotencyKey: idem });
|
|
2686
2234
|
expect(handle.id).not.toBe(staleRunId);
|
|
2687
2235
|
|
|
2688
|
-
const out = await handle.result({ timeoutMs: 5000
|
|
2236
|
+
const out = await handle.result({ timeoutMs: 5000 });
|
|
2689
2237
|
expect(out).toEqual({ ok: true });
|
|
2690
2238
|
|
|
2691
2239
|
const resolved = await redis.get(idempotencyRedisKey);
|
|
@@ -2706,7 +2254,7 @@ test("idempotencyKey: partial existing run is repaired and executed", async () =
|
|
|
2706
2254
|
const idem = "partial-key";
|
|
2707
2255
|
const countKey = `${prefix}:t:idem-partial-count`;
|
|
2708
2256
|
|
|
2709
|
-
const wf = defineWorkflow(
|
|
2257
|
+
const wf = defineWorkflow(workflowName, {queue }, async ({ step }) => {
|
|
2710
2258
|
await step.run({ name: "do" }, async () => {
|
|
2711
2259
|
await redis.incr(countKey);
|
|
2712
2260
|
return true;
|
|
@@ -2742,7 +2290,7 @@ test("idempotencyKey: partial existing run is repaired and executed", async () =
|
|
|
2742
2290
|
const handle = await wf.run({}, { idempotencyKey: idem });
|
|
2743
2291
|
expect(handle.id).toBe(runId);
|
|
2744
2292
|
|
|
2745
|
-
const out = await handle.result({ timeoutMs: 6000
|
|
2293
|
+
const out = await handle.result({ timeoutMs: 6000 });
|
|
2746
2294
|
expect(out).toEqual({ ok: true });
|
|
2747
2295
|
|
|
2748
2296
|
const count = Number((await redis.get(countKey)) ?? "0");
|
|
@@ -2757,16 +2305,11 @@ test("syncRegistry: duplicate custom cron ids in one workflow are rejected", asy
|
|
|
2757
2305
|
const redis = new RedisClient(redisServer.url);
|
|
2758
2306
|
const client = createClient({ redis, prefix });
|
|
2759
2307
|
|
|
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
|
-
},
|
|
2308
|
+
defineWorkflow("dup-custom-cron-id", {queue: "q_dup_cron_id",
|
|
2309
|
+
cron: [
|
|
2310
|
+
{ id: "same", expression: "*/5 * * * * *", input: { n: 1 } },
|
|
2311
|
+
{ id: "same", expression: "*/7 * * * * *", input: { n: 2 } },
|
|
2312
|
+
],
|
|
2770
2313
|
},
|
|
2771
2314
|
async () => ({ ok: true }),
|
|
2772
2315
|
);
|
|
@@ -2783,7 +2326,7 @@ test("scheduled promoter: stale scheduled entry does not resurrect terminal run"
|
|
|
2783
2326
|
setDefaultClient(client);
|
|
2784
2327
|
|
|
2785
2328
|
const queue = "q_sched_guard";
|
|
2786
|
-
const wf = defineWorkflow(
|
|
2329
|
+
const wf = defineWorkflow("sched-guard", {queue }, async ({ step }) => {
|
|
2787
2330
|
await step.run({ name: "once" }, async () => true);
|
|
2788
2331
|
return { ok: true };
|
|
2789
2332
|
});
|
|
@@ -2796,7 +2339,7 @@ test("scheduled promoter: stale scheduled entry does not resurrect terminal run"
|
|
|
2796
2339
|
});
|
|
2797
2340
|
|
|
2798
2341
|
const handle = await wf.run({});
|
|
2799
|
-
const out = await handle.result({ timeoutMs: 5000
|
|
2342
|
+
const out = await handle.result({ timeoutMs: 5000 });
|
|
2800
2343
|
expect(out).toEqual({ ok: true });
|
|
2801
2344
|
|
|
2802
2345
|
const firstState = await client.getRun(handle.id);
|
|
@@ -2824,11 +2367,8 @@ test(
|
|
|
2824
2367
|
const workflowName = "cron-retry";
|
|
2825
2368
|
const counterKey = `${prefix}:t:cron-retry-count`;
|
|
2826
2369
|
|
|
2827
|
-
defineWorkflow(
|
|
2828
|
-
|
|
2829
|
-
name: workflowName,
|
|
2830
|
-
queue,
|
|
2831
|
-
triggers: { cron: [{ expression: "*/1 * * * * *" }] },
|
|
2370
|
+
defineWorkflow(workflowName, {queue,
|
|
2371
|
+
cron: [{ expression: "*/1 * * * * *" }],
|
|
2832
2372
|
},
|
|
2833
2373
|
async ({ step }) => {
|
|
2834
2374
|
await step.run({ name: "tick" }, async () => {
|
|
@@ -2893,11 +2433,8 @@ test(
|
|
|
2893
2433
|
const counterKey = `${prefix}:t:cronStaleCount`;
|
|
2894
2434
|
const cronLockKey = keys.lockCron(prefix);
|
|
2895
2435
|
|
|
2896
|
-
defineWorkflow(
|
|
2897
|
-
|
|
2898
|
-
name: workflowName,
|
|
2899
|
-
queue,
|
|
2900
|
-
triggers: { cron: [{ expression: "*/1 * * * * *" }] },
|
|
2436
|
+
defineWorkflow(workflowName, {queue,
|
|
2437
|
+
cron: [{ expression: "*/1 * * * * *" }],
|
|
2901
2438
|
},
|
|
2902
2439
|
async ({ step }) => {
|
|
2903
2440
|
await step.run({ name: "tick" }, async () => {
|
|
@@ -2950,8 +2487,8 @@ test("workflow names ending with ':runs' do not collide with workflow run indexe
|
|
|
2950
2487
|
setDefaultClient(client);
|
|
2951
2488
|
|
|
2952
2489
|
const queue = "q_keyspace";
|
|
2953
|
-
const base = defineWorkflow(
|
|
2954
|
-
defineWorkflow(
|
|
2490
|
+
const base = defineWorkflow("keyspace-base", {queue }, async () => ({ ok: "base" }));
|
|
2491
|
+
defineWorkflow("keyspace-base:runs", {queue }, async () => ({ ok: "suffix" }));
|
|
2955
2492
|
|
|
2956
2493
|
expect(keys.workflow(prefix, "keyspace-base:runs")).not.toBe(keys.workflowRuns(prefix, "keyspace-base"));
|
|
2957
2494
|
|
|
@@ -2963,7 +2500,7 @@ test("workflow names ending with ':runs' do not collide with workflow run indexe
|
|
|
2963
2500
|
});
|
|
2964
2501
|
|
|
2965
2502
|
const handle = await base.run({});
|
|
2966
|
-
const out = await handle.result({ timeoutMs: 5000
|
|
2503
|
+
const out = await handle.result({ timeoutMs: 5000 });
|
|
2967
2504
|
expect(out).toEqual({ ok: "base" });
|
|
2968
2505
|
|
|
2969
2506
|
const suffixMeta = await client.getWorkflowMeta("keyspace-base:runs");
|
|
@@ -2980,7 +2517,7 @@ test("cancelRun race: near-finish cancellation settles as canceled", async () =>
|
|
|
2980
2517
|
setDefaultClient(client);
|
|
2981
2518
|
|
|
2982
2519
|
const queue = "q_cancel_late";
|
|
2983
|
-
const wf = defineWorkflow(
|
|
2520
|
+
const wf = defineWorkflow("cancel-late", {queue }, async ({ step }) => {
|
|
2984
2521
|
await step.run({ name: "short" }, async () => {
|
|
2985
2522
|
await new Promise((r) => setTimeout(r, 100));
|
|
2986
2523
|
return true;
|
|
@@ -3025,7 +2562,7 @@ test("cancelRun race: near-finish cancellation settles as canceled", async () =>
|
|
|
3025
2562
|
const canceled = await cancelPromise;
|
|
3026
2563
|
expect(canceled).toBe(true);
|
|
3027
2564
|
|
|
3028
|
-
await expect(handle.result({ timeoutMs: 8000
|
|
2565
|
+
await expect(handle.result({ timeoutMs: 8000 })).rejects.toBeInstanceOf(CanceledError);
|
|
3029
2566
|
|
|
3030
2567
|
const state = await client.getRun(handle.id);
|
|
3031
2568
|
expect(state?.status).toBe("canceled");
|
|
@@ -3044,7 +2581,7 @@ test("cancelRun race: cancellation requested during finalize wins over success",
|
|
|
3044
2581
|
setDefaultClient(client);
|
|
3045
2582
|
|
|
3046
2583
|
const queue = "q_cancel_finalize_race";
|
|
3047
|
-
const wf = defineWorkflow(
|
|
2584
|
+
const wf = defineWorkflow("cancel-finalize-race", {queue }, async () => {
|
|
3048
2585
|
return { ok: true };
|
|
3049
2586
|
});
|
|
3050
2587
|
|
|
@@ -3089,7 +2626,7 @@ test("cancelRun race: cancellation requested during finalize wins over success",
|
|
|
3089
2626
|
{ timeoutMs: 6000, label: "run canceled in finalize race" },
|
|
3090
2627
|
);
|
|
3091
2628
|
|
|
3092
|
-
await expect(handle.result({ timeoutMs: 1000
|
|
2629
|
+
await expect(handle.result({ timeoutMs: 1000 })).rejects.toBeInstanceOf(CanceledError);
|
|
3093
2630
|
|
|
3094
2631
|
const state = await client.getRun(handle.id);
|
|
3095
2632
|
expect(state?.status).toBe("canceled");
|
|
@@ -3109,7 +2646,7 @@ test(
|
|
|
3109
2646
|
setDefaultClient(client);
|
|
3110
2647
|
|
|
3111
2648
|
const queue = "q_stop_abort";
|
|
3112
|
-
const wf = defineWorkflow(
|
|
2649
|
+
const wf = defineWorkflow("stop-abort", {queue }, async () => {
|
|
3113
2650
|
await new Promise<never>(() => {});
|
|
3114
2651
|
});
|
|
3115
2652
|
|
|
@@ -3151,4 +2688,4 @@ test(
|
|
|
3151
2688
|
}
|
|
3152
2689
|
},
|
|
3153
2690
|
{ timeout: 20_000 },
|
|
3154
|
-
);
|
|
2691
|
+
);
|