Skip to content

Commit 2c1f173

Browse files
committed
fix(redis-worker): move unrecoverable queue items to the dead-letter queue
SimpleQueue.dequeue() skipped items whose job field was not a string, whose job had no registered schema, or whose payload failed schema validation. Because the queue uses a visibility-timeout redelivery model (items are only removed on ack or by moving them to the DLQ), these skipped items were re-scored and redelivered on every visibility-timeout cycle indefinitely. These failures are unrecoverable, so route all three branches to moveToDeadLetterQueue() instead of silently skipping. Add tests covering the unknown-job and unparseable-payload cases.
1 parent c936c79 commit 2c1f173

2 files changed

Lines changed: 127 additions & 0 deletions

File tree

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

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,124 @@ describe("SimpleQueue", () => {
484484
}
485485
});
486486

487+
redisTest(
488+
"dequeue moves items with an unknown job to the DLQ",
489+
{ timeout: 20_000 },
490+
async ({ redisContainer }) => {
491+
const redisOptions = {
492+
host: redisContainer.getHost(),
493+
port: redisContainer.getPort(),
494+
password: redisContainer.getPassword(),
495+
};
496+
497+
// Producer enqueues an item for the "test" job.
498+
const producer = new SimpleQueue({
499+
name: "test-invalid-job",
500+
schema: {
501+
test: z.object({
502+
value: z.number(),
503+
}),
504+
},
505+
redisOptions,
506+
logger: new Logger("test", "log"),
507+
});
508+
509+
// Consumer shares the same queue name but doesn't know about the "test" job.
510+
const consumer = new SimpleQueue({
511+
name: "test-invalid-job",
512+
schema: {
513+
other: z.object({
514+
value: z.number(),
515+
}),
516+
},
517+
redisOptions,
518+
logger: new Logger("test", "log"),
519+
});
520+
521+
try {
522+
await producer.enqueue({
523+
id: "1",
524+
job: "test",
525+
item: { value: 1 },
526+
visibilityTimeoutMs: 2000,
527+
});
528+
expect(await consumer.size()).toBe(1);
529+
expect(await consumer.sizeOfDeadLetterQueue()).toBe(0);
530+
531+
// Dequeue can't find a schema for the job, so it should be moved to the DLQ.
532+
const dequeued = await consumer.dequeue(1);
533+
expect(dequeued).toEqual([]);
534+
expect(await consumer.size({ includeFuture: true })).toBe(0);
535+
expect(await consumer.sizeOfDeadLetterQueue()).toBe(1);
536+
537+
// The item must not resurface on a subsequent dequeue.
538+
expect(await consumer.dequeue(1)).toEqual([]);
539+
} finally {
540+
await producer.close();
541+
await consumer.close();
542+
}
543+
}
544+
);
545+
546+
redisTest(
547+
"dequeue moves items with an unparseable payload to the DLQ",
548+
{ timeout: 20_000 },
549+
async ({ redisContainer }) => {
550+
const redisOptions = {
551+
host: redisContainer.getHost(),
552+
port: redisContainer.getPort(),
553+
password: redisContainer.getPassword(),
554+
};
555+
556+
// Producer enqueues a numeric payload.
557+
const producer = new SimpleQueue({
558+
name: "test-invalid-payload",
559+
schema: {
560+
test: z.object({
561+
value: z.number(),
562+
}),
563+
},
564+
redisOptions,
565+
logger: new Logger("test", "log"),
566+
});
567+
568+
// Consumer expects a string payload for the same job, so validation fails.
569+
const consumer = new SimpleQueue({
570+
name: "test-invalid-payload",
571+
schema: {
572+
test: z.object({
573+
value: z.string(),
574+
}),
575+
},
576+
redisOptions,
577+
logger: new Logger("test", "log"),
578+
});
579+
580+
try {
581+
await producer.enqueue({
582+
id: "1",
583+
job: "test",
584+
item: { value: 1 },
585+
visibilityTimeoutMs: 2000,
586+
});
587+
expect(await consumer.size()).toBe(1);
588+
expect(await consumer.sizeOfDeadLetterQueue()).toBe(0);
589+
590+
// The payload fails schema validation, so it should be moved to the DLQ.
591+
const dequeued = await consumer.dequeue(1);
592+
expect(dequeued).toEqual([]);
593+
expect(await consumer.size({ includeFuture: true })).toBe(0);
594+
expect(await consumer.sizeOfDeadLetterQueue()).toBe(1);
595+
596+
// The item must not resurface on a subsequent dequeue.
597+
expect(await consumer.dequeue(1)).toEqual([]);
598+
} finally {
599+
await producer.close();
600+
await consumer.close();
601+
}
602+
}
603+
);
604+
487605
redisTest("cleanup orphaned queue entries", { timeout: 20_000 }, async ({ redisContainer }) => {
488606
const queue = new SimpleQueue({
489607
name: "test-orphaned",

packages/redis-worker/src/queue.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ export class SimpleQueue<TMessageCatalog extends MessageCatalogSchema> {
198198
const parsedItem = JSON.parse(serializedItem) as any;
199199
if (typeof parsedItem.job !== "string") {
200200
this.logger.error(`Invalid item in queue`, { queue: this.name, id, item: parsedItem });
201+
await this.moveToDeadLetterQueue(id, "Invalid item in queue: 'job' is not a string");
201202
continue;
202203
}
203204

@@ -214,6 +215,10 @@ export class SimpleQueue<TMessageCatalog extends MessageCatalogSchema> {
214215
timestamp,
215216
availableJobs: Object.keys(this.schema),
216217
});
218+
await this.moveToDeadLetterQueue(
219+
id,
220+
`Invalid item in queue: no schema found for job "${parsedItem.job}"`
221+
);
217222
continue;
218223
}
219224

@@ -228,6 +233,10 @@ export class SimpleQueue<TMessageCatalog extends MessageCatalogSchema> {
228233
attempt: parsedItem.attempt,
229234
timestamp,
230235
});
236+
await this.moveToDeadLetterQueue(
237+
id,
238+
`Invalid item in queue: payload failed schema validation: ${validatedItem.error.message}`
239+
);
231240
continue;
232241
}
233242

0 commit comments

Comments
 (0)