@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
text/typescript
/**
* 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();
}