UNPKG

easy-kafka-accessor

Version:

Kafka producer and consumer service with message processing capabilities

769 lines (661 loc) 22.3 kB
/** * Dynamic Processor Registry * Manages Kafka topic processors with dynamic registration, deregistration, and updates * Supports configurable processor directories and automatic refresh */ const EventEmitter = require('events'); const fs = require('fs'); const path = require('path'); const winston = require('winston'); class ProcessorRegistry extends EventEmitter { constructor(options = {}) { super(); // Configuration this.processorsDir = options.processorsDir || './processors'; this.autoRefresh = options.autoRefresh !== false; // Default to true this.refreshInterval = options.refreshInterval || 5000; // 5 seconds this.fileExtensions = options.fileExtensions || ['.js']; // Initialize logger this.logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json() ), defaultMeta: { service: 'processor-registry' }, transports: [ new winston.transports.Console({ format: winston.format.simple() }) ] }); // Internal state this.processors = new Map(); this.processorVersions = new Map(); this.processorFiles = new Map(); // Track file paths this.registryStats = { totalRegistered: 0, totalDeregistered: 0, totalUpdated: 0, lastUpdated: null, autoRefreshCount: 0 }; // Auto-refresh functionality this.refreshTimer = null; this.watcher = null; // Initialize auto-refresh if enabled if (this.autoRefresh) { this.startAutoRefresh(); } } /** * Start automatic refresh of processors */ startAutoRefresh() { if (this.refreshTimer) { clearInterval(this.refreshTimer); } this.refreshTimer = setInterval(async () => { try { await this.refreshProcessors(); } catch (error) { this.logger.error('Auto-refresh error', { error: error.message }); } }, this.refreshInterval); this.logger.info('Auto-refresh started', { processorsDir: this.processorsDir }); } /** * Stop automatic refresh */ stopAutoRefresh() { if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null; } if (this.watcher) { this.watcher.close(); this.watcher = null; } this.logger.info('Auto-refresh stopped'); } /** * Set processors directory * @param {string} dir - New processors directory path */ setProcessorsDirectory(dir) { this.processorsDir = dir; this.logger.info('Processors directory changed', { newDir: dir }); // Refresh processors from new directory if (this.autoRefresh) { this.refreshProcessors(); } } /** * Get processors directory * @returns {string} Current processors directory */ getProcessorsDirectory() { return this.processorsDir; } /** * Scan processors directory for available processor files * @returns {Array} Array of processor file paths */ scanProcessorFiles() { try { if (!fs.existsSync(this.processorsDir)) { this.logger.warn('Processors directory does not exist', { dir: this.processorsDir }); return []; } const files = fs.readdirSync(this.processorsDir) .filter(file => { const ext = path.extname(file); return this.fileExtensions.includes(ext) && !file.startsWith('.'); }) .map(file => path.join(this.processorsDir, file)); return files; } catch (error) { this.logger.error('Error scanning processors directory', { error: error.message }); return []; } } /** * Load processor from file * @param {string} filePath - Path to processor file * @returns {Object|null} Processor instance or null if failed */ loadProcessorFromFile(filePath) { try { // Resolve the absolute path const absolutePath = path.resolve(filePath); // Clear require cache to ensure fresh load delete require.cache[absolutePath]; const processorModule = require(absolutePath); const ProcessorClass = processorModule.default || processorModule; if (typeof ProcessorClass === 'function') { return new ProcessorClass(); } else if (typeof processorModule === 'object' && processorModule.process) { return processorModule; } this.logger.warn('Invalid processor in file', { filePath }); return null; } catch (error) { this.logger.error('Error loading processor from file', { filePath, error: error.message }); return null; } } /** * Auto-discover and register processors from directory * @param {Object} options - Discovery options * @returns {Object} Discovery result */ async autoDiscoverProcessors(options = {}) { const forceRefresh = options.forceRefresh || false; const kafkaAccessor = options.kafkaAccessor; const discoveredProcessors = []; const errors = []; this.logger.info('Scanning processors directory', { dir: this.processorsDir }); // First, get available Kafka topics if kafkaAccessor is provided let availableTopics = []; if (kafkaAccessor && kafkaAccessor.admin) { try { this.logger.info('Scanning Kafka topics...'); availableTopics = await kafkaAccessor.admin.listTopics(); this.logger.info('Found Kafka topics', { count: availableTopics.length }); } catch (error) { this.logger.warn('Could not scan Kafka topics', { error: error.message }); // Fall back to scanning processor files without topic validation availableTopics = null; } } // Scan processor files const currentFiles = this.scanProcessorFiles(); for (const filePath of currentFiles) { const fileName = path.basename(filePath, path.extname(filePath)); const existingFile = this.processorFiles.get(fileName); // Check if file has changed if (existingFile && !forceRefresh) { try { const stats = fs.statSync(filePath); if (stats.mtime.getTime() === existingFile.mtime.getTime()) { continue; // File hasn't changed } } catch (error) { this.logger.warn('Could not check file modification time', { filePath }); } } // Only register processor if topic exists in Kafka (if we have topic list) if (availableTopics !== null && !availableTopics.includes(fileName)) { this.logger.info('Skipping processor', { fileName, reason: 'topic not found in Kafka' }); continue; } // Load and register processor const processor = this.loadProcessorFromFile(filePath); if (processor) { try { // Use filename as topic name (remove .js extension) const topic = fileName; // Register processor const result = this.registerProcessor(topic, processor, { source: 'auto-discovery', filePath: filePath }); if (result.success) { // Track file info try { const stats = fs.statSync(filePath); this.processorFiles.set(fileName, { filePath, mtime: stats.mtime, size: stats.size }); } catch (error) { this.logger.warn('Could not track file info', { filePath }); } discoveredProcessors.push({ topic, filePath, result }); this.logger.info('Registered processor for topic', { topic }); } } catch (error) { errors.push({ filePath, error: error.message }); } } } // Remove processors for files that no longer exist const existingTopics = Array.from(this.processors.keys()); for (const topic of existingTopics) { const processorInfo = this.processorVersions.get(topic); if (processorInfo && processorInfo.options.source === 'auto-discovery') { const filePath = processorInfo.options.filePath; if (!fs.existsSync(filePath)) { this.logger.info('Removing processor for deleted file', { topic }); this.deregisterProcessor(topic); } } } this.registryStats.autoRefreshCount++; return { success: true, discovered: discoveredProcessors.length, errors: errors.length, totalProcessors: this.processors.size, availableTopics: availableTopics || [], timestamp: new Date().toISOString() }; } /** * Refresh processors (alias for autoDiscoverProcessors) * @param {Object} options - Refresh options * @returns {Object} Refresh result */ async refreshProcessors(options = {}) { return await this.autoDiscoverProcessors(options); } /** * Get processor file information * @returns {Object} Map of processor names to file info */ getProcessorFileInfo() { return Object.fromEntries(this.processorFiles); } /** * Get auto-refresh status * @returns {Object} Auto-refresh status information */ getAutoRefreshStatus() { return { enabled: this.autoRefresh, interval: this.refreshInterval, isRunning: !!this.refreshTimer, lastRefresh: this.registryStats.lastUpdated, refreshCount: this.registryStats.autoRefreshCount }; } /** * Register a processor for a topic * @param {string} topic - The Kafka topic name * @param {Object} processor - The processor instance * @param {Object} options - Registration options * @returns {Object} Registration result */ registerProcessor(topic, processor, options = {}) { try { // Validate inputs if (!topic || typeof topic !== 'string') { throw new Error('Topic must be a valid string'); } if (!processor || typeof processor.process !== 'function') { throw new Error('Processor must have a process method'); } // Check if processor already exists const existingProcessor = this.processors.get(topic); const isUpdate = !!existingProcessor; // Generate processor version const version = this.generateProcessorVersion(processor); // Store processor and version this.processors.set(topic, processor); this.processorVersions.set(topic, { version, registeredAt: new Date().toISOString(), updatedAt: new Date().toISOString(), options }); // Update stats if (isUpdate) { this.registryStats.totalUpdated++; this.emit('processor:updated', { topic, processor, version, options }); this.logger.info('Updated processor for topic', { topic, version }); } else { this.registryStats.totalRegistered++; this.emit('processor:registered', { topic, processor, version, options }); this.logger.info('Registered processor for topic', { topic, version }); } this.registryStats.lastUpdated = new Date().toISOString(); return { success: true, topic, version, action: isUpdate ? 'updated' : 'registered', timestamp: new Date().toISOString() }; } catch (error) { const result = { success: false, topic, error: error.message, timestamp: new Date().toISOString() }; this.emit('processor:error', result); this.logger.error('Failed to register processor for topic', { topic, error: error.message }); return result; } } /** * Deregister a processor for a topic * @param {string} topic - The Kafka topic name * @param {Object} options - Deregistration options * @returns {Object} Deregistration result */ deregisterProcessor(topic, options = {}) { try { // Validate inputs if (!topic || typeof topic !== 'string') { throw new Error('Topic must be a valid string'); } // Check if processor exists if (!this.processors.has(topic)) { throw new Error(`No processor found for topic: ${topic}`); } // Get processor info before removal const processor = this.processors.get(topic); const versionInfo = this.processorVersions.get(topic); // Remove processor and version info this.processors.delete(topic); this.processorVersions.delete(topic); // Update stats this.registryStats.totalDeregistered++; this.registryStats.lastUpdated = new Date().toISOString(); // Emit event this.emit('processor:deregistered', { topic, processor, versionInfo, options }); this.logger.info('Deregistered processor for topic', { topic }); return { success: true, topic, action: 'deregistered', version: versionInfo?.version, timestamp: new Date().toISOString() }; } catch (error) { const result = { success: false, topic, error: error.message, timestamp: new Date().toISOString() }; this.emit('processor:error', result); this.logger.error('Failed to deregister processor for topic', { topic, error: error.message }); return result; } } /** * Update an existing processor * @param {string} topic - The Kafka topic name * @param {Object} processor - The new processor instance * @param {Object} options - Update options * @returns {Object} Update result */ updateProcessor(topic, processor, options = {}) { try { // Validate inputs if (!topic || typeof topic !== 'string') { throw new Error('Topic must be a valid string'); } if (!processor || typeof processor.process !== 'function') { throw new Error('Processor must have a process method'); } // Check if processor exists if (!this.processors.has(topic)) { throw new Error(`No processor found for topic: ${topic}. Use registerProcessor instead.`); } // Get old processor info const oldProcessor = this.processors.get(topic); const oldVersion = this.processorVersions.get(topic); // Update processor this.processors.set(topic, processor); // Update version info const newVersion = this.generateProcessorVersion(processor); this.processorVersions.set(topic, { ...oldVersion, version: newVersion, updatedAt: new Date().toISOString(), previousVersion: oldVersion.version, options }); // Update stats this.registryStats.totalUpdated++; this.registryStats.lastUpdated = new Date().toISOString(); // Emit event this.emit('processor:updated', { topic, processor, oldProcessor, version: newVersion, previousVersion: oldVersion.version, options }); this.logger.info('Updated processor for topic', { topic, oldVersion: oldVersion.version, newVersion: newVersion }); return { success: true, topic, version: newVersion, previousVersion: oldVersion.version, action: 'updated', timestamp: new Date().toISOString() }; } catch (error) { const result = { success: false, topic, error: error.message, timestamp: new Date().toISOString() }; this.emit('processor:error', result); this.logger.error('Failed to update processor for topic', { topic, error: error.message }); return result; } } /** * Get processor for a specific topic * @param {string} topic - The Kafka topic name * @returns {Object|null} The processor instance or null if not found */ getProcessor(topic) { return this.processors.get(topic) || null; } /** * Check if a topic has a processor * @param {string} topic - The Kafka topic name * @returns {boolean} True if processor exists */ hasProcessor(topic) { return this.processors.has(topic); } /** * Get all available topics * @returns {Array<string>} List of topics with processors */ getAvailableTopics() { return Array.from(this.processors.keys()); } /** * Get processor information for a specific topic * @param {string} topic - The Kafka topic name * @returns {Object|null} Processor info or null if not found */ getProcessorInfo(topic) { if (!this.processors.has(topic)) { return null; } const processor = this.processors.get(topic); const versionInfo = this.processorVersions.get(topic); return { topic, processor: { name: processor.name || processor.constructor.name, description: processor.description || 'No description', hasProcessMethod: typeof processor.process === 'function', hasGetInfoMethod: typeof processor.getInfo === 'function' }, version: versionInfo.version, registeredAt: versionInfo.registeredAt, updatedAt: versionInfo.updatedAt, options: versionInfo.options }; } /** * Get all processor information * @returns {Object} Map of topic to processor info */ getAllProcessorInfo() { const info = {}; for (const topic of this.processors.keys()) { info[topic] = this.getProcessorInfo(topic); } return info; } /** * Get registry statistics * @returns {Object} Registry statistics */ getRegistryStats() { return { ...this.registryStats, currentProcessors: this.processors.size, currentTopics: this.getAvailableTopics() }; } /** * Remove a processor for a topic (alias for deregisterProcessor) * @param {string} topic - The Kafka topic name * @param {Object} options - Removal options * @returns {Object} Removal result */ removeProcessor(topic, options = {}) { return this.deregisterProcessor(topic, options); } /** * Clear all processors * @param {Object} options - Clear options * @returns {Object} Clear result */ clear(options = {}) { try { const topics = this.getAvailableTopics(); const clearedCount = topics.length; // Clear all processors this.processors.clear(); this.processorVersions.clear(); // Update stats this.registryStats.lastUpdated = new Date().toISOString(); // Emit event this.emit('registry:cleared', { clearedCount, topics, options }); this.logger.info('Cleared all processors', { clearedCount }); return { success: true, clearedCount, topics, action: 'cleared', timestamp: new Date().toISOString() }; } catch (error) { const result = { success: false, error: error.message, timestamp: new Date().toISOString() }; this.emit('registry:error', result); this.logger.error('Failed to clear registry', { error: error.message }); return result; } } /** * Generate a version for a processor * @param {Object} processor - The processor instance * @returns {string} Version string */ generateProcessorVersion(processor) { const timestamp = Date.now(); const processorHash = this.hashProcessor(processor); return `${timestamp}-${processorHash}`; } /** * Simple hash function for processor * @param {Object} processor - The processor instance * @returns {string} Hash string */ hashProcessor(processor) { const processorStr = JSON.stringify({ name: processor.name || processor.constructor.name, description: processor.description, hasProcess: typeof processor.process === 'function', hasGetInfo: typeof processor.getInfo === 'function' }); let hash = 0; for (let i = 0; i < processorStr.length; i++) { const char = processorStr.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash).toString(16); } /** * Validate processor structure * @param {Object} processor - The processor to validate * @returns {Object} Validation result */ validateProcessor(processor) { const errors = []; if (!processor) { errors.push('Processor is required'); } else { if (typeof processor.process !== 'function') { errors.push('Processor must have a process method'); } if (!processor.name && !processor.constructor.name) { errors.push('Processor must have a name'); } } return { valid: errors.length === 0, errors }; } /** * Process a message using the appropriate processor * @param {string} topic - The Kafka topic name * @param {Object} message - The message payload * @param {Object} metadata - Message metadata * @returns {Promise<Object>} Processing result */ async processMessage(topic, message, metadata) { const processor = this.getProcessor(topic); if (!processor) { this.logger.warn('No processor found for topic', { topic }); return { status: 'error', message: `No processor found for topic: ${topic}`, topic, timestamp: new Date().toISOString() }; } try { this.logger.info('Processing message from topic', { topic }); const result = await processor.process(topic, message, metadata); return { ...result, topic, processor: processor.constructor.name, timestamp: new Date().toISOString() }; } catch (error) { this.logger.error('Error processing message from topic', { topic, error: error.message }); return { status: 'error', message: error.message, topic, processor: processor.constructor.name, timestamp: new Date().toISOString() }; } } } module.exports = ProcessorRegistry;