UNPKG

@sailboat-computer/event-bus

Version:

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

846 lines (719 loc) 24.4 kB
/** * Event bus implementation */ import { v4 as uuidv4 } from 'uuid'; import { EventBus, EventAdapter, EventBusConfig, EventEnvelope, EventHandler, Subscription, PublishOptions, EventBusMetrics, BufferedEvent, EventPriority, EventPriorityType, EventCategory, Alert, AlertHandler, AlertType, AlertSeverity } from './types'; import { validateConfig } from './config'; import { MetricsCollector } from './metrics'; import { logger } from './utils'; import { SchemaRegistry, ValidationResult } from './validation'; import { DeadLetterQueueManager } from './dead-letter-queue'; import { AlertManager } from './monitoring'; import { EventBusError, EventBusErrorCode, NotInitializedError, PublishError, SubscribeError } from './errors'; /** * Event bus implementation */ export class EventBusImpl implements EventBus { /** * Event adapter */ private adapter: EventAdapter; /** * Whether the event bus is initialized */ private initialized = false; /** * Offline buffer for events */ private offlineBuffer = new Map<string, BufferedEvent[]>(); /** * Service name */ private serviceName: string; /** * Metrics collector */ private metricsCollector: MetricsCollector; /** * Schema registry */ private schemaRegistry: SchemaRegistry; /** * Dead letter queue manager */ private deadLetterQueue: DeadLetterQueueManager; /** * Alert manager */ private alertManager: AlertManager; /** * Monitoring interval */ private monitoringInterval: NodeJS.Timeout | null = null; /** * Configuration */ private config: EventBusConfig; /** * Create a new event bus * * @param adapter - Event adapter */ constructor(adapter: EventAdapter) { this.adapter = adapter; this.serviceName = process.env['SERVICE_NAME'] || 'unknown-service'; this.metricsCollector = new MetricsCollector(); this.schemaRegistry = new SchemaRegistry(); this.deadLetterQueue = new DeadLetterQueueManager(); this.alertManager = new 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: EventBusConfig): Promise<void> { if (this.initialized) { throw new EventBusError(EventBusErrorCode.ALREADY_INITIALIZED, 'Event bus already initialized'); } // Validate configuration this.config = validateConfig(config); // Initialize metrics collector this.metricsCollector = new MetricsCollector(this.config.metrics.detailedTimings); // Initialize dead letter queue if (this.config.deadLetterQueue?.enabled) { this.deadLetterQueue = new 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 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() as any); // Check connection status this.alertManager.checkConnectionStatus(this.adapter.isConnected()); }, checkInterval); } logger.info(`Event bus initialized for service ${this.serviceName}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Failed to initialize event bus: ${errorMessage}`, error as Error); throw new EventBusError(EventBusErrorCode.INITIALIZATION_FAILED, `Failed to initialize event bus: ${errorMessage}`); } } /** * Shutdown the event bus */ async shutdown(): Promise<void> { 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; logger.info(`Event bus shut down for service ${this.serviceName}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Failed to shut down event bus: ${errorMessage}`, error as Error); throw new EventBusError(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<T>(eventType: string, data: T, options?: PublishOptions): Promise<string> { // Validate event type if (!eventType) { throw new PublishError('Event type is required'); } // Create event envelope const event: EventEnvelope = { id: uuidv4(), 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 || EventPriority.NORMAL, category: options?.category || 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); logger.error(`Event validation failed for type ${eventType}: ${errorDetails}`); throw new 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); logger.error(`Failed to publish event: ${errorMessage}`, error as Error); return event.id; } } /** * Subscribe to an event * * @param eventType - Event type * @param handler - Event handler * @returns Subscription */ async subscribe<T>(eventType: string, handler: EventHandler<T>): Promise<Subscription> { if (!this.initialized) { throw new NotInitializedError(); } // Validate event type if (!eventType) { throw new SubscribeError('Event type is required'); } // Validate handler if (!handler || typeof handler !== 'function') { throw new 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: EventHandler<T> = (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()); } 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(() => { logger.info(`Retrying event ${event.id} of type ${event.type} (attempt ${attempts})`); }) .catch((retryError) => { logger.error(`Failed to retry event ${event.id} of type ${event.type}: ${retryError instanceof Error ? retryError.message : String(retryError)}`, retryError as Error); }); }, 0); } } else { // Just log the error if dead letter queue is not enabled logger.error(`Error processing event ${event.id} of type ${event.type}: ${error instanceof Error ? error.message : String(error)}`, error as 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); logger.error(`Failed to subscribe to event ${eventType}: ${errorMessage}`, error as Error); throw new SubscribeError(`Failed to subscribe to event ${eventType}: ${errorMessage}`); } } /** * Get event bus metrics * * @returns Event bus metrics */ getMetrics(): EventBusMetrics { return this.metricsCollector.getMetrics(); } /** * Register a schema for an event type * * @param eventType - Event type * @param schema - JSON schema */ registerSchema<T>(eventType: string, schema: any): void { this.schemaRegistry.registerSchema<T>(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: string): boolean { 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: string): any { 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?: string, limit?: number): Promise<any[]> { if (!this.initialized) { throw new 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: string): Promise<string> { if (!this.initialized) { throw new NotInitializedError(); } // Get the event from the dead letter queue const event = this.deadLetterQueue.getEvent(eventId); if (!event) { throw new EventBusError( 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: EventPriority.HIGH, // Prioritize republished events category: EventCategory.SYSTEM, tags: ['republished', 'dead-letter-queue'] } ); // Remove the event from the dead letter queue this.deadLetterQueue.removeEvent(eventId); 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: string): Promise<void> { if (!this.initialized) { throw new NotInitializedError(); } const removed = this.deadLetterQueue.removeEvent(eventId); if (!removed) { throw new EventBusError( 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()); } 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?: string): Promise<void> { if (!this.initialized) { throw new NotInitializedError(); } const count = this.deadLetterQueue.clearEvents(eventType); // Update metrics if (this.config.metrics.enabled) { this.metricsCollector.setDeadLetterQueueSize(this.deadLetterQueue.getSize()); } 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: AlertHandler): string { if (!this.initialized) { throw new NotInitializedError(); } return this.alertManager.registerHandler(handler); } /** * Unregister an alert handler * * @param handlerId - Handler ID */ unregisterAlertHandler(handlerId: string): void { if (!this.initialized) { throw new NotInitializedError(); } this.alertManager.unregisterHandler(handlerId); } /** * Get active alerts * * @param type - Optional alert type to filter by * @returns Active alerts */ getActiveAlerts(type?: AlertType): Alert[] { if (!this.initialized) { throw new 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?: number, type?: AlertType): Alert[] { if (!this.initialized) { throw new NotInitializedError(); } return this.alertManager.getAlertHistory(limit, type); } /** * Clear alert history */ clearAlertHistory(): void { if (!this.initialized) { throw new NotInitializedError(); } this.alertManager.clearAlertHistory(); } /** * Unsubscribe from all handlers for an event type * * @param eventType - Event type to unsubscribe from */ async unsubscribe(eventType: string): Promise<void> { if (!this.initialized) { throw new NotInitializedError(); } // Validate event type if (!eventType) { throw new SubscribeError('Event type is required'); } try { 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 } logger.info(`Successfully unsubscribed from all handlers for event type ${eventType}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Failed to unsubscribe from event type ${eventType}: ${errorMessage}`, error as Error); throw new 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(): Promise<boolean> { // 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 === 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) { logger.warn('Event bus is not healthy', { adapterConnected, criticalAlerts: criticalAlerts.length }); } return isHealthy; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); 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 */ private bufferEvent<T>(eventType: string, data: T, options?: PublishOptions): void { // 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(); } logger.debug(`Buffered event of type ${eventType} (buffer size: ${totalBufferedEvents})`); } /** * Process offline buffer */ private async processOfflineBuffer(): Promise<void> { if (!this.initialized || !this.adapter.isConnected() || this.offlineBuffer.size === 0) { return; } 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 { logger.error(`Failed to publish buffered event after ${event.attempts} attempts`, error as Error); } } } } // Update metrics if (this.config.metrics.enabled) { this.metricsCollector.setBufferedEvents(0); } logger.info('Offline buffer processing complete'); } /** * Prune offline buffer when it's full */ private pruneOfflineBuffer(): void { 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(EventPriority.LOW)) { return; } // Then try NORMAL priority if (this.removeEventsByPriority(EventPriority.NORMAL)) { return; } // Then try HIGH priority if (this.removeEventsByPriority(EventPriority.HIGH)) { return; } // Finally, remove CRITICAL priority if necessary this.removeEventsByPriority(EventPriority.CRITICAL); } } /** * Remove events by priority * * @param priority - Event priority * @returns True if an event was removed */ private removeEventsByPriority(priority: EventPriorityType): boolean { 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 === 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; } } /** * Create a new event bus * * @param adapter - Event adapter * @returns Event bus */ export function createEventBusWithAdapter(adapter: EventAdapter): EventBus { return new EventBusImpl(adapter); }