Skip to content

Commit 7152f89

Browse files
0skiTrigger.dev RepoOps
authored andcommitted
fix(redis-worker): keep a failed attempt from overwriting, reviving or dead-lettering a newer enqueue
Fix a redis-worker job that was re-enqueued while a previous attempt was running being overwritten or moved to the dead letter queue when that earlier attempt failed. Mono-RevId: 0fa23407e2e548cc0ee2841d301bbf0562ce8919
1 parent 3d54889 commit 7152f89

4 files changed

Lines changed: 289 additions & 6 deletions

File tree

‎packages/redis-worker/src/queue.test.ts‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,4 +603,111 @@ describe("SimpleQueue", () => {
603603
}
604604
}
605605
);
606+
607+
redisTest(
608+
"requeue and moveToDeadLetterQueue skip an item a newer enqueue replaced",
609+
{ timeout: 20_000 },
610+
async ({ redisContainer }) => {
611+
const queue = new SimpleQueue({
612+
name: "test-supersede",
613+
schema: { test: z.object({ value: z.number() }) },
614+
redisOptions: {
615+
host: redisContainer.getHost(),
616+
port: redisContainer.getPort(),
617+
password: redisContainer.getPassword(),
618+
},
619+
logger: new Logger("test", "error"),
620+
});
621+
622+
try {
623+
await queue.enqueue({
624+
id: "1",
625+
job: "test",
626+
item: { value: 1 },
627+
visibilityTimeoutMs: 2000,
628+
});
629+
const [first] = await queue.dequeue(1);
630+
await queue.enqueue({
631+
id: "1",
632+
job: "test",
633+
item: { value: 2 },
634+
visibilityTimeoutMs: 2000,
635+
});
636+
637+
const retry = {
638+
id: "1",
639+
job: "test" as const,
640+
item: { value: 1 },
641+
attempt: 1,
642+
availableAt: new Date(),
643+
visibilityTimeoutMs: 2000,
644+
};
645+
expect(await queue.requeue({ ...retry, deduplicationKey: first?.deduplicationKey })).toBe(
646+
false
647+
);
648+
expect(await queue.moveToDeadLetterQueue("1", "failed", first?.deduplicationKey)).toBe(
649+
false
650+
);
651+
expect(await queue.sizeOfDeadLetterQueue()).toBe(0);
652+
653+
const [newer] = await queue.dequeue(1);
654+
expect(newer).toEqual(expect.objectContaining({ id: "1", item: { value: 2 } }));
655+
656+
expect(await queue.requeue({ ...retry, deduplicationKey: newer?.deduplicationKey })).toBe(
657+
true
658+
);
659+
const [retried] = await queue.dequeue(1);
660+
expect(retried).toEqual(expect.objectContaining({ item: { value: 1 }, attempt: 1 }));
661+
expect(await queue.moveToDeadLetterQueue("1", "failed", retried?.deduplicationKey)).toBe(
662+
true
663+
);
664+
expect(await queue.sizeOfDeadLetterQueue()).toBe(1);
665+
} finally {
666+
await queue.close();
667+
}
668+
}
669+
);
670+
671+
redisTest(
672+
"requeue does not revive an item acked while the attempt ran",
673+
{ timeout: 20_000 },
674+
async ({ redisContainer }) => {
675+
const queue = new SimpleQueue({
676+
name: "test-requeue-acked",
677+
schema: { test: z.object({ value: z.number() }) },
678+
redisOptions: {
679+
host: redisContainer.getHost(),
680+
port: redisContainer.getPort(),
681+
password: redisContainer.getPassword(),
682+
},
683+
logger: new Logger("test", "error"),
684+
});
685+
686+
try {
687+
await queue.enqueue({
688+
id: "1",
689+
job: "test",
690+
item: { value: 1 },
691+
visibilityTimeoutMs: 2000,
692+
});
693+
const [first] = await queue.dequeue(1);
694+
await queue.ack("1");
695+
696+
const requeued = await queue.requeue({
697+
id: "1",
698+
job: "test",
699+
item: { value: 1 },
700+
attempt: 1,
701+
availableAt: new Date(),
702+
visibilityTimeoutMs: 2000,
703+
deduplicationKey: first?.deduplicationKey,
704+
});
705+
706+
expect(requeued).toBe(false);
707+
expect(await queue.getJob("1")).toBeNull();
708+
} finally {
709+
await queue.close();
710+
}
711+
}
712+
);
606713
});

