UNPKG

@sailboat-computer/event-bus

Version:

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

559 lines (464 loc) 15.2 kB
/** * Redis adapter for the event bus * Uses Redis Streams for event persistence and consumer groups for event distribution */ import Redis from 'ioredis'; import { v4 as uuidv4 } from 'uuid'; import { EventAdapter, RedisAdapterConfig, EventHandler, EventEnvelope, Subscription } from '../types'; import { BaseAdapter } from './base-adapter'; import { logger, retry } from '../utils'; import { ConnectionError, PublishError, SubscribeError, EventBusErrorCode } from '../errors'; /** * Redis adapter for the event bus */ export class RedisAdapter extends BaseAdapter { /** * Redis client */ private client: Redis | null = null; /** * Consumer group name */ private consumerGroup: string = ''; /** * Consumer name */ private consumerName: string = ''; /** * Maximum batch size for event processing */ private maxBatchSize: number = 100; /** * Polling interval in milliseconds */ private pollInterval: number = 1000; /** * Polling intervals by event type */ private pollingIntervals: Map<string, NodeJS.Timeout> = new Map(); /** * Reconnection options */ private reconnectOptions: { baseDelay: number; maxDelay: number; maxRetries: number; } = { baseDelay: 1000, maxDelay: 30000, maxRetries: 10 }; /** * Initialize the adapter * * @param config - Redis adapter configuration */ override async initialize(config: RedisAdapterConfig): Promise<void> { await super.initialize(config); this.consumerGroup = config.consumerGroup; this.consumerName = config.consumerName; this.maxBatchSize = config.maxBatchSize; this.pollInterval = config.pollInterval; this.reconnectOptions = config.reconnectOptions; try { // Create Redis client this.client = new Redis(config.url, { retryStrategy: (times) => { const delay = Math.min( this.reconnectOptions.baseDelay * Math.pow(2, times - 1), this.reconnectOptions.maxDelay ); logger.info(`Reconnecting to Redis in ${delay}ms (attempt ${times})`); return times <= this.reconnectOptions.maxRetries ? delay : null; } }); // Set up event handlers this.client.on('connect', () => { logger.info('Connected to Redis'); this.connected = true; }); this.client.on('error', (error) => { logger.error('Redis connection error', error); this.connected = false; }); this.client.on('close', () => { logger.info('Redis connection closed'); this.connected = false; }); this.client.on('reconnecting', () => { logger.info('Reconnecting to Redis...'); }); // Wait for connection await this.waitForConnection(); logger.info(`Redis adapter initialized for service ${this.config.serviceName}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Failed to initialize Redis adapter: ${errorMessage}`, error as Error); throw new ConnectionError(`Failed to initialize Redis adapter: ${errorMessage}`); } } /** * Shutdown the adapter */ override async shutdown(): Promise<void> { // Stop all polling intervals for (const interval of this.pollingIntervals.values()) { clearInterval(interval); } this.pollingIntervals.clear(); // Close Redis connection if (this.client) { await this.client.quit(); this.client = null; } await super.shutdown(); logger.info(`Redis adapter shut down for service ${this.config.serviceName}`); } /** * Publish an event * * @param event - Event envelope * @returns Event ID */ override async publish<T>(event: EventEnvelope): Promise<string> { this.ensureInitialized(); if (!this.client) { throw new ConnectionError('Redis client not initialized'); } try { // Generate event ID if not provided const eventId = event.id || uuidv4(); const eventWithId = { ...event, id: eventId }; // Convert event to Redis stream entry const streamEntry = this.eventToStreamEntry(eventWithId); // Get stream name from event type const streamName = this.getStreamName(event.type); // Add event to stream await this.client.xadd( streamName, 'MAXLEN', '~', '10000', // Limit stream length '*', // Auto-generate ID ...streamEntry ); logger.debug(`Published event ${eventId} to stream ${streamName}`); return eventId; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Failed to publish event: ${errorMessage}`, error as Error); throw new PublishError(`Failed to publish event: ${errorMessage}`); } } /** * Subscribe to an event * * @param eventType - Event type * @param handler - Event handler * @returns Subscription */ override async subscribe<T>(eventType: string, handler: EventHandler<T>): Promise<Subscription> { this.ensureInitialized(); if (!this.client) { throw new ConnectionError('Redis client not initialized'); } try { // Get stream name from event type const streamName = this.getStreamName(eventType); // Create consumer group if it doesn't exist await this.createConsumerGroup(streamName); // Add subscription const subscription = await super.subscribe(eventType, handler); // Start polling if not already polling if (!this.pollingIntervals.has(streamName)) { this.startPolling(streamName); } 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}`); } } /** * Acknowledge an event * * @param eventId - Event ID * @param eventType - Event type */ override async acknowledgeEvent(eventId: string, eventType: string): Promise<void> { this.ensureInitialized(); if (!this.client) { throw new ConnectionError('Redis client not initialized'); } try { // Get stream name from event type const streamName = this.getStreamName(eventType); // Acknowledge event await this.client.xack(streamName, this.consumerGroup, eventId); logger.debug(`Acknowledged event ${eventId} in stream ${streamName}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Failed to acknowledge event ${eventId}: ${errorMessage}`, error as Error); // Don't throw, just log the error } } /** * Create a consumer group for a stream * * @param streamName - Stream name */ private async createConsumerGroup(streamName: string): Promise<void> { if (!this.client) { throw new ConnectionError('Redis client not initialized'); } try { // Create consumer group await this.client.xgroup('CREATE', streamName, this.consumerGroup, '$', 'MKSTREAM'); logger.info(`Created consumer group ${this.consumerGroup} for stream ${streamName}`); } catch (error) { // Ignore BUSYGROUP error (group already exists) if (error instanceof Error && error.message.includes('BUSYGROUP')) { logger.debug(`Consumer group ${this.consumerGroup} already exists for stream ${streamName}`); } else { throw error; } } } /** * Start polling for events * * @param streamName - Stream name */ private startPolling(streamName: string): void { if (this.pollingIntervals.has(streamName)) { return; } logger.info(`Starting polling for stream ${streamName}`); // Start polling const interval = setInterval(() => { this.pollEvents(streamName).catch(error => { logger.error(`Error polling events from ${streamName}`, error); }); }, this.pollInterval); // Store interval this.pollingIntervals.set(streamName, interval); } /** * Poll events from a stream * * @param streamName - Stream name */ private async pollEvents(streamName: string): Promise<void> { if (!this.client || !this.connected) { return; } try { // Read events from stream const result = await this.client.xreadgroup( 'GROUP', this.consumerGroup, this.consumerName, 'COUNT', this.maxBatchSize, 'BLOCK', 1000, 'STREAMS', streamName, '>' ); // No events if (!result) { return; } // Process events for (const streamData of result as Array<[string, any[]]>) { const [stream, events] = streamData; await this.processEvents(stream, events); } } catch (error) { logger.error(`Error polling events from ${streamName}`, error as Error); } } /** * Process events from a stream * * @param streamName - Stream name * @param events - Events */ private async processEvents(streamName: string, events: any[]): Promise<void> { if (events.length === 0) { return; } // Get event type from stream name const eventType = this.getEventTypeFromStream(streamName); // Get subscriptions for this event type const eventSubscriptions = this.subscriptions.get(eventType); if (!eventSubscriptions || eventSubscriptions.size === 0) { // No subscriptions, acknowledge events for (const [eventId] of events) { await this.acknowledgeEvent(eventId, eventType); } return; } // Process each event for (const [eventId, fields] of events) { try { // Convert stream entry to event const event = this.streamEntryToEvent(eventId, fields, eventType); // Call all handlers const promises: Promise<void>[] = []; for (const handler of eventSubscriptions.values()) { try { const result = handler(event); if (result instanceof Promise) { promises.push(result); } } catch (error) { logger.error(`Error handling event ${eventId} of type ${eventType}`, error as Error); } } // Wait for all promises to resolve if (promises.length > 0) { await Promise.all(promises); } // Acknowledge event await this.acknowledgeEvent(eventId, eventType); } catch (error) { logger.error(`Error processing event ${eventId} from ${streamName}`, error as Error); } } } /** * Convert an event to a Redis stream entry * * @param event - Event envelope * @returns Redis stream entry */ private eventToStreamEntry(event: EventEnvelope): string[] { const entry: string[] = []; // Add event ID entry.push('id', event.id); // Add event type entry.push('type', event.type); // Add timestamp entry.push('timestamp', event.timestamp.toISOString()); // Add source entry.push('source', event.source); // Add version entry.push('version', event.version); // Add correlation ID if present if (event.correlationId) { entry.push('correlationId', event.correlationId); } // Add causation ID if present if (event.causationId) { entry.push('causationId', event.causationId); } // Add data entry.push('data', JSON.stringify(event.data)); // Add metadata entry.push('metadata', JSON.stringify(event.metadata)); return entry; } /** * Convert a Redis stream entry to an event * * @param eventId - Event ID * @param fields - Stream entry fields * @param eventType - Event type * @returns Event envelope */ private streamEntryToEvent(eventId: string, fields: any[], eventType: string): EventEnvelope { // Convert fields array to object const fieldsObj: Record<string, string> = {}; for (let i = 0; i < fields.length; i += 2) { fieldsObj[fields[i]] = fields[i + 1]; } // Create event const event: EventEnvelope = { id: fieldsObj['id'] || eventId, type: fieldsObj['type'] || eventType, timestamp: new Date(fieldsObj['timestamp'] || Date.now()), source: fieldsObj['source'] || 'unknown', version: fieldsObj['version'] || '1.0', data: JSON.parse(fieldsObj['data'] || '{}'), metadata: JSON.parse(fieldsObj['metadata'] || '{}') }; // Add correlation ID if present if (fieldsObj['correlationId']) { event.correlationId = fieldsObj['correlationId']; } // Add causation ID if present if (fieldsObj['causationId']) { event.causationId = fieldsObj['causationId']; } return event; } /** * Get stream name from event type * * @param eventType - Event type * @returns Stream name */ private getStreamName(eventType: string): string { // Replace dots with colons for Redis stream names return `event:${eventType.replace(/\./g, ':')}`; } /** * Get event type from stream name * * @param streamName - Stream name * @returns Event type */ private getEventTypeFromStream(streamName: string): string { // Remove 'event:' prefix and replace colons with dots return streamName.replace(/^event:/, '').replace(/:/g, '.'); } /** * Wait for Redis connection */ private async waitForConnection(): Promise<void> { if (!this.client) { throw new ConnectionError('Redis client not initialized'); } // Check if already connected if (this.client.status === 'ready') { this.connected = true; return; } // Wait for connection await new Promise<void>((resolve, reject) => { if (!this.client) { reject(new ConnectionError('Redis client not initialized')); return; } // Set timeout const timeout = setTimeout(() => { reject(new ConnectionError('Timeout waiting for Redis connection')); }, 10000); // Wait for connect event this.client.once('ready', () => { clearTimeout(timeout); this.connected = true; resolve(); }); // Handle error this.client.once('error', (error) => { clearTimeout(timeout); reject(new ConnectionError(`Redis connection error: ${error.message}`)); }); }); } } /** * Create a new Redis adapter * * @returns Redis adapter */ export function createRedisAdapter(): RedisAdapter { return new RedisAdapter(); }