UNPKG

@sailboat-computer/event-bus

Version:

Standardized event bus for sailboat computer v3 with resilience features and offline capabilities

665 lines 26.1 kB
"use strict"; /** * Event bus implementation */ Object.defineProperty(exports, "__esModule", { value: true }); exports.createEventBusWithAdapter = exports.EventBusImpl = void 0; const uuid_1 = require("uuid"); const types_1 = require("./types"); const config_1 = require("./config"); const metrics_1 = require("./metrics"); const utils_1 = require("./utils"); const validation_1 = require("./validation"); const dead_letter_queue_1 = require("./dead-letter-queue"); const monitoring_1 = require("./monitoring"); const errors_1 = require("./errors"); /** * Event bus implementation */ class EventBusImpl { /** * Create a new event bus * * @param adapter - Event adapter */ constructor(adapter) { /** * Whether the event bus is initialized */ this.initialized = false; /** * Offline buffer for events */ this.offlineBuffer = new Map(); /** * Monitoring interval */ this.monitoringInterval = null; this.adapter = adapter; this.serviceName = process.env['SERVICE_NAME'] || 'unknown-service'; this.metricsCollector = new metrics_1.MetricsCollector(); this.schemaRegistry = new validation_1.SchemaRegistry(); this.deadLetterQueue = new dead_letter_queue_1.DeadLetterQueueManager(); this.alertManager = new monitoring_1.AlertManager(); // Default config, will be overridden in initialize() this.config = { adapter: { type: 'memory', config: { serviceName: this.serviceName } }, offlineBuffer: { maxSize: 10000, priorityRetention: true }, metrics: { enabled: true, detailedTimings: false } }; } /** * Initialize the event bus * * @param config - Event bus configuration */ async initialize(config) { if (this.initialized) { throw new errors_1.EventBusError(errors_1.EventBusErrorCode.ALREADY_INITIALIZED, 'Event bus already initialized'); } // Validate configuration this.config = (0, config_1.validateConfig)(config); // Initialize metrics collector this.metricsCollector = new metrics_1.MetricsCollector(this.config.metrics.detailedTimings); // Initialize dead letter queue if (this.config.deadLetterQueue?.enabled) { this.deadLetterQueue = new dead_letter_queue_1.DeadLetterQueueManager(this.config.deadLetterQueue.maxSize, this.config.deadLetterQueue.maxAttempts); } try { // Initialize adapter await this.adapter.initialize({ ...this.config.adapter.config, serviceName: this.serviceName }); this.initialized = true; // Process offline buffer if any if (this.offlineBuffer.size > 0) { this.processOfflineBuffer(); } // Set up monitoring if enabled if (this.config.monitoring?.enabled) { // Initialize alert manager with thresholds this.alertManager = new monitoring_1.AlertManager(this.config.monitoring.alertThresholds); // Set up monitoring interval const checkInterval = this.config.monitoring.checkInterval || 60000; // Default: 1 minute this.monitoringInterval = setInterval(() => { // Check metrics for alerts this.alertManager.checkMetrics(this.metricsCollector.getMetrics()); // Check connection status this.alertManager.checkConnectionStatus(this.adapter.isConnected()); }, checkInterval); } utils_1.logger.info(`Event bus initialized for service ${this.serviceName}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); utils_1.logger.error(`Failed to initialize event bus: ${errorMessage}`, error); throw new errors_1.EventBusError(errors_1.EventBusErrorCode.INITIALIZATION_FAILED, `Failed to initialize event bus: ${errorMessage}`); } } /** * Shutdown the event bus */ async shutdown() { if (!this.initialized) { return; } try { // Clear monitoring interval if set if (this.monitoringInterval) { clearInterval(this.monitoringInterval); this.monitoringInterval = null; } await this.adapter.shutdown(); this.initialized = false; utils_1.logger.info(`Event bus shut down for service ${this.serviceName}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); utils_1.logger.error(`Failed to shut down event bus: ${errorMessage}`, error); throw new errors_1.EventBusError(errors_1.EventBusErrorCode.ALREADY_CLOSED, `Failed to shut down event bus: ${errorMessage}`); } } /** * Publish an event * * @param eventType - Event type * @param data - Event data * @param options - Publish options * @returns Event ID */ async publish(eventType, data, options) { // Validate event type if (!eventType) { throw new errors_1.PublishError('Event type is required'); } // Create event envelope const event = { id: (0, uuid_1.v4)(), type: eventType, timestamp: new Date(), source: this.serviceName, version: '1.0', ...(options?.correlationId ? { correlationId: options.correlationId } : {}), ...(options?.causationId ? { causationId: options.causationId } : {}), data, metadata: { priority: options?.priority || types_1.EventPriority.NORMAL, category: options?.category || types_1.EventCategory.DATA, ...(options?.tags ? { tags: options.tags } : {}), retryCount: 0 } }; // If not initialized or adapter not connected, buffer the event if (!this.initialized || !this.adapter.isConnected()) { this.bufferEvent(eventType, data, options); return event.id; } // Validate against schema if one exists if (this.schemaRegistry.hasSchema(eventType)) { const validationResult = this.schemaRegistry.validate(event); if (!validationResult.valid) { const errorDetails = JSON.stringify(validationResult.errors); utils_1.logger.error(`Event validation failed for type ${eventType}: ${errorDetails}`); throw new errors_1.PublishError(`Event validation failed: ${errorDetails}`); } } try { // Publish event const eventId = await this.adapter.publish(event); // Update metrics if (this.config.metrics.enabled) { this.metricsCollector.incrementPublishedEvents(); } return eventId; } catch (error) { // Buffer event on failure this.bufferEvent(eventType, data, options); // Update metrics if (this.config.metrics.enabled) { this.metricsCollector.incrementFailedPublishes(); } const errorMessage = error instanceof Error ? error.message : String(error); utils_1.logger.error(`Failed to publish event: ${errorMessage}`, error); return event.id; } } /** * Subscribe to an event * * @param eventType - Event type * @param handler - Event handler * @returns Subscription */ async subscribe(eventType, handler) { if (!this.initialized) { throw new errors_1.NotInitializedError(); } // Validate event type if (!eventType) { throw new errors_1.SubscribeError('Event type is required'); } // Validate handler if (!handler || typeof handler !== 'function') { throw new errors_1.SubscribeError('Event handler is required and must be a function'); } try { // Wrap the handler to catch errors and send to dead letter queue const wrappedHandler = (event) => { try { // Call the original handler return handler(event); } catch (error) { // Send to dead letter queue if enabled if (this.config.deadLetterQueue?.enabled) { const attempts = (event.metadata.retryCount || 0) + 1; // Check if we've reached the maximum number of attempts if (attempts >= (this.config.deadLetterQueue.maxAttempts || 3)) { // Send to dead letter queue const deadLetterEventId = this.deadLetterQueue.addEvent(event, error instanceof Error ? error : new Error(String(error)), attempts); // Update metrics if (this.config.metrics.enabled) { this.metricsCollector.incrementDeadLetterQueueEvents(); this.metricsCollector.setDeadLetterQueueSize(this.deadLetterQueue.getSize()); } utils_1.logger.warn(`Event ${event.id} of type ${event.type} sent to dead letter queue after ${attempts} attempts: ${error instanceof Error ? error.message : String(error)}`); } else { // Retry the event with increased retry count const updatedEvent = { ...event, metadata: { ...event.metadata, retryCount: attempts } }; // Republish the event asynchronously setTimeout(() => { this.adapter.publish(updatedEvent) .then(() => { utils_1.logger.info(`Retrying event ${event.id} of type ${event.type} (attempt ${attempts})`); }) .catch((retryError) => { utils_1.logger.error(`Failed to retry event ${event.id} of type ${event.type}: ${retryError instanceof Error ? retryError.message : String(retryError)}`, retryError); }); }, 0); } } else { // Just log the error if dead letter queue is not enabled utils_1.logger.error(`Error processing event ${event.id} of type ${event.type}: ${error instanceof Error ? error.message : String(error)}`, error); } // Re-throw the error throw error; } }; // Subscribe to event with wrapped handler const subscription = await this.adapter.subscribe(eventType, wrappedHandler); // Update metrics if (this.config.metrics.enabled) { this.metricsCollector.incrementActiveSubscriptions(); // Wrap unsubscribe to update metrics const originalUnsubscribe = subscription.unsubscribe; subscription.unsubscribe = async () => { await originalUnsubscribe(); if (this.config.metrics.enabled) { this.metricsCollector.decrementActiveSubscriptions(); } }; } return subscription; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); utils_1.logger.error(`Failed to subscribe to event ${eventType}: ${errorMessage}`, error); throw new errors_1.SubscribeError(`Failed to subscribe to event ${eventType}: ${errorMessage}`); } } /** * Get event bus metrics * * @returns Event bus metrics */ getMetrics() { return this.metricsCollector.getMetrics(); } /** * Register a schema for an event type * * @param eventType - Event type * @param schema - JSON schema */ registerSchema(eventType, schema) { this.schemaRegistry.registerSchema(eventType, schema); } /** * Check if a schema is registered for an event type * * @param eventType - Event type * @returns True if a schema is registered */ hasSchema(eventType) { return this.schemaRegistry.hasSchema(eventType); } /** * Get a schema for an event type * * @param eventType - Event type * @returns JSON schema or null if not found */ getSchema(eventType) { return this.schemaRegistry.getSchema(eventType); } /** * Get events from the dead letter queue * * @param eventType - Optional event type to filter by * @param limit - Maximum number of events to return * @returns Dead letter queue events */ async getDeadLetterEvents(eventType, limit) { if (!this.initialized) { throw new errors_1.NotInitializedError(); } return this.deadLetterQueue.getEvents(eventType, limit); } /** * Republish an event from the dead letter queue * * @param eventId - ID of the event to republish * @returns New event ID */ async republishDeadLetterEvent(eventId) { if (!this.initialized) { throw new errors_1.NotInitializedError(); } // Get the event from the dead letter queue const event = this.deadLetterQueue.getEvent(eventId); if (!event) { throw new errors_1.EventBusError(errors_1.EventBusErrorCode.EVENT_NOT_FOUND, `Event with ID ${eventId} not found in dead letter queue`); } // Republish the event const newEventId = await this.publish(event.eventType, event.data, { priority: types_1.EventPriority.HIGH, // Prioritize republished events category: types_1.EventCategory.SYSTEM, tags: ['republished', 'dead-letter-queue'] }); // Remove the event from the dead letter queue this.deadLetterQueue.removeEvent(eventId); utils_1.logger.info(`Republished event ${eventId} from dead letter queue with new ID ${newEventId}`); return newEventId; } /** * Remove an event from the dead letter queue * * @param eventId - ID of the event to remove */ async removeDeadLetterEvent(eventId) { if (!this.initialized) { throw new errors_1.NotInitializedError(); } const removed = this.deadLetterQueue.removeEvent(eventId); if (!removed) { throw new errors_1.EventBusError(errors_1.EventBusErrorCode.EVENT_NOT_FOUND, `Event with ID ${eventId} not found in dead letter queue`); } // Update metrics if (this.config.metrics.enabled) { this.metricsCollector.setDeadLetterQueueSize(this.deadLetterQueue.getSize()); } utils_1.logger.info(`Removed event ${eventId} from dead letter queue`); } /** * Clear the dead letter queue * * @param eventType - Optional event type to filter by */ async clearDeadLetterQueue(eventType) { if (!this.initialized) { throw new errors_1.NotInitializedError(); } const count = this.deadLetterQueue.clearEvents(eventType); // Update metrics if (this.config.metrics.enabled) { this.metricsCollector.setDeadLetterQueueSize(this.deadLetterQueue.getSize()); } utils_1.logger.info(`Cleared ${count} events from dead letter queue${eventType ? ` of type ${eventType}` : ''}`); } /** * Register an alert handler * * @param handler - Alert handler * @returns Handler ID */ registerAlertHandler(handler) { if (!this.initialized) { throw new errors_1.NotInitializedError(); } return this.alertManager.registerHandler(handler); } /** * Unregister an alert handler * * @param handlerId - Handler ID */ unregisterAlertHandler(handlerId) { if (!this.initialized) { throw new errors_1.NotInitializedError(); } this.alertManager.unregisterHandler(handlerId); } /** * Get active alerts * * @param type - Optional alert type to filter by * @returns Active alerts */ getActiveAlerts(type) { if (!this.initialized) { throw new errors_1.NotInitializedError(); } return this.alertManager.getActiveAlerts(type); } /** * Get alert history * * @param limit - Maximum number of alerts to return * @param type - Optional alert type to filter by * @returns Alert history */ getAlertHistory(limit, type) { if (!this.initialized) { throw new errors_1.NotInitializedError(); } return this.alertManager.getAlertHistory(limit, type); } /** * Clear alert history */ clearAlertHistory() { if (!this.initialized) { throw new errors_1.NotInitializedError(); } this.alertManager.clearAlertHistory(); } /** * Unsubscribe from all handlers for an event type * * @param eventType - Event type to unsubscribe from */ async unsubscribe(eventType) { if (!this.initialized) { throw new errors_1.NotInitializedError(); } // Validate event type if (!eventType) { throw new errors_1.SubscribeError('Event type is required'); } try { utils_1.logger.info(`Unsubscribing from all handlers for event type ${eventType}`); // Delegate to adapter await this.adapter.unsubscribe(eventType); // Update metrics if (this.config.metrics.enabled) { // We don't know exactly how many subscriptions were removed, // but we can reset the counter based on adapter's current state // This would require adapter to expose active subscription count } utils_1.logger.info(`Successfully unsubscribed from all handlers for event type ${eventType}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); utils_1.logger.error(`Failed to unsubscribe from event type ${eventType}: ${errorMessage}`, error); throw new errors_1.SubscribeError(`Failed to unsubscribe from event type ${eventType}: ${errorMessage}`); } } /** * Check if the event bus is healthy * * @returns True if the event bus is healthy */ async isHealthy() { // If not initialized, the event bus is not healthy if (!this.initialized) { return false; } try { // Check if adapter is connected const adapterConnected = this.adapter.isConnected(); // Check if there are any critical alerts const criticalAlerts = this.alertManager.getActiveAlerts().filter(alert => alert.severity === types_1.AlertSeverity.CRITICAL); // Event bus is healthy if adapter is connected and there are no critical alerts const isHealthy = adapterConnected && criticalAlerts.length === 0; // Log health status if (!isHealthy) { utils_1.logger.warn('Event bus is not healthy', { adapterConnected, criticalAlerts: criticalAlerts.length }); } return isHealthy; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); utils_1.logger.error(`Error checking event bus health: ${errorMessage}`); return false; } } /** * Buffer an event for later publishing * * @param eventType - Event type * @param data - Event data * @param options - Publish options */ bufferEvent(eventType, data, options) { // Get or create buffer for this event type if (!this.offlineBuffer.has(eventType)) { this.offlineBuffer.set(eventType, []); } // Add event to buffer this.offlineBuffer.get(eventType).push({ eventType, data, options, timestamp: new Date(), attempts: 0 }); // Check if buffer is full let totalBufferedEvents = 0; for (const events of this.offlineBuffer.values()) { totalBufferedEvents += events.length; } // Update metrics if (this.config.metrics.enabled) { this.metricsCollector.setBufferedEvents(totalBufferedEvents); } // If buffer is full, remove events based on priority if (totalBufferedEvents > this.config.offlineBuffer.maxSize) { this.pruneOfflineBuffer(); } utils_1.logger.debug(`Buffered event of type ${eventType} (buffer size: ${totalBufferedEvents})`); } /** * Process offline buffer */ async processOfflineBuffer() { if (!this.initialized || !this.adapter.isConnected() || this.offlineBuffer.size === 0) { return; } utils_1.logger.info('Processing offline buffer...'); // Process each event type for (const [eventType, events] of this.offlineBuffer.entries()) { // Skip if no events if (events.length === 0) { continue; } // Process events in order const eventsCopy = [...events]; this.offlineBuffer.set(eventType, []); for (const event of eventsCopy) { try { await this.publish(event.eventType, event.data, event.options); } catch (error) { // Re-buffer event with increased attempt count event.attempts++; // Only re-buffer if attempts are below threshold if (event.attempts < 3) { this.bufferEvent(event.eventType, event.data, event.options); } else { utils_1.logger.error(`Failed to publish buffered event after ${event.attempts} attempts`, error); } } } } // Update metrics if (this.config.metrics.enabled) { this.metricsCollector.setBufferedEvents(0); } utils_1.logger.info('Offline buffer processing complete'); } /** * Prune offline buffer when it's full */ pruneOfflineBuffer() { if (!this.config.offlineBuffer.priorityRetention) { // Simple FIFO strategy - remove oldest events first for (const [eventType, events] of this.offlineBuffer.entries()) { if (events.length > 0) { events.shift(); // Remove event type if empty if (events.length === 0) { this.offlineBuffer.delete(eventType); } return; } } } else { // Priority-based strategy // First try to remove LOW priority events if (this.removeEventsByPriority(types_1.EventPriority.LOW)) { return; } // Then try NORMAL priority if (this.removeEventsByPriority(types_1.EventPriority.NORMAL)) { return; } // Then try HIGH priority if (this.removeEventsByPriority(types_1.EventPriority.HIGH)) { return; } // Finally, remove CRITICAL priority if necessary this.removeEventsByPriority(types_1.EventPriority.CRITICAL); } } /** * Remove events by priority * * @param priority - Event priority * @returns True if an event was removed */ removeEventsByPriority(priority) { for (const [eventType, events] of this.offlineBuffer.entries()) { // Find events with the specified priority const index = events.findIndex(event => event.options?.priority === priority || (!event.options?.priority && priority === types_1.EventPriority.NORMAL)); if (index !== -1) { // Remove the event events.splice(index, 1); // Remove event type if empty if (events.length === 0) { this.offlineBuffer.delete(eventType); } return true; } } return false; } } exports.EventBusImpl = EventBusImpl; /** * Create a new event bus * * @param adapter - Event adapter * @returns Event bus */ function createEventBusWithAdapter(adapter) { return new EventBusImpl(adapter); } exports.createEventBusWithAdapter = createEventBusWithAdapter; //# sourceMappingURL=event-bus.js.map