reworked queue lib
This commit is contained in:
parent
629ba2b8d4
commit
c05a7413dc
34 changed files with 3887 additions and 861 deletions
354
libs/queue/test/batch-processor.test.ts
Normal file
354
libs/queue/test/batch-processor.test.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { QueueManager, Queue, handlerRegistry, processItems, initializeBatchCache } from '../src';
|
||||
|
||||
// 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('Batch Processor', () => {
|
||||
let queueManager: QueueManager;
|
||||
let queue: Queue;
|
||||
|
||||
const redisConfig = {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
password: '',
|
||||
db: 0,
|
||||
};
|
||||
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear handler registry
|
||||
handlerRegistry.clear();
|
||||
|
||||
// Register test handler
|
||||
handlerRegistry.register('batch-test', {
|
||||
'process-item': async (payload) => {
|
||||
return { processed: true, data: payload };
|
||||
},
|
||||
'generic': async (payload) => {
|
||||
return { processed: true, data: payload };
|
||||
},
|
||||
'process-batch-items': async (batchData) => {
|
||||
// This is called by the batch processor internally
|
||||
return { batchProcessed: true };
|
||||
},
|
||||
});
|
||||
|
||||
// Use unique queue name per test to avoid conflicts
|
||||
const uniqueQueueName = `batch-test-queue-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Initialize queue manager with no workers to prevent immediate processing
|
||||
queueManager = new QueueManager({
|
||||
queueName: uniqueQueueName,
|
||||
redis: redisConfig,
|
||||
workers: 0, // No workers in tests
|
||||
concurrency: 5,
|
||||
});
|
||||
|
||||
await queueManager.initialize();
|
||||
|
||||
// Create Queue instance without worker to prevent immediate job processing
|
||||
queue = new Queue(queueManager.getQueueName(), queueManager.getRedisConfig(), { startWorker: false });
|
||||
await initializeBatchCache(queue);
|
||||
|
||||
// Ensure completely clean state - wait for queue to be ready first
|
||||
await queue.getBullQueue().waitUntilReady();
|
||||
|
||||
// Clear all job states
|
||||
await queue.getBullQueue().drain(true);
|
||||
await queue.getBullQueue().clean(0, 1000, 'completed');
|
||||
await queue.getBullQueue().clean(0, 1000, 'failed');
|
||||
await queue.getBullQueue().clean(0, 1000, 'active');
|
||||
await queue.getBullQueue().clean(0, 1000, 'waiting');
|
||||
await queue.getBullQueue().clean(0, 1000, 'delayed');
|
||||
|
||||
// Add a small delay to ensure cleanup is complete
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
// Clean up jobs first
|
||||
if (queue) {
|
||||
try {
|
||||
await queue.getBullQueue().drain(true);
|
||||
await queue.getBullQueue().clean(0, 1000, 'completed');
|
||||
await queue.getBullQueue().clean(0, 1000, 'failed');
|
||||
await queue.getBullQueue().clean(0, 1000, 'active');
|
||||
await queue.getBullQueue().clean(0, 1000, 'waiting');
|
||||
await queue.getBullQueue().clean(0, 1000, 'delayed');
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
await queue.shutdown();
|
||||
}
|
||||
|
||||
if (queueManager) {
|
||||
await Promise.race([
|
||||
queueManager.shutdown(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Shutdown timeout')), 3000)
|
||||
)
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Cleanup error:', error.message);
|
||||
} finally {
|
||||
handlerRegistry.clear();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
});
|
||||
|
||||
describe('Direct Processing', () => {
|
||||
test('should process items directly without batching', async () => {
|
||||
const items = ['item1', 'item2', 'item3', 'item4', 'item5'];
|
||||
|
||||
const result = await processItems(items, queue, {
|
||||
totalDelayHours: 0.001, // 3.6 seconds total
|
||||
useBatching: false,
|
||||
handler: 'batch-test',
|
||||
operation: 'process-item',
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
expect(result.mode).toBe('direct');
|
||||
expect(result.totalItems).toBe(5);
|
||||
expect(result.jobsCreated).toBe(5);
|
||||
|
||||
// Verify jobs were created - BullMQ has an issue where job ID "1" doesn't show up in state queries
|
||||
// but exists when queried directly, so we need to check both ways
|
||||
const [delayedJobs, waitingJobs, activeJobs, completedJobs, failedJobs, job1] = await Promise.all([
|
||||
queue.getBullQueue().getJobs(['delayed']),
|
||||
queue.getBullQueue().getJobs(['waiting']),
|
||||
queue.getBullQueue().getJobs(['active']),
|
||||
queue.getBullQueue().getJobs(['completed']),
|
||||
queue.getBullQueue().getJobs(['failed']),
|
||||
queue.getBullQueue().getJob('1'), // Job 1 often doesn't show up in state queries
|
||||
]);
|
||||
|
||||
const jobs = [...delayedJobs, ...waitingJobs, ...activeJobs, ...completedJobs, ...failedJobs];
|
||||
const ourJobs = jobs.filter(j => j.name === 'process-item' && j.data.handler === 'batch-test');
|
||||
|
||||
// Include job 1 if we found it directly but it wasn't in the state queries
|
||||
if (job1 && job1.name === 'process-item' && job1.data.handler === 'batch-test' && !ourJobs.find(j => j.id === '1')) {
|
||||
ourJobs.push(job1);
|
||||
}
|
||||
|
||||
expect(ourJobs.length).toBe(5);
|
||||
|
||||
// Check delays are distributed
|
||||
const delays = ourJobs.map(j => j.opts.delay || 0).sort((a, b) => a - b);
|
||||
expect(delays[0]).toBe(0);
|
||||
expect(delays[4]).toBeGreaterThan(delays[0]);
|
||||
});
|
||||
|
||||
test('should process complex objects directly', async () => {
|
||||
const items = [
|
||||
{ id: 1, name: 'Product A', price: 100 },
|
||||
{ id: 2, name: 'Product B', price: 200 },
|
||||
{ id: 3, name: 'Product C', price: 300 },
|
||||
];
|
||||
|
||||
const result = await processItems(items, queue, {
|
||||
totalDelayHours: 0.001,
|
||||
useBatching: false,
|
||||
handler: 'batch-test',
|
||||
operation: 'process-item',
|
||||
});
|
||||
|
||||
expect(result.jobsCreated).toBe(3);
|
||||
|
||||
// Check job payloads
|
||||
const jobs = await queue.getBullQueue().getJobs(['waiting', 'delayed']);
|
||||
const ourJobs = jobs.filter(j => j.name === 'process-item' && j.data.handler === 'batch-test');
|
||||
const payloads = ourJobs.map(j => j.data.payload);
|
||||
|
||||
expect(payloads).toContainEqual({ id: 1, name: 'Product A', price: 100 });
|
||||
expect(payloads).toContainEqual({ id: 2, name: 'Product B', price: 200 });
|
||||
expect(payloads).toContainEqual({ id: 3, name: 'Product C', price: 300 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Batch Processing', () => {
|
||||
test('should process items in batches', async () => {
|
||||
const items = Array.from({ length: 50 }, (_, i) => ({ id: i, value: `item-${i}` }));
|
||||
|
||||
const result = await processItems(items, queue, {
|
||||
totalDelayHours: 0.001,
|
||||
useBatching: true,
|
||||
batchSize: 10,
|
||||
handler: 'batch-test',
|
||||
operation: 'process-item',
|
||||
});
|
||||
|
||||
expect(result.mode).toBe('batch');
|
||||
expect(result.totalItems).toBe(50);
|
||||
expect(result.batchesCreated).toBe(5); // 50 items / 10 per batch
|
||||
expect(result.jobsCreated).toBe(5); // 5 batch jobs
|
||||
|
||||
// Verify batch jobs were created
|
||||
const jobs = await queue.getBullQueue().getJobs(['delayed', 'waiting']);
|
||||
const batchJobs = jobs.filter(j => j.name === 'process-batch');
|
||||
expect(batchJobs.length).toBe(5);
|
||||
});
|
||||
|
||||
test('should handle different batch sizes', async () => {
|
||||
const items = Array.from({ length: 23 }, (_, i) => i);
|
||||
|
||||
const result = await processItems(items, queue, {
|
||||
totalDelayHours: 0.001,
|
||||
useBatching: true,
|
||||
batchSize: 7,
|
||||
handler: 'batch-test',
|
||||
operation: 'process-item',
|
||||
});
|
||||
|
||||
expect(result.batchesCreated).toBe(4); // 23/7 = 3.28, rounded up to 4
|
||||
expect(result.jobsCreated).toBe(4);
|
||||
});
|
||||
|
||||
test('should store batch payloads in cache', async () => {
|
||||
const items = [
|
||||
{ type: 'A', data: 'test1' },
|
||||
{ type: 'B', data: 'test2' },
|
||||
];
|
||||
|
||||
const result = await processItems(items, queue, {
|
||||
totalDelayHours: 0.001,
|
||||
useBatching: true,
|
||||
batchSize: 2,
|
||||
handler: 'batch-test',
|
||||
operation: 'process-item',
|
||||
ttl: 3600, // 1 hour TTL
|
||||
});
|
||||
|
||||
expect(result.jobsCreated).toBe(1);
|
||||
|
||||
// Get the batch job
|
||||
const jobs = await queue.getBullQueue().getJobs(['waiting', 'delayed']);
|
||||
expect(jobs.length).toBe(1);
|
||||
|
||||
const batchJob = jobs[0];
|
||||
expect(batchJob.data.payload.payloadKey).toBeDefined();
|
||||
expect(batchJob.data.payload.itemCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty and Edge Cases', () => {
|
||||
test('should handle empty item list', async () => {
|
||||
const result = await processItems([], queue, {
|
||||
totalDelayHours: 1,
|
||||
handler: 'batch-test',
|
||||
operation: 'process-item',
|
||||
});
|
||||
|
||||
expect(result.totalItems).toBe(0);
|
||||
expect(result.jobsCreated).toBe(0);
|
||||
expect(result.duration).toBeDefined();
|
||||
});
|
||||
|
||||
test('should handle single item', async () => {
|
||||
const result = await processItems(['single-item'], queue, {
|
||||
totalDelayHours: 0.001,
|
||||
handler: 'batch-test',
|
||||
operation: 'process-item',
|
||||
});
|
||||
|
||||
expect(result.totalItems).toBe(1);
|
||||
expect(result.jobsCreated).toBe(1);
|
||||
});
|
||||
|
||||
test('should handle large batch with delays', async () => {
|
||||
const items = Array.from({ length: 100 }, (_, i) => ({ index: i }));
|
||||
|
||||
const result = await processItems(items, queue, {
|
||||
totalDelayHours: 0.01, // 36 seconds total
|
||||
useBatching: true,
|
||||
batchSize: 25,
|
||||
handler: 'batch-test',
|
||||
operation: 'process-item',
|
||||
});
|
||||
|
||||
expect(result.batchesCreated).toBe(4); // 100/25
|
||||
expect(result.jobsCreated).toBe(4);
|
||||
|
||||
// Check delays are distributed
|
||||
const jobs = await queue.getBullQueue().getJobs(['delayed', 'waiting']);
|
||||
const delays = jobs.map(j => j.opts.delay || 0).sort((a, b) => a - b);
|
||||
|
||||
expect(delays[0]).toBe(0); // First batch has no delay
|
||||
expect(delays[3]).toBeGreaterThan(0); // Last batch has delay
|
||||
});
|
||||
});
|
||||
|
||||
describe('Job Options', () => {
|
||||
test('should respect custom job options', async () => {
|
||||
const items = ['a', 'b', 'c'];
|
||||
|
||||
await processItems(items, queue, {
|
||||
totalDelayHours: 0,
|
||||
handler: 'batch-test',
|
||||
operation: 'process-item',
|
||||
priority: 5,
|
||||
retries: 10,
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 50,
|
||||
});
|
||||
|
||||
// Check all states including job ID "1" specifically (as it often doesn't show up in state queries)
|
||||
const [waitingJobs, delayedJobs, job1, job2, job3] = await Promise.all([
|
||||
queue.getBullQueue().getJobs(['waiting']),
|
||||
queue.getBullQueue().getJobs(['delayed']),
|
||||
queue.getBullQueue().getJob('1'),
|
||||
queue.getBullQueue().getJob('2'),
|
||||
queue.getBullQueue().getJob('3'),
|
||||
]);
|
||||
|
||||
const jobs = [...waitingJobs, ...delayedJobs];
|
||||
// Add any missing jobs that exist but don't show up in state queries
|
||||
[job1, job2, job3].forEach(job => {
|
||||
if (job && !jobs.find(j => j.id === job.id)) {
|
||||
jobs.push(job);
|
||||
}
|
||||
});
|
||||
|
||||
expect(jobs.length).toBe(3);
|
||||
|
||||
jobs.forEach(job => {
|
||||
expect(job.opts.priority).toBe(5);
|
||||
expect(job.opts.attempts).toBe(10);
|
||||
expect(job.opts.removeOnComplete).toBe(100);
|
||||
expect(job.opts.removeOnFail).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
test('should set handler and operation correctly', async () => {
|
||||
// Register custom handler for this test
|
||||
handlerRegistry.register('custom-handler', {
|
||||
'custom-operation': async (payload) => {
|
||||
return { processed: true, data: payload };
|
||||
},
|
||||
});
|
||||
|
||||
await processItems(['test'], queue, {
|
||||
totalDelayHours: 0,
|
||||
handler: 'custom-handler',
|
||||
operation: 'custom-operation',
|
||||
});
|
||||
|
||||
const jobs = await queue.getBullQueue().getJobs(['waiting']);
|
||||
expect(jobs.length).toBe(1);
|
||||
expect(jobs[0].data.handler).toBe('custom-handler');
|
||||
expect(jobs[0].data.operation).toBe('custom-operation');
|
||||
});
|
||||
});
|
||||
});
|
||||
357
libs/queue/test/dlq-handler.test.ts
Normal file
357
libs/queue/test/dlq-handler.test.ts
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { Queue, Worker, Job } from 'bullmq';
|
||||
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 (error) {
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
221
libs/queue/test/queue-integration.test.ts
Normal file
221
libs/queue/test/queue-integration.test.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { QueueManager, handlerRegistry } from '../src';
|
||||
|
||||
// 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')) {
|
||||
// Suppress these specific Redis errors in tests
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||
});
|
||||
|
||||
describe('QueueManager Integration Tests', () => {
|
||||
let queueManager: QueueManager;
|
||||
|
||||
// Use local Redis/Dragonfly
|
||||
const redisConfig = {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
password: '',
|
||||
db: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
handlerRegistry.clear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (queueManager) {
|
||||
try {
|
||||
await Promise.race([
|
||||
queueManager.shutdown(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Shutdown timeout')), 3000)
|
||||
)
|
||||
]);
|
||||
} catch (error) {
|
||||
// Ignore shutdown errors in tests
|
||||
console.warn('Shutdown error:', error.message);
|
||||
} finally {
|
||||
queueManager = null as any;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear handler registry to prevent conflicts
|
||||
handlerRegistry.clear();
|
||||
|
||||
// Add delay to allow connections to close
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
test('should initialize queue manager', async () => {
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue',
|
||||
redis: redisConfig,
|
||||
workers: 1,
|
||||
concurrency: 5,
|
||||
});
|
||||
|
||||
await queueManager.initialize();
|
||||
expect(queueManager.queueName).toBe('test-queue');
|
||||
});
|
||||
|
||||
test('should add and process a job', async () => {
|
||||
let processedPayload: any;
|
||||
|
||||
// Register handler
|
||||
handlerRegistry.register('test-handler', {
|
||||
'test-operation': async payload => {
|
||||
processedPayload = payload;
|
||||
return { success: true, data: payload };
|
||||
},
|
||||
});
|
||||
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue',
|
||||
redis: redisConfig,
|
||||
workers: 1,
|
||||
});
|
||||
|
||||
await queueManager.initialize();
|
||||
|
||||
// Add job
|
||||
const job = await queueManager.add('test-job', {
|
||||
handler: 'test-handler',
|
||||
operation: 'test-operation',
|
||||
payload: { message: 'Hello, Queue!' },
|
||||
});
|
||||
|
||||
expect(job.name).toBe('test-job');
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(processedPayload).toEqual({ message: 'Hello, Queue!' });
|
||||
});
|
||||
|
||||
test('should handle job errors with retries', async () => {
|
||||
let attemptCount = 0;
|
||||
|
||||
handlerRegistry.register('retry-handler', {
|
||||
'failing-operation': async () => {
|
||||
attemptCount++;
|
||||
if (attemptCount < 3) {
|
||||
throw new Error(`Attempt ${attemptCount} failed`);
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue-retry',
|
||||
redis: redisConfig,
|
||||
workers: 1,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'fixed',
|
||||
delay: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await queueManager.initialize();
|
||||
|
||||
const job = await queueManager.add('retry-job', {
|
||||
handler: 'retry-handler',
|
||||
operation: 'failing-operation',
|
||||
payload: {},
|
||||
});
|
||||
|
||||
// Wait for retries
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const completed = await job.isCompleted();
|
||||
expect(completed).toBe(true);
|
||||
expect(attemptCount).toBe(3);
|
||||
});
|
||||
|
||||
test('should collect metrics when enabled', async () => {
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue-metrics',
|
||||
redis: redisConfig,
|
||||
workers: 0,
|
||||
enableMetrics: true,
|
||||
});
|
||||
|
||||
await queueManager.initialize();
|
||||
|
||||
// Add some jobs
|
||||
await queueManager.add('job1', {
|
||||
handler: 'test',
|
||||
operation: 'test',
|
||||
payload: { id: 1 },
|
||||
});
|
||||
|
||||
await queueManager.add('job2', {
|
||||
handler: 'test',
|
||||
operation: 'test',
|
||||
payload: { id: 2 },
|
||||
});
|
||||
|
||||
const metrics = await queueManager.getMetrics();
|
||||
expect(metrics).toBeDefined();
|
||||
expect(metrics.waiting).toBeDefined();
|
||||
expect(metrics.active).toBeDefined();
|
||||
expect(metrics.completed).toBeDefined();
|
||||
expect(metrics.failed).toBeDefined();
|
||||
expect(metrics.processingTime).toBeDefined();
|
||||
expect(metrics.throughput).toBeDefined();
|
||||
});
|
||||
|
||||
test('should handle rate limiting when enabled', async () => {
|
||||
let processedCount = 0;
|
||||
|
||||
handlerRegistry.register('rate-limited-handler', {
|
||||
'limited-op': async () => {
|
||||
processedCount++;
|
||||
return { processed: true };
|
||||
},
|
||||
});
|
||||
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue-rate',
|
||||
redis: redisConfig,
|
||||
workers: 1,
|
||||
enableRateLimit: true,
|
||||
rateLimitRules: [
|
||||
{
|
||||
level: 'handler',
|
||||
handler: 'rate-limited-handler',
|
||||
config: {
|
||||
points: 2, // 2 requests
|
||||
duration: 1, // per 1 second
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await queueManager.initialize();
|
||||
|
||||
// Add 3 jobs quickly
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await queueManager.add(`job${i}`, {
|
||||
handler: 'rate-limited-handler',
|
||||
operation: 'limited-op',
|
||||
payload: { id: i },
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Only 2 should be processed due to rate limit
|
||||
expect(processedCount).toBe(2);
|
||||
});
|
||||
});
|
||||
371
libs/queue/test/queue-manager.test.ts
Normal file
371
libs/queue/test/queue-manager.test.ts
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { handlerRegistry, QueueManager } from '../src';
|
||||
|
||||
// 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('QueueManager', () => {
|
||||
let queueManager: QueueManager;
|
||||
|
||||
// Use local Redis/Dragonfly
|
||||
const redisConfig = {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
password: '',
|
||||
db: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
handlerRegistry.clear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (queueManager) {
|
||||
try {
|
||||
await Promise.race([
|
||||
queueManager.shutdown(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Shutdown timeout')), 3000)
|
||||
)
|
||||
]);
|
||||
} catch (error) {
|
||||
console.warn('Shutdown error:', error.message);
|
||||
} finally {
|
||||
queueManager = null as any;
|
||||
}
|
||||
}
|
||||
|
||||
handlerRegistry.clear();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
describe('Basic Operations', () => {
|
||||
test('should initialize queue manager', async () => {
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue',
|
||||
redis: redisConfig,
|
||||
workers: 1,
|
||||
concurrency: 5,
|
||||
});
|
||||
|
||||
await queueManager.initialize();
|
||||
expect(queueManager.queueName).toBe('test-queue');
|
||||
});
|
||||
|
||||
test('should add and process a job', async () => {
|
||||
let processedPayload: any;
|
||||
|
||||
// Register handler
|
||||
handlerRegistry.register('test-handler', {
|
||||
'test-operation': async payload => {
|
||||
processedPayload = payload;
|
||||
return { success: true, data: payload };
|
||||
},
|
||||
});
|
||||
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue',
|
||||
redis: redisConfig,
|
||||
workers: 1,
|
||||
});
|
||||
|
||||
await queueManager.initialize();
|
||||
|
||||
// Add job
|
||||
const job = await queueManager.add('test-job', {
|
||||
handler: 'test-handler',
|
||||
operation: 'test-operation',
|
||||
payload: { message: 'Hello, Queue!' },
|
||||
});
|
||||
|
||||
expect(job.name).toBe('test-job');
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(processedPayload).toEqual({ message: 'Hello, Queue!' });
|
||||
});
|
||||
|
||||
test('should handle missing handler gracefully', async () => {
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue',
|
||||
redis: redisConfig,
|
||||
workers: 1,
|
||||
});
|
||||
|
||||
await queueManager.initialize();
|
||||
|
||||
const job = await queueManager.add('test-job', {
|
||||
handler: 'non-existent',
|
||||
operation: 'test-operation',
|
||||
payload: { test: true },
|
||||
});
|
||||
|
||||
// Wait for job to fail
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const failed = await job.isFailed();
|
||||
expect(failed).toBe(true);
|
||||
});
|
||||
|
||||
test('should add multiple jobs in bulk', async () => {
|
||||
let processedCount = 0;
|
||||
|
||||
handlerRegistry.register('bulk-handler', {
|
||||
process: async payload => {
|
||||
processedCount++;
|
||||
return { processed: true };
|
||||
},
|
||||
});
|
||||
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue',
|
||||
redis: redisConfig,
|
||||
workers: 2,
|
||||
concurrency: 5,
|
||||
});
|
||||
|
||||
await queueManager.initialize();
|
||||
|
||||
const jobs = await queueManager.addBulk([
|
||||
{
|
||||
name: 'job1',
|
||||
data: { handler: 'bulk-handler', operation: 'process', payload: { id: 1 } },
|
||||
},
|
||||
{
|
||||
name: 'job2',
|
||||
data: { handler: 'bulk-handler', operation: 'process', payload: { id: 2 } },
|
||||
},
|
||||
{
|
||||
name: 'job3',
|
||||
data: { handler: 'bulk-handler', operation: 'process', payload: { id: 3 } },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(jobs.length).toBe(3);
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
expect(processedCount).toBe(3);
|
||||
});
|
||||
|
||||
test('should get queue statistics', async () => {
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue',
|
||||
redis: redisConfig,
|
||||
workers: 0, // No workers, jobs will stay in waiting
|
||||
});
|
||||
|
||||
await queueManager.initialize();
|
||||
|
||||
// Add some jobs
|
||||
await queueManager.add('job1', {
|
||||
handler: 'test',
|
||||
operation: 'test',
|
||||
payload: { id: 1 },
|
||||
});
|
||||
|
||||
await queueManager.add('job2', {
|
||||
handler: 'test',
|
||||
operation: 'test',
|
||||
payload: { id: 2 },
|
||||
});
|
||||
|
||||
const stats = await queueManager.getStats();
|
||||
|
||||
expect(stats.waiting).toBe(2);
|
||||
expect(stats.active).toBe(0);
|
||||
expect(stats.completed).toBe(0);
|
||||
expect(stats.failed).toBe(0);
|
||||
});
|
||||
|
||||
test('should pause and resume queue', async () => {
|
||||
let processedCount = 0;
|
||||
|
||||
handlerRegistry.register('pause-test', {
|
||||
process: async () => {
|
||||
processedCount++;
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue',
|
||||
redis: redisConfig,
|
||||
workers: 1,
|
||||
});
|
||||
|
||||
await queueManager.initialize();
|
||||
|
||||
// Pause queue
|
||||
await queueManager.pause();
|
||||
|
||||
// Add job while paused
|
||||
await queueManager.add('job1', {
|
||||
handler: 'pause-test',
|
||||
operation: 'process',
|
||||
payload: {},
|
||||
});
|
||||
|
||||
// Wait a bit - job should not be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(processedCount).toBe(0);
|
||||
|
||||
// Resume queue
|
||||
await queueManager.resume();
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(processedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scheduled Jobs', () => {
|
||||
test('should register and process scheduled jobs', async () => {
|
||||
let executionCount = 0;
|
||||
|
||||
handlerRegistry.registerWithSchedule({
|
||||
name: 'scheduled-handler',
|
||||
operations: {
|
||||
'scheduled-task': async payload => {
|
||||
executionCount++;
|
||||
return { executed: true, timestamp: Date.now() };
|
||||
},
|
||||
},
|
||||
scheduledJobs: [
|
||||
{
|
||||
type: 'test-schedule',
|
||||
operation: 'scheduled-task',
|
||||
payload: { test: true },
|
||||
cronPattern: '*/1 * * * * *', // Every second
|
||||
description: 'Test scheduled job',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue',
|
||||
redis: redisConfig,
|
||||
workers: 1,
|
||||
enableScheduledJobs: true,
|
||||
});
|
||||
|
||||
await queueManager.initialize();
|
||||
|
||||
// Wait for scheduled job to execute
|
||||
await new Promise(resolve => setTimeout(resolve, 2500));
|
||||
|
||||
expect(executionCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
test('should handle job errors with retries', async () => {
|
||||
let attemptCount = 0;
|
||||
|
||||
handlerRegistry.register('retry-handler', {
|
||||
'failing-operation': async () => {
|
||||
attemptCount++;
|
||||
if (attemptCount < 3) {
|
||||
throw new Error(`Attempt ${attemptCount} failed`);
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue',
|
||||
redis: redisConfig,
|
||||
workers: 1,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'fixed',
|
||||
delay: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await queueManager.initialize();
|
||||
|
||||
const job = await queueManager.add('retry-job', {
|
||||
handler: 'retry-handler',
|
||||
operation: 'failing-operation',
|
||||
payload: {},
|
||||
});
|
||||
|
||||
// Wait for retries
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const completed = await job.isCompleted();
|
||||
expect(completed).toBe(true);
|
||||
expect(attemptCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Handlers', () => {
|
||||
test('should handle multiple handlers with different operations', async () => {
|
||||
const results: any[] = [];
|
||||
|
||||
handlerRegistry.register('handler-a', {
|
||||
'operation-1': async payload => {
|
||||
results.push({ handler: 'a', op: '1', payload });
|
||||
return { handler: 'a', op: '1' };
|
||||
},
|
||||
'operation-2': async payload => {
|
||||
results.push({ handler: 'a', op: '2', payload });
|
||||
return { handler: 'a', op: '2' };
|
||||
},
|
||||
});
|
||||
|
||||
handlerRegistry.register('handler-b', {
|
||||
'operation-1': async payload => {
|
||||
results.push({ handler: 'b', op: '1', payload });
|
||||
return { handler: 'b', op: '1' };
|
||||
},
|
||||
});
|
||||
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue',
|
||||
redis: redisConfig,
|
||||
workers: 2,
|
||||
});
|
||||
|
||||
await queueManager.initialize();
|
||||
|
||||
// Add jobs for different handlers
|
||||
await queueManager.addBulk([
|
||||
{
|
||||
name: 'job1',
|
||||
data: { handler: 'handler-a', operation: 'operation-1', payload: { id: 1 } },
|
||||
},
|
||||
{
|
||||
name: 'job2',
|
||||
data: { handler: 'handler-a', operation: 'operation-2', payload: { id: 2 } },
|
||||
},
|
||||
{
|
||||
name: 'job3',
|
||||
data: { handler: 'handler-b', operation: 'operation-1', payload: { id: 3 } },
|
||||
},
|
||||
]);
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
expect(results.length).toBe(3);
|
||||
expect(results).toContainEqual({ handler: 'a', op: '1', payload: { id: 1 } });
|
||||
expect(results).toContainEqual({ handler: 'a', op: '2', payload: { id: 2 } });
|
||||
expect(results).toContainEqual({ handler: 'b', op: '1', payload: { id: 3 } });
|
||||
});
|
||||
});
|
||||
});
|
||||
303
libs/queue/test/queue-metrics.test.ts
Normal file
303
libs/queue/test/queue-metrics.test.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { Queue, QueueEvents, Worker } from 'bullmq';
|
||||
import { QueueMetricsCollector } from '../src/queue-metrics';
|
||||
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('QueueMetricsCollector', () => {
|
||||
let queue: Queue;
|
||||
let queueEvents: QueueEvents;
|
||||
let metricsCollector: QueueMetricsCollector;
|
||||
let worker: Worker;
|
||||
let connection: any;
|
||||
|
||||
const redisConfig = {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
password: '',
|
||||
db: 0,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
connection = getRedisConnection(redisConfig);
|
||||
|
||||
// Create queue and events
|
||||
queue = new Queue('metrics-test-queue', { connection });
|
||||
queueEvents = new QueueEvents('metrics-test-queue', { connection });
|
||||
|
||||
// Create metrics collector
|
||||
metricsCollector = new QueueMetricsCollector(queue, queueEvents);
|
||||
|
||||
// Wait for connections
|
||||
await queue.waitUntilReady();
|
||||
await queueEvents.waitUntilReady();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
if (worker) {
|
||||
await worker.close();
|
||||
}
|
||||
await queueEvents.close();
|
||||
await queue.close();
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
describe('Job Count Metrics', () => {
|
||||
test('should collect basic job counts', async () => {
|
||||
// Add jobs in different states
|
||||
await queue.add('waiting-job', { test: true });
|
||||
await queue.add('delayed-job', { test: true }, { delay: 60000 });
|
||||
|
||||
const metrics = await metricsCollector.collect();
|
||||
|
||||
expect(metrics.waiting).toBe(1);
|
||||
expect(metrics.delayed).toBe(1);
|
||||
expect(metrics.active).toBe(0);
|
||||
expect(metrics.completed).toBe(0);
|
||||
expect(metrics.failed).toBe(0);
|
||||
});
|
||||
|
||||
test('should track completed and failed jobs', async () => {
|
||||
let jobCount = 0;
|
||||
|
||||
// Create worker that alternates between success and failure
|
||||
worker = new Worker('metrics-test-queue', async () => {
|
||||
jobCount++;
|
||||
if (jobCount % 2 === 0) {
|
||||
throw new Error('Test failure');
|
||||
}
|
||||
return { success: true };
|
||||
}, { connection });
|
||||
|
||||
// Add jobs
|
||||
await queue.add('job1', { test: 1 });
|
||||
await queue.add('job2', { test: 2 });
|
||||
await queue.add('job3', { test: 3 });
|
||||
await queue.add('job4', { test: 4 });
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
const metrics = await metricsCollector.collect();
|
||||
|
||||
expect(metrics.completed).toBe(2);
|
||||
expect(metrics.failed).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Processing Time Metrics', () => {
|
||||
test('should track processing times', async () => {
|
||||
const processingTimes = [50, 100, 150, 200, 250];
|
||||
let jobIndex = 0;
|
||||
|
||||
// Create worker with variable processing times
|
||||
worker = new Worker('metrics-test-queue', async () => {
|
||||
const delay = processingTimes[jobIndex++] || 100;
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return { processed: true };
|
||||
}, { connection });
|
||||
|
||||
// Add jobs
|
||||
for (let i = 0; i < processingTimes.length; i++) {
|
||||
await queue.add(`job${i}`, { index: i });
|
||||
}
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
const metrics = await metricsCollector.collect();
|
||||
|
||||
expect(metrics.processingTime.avg).toBeGreaterThan(0);
|
||||
expect(metrics.processingTime.min).toBeGreaterThanOrEqual(50);
|
||||
expect(metrics.processingTime.max).toBeLessThanOrEqual(300);
|
||||
expect(metrics.processingTime.p95).toBeGreaterThan(metrics.processingTime.avg);
|
||||
});
|
||||
|
||||
test('should handle empty processing times', async () => {
|
||||
const metrics = await metricsCollector.collect();
|
||||
|
||||
expect(metrics.processingTime).toEqual({
|
||||
avg: 0,
|
||||
min: 0,
|
||||
max: 0,
|
||||
p95: 0,
|
||||
p99: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Throughput Metrics', () => {
|
||||
test('should calculate throughput correctly', async () => {
|
||||
// Create fast worker
|
||||
worker = new Worker('metrics-test-queue', async () => {
|
||||
return { success: true };
|
||||
}, { connection, concurrency: 5 });
|
||||
|
||||
// Add multiple jobs
|
||||
const jobPromises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
jobPromises.push(queue.add(`job${i}`, { index: i }));
|
||||
}
|
||||
await Promise.all(jobPromises);
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const metrics = await metricsCollector.collect();
|
||||
|
||||
expect(metrics.throughput.completedPerMinute).toBeGreaterThan(0);
|
||||
expect(metrics.throughput.totalPerMinute).toBe(
|
||||
metrics.throughput.completedPerMinute + metrics.throughput.failedPerMinute
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Queue Health', () => {
|
||||
test('should report healthy queue', async () => {
|
||||
const metrics = await metricsCollector.collect();
|
||||
|
||||
expect(metrics.isHealthy).toBe(true);
|
||||
expect(metrics.healthIssues).toEqual([]);
|
||||
});
|
||||
|
||||
test('should detect high failure rate', async () => {
|
||||
// Create worker that always fails
|
||||
worker = new Worker('metrics-test-queue', async () => {
|
||||
throw new Error('Always fails');
|
||||
}, { connection });
|
||||
|
||||
// Add jobs
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await queue.add(`job${i}`, { index: i });
|
||||
}
|
||||
|
||||
// Wait for failures
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const metrics = await metricsCollector.collect();
|
||||
|
||||
expect(metrics.isHealthy).toBe(false);
|
||||
expect(metrics.healthIssues).toContain(
|
||||
expect.stringMatching(/High failure rate/)
|
||||
);
|
||||
});
|
||||
|
||||
test('should detect large queue backlog', async () => {
|
||||
// Add many jobs without workers
|
||||
for (let i = 0; i < 1001; i++) {
|
||||
await queue.add(`job${i}`, { index: i });
|
||||
}
|
||||
|
||||
const metrics = await metricsCollector.collect();
|
||||
|
||||
expect(metrics.isHealthy).toBe(false);
|
||||
expect(metrics.healthIssues).toContain(
|
||||
expect.stringMatching(/Large queue backlog/)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Oldest Waiting Job', () => {
|
||||
test('should track oldest waiting job', async () => {
|
||||
const beforeAdd = Date.now();
|
||||
|
||||
// Add jobs with delays
|
||||
await queue.add('old-job', { test: true });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await queue.add('new-job', { test: true });
|
||||
|
||||
const metrics = await metricsCollector.collect();
|
||||
|
||||
expect(metrics.oldestWaitingJob).toBeDefined();
|
||||
expect(metrics.oldestWaitingJob!.getTime()).toBeGreaterThanOrEqual(beforeAdd);
|
||||
});
|
||||
|
||||
test('should return null when no waiting jobs', async () => {
|
||||
// Create worker that processes immediately
|
||||
worker = new Worker('metrics-test-queue', async () => {
|
||||
return { success: true };
|
||||
}, { connection });
|
||||
|
||||
const metrics = await metricsCollector.collect();
|
||||
expect(metrics.oldestWaitingJob).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metrics Report', () => {
|
||||
test('should generate formatted report', async () => {
|
||||
// Add some jobs
|
||||
await queue.add('job1', { test: true });
|
||||
await queue.add('job2', { test: true }, { delay: 5000 });
|
||||
|
||||
const report = await metricsCollector.getReport();
|
||||
|
||||
expect(report).toContain('Queue Metrics Report');
|
||||
expect(report).toContain('Status:');
|
||||
expect(report).toContain('Job Counts:');
|
||||
expect(report).toContain('Performance:');
|
||||
expect(report).toContain('Throughput:');
|
||||
expect(report).toContain('Waiting: 1');
|
||||
expect(report).toContain('Delayed: 1');
|
||||
});
|
||||
|
||||
test('should include health issues in report', async () => {
|
||||
// Add many jobs to trigger health issue
|
||||
for (let i = 0; i < 1001; i++) {
|
||||
await queue.add(`job${i}`, { index: i });
|
||||
}
|
||||
|
||||
const report = await metricsCollector.getReport();
|
||||
|
||||
expect(report).toContain('Issues Detected');
|
||||
expect(report).toContain('Health Issues:');
|
||||
expect(report).toContain('Large queue backlog');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prometheus Metrics', () => {
|
||||
test('should export metrics in Prometheus format', async () => {
|
||||
// Add some jobs and process them
|
||||
worker = new Worker('metrics-test-queue', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
return { success: true };
|
||||
}, { connection });
|
||||
|
||||
await queue.add('job1', { test: true });
|
||||
await queue.add('job2', { test: true });
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
const prometheusMetrics = await metricsCollector.getPrometheusMetrics();
|
||||
|
||||
// Check format
|
||||
expect(prometheusMetrics).toContain('# HELP queue_jobs_total');
|
||||
expect(prometheusMetrics).toContain('# TYPE queue_jobs_total gauge');
|
||||
expect(prometheusMetrics).toContain('queue_jobs_total{queue="metrics-test-queue",status="completed"}');
|
||||
|
||||
expect(prometheusMetrics).toContain('# HELP queue_processing_time_seconds');
|
||||
expect(prometheusMetrics).toContain('# TYPE queue_processing_time_seconds summary');
|
||||
|
||||
expect(prometheusMetrics).toContain('# HELP queue_throughput_per_minute');
|
||||
expect(prometheusMetrics).toContain('# TYPE queue_throughput_per_minute gauge');
|
||||
|
||||
expect(prometheusMetrics).toContain('# HELP queue_health');
|
||||
expect(prometheusMetrics).toContain('# TYPE queue_health gauge');
|
||||
});
|
||||
});
|
||||
});
|
||||
81
libs/queue/test/queue-simple.test.ts
Normal file
81
libs/queue/test/queue-simple.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { QueueManager, handlerRegistry } from '../src';
|
||||
|
||||
describe('QueueManager Simple Tests', () => {
|
||||
let queueManager: QueueManager;
|
||||
|
||||
// Assumes Redis is running locally on default port
|
||||
const redisConfig = {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
handlerRegistry.clear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (queueManager) {
|
||||
try {
|
||||
await queueManager.shutdown();
|
||||
} catch (error) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should create queue manager instance', () => {
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue',
|
||||
redis: redisConfig,
|
||||
});
|
||||
|
||||
expect(queueManager.queueName).toBe('test-queue');
|
||||
});
|
||||
|
||||
test('should handle missing Redis gracefully', async () => {
|
||||
// Use a port that's likely not running Redis
|
||||
queueManager = new QueueManager({
|
||||
queueName: 'test-queue',
|
||||
redis: {
|
||||
host: 'localhost',
|
||||
port: 9999,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(queueManager.initialize()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('handler registry should work', () => {
|
||||
const testHandler = async (payload: any) => {
|
||||
return { success: true, payload };
|
||||
};
|
||||
|
||||
handlerRegistry.register('test-handler', {
|
||||
'test-op': testHandler,
|
||||
});
|
||||
|
||||
const handler = handlerRegistry.getHandler('test-handler', 'test-op');
|
||||
expect(handler).toBe(testHandler);
|
||||
});
|
||||
|
||||
test('handler registry should return null for missing handler', () => {
|
||||
const handler = handlerRegistry.getHandler('missing', 'op');
|
||||
expect(handler).toBe(null);
|
||||
});
|
||||
|
||||
test('should get handler statistics', () => {
|
||||
handlerRegistry.register('handler1', {
|
||||
'op1': async () => ({}),
|
||||
'op2': async () => ({}),
|
||||
});
|
||||
|
||||
handlerRegistry.register('handler2', {
|
||||
'op1': async () => ({}),
|
||||
});
|
||||
|
||||
const stats = handlerRegistry.getStats();
|
||||
expect(stats.handlers).toBe(2);
|
||||
expect(stats.totalOperations).toBe(3);
|
||||
});
|
||||
});
|
||||
309
libs/queue/test/rate-limiter.test.ts
Normal file
309
libs/queue/test/rate-limiter.test.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { QueueRateLimiter } from '../src/rate-limiter';
|
||||
import { getRedisConnection } from '../src/utils';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
// 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('QueueRateLimiter', () => {
|
||||
let redisClient: Redis;
|
||||
let rateLimiter: QueueRateLimiter;
|
||||
|
||||
const redisConfig = {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
password: '',
|
||||
db: 0,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create Redis client
|
||||
redisClient = new Redis(getRedisConnection(redisConfig));
|
||||
|
||||
// Clear Redis keys for tests
|
||||
try {
|
||||
const keys = await redisClient.keys('rl:*');
|
||||
if (keys.length > 0) {
|
||||
await redisClient.del(...keys);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
rateLimiter = new QueueRateLimiter(redisClient);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (redisClient) {
|
||||
try {
|
||||
await redisClient.quit();
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
describe('Rate Limit Rules', () => {
|
||||
test('should add and enforce global rate limit', async () => {
|
||||
rateLimiter.addRule({
|
||||
level: 'global',
|
||||
config: {
|
||||
points: 5,
|
||||
duration: 1, // 1 second
|
||||
},
|
||||
});
|
||||
|
||||
// Consume 5 points
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const result = await rateLimiter.checkLimit('any-handler', 'any-operation');
|
||||
expect(result.allowed).toBe(true);
|
||||
}
|
||||
|
||||
// 6th request should be blocked
|
||||
const blocked = await rateLimiter.checkLimit('any-handler', 'any-operation');
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should add and enforce handler-level rate limit', async () => {
|
||||
rateLimiter.addRule({
|
||||
level: 'handler',
|
||||
handler: 'api-handler',
|
||||
config: {
|
||||
points: 3,
|
||||
duration: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// api-handler should be limited
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const result = await rateLimiter.checkLimit('api-handler', 'any-operation');
|
||||
expect(result.allowed).toBe(true);
|
||||
}
|
||||
|
||||
const blocked = await rateLimiter.checkLimit('api-handler', 'any-operation');
|
||||
expect(blocked.allowed).toBe(false);
|
||||
|
||||
// Other handlers should not be limited
|
||||
const otherHandler = await rateLimiter.checkLimit('other-handler', 'any-operation');
|
||||
expect(otherHandler.allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('should add and enforce operation-level rate limit', async () => {
|
||||
rateLimiter.addRule({
|
||||
level: 'operation',
|
||||
handler: 'data-handler',
|
||||
operation: 'fetch-prices',
|
||||
config: {
|
||||
points: 2,
|
||||
duration: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// Specific operation should be limited
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const result = await rateLimiter.checkLimit('data-handler', 'fetch-prices');
|
||||
expect(result.allowed).toBe(true);
|
||||
}
|
||||
|
||||
const blocked = await rateLimiter.checkLimit('data-handler', 'fetch-prices');
|
||||
expect(blocked.allowed).toBe(false);
|
||||
|
||||
// Other operations on same handler should work
|
||||
const otherOp = await rateLimiter.checkLimit('data-handler', 'fetch-volume');
|
||||
expect(otherOp.allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('should enforce multiple rate limits (most restrictive wins)', async () => {
|
||||
// Global: 10/sec
|
||||
rateLimiter.addRule({
|
||||
level: 'global',
|
||||
config: { points: 10, duration: 1 },
|
||||
});
|
||||
|
||||
// Handler: 5/sec
|
||||
rateLimiter.addRule({
|
||||
level: 'handler',
|
||||
handler: 'test-handler',
|
||||
config: { points: 5, duration: 1 },
|
||||
});
|
||||
|
||||
// Operation: 2/sec
|
||||
rateLimiter.addRule({
|
||||
level: 'operation',
|
||||
handler: 'test-handler',
|
||||
operation: 'test-op',
|
||||
config: { points: 2, duration: 1 },
|
||||
});
|
||||
|
||||
// Should be limited by operation level (most restrictive)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const result = await rateLimiter.checkLimit('test-handler', 'test-op');
|
||||
expect(result.allowed).toBe(true);
|
||||
}
|
||||
|
||||
const blocked = await rateLimiter.checkLimit('test-handler', 'test-op');
|
||||
expect(blocked.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limit Status', () => {
|
||||
test('should get rate limit status', async () => {
|
||||
rateLimiter.addRule({
|
||||
level: 'handler',
|
||||
handler: 'status-test',
|
||||
config: { points: 10, duration: 60 },
|
||||
});
|
||||
|
||||
// Consume some points
|
||||
await rateLimiter.checkLimit('status-test', 'operation');
|
||||
await rateLimiter.checkLimit('status-test', 'operation');
|
||||
|
||||
const status = await rateLimiter.getStatus('status-test', 'operation');
|
||||
expect(status.handler).toBe('status-test');
|
||||
expect(status.operation).toBe('operation');
|
||||
expect(status.limits.length).toBe(1);
|
||||
expect(status.limits[0].points).toBe(10);
|
||||
expect(status.limits[0].remaining).toBe(8);
|
||||
});
|
||||
|
||||
test('should show multiple applicable limits in status', async () => {
|
||||
rateLimiter.addRule({
|
||||
level: 'global',
|
||||
config: { points: 100, duration: 60 },
|
||||
});
|
||||
|
||||
rateLimiter.addRule({
|
||||
level: 'handler',
|
||||
handler: 'multi-test',
|
||||
config: { points: 50, duration: 60 },
|
||||
});
|
||||
|
||||
const status = await rateLimiter.getStatus('multi-test', 'operation');
|
||||
expect(status.limits.length).toBe(2);
|
||||
|
||||
const globalLimit = status.limits.find(l => l.level === 'global');
|
||||
const handlerLimit = status.limits.find(l => l.level === 'handler');
|
||||
|
||||
expect(globalLimit?.points).toBe(100);
|
||||
expect(handlerLimit?.points).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limit Management', () => {
|
||||
test('should reset rate limits', async () => {
|
||||
rateLimiter.addRule({
|
||||
level: 'handler',
|
||||
handler: 'reset-test',
|
||||
config: { points: 1, duration: 60 },
|
||||
});
|
||||
|
||||
// Consume the limit
|
||||
await rateLimiter.checkLimit('reset-test', 'operation');
|
||||
let blocked = await rateLimiter.checkLimit('reset-test', 'operation');
|
||||
expect(blocked.allowed).toBe(false);
|
||||
|
||||
// Reset limits
|
||||
await rateLimiter.reset('reset-test');
|
||||
|
||||
// Should be allowed again
|
||||
const afterReset = await rateLimiter.checkLimit('reset-test', 'operation');
|
||||
expect(afterReset.allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('should get all rules', async () => {
|
||||
rateLimiter.addRule({
|
||||
level: 'global',
|
||||
config: { points: 100, duration: 60 },
|
||||
});
|
||||
|
||||
rateLimiter.addRule({
|
||||
level: 'handler',
|
||||
handler: 'test',
|
||||
config: { points: 50, duration: 60 },
|
||||
});
|
||||
|
||||
const rules = rateLimiter.getRules();
|
||||
expect(rules.length).toBe(2);
|
||||
expect(rules[0].level).toBe('global');
|
||||
expect(rules[1].level).toBe('handler');
|
||||
});
|
||||
|
||||
test('should remove specific rule', async () => {
|
||||
rateLimiter.addRule({
|
||||
level: 'handler',
|
||||
handler: 'remove-test',
|
||||
config: { points: 1, duration: 1 },
|
||||
});
|
||||
|
||||
// Verify rule exists
|
||||
await rateLimiter.checkLimit('remove-test', 'op');
|
||||
let blocked = await rateLimiter.checkLimit('remove-test', 'op');
|
||||
expect(blocked.allowed).toBe(false);
|
||||
|
||||
// Remove rule
|
||||
const removed = rateLimiter.removeRule('handler', 'remove-test');
|
||||
expect(removed).toBe(true);
|
||||
|
||||
// Should not be limited anymore
|
||||
const afterRemove = await rateLimiter.checkLimit('remove-test', 'op');
|
||||
expect(afterRemove.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Block Duration', () => {
|
||||
test('should block for specified duration after limit exceeded', async () => {
|
||||
rateLimiter.addRule({
|
||||
level: 'handler',
|
||||
handler: 'block-test',
|
||||
config: {
|
||||
points: 1,
|
||||
duration: 1,
|
||||
blockDuration: 2, // Block for 2 seconds
|
||||
},
|
||||
});
|
||||
|
||||
// Consume limit
|
||||
await rateLimiter.checkLimit('block-test', 'op');
|
||||
|
||||
// Should be blocked
|
||||
const blocked = await rateLimiter.checkLimit('block-test', 'op');
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.retryAfter).toBeGreaterThanOrEqual(1000); // At least 1 second
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
test('should allow requests when rate limiter fails', async () => {
|
||||
// Create a rate limiter with invalid redis client
|
||||
const badRedis = new Redis({
|
||||
host: 'invalid-host',
|
||||
port: 9999,
|
||||
retryStrategy: () => null, // Disable retries
|
||||
});
|
||||
|
||||
const failingLimiter = new QueueRateLimiter(badRedis);
|
||||
|
||||
failingLimiter.addRule({
|
||||
level: 'global',
|
||||
config: { points: 1, duration: 1 },
|
||||
});
|
||||
|
||||
// Should allow even though Redis is not available
|
||||
const result = await failingLimiter.checkLimit('test', 'test');
|
||||
expect(result.allowed).toBe(true);
|
||||
|
||||
badRedis.disconnect();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue