@redflow/client 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3154 @@
1
+ import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
2
+ import { RedisClient } from "bun";
3
+ import { z } from "zod";
4
+ import {
5
+ createClient,
6
+ CanceledError,
7
+ NonRetriableError,
8
+ UnknownWorkflowError,
9
+ defineWorkflow,
10
+ setDefaultClient,
11
+ startWorker,
12
+ type RunStatus,
13
+ } from "../src/index";
14
+ import { RedflowClient } from "../src/client";
15
+ import { keys } from "../src/internal/keys";
16
+ import { __unstableResetDefaultRegistryForTests, getDefaultRegistry } from "../src/registry";
17
+ import { safeJsonParse, safeJsonStringify } from "../src/internal/json";
18
+ import { waitFor } from "./helpers/waitFor";
19
+ import { startRedisTestServer } from "./helpers/redisTestServer";
20
+
21
+ let redisServer: Awaited<ReturnType<typeof startRedisTestServer>>;
22
+
23
+ beforeAll(async () => {
24
+ redisServer = await startRedisTestServer();
25
+ });
26
+
27
+ afterAll(async () => {
28
+ await redisServer.stop();
29
+ });
30
+
31
+ beforeEach(() => {
32
+ __unstableResetDefaultRegistryForTests();
33
+ });
34
+
35
+ function testPrefix(): string {
36
+ return `redflow:test:${crypto.randomUUID()}`;
37
+ }
38
+
39
+ test("manual run: succeeds and records steps", async () => {
40
+ const prefix = testPrefix();
41
+ const redis = new RedisClient(redisServer.url);
42
+ const client = createClient({ redis, prefix });
43
+ setDefaultClient(client);
44
+
45
+ const wf = defineWorkflow(
46
+ {
47
+ name: "manual-success",
48
+ queue: "q1",
49
+ schema: z.object({ n: z.number().int() }),
50
+ },
51
+ async ({ input, step }) => {
52
+ const a = await step.run({ name: "a" }, async () => input.n + 1);
53
+ const b = await step.run({ name: "b" }, async () => a + 1);
54
+ return { ok: true, b };
55
+ },
56
+ );
57
+
58
+ const worker = await startWorker({
59
+ redis,
60
+ prefix,
61
+ queues: ["q1"],
62
+ runtime: { leaseMs: 500 },
63
+ });
64
+
65
+ const handle = await wf.run({ n: 1 });
66
+ const result = await handle.result({ timeoutMs: 3000, pollMs: 50 });
67
+ expect(result).toEqual({ ok: true, b: 3 });
68
+
69
+ const steps = await client.getRunSteps(handle.id);
70
+ expect(steps.map((s) => s.name)).toEqual(["a", "b"]);
71
+ expect(steps.map((s) => s.status)).toEqual(["succeeded", "succeeded"]);
72
+
73
+ await worker.stop();
74
+ redis.close();
75
+ });
76
+
77
+ test("availableAt: scheduled -> promoted -> executed", async () => {
78
+ const prefix = testPrefix();
79
+ const redis = new RedisClient(redisServer.url);
80
+ const client = createClient({ redis, prefix });
81
+ setDefaultClient(client);
82
+
83
+ const wf = defineWorkflow({ name: "delayed", queue: "q2" }, async ({ step }) => {
84
+ await step.run({ name: "do" }, async () => true);
85
+ return { ok: true };
86
+ });
87
+
88
+ const worker = await startWorker({
89
+ redis,
90
+ prefix,
91
+ queues: ["q2"],
92
+ runtime: { leaseMs: 500 },
93
+ });
94
+
95
+ const runAt = new Date(Date.now() + 1200);
96
+ const handle = await wf.run({}, { availableAt: runAt });
97
+ const state1 = await client.getRun(handle.id);
98
+ expect(state1?.status).toBe("scheduled");
99
+
100
+ const result = await handle.result({ timeoutMs: 4000, pollMs: 50 });
101
+ expect(result).toEqual({ ok: true });
102
+
103
+ const state2 = await client.getRun(handle.id);
104
+ expect(state2?.status).toBe("succeeded");
105
+ expect((state2?.startedAt ?? 0) + 40).toBeGreaterThanOrEqual(runAt.getTime());
106
+ expect(state2?.availableAt).toBeUndefined();
107
+
108
+ await worker.stop();
109
+ redis.close();
110
+ });
111
+
112
+ test("availableAt: cleared after retry exhaustion final failure", async () => {
113
+ const prefix = testPrefix();
114
+ const redis = new RedisClient(redisServer.url);
115
+ const client = createClient({ redis, prefix });
116
+ setDefaultClient(client);
117
+
118
+ const wf = defineWorkflow(
119
+ {
120
+ name: "retry-failure-clears-available-at",
121
+ queue: "q2_retry_fail",
122
+ retries: { maxAttempts: 2 },
123
+ },
124
+ async () => {
125
+ throw new Error("boom");
126
+ },
127
+ );
128
+
129
+ const worker = await startWorker({
130
+ redis,
131
+ prefix,
132
+ queues: ["q2_retry_fail"],
133
+ runtime: { leaseMs: 500 },
134
+ });
135
+
136
+ const handle = await wf.run({});
137
+ await expect(handle.result({ timeoutMs: 8000, pollMs: 50 })).rejects.toThrow("Run failed");
138
+
139
+ const state = await client.getRun(handle.id);
140
+ expect(state?.status).toBe("failed");
141
+ expect(state?.attempt).toBe(2);
142
+ expect(state?.availableAt).toBeUndefined();
143
+
144
+ await worker.stop();
145
+ redis.close();
146
+ });
147
+
148
+ test("NonRetriableError: skips retries and fails immediately on attempt 1", async () => {
149
+ const prefix = testPrefix();
150
+ const redis = new RedisClient(redisServer.url);
151
+ const client = createClient({ redis, prefix });
152
+ setDefaultClient(client);
153
+
154
+ const wf = defineWorkflow(
155
+ {
156
+ name: "non-retriable-error-wf",
157
+ queue: "q_non_retriable",
158
+ retries: { maxAttempts: 3 },
159
+ },
160
+ async () => {
161
+ throw new NonRetriableError("permanent failure");
162
+ },
163
+ );
164
+
165
+ const worker = await startWorker({
166
+ redis,
167
+ prefix,
168
+ queues: ["q_non_retriable"],
169
+ runtime: { leaseMs: 500 },
170
+ });
171
+
172
+ const handle = await wf.run({});
173
+ await expect(handle.result({ timeoutMs: 8000, pollMs: 50 })).rejects.toThrow("Run failed");
174
+
175
+ const state = await client.getRun(handle.id);
176
+ expect(state?.status).toBe("failed");
177
+ // Should fail on the first attempt without retrying
178
+ expect(state?.attempt).toBe(1);
179
+
180
+ // Verify the error is serialized correctly
181
+ const error = state?.error as Record<string, unknown> | undefined;
182
+ expect(error?.kind).toBe("non_retriable");
183
+ expect(error?.message).toBe("permanent failure");
184
+
185
+ await worker.stop();
186
+ redis.close();
187
+ });
188
+
189
+ test("onFailure: called after retry exhaustion with correct context", async () => {
190
+ const prefix = testPrefix();
191
+ const redis = new RedisClient(redisServer.url);
192
+ const client = createClient({ redis, prefix });
193
+ setDefaultClient(client);
194
+
195
+ let failureCtx: any = null;
196
+
197
+ const wf = defineWorkflow(
198
+ {
199
+ name: "on-failure-retry-exhaustion",
200
+ queue: "q_on_failure_1",
201
+ retries: { maxAttempts: 2 },
202
+ onFailure: (ctx) => {
203
+ failureCtx = ctx;
204
+ },
205
+ },
206
+ async () => {
207
+ throw new Error("always fails");
208
+ },
209
+ );
210
+
211
+ const worker = await startWorker({
212
+ redis,
213
+ prefix,
214
+ queues: ["q_on_failure_1"],
215
+ runtime: { leaseMs: 500 },
216
+ });
217
+
218
+ const handle = await wf.run({ some: "data" });
219
+ await expect(handle.result({ timeoutMs: 8000, pollMs: 50 })).rejects.toThrow("Run failed");
220
+
221
+ expect(failureCtx).not.toBeNull();
222
+ expect(failureCtx.error).toBeInstanceOf(Error);
223
+ expect(failureCtx.error.message).toBe("always fails");
224
+ expect(failureCtx.input).toEqual({ some: "data" });
225
+ expect(failureCtx.run.id).toBe(handle.id);
226
+ expect(failureCtx.run.workflow).toBe("on-failure-retry-exhaustion");
227
+ expect(failureCtx.run.queue).toBe("q_on_failure_1");
228
+ expect(failureCtx.run.attempt).toBe(2);
229
+ expect(failureCtx.run.maxAttempts).toBe(2);
230
+
231
+ await worker.stop();
232
+ redis.close();
233
+ });
234
+
235
+ test("onFailure: called immediately with NonRetriableError", async () => {
236
+ const prefix = testPrefix();
237
+ const redis = new RedisClient(redisServer.url);
238
+ const client = createClient({ redis, prefix });
239
+ setDefaultClient(client);
240
+
241
+ let failureCtx: any = null;
242
+
243
+ const wf = defineWorkflow(
244
+ {
245
+ name: "on-failure-non-retriable",
246
+ queue: "q_on_failure_2",
247
+ retries: { maxAttempts: 5 },
248
+ onFailure: (ctx) => {
249
+ failureCtx = ctx;
250
+ },
251
+ },
252
+ async () => {
253
+ throw new NonRetriableError("permanent");
254
+ },
255
+ );
256
+
257
+ const worker = await startWorker({
258
+ redis,
259
+ prefix,
260
+ queues: ["q_on_failure_2"],
261
+ runtime: { leaseMs: 500 },
262
+ });
263
+
264
+ const handle = await wf.run({});
265
+ await expect(handle.result({ timeoutMs: 8000, pollMs: 50 })).rejects.toThrow("Run failed");
266
+
267
+ expect(failureCtx).not.toBeNull();
268
+ expect(failureCtx.error).toBeInstanceOf(NonRetriableError);
269
+ expect(failureCtx.run.attempt).toBe(1);
270
+
271
+ await worker.stop();
272
+ redis.close();
273
+ });
274
+
275
+ test("onFailure: NOT called on cancellation", async () => {
276
+ const prefix = testPrefix();
277
+ const redis = new RedisClient(redisServer.url);
278
+ const client = createClient({ redis, prefix });
279
+ setDefaultClient(client);
280
+
281
+ let onFailureCalled = false;
282
+
283
+ const wf = defineWorkflow(
284
+ {
285
+ name: "on-failure-not-on-cancel",
286
+ queue: "q_on_failure_3",
287
+ onFailure: () => {
288
+ onFailureCalled = true;
289
+ },
290
+ },
291
+ async ({ signal }) => {
292
+ // Long-running work that will be canceled
293
+ await new Promise((resolve, reject) => {
294
+ const timer = setTimeout(resolve, 60_000);
295
+ signal.addEventListener("abort", () => {
296
+ clearTimeout(timer);
297
+ reject(new Error("aborted"));
298
+ });
299
+ });
300
+ return {};
301
+ },
302
+ );
303
+
304
+ const worker = await startWorker({
305
+ redis,
306
+ prefix,
307
+ queues: ["q_on_failure_3"],
308
+ runtime: { leaseMs: 500 },
309
+ });
310
+
311
+ const handle = await wf.run({});
312
+
313
+ // Wait for the run to start
314
+ await waitFor(async () => {
315
+ const state = await client.getRun(handle.id);
316
+ return state?.status === "running";
317
+ }, { timeoutMs: 5000, label: "run starts" });
318
+
319
+ await client.cancelRun(handle.id);
320
+
321
+ await waitFor(async () => {
322
+ const state = await client.getRun(handle.id);
323
+ return state?.status === "canceled";
324
+ }, { timeoutMs: 5000, label: "run canceled" });
325
+
326
+ // Give a small window for any erroneous onFailure call
327
+ await new Promise((r) => setTimeout(r, 200));
328
+ expect(onFailureCalled).toBe(false);
329
+
330
+ await worker.stop();
331
+ redis.close();
332
+ });
333
+
334
+ test("cron: creates runs and executes workflow", async () => {
335
+ const prefix = testPrefix();
336
+ const redis = new RedisClient(redisServer.url);
337
+ const client = createClient({ redis, prefix });
338
+ setDefaultClient(client);
339
+
340
+ const counterKey = `${prefix}:t:cronCount`;
341
+
342
+ defineWorkflow(
343
+ {
344
+ name: "cron-wf",
345
+ queue: "q4",
346
+ triggers: { cron: [{ expression: "*/1 * * * * *", input: { x: 1 } }] },
347
+ },
348
+ async ({ step }) => {
349
+ await step.run({ name: "tick" }, async () => {
350
+ await redis.incr(counterKey);
351
+ return true;
352
+ });
353
+ return { ok: true };
354
+ },
355
+ );
356
+
357
+ const worker = await startWorker({
358
+ redis,
359
+ prefix,
360
+ queues: ["q4"],
361
+ runtime: { leaseMs: 500 },
362
+ });
363
+
364
+ await waitFor(
365
+ async () => {
366
+ const v = await redis.get(counterKey);
367
+ return Number(v ?? "0") >= 1;
368
+ },
369
+ { timeoutMs: 5000, label: "cron tick" },
370
+ );
371
+
372
+ await worker.stop();
373
+ redis.close();
374
+ });
375
+
376
+ test("step.runWorkflow: auto idempotency and override are forwarded to child runs", async () => {
377
+ const prefix = testPrefix();
378
+ const redis = new RedisClient(redisServer.url);
379
+ const client = createClient({ redis, prefix });
380
+ setDefaultClient(client);
381
+
382
+ const observedIdempotencyKeys: string[] = [];
383
+ const originalRunByName: RedflowClient["runByName"] = RedflowClient.prototype.runByName;
384
+
385
+ RedflowClient.prototype.runByName = (async function (
386
+ this: RedflowClient,
387
+ workflowName: string,
388
+ input: unknown,
389
+ options?: Parameters<RedflowClient["runByName"]>[2],
390
+ ): Promise<Awaited<ReturnType<RedflowClient["runByName"]>>> {
391
+ if (workflowName === "child-rw-forward") {
392
+ observedIdempotencyKeys.push(options?.idempotencyKey ?? "");
393
+ }
394
+ return await originalRunByName.call(this, workflowName, input, options);
395
+ }) as RedflowClient["runByName"];
396
+
397
+ const child = defineWorkflow({ name: "child-rw-forward", queue: "q_rw_child" }, async ({ input }) => input);
398
+
399
+ const encodePart = (value: string) => `${value.length}:${value}`;
400
+ let expectedAutoIdempotencyKey = "";
401
+
402
+ const parent = defineWorkflow({ name: "parent-rw-forward", queue: "q_rw_parent" }, async ({ run, step }) => {
403
+ expectedAutoIdempotencyKey =
404
+ `stepwf:${encodePart(run.id)}:${encodePart("child-auto")}:${encodePart(child.name)}`;
405
+
406
+ const auto = await step.runWorkflow({ name: "child-auto" }, child, { n: 1 });
407
+ const custom = await step.runWorkflow({ name: "child-custom", idempotencyKey: "custom-idem" }, child, { n: 2 });
408
+
409
+ return { auto, custom };
410
+ });
411
+
412
+ const worker = await startWorker({
413
+ redis,
414
+ prefix,
415
+ queues: ["q_rw_parent", "q_rw_child"],
416
+ concurrency: 2,
417
+ runtime: { leaseMs: 500 },
418
+ });
419
+
420
+ try {
421
+ const handle = await parent.run({});
422
+ const out = await handle.result({ timeoutMs: 5000, pollMs: 50 });
423
+ expect(out).toEqual({
424
+ auto: { n: 1 },
425
+ custom: { n: 2 },
426
+ });
427
+
428
+ expect(observedIdempotencyKeys.length).toBe(2);
429
+ expect(observedIdempotencyKeys[0]).toBe(expectedAutoIdempotencyKey);
430
+ expect(observedIdempotencyKeys[1]).toBe("custom-idem");
431
+ } finally {
432
+ RedflowClient.prototype.runByName = originalRunByName;
433
+ await worker.stop();
434
+ redis.close();
435
+ }
436
+ }, { timeout: 15_000 });
437
+
438
+ test("step.runWorkflow: child workflow executes once across parent retries", async () => {
439
+ const prefix = testPrefix();
440
+ const redis = new RedisClient(redisServer.url);
441
+ const client = createClient({ redis, prefix });
442
+ setDefaultClient(client);
443
+
444
+ const childCountKey = `${prefix}:t:child-runWorkflow-count`;
445
+ const failOnceKey = `${prefix}:t:parent-runWorkflow-fail-once`;
446
+
447
+ const child = defineWorkflow({ name: "child-rw-retry", queue: "q_rw_retry_child" }, async ({ step }) => {
448
+ await step.run({ name: "count-child" }, async () => {
449
+ await redis.incr(childCountKey);
450
+ return true;
451
+ });
452
+
453
+ return { ok: true };
454
+ });
455
+
456
+ const parent = defineWorkflow(
457
+ {
458
+ name: "parent-rw-retry",
459
+ queue: "q_rw_retry_parent",
460
+ retries: { maxAttempts: 2 },
461
+ },
462
+ async ({ step }) => {
463
+ const childOut = await step.runWorkflow({ name: "invoke-child" }, child, {});
464
+
465
+ await step.run({ name: "fail-once" }, async () => {
466
+ const seen = await redis.get(failOnceKey);
467
+ if (!seen) {
468
+ await redis.set(failOnceKey, "1");
469
+ throw new Error("boom once");
470
+ }
471
+ return true;
472
+ });
473
+
474
+ return childOut;
475
+ },
476
+ );
477
+
478
+ const worker = await startWorker({
479
+ redis,
480
+ prefix,
481
+ queues: ["q_rw_retry_parent", "q_rw_retry_child"],
482
+ concurrency: 2,
483
+ runtime: { leaseMs: 500 },
484
+ });
485
+
486
+ const handle = await parent.run({});
487
+ const out = await handle.result({ timeoutMs: 8000, pollMs: 50 });
488
+ expect(out).toEqual({ ok: true });
489
+
490
+ const childCount = Number((await redis.get(childCountKey)) ?? "0");
491
+ expect(childCount).toBe(1);
492
+
493
+ const childRuns = await client.listRuns({ workflow: "child-rw-retry", limit: 10 });
494
+ expect(childRuns.length).toBe(1);
495
+
496
+ const parentState = await client.getRun(handle.id);
497
+ expect(parentState?.status).toBe("succeeded");
498
+ expect(parentState?.attempt).toBe(2);
499
+
500
+ await worker.stop();
501
+ redis.close();
502
+ }, { timeout: 15_000 });
503
+
504
+ test("step.runWorkflow: same queue avoids self-deadlock with concurrency 1", async () => {
505
+ const prefix = testPrefix();
506
+ const redis = new RedisClient(redisServer.url);
507
+ const client = createClient({ redis, prefix });
508
+ setDefaultClient(client);
509
+
510
+ const childFailOnceKey = `${prefix}:t:child-rw-fail-once`;
511
+ const childCountKey = `${prefix}:t:child-rw-count`;
512
+
513
+ const child = defineWorkflow(
514
+ {
515
+ name: "child-rw-single-worker",
516
+ queue: "q_rw_single",
517
+ retries: { maxAttempts: 2 },
518
+ },
519
+ async ({ step }) => {
520
+ await step.run({ name: "do-child" }, async () => {
521
+ const seen = await redis.get(childFailOnceKey);
522
+ if (!seen) {
523
+ await redis.set(childFailOnceKey, "1");
524
+ throw new Error("child fails once");
525
+ }
526
+ await redis.incr(childCountKey);
527
+ return true;
528
+ });
529
+
530
+ return { ok: true };
531
+ },
532
+ );
533
+
534
+ const parent = defineWorkflow({ name: "parent-rw-single-worker", queue: "q_rw_single" }, async ({ step }) => {
535
+ return await step.runWorkflow({ name: "call-child" }, child, {});
536
+ });
537
+
538
+ const worker = await startWorker({
539
+ redis,
540
+ prefix,
541
+ queues: ["q_rw_single"],
542
+ concurrency: 1,
543
+ runtime: { leaseMs: 500, blmoveTimeoutSec: 0.2, reaperIntervalMs: 50 },
544
+ });
545
+
546
+ const handle = await parent.run({});
547
+ const out = await handle.result({ timeoutMs: 10_000, pollMs: 50 });
548
+ expect(out).toEqual({ ok: true });
549
+
550
+ const childCount = Number((await redis.get(childCountKey)) ?? "0");
551
+ expect(childCount).toBe(1);
552
+
553
+ const childRuns = await client.listRuns({ workflow: "child-rw-single-worker", limit: 10 });
554
+ expect(childRuns.length).toBe(1);
555
+ expect(childRuns[0]?.status).toBe("succeeded");
556
+ expect(childRuns[0]?.attempt).toBe(2);
557
+
558
+ await worker.stop();
559
+ redis.close();
560
+ }, { timeout: 20_000 });
561
+
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
+
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
+
798
+ test("retries: step results are cached across attempts", async () => {
799
+ const prefix = testPrefix();
800
+ const redis = new RedisClient(redisServer.url);
801
+ const client = createClient({ redis, prefix });
802
+ setDefaultClient(client);
803
+
804
+ const step1CountKey = `${prefix}:t:step1Count`;
805
+ const failOnceKey = `${prefix}:t:failOnce`;
806
+
807
+ const wf = defineWorkflow(
808
+ {
809
+ name: "retry-wf",
810
+ queue: "q5",
811
+ retries: { maxAttempts: 2 },
812
+ },
813
+ async ({ step }) => {
814
+ await step.run({ name: "step1" }, async () => {
815
+ await redis.incr(step1CountKey);
816
+ return { ok: true };
817
+ });
818
+
819
+ await step.run({ name: "step2" }, async () => {
820
+ const seen = await redis.get(failOnceKey);
821
+ if (!seen) {
822
+ await redis.set(failOnceKey, "1");
823
+ throw new Error("boom");
824
+ }
825
+ return true;
826
+ });
827
+
828
+ return { ok: true };
829
+ },
830
+ );
831
+
832
+ const worker = await startWorker({
833
+ redis,
834
+ prefix,
835
+ queues: ["q5"],
836
+ runtime: { leaseMs: 500 },
837
+ });
838
+
839
+ const handle = await wf.run({});
840
+ const out = await handle.result({ timeoutMs: 8000, pollMs: 50 });
841
+ expect(out).toEqual({ ok: true });
842
+
843
+ const step1Count = Number((await redis.get(step1CountKey)) ?? "0");
844
+ expect(step1Count).toBe(1);
845
+
846
+ const state = await client.getRun(handle.id);
847
+ expect(state?.attempt).toBe(2);
848
+ expect(state?.status).toBe("succeeded");
849
+
850
+ await worker.stop();
851
+ redis.close();
852
+ });
853
+
854
+ test("retries: run queued before worker start keeps workflow maxAttempts", async () => {
855
+ const prefix = testPrefix();
856
+ const redis = new RedisClient(redisServer.url);
857
+ const client = createClient({ redis, prefix });
858
+ setDefaultClient(client);
859
+
860
+ const failOnceKey = `${prefix}:t:retry-before-sync`;
861
+
862
+ const wf = defineWorkflow(
863
+ {
864
+ name: "retry-before-sync",
865
+ queue: "q5_before_sync",
866
+ retries: { maxAttempts: 2 },
867
+ },
868
+ async () => {
869
+ const seen = await redis.get(failOnceKey);
870
+ if (!seen) {
871
+ await redis.set(failOnceKey, "1");
872
+ throw new Error("fail once");
873
+ }
874
+
875
+ return { ok: true };
876
+ },
877
+ );
878
+
879
+ const handle = await wf.run({});
880
+ const queuedState = await client.getRun(handle.id);
881
+ expect(queuedState?.maxAttempts).toBe(2);
882
+
883
+ const worker = await startWorker({
884
+ redis,
885
+ prefix,
886
+ queues: ["q5_before_sync"],
887
+ runtime: { leaseMs: 500 },
888
+ });
889
+
890
+ const out = await handle.result({ timeoutMs: 8000, pollMs: 50 });
891
+ expect(out).toEqual({ ok: true });
892
+
893
+ const state = await client.getRun(handle.id);
894
+ expect(state?.status).toBe("succeeded");
895
+ expect(state?.attempt).toBe(2);
896
+ expect(state?.maxAttempts).toBe(2);
897
+
898
+ await worker.stop();
899
+ redis.close();
900
+ });
901
+
902
+ test("step timeout: run fails and error is recorded", async () => {
903
+ const prefix = testPrefix();
904
+ const redis = new RedisClient(redisServer.url);
905
+ const client = createClient({ redis, prefix });
906
+ setDefaultClient(client);
907
+
908
+ const wf = defineWorkflow({ name: "timeout-wf", queue: "q6" }, async ({ step }) => {
909
+ await step.run({ name: "slow", timeoutMs: 80 }, async () => {
910
+ await new Promise((r) => setTimeout(r, 250));
911
+ return true;
912
+ });
913
+ return { ok: true };
914
+ });
915
+
916
+ const worker = await startWorker({
917
+ redis,
918
+ prefix,
919
+ queues: ["q6"],
920
+ runtime: { leaseMs: 500 },
921
+ });
922
+
923
+ const handle = await wf.run({});
924
+ await waitFor(
925
+ async () => {
926
+ const st = await client.getRun(handle.id);
927
+ return st?.status === "failed";
928
+ },
929
+ { timeoutMs: 6000, label: "run fail" },
930
+ );
931
+
932
+ const state = await client.getRun(handle.id);
933
+ expect(state?.status).toBe("failed");
934
+ expect((state?.error as any)?.kind).toBe("timeout");
935
+
936
+ const steps = await client.getRunSteps(handle.id);
937
+ expect(steps[0]?.status).toBe("failed");
938
+ expect((steps[0]?.error as any)?.kind).toBe("timeout");
939
+
940
+ await worker.stop();
941
+ redis.close();
942
+ });
943
+
944
+ test("cancellation: scheduled/queued/running", async () => {
945
+ const prefix = testPrefix();
946
+ const redis = new RedisClient(redisServer.url);
947
+ const client = createClient({ redis, prefix });
948
+ setDefaultClient(client);
949
+
950
+ const touchedKey = `${prefix}:t:touched`;
951
+
952
+ const wf = defineWorkflow({ name: "cancel-wf", queue: "q7" }, async ({ step }) => {
953
+ await step.run({ name: "hold" }, async () => {
954
+ await new Promise((r) => setTimeout(r, 600));
955
+ return true;
956
+ });
957
+ await step.run({ name: "touch" }, async () => {
958
+ await redis.incr(touchedKey);
959
+ return true;
960
+ });
961
+ return { ok: true };
962
+ });
963
+
964
+ // Scheduled cancel.
965
+ {
966
+ const worker = await startWorker({
967
+ redis,
968
+ prefix,
969
+ queues: ["q7"],
970
+ runtime: { leaseMs: 300 },
971
+ });
972
+
973
+ const handle = await wf.run({}, { availableAt: new Date(Date.now() + 300) });
974
+ const canceled = await client.cancelRun(handle.id, { reason: "test" });
975
+ expect(canceled).toBe(true);
976
+
977
+ await waitFor(
978
+ async () => {
979
+ const st = await client.getRun(handle.id);
980
+ return st?.status === "canceled";
981
+ },
982
+ { timeoutMs: 2000, label: "scheduled cancel" },
983
+ );
984
+
985
+ await worker.stop();
986
+ }
987
+
988
+ // Queued cancel (no worker running).
989
+ {
990
+ const handle = await wf.run({});
991
+ const st = await client.getRun(handle.id);
992
+ expect(st?.status).toBe("queued");
993
+
994
+ const ok = await client.cancelRun(handle.id, { reason: "test" });
995
+ expect(ok).toBe(true);
996
+ const st2 = await client.getRun(handle.id);
997
+ expect(st2?.status).toBe("canceled");
998
+ }
999
+
1000
+ // Running cancel.
1001
+ {
1002
+ const worker = await startWorker({
1003
+ redis,
1004
+ prefix,
1005
+ queues: ["q7"],
1006
+ runtime: { leaseMs: 500 },
1007
+ });
1008
+
1009
+ const handle = await wf.run({});
1010
+ await waitFor(
1011
+ async () => {
1012
+ const st = await client.getRun(handle.id);
1013
+ return st?.status === "running";
1014
+ },
1015
+ { timeoutMs: 4000, label: "running status" },
1016
+ );
1017
+
1018
+ await client.cancelRun(handle.id, { reason: "test" });
1019
+ await waitFor(
1020
+ async () => {
1021
+ const st = await client.getRun(handle.id);
1022
+ return st?.status === "canceled";
1023
+ },
1024
+ { timeoutMs: 5000, label: "running cancel" },
1025
+ );
1026
+
1027
+ const touched = Number((await redis.get(touchedKey)) ?? "0");
1028
+ expect(touched).toBe(0);
1029
+
1030
+ await worker.stop();
1031
+ }
1032
+
1033
+ redis.close();
1034
+ });
1035
+
1036
+ test("cancel race: queued cancel before start does not execute handler", async () => {
1037
+ const prefix = testPrefix();
1038
+ const redis = new RedisClient(redisServer.url);
1039
+ const client = createClient({ redis, prefix });
1040
+ setDefaultClient(client);
1041
+
1042
+ const queue = "q7_race";
1043
+ const sideEffectsKey = `${prefix}:t:cancelRaceSideEffects`;
1044
+
1045
+ const wf = defineWorkflow({ name: "cancel-race-wf", queue }, async () => {
1046
+ await redis.incr(sideEffectsKey);
1047
+ return { ok: true };
1048
+ });
1049
+
1050
+ const originalTransitionRunStatus: RedflowClient["transitionRunStatus"] = RedflowClient.prototype.transitionRunStatus;
1051
+ const originalTransitionRunStatusIfCurrent: RedflowClient["transitionRunStatusIfCurrent"] =
1052
+ RedflowClient.prototype.transitionRunStatusIfCurrent;
1053
+
1054
+ let transitionDelayed = false;
1055
+
1056
+ RedflowClient.prototype.transitionRunStatus = (async function (
1057
+ this: RedflowClient,
1058
+ runId: string,
1059
+ nextStatus: RunStatus,
1060
+ updatedAt: number,
1061
+ ): Promise<void> {
1062
+ if (!transitionDelayed && nextStatus === "running") {
1063
+ transitionDelayed = true;
1064
+ await new Promise((r) => setTimeout(r, 250));
1065
+ }
1066
+ return await originalTransitionRunStatus.call(this, runId, nextStatus, updatedAt);
1067
+ }) as RedflowClient["transitionRunStatus"];
1068
+
1069
+ RedflowClient.prototype.transitionRunStatusIfCurrent = (async function (
1070
+ this: RedflowClient,
1071
+ runId: string,
1072
+ expectedStatus: RunStatus,
1073
+ nextStatus: RunStatus,
1074
+ updatedAt: number,
1075
+ ): Promise<boolean> {
1076
+ if (!transitionDelayed && expectedStatus === "queued" && nextStatus === "running") {
1077
+ transitionDelayed = true;
1078
+ await new Promise((r) => setTimeout(r, 250));
1079
+ }
1080
+ return await originalTransitionRunStatusIfCurrent.call(this, runId, expectedStatus, nextStatus, updatedAt);
1081
+ }) as RedflowClient["transitionRunStatusIfCurrent"];
1082
+
1083
+ let worker: Awaited<ReturnType<typeof startWorker>> | null = null;
1084
+ try {
1085
+ worker = await startWorker({
1086
+ redis,
1087
+ prefix,
1088
+ queues: [queue],
1089
+ runtime: { leaseMs: 500, blmoveTimeoutSec: 0.2, reaperIntervalMs: 50 },
1090
+ });
1091
+
1092
+ const handle = await wf.run({});
1093
+
1094
+ await waitFor(
1095
+ async () => {
1096
+ const processing = await redis.lrange(`${prefix}:q:${queue}:processing`, 0, -1);
1097
+ return processing.includes(handle.id);
1098
+ },
1099
+ { timeoutMs: 4000, label: "run moved to processing" },
1100
+ );
1101
+
1102
+ const canceled = await client.cancelRun(handle.id, { reason: "race" });
1103
+ expect(canceled).toBe(true);
1104
+
1105
+ await waitFor(
1106
+ async () => {
1107
+ const st = await client.getRun(handle.id);
1108
+ return st?.status === "canceled";
1109
+ },
1110
+ { timeoutMs: 6000, label: "run canceled" },
1111
+ );
1112
+
1113
+ await new Promise((r) => setTimeout(r, 400));
1114
+ const sideEffects = Number((await redis.get(sideEffectsKey)) ?? "0");
1115
+ expect(sideEffects).toBe(0);
1116
+ } finally {
1117
+ if (worker) await worker.stop();
1118
+ RedflowClient.prototype.transitionRunStatus = originalTransitionRunStatus;
1119
+ RedflowClient.prototype.transitionRunStatusIfCurrent = originalTransitionRunStatusIfCurrent;
1120
+ redis.close();
1121
+ }
1122
+ });
1123
+
1124
+ test("crash recovery: processing item is re-queued and cached steps are not re-run", async () => {
1125
+ const prefix = testPrefix();
1126
+ const workflowName = "crash-recover";
1127
+ const queue = "q8";
1128
+ const step1Key = `${prefix}:t:crashStep1`;
1129
+
1130
+ // Spawn a crashing worker.
1131
+ const crashProc = Bun.spawn({
1132
+ cmd: ["bun", "tests/fixtures/worker-crash.ts"],
1133
+ env: {
1134
+ ...process.env,
1135
+ REDIS_URL: redisServer.url,
1136
+ REDFLOW_PREFIX: prefix,
1137
+ TEST_WORKFLOW: workflowName,
1138
+ TEST_QUEUE: queue,
1139
+ TEST_STEP1_KEY: step1Key,
1140
+ },
1141
+ stdout: "ignore",
1142
+ stderr: "pipe",
1143
+ });
1144
+
1145
+ const redis = new RedisClient(redisServer.url);
1146
+ const client = createClient({ redis, prefix });
1147
+
1148
+ // Wait for workflow meta to appear (crash worker syncRegistry).
1149
+ await waitFor(
1150
+ async () => {
1151
+ const meta = await client.getWorkflowMeta(workflowName);
1152
+ return !!meta;
1153
+ },
1154
+ { timeoutMs: 5000, label: "workflow meta" },
1155
+ );
1156
+
1157
+ const handle = await client.runByName(workflowName, { ok: true }, { queueOverride: queue });
1158
+
1159
+ // Wait for the crash worker to exit.
1160
+ await crashProc.exited;
1161
+
1162
+ // Spawn a recovery worker that will requeue and complete the run, then exit.
1163
+ const recoverProc = Bun.spawn({
1164
+ cmd: ["bun", "tests/fixtures/worker-recover.ts"],
1165
+ env: {
1166
+ ...process.env,
1167
+ REDIS_URL: redisServer.url,
1168
+ REDFLOW_PREFIX: prefix,
1169
+ TEST_WORKFLOW: workflowName,
1170
+ TEST_QUEUE: queue,
1171
+ TEST_STEP1_KEY: step1Key,
1172
+ TEST_RUN_ID: handle.id,
1173
+ },
1174
+ stdout: "ignore",
1175
+ stderr: "pipe",
1176
+ });
1177
+
1178
+ const code = await recoverProc.exited;
1179
+ expect(code).toBe(0);
1180
+
1181
+ const state = await client.getRun(handle.id);
1182
+ expect(state?.status).toBe("succeeded");
1183
+
1184
+ const step1Count = Number((await redis.get(step1Key)) ?? "0");
1185
+ expect(step1Count).toBe(1);
1186
+
1187
+ redis.close();
1188
+ });
1189
+
1190
+ test("idempotencyKey: same key returns same run id and executes once", async () => {
1191
+ const prefix = testPrefix();
1192
+ const redis = new RedisClient(redisServer.url);
1193
+ const client = createClient({ redis, prefix });
1194
+ setDefaultClient(client);
1195
+
1196
+ const countKey = `${prefix}:t:idemCount`;
1197
+
1198
+ const wf = defineWorkflow({ name: "idem-wf", queue: "q9" }, async ({ step }) => {
1199
+ await step.run({ name: "do" }, async () => {
1200
+ await redis.incr(countKey);
1201
+ return true;
1202
+ });
1203
+ return { ok: true };
1204
+ });
1205
+
1206
+ const worker = await startWorker({
1207
+ redis,
1208
+ prefix,
1209
+ queues: ["q9"],
1210
+ runtime: { leaseMs: 500 },
1211
+ });
1212
+
1213
+ const [h1, h2] = await Promise.all([wf.run({}, { idempotencyKey: "k" }), wf.run({}, { idempotencyKey: "k" })]);
1214
+ expect(h1.id).toBe(h2.id);
1215
+
1216
+ const out = await h1.result({ timeoutMs: 4000, pollMs: 50 });
1217
+ expect(out).toEqual({ ok: true });
1218
+
1219
+ // Give the worker a moment to potentially pick duplicates.
1220
+ await new Promise((r) => setTimeout(r, 200));
1221
+ const cnt = Number((await redis.get(countKey)) ?? "0");
1222
+ expect(cnt).toBe(1);
1223
+
1224
+ await worker.stop();
1225
+ redis.close();
1226
+ });
1227
+
1228
+ test("idempotencyKey: delayed TTL refresh cannot fork duplicate runs", async () => {
1229
+ const prefix = testPrefix();
1230
+ const redis = new RedisClient(redisServer.url);
1231
+ const client = createClient({ redis, prefix });
1232
+ setDefaultClient(client);
1233
+
1234
+ const countKey = `${prefix}:t:idem-race-fix-count`;
1235
+
1236
+ const wf = defineWorkflow({ name: "idem-race-fix", queue: "q9_race_fix" }, async ({ step }) => {
1237
+ await step.run({ name: "do" }, async () => {
1238
+ await redis.incr(countKey);
1239
+ return true;
1240
+ });
1241
+ return { ok: true };
1242
+ });
1243
+
1244
+ const worker = await startWorker({
1245
+ redis,
1246
+ prefix,
1247
+ queues: ["q9_race_fix"],
1248
+ runtime: { leaseMs: 500 },
1249
+ });
1250
+
1251
+ const originalExpire = redis.expire.bind(redis);
1252
+ let delayedExpire = false;
1253
+ redis.expire = (async (...args: Parameters<RedisClient["expire"]>) => {
1254
+ if (!delayedExpire) {
1255
+ delayedExpire = true;
1256
+ await new Promise((r) => setTimeout(r, 200));
1257
+ }
1258
+ return await originalExpire(...args);
1259
+ }) as RedisClient["expire"];
1260
+
1261
+ try {
1262
+ const [h1, h2] = await Promise.all([
1263
+ wf.run({}, { idempotencyKey: "race-key" }),
1264
+ wf.run({}, { idempotencyKey: "race-key" }),
1265
+ ]);
1266
+
1267
+ expect(h1.id).toBe(h2.id);
1268
+
1269
+ const [out1, out2] = await Promise.all([
1270
+ h1.result({ timeoutMs: 5000, pollMs: 50 }),
1271
+ h2.result({ timeoutMs: 5000, pollMs: 50 }),
1272
+ ]);
1273
+ expect(out1).toEqual({ ok: true });
1274
+ expect(out2).toEqual({ ok: true });
1275
+
1276
+ const count = Number((await redis.get(countKey)) ?? "0");
1277
+ expect(count).toBe(1);
1278
+ } finally {
1279
+ redis.expire = originalExpire;
1280
+ await worker.stop();
1281
+ redis.close();
1282
+ }
1283
+ });
1284
+
1285
+ test("enqueue: producer-side zadd failure does not leave runs orphaned", async () => {
1286
+ const prefix = testPrefix();
1287
+ const redis = new RedisClient(redisServer.url);
1288
+ const client = createClient({ redis, prefix });
1289
+ setDefaultClient(client);
1290
+
1291
+ const wf = defineWorkflow({ name: "enqueue-atomic", queue: "q_enqueue_atomic" }, async () => ({ ok: true }));
1292
+ await client.syncRegistry(getDefaultRegistry());
1293
+
1294
+ const originalZadd = redis.zadd.bind(redis);
1295
+ let injected = false;
1296
+ redis.zadd = (async (...args: Parameters<RedisClient["zadd"]>) => {
1297
+ if (!injected) {
1298
+ injected = true;
1299
+ throw new Error("injected zadd failure");
1300
+ }
1301
+ return await originalZadd(...args);
1302
+ }) as RedisClient["zadd"];
1303
+
1304
+ let handle: Awaited<ReturnType<typeof wf.run>>;
1305
+ try {
1306
+ handle = await wf.run({});
1307
+ } finally {
1308
+ redis.zadd = originalZadd;
1309
+ }
1310
+
1311
+ const worker = await startWorker({
1312
+ redis,
1313
+ prefix,
1314
+ queues: ["q_enqueue_atomic"],
1315
+ runtime: { leaseMs: 500 },
1316
+ });
1317
+
1318
+ try {
1319
+ const out = await handle.result({ timeoutMs: 6000, pollMs: 50 });
1320
+ expect(out).toEqual({ ok: true });
1321
+ } finally {
1322
+ await worker.stop();
1323
+ redis.close();
1324
+ }
1325
+ });
1326
+
1327
+ test("idempotencyKey: delimiter-like workflow/key pairs do not collide", async () => {
1328
+ const prefix = testPrefix();
1329
+ const redis = new RedisClient(redisServer.url);
1330
+ const client = createClient({ redis, prefix });
1331
+ setDefaultClient(client);
1332
+
1333
+ const wfA = defineWorkflow({ name: "idem:a", queue: "q9a" }, async () => ({ workflow: "idem:a" }));
1334
+ const wfB = defineWorkflow({ name: "idem:a:b", queue: "q9b" }, async () => ({ workflow: "idem:a:b" }));
1335
+
1336
+ const worker = await startWorker({
1337
+ redis,
1338
+ prefix,
1339
+ queues: ["q9a", "q9b"],
1340
+ concurrency: 2,
1341
+ runtime: { leaseMs: 500 },
1342
+ });
1343
+
1344
+ const [h1, h2] = await Promise.all([
1345
+ wfA.run({}, { idempotencyKey: "b:c" }),
1346
+ wfB.run({}, { idempotencyKey: "c" }),
1347
+ ]);
1348
+
1349
+ expect(h1.id).not.toBe(h2.id);
1350
+
1351
+ const [out1, out2] = await Promise.all([
1352
+ h1.result({ timeoutMs: 5000, pollMs: 50 }),
1353
+ h2.result({ timeoutMs: 5000, pollMs: 50 }),
1354
+ ]);
1355
+
1356
+ expect(out1).toEqual({ workflow: "idem:a" });
1357
+ expect(out2).toEqual({ workflow: "idem:a:b" });
1358
+
1359
+ await worker.stop();
1360
+ redis.close();
1361
+ });
1362
+
1363
+ test("idempotencyTtl: short TTL expires and allows a new run with same key", async () => {
1364
+ const prefix = testPrefix();
1365
+ const redis = new RedisClient(redisServer.url);
1366
+ const client = createClient({ redis, prefix });
1367
+ setDefaultClient(client);
1368
+
1369
+ const countKey = `${prefix}:t:idemTtlCount`;
1370
+
1371
+ const wf = defineWorkflow({ name: "idem-ttl-wf", queue: "q_idem_ttl" }, async ({ step }) => {
1372
+ await step.run({ name: "count" }, async () => {
1373
+ await redis.incr(countKey);
1374
+ return true;
1375
+ });
1376
+ return { ok: true };
1377
+ });
1378
+
1379
+ const worker = await startWorker({
1380
+ redis,
1381
+ prefix,
1382
+ queues: ["q_idem_ttl"],
1383
+ runtime: { leaseMs: 500 },
1384
+ });
1385
+
1386
+ // First run with 1-second TTL
1387
+ const h1 = await wf.run({}, { idempotencyKey: "ttl-key", idempotencyTtl: 1 });
1388
+ const out1 = await h1.result({ timeoutMs: 5000, pollMs: 50 });
1389
+ expect(out1).toEqual({ ok: true });
1390
+
1391
+ // Same key within TTL — returns existing run
1392
+ const h2 = await wf.run({}, { idempotencyKey: "ttl-key", idempotencyTtl: 1 });
1393
+ expect(h2.id).toBe(h1.id);
1394
+
1395
+ // Wait for TTL to expire
1396
+ await new Promise((r) => setTimeout(r, 1200));
1397
+
1398
+ // Same key after TTL — creates a new run
1399
+ const h3 = await wf.run({}, { idempotencyKey: "ttl-key", idempotencyTtl: 1 });
1400
+ expect(h3.id).not.toBe(h1.id);
1401
+
1402
+ const out3 = await h3.result({ timeoutMs: 5000, pollMs: 50 });
1403
+ expect(out3).toEqual({ ok: true });
1404
+
1405
+ // Handler executed twice (once per unique run)
1406
+ const count = Number((await redis.get(countKey)) ?? "0");
1407
+ expect(count).toBe(2);
1408
+
1409
+ await worker.stop();
1410
+ redis.close();
1411
+ });
1412
+
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
+ test("input validation: invalid input fails once and is not retried", async () => {
1555
+ const prefix = testPrefix();
1556
+ const redis = new RedisClient(redisServer.url);
1557
+ const client = createClient({ redis, prefix });
1558
+ setDefaultClient(client);
1559
+
1560
+ defineWorkflow(
1561
+ {
1562
+ name: "validate-wf",
1563
+ queue: "q11",
1564
+ schema: z.object({ x: z.number() }),
1565
+ retries: { maxAttempts: 3 },
1566
+ },
1567
+ async () => {
1568
+ return { ok: true };
1569
+ },
1570
+ );
1571
+
1572
+ const worker = await startWorker({
1573
+ redis,
1574
+ prefix,
1575
+ queues: ["q11"],
1576
+ runtime: { leaseMs: 500 },
1577
+ });
1578
+
1579
+ const handle = await client.runByName("validate-wf", { x: "nope" }, { queueOverride: "q11" });
1580
+
1581
+ await waitFor(
1582
+ async () => {
1583
+ const st = await client.getRun(handle.id);
1584
+ return st?.status === "failed";
1585
+ },
1586
+ { timeoutMs: 6000, label: "validation fail" },
1587
+ );
1588
+
1589
+ const st = await client.getRun(handle.id);
1590
+ expect(st?.status).toBe("failed");
1591
+ expect(st?.attempt).toBe(1);
1592
+ expect((st?.error as any)?.kind).toBe("validation");
1593
+
1594
+ const schedKey = `${prefix}:q:q11:scheduled`;
1595
+ const scheduled = await redis.zrange(schedKey, 0, -1);
1596
+ expect(scheduled.includes(handle.id)).toBe(false);
1597
+
1598
+ await worker.stop();
1599
+ redis.close();
1600
+ });
1601
+
1602
+ test("unknown workflow: run fails and is not retried", async () => {
1603
+ const prefix = testPrefix();
1604
+ const redis = new RedisClient(redisServer.url);
1605
+ const client = createClient({ redis, prefix });
1606
+
1607
+ const worker = await startWorker({
1608
+ redis,
1609
+ prefix,
1610
+ queues: ["qx"],
1611
+ runtime: { leaseMs: 500 },
1612
+ });
1613
+
1614
+ const handle = await client.runByName("no-such-wf", { ok: true }, { queueOverride: "qx" });
1615
+
1616
+ await waitFor(
1617
+ async () => {
1618
+ const st = await client.getRun(handle.id);
1619
+ return st?.status === "failed";
1620
+ },
1621
+ { timeoutMs: 6000, label: "unknown wf" },
1622
+ );
1623
+
1624
+ const st = await client.getRun(handle.id);
1625
+ expect(st?.status).toBe("failed");
1626
+ expect(st?.attempt).toBe(1);
1627
+ expect((st?.error as any)?.kind).toBe("unknown_workflow");
1628
+
1629
+ await worker.stop();
1630
+ redis.close();
1631
+ });
1632
+
1633
+ test("runByName: unknown workflow without queueOverride fails fast", async () => {
1634
+ const prefix = testPrefix();
1635
+ const redis = new RedisClient(redisServer.url);
1636
+ const client = createClient({ redis, prefix });
1637
+
1638
+ await expect(client.runByName("no-such-wf", { ok: true })).rejects.toBeInstanceOf(UnknownWorkflowError);
1639
+
1640
+ const runs = await client.listRuns({ limit: 1 });
1641
+ expect(runs).toEqual([]);
1642
+
1643
+ redis.close();
1644
+ });
1645
+
1646
+ test("cancel during step: run becomes canceled and step error kind is canceled", async () => {
1647
+ const prefix = testPrefix();
1648
+ const redis = new RedisClient(redisServer.url);
1649
+ const client = createClient({ redis, prefix });
1650
+ setDefaultClient(client);
1651
+
1652
+ const wf = defineWorkflow({ name: "cancel-mid-step", queue: "q12" }, async ({ step }) => {
1653
+ await step.run({ name: "slow" }, async () => {
1654
+ await new Promise((r) => setTimeout(r, 2000));
1655
+ return true;
1656
+ });
1657
+ return { ok: true };
1658
+ });
1659
+
1660
+ const worker = await startWorker({
1661
+ redis,
1662
+ prefix,
1663
+ queues: ["q12"],
1664
+ runtime: { leaseMs: 500 },
1665
+ });
1666
+
1667
+ const handle = await wf.run({});
1668
+
1669
+ await waitFor(
1670
+ async () => {
1671
+ const st = await client.getRun(handle.id);
1672
+ return st?.status === "running";
1673
+ },
1674
+ { timeoutMs: 4000, label: "running" },
1675
+ );
1676
+
1677
+ await client.cancelRun(handle.id, { reason: "test" });
1678
+
1679
+ await waitFor(
1680
+ async () => {
1681
+ const st = await client.getRun(handle.id);
1682
+ return st?.status === "canceled";
1683
+ },
1684
+ { timeoutMs: 6000, label: "canceled" },
1685
+ );
1686
+
1687
+ const st = await client.getRun(handle.id);
1688
+ expect(st?.status).toBe("canceled");
1689
+
1690
+ const steps = await client.getRunSteps(handle.id);
1691
+ expect(steps.length).toBe(1);
1692
+ expect((steps[0]?.error as any)?.kind).toBe("canceled");
1693
+
1694
+ // `result()` should reject with CanceledError.
1695
+ await expect(handle.result({ timeoutMs: 1000, pollMs: 50 })).rejects.toBeInstanceOf(CanceledError);
1696
+
1697
+ await worker.stop();
1698
+ redis.close();
1699
+ });
1700
+
1701
+ test("terminal run re-queued is ignored (no re-execution)", async () => {
1702
+ const prefix = testPrefix();
1703
+ const redis = new RedisClient(redisServer.url);
1704
+ const client = createClient({ redis, prefix });
1705
+ setDefaultClient(client);
1706
+
1707
+ const queue = "q13";
1708
+ const countKey = `${prefix}:t:termCount`;
1709
+
1710
+ const wf = defineWorkflow({ name: "term-wf", queue }, async ({ step }) => {
1711
+ await step.run({ name: "do" }, async () => {
1712
+ await redis.incr(countKey);
1713
+ return true;
1714
+ });
1715
+ return { ok: true };
1716
+ });
1717
+
1718
+ const worker = await startWorker({
1719
+ redis,
1720
+ prefix,
1721
+ queues: [queue],
1722
+ concurrency: 2,
1723
+ runtime: { leaseMs: 400, reaperIntervalMs: 50 },
1724
+ });
1725
+
1726
+ const handle = await wf.run({});
1727
+ const out = await handle.result({ timeoutMs: 4000, pollMs: 50 });
1728
+ expect(out).toEqual({ ok: true });
1729
+
1730
+ const cnt1 = Number((await redis.get(countKey)) ?? "0");
1731
+ expect(cnt1).toBe(1);
1732
+
1733
+ // Malicious/buggy duplicate enqueue of the same run id.
1734
+ await redis.lpush(`${prefix}:q:${queue}:ready`, handle.id);
1735
+ await new Promise((r) => setTimeout(r, 400));
1736
+
1737
+ const cnt2 = Number((await redis.get(countKey)) ?? "0");
1738
+ expect(cnt2).toBe(1);
1739
+
1740
+ const st = await client.getRun(handle.id);
1741
+ expect(st?.status).toBe("succeeded");
1742
+ expect(st?.attempt).toBe(1);
1743
+
1744
+ await worker.stop();
1745
+ redis.close();
1746
+ });
1747
+
1748
+ test("lease+reaper: long running step is not duplicated", async () => {
1749
+ const prefix = testPrefix();
1750
+ const redis = new RedisClient(redisServer.url);
1751
+ const client = createClient({ redis, prefix });
1752
+ setDefaultClient(client);
1753
+
1754
+ const queue = "q14";
1755
+ const countKey = `${prefix}:t:leaseCount`;
1756
+
1757
+ const wf = defineWorkflow({ name: "lease-wf", queue }, async ({ step }) => {
1758
+ await step.run({ name: "long" }, async () => {
1759
+ await redis.incr(countKey);
1760
+ await new Promise((r) => setTimeout(r, 700));
1761
+ return true;
1762
+ });
1763
+ return { ok: true };
1764
+ });
1765
+
1766
+ const worker = await startWorker({
1767
+ redis,
1768
+ prefix,
1769
+ queues: [queue],
1770
+ concurrency: 2,
1771
+ runtime: { leaseMs: 200, reaperIntervalMs: 20, blmoveTimeoutSec: 0.2 },
1772
+ });
1773
+
1774
+ const handle = await wf.run({});
1775
+ const out = await handle.result({ timeoutMs: 8000, pollMs: 50 });
1776
+ expect(out).toEqual({ ok: true });
1777
+
1778
+ const cnt = Number((await redis.get(countKey)) ?? "0");
1779
+ expect(cnt).toBe(1);
1780
+
1781
+ const st = await client.getRun(handle.id);
1782
+ expect(st?.attempt).toBe(1);
1783
+
1784
+ await worker.stop();
1785
+ redis.close();
1786
+ });
1787
+
1788
+ test(
1789
+ "cron lock: two workers do not double-schedule",
1790
+ async () => {
1791
+ const prefix = testPrefix();
1792
+ const redis = new RedisClient(redisServer.url);
1793
+ const client = createClient({ redis, prefix });
1794
+ setDefaultClient(client);
1795
+
1796
+ const queue = "q15";
1797
+ const counterKey = `${prefix}:t:cronLockCount`;
1798
+
1799
+ defineWorkflow(
1800
+ {
1801
+ name: "cron-lock-wf",
1802
+ queue,
1803
+ triggers: { cron: [{ expression: "*/1 * * * * *" }] },
1804
+ },
1805
+ async ({ step }) => {
1806
+ await step.run({ name: "tick" }, async () => {
1807
+ await redis.incr(counterKey);
1808
+ return true;
1809
+ });
1810
+ return { ok: true };
1811
+ },
1812
+ );
1813
+
1814
+ const w1 = await startWorker({ redis, prefix, queues: [queue], concurrency: 1, runtime: { leaseMs: 500 } });
1815
+ const w2 = await startWorker({ redis, prefix, queues: [queue], concurrency: 1, runtime: { leaseMs: 500 } });
1816
+
1817
+ await new Promise((r) => setTimeout(r, 3500));
1818
+ const count = Number((await redis.get(counterKey)) ?? "0");
1819
+ // With a single scheduler, we expect ~3-4 ticks. Allow some slack for timing.
1820
+ expect(count).toBeGreaterThan(0);
1821
+ expect(count).toBeLessThan(7);
1822
+
1823
+ await w1.stop();
1824
+ await w2.stop();
1825
+ redis.close();
1826
+ },
1827
+ { timeout: 15_000 },
1828
+ );
1829
+
1830
+ test(
1831
+ "production: parent waits for child; child retries and caches steps",
1832
+ async () => {
1833
+ const prefix = testPrefix();
1834
+ const redis = new RedisClient(redisServer.url);
1835
+ const client = createClient({ redis, prefix });
1836
+ setDefaultClient(client);
1837
+
1838
+ const childStep1CountKey = `${prefix}:t:childStep1Count`;
1839
+ const childFailOnceKey = `${prefix}:t:childFailOnce`;
1840
+ const parentBeforeKey = `${prefix}:t:parentBefore`;
1841
+
1842
+ const child = defineWorkflow(
1843
+ {
1844
+ name: "child-wf",
1845
+ queue: "q_child",
1846
+ retries: { maxAttempts: 2 },
1847
+ },
1848
+ async ({ step, run }) => {
1849
+ await step.run({ name: "fetch" }, async () => {
1850
+ await redis.incr(childStep1CountKey);
1851
+ return { ok: true };
1852
+ });
1853
+
1854
+ await step.run({ name: "send" }, async () => {
1855
+ const seen = await redis.get(childFailOnceKey);
1856
+ if (!seen) {
1857
+ await redis.set(childFailOnceKey, "1");
1858
+ throw new Error("transient child failure");
1859
+ }
1860
+ return true;
1861
+ });
1862
+
1863
+ return { ok: true, attempt: run.attempt };
1864
+ },
1865
+ );
1866
+
1867
+ const parent = defineWorkflow(
1868
+ {
1869
+ name: "parent-wf",
1870
+ queue: "q_parent",
1871
+ },
1872
+ async ({ step }) => {
1873
+ await step.run({ name: "before" }, async () => {
1874
+ await redis.incr(parentBeforeKey);
1875
+ return true;
1876
+ });
1877
+
1878
+ const childOut = await step.run({ name: "child" }, async () => {
1879
+ const h = await child.run({});
1880
+ return await h.result({ timeoutMs: 10_000, pollMs: 50 });
1881
+ });
1882
+
1883
+ return { ok: true, child: childOut };
1884
+ },
1885
+ );
1886
+
1887
+ const worker = await startWorker({
1888
+ redis,
1889
+ prefix,
1890
+ queues: ["q_parent", "q_child"],
1891
+ concurrency: 2,
1892
+ runtime: { leaseMs: 500 },
1893
+ });
1894
+
1895
+ const handle = await parent.run({});
1896
+ const out = await handle.result({ timeoutMs: 15_000, pollMs: 50 });
1897
+ expect(out.ok).toBe(true);
1898
+ expect(out.child.ok).toBe(true);
1899
+ expect(out.child.attempt).toBe(2);
1900
+
1901
+ const childStep1Count = Number((await redis.get(childStep1CountKey)) ?? "0");
1902
+ expect(childStep1Count).toBe(1);
1903
+
1904
+ const parentBeforeCount = Number((await redis.get(parentBeforeKey)) ?? "0");
1905
+ expect(parentBeforeCount).toBe(1);
1906
+
1907
+ const childRuns = await client.listRuns({ workflow: "child-wf", limit: 10 });
1908
+ expect(childRuns.length).toBe(1);
1909
+ const childState = await client.getRun(childRuns[0]!.id);
1910
+ expect(childState?.status).toBe("succeeded");
1911
+ expect(childState?.attempt).toBe(2);
1912
+
1913
+ await worker.stop();
1914
+ redis.close();
1915
+ },
1916
+ { timeout: 25_000 },
1917
+ );
1918
+
1919
+ test(
1920
+ "production: runs queued before worker start are picked and status indexes move",
1921
+ async () => {
1922
+ const prefix = testPrefix();
1923
+ const redis = new RedisClient(redisServer.url);
1924
+ const client = createClient({ redis, prefix });
1925
+ setDefaultClient(client);
1926
+
1927
+ const queue = "q_status";
1928
+ const wf = defineWorkflow({ name: "status-wf", queue }, async ({ step }) => {
1929
+ await step.run({ name: "sleep" }, async () => {
1930
+ await new Promise((r) => setTimeout(r, 350));
1931
+ return true;
1932
+ });
1933
+ return { ok: true };
1934
+ });
1935
+
1936
+ const handle = await wf.run({});
1937
+
1938
+ const queuedIndex = `${prefix}:runs:status:queued`;
1939
+ const queuedMembers = await redis.zrange(queuedIndex, 0, -1);
1940
+ expect(queuedMembers.includes(handle.id)).toBe(true);
1941
+
1942
+ const worker = await startWorker({
1943
+ redis,
1944
+ prefix,
1945
+ queues: [queue],
1946
+ runtime: { leaseMs: 500 },
1947
+ });
1948
+
1949
+ await waitFor(
1950
+ async () => (await client.getRun(handle.id))?.status === "running",
1951
+ { timeoutMs: 8000, label: "becomes running" },
1952
+ );
1953
+
1954
+ const runningMembers = await redis.zrange(`${prefix}:runs:status:running`, 0, -1);
1955
+ expect(runningMembers.includes(handle.id)).toBe(true);
1956
+ const queuedMembers2 = await redis.zrange(queuedIndex, 0, -1);
1957
+ expect(queuedMembers2.includes(handle.id)).toBe(false);
1958
+
1959
+ const out = await handle.result({ timeoutMs: 8000, pollMs: 50 });
1960
+ expect(out).toEqual({ ok: true });
1961
+
1962
+ const succeededMembers = await redis.zrange(`${prefix}:runs:status:succeeded`, 0, -1);
1963
+ expect(succeededMembers.includes(handle.id)).toBe(true);
1964
+ const runningMembers2 = await redis.zrange(`${prefix}:runs:status:running`, 0, -1);
1965
+ expect(runningMembers2.includes(handle.id)).toBe(false);
1966
+
1967
+ await worker.stop();
1968
+ redis.close();
1969
+ },
1970
+ { timeout: 20_000 },
1971
+ );
1972
+
1973
+ test(
1974
+ "production: registry sync updates triggers (events move, cron removed)",
1975
+ async () => {
1976
+ const prefix = testPrefix();
1977
+ const redis = new RedisClient(redisServer.url);
1978
+ const client = createClient({ redis, prefix });
1979
+ setDefaultClient(client);
1980
+
1981
+ 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
+
1997
+ await client.syncRegistry(getDefaultRegistry());
1998
+
1999
+ const subsA1 = await redis.smembers(`${prefix}:event:${eventA}:workflows`);
2000
+ const subsB1 = await redis.smembers(`${prefix}:event:${eventB}:workflows`);
2001
+ expect(subsA1.includes(name)).toBe(true);
2002
+ expect(subsB1.includes(name)).toBe(false);
2003
+
2004
+ const workflowKey = `${prefix}:workflow:${name}`;
2005
+ const cronIdsJson = await redis.hget(workflowKey, "cronIdsJson");
2006
+ expect(cronIdsJson).not.toBeNull();
2007
+ const cronIds = JSON.parse(cronIdsJson!) as string[];
2008
+ expect(cronIds.length).toBe(1);
2009
+ const cronId = cronIds[0]!;
2010
+ expect(await redis.hget(`${prefix}:cron:def`, cronId)).not.toBeNull();
2011
+ const cronNext = await redis.zrange(`${prefix}:cron:next`, 0, -1);
2012
+ expect(cronNext.includes(cronId)).toBe(true);
2013
+
2014
+ // Simulate a new deployment: same workflow name, different triggers.
2015
+ __unstableResetDefaultRegistryForTests();
2016
+ defineWorkflow(
2017
+ {
2018
+ name,
2019
+ queue: "q_update",
2020
+ triggers: { events: [eventB] },
2021
+ },
2022
+ async () => ({ ok: true }),
2023
+ );
2024
+
2025
+ await client.syncRegistry(getDefaultRegistry());
2026
+
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
+ expect(await redis.hget(`${prefix}:cron:def`, cronId)).toBeNull();
2034
+ const cronNext2 = await redis.zrange(`${prefix}:cron:next`, 0, -1);
2035
+ expect(cronNext2.includes(cronId)).toBe(false);
2036
+
2037
+ redis.close();
2038
+ },
2039
+ { timeout: 20_000 },
2040
+ );
2041
+
2042
+ test(
2043
+ "production: retry uses delayed backoff (attempt 2 starts later)",
2044
+ async () => {
2045
+ const prefix = testPrefix();
2046
+ const redis = new RedisClient(redisServer.url);
2047
+ const client = createClient({ redis, prefix });
2048
+ setDefaultClient(client);
2049
+
2050
+ const queue = "q_backoff";
2051
+ const t1Key = `${prefix}:t:attempt1`;
2052
+ const t2Key = `${prefix}:t:attempt2`;
2053
+
2054
+ const wf = defineWorkflow(
2055
+ {
2056
+ name: "backoff-wf",
2057
+ queue,
2058
+ retries: { maxAttempts: 2 },
2059
+ },
2060
+ async ({ run }) => {
2061
+ const key = run.attempt === 1 ? t1Key : t2Key;
2062
+ await redis.set(key, String(Date.now()));
2063
+ if (run.attempt === 1) throw new Error("fail once");
2064
+ return { ok: true };
2065
+ },
2066
+ );
2067
+
2068
+ const worker = await startWorker({
2069
+ redis,
2070
+ prefix,
2071
+ queues: [queue],
2072
+ runtime: { leaseMs: 500 },
2073
+ });
2074
+
2075
+ const handle = await wf.run({});
2076
+ const out = await handle.result({ timeoutMs: 15_000, pollMs: 50 });
2077
+ expect(out).toEqual({ ok: true });
2078
+
2079
+ const t1 = Number((await redis.get(t1Key)) ?? "0");
2080
+ const t2 = Number((await redis.get(t2Key)) ?? "0");
2081
+ expect(t1).toBeGreaterThan(0);
2082
+ expect(t2).toBeGreaterThan(0);
2083
+ expect(t2 - t1).toBeGreaterThan(200);
2084
+
2085
+ const st = await client.getRun(handle.id);
2086
+ expect(st?.attempt).toBe(2);
2087
+ expect(st?.status).toBe("succeeded");
2088
+
2089
+ await worker.stop();
2090
+ redis.close();
2091
+ },
2092
+ { timeout: 25_000 },
2093
+ );
2094
+
2095
+ test(
2096
+ "production: cron leader failover (ticks continue after one worker stops)",
2097
+ async () => {
2098
+ const prefix = testPrefix();
2099
+ const redis = new RedisClient(redisServer.url);
2100
+ const client = createClient({ redis, prefix });
2101
+ setDefaultClient(client);
2102
+
2103
+ const queue = "q_failover";
2104
+ const counterKey = `${prefix}:t:cronFailoverCount`;
2105
+
2106
+ defineWorkflow(
2107
+ {
2108
+ name: "cron-failover-wf",
2109
+ queue,
2110
+ triggers: { cron: [{ expression: "*/1 * * * * *" }] },
2111
+ },
2112
+ async ({ step }) => {
2113
+ await step.run({ name: "tick" }, async () => {
2114
+ await redis.incr(counterKey);
2115
+ return true;
2116
+ });
2117
+ return { ok: true };
2118
+ },
2119
+ );
2120
+
2121
+ const w1 = await startWorker({ redis, prefix, queues: [queue], concurrency: 1, runtime: { leaseMs: 500 } });
2122
+ const w2 = await startWorker({ redis, prefix, queues: [queue], concurrency: 1, runtime: { leaseMs: 500 } });
2123
+
2124
+ await waitFor(
2125
+ async () => Number((await redis.get(counterKey)) ?? "0") >= 1,
2126
+ { timeoutMs: 8000, label: "first tick" },
2127
+ );
2128
+
2129
+ await w1.stop();
2130
+ const before = Number((await redis.get(counterKey)) ?? "0");
2131
+
2132
+ // After the lock expires, the other worker should continue ticking.
2133
+ await waitFor(
2134
+ async () => Number((await redis.get(counterKey)) ?? "0") > before,
2135
+ { timeoutMs: 10_000, label: "ticks continue" },
2136
+ );
2137
+
2138
+ await w2.stop();
2139
+ redis.close();
2140
+ },
2141
+ { timeout: 25_000 },
2142
+ );
2143
+
2144
+ test("listRuns: status + workflow filters are applied together", async () => {
2145
+ const prefix = testPrefix();
2146
+ const redis = new RedisClient(redisServer.url);
2147
+ const client = createClient({ redis, prefix });
2148
+ setDefaultClient(client);
2149
+
2150
+ const queue = "q_filters";
2151
+
2152
+ const succeeds = defineWorkflow({ name: "filters-success", queue }, async () => ({ ok: true }));
2153
+ const fails = defineWorkflow({ name: "filters-fail", queue }, async () => {
2154
+ throw new Error("expected failure");
2155
+ });
2156
+
2157
+ const worker = await startWorker({ redis, prefix, queues: [queue], runtime: { leaseMs: 500 } });
2158
+
2159
+ const successHandle = await succeeds.run({});
2160
+ const failHandle = await fails.run({});
2161
+
2162
+ await successHandle.result({ timeoutMs: 4000, pollMs: 50 });
2163
+ await waitFor(
2164
+ async () => (await client.getRun(failHandle.id))?.status === "failed",
2165
+ { timeoutMs: 6000, label: "failed run" },
2166
+ );
2167
+
2168
+ const shouldBeEmpty = await client.listRuns({
2169
+ workflow: "filters-success",
2170
+ status: "failed" satisfies RunStatus,
2171
+ limit: 20,
2172
+ });
2173
+ expect(shouldBeEmpty).toEqual([]);
2174
+
2175
+ const failedForWorkflow = await client.listRuns({
2176
+ workflow: "filters-fail",
2177
+ status: "failed" satisfies RunStatus,
2178
+ limit: 20,
2179
+ });
2180
+ expect(failedForWorkflow.length).toBeGreaterThan(0);
2181
+ expect(failedForWorkflow.some((r) => r.id === failHandle.id)).toBe(true);
2182
+ for (const run of failedForWorkflow) {
2183
+ expect(run.status).toBe("failed");
2184
+ expect(run.workflow).toBe("filters-fail");
2185
+ }
2186
+
2187
+ await worker.stop();
2188
+ redis.close();
2189
+ });
2190
+
2191
+ test("cron trigger ids: same custom id in two workflows does not collide", async () => {
2192
+ const prefix = testPrefix();
2193
+ const redis = new RedisClient(redisServer.url);
2194
+ const client = createClient({ redis, prefix });
2195
+
2196
+ defineWorkflow(
2197
+ {
2198
+ name: "cron-id-a",
2199
+ queue: "qa",
2200
+ triggers: { cron: [{ id: "shared", expression: "*/5 * * * * *" }] },
2201
+ },
2202
+ async () => ({ ok: true }),
2203
+ );
2204
+
2205
+ defineWorkflow(
2206
+ {
2207
+ name: "cron-id-b",
2208
+ queue: "qb",
2209
+ triggers: { cron: [{ id: "shared", expression: "*/5 * * * * *" }] },
2210
+ },
2211
+ async () => ({ ok: true }),
2212
+ );
2213
+
2214
+ await client.syncRegistry(getDefaultRegistry());
2215
+
2216
+ const idsA = JSON.parse((await redis.hget(`${prefix}:workflow:cron-id-a`, "cronIdsJson")) ?? "[]") as string[];
2217
+ const idsB = JSON.parse((await redis.hget(`${prefix}:workflow:cron-id-b`, "cronIdsJson")) ?? "[]") as string[];
2218
+
2219
+ expect(idsA.length).toBe(1);
2220
+ expect(idsB.length).toBe(1);
2221
+ expect(idsA[0]).not.toBe(idsB[0]);
2222
+
2223
+ expect(await redis.hget(`${prefix}:cron:def`, idsA[0]!)).not.toBeNull();
2224
+ expect(await redis.hget(`${prefix}:cron:def`, idsB[0]!)).not.toBeNull();
2225
+
2226
+ redis.close();
2227
+ });
2228
+
2229
+ test("syncRegistry: cron trigger with explicit null input preserves null payload", async () => {
2230
+ const prefix = testPrefix();
2231
+ const redis = new RedisClient(redisServer.url);
2232
+ const client = createClient({ redis, prefix });
2233
+
2234
+ const workflowName = "cron-null-input";
2235
+
2236
+ defineWorkflow(
2237
+ {
2238
+ name: workflowName,
2239
+ queue: "q-cron-null",
2240
+ triggers: { cron: [{ id: "null-input", expression: "*/5 * * * * *", input: null }] },
2241
+ },
2242
+ async () => ({ ok: true }),
2243
+ );
2244
+
2245
+ await client.syncRegistry(getDefaultRegistry());
2246
+
2247
+ const workflowKey = `${prefix}:workflow:${workflowName}`;
2248
+ const cronIds = JSON.parse((await redis.hget(workflowKey, "cronIdsJson")) ?? "[]") as string[];
2249
+ expect(cronIds.length).toBe(1);
2250
+
2251
+ const cronDefRaw = await redis.hget(`${prefix}:cron:def`, cronIds[0]!);
2252
+ expect(cronDefRaw).not.toBeNull();
2253
+
2254
+ const cronDef = safeJsonParse<{ inputJson: string }>(cronDefRaw!);
2255
+ const parsedInput = safeJsonParse(cronDef.inputJson);
2256
+ expect(parsedInput).toBeNull();
2257
+
2258
+ redis.close();
2259
+ });
2260
+
2261
+ test("syncRegistry: invalid cron expression removes existing next schedule for same custom id", async () => {
2262
+ const prefix = testPrefix();
2263
+ const redis = new RedisClient(redisServer.url);
2264
+ const client = createClient({ redis, prefix });
2265
+
2266
+ const workflowName = "cron-invalid-update";
2267
+ const workflowKey = `${prefix}:workflow:${workflowName}`;
2268
+ const cronNextKey = `${prefix}:cron:next`;
2269
+
2270
+ defineWorkflow(
2271
+ {
2272
+ name: workflowName,
2273
+ queue: "q-cron-invalid",
2274
+ triggers: { cron: [{ id: "stable", expression: "*/5 * * * * *" }] },
2275
+ },
2276
+ async () => ({ ok: true }),
2277
+ );
2278
+
2279
+ await client.syncRegistry(getDefaultRegistry());
2280
+
2281
+ const cronIds = JSON.parse((await redis.hget(workflowKey, "cronIdsJson")) ?? "[]") as string[];
2282
+ expect(cronIds.length).toBe(1);
2283
+ const cronId = cronIds[0]!;
2284
+
2285
+ const before = await redis.zrange(cronNextKey, 0, -1);
2286
+ expect(before.includes(cronId)).toBe(true);
2287
+
2288
+ __unstableResetDefaultRegistryForTests();
2289
+ defineWorkflow(
2290
+ {
2291
+ name: workflowName,
2292
+ queue: "q-cron-invalid",
2293
+ triggers: { cron: [{ id: "stable", expression: "not a valid cron" }] },
2294
+ },
2295
+ async () => ({ ok: true }),
2296
+ );
2297
+
2298
+ await client.syncRegistry(getDefaultRegistry());
2299
+
2300
+ const after = await redis.zrange(cronNextKey, 0, -1);
2301
+ expect(after.includes(cronId)).toBe(false);
2302
+
2303
+ redis.close();
2304
+ });
2305
+
2306
+ test("syncRegistry: removes stale workflow metadata (events + cron)", async () => {
2307
+ const prefix = testPrefix();
2308
+ const redis = new RedisClient(redisServer.url);
2309
+ const client = createClient({ redis, prefix });
2310
+
2311
+ const workflowName = "stale-workflow";
2312
+ const eventName = "stale.event";
2313
+
2314
+ defineWorkflow(
2315
+ {
2316
+ name: workflowName,
2317
+ queue: "q-stale",
2318
+ triggers: { events: [eventName], cron: [{ id: "stale", expression: "*/10 * * * * *" }] },
2319
+ },
2320
+ async () => ({ ok: true }),
2321
+ );
2322
+
2323
+ await client.syncRegistry(getDefaultRegistry());
2324
+
2325
+ const workflowKey = `${prefix}:workflow:${workflowName}`;
2326
+ const cronIds = JSON.parse((await redis.hget(workflowKey, "cronIdsJson")) ?? "[]") as string[];
2327
+ expect(cronIds.length).toBe(1);
2328
+ const cronId = cronIds[0]!;
2329
+
2330
+ expect((await redis.smembers(`${prefix}:event:${eventName}:workflows`)).includes(workflowName)).toBe(true);
2331
+ expect(await redis.hget(`${prefix}:cron:def`, cronId)).not.toBeNull();
2332
+
2333
+ // Force stale age beyond grace period and sync an empty registry.
2334
+ await redis.hset(workflowKey, { updatedAt: "1" });
2335
+ __unstableResetDefaultRegistryForTests();
2336
+ await client.syncRegistry(getDefaultRegistry());
2337
+
2338
+ expect((await redis.smembers(`${prefix}:workflows`)).includes(workflowName)).toBe(false);
2339
+ expect((await redis.smembers(`${prefix}:event:${eventName}:workflows`)).includes(workflowName)).toBe(false);
2340
+ expect(await redis.hget(workflowKey, "queue")).toBeNull();
2341
+ expect(await redis.hget(`${prefix}:cron:def`, cronId)).toBeNull();
2342
+ const cronNext = await redis.zrange(`${prefix}:cron:next`, 0, -1);
2343
+ expect(cronNext.includes(cronId)).toBe(false);
2344
+
2345
+ redis.close();
2346
+ });
2347
+
2348
+ test("syncRegistry: stale cleanup is isolated by owner", async () => {
2349
+ const prefix = testPrefix();
2350
+ const redis = new RedisClient(redisServer.url);
2351
+ const client = createClient({ redis, prefix });
2352
+
2353
+ __unstableResetDefaultRegistryForTests();
2354
+ defineWorkflow(
2355
+ {
2356
+ name: "owner-a-wf",
2357
+ queue: "qa",
2358
+ triggers: { events: ["owner.a"], cron: [{ id: "a", expression: "*/30 * * * * *" }] },
2359
+ },
2360
+ async () => ({ ok: true }),
2361
+ );
2362
+ await client.syncRegistry(getDefaultRegistry(), { owner: "svc-a" });
2363
+
2364
+ __unstableResetDefaultRegistryForTests();
2365
+ defineWorkflow(
2366
+ {
2367
+ name: "owner-b-wf",
2368
+ queue: "qb",
2369
+ triggers: { events: ["owner.b"], cron: [{ id: "b", expression: "*/30 * * * * *" }] },
2370
+ },
2371
+ async () => ({ ok: true }),
2372
+ );
2373
+ await client.syncRegistry(getDefaultRegistry(), { owner: "svc-b" });
2374
+
2375
+ await redis.hset(`${prefix}:workflow:owner-a-wf`, { updatedAt: "1" });
2376
+ await redis.hset(`${prefix}:workflow:owner-b-wf`, { updatedAt: "1" });
2377
+
2378
+ __unstableResetDefaultRegistryForTests();
2379
+ await client.syncRegistry(getDefaultRegistry(), { owner: "svc-a" });
2380
+
2381
+ expect(await redis.hget(`${prefix}:workflow:owner-a-wf`, "queue")).toBeNull();
2382
+ expect(await redis.hget(`${prefix}:workflow:owner-b-wf`, "queue")).not.toBeNull();
2383
+ expect((await redis.smembers(`${prefix}:workflows`)).includes("owner-a-wf")).toBe(false);
2384
+ expect((await redis.smembers(`${prefix}:workflows`)).includes("owner-b-wf")).toBe(true);
2385
+
2386
+ redis.close();
2387
+ });
2388
+
2389
+ test("cancelRun: terminal runs are unchanged", async () => {
2390
+ const prefix = testPrefix();
2391
+ const redis = new RedisClient(redisServer.url);
2392
+ const client = createClient({ redis, prefix });
2393
+ setDefaultClient(client);
2394
+
2395
+ const wf = defineWorkflow({ name: "cancel-terminal", queue: "q_cancel_terminal" }, async () => ({ ok: true }));
2396
+
2397
+ const worker = await startWorker({
2398
+ redis,
2399
+ prefix,
2400
+ queues: ["q_cancel_terminal"],
2401
+ runtime: { leaseMs: 500 },
2402
+ });
2403
+
2404
+ const handle = await wf.run({});
2405
+ await handle.result({ timeoutMs: 4000, pollMs: 50 });
2406
+
2407
+ const before = await client.getRun(handle.id);
2408
+ const canceled = await client.cancelRun(handle.id, { reason: "late" });
2409
+ const after = await client.getRun(handle.id);
2410
+
2411
+ expect(canceled).toBe(false);
2412
+ expect(after?.status).toBe("succeeded");
2413
+ expect(after?.cancelRequestedAt).toBe(before?.cancelRequestedAt);
2414
+ expect(after?.cancelReason).toBe(before?.cancelReason);
2415
+
2416
+ await worker.stop();
2417
+ redis.close();
2418
+ });
2419
+
2420
+ test("output serialization: non-JSON output fails once and is not stuck", async () => {
2421
+ const prefix = testPrefix();
2422
+ const redis = new RedisClient(redisServer.url);
2423
+ const client = createClient({ redis, prefix });
2424
+ setDefaultClient(client);
2425
+
2426
+ const wf = defineWorkflow(
2427
+ {
2428
+ name: "serialization-fail",
2429
+ queue: "q_serial",
2430
+ retries: { maxAttempts: 3 },
2431
+ },
2432
+ async () => ({ bad: 1n }),
2433
+ );
2434
+
2435
+ const worker = await startWorker({
2436
+ redis,
2437
+ prefix,
2438
+ queues: ["q_serial"],
2439
+ runtime: { leaseMs: 300, reaperIntervalMs: 50, blmoveTimeoutSec: 0.2 },
2440
+ });
2441
+
2442
+ const handle = await wf.run({});
2443
+ await waitFor(
2444
+ async () => (await client.getRun(handle.id))?.status === "failed",
2445
+ { timeoutMs: 8000, label: "serialization failure" },
2446
+ );
2447
+
2448
+ const state = await client.getRun(handle.id);
2449
+ expect(state?.status).toBe("failed");
2450
+ expect(state?.attempt).toBe(1);
2451
+ expect((state?.error as any)?.kind).toBe("serialization");
2452
+
2453
+ const ready = await redis.lrange(`${prefix}:q:q_serial:ready`, 0, -1);
2454
+ const processing = await redis.lrange(`${prefix}:q:q_serial:processing`, 0, -1);
2455
+ expect(ready.includes(handle.id)).toBe(false);
2456
+ expect(processing.includes(handle.id)).toBe(false);
2457
+
2458
+ await worker.stop();
2459
+ redis.close();
2460
+ });
2461
+
2462
+ test("retry step sequence: new steps keep monotonic order after cached steps", async () => {
2463
+ const prefix = testPrefix();
2464
+ const redis = new RedisClient(redisServer.url);
2465
+ const client = createClient({ redis, prefix });
2466
+ setDefaultClient(client);
2467
+
2468
+ const failOnceKey = `${prefix}:t:seq-fail-once`;
2469
+
2470
+ const wf = defineWorkflow(
2471
+ {
2472
+ name: "seq-retry",
2473
+ queue: "q_seq",
2474
+ retries: { maxAttempts: 2 },
2475
+ },
2476
+ async ({ step }) => {
2477
+ await step.run({ name: "step1" }, async () => true);
2478
+
2479
+ await step.run({ name: "step2" }, async () => {
2480
+ const seen = await redis.get(failOnceKey);
2481
+ if (!seen) {
2482
+ await redis.set(failOnceKey, "1");
2483
+ throw new Error("fail once");
2484
+ }
2485
+ return true;
2486
+ });
2487
+
2488
+ await step.run({ name: "step3" }, async () => true);
2489
+ return { ok: true };
2490
+ },
2491
+ );
2492
+
2493
+ const worker = await startWorker({
2494
+ redis,
2495
+ prefix,
2496
+ queues: ["q_seq"],
2497
+ runtime: { leaseMs: 500 },
2498
+ });
2499
+
2500
+ const handle = await wf.run({});
2501
+ await handle.result({ timeoutMs: 12_000, pollMs: 50 });
2502
+
2503
+ const rawSteps = await redis.hgetall(`${prefix}:run:${handle.id}:steps`);
2504
+ const seq1 = safeJsonParse<any>(rawSteps.step1 ?? null).seq;
2505
+ const seq2 = safeJsonParse<any>(rawSteps.step2 ?? null).seq;
2506
+ const seq3 = safeJsonParse<any>(rawSteps.step3 ?? null).seq;
2507
+ expect(seq1).toBeLessThan(seq2);
2508
+ expect(seq2).toBeLessThan(seq3);
2509
+
2510
+ const steps = await client.getRunSteps(handle.id);
2511
+ expect(steps.map((s) => s.name)).toEqual(["step1", "step2", "step3"]);
2512
+
2513
+ await worker.stop();
2514
+ redis.close();
2515
+ });
2516
+
2517
+ test("listRuns: negative offset is clamped to zero", async () => {
2518
+ const prefix = testPrefix();
2519
+ const redis = new RedisClient(redisServer.url);
2520
+ const client = createClient({ redis, prefix });
2521
+ setDefaultClient(client);
2522
+
2523
+ const wf = defineWorkflow({ name: "offset-wf", queue: "q_offset" }, async () => ({ ok: true }));
2524
+ const worker = await startWorker({
2525
+ redis,
2526
+ prefix,
2527
+ queues: ["q_offset"],
2528
+ runtime: { leaseMs: 500 },
2529
+ });
2530
+
2531
+ const h1 = await wf.run({});
2532
+ await h1.result({ timeoutMs: 4000, pollMs: 50 });
2533
+ await new Promise((r) => setTimeout(r, 5));
2534
+ const h2 = await wf.run({});
2535
+ await h2.result({ timeoutMs: 4000, pollMs: 50 });
2536
+ await new Promise((r) => setTimeout(r, 5));
2537
+ const h3 = await wf.run({});
2538
+ await h3.result({ timeoutMs: 4000, pollMs: 50 });
2539
+
2540
+ const fromZero = await client.listRuns({ workflow: "offset-wf", limit: 1, offset: 0 });
2541
+ const fromNegative = await client.listRuns({ workflow: "offset-wf", limit: 1, offset: -1 });
2542
+
2543
+ expect(fromZero.length).toBe(1);
2544
+ expect(fromNegative.length).toBe(1);
2545
+ expect(fromNegative[0]!.id).toBe(fromZero[0]!.id);
2546
+
2547
+ await worker.stop();
2548
+ redis.close();
2549
+ });
2550
+
2551
+ test("json utils: sentinel-shaped objects are preserved as data", () => {
2552
+ const userObject = { __redflow_undefined__: true };
2553
+ const encoded = safeJsonStringify(userObject);
2554
+ const decoded = safeJsonParse<typeof userObject>(encoded);
2555
+ expect(decoded).toEqual(userObject);
2556
+
2557
+ const sentinelShapeWithKind = {
2558
+ __redflow_undefined__: true,
2559
+ __redflow_kind__: "undefined:v2",
2560
+ };
2561
+ const fullEncoded = safeJsonStringify(sentinelShapeWithKind);
2562
+ const fullDecoded = safeJsonParse<typeof sentinelShapeWithKind>(fullEncoded);
2563
+ expect(fullDecoded).toEqual(sentinelShapeWithKind);
2564
+
2565
+ const encodedUndefined = safeJsonStringify(undefined);
2566
+ const decodedUndefined = safeJsonParse(encodedUndefined);
2567
+ expect(decodedUndefined).toBeUndefined();
2568
+
2569
+ const sentinelRaw = '{"__redflow_undefined__":true,"__redflow_kind__":"undefined:v2"}';
2570
+ const decodedSentinelRaw = safeJsonParse<typeof sentinelShapeWithKind>(sentinelRaw);
2571
+ expect(decodedSentinelRaw).toEqual(sentinelShapeWithKind);
2572
+ });
2573
+
2574
+ test("error serialization: non-serializable thrown values do not wedge a run", async () => {
2575
+ const prefix = testPrefix();
2576
+ const redis = new RedisClient(redisServer.url);
2577
+ const client = createClient({ redis, prefix });
2578
+ setDefaultClient(client);
2579
+
2580
+ const wf = defineWorkflow(
2581
+ {
2582
+ name: "throw-bigint",
2583
+ queue: "q_bigint",
2584
+ retries: { maxAttempts: 2 },
2585
+ },
2586
+ async () => {
2587
+ throw 1n;
2588
+ },
2589
+ );
2590
+
2591
+ const worker = await startWorker({
2592
+ redis,
2593
+ prefix,
2594
+ queues: ["q_bigint"],
2595
+ runtime: { leaseMs: 300, reaperIntervalMs: 50, blmoveTimeoutSec: 0.2 },
2596
+ });
2597
+
2598
+ const handle = await wf.run({});
2599
+ await waitFor(
2600
+ async () => (await client.getRun(handle.id))?.status === "failed",
2601
+ { timeoutMs: 12_000, label: "bigint failure" },
2602
+ );
2603
+
2604
+ const state = await client.getRun(handle.id);
2605
+ expect(state?.status).toBe("failed");
2606
+ expect(state?.attempt).toBe(2);
2607
+ expect((state?.error as any)?.kind).toBe("error");
2608
+ expect(typeof (state?.error as any)?.message).toBe("string");
2609
+
2610
+ const processing = await redis.lrange(`${prefix}:q:q_bigint:processing`, 0, -1);
2611
+ expect(processing.includes(handle.id)).toBe(false);
2612
+
2613
+ await worker.stop();
2614
+ redis.close();
2615
+ });
2616
+
2617
+ test("cron trigger ids: delimiter-like custom ids do not collide", async () => {
2618
+ const prefix = testPrefix();
2619
+ const redis = new RedisClient(redisServer.url);
2620
+ const client = createClient({ redis, prefix });
2621
+
2622
+ defineWorkflow(
2623
+ {
2624
+ name: "wf:a",
2625
+ queue: "qa",
2626
+ triggers: { cron: [{ id: "b:c", expression: "*/5 * * * * *" }] },
2627
+ },
2628
+ async () => ({ ok: true }),
2629
+ );
2630
+
2631
+ defineWorkflow(
2632
+ {
2633
+ name: "wf:a:b",
2634
+ queue: "qb",
2635
+ triggers: { cron: [{ id: "c", expression: "*/5 * * * * *" }] },
2636
+ },
2637
+ async () => ({ ok: true }),
2638
+ );
2639
+
2640
+ await client.syncRegistry(getDefaultRegistry());
2641
+
2642
+ const idsA = JSON.parse((await redis.hget(`${prefix}:workflow:wf:a`, "cronIdsJson")) ?? "[]") as string[];
2643
+ const idsB = JSON.parse((await redis.hget(`${prefix}:workflow:wf:a:b`, "cronIdsJson")) ?? "[]") as string[];
2644
+
2645
+ expect(idsA.length).toBe(1);
2646
+ expect(idsB.length).toBe(1);
2647
+ expect(idsA[0]).not.toBe(idsB[0]);
2648
+
2649
+ const defA = await redis.hget(`${prefix}:cron:def`, idsA[0]!);
2650
+ const defB = await redis.hget(`${prefix}:cron:def`, idsB[0]!);
2651
+ expect(defA).not.toBeNull();
2652
+ expect(defB).not.toBeNull();
2653
+
2654
+ const parsedA = JSON.parse(defA!) as { workflow: string };
2655
+ const parsedB = JSON.parse(defB!) as { workflow: string };
2656
+ expect(parsedA.workflow).toBe("wf:a");
2657
+ expect(parsedB.workflow).toBe("wf:a:b");
2658
+
2659
+ redis.close();
2660
+ });
2661
+
2662
+ test("idempotencyKey: stale pointer is recovered instead of returning missing run", async () => {
2663
+ const prefix = testPrefix();
2664
+ const redis = new RedisClient(redisServer.url);
2665
+ const client = createClient({ redis, prefix });
2666
+ setDefaultClient(client);
2667
+
2668
+ const queue = "q_idem_recover";
2669
+ const workflowName = "idem-recover";
2670
+ const idem = "stale-key";
2671
+
2672
+ const wf = defineWorkflow({ name: workflowName, queue }, async () => ({ ok: true }));
2673
+
2674
+ const worker = await startWorker({
2675
+ redis,
2676
+ prefix,
2677
+ queues: [queue],
2678
+ runtime: { leaseMs: 500 },
2679
+ });
2680
+
2681
+ const staleRunId = `run_stale_${crypto.randomUUID()}`;
2682
+ const idempotencyRedisKey = keys.idempotency(prefix, workflowName, idem);
2683
+ await redis.set(idempotencyRedisKey, staleRunId);
2684
+
2685
+ const handle = await wf.run({}, { idempotencyKey: idem });
2686
+ expect(handle.id).not.toBe(staleRunId);
2687
+
2688
+ const out = await handle.result({ timeoutMs: 5000, pollMs: 50 });
2689
+ expect(out).toEqual({ ok: true });
2690
+
2691
+ const resolved = await redis.get(idempotencyRedisKey);
2692
+ expect(resolved).toBe(handle.id);
2693
+
2694
+ await worker.stop();
2695
+ redis.close();
2696
+ });
2697
+
2698
+ test("idempotencyKey: partial existing run is repaired and executed", async () => {
2699
+ const prefix = testPrefix();
2700
+ const redis = new RedisClient(redisServer.url);
2701
+ const client = createClient({ redis, prefix });
2702
+ setDefaultClient(client);
2703
+
2704
+ const queue = "q_idem_partial";
2705
+ const workflowName = "idem-partial";
2706
+ const idem = "partial-key";
2707
+ const countKey = `${prefix}:t:idem-partial-count`;
2708
+
2709
+ const wf = defineWorkflow({ name: workflowName, queue }, async ({ step }) => {
2710
+ await step.run({ name: "do" }, async () => {
2711
+ await redis.incr(countKey);
2712
+ return true;
2713
+ });
2714
+ return { ok: true };
2715
+ });
2716
+
2717
+ const worker = await startWorker({
2718
+ redis,
2719
+ prefix,
2720
+ queues: [queue],
2721
+ runtime: { leaseMs: 500 },
2722
+ });
2723
+
2724
+ const runId = `run_partial_${crypto.randomUUID()}`;
2725
+ const idempotencyRedisKey = keys.idempotency(prefix, workflowName, idem);
2726
+
2727
+ // Simulate a producer crash after writing run hash but before queue/index writes.
2728
+ await redis.set(idempotencyRedisKey, runId);
2729
+ await redis.hset(keys.run(prefix, runId), {
2730
+ workflow: workflowName,
2731
+ queue,
2732
+ status: "queued",
2733
+ inputJson: safeJsonStringify({}),
2734
+ attempt: 0,
2735
+ maxAttempts: 1,
2736
+ createdAt: Date.now(),
2737
+ availableAt: "",
2738
+ cancelRequestedAt: "",
2739
+ cancelReason: "",
2740
+ });
2741
+
2742
+ const handle = await wf.run({}, { idempotencyKey: idem });
2743
+ expect(handle.id).toBe(runId);
2744
+
2745
+ const out = await handle.result({ timeoutMs: 6000, pollMs: 50 });
2746
+ expect(out).toEqual({ ok: true });
2747
+
2748
+ const count = Number((await redis.get(countKey)) ?? "0");
2749
+ expect(count).toBe(1);
2750
+
2751
+ await worker.stop();
2752
+ redis.close();
2753
+ });
2754
+
2755
+ test("syncRegistry: duplicate custom cron ids in one workflow are rejected", async () => {
2756
+ const prefix = testPrefix();
2757
+ const redis = new RedisClient(redisServer.url);
2758
+ const client = createClient({ redis, prefix });
2759
+
2760
+ defineWorkflow(
2761
+ {
2762
+ name: "dup-custom-cron-id",
2763
+ queue: "q_dup_cron_id",
2764
+ triggers: {
2765
+ cron: [
2766
+ { id: "same", expression: "*/5 * * * * *", input: { n: 1 } },
2767
+ { id: "same", expression: "*/7 * * * * *", input: { n: 2 } },
2768
+ ],
2769
+ },
2770
+ },
2771
+ async () => ({ ok: true }),
2772
+ );
2773
+
2774
+ await expect(client.syncRegistry(getDefaultRegistry())).rejects.toThrow("Duplicate cron trigger id");
2775
+
2776
+ redis.close();
2777
+ });
2778
+
2779
+ test("scheduled promoter: stale scheduled entry does not resurrect terminal run", async () => {
2780
+ const prefix = testPrefix();
2781
+ const redis = new RedisClient(redisServer.url);
2782
+ const client = createClient({ redis, prefix });
2783
+ setDefaultClient(client);
2784
+
2785
+ const queue = "q_sched_guard";
2786
+ const wf = defineWorkflow({ name: "sched-guard", queue }, async ({ step }) => {
2787
+ await step.run({ name: "once" }, async () => true);
2788
+ return { ok: true };
2789
+ });
2790
+
2791
+ const worker = await startWorker({
2792
+ redis,
2793
+ prefix,
2794
+ queues: [queue],
2795
+ runtime: { leaseMs: 400, reaperIntervalMs: 50, blmoveTimeoutSec: 0.2 },
2796
+ });
2797
+
2798
+ const handle = await wf.run({});
2799
+ const out = await handle.result({ timeoutMs: 5000, pollMs: 50 });
2800
+ expect(out).toEqual({ ok: true });
2801
+
2802
+ const firstState = await client.getRun(handle.id);
2803
+ expect(firstState?.status).toBe("succeeded");
2804
+ expect(firstState?.attempt).toBe(1);
2805
+
2806
+ await redis.zadd(`${prefix}:q:${queue}:scheduled`, Date.now() - 1, handle.id);
2807
+ await new Promise((r) => setTimeout(r, 600));
2808
+
2809
+ const secondState = await client.getRun(handle.id);
2810
+ expect(secondState?.status).toBe("succeeded");
2811
+ expect(secondState?.attempt).toBe(1);
2812
+
2813
+ await worker.stop();
2814
+ redis.close();
2815
+ });
2816
+
2817
+ test(
2818
+ "cron scheduler: transient enqueue failure does not drop trigger",
2819
+ async () => {
2820
+ const prefix = testPrefix();
2821
+ const redis = new RedisClient(redisServer.url);
2822
+
2823
+ const queue = "q_cron_retry";
2824
+ const workflowName = "cron-retry";
2825
+ const counterKey = `${prefix}:t:cron-retry-count`;
2826
+
2827
+ defineWorkflow(
2828
+ {
2829
+ name: workflowName,
2830
+ queue,
2831
+ triggers: { cron: [{ expression: "*/1 * * * * *" }] },
2832
+ },
2833
+ async ({ step }) => {
2834
+ await step.run({ name: "tick" }, async () => {
2835
+ await redis.incr(counterKey);
2836
+ return true;
2837
+ });
2838
+ return { ok: true };
2839
+ },
2840
+ );
2841
+
2842
+ let injected = false;
2843
+ const originalRunByName: RedflowClient["runByName"] = RedflowClient.prototype.runByName;
2844
+ const patchedRunByName = (async function (
2845
+ this: RedflowClient,
2846
+ name: string,
2847
+ input: unknown,
2848
+ options?: Parameters<RedflowClient["runByName"]>[2],
2849
+ ): Promise<Awaited<ReturnType<RedflowClient["runByName"]>>> {
2850
+ if (!injected && name === workflowName) {
2851
+ injected = true;
2852
+ throw new Error("injected cron enqueue failure");
2853
+ }
2854
+ return await originalRunByName.call(this, name, input, options);
2855
+ }) as RedflowClient["runByName"];
2856
+
2857
+ RedflowClient.prototype.runByName = patchedRunByName;
2858
+
2859
+ let worker: Awaited<ReturnType<typeof startWorker>> | null = null;
2860
+ try {
2861
+ worker = await startWorker({
2862
+ redis,
2863
+ prefix,
2864
+ queues: [queue],
2865
+ concurrency: 1,
2866
+ runtime: { leaseMs: 500 },
2867
+ });
2868
+
2869
+ await waitFor(
2870
+ async () => Number((await redis.get(counterKey)) ?? "0") >= 1,
2871
+ { timeoutMs: 10_000, label: "cron tick after transient enqueue failure" },
2872
+ );
2873
+ expect(injected).toBe(true);
2874
+ } finally {
2875
+ if (worker) await worker.stop();
2876
+ RedflowClient.prototype.runByName = originalRunByName;
2877
+ redis.close();
2878
+ }
2879
+ },
2880
+ { timeout: 20_000 },
2881
+ );
2882
+
2883
+ test(
2884
+ "cron scheduler: stale score does not enqueue a catch-up burst",
2885
+ async () => {
2886
+ const prefix = testPrefix();
2887
+ const redis = new RedisClient(redisServer.url);
2888
+ const client = createClient({ redis, prefix });
2889
+ setDefaultClient(client);
2890
+
2891
+ const queue = "q_cron_stale";
2892
+ const workflowName = "cron-stale";
2893
+ const counterKey = `${prefix}:t:cronStaleCount`;
2894
+ const cronLockKey = keys.lockCron(prefix);
2895
+
2896
+ defineWorkflow(
2897
+ {
2898
+ name: workflowName,
2899
+ queue,
2900
+ triggers: { cron: [{ expression: "*/1 * * * * *" }] },
2901
+ },
2902
+ async ({ step }) => {
2903
+ await step.run({ name: "tick" }, async () => {
2904
+ await redis.incr(counterKey);
2905
+ return true;
2906
+ });
2907
+ return { ok: true };
2908
+ },
2909
+ );
2910
+
2911
+ await redis.set(cronLockKey, "manual-test-lock");
2912
+
2913
+ let worker: Awaited<ReturnType<typeof startWorker>> | null = null;
2914
+ try {
2915
+ worker = await startWorker({
2916
+ redis,
2917
+ prefix,
2918
+ queues: [queue],
2919
+ concurrency: 1,
2920
+ runtime: { leaseMs: 500 },
2921
+ });
2922
+
2923
+ const workflowKey = `${prefix}:workflow:${workflowName}`;
2924
+ const cronIds = JSON.parse((await redis.hget(workflowKey, "cronIdsJson")) ?? "[]") as string[];
2925
+ expect(cronIds.length).toBe(1);
2926
+ const cronId = cronIds[0]!;
2927
+
2928
+ await redis.set(counterKey, "0");
2929
+ await redis.zadd(`${prefix}:cron:next`, Date.now() - 5000, cronId);
2930
+ await redis.del(cronLockKey);
2931
+
2932
+ await new Promise((r) => setTimeout(r, 1200));
2933
+
2934
+ const count = Number((await redis.get(counterKey)) ?? "0");
2935
+ expect(count).toBeGreaterThan(0);
2936
+ expect(count).toBeLessThanOrEqual(2);
2937
+ } finally {
2938
+ await redis.del(cronLockKey);
2939
+ if (worker) await worker.stop();
2940
+ redis.close();
2941
+ }
2942
+ },
2943
+ { timeout: 20_000 },
2944
+ );
2945
+
2946
+ test("workflow names ending with ':runs' do not collide with workflow run indexes", async () => {
2947
+ const prefix = testPrefix();
2948
+ const redis = new RedisClient(redisServer.url);
2949
+ const client = createClient({ redis, prefix });
2950
+ setDefaultClient(client);
2951
+
2952
+ const queue = "q_keyspace";
2953
+ const base = defineWorkflow({ name: "keyspace-base", queue }, async () => ({ ok: "base" }));
2954
+ defineWorkflow({ name: "keyspace-base:runs", queue }, async () => ({ ok: "suffix" }));
2955
+
2956
+ expect(keys.workflow(prefix, "keyspace-base:runs")).not.toBe(keys.workflowRuns(prefix, "keyspace-base"));
2957
+
2958
+ const worker = await startWorker({
2959
+ redis,
2960
+ prefix,
2961
+ queues: [queue],
2962
+ runtime: { leaseMs: 500 },
2963
+ });
2964
+
2965
+ const handle = await base.run({});
2966
+ const out = await handle.result({ timeoutMs: 5000, pollMs: 50 });
2967
+ expect(out).toEqual({ ok: "base" });
2968
+
2969
+ const suffixMeta = await client.getWorkflowMeta("keyspace-base:runs");
2970
+ expect(suffixMeta?.name).toBe("keyspace-base:runs");
2971
+
2972
+ await worker.stop();
2973
+ redis.close();
2974
+ });
2975
+
2976
+ test("cancelRun race: near-finish cancellation settles as canceled", async () => {
2977
+ const prefix = testPrefix();
2978
+ const redis = new RedisClient(redisServer.url);
2979
+ const client = createClient({ redis, prefix });
2980
+ setDefaultClient(client);
2981
+
2982
+ const queue = "q_cancel_late";
2983
+ const wf = defineWorkflow({ name: "cancel-late", queue }, async ({ step }) => {
2984
+ await step.run({ name: "short" }, async () => {
2985
+ await new Promise((r) => setTimeout(r, 100));
2986
+ return true;
2987
+ });
2988
+ return { ok: true };
2989
+ });
2990
+
2991
+ const originalFinalizeRun: RedflowClient["finalizeRun"] = RedflowClient.prototype.finalizeRun;
2992
+ RedflowClient.prototype.finalizeRun = (async function (
2993
+ this: RedflowClient,
2994
+ runId: string,
2995
+ args: Parameters<RedflowClient["finalizeRun"]>[1],
2996
+ ): Promise<void> {
2997
+ if (args.status === "succeeded") {
2998
+ await new Promise((r) => setTimeout(r, 250));
2999
+ }
3000
+ return await originalFinalizeRun.call(this, runId, args);
3001
+ }) as RedflowClient["finalizeRun"];
3002
+
3003
+ const worker = await startWorker({
3004
+ redis,
3005
+ prefix,
3006
+ queues: [queue],
3007
+ runtime: { leaseMs: 500, blmoveTimeoutSec: 0.2, reaperIntervalMs: 50 },
3008
+ });
3009
+
3010
+ try {
3011
+ const handle = await wf.run({});
3012
+
3013
+ await waitFor(
3014
+ async () => {
3015
+ const state = await client.getRun(handle.id);
3016
+ return state?.status === "running";
3017
+ },
3018
+ { timeoutMs: 4000, label: "run becomes running" },
3019
+ );
3020
+
3021
+ await new Promise((r) => setTimeout(r, 80));
3022
+
3023
+ const cancelPromise = client.cancelRun(handle.id, { reason: "race" });
3024
+
3025
+ const canceled = await cancelPromise;
3026
+ expect(canceled).toBe(true);
3027
+
3028
+ await expect(handle.result({ timeoutMs: 8000, pollMs: 50 })).rejects.toBeInstanceOf(CanceledError);
3029
+
3030
+ const state = await client.getRun(handle.id);
3031
+ expect(state?.status).toBe("canceled");
3032
+ expect(state?.cancelReason).toBe("race");
3033
+ } finally {
3034
+ await worker.stop();
3035
+ RedflowClient.prototype.finalizeRun = originalFinalizeRun;
3036
+ redis.close();
3037
+ }
3038
+ });
3039
+
3040
+ test("cancelRun race: cancellation requested during finalize wins over success", async () => {
3041
+ const prefix = testPrefix();
3042
+ const redis = new RedisClient(redisServer.url);
3043
+ const client = createClient({ redis, prefix });
3044
+ setDefaultClient(client);
3045
+
3046
+ const queue = "q_cancel_finalize_race";
3047
+ const wf = defineWorkflow({ name: "cancel-finalize-race", queue }, async () => {
3048
+ return { ok: true };
3049
+ });
3050
+
3051
+ const originalFinalizeRun: RedflowClient["finalizeRun"] = RedflowClient.prototype.finalizeRun;
3052
+ let enteredFinalize = false;
3053
+
3054
+ RedflowClient.prototype.finalizeRun = (async function (
3055
+ this: RedflowClient,
3056
+ runId: string,
3057
+ args: Parameters<RedflowClient["finalizeRun"]>[1],
3058
+ ): Promise<void> {
3059
+ if (args.status === "succeeded") {
3060
+ enteredFinalize = true;
3061
+ await new Promise((r) => setTimeout(r, 250));
3062
+ }
3063
+ return await originalFinalizeRun.call(this, runId, args);
3064
+ }) as RedflowClient["finalizeRun"];
3065
+
3066
+ const worker = await startWorker({
3067
+ redis,
3068
+ prefix,
3069
+ queues: [queue],
3070
+ runtime: { leaseMs: 500, blmoveTimeoutSec: 0.2, reaperIntervalMs: 50 },
3071
+ });
3072
+
3073
+ try {
3074
+ const handle = await wf.run({});
3075
+
3076
+ await waitFor(
3077
+ async () => enteredFinalize,
3078
+ { timeoutMs: 5000, label: "run enters finalize" },
3079
+ );
3080
+
3081
+ const canceled = await client.cancelRun(handle.id, { reason: "race" });
3082
+ expect(canceled).toBe(true);
3083
+
3084
+ await waitFor(
3085
+ async () => {
3086
+ const run = await client.getRun(handle.id);
3087
+ return run?.status === "canceled";
3088
+ },
3089
+ { timeoutMs: 6000, label: "run canceled in finalize race" },
3090
+ );
3091
+
3092
+ await expect(handle.result({ timeoutMs: 1000, pollMs: 50 })).rejects.toBeInstanceOf(CanceledError);
3093
+
3094
+ const state = await client.getRun(handle.id);
3095
+ expect(state?.status).toBe("canceled");
3096
+ } finally {
3097
+ await worker.stop();
3098
+ RedflowClient.prototype.finalizeRun = originalFinalizeRun;
3099
+ redis.close();
3100
+ }
3101
+ });
3102
+
3103
+ test(
3104
+ "worker.stop aborts an in-flight non-cooperative handler",
3105
+ async () => {
3106
+ const prefix = testPrefix();
3107
+ const redis = new RedisClient(redisServer.url);
3108
+ const client = createClient({ redis, prefix });
3109
+ setDefaultClient(client);
3110
+
3111
+ const queue = "q_stop_abort";
3112
+ const wf = defineWorkflow({ name: "stop-abort", queue }, async () => {
3113
+ await new Promise<never>(() => {});
3114
+ });
3115
+
3116
+ const worker = await startWorker({
3117
+ redis,
3118
+ prefix,
3119
+ queues: [queue],
3120
+ runtime: { leaseMs: 500, blmoveTimeoutSec: 0.2, reaperIntervalMs: 50 },
3121
+ });
3122
+
3123
+ try {
3124
+ const handle = await wf.run({});
3125
+
3126
+ await waitFor(
3127
+ async () => {
3128
+ const run = await client.getRun(handle.id);
3129
+ return run?.status === "running";
3130
+ },
3131
+ { timeoutMs: 5000, label: "run becomes running" },
3132
+ );
3133
+
3134
+ const startedAt = Date.now();
3135
+ await worker.stop();
3136
+ const stopElapsedMs = Date.now() - startedAt;
3137
+ expect(stopElapsedMs).toBeLessThan(2500);
3138
+
3139
+ await waitFor(
3140
+ async () => {
3141
+ const run = await client.getRun(handle.id);
3142
+ return run?.status === "canceled";
3143
+ },
3144
+ { timeoutMs: 4000, label: "run canceled by stop" },
3145
+ );
3146
+
3147
+ const state = await client.getRun(handle.id);
3148
+ expect(state?.status).toBe("canceled");
3149
+ } finally {
3150
+ redis.close();
3151
+ }
3152
+ },
3153
+ { timeout: 20_000 },
3154
+ );