UNPKG

@flowlab/all

Version:

A cool library focusing on handling various flows

232 lines (197 loc) 12.3 kB
// src/streaming/redisStreamAdapter.ts import { Redis as IORedisClient, RedisOptions } from 'ioredis'; import { IStreamSourceAdapter } from './interfaces'; import { IStreamProcessor, PipelineContext } from '../core/interfaces'; import { Logger } from 'pino'; import { ConfigurationError, PipelineRunError } from '../core/errors'; // Define expected config structure for Redis Streams interface RedisStreamAdapterConfig { connectionOptions: RedisOptions; // ioredis connection options streamKey: string; groupName: string; consumerName: string; blockMs?: number; // BLOCK timeout in ms for XREADGROUP count?: number; // COUNT for XREADGROUP // Option for auto-claiming pending messages? claimMinIdleTimeMs?: number; autoCreateGroup?: boolean; // Attempt to create consumer group if it doesn't exist } // Helper to parse Redis Stream message format [messageId, [key1, value1, key2, value2, ...]] function parseRedisStreamMessage(rawMessage: [string, string[]]): { id: string; data: Record<string, string> } | null { if (!rawMessage || rawMessage.length < 2) return null; const id = rawMessage[0]; const rawData = rawMessage[1]; const data: Record<string, string> = {}; if (rawData && rawData.length % 2 === 0) { for (let i = 0; i < rawData.length; i += 2) { data[rawData[i]] = rawData[i + 1]; } } return { id, data }; } export class RedisStreamAdapter implements IStreamSourceAdapter { // Use separate clients: one for blocking reads, one for other commands (like ack, claim) private blockingClient: IORedisClient | null = null; private commandClient: IORedisClient | null = null; private logger: Logger = {} as Logger; private config: RedisStreamAdapterConfig = {} as RedisStreamAdapterConfig; private processor: IStreamProcessor<ReturnType<typeof parseRedisStreamMessage>, any> | null = null; private pipelineContext: PipelineContext | null = null; private isConsuming: boolean = false; private shouldStop: boolean = false; // Flag to signal the read loop to exit async initialize(config: any, logger: Logger): Promise<void> { this.logger = logger.child({ adapter: 'RedisStreamAdapter' }); this.config = config as RedisStreamAdapterConfig; // Add validation if (!this.config.connectionOptions || !this.config.streamKey || !this.config.groupName || !this.config.consumerName) { throw new ConfigurationError('RedisStreamAdapter requires "connectionOptions", "streamKey", "groupName", and "consumerName".'); } this.config.blockMs = this.config.blockMs ?? 5000; // Default block 5s this.config.count = this.config.count ?? 10; // Default count 10 this.config.autoCreateGroup = this.config.autoCreateGroup !== false; // Default true this.logger.info({ stream: this.config.streamKey, group: this.config.groupName }, 'Initializing Redis Stream adapter...'); try { // Blocking client might need different options if applicable (e.g., readOnly replicas) this.blockingClient = new IORedisClient(this.config.connectionOptions); this.commandClient = new IORedisClient(this.config.connectionOptions); // Add error listeners this.blockingClient.on('error', (err) => this.logger.error({ err }, 'Redis blocking client error')); this.commandClient.on('error', (err) => this.logger.error({ err }, 'Redis command client error')); await Promise.all([this.blockingClient.ping(), this.commandClient.ping()]); // Test connection // Optionally create consumer group if it doesn't exist if (this.config.autoCreateGroup) { await this.createGroupIfNeeded(); } this.logger.info('Redis clients connected and group checked/created.'); } catch (error: any) { this.logger.error({ err: error }, 'Failed to initialize Redis clients or check group.'); await this.cleanupClients(); // Ensure clients are closed on init failure throw new ConfigurationError('Failed to initialize Redis Streams', undefined, error.message); } } private async createGroupIfNeeded(): Promise<void> { if (!this.commandClient) return; try { // MKSTREAM creates stream+group, ignores error if group exists await this.commandClient.xgroup('CREATE', this.config.streamKey, this.config.groupName, '$', 'MKSTREAM'); this.logger.info(`Ensured Redis consumer group "<span class="math-inline">\{this\.config\.groupName\}" exists for stream "</span>{this.config.streamKey}".`); } catch (error: any) { // Ignore 'BUSYGROUP Consumer Group name already exists' error if (error.message.includes('BUSYGROUP')) { this.logger.info(`Redis consumer group "${this.config.groupName}" already exists.`); } else { this.logger.error({ err: error }, `Failed to create Redis consumer group "${this.config.groupName}".`); throw error; // Rethrow other errors } } } async consume(processor: IStreamProcessor<ReturnType<typeof parseRedisStreamMessage>, any>, context: PipelineContext): Promise<void> { if (!this.blockingClient || !this.commandClient) { throw new PipelineRunError('Redis clients not initialized.', context?.pipelineId, context?.runId); } if (this.isConsuming) { this.logger.warn('Redis Stream adapter is already consuming.'); return; } this.processor = processor; this.pipelineContext = context; this.isConsuming = true; this.shouldStop = false; this.logger.info({ stream: this.config.streamKey, group: this.config.groupName, consumer: this.config.consumerName }, 'Starting Redis Stream consumption...'); // Read loop while (this.isConsuming && !this.shouldStop) { try { // 1. TODO: Optionally claim old pending messages from other consumers // await this.claimPendingMessages(); // 2. Read new messages for this consumer // '>' ID means only new messages never delivered to any consumer in this group const response = await this.blockingClient.xreadgroup( 'GROUP', this.config.groupName, this.config.consumerName, 'COUNT', this.config.count!, 'BLOCK', this.config.blockMs!, 'STREAMS', this.config.streamKey, '>' // Read new messages ); // Check response format: [[streamName, [[messageId, [data]], ...]]] or null on timeout/empty if (!response || response.length === 0 || !response[0] || response[0].length < 2) { // Block timeout expired, no new messages this.logger.trace('XREADGROUP timed out or returned no new messages.'); continue; // Loop again } const messages = response[0][1]; // Array of [messageId, [data]] this.logger.debug(`Received ${messages.length} messages from stream ${this.config.streamKey}`); for (const rawMessage of messages) { if (this.shouldStop) break; // Check stop flag between messages const parsedMessage = parseRedisStreamMessage(rawMessage); if (!parsedMessage) { this.logger.warn({ rawMessage }, 'Failed to parse Redis stream message.'); continue; } const childLogger = this.pipelineContext.logger.child({ stream: this.config.streamKey, messageId: parsedMessage.id }); const messageContext = { ...this.pipelineContext, logger: childLogger }; try { const result = await this.processor.process(parsedMessage, messageContext); // TODO: Handle result if batching is needed // Acknowledge the message await this.commandClient.xack(this.config.streamKey, this.config.groupName, parsedMessage.id); childLogger.trace({ messageId: parsedMessage.id }, 'Message processed and acknowledged.'); } catch (processError: any) { childLogger.error({ err: processError, messageId: parsedMessage.id }, 'Error processing Redis stream message.'); if (this.processor.onError) { try { await this.processor.onError(processError, parsedMessage, messageContext); } catch (onErrorError) { childLogger.error({ err: onErrorError }, 'Error in processor.onError handler itself.'); } } // Decide if we should ACK on error? Probably not. Let it be re-delivered or claimed. // Should we stop consumption on processing error? // For now, continue processing other messages in the batch. } } // end for loop messages } catch (error: any) { // Handle potential client connection errors or command errors this.logger.error({ err: error }, 'Error during Redis Stream XREADGROUP loop.'); if (!this.isRedisConnected()) { this.logger.error('Redis connection lost. Stopping consumption.'); this.isConsuming = false; // Stop the loop // Propagate error to allow StreamManager to handle restart/failure? throw new PipelineRunError('Redis connection lost during consumption', context?.pipelineId, context?.runId, error); } else { // Transient error? Log and continue after a short delay? this.logger.warn('Recoverable error in XREADGROUP loop, pausing before retry...'); await new Promise(resolve => setTimeout(resolve, 1000)); // 1s delay } } } // end while loop this.logger.info('Redis Stream consumption loop finished.'); this.isConsuming = false; // Ensure state is updated } // TODO: Implement claiming logic if needed // private async claimPendingMessages(): Promise<void> { ... } private isRedisConnected(): boolean { return (this.blockingClient?.status === 'ready' || this.blockingClient?.status === 'connect') && (this.commandClient?.status === 'ready' || this.commandClient?.status === 'connect'); } private async cleanupClients(): Promise<void> { const promises: Promise<any>[] = []; if (this.blockingClient) promises.push(this.blockingClient.quit().catch(e => this.logger.warn({err: e}, "Error quitting blocking Redis client"))); if (this.commandClient) promises.push(this.commandClient.quit().catch(e => this.logger.warn({err: e}, "Error quitting command Redis client"))); await Promise.allSettled(promises); this.blockingClient = null; this.commandClient = null; } async shutdown(): Promise<void> { if (!this.isConsuming && !this.blockingClient && !this.commandClient) { this.logger.warn('Redis Stream adapter already stopped or not initialized.'); return; } this.logger.info('Shutting down Redis Stream adapter...'); this.shouldStop = true; // Signal the consume loop to stop this.isConsuming = false; // Prevent new iterations // Give the loop a moment to exit gracefully if blocked await new Promise(resolve => setTimeout(resolve, 100)); await this.cleanupClients(); this.processor = null; this.pipelineContext = null; this.logger.info('Redis Stream adapter shutdown complete.'); } }