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