From 486b911bed452cf3157920294fe39afca3e28c5a Mon Sep 17 00:00:00 2001 From: discountry Date: Wed, 12 Nov 2025 23:57:21 +0800 Subject: [PATCH] feat: implement feed staleness monitoring in LighterGateway to enhance connection reliability and update handling --- src/exchanges/lighter/gateway.ts | 77 +++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/src/exchanges/lighter/gateway.ts b/src/exchanges/lighter/gateway.ts index d8095b1..d4deedf 100644 --- a/src/exchanges/lighter/gateway.ts +++ b/src/exchanges/lighter/gateway.ts @@ -120,6 +120,8 @@ const DEFAULT_TICKER_POLL_MS = 3000; const DEFAULT_KLINE_POLL_MS = 15000; const WS_HEARTBEAT_INTERVAL_MS = 5_000; const WS_STALE_TIMEOUT_MS = 20_000; +const FEED_STALE_TIMEOUT_MS = 30_000; +const STALE_CHECK_INTERVAL_MS = 5_000; const ACCOUNT_POLL_INTERVAL_MS = 5_000; const ACCOUNT_HTTP_EMPTY_CONFIRM_MS = 15_000; const POSITION_EPSILON = 1e-12; @@ -207,6 +209,12 @@ export class LighterGateway { private readonly tickerPollMs: number; private readonly klinePollMs: number; + private lastDepthUpdateAt = Date.now(); + private lastOrdersUpdateAt = Date.now(); + private lastAccountUpdateAt = Date.now(); + private lastTickerUpdateAt = Date.now(); + private staleReason: string | null = null; + private staleMonitor: ReturnType | null = null; // Track last applied order book sequence to drop stale WS messages private lastOrderBookOffset: number = 0; @@ -252,6 +260,11 @@ export class LighterGateway { this.klinePollMs = options.klinePollMs ?? DEFAULT_KLINE_POLL_MS; this.l1Address = options.l1Address ?? null; this.logTxInfo = process.env.LIGHTER_LOG_TX === "1" || process.env.LIGHTER_LOG_TX === "true"; + const now = Date.now(); + this.lastDepthUpdateAt = now; + this.lastOrdersUpdateAt = now; + this.lastAccountUpdateAt = now; + this.lastTickerUpdateAt = now; } async ensureInitialized(): Promise { @@ -389,6 +402,7 @@ export class LighterGateway { // orders until there is activity. this.emitOrders(); this.startPolling(); + this.startStaleMonitor(); } private async loadMetadata(): Promise { @@ -602,6 +616,19 @@ export class LighterGateway { }, 2000); } + private forceReconnect(reason: string): void { + if (this.staleReason) return; + this.staleReason = reason; + this.logger("ws:stale", reason); + try { + this.ws?.terminate(); + } catch (error) { + this.logger("ws:terminate", error); + } + this.stopHeartbeat(); + this.scheduleReconnect(); + } + private startHeartbeat(): void { if (this.heartbeatTimer) return; this.heartbeatTimer = setInterval(() => { @@ -688,6 +715,7 @@ export class LighterGateway { this.lastOrderBookOffset = snapshot.offset ?? incomingOffset ?? this.lastOrderBookOffset; this.lastOrderBookTimestamp = incomingTs || Date.now(); this.emitDepth(); + this.markDepthUpdate(); } private handleOrderBookUpdate(message: any): void { @@ -714,6 +742,7 @@ export class LighterGateway { this.lastOrderBookOffset = Number(this.orderBook.offset ?? incomingOffset ?? this.lastOrderBookOffset); this.lastOrderBookTimestamp = incomingTs || Date.now(); this.emitDepth(); + this.markDepthUpdate(); } private handleAccountAll(message: any): void { @@ -916,7 +945,7 @@ export class LighterGateway { } const key = orderIndex ?? clientIndex; if (!key) return; - const status = (order.status ?? "").toLowerCase(); + const status = String(order.status ?? "").toLowerCase(); if (TERMINAL_ORDER_STATUSES.has(status)) { const existing = this.orderMap.get(key); this.orderMap.delete(key); @@ -989,6 +1018,13 @@ export class LighterGateway { this.emitSyntheticTicker(); } + private markDepthUpdate(): void { + this.lastDepthUpdateAt = Date.now(); + if (this.staleReason && this.staleReason.startsWith("depth")) { + this.staleReason = null; + } + } + private emitAccount(): void { if (!this.accountDetails) return; const snapshot = toAccountSnapshot( @@ -999,11 +1035,19 @@ export class LighterGateway { { marketSymbol: this.marketSymbol, marketId: this.marketId } ); this.accountEvent.emit(snapshot); + this.lastAccountUpdateAt = Date.now(); + if (this.staleReason && this.staleReason.startsWith("account")) { + this.staleReason = null; + } } private emitOrders(): void { const mapped = toOrders(this.displaySymbol, this.orders ?? []); this.ordersEvent.emit(mapped); + this.lastOrdersUpdateAt = Date.now(); + if (this.staleReason && this.staleReason.startsWith("orders")) { + this.staleReason = null; + } } private resolveOrderIndex(orderId: string): string { @@ -1077,6 +1121,33 @@ export class LighterGateway { } } + private startStaleMonitor(): void { + if (this.staleMonitor) return; + this.staleMonitor = setInterval(() => this.checkFeedStaleness(), STALE_CHECK_INTERVAL_MS); + } + + private stopStaleMonitor(): void { + if (!this.staleMonitor) return; + clearInterval(this.staleMonitor); + this.staleMonitor = null; + } + + private checkFeedStaleness(): void { + if (this.staleReason) return; + const now = Date.now(); + if (now - this.lastDepthUpdateAt > FEED_STALE_TIMEOUT_MS) { + this.forceReconnect("depth stale"); + return; + } + if (now - this.lastOrdersUpdateAt > FEED_STALE_TIMEOUT_MS) { + this.forceReconnect("orders stale"); + return; + } + if (now - this.lastAccountUpdateAt > FEED_STALE_TIMEOUT_MS * 2) { + this.forceReconnect("account stale"); + } + } + private async refreshTicker(): Promise { try { const stats = await this.http.getExchangeStats(); @@ -1089,6 +1160,10 @@ export class LighterGateway { const ticker = toTicker(this.displaySymbol, match); this.tickerEvent.emit(ticker); this.loggedCreateOrderPayload = false; + this.lastTickerUpdateAt = Date.now(); + if (this.staleReason && this.staleReason.startsWith("ticker")) { + this.staleReason = null; + } } catch (error) { this.logger("refreshTicker", error); }