@sailboat-computer/event-bus
Version:
Standardized event bus for sailboat computer v3 with resilience features and offline capabilities
458 lines • 16.4 kB
JavaScript
;
/**
* Redis adapter for the event bus
* Uses Redis Streams for event persistence and consumer groups for event distribution
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRedisAdapter = exports.RedisAdapter = void 0;
const ioredis_1 = __importDefault(require("ioredis"));
const uuid_1 = require("uuid");
const base_adapter_1 = require("./base-adapter");
const utils_1 = require("../utils");
const errors_1 = require("../errors");
/**
* Redis adapter for the event bus
*/
class RedisAdapter extends base_adapter_1.BaseAdapter {
constructor() {
super(...arguments);
/**
* Redis client
*/
this.client = null;
/**
* Consumer group name
*/
this.consumerGroup = '';
/**
* Consumer name
*/
this.consumerName = '';
/**
* Maximum batch size for event processing
*/
this.maxBatchSize = 100;
/**
* Polling interval in milliseconds
*/
this.pollInterval = 1000;
/**
* Polling intervals by event type
*/
this.pollingIntervals = new Map();
/**
* Reconnection options
*/
this.reconnectOptions = {
baseDelay: 1000,
maxDelay: 30000,
maxRetries: 10
};
}
/**
* Initialize the adapter
*
* @param config - Redis adapter configuration
*/
async initialize(config) {
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 ioredis_1.default(config.url, {
retryStrategy: (times) => {
const delay = Math.min(this.reconnectOptions.baseDelay * Math.pow(2, times - 1), this.reconnectOptions.maxDelay);
utils_1.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', () => {
utils_1.logger.info('Connected to Redis');
this.connected = true;
});
this.client.on('error', (error) => {
utils_1.logger.error('Redis connection error', error);
this.connected = false;
});
this.client.on('close', () => {
utils_1.logger.info('Redis connection closed');
this.connected = false;
});
this.client.on('reconnecting', () => {
utils_1.logger.info('Reconnecting to Redis...');
});
// Wait for connection
await this.waitForConnection();
utils_1.logger.info(`Redis adapter initialized for service ${this.config.serviceName}`);
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
utils_1.logger.error(`Failed to initialize Redis adapter: ${errorMessage}`, error);
throw new errors_1.ConnectionError(`Failed to initialize Redis adapter: ${errorMessage}`);
}
}
/**
* Shutdown the adapter
*/
async shutdown() {
// 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();
utils_1.logger.info(`Redis adapter shut down for service ${this.config.serviceName}`);
}
/**
* Publish an event
*
* @param event - Event envelope
* @returns Event ID
*/
async publish(event) {
this.ensureInitialized();
if (!this.client) {
throw new errors_1.ConnectionError('Redis client not initialized');
}
try {
// Generate event ID if not provided
const eventId = event.id || (0, uuid_1.v4)();
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);
utils_1.logger.debug(`Published event ${eventId} to stream ${streamName}`);
return eventId;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
utils_1.logger.error(`Failed to publish event: ${errorMessage}`, error);
throw new errors_1.PublishError(`Failed to publish event: ${errorMessage}`);
}
}
/**
* Subscribe to an event
*
* @param eventType - Event type
* @param handler - Event handler
* @returns Subscription
*/
async subscribe(eventType, handler) {
this.ensureInitialized();
if (!this.client) {
throw new errors_1.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);
utils_1.logger.error(`Failed to subscribe to event ${eventType}: ${errorMessage}`, error);
throw new errors_1.SubscribeError(`Failed to subscribe to event ${eventType}: ${errorMessage}`);
}
}
/**
* Acknowledge an event
*
* @param eventId - Event ID
* @param eventType - Event type
*/
async acknowledgeEvent(eventId, eventType) {
this.ensureInitialized();
if (!this.client) {
throw new errors_1.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);
utils_1.logger.debug(`Acknowledged event ${eventId} in stream ${streamName}`);
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
utils_1.logger.error(`Failed to acknowledge event ${eventId}: ${errorMessage}`, error);
// Don't throw, just log the error
}
}
/**
* Create a consumer group for a stream
*
* @param streamName - Stream name
*/
async createConsumerGroup(streamName) {
if (!this.client) {
throw new errors_1.ConnectionError('Redis client not initialized');
}
try {
// Create consumer group
await this.client.xgroup('CREATE', streamName, this.consumerGroup, '$', 'MKSTREAM');
utils_1.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')) {
utils_1.logger.debug(`Consumer group ${this.consumerGroup} already exists for stream ${streamName}`);
}
else {
throw error;
}
}
}
/**
* Start polling for events
*
* @param streamName - Stream name
*/
startPolling(streamName) {
if (this.pollingIntervals.has(streamName)) {
return;
}
utils_1.logger.info(`Starting polling for stream ${streamName}`);
// Start polling
const interval = setInterval(() => {
this.pollEvents(streamName).catch(error => {
utils_1.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
*/
async pollEvents(streamName) {
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) {
const [stream, events] = streamData;
await this.processEvents(stream, events);
}
}
catch (error) {
utils_1.logger.error(`Error polling events from ${streamName}`, error);
}
}
/**
* Process events from a stream
*
* @param streamName - Stream name
* @param events - Events
*/
async processEvents(streamName, events) {
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 = [];
for (const handler of eventSubscriptions.values()) {
try {
const result = handler(event);
if (result instanceof Promise) {
promises.push(result);
}
}
catch (error) {
utils_1.logger.error(`Error handling event ${eventId} of type ${eventType}`, error);
}
}
// Wait for all promises to resolve
if (promises.length > 0) {
await Promise.all(promises);
}
// Acknowledge event
await this.acknowledgeEvent(eventId, eventType);
}
catch (error) {
utils_1.logger.error(`Error processing event ${eventId} from ${streamName}`, error);
}
}
}
/**
* Convert an event to a Redis stream entry
*
* @param event - Event envelope
* @returns Redis stream entry
*/
eventToStreamEntry(event) {
const entry = [];
// 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
*/
streamEntryToEvent(eventId, fields, eventType) {
// Convert fields array to object
const fieldsObj = {};
for (let i = 0; i < fields.length; i += 2) {
fieldsObj[fields[i]] = fields[i + 1];
}
// Create event
const event = {
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
*/
getStreamName(eventType) {
// 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
*/
getEventTypeFromStream(streamName) {
// Remove 'event:' prefix and replace colons with dots
return streamName.replace(/^event:/, '').replace(/:/g, '.');
}
/**
* Wait for Redis connection
*/
async waitForConnection() {
if (!this.client) {
throw new errors_1.ConnectionError('Redis client not initialized');
}
// Check if already connected
if (this.client.status === 'ready') {
this.connected = true;
return;
}
// Wait for connection
await new Promise((resolve, reject) => {
if (!this.client) {
reject(new errors_1.ConnectionError('Redis client not initialized'));
return;
}
// Set timeout
const timeout = setTimeout(() => {
reject(new errors_1.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 errors_1.ConnectionError(`Redis connection error: ${error.message}`));
});
});
}
}
exports.RedisAdapter = RedisAdapter;
/**
* Create a new Redis adapter
*
* @returns Redis adapter
*/
function createRedisAdapter() {
return new RedisAdapter();
}
exports.createRedisAdapter = createRedisAdapter;
//# sourceMappingURL=redis-adapter.js.map