515 lines
14 KiB
TypeScript
515 lines
14 KiB
TypeScript
import { Queue as BullQueue, QueueEvents, Worker, type Job } from 'bullmq';
|
|
// Handler registry will be injected
|
|
import type { HandlerRegistry } from '@stock-bot/handler-registry';
|
|
import type { ExtendedJobOptions, JobData, JobOptions, QueueStats, RedisConfig } from './types';
|
|
import { getRedisConnection } from './utils';
|
|
|
|
// Logger interface for type safety
|
|
interface Logger {
|
|
info(message: string, meta?: Record<string, unknown>): void;
|
|
error(message: string, meta?: Record<string, unknown>): void;
|
|
warn(message: string, meta?: Record<string, unknown>): void;
|
|
debug(message: string, meta?: Record<string, unknown>): void;
|
|
trace(message: string, meta?: Record<string, unknown>): void;
|
|
child?(name: string, context?: Record<string, unknown>): Logger;
|
|
}
|
|
|
|
export interface QueueWorkerConfig {
|
|
workers?: number;
|
|
concurrency?: number;
|
|
startWorker?: boolean;
|
|
handlerRegistry?: HandlerRegistry;
|
|
serviceName?: string;
|
|
}
|
|
|
|
/**
|
|
* Consolidated Queue class that handles both job operations and optional worker management
|
|
* Can be used as a simple job queue or with workers for automatic processing
|
|
*/
|
|
export class Queue {
|
|
private bullQueue: BullQueue;
|
|
private workers: Worker[] = [];
|
|
private queueEvents?: QueueEvents;
|
|
private queueName: string;
|
|
private redisConfig: RedisConfig;
|
|
private readonly logger: Logger;
|
|
private readonly handlerRegistry?: HandlerRegistry;
|
|
private serviceName?: string;
|
|
|
|
constructor(
|
|
queueName: string,
|
|
redisConfig: RedisConfig,
|
|
defaultJobOptions: JobOptions = {},
|
|
config: QueueWorkerConfig = {},
|
|
logger?: Logger
|
|
) {
|
|
this.queueName = queueName;
|
|
this.redisConfig = redisConfig;
|
|
this.logger = logger || console;
|
|
this.handlerRegistry = config.handlerRegistry;
|
|
this.serviceName = config.serviceName;
|
|
|
|
this.logger.debug('Queue constructor called', {
|
|
queueName,
|
|
serviceName: this.serviceName,
|
|
hasHandlerRegistry: !!config.handlerRegistry,
|
|
handlerRegistryType: config.handlerRegistry ? typeof config.handlerRegistry : 'undefined',
|
|
configKeys: Object.keys(config),
|
|
});
|
|
|
|
const connection = getRedisConnection(redisConfig);
|
|
|
|
// Initialize BullMQ queue
|
|
this.bullQueue = new BullQueue(queueName, {
|
|
connection,
|
|
defaultJobOptions: {
|
|
removeOnComplete: 10,
|
|
removeOnFail: 5,
|
|
attempts: 3,
|
|
backoff: {
|
|
type: 'exponential',
|
|
delay: 1000,
|
|
},
|
|
...defaultJobOptions,
|
|
},
|
|
});
|
|
|
|
// Initialize queue events if workers will be used
|
|
if (config.workers && config.workers > 0) {
|
|
this.queueEvents = new QueueEvents(queueName, { connection });
|
|
}
|
|
|
|
// Start workers if requested and not explicitly disabled
|
|
if (config.workers && config.workers > 0 && config.startWorker !== false) {
|
|
this.logger.info('Starting workers for queue', {
|
|
queueName,
|
|
workers: config.workers,
|
|
concurrency: config.concurrency || 1,
|
|
hasHandlerRegistry: !!this.handlerRegistry,
|
|
});
|
|
this.startWorkers(config.workers, config.concurrency || 1);
|
|
} else {
|
|
this.logger.info('Not starting workers for queue', {
|
|
queueName,
|
|
workers: config.workers || 0,
|
|
startWorker: config.startWorker,
|
|
hasHandlerRegistry: !!this.handlerRegistry,
|
|
});
|
|
}
|
|
|
|
this.logger.trace('Queue created', {
|
|
queueName,
|
|
workers: config.workers || 0,
|
|
concurrency: config.concurrency || 1,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the queue name
|
|
*/
|
|
getName(): string {
|
|
return this.queueName;
|
|
}
|
|
|
|
/**
|
|
* Get the underlying BullMQ queue instance (for monitoring/admin purposes)
|
|
*/
|
|
getBullQueue(): BullQueue {
|
|
return this.bullQueue;
|
|
}
|
|
|
|
/**
|
|
* Add a single job to the queue
|
|
*/
|
|
async add(name: string, data: JobData, options: JobOptions = {}): Promise<Job> {
|
|
this.logger.trace('Adding job', { queueName: this.queueName, jobName: name });
|
|
return await this.bullQueue.add(name, data, options);
|
|
}
|
|
|
|
/**
|
|
* Add multiple jobs to the queue in bulk
|
|
*/
|
|
async addBulk(jobs: Array<{ name: string; data: JobData; opts?: JobOptions }>): Promise<Job[]> {
|
|
this.logger.trace('Adding bulk jobs', {
|
|
queueName: this.queueName,
|
|
jobCount: jobs.length,
|
|
});
|
|
return await this.bullQueue.addBulk(jobs);
|
|
}
|
|
|
|
/**
|
|
* Add a scheduled job with cron-like pattern
|
|
*/
|
|
async addScheduledJob(
|
|
name: string,
|
|
data: JobData,
|
|
cronPattern: string,
|
|
options: ExtendedJobOptions = {}
|
|
): Promise<Job> {
|
|
const scheduledOptions: ExtendedJobOptions = {
|
|
...options,
|
|
repeat: {
|
|
pattern: cronPattern,
|
|
// Use job name as repeat key to prevent duplicates
|
|
key: `${this.queueName}:${name}`,
|
|
...options.repeat,
|
|
},
|
|
};
|
|
|
|
this.logger.info('Adding scheduled job', {
|
|
queueName: this.queueName,
|
|
jobName: name,
|
|
cronPattern,
|
|
repeatKey: scheduledOptions.repeat?.key,
|
|
immediately: scheduledOptions.repeat?.immediately,
|
|
});
|
|
|
|
return await this.bullQueue.add(name, data, scheduledOptions);
|
|
}
|
|
|
|
/**
|
|
* Get queue statistics
|
|
*/
|
|
async getStats(): Promise<QueueStats> {
|
|
const [waiting, active, completed, failed, delayed] = await Promise.all([
|
|
this.bullQueue.getWaiting(),
|
|
this.bullQueue.getActive(),
|
|
this.bullQueue.getCompleted(),
|
|
this.bullQueue.getFailed(),
|
|
this.bullQueue.getDelayed(),
|
|
]);
|
|
|
|
const isPaused = await this.bullQueue.isPaused();
|
|
|
|
// Get actual active worker count from BullMQ
|
|
const activeWorkerCount = await this.getActiveWorkerCount();
|
|
|
|
return {
|
|
waiting: waiting.length,
|
|
active: active.length,
|
|
completed: completed.length,
|
|
failed: failed.length,
|
|
delayed: delayed.length,
|
|
paused: isPaused,
|
|
workers: activeWorkerCount, // Use BullMQ's tracked count instead of local array
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get a specific job by ID
|
|
*/
|
|
async getJob(jobId: string): Promise<Job | undefined> {
|
|
return await this.bullQueue.getJob(jobId);
|
|
}
|
|
|
|
/**
|
|
* Get jobs by state
|
|
*/
|
|
async getJobs(
|
|
states: Array<'waiting' | 'active' | 'completed' | 'failed' | 'delayed'>,
|
|
start = 0,
|
|
end = 100
|
|
): Promise<Job[]> {
|
|
return await this.bullQueue.getJobs(states, start, end);
|
|
}
|
|
|
|
/**
|
|
* Pause the queue (stops processing new jobs)
|
|
*/
|
|
async pause(): Promise<void> {
|
|
await this.bullQueue.pause();
|
|
this.logger.info('Queue paused', { queueName: this.queueName });
|
|
}
|
|
|
|
/**
|
|
* Resume the queue
|
|
*/
|
|
async resume(): Promise<void> {
|
|
await this.bullQueue.resume();
|
|
this.logger.info('Queue resumed', { queueName: this.queueName });
|
|
}
|
|
|
|
/**
|
|
* Drain the queue (remove all jobs)
|
|
*/
|
|
async drain(delayed = false): Promise<void> {
|
|
await this.bullQueue.drain(delayed);
|
|
this.logger.info('Queue drained', { queueName: this.queueName, delayed });
|
|
}
|
|
|
|
/**
|
|
* Clean completed and failed jobs
|
|
*/
|
|
async clean(
|
|
grace: number = 0,
|
|
limit: number = 100,
|
|
type: 'completed' | 'failed' = 'completed'
|
|
): Promise<void> {
|
|
await this.bullQueue.clean(grace, limit, type);
|
|
this.logger.debug('Queue cleaned', { queueName: this.queueName, type, grace, limit });
|
|
}
|
|
|
|
/**
|
|
* Wait until the queue is ready
|
|
*/
|
|
async waitUntilReady(): Promise<void> {
|
|
await this.bullQueue.waitUntilReady();
|
|
}
|
|
|
|
/**
|
|
* Close the queue (cleanup resources)
|
|
*/
|
|
/**
|
|
* Close the queue (cleanup resources)
|
|
*/
|
|
async close(): Promise<void> {
|
|
try {
|
|
// Close the queue itself
|
|
await this.bullQueue.close();
|
|
this.logger.info('Queue closed', { queueName: this.queueName });
|
|
|
|
// Close queue events
|
|
if (this.queueEvents) {
|
|
await this.queueEvents.close();
|
|
this.logger.debug('Queue events closed', { queueName: this.queueName });
|
|
}
|
|
|
|
// Close workers first
|
|
if (this.workers.length > 0) {
|
|
await Promise.all(
|
|
this.workers.map(async worker => {
|
|
return await worker.close();
|
|
})
|
|
);
|
|
this.workers = [];
|
|
this.logger.debug('Workers closed', { queueName: this.queueName });
|
|
}
|
|
} catch (error) {
|
|
this.logger.error('Error closing queue', { queueName: this.queueName, error });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a child logger with additional context
|
|
* Useful for batch processing and other queue operations
|
|
*/
|
|
createChildLogger(name: string, context?: Record<string, unknown>) {
|
|
if (this.logger && typeof this.logger.child === 'function') {
|
|
return this.logger.child(name, context);
|
|
}
|
|
// Fallback to main logger if child not supported (e.g., console)
|
|
return this.logger;
|
|
}
|
|
|
|
/**
|
|
* Start workers for this queue
|
|
*/
|
|
private startWorkers(workerCount: number, concurrency: number): void {
|
|
const connection = getRedisConnection(this.redisConfig);
|
|
|
|
for (let i = 0; i < workerCount; i++) {
|
|
const worker = new Worker(this.queueName, this.processJob.bind(this), {
|
|
connection,
|
|
concurrency,
|
|
maxStalledCount: 3,
|
|
stalledInterval: 30000,
|
|
// Add a name to identify the worker
|
|
name: `${this.serviceName || 'unknown'}_worker_${i}`,
|
|
});
|
|
|
|
this.logger.info(`Starting worker ${i + 1}/${workerCount} for queue`, {
|
|
queueName: this.queueName,
|
|
workerName: worker.name,
|
|
concurrency,
|
|
});
|
|
|
|
// Setup worker event handlers
|
|
worker.on('completed', job => {
|
|
this.logger.trace('Job completed', {
|
|
queueName: this.queueName,
|
|
jobId: job.id,
|
|
handler: job.data?.handler,
|
|
operation: job.data?.operation,
|
|
});
|
|
});
|
|
|
|
worker.on('failed', (job, err) => {
|
|
this.logger.error('Job failed', {
|
|
queueName: this.queueName,
|
|
jobId: job?.id,
|
|
handler: job?.data?.handler,
|
|
operation: job?.data?.operation,
|
|
error: err.message,
|
|
});
|
|
});
|
|
|
|
worker.on('error', error => {
|
|
this.logger.error('Worker error', {
|
|
queueName: this.queueName,
|
|
workerId: i,
|
|
error: error.message,
|
|
});
|
|
});
|
|
|
|
this.workers.push(worker);
|
|
}
|
|
|
|
this.logger.info('Workers started', {
|
|
queueName: this.queueName,
|
|
workerCount,
|
|
concurrency,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Process a job using the handler registry
|
|
*/
|
|
private async processJob(job: Job): Promise<unknown> {
|
|
const { handler, operation, payload }: JobData = job.data;
|
|
|
|
this.logger.trace('Processing job', {
|
|
id: job.id,
|
|
handler,
|
|
operation,
|
|
queueName: this.queueName,
|
|
});
|
|
|
|
try {
|
|
// Look up handler in registry
|
|
if (!this.handlerRegistry) {
|
|
throw new Error('Handler registry not configured for worker processing');
|
|
}
|
|
|
|
this.logger.debug('Looking up handler in registry', {
|
|
handler,
|
|
operation,
|
|
queueName: this.queueName,
|
|
registeredHandlers: this.handlerRegistry.getHandlerNames(),
|
|
});
|
|
|
|
const jobHandler = this.handlerRegistry.getOperation(handler, operation);
|
|
|
|
if (!jobHandler) {
|
|
throw new Error(`No handler found for ${handler}:${operation}`);
|
|
}
|
|
|
|
const result = await jobHandler(payload);
|
|
|
|
this.logger.trace('Job completed successfully', {
|
|
id: job.id,
|
|
handler,
|
|
operation,
|
|
queueName: this.queueName,
|
|
});
|
|
|
|
return result;
|
|
} catch (error) {
|
|
this.logger.error('Job processing failed', {
|
|
id: job.id,
|
|
handler,
|
|
operation,
|
|
queueName: this.queueName,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start workers manually (for delayed initialization)
|
|
*/
|
|
startWorkersManually(workerCount: number, concurrency: number = 1): void {
|
|
if (this.workers.length > 0) {
|
|
this.logger.warn('Workers already started for queue', { queueName: this.queueName });
|
|
return;
|
|
}
|
|
|
|
this.logger.info('Starting workers manually', {
|
|
queueName: this.queueName,
|
|
workerCount,
|
|
concurrency,
|
|
hasHandlerRegistry: !!this.handlerRegistry,
|
|
});
|
|
|
|
// Initialize queue events if not already done
|
|
if (!this.queueEvents) {
|
|
const connection = getRedisConnection(this.redisConfig);
|
|
this.queueEvents = new QueueEvents(this.queueName, { connection });
|
|
}
|
|
|
|
this.startWorkers(workerCount, concurrency);
|
|
}
|
|
|
|
/**
|
|
* Get the number of active workers
|
|
*/
|
|
getWorkerCount(): number {
|
|
return this.workers.length;
|
|
}
|
|
|
|
/**
|
|
* Get active workers from BullMQ
|
|
* This uses BullMQ's built-in worker tracking
|
|
*/
|
|
async getActiveWorkers(): Promise<any[]> {
|
|
try {
|
|
const workers = await this.bullQueue.getWorkers();
|
|
return workers;
|
|
} catch (error) {
|
|
this.logger.error('Failed to get active workers', {
|
|
queueName: this.queueName,
|
|
error
|
|
});
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get count of active workers from BullMQ
|
|
*/
|
|
async getActiveWorkerCount(): Promise<number> {
|
|
try {
|
|
const workers = await this.bullQueue.getWorkers();
|
|
return workers.length;
|
|
} catch (error) {
|
|
this.logger.error('Failed to get active worker count', {
|
|
queueName: this.queueName,
|
|
error
|
|
});
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get detailed worker information
|
|
*/
|
|
async getWorkerDetails(): Promise<Array<{
|
|
id: string;
|
|
name?: string;
|
|
addr?: string;
|
|
age?: number;
|
|
idle?: number;
|
|
started?: number;
|
|
}>> {
|
|
try {
|
|
const workers = await this.bullQueue.getWorkers();
|
|
return workers.map(worker => ({
|
|
id: worker.id || 'unknown',
|
|
name: worker.name,
|
|
addr: worker.addr,
|
|
age: typeof worker.age === 'string' ? parseInt(worker.age) : worker.age,
|
|
idle: typeof worker.idle === 'string' ? parseInt(worker.idle) : worker.idle,
|
|
started: typeof worker.started === 'string' ? parseInt(worker.started) : worker.started,
|
|
}));
|
|
} catch (error) {
|
|
this.logger.error('Failed to get worker details', {
|
|
queueName: this.queueName,
|
|
error
|
|
});
|
|
return [];
|
|
}
|
|
}
|
|
|
|
|
|
}
|