379 lines
10 KiB
TypeScript
379 lines
10 KiB
TypeScript
import { Queue, Worker } from 'bullmq';
|
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
import { DeadLetterQueueHandler } from '../src/dlq-handler';
|
|
import { getRedisConnection } from '../src/utils';
|
|
|
|
// Suppress Redis connection errors in tests
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|
if (reason && typeof reason === 'object' && 'message' in reason) {
|
|
const message = (reason as Error).message;
|
|
if (
|
|
message.includes('Connection is closed') ||
|
|
message.includes('Connection is in monitoring mode')
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
|
});
|
|
|
|
describe('DeadLetterQueueHandler', () => {
|
|
let mainQueue: Queue;
|
|
let dlqHandler: DeadLetterQueueHandler;
|
|
let worker: Worker;
|
|
let connection: any;
|
|
|
|
const redisConfig = {
|
|
host: 'localhost',
|
|
port: 6379,
|
|
password: '',
|
|
db: 0,
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
connection = getRedisConnection(redisConfig);
|
|
|
|
// Create main queue
|
|
mainQueue = new Queue('test-queue', { connection });
|
|
|
|
// Create DLQ handler
|
|
dlqHandler = new DeadLetterQueueHandler(mainQueue, connection, {
|
|
maxRetries: 3,
|
|
retryDelay: 100,
|
|
alertThreshold: 5,
|
|
cleanupAge: 24,
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
try {
|
|
if (worker) {
|
|
await worker.close();
|
|
}
|
|
await dlqHandler.shutdown();
|
|
await mainQueue.close();
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
});
|
|
|
|
describe('Failed Job Handling', () => {
|
|
test('should move job to DLQ after max retries', async () => {
|
|
let attemptCount = 0;
|
|
|
|
// Create worker that always fails
|
|
worker = new Worker(
|
|
'test-queue',
|
|
async () => {
|
|
attemptCount++;
|
|
throw new Error('Job failed');
|
|
},
|
|
{
|
|
connection,
|
|
autorun: false,
|
|
}
|
|
);
|
|
|
|
// Add job with limited attempts
|
|
const _job = await mainQueue.add(
|
|
'failing-job',
|
|
{ test: true },
|
|
{
|
|
attempts: 3,
|
|
backoff: { type: 'fixed', delay: 50 },
|
|
}
|
|
);
|
|
|
|
// Process job manually
|
|
await worker.run();
|
|
|
|
// Wait for retries
|
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
|
|
// Job should have failed 3 times
|
|
expect(attemptCount).toBe(3);
|
|
|
|
// Check if job was moved to DLQ
|
|
const dlqStats = await dlqHandler.getStats();
|
|
expect(dlqStats.total).toBe(1);
|
|
expect(dlqStats.byJobName['failing-job']).toBe(1);
|
|
});
|
|
|
|
test('should track failure count correctly', async () => {
|
|
const job = await mainQueue.add('test-job', { data: 'test' });
|
|
const error = new Error('Test error');
|
|
|
|
// Simulate multiple failures
|
|
await dlqHandler.handleFailedJob(job, error);
|
|
await dlqHandler.handleFailedJob(job, error);
|
|
|
|
// On third failure with max attempts reached, should move to DLQ
|
|
job.attemptsMade = 3;
|
|
job.opts.attempts = 3;
|
|
await dlqHandler.handleFailedJob(job, error);
|
|
|
|
const stats = await dlqHandler.getStats();
|
|
expect(stats.total).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('DLQ Statistics', () => {
|
|
test('should provide detailed statistics', async () => {
|
|
// Add some failed jobs to DLQ
|
|
const dlq = new Queue(`test-queue-dlq`, { connection });
|
|
|
|
await dlq.add('failed-job', {
|
|
originalJob: {
|
|
id: '1',
|
|
name: 'job-type-a',
|
|
data: { test: true },
|
|
attemptsMade: 3,
|
|
},
|
|
error: { message: 'Error 1' },
|
|
movedToDLQAt: new Date().toISOString(),
|
|
});
|
|
|
|
await dlq.add('failed-job', {
|
|
originalJob: {
|
|
id: '2',
|
|
name: 'job-type-b',
|
|
data: { test: true },
|
|
attemptsMade: 3,
|
|
},
|
|
error: { message: 'Error 2' },
|
|
movedToDLQAt: new Date().toISOString(),
|
|
});
|
|
|
|
const stats = await dlqHandler.getStats();
|
|
expect(stats.total).toBe(2);
|
|
expect(stats.recent).toBe(2); // Both are recent
|
|
expect(Object.keys(stats.byJobName).length).toBe(2);
|
|
expect(stats.oldestJob).toBeDefined();
|
|
|
|
await dlq.close();
|
|
});
|
|
|
|
test('should count recent jobs correctly', async () => {
|
|
const dlq = new Queue(`test-queue-dlq`, { connection });
|
|
|
|
// Add old job (25 hours ago)
|
|
const oldTimestamp = Date.now() - 25 * 60 * 60 * 1000;
|
|
await dlq.add(
|
|
'failed-job',
|
|
{
|
|
originalJob: { id: '1', name: 'old-job' },
|
|
error: { message: 'Old error' },
|
|
movedToDLQAt: new Date(oldTimestamp).toISOString(),
|
|
},
|
|
{ timestamp: oldTimestamp }
|
|
);
|
|
|
|
// Add recent job
|
|
await dlq.add('failed-job', {
|
|
originalJob: { id: '2', name: 'recent-job' },
|
|
error: { message: 'Recent error' },
|
|
movedToDLQAt: new Date().toISOString(),
|
|
});
|
|
|
|
const stats = await dlqHandler.getStats();
|
|
expect(stats.total).toBe(2);
|
|
expect(stats.recent).toBe(1); // Only one is recent
|
|
|
|
await dlq.close();
|
|
});
|
|
});
|
|
|
|
describe('DLQ Retry', () => {
|
|
test('should retry jobs from DLQ', async () => {
|
|
const dlq = new Queue(`test-queue-dlq`, { connection });
|
|
|
|
// Add failed jobs to DLQ
|
|
await dlq.add('failed-job', {
|
|
originalJob: {
|
|
id: '1',
|
|
name: 'retry-job',
|
|
data: { retry: true },
|
|
opts: { priority: 1 },
|
|
},
|
|
error: { message: 'Failed' },
|
|
movedToDLQAt: new Date().toISOString(),
|
|
});
|
|
|
|
await dlq.add('failed-job', {
|
|
originalJob: {
|
|
id: '2',
|
|
name: 'retry-job-2',
|
|
data: { retry: true },
|
|
opts: {},
|
|
},
|
|
error: { message: 'Failed' },
|
|
movedToDLQAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Retry jobs
|
|
const retriedCount = await dlqHandler.retryDLQJobs(10);
|
|
expect(retriedCount).toBe(2);
|
|
|
|
// Check main queue has the retried jobs
|
|
const mainQueueJobs = await mainQueue.getWaiting();
|
|
expect(mainQueueJobs.length).toBe(2);
|
|
expect(mainQueueJobs[0].name).toBe('retry-job');
|
|
expect(mainQueueJobs[0].data).toEqual({ retry: true });
|
|
|
|
// DLQ should be empty
|
|
const dlqJobs = await dlq.getCompleted();
|
|
expect(dlqJobs.length).toBe(0);
|
|
|
|
await dlq.close();
|
|
});
|
|
|
|
test('should respect retry limit', async () => {
|
|
const dlq = new Queue(`test-queue-dlq`, { connection });
|
|
|
|
// Add 5 failed jobs
|
|
for (let i = 0; i < 5; i++) {
|
|
await dlq.add('failed-job', {
|
|
originalJob: {
|
|
id: `${i}`,
|
|
name: `job-${i}`,
|
|
data: { index: i },
|
|
},
|
|
error: { message: 'Failed' },
|
|
movedToDLQAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
// Retry only 3 jobs
|
|
const retriedCount = await dlqHandler.retryDLQJobs(3);
|
|
expect(retriedCount).toBe(3);
|
|
|
|
// Check counts
|
|
const mainQueueJobs = await mainQueue.getWaiting();
|
|
expect(mainQueueJobs.length).toBe(3);
|
|
|
|
const remainingDLQ = await dlq.getCompleted();
|
|
expect(remainingDLQ.length).toBe(2);
|
|
|
|
await dlq.close();
|
|
});
|
|
});
|
|
|
|
describe('DLQ Cleanup', () => {
|
|
test('should cleanup old DLQ entries', async () => {
|
|
const dlq = new Queue(`test-queue-dlq`, { connection });
|
|
|
|
// Add old job (25 hours ago)
|
|
const oldTimestamp = Date.now() - 25 * 60 * 60 * 1000;
|
|
await dlq.add(
|
|
'failed-job',
|
|
{
|
|
originalJob: { id: '1', name: 'old-job' },
|
|
error: { message: 'Old error' },
|
|
},
|
|
{ timestamp: oldTimestamp }
|
|
);
|
|
|
|
// Add recent job (1 hour ago)
|
|
const recentTimestamp = Date.now() - 1 * 60 * 60 * 1000;
|
|
await dlq.add(
|
|
'failed-job',
|
|
{
|
|
originalJob: { id: '2', name: 'recent-job' },
|
|
error: { message: 'Recent error' },
|
|
},
|
|
{ timestamp: recentTimestamp }
|
|
);
|
|
|
|
// Run cleanup (24 hour threshold)
|
|
const removedCount = await dlqHandler.cleanup();
|
|
expect(removedCount).toBe(1);
|
|
|
|
// Check remaining jobs
|
|
const remaining = await dlq.getCompleted();
|
|
expect(remaining.length).toBe(1);
|
|
expect(remaining[0].data.originalJob.name).toBe('recent-job');
|
|
|
|
await dlq.close();
|
|
});
|
|
});
|
|
|
|
describe('Failed Job Inspection', () => {
|
|
test('should inspect failed jobs', async () => {
|
|
const dlq = new Queue(`test-queue-dlq`, { connection });
|
|
|
|
// Add failed jobs with different error types
|
|
await dlq.add('failed-job', {
|
|
originalJob: {
|
|
id: '1',
|
|
name: 'network-job',
|
|
data: { url: 'https://api.example.com' },
|
|
attemptsMade: 3,
|
|
},
|
|
error: {
|
|
message: 'Network timeout',
|
|
stack: 'Error: Network timeout\n at ...',
|
|
name: 'NetworkError',
|
|
},
|
|
movedToDLQAt: '2024-01-01T10:00:00Z',
|
|
});
|
|
|
|
await dlq.add('failed-job', {
|
|
originalJob: {
|
|
id: '2',
|
|
name: 'parse-job',
|
|
data: { input: 'invalid-json' },
|
|
attemptsMade: 2,
|
|
},
|
|
error: {
|
|
message: 'Invalid JSON',
|
|
stack: 'SyntaxError: Invalid JSON\n at ...',
|
|
name: 'SyntaxError',
|
|
},
|
|
movedToDLQAt: '2024-01-01T11:00:00Z',
|
|
});
|
|
|
|
const failedJobs = await dlqHandler.inspectFailedJobs(10);
|
|
expect(failedJobs.length).toBe(2);
|
|
|
|
expect(failedJobs[0]).toMatchObject({
|
|
id: '1',
|
|
name: 'network-job',
|
|
data: { url: 'https://api.example.com' },
|
|
error: {
|
|
message: 'Network timeout',
|
|
name: 'NetworkError',
|
|
},
|
|
failedAt: '2024-01-01T10:00:00Z',
|
|
attempts: 3,
|
|
});
|
|
|
|
await dlq.close();
|
|
});
|
|
});
|
|
|
|
describe('Alert Threshold', () => {
|
|
test('should detect when alert threshold is exceeded', async () => {
|
|
const dlq = new Queue(`test-queue-dlq`, { connection });
|
|
|
|
// Add jobs to exceed threshold (5)
|
|
for (let i = 0; i < 6; i++) {
|
|
await dlq.add('failed-job', {
|
|
originalJob: {
|
|
id: `${i}`,
|
|
name: `job-${i}`,
|
|
data: { index: i },
|
|
},
|
|
error: { message: 'Failed' },
|
|
movedToDLQAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
const stats = await dlqHandler.getStats();
|
|
expect(stats.total).toBe(6);
|
|
// In a real implementation, this would trigger alerts
|
|
|
|
await dlq.close();
|
|
});
|
|
});
|
|
});
|