// ZeroMQ client for connecting to Flink control channels import * as zmq from 'zeromq'; import { decodeMessage } from './proto/messages.js'; export class ZmqClient { constructor(config, logger) { this.config = config; this.logger = logger; // Work queue - SUB socket to receive data requests with exchange prefix filtering this.workSocket = null; // NOTE: NO RESPONSE SOCKET - Async architecture via Kafka! // Ingestors write data to Kafka only // Flink processes and publishes notifications this.isShutdown = false; this.supportedExchanges = config.supported_exchanges || ['BINANCE', 'COINBASE']; } /** * Connect to Relay ZMQ endpoints */ async connect() { const { flink_hostname, ingestor_work_port } = this.config; // Connect to work queue (SUB with exchange prefix filtering) this.workSocket = new zmq.Subscriber(); const workEndpoint = `tcp://${flink_hostname}:${ingestor_work_port}`; await this.workSocket.connect(workEndpoint); // Subscribe to each supported exchange suffix (Nautilus format: "BTC/USDT.BINANCE") for (const exchange of this.supportedExchanges) { const prefix = `${exchange}.`; this.workSocket.subscribe(prefix); this.logger.info(`Subscribed to exchange prefix: ${prefix}`); } this.logger.info(`Connected to work queue at ${workEndpoint}`); this.logger.info('ASYNC MODE: No response socket - data flows via Kafka → Flink → pub/sub notification'); } /** * Pull a data request from the work queue * @returns {Promise} Decoded DataRequest message */ async pullDataRequest() { if (this.isShutdown) { return null; } try { const frames = await this.workSocket.receive(); this.logger.info({ frameCount: frames.length, frame0Len: frames[0]?.length, frame1Len: frames[1]?.length, frame2Len: frames[2]?.length, frame0: frames[0]?.toString('utf8').substring(0, 50), frame1Hex: frames[1]?.toString('hex').substring(0, 20), frame2Hex: frames[2]?.toString('hex').substring(0, 20) }, 'Received raw ZMQ frames'); // First frame is the topic (exchange prefix), skip it // Remaining frames are: [version_frame, message_frame] if (frames.length < 3) { this.logger.warn({ frameCount: frames.length }, 'Unexpected frame count'); return null; } const messageFrames = frames.slice(1); // Skip topic, keep version + message const { version, typeId, message } = decodeMessage(messageFrames); this.logger.info({ version, typeId: `0x${typeId.toString(16)}`, requestId: message.requestId, type: message.type, typeOf: typeof message.type, ticker: message.ticker }, 'Decoded data request'); return message; } catch (error) { if (!this.isShutdown) { this.logger.error({ error: error.message, stack: error.stack }, 'Error receiving data request'); } return null; } } /** * Start listening for control messages in the background * @param {Function} handler - Callback function to handle control messages * * NOTE: Control channel not implemented yet. This is a stub for future use. * For now, just log and ignore. */ startControlListener(handler) { this.logger.info('Control channel listener stub - not implemented yet'); // TODO: Implement control channel when needed // Control messages would be used for: // - Canceling realtime subscriptions // - Graceful shutdown signals // - Configuration updates } /** * Shutdown and close connections */ async shutdown() { this.isShutdown = true; this.logger.info('Shutting down ZMQ connections'); if (this.workSocket) { await this.workSocket.close(); } } }