‎packages/redis-worker/src/queue.ts‎

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,50 @@ export class SimpleQueue<TMessageCatalog extends MessageCatalogSchema> {
140140
}
141141
}
142142

143+
/**
144+
* Re-enqueues an item for a retry, unless since it was dequeued a newer enqueue replaced it (its
145+
* stored deduplication key no longer matches) or it was removed by an ack or cancel. Returns
146+
* false when it skipped, so a failed attempt never overwrites the newer item or revives a removed
147+
* one. A newer enqueue starts its own retry budget: an item a handler re-enqueues before failing
148+
* restarts at attempt 0 rather than moving towards the DLQ.
149+
*/
150+
async requeue({
151+
id,
152+
job,
153+
item,
154+
attempt,
155+
availableAt,
156+
visibilityTimeoutMs,
157+
deduplicationKey,
158+
}: {
159+
id: string;
160+
job: MessageCatalogKey<TMessageCatalog>;
161+
item: MessageCatalogValue<TMessageCatalog, MessageCatalogKey<TMessageCatalog>>;
162+
attempt: number;
163+
availableAt: Date;
164+
visibilityTimeoutMs: number;
165+
deduplicationKey?: string;
166+
}): Promise<boolean> {
167+
const serializedItem = JSON.stringify({
168+
job,
169+
item,
170+
visibilityTimeoutMs,
171+
attempt,
172+
deduplicationKey: nanoid(),
173+
});
174+
175+
const result = await this.redis.requeueItem(
176+
`queue`,
177+
`items`,
178+
id,
179+
availableAt.getTime(),
180+
serializedItem,
181+
deduplicationKey ?? ""
182+
);
183+
184+
return result === 1;
185+
}
186+
143187
async enqueueOnce({
144188
id,
145189
job,
@@ -362,7 +406,15 @@ export class SimpleQueue<TMessageCatalog extends MessageCatalogSchema> {
362406
};
363407
}
364408

365-
async moveToDeadLetterQueue(id: string, errorMessage: string): Promise<void> {
409+
/**
410+
* Moves an item to the dead letter queue. With a deduplication key, an item a newer enqueue has
411+
* replaced is left in the queue and false is returned.
412+
*/
413+
async moveToDeadLetterQueue(
414+
id: string,
415+
errorMessage: string,
416+
deduplicationKey?: string
417+
): Promise<boolean> {
366418
try {
367419
this.logger.debug(`SimpleQueue ${this.name}.moveToDeadLetterQueue(): moving item to DLQ`, {
368420
queue: this.name,
@@ -376,12 +428,15 @@ export class SimpleQueue<TMessageCatalog extends MessageCatalogSchema> {
376428
`dlq`,
377429
`dlq:items`,
378430
id,
379-
errorMessage
431+
errorMessage,
432+
deduplicationKey ?? ""
380433
);
381434

435+
if (result === 2) return false;
382436
if (result !== 1) {
383437
throw new Error("Move to Dead Letter Queue operation failed");
384438
}
439+
return true;
385440
} catch (e) {
386441
this.logger.error(
387442
`SimpleQueue ${this.name}.moveToDeadLetterQueue(): error moving item to DLQ`,
@@ -596,6 +651,36 @@ export class SimpleQueue<TMessageCatalog extends MessageCatalogSchema> {
596651
`,
597652
});
598653

654+
this.redis.defineCommand("requeueItem", {
655+
numberOfKeys: 2,
656+
lua: `
657+
local queue = KEYS[1]
658+
local items = KEYS[2]
659+
local id = ARGV[1]
660+
local score = ARGV[2]
661+
local serializedItem = ARGV[3]
662+
local deduplicationKey = ARGV[4]
663+
664+
-- Leave the item alone if, while this attempt ran, it was removed (acked or cancelled)
665+
-- or a newer enqueue replaced it.
666+
if deduplicationKey ~= "" then
667+
local current = redis.call('HGET', items, id)
668+
if not current then
669+
return 2
670+
end
671+
local success, parsed = pcall(cjson.decode, current)
672+
if success and parsed.deduplicationKey and parsed.deduplicationKey ~= deduplicationKey then
673+
return 2
674+
end
675+
end
676+
677+
redis.call('ZADD', queue, score, id)
678+
redis.call('HSET', items, id, serializedItem)
679+
680+
return 1
681+
`,
682+
});
683+
599684
this.redis.defineCommand("moveToDeadLetterQueue", {
600685
numberOfKeys: 4,
601686
lua: `
@@ -605,13 +690,17 @@ export class SimpleQueue<TMessageCatalog extends MessageCatalogSchema> {
605690
local dlqItems = KEYS[4]
606691
local id = ARGV[1]
607692
local errorMessage = ARGV[2]
693+
local deduplicationKey = ARGV[3]
608694
609695
local item = redis.call('HGET', items, id)
610696
if not item then
611697
return 0
612698
end
613699
614700
local parsedItem = cjson.decode(item)
701+
if deduplicationKey ~= "" and parsedItem.deduplicationKey and parsedItem.deduplicationKey ~= deduplicationKey then
702+
return 2
703+
end
615704
parsedItem.errorMessage = errorMessage
616705
617706
local time = redis.call('TIME')
@@ -738,6 +827,17 @@ declare module "@internal/redis" {
738827
dlqItems: string,
739828
id: string,
740829
errorMessage: string,
830+
deduplicationKey: string,
831+
callback?: Callback<number>
832+
): Result<number, Context>;
833+
834+
requeueItem(
835+
queue: string,
836+
items: string,
837+
id: string,
838+
score: number,
839+
serializedItem: string,
840+
deduplicationKey: string,
741841
callback?: Callback<number>
742842
): Result<number, Context>;
743843

‎packages/redis-worker/src/worker.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,4 +694,60 @@ describe("Worker", () => {
694694
await worker.stop();
695695
}
696696
);
697+
698+
redisTest(
699+
"A failed attempt does not overwrite or dead-letter a newer enqueue of the same id",
700+
{ timeout: 30_000 },
701+
async ({ redisContainer }) => {
702+
for (const maxAttempts of [1, 3]) {
703+
const seen: Array<{ value: number; attempt: number }> = [];
704+
let release!: () => void;
705+
const released = new Promise<void>((resolve) => (release = resolve));
706+
let started!: () => void;
707+
const firstStarted = new Promise<void>((resolve) => (started = resolve));
708+
709+
const worker = new Worker({
710+
name: `test-supersede-${maxAttempts}`,
711+
redisOptions: {
712+
host: redisContainer.getHost(),
713+
port: redisContainer.getPort(),
714+
password: redisContainer.getPassword(),
715+
},
716+
catalog: {
717+
testJob: {
718+
schema: z.object({ value: z.number() }),
719+
visibilityTimeoutMs: 5000,
720+
retry: { maxAttempts, minTimeoutInMs: 10_000, maxTimeoutInMs: 10_000 },
721+
},
722+
},
723+
jobs: {
724+
testJob: async ({ payload, attempt }) => {
725+
seen.push({ value: payload.value, attempt });
726+
if (payload.value !== 1) return;
727+
started();
728+
await released;
729+
throw new Error("first attempt failed");
730+
},
731+
},
732+
concurrency: { workers: 1, tasksPerWorker: 2 },
733+
pollIntervalMs: 20,
734+
logger: new Logger("test", "error"),
735+
}).start();
736+
737+
await worker.enqueue({ id: "same", job: "testJob", payload: { value: 1 } });
738+
await firstStarted;
739+
await worker.enqueue({ id: "same", job: "testJob", payload: { value: 2 } });
740+
release();
741+
742+
await new Promise((resolve) => setTimeout(resolve, 1000));
743+
expect(await worker.queue.sizeOfDeadLetterQueue()).toBe(0);
744+
await worker.stop();
745+
746+
// The newer enqueue ran on its own first attempt, and the failed attempt neither
747+
// requeued its stale payload over it nor moved it to the dead letter queue.
748+
expect(seen).toContainEqual({ value: 2, attempt: 0 });
749+
expect(seen.filter((s) => s.value === 1)).toHaveLength(1);
750+
}
751+
}
752+
);
697753
});

0 commit comments

Comments
 (0)