Files
ai/ingestor/src/realtime-poller.js

189 lines
6.0 KiB
JavaScript

// Realtime tick data poller — polls exchange every 10s, writes ticks to market-tick Kafka topic.
// Heartbeats every 5s so Flink IngestorBroker knows the job is alive.
export class RealtimePoller {
constructor(ccxtFetcher, kafkaProducer, zmqClient, logger) {
this.ccxtFetcher = ccxtFetcher;
this.kafkaProducer = kafkaProducer;
this.zmqClient = zmqClient;
this.logger = logger;
// Active subscriptions: jobId -> subscription info
this.subscriptions = new Map();
// Poll interval in milliseconds (10 seconds)
this.pollInterval = 10000;
// Heartbeat interval (5 seconds)
this.heartbeatInterval = 5000;
this.pollingLoop = null;
this.heartbeatLoop = null;
// Called with (jobId, error) when a subscription terminates abnormally.
// Set by IngestorWorker to release the slot in SlotPool.
this.onJobComplete = null;
}
/**
* Start a realtime subscription for a job dispatched by IngestorBroker.
* @param {string} jobId - Broker-assigned job ID (for heartbeats and COMPLETE)
* @param {string} requestId - Original request ID (for metadata)
* @param {string} ticker - Ticker to subscribe to
* @param {string} kafkaTopic - Kafka topic to write ticks to (market-tick)
*/
startSubscription(jobId, requestId, ticker, kafkaTopic) {
if (this.subscriptions.has(jobId)) {
this.logger.warn({ jobId }, 'Subscription already exists');
return;
}
const subscription = {
jobId,
requestId,
ticker,
kafkaTopic,
lastTimestamp: null,
isActive: true,
errorCount: 0
};
this.subscriptions.set(jobId, subscription);
this.logger.info({ jobId, requestId, ticker, kafkaTopic }, 'Started realtime subscription');
if (!this.pollingLoop) {
this.startPollingLoop();
}
if (!this.heartbeatLoop) {
this.startHeartbeatLoop();
}
}
/**
* Stop a realtime subscription. Called when Flink sends WorkStop or on error.
* Does NOT send WorkComplete — caller is responsible for that.
*/
cancelSubscription(jobId) {
const subscription = this.subscriptions.get(jobId);
if (subscription) {
subscription.isActive = false;
this.subscriptions.delete(jobId);
this.logger.info({ jobId, ticker: subscription.ticker }, 'Cancelled realtime subscription');
}
if (this.subscriptions.size === 0) {
if (this.pollingLoop) {
clearInterval(this.pollingLoop);
this.pollingLoop = null;
}
if (this.heartbeatLoop) {
clearInterval(this.heartbeatLoop);
this.heartbeatLoop = null;
}
this.logger.info('Stopped polling/heartbeat loops — no active subscriptions');
}
}
startPollingLoop() {
this.logger.info({ interval: this.pollInterval }, 'Starting polling loop');
this.pollingLoop = setInterval(() => this.pollAllSubscriptions(), this.pollInterval);
// Immediate first poll
this.pollAllSubscriptions();
}
startHeartbeatLoop() {
this.logger.info({ interval: this.heartbeatInterval }, 'Starting heartbeat loop');
this.heartbeatLoop = setInterval(async () => {
for (const { jobId } of this.subscriptions.values()) {
try {
await this.zmqClient.sendHeartbeat(jobId);
} catch (err) {
this.logger.error({ jobId, error: err.message }, 'Failed to send heartbeat');
}
}
}, this.heartbeatInterval);
}
async pollAllSubscriptions() {
const subscriptions = Array.from(this.subscriptions.values());
await Promise.allSettled(subscriptions.map(sub => this.pollSubscription(sub)));
}
async pollSubscription(subscription) {
if (!subscription.isActive) return;
const { jobId, requestId, ticker, kafkaTopic, lastTimestamp } = subscription;
try {
const trades = await this.ccxtFetcher.fetchRecentTrades(ticker, lastTimestamp);
if (trades.length === 0) {
this.logger.debug({ jobId, ticker }, 'No new trades');
return;
}
// Skip trades we've already seen (timestamp-based dedup)
let newTrades = trades;
if (lastTimestamp) {
const lastTs = BigInt(lastTimestamp);
newTrades = trades.filter(t => BigInt(t.timestamp) > lastTs);
}
if (newTrades.length > 0) {
await this.kafkaProducer.writeTicks(kafkaTopic, newTrades);
subscription.lastTimestamp = newTrades[newTrades.length - 1].timestamp;
this.logger.info({ jobId, ticker, count: newTrades.length, kafkaTopic }, 'Wrote ticks to Kafka');
}
subscription.errorCount = 0;
} catch (error) {
subscription.errorCount++;
this.logger.error(
{ error: error.message, jobId, ticker, errorCount: subscription.errorCount },
'Error polling subscription'
);
// After 5 consecutive errors, give up and notify Flink
if (subscription.errorCount >= 5) {
this.logger.error({ jobId, ticker }, 'Cancelling subscription due to repeated errors');
this.cancelSubscription(jobId);
try {
await this.zmqClient.sendComplete(jobId, false, `Polling failed after 5 errors: ${error.message}`);
} catch (zmqErr) {
this.logger.error({ jobId, error: zmqErr.message }, 'Failed to send WorkComplete after error');
}
if (this.onJobComplete) this.onJobComplete(jobId, error);
}
}
}
getStats() {
return {
totalSubscriptions: this.subscriptions.size,
subscriptions: Array.from(this.subscriptions.values()).map(sub => ({
jobId: sub.jobId,
requestId: sub.requestId,
ticker: sub.ticker,
isActive: sub.isActive,
errorCount: sub.errorCount,
lastTimestamp: sub.lastTimestamp
}))
};
}
shutdown() {
this.logger.info('Shutting down realtime poller');
if (this.pollingLoop) {
clearInterval(this.pollingLoop);
this.pollingLoop = null;
}
if (this.heartbeatLoop) {
clearInterval(this.heartbeatLoop);
this.heartbeatLoop = null;
}
for (const subscription of this.subscriptions.values()) {
subscription.isActive = false;
}
this.subscriptions.clear();
}
}