import { CacheProvider, createCache } from '@stock-bot/cache'; import { getLogger } from '@stock-bot/logger'; import { Queue, type QueueWorkerConfig } from './queue'; import { QueueRateLimiter } from './rate-limiter'; import type { GlobalStats, QueueManagerConfig, QueueOptions, QueueStats, RateLimitRule, } from './types'; import { getRedisConnection } from './utils'; const logger = getLogger('queue-manager'); /** * Singleton QueueManager that provides unified queue and cache management * Main entry point for all queue operations with getQueue() method */ export class QueueManager { private static instance: QueueManager | null = null; private queues = new Map(); private caches = new Map(); private rateLimiter?: QueueRateLimiter; private redisConnection: ReturnType; private isShuttingDown = false; private shutdownPromise: Promise | null = null; private config: QueueManagerConfig; private constructor(config: QueueManagerConfig) { this.config = config; this.redisConnection = getRedisConnection(config.redis); // Initialize rate limiter if rules are provided if (config.rateLimitRules && config.rateLimitRules.length > 0) { this.rateLimiter = new QueueRateLimiter(this.redisConnection); config.rateLimitRules.forEach(rule => { if (this.rateLimiter) { this.rateLimiter.addRule(rule); } }); } logger.info('QueueManager singleton initialized', { redis: `${config.redis.host}:${config.redis.port}`, }); } /** * Get the singleton instance * @throws Error if not initialized - use initialize() first */ static getInstance(): QueueManager { if (!QueueManager.instance) { throw new Error('QueueManager not initialized. Call QueueManager.initialize(config) first.'); } return QueueManager.instance; } /** * Initialize the singleton with config * Must be called before getInstance() */ static initialize(config: QueueManagerConfig): QueueManager { if (QueueManager.instance) { logger.warn('QueueManager already initialized, returning existing instance'); return QueueManager.instance; } QueueManager.instance = new QueueManager(config); return QueueManager.instance; } /** * Get or initialize the singleton * Convenience method that combines initialize and getInstance */ static getOrInitialize(config?: QueueManagerConfig): QueueManager { if (QueueManager.instance) { return QueueManager.instance; } if (!config) { throw new Error( 'QueueManager not initialized and no config provided. ' + 'Either call initialize(config) first or provide config to getOrInitialize(config).' ); } return QueueManager.initialize(config); } /** * Check if the QueueManager is initialized */ static isInitialized(): boolean { return QueueManager.instance !== null; } /** * Reset the singleton (mainly for testing) */ static async reset(): Promise { if (QueueManager.instance) { await QueueManager.instance.shutdown(); QueueManager.instance = null; } } /** * Get or create a queue - unified method that handles both scenarios * This is the main method for accessing queues */ getQueue(queueName: string, options: QueueOptions = {}): Queue { // Return existing queue if it exists if (this.queues.has(queueName)) { const existingQueue = this.queues.get(queueName); if (existingQueue) { return existingQueue; } } // Create new queue with merged options const mergedOptions = { ...this.config.defaultQueueOptions, ...options, }; // Prepare queue configuration const queueConfig: QueueWorkerConfig = { workers: mergedOptions.workers, concurrency: mergedOptions.concurrency, startWorker: !!mergedOptions.workers && mergedOptions.workers > 0 && !this.config.delayWorkerStart, }; const queue = new Queue( queueName, this.config.redis, mergedOptions.defaultJobOptions || {}, queueConfig ); // Store the queue this.queues.set(queueName, queue); // Automatically initialize batch cache for the queue this.initializeBatchCacheSync(queueName); // Add queue-specific rate limit rules if (this.rateLimiter && mergedOptions.rateLimitRules) { mergedOptions.rateLimitRules.forEach(rule => { // Ensure queue name is set for queue-specific rules const ruleWithQueue = { ...rule, queueName }; if (this.rateLimiter) { this.rateLimiter.addRule(ruleWithQueue); } }); } logger.info('Queue created with batch cache', { queueName, workers: mergedOptions.workers || 0, concurrency: mergedOptions.concurrency || 1, }); return queue; } /** * Check if a queue exists */ hasQueue(queueName: string): boolean { return this.queues.has(queueName); } /** * Get all queue names */ getQueueNames(): string[] { return Array.from(this.queues.keys()); } /** * Get or create a cache for a queue */ getCache(queueName: string): CacheProvider { if (!this.caches.has(queueName)) { const cacheProvider = createCache({ redisConfig: this.config.redis, keyPrefix: `batch:${queueName}:`, ttl: 86400, // 24 hours default enableMetrics: true, }); this.caches.set(queueName, cacheProvider); logger.trace('Cache created for queue', { queueName }); } const cache = this.caches.get(queueName); if (!cache) { throw new Error(`Expected cache for queue ${queueName} to exist`); } return cache; } /** * Initialize cache for a queue (ensures it's ready) */ async initializeCache(queueName: string): Promise { const cache = this.getCache(queueName); await cache.waitForReady(10000); logger.info('Cache initialized for queue', { queueName }); } /** * Initialize batch cache synchronously (for automatic initialization) * The cache will be ready for use, but we don't wait for Redis connection */ private initializeBatchCacheSync(queueName: string): void { // Just create the cache - it will connect automatically when first used this.getCache(queueName); logger.trace('Batch cache initialized synchronously for queue', { queueName }); } /** * Get statistics for all queues */ async getGlobalStats(): Promise { const queueStats: Record = {}; let totalJobs = 0; let totalWorkers = 0; for (const [queueName, queue] of this.queues) { const stats = await queue.getStats(); queueStats[queueName] = stats; totalJobs += stats.waiting + stats.active + stats.completed + stats.failed + stats.delayed; totalWorkers += stats.workers || 0; } return { queues: queueStats, totalJobs, totalWorkers, uptime: process.uptime(), }; } /** * Get statistics for a specific queue */ async getQueueStats(queueName: string): Promise { const queue = this.queues.get(queueName); if (!queue) { return undefined; } return await queue.getStats(); } /** * Add a rate limit rule */ addRateLimitRule(rule: RateLimitRule): void { if (!this.rateLimiter) { this.rateLimiter = new QueueRateLimiter(this.redisConnection); } this.rateLimiter.addRule(rule); } /** * Check rate limits for a job */ async checkRateLimit( queueName: string, handler: string, operation: string ): Promise<{ allowed: boolean; retryAfter?: number; remainingPoints?: number; appliedRule?: RateLimitRule; }> { if (!this.rateLimiter) { return { allowed: true }; } return await this.rateLimiter.checkLimit(queueName, handler, operation); } /** * Get rate limit status */ async getRateLimitStatus(queueName: string, handler: string, operation: string) { if (!this.rateLimiter) { return { queueName, handler, operation, }; } return await this.rateLimiter.getStatus(queueName, handler, operation); } /** * Pause all queues */ async pauseAll(): Promise { const pausePromises = Array.from(this.queues.values()).map(queue => queue.pause()); await Promise.all(pausePromises); logger.info('All queues paused'); } /** * Resume all queues */ async resumeAll(): Promise { const resumePromises = Array.from(this.queues.values()).map(queue => queue.resume()); await Promise.all(resumePromises); logger.info('All queues resumed'); } /** * Pause a specific queue */ async pauseQueue(queueName: string): Promise { const queue = this.queues.get(queueName); if (!queue) { return false; } await queue.pause(); return true; } /** * Resume a specific queue */ async resumeQueue(queueName: string): Promise { const queue = this.queues.get(queueName); if (!queue) { return false; } await queue.resume(); return true; } /** * Drain all queues */ async drainAll(delayed = false): Promise { const drainPromises = Array.from(this.queues.values()).map(queue => queue.drain(delayed)); await Promise.all(drainPromises); logger.info('All queues drained', { delayed }); } /** * Clean all queues */ async cleanAll( grace: number = 0, limit: number = 100, type: 'completed' | 'failed' = 'completed' ): Promise { const cleanPromises = Array.from(this.queues.values()).map(queue => queue.clean(grace, limit, type) ); await Promise.all(cleanPromises); logger.info('All queues cleaned', { type, grace, limit }); } /** * Shutdown all queues and workers (thread-safe) */ async shutdown(): Promise { // If already shutting down, return the existing promise if (this.shutdownPromise) { return this.shutdownPromise; } if (this.isShuttingDown) { return; } this.isShuttingDown = true; logger.info('Shutting down QueueManager...'); // Create shutdown promise this.shutdownPromise = this.performShutdown(); return this.shutdownPromise; } /** * Perform the actual shutdown */ private async performShutdown(): Promise { try { // Close all queues (this now includes workers since they're managed by Queue class) const queueShutdownPromises = Array.from(this.queues.values()).map(async queue => { try { // Add timeout to queue.close() to prevent hanging await queue.close(); // const timeoutPromise = new Promise((_, reject) => // setTimeout(() => reject(new Error('Queue close timeout')), 100) // ); // await Promise.race([closePromise, timeoutPromise]); } catch (error) { logger.warn('Error closing queue', { error: (error as Error).message }); } }); await Promise.all(queueShutdownPromises); // Close all caches const cacheShutdownPromises = Array.from(this.caches.values()).map(async cache => { try { // Clear cache before shutdown await cache.clear(); } catch (error) { logger.warn('Error clearing cache', { error: (error as Error).message }); } }); await Promise.all(cacheShutdownPromises); // Clear collections this.queues.clear(); this.caches.clear(); logger.info('QueueManager shutdown complete'); } catch (error) { logger.error('Error during shutdown', { error: (error as Error).message }); throw error; } finally { // Reset shutdown state this.shutdownPromise = null; this.isShuttingDown = false; } } /** * Start workers for all queues (used when delayWorkerStart is enabled) */ startAllWorkers(): void { if (!this.config.delayWorkerStart) { logger.warn('startAllWorkers() called but delayWorkerStart is not enabled'); return; } let workersStarted = 0; for (const queue of this.queues.values()) { const workerCount = this.config.defaultQueueOptions?.workers || 1; const concurrency = this.config.defaultQueueOptions?.concurrency || 1; if (workerCount > 0) { queue.startWorkersManually(workerCount, concurrency); workersStarted++; } } logger.info('All workers started', { totalQueues: this.queues.size, queuesWithWorkers: workersStarted, delayWorkerStart: this.config.delayWorkerStart }); } /** * Wait for all queues to be ready */ async waitUntilReady(): Promise { const readyPromises = Array.from(this.queues.values()).map(queue => queue.waitUntilReady()); await Promise.all(readyPromises); } /** * Get Redis configuration (for backward compatibility) */ getRedisConfig() { return this.config.redis; } /** * Get the current configuration */ getConfig(): Readonly { return { ...this.config }; } }