10 Commits

Author SHA1 Message Date
GitHub Actions
8a378b7a08 Bumping drift-common to 804744ee3dbdc07d079e7a61a889b5e231a0b607 2026-01-08 20:38:46 +00:00
GitHub Actions
e2c0fca9c6 Bumping drift-common to 8f50911a11fcbafd920c3836f2c194a2017d23c0 2026-01-08 13:09:47 +00:00
GitHub Actions
b165870c74 Bumping drift-common to 855367527dbfa07faaae762ba580377c21aeba49 2026-01-08 02:16:34 +00:00
GitHub Actions
cd7dba87e3 Bumping drift-common to 1da0ea6262fb9a025fdb1e2b3a5671b9c3878e6b 2026-01-07 23:17:23 +00:00
GitHub Actions
6869a6a7b7 Bumping drift-common to d7ddb0e19542006f2ad16e2c7650e093759476cd 2026-01-07 22:45:50 +00:00
GitHub Actions
880d2c7710 Bumping drift-common to 590c428c3c6dbc115881e75ff15aa1d8951ab97a 2026-01-07 21:37:09 +00:00
GitHub Actions
9e29acf819 Bumping drift-common to e49dd2283de594ced2fae1ad95b098146d62ce9f 2026-01-07 18:53:10 +00:00
wphan
f5bb616f68 Merge pull request #573 from drift-labs/wphan/reliable_ws_switchover
add ws redis switchover with hysteresis
2026-01-06 11:43:00 -08:00
wphan
1e8f06a3e4 add ws redis switchover with hysteresis 2026-01-06 11:41:52 -08:00
GitHub Actions
6a744743ef Bumping drift-common to ea51f5ea27821a8352dddcb20e97e4fec6955821 2026-01-06 18:52:40 +00:00
2 changed files with 209 additions and 7 deletions

View File

@@ -6,7 +6,161 @@ import { WebSocket, WebSocketServer } from 'ws';
import { sleep, selectMostRecentBySlot, GROUPING_OPTIONS } from './utils/utils';
import { DriftEnv, PerpMarkets, SpotMarkets } from '@drift-labs/sdk';
import { RedisClient, RedisClientPrefix } from '@drift/common/clients';
import { Metrics } from './core/metricsV2';
import { Metrics, GaugeValue } from './core/metricsV2';
// Stream selector with hysteresis to prevent flip-flopping between sources
const STREAM_SWITCH_THRESHOLD_MS = 10_000; // 10 seconds
const STREAM_STALE_THRESHOLD_MS = 3_000; // Consider stream stale if no message for 3 seconds
class StreamSelector {
private activeStream: string | null = null;
private streamLastMessageTime: Map<string, number> = new Map();
private streamLastSlot: Map<string, number> = new Map();
private streamLeadingSince: Map<string, number> = new Map();
private streamHealthyGauge: GaugeValue;
private activeStreamGauge: GaugeValue;
private streams: string[];
constructor(
streams: string[],
streamHealthyGauge: GaugeValue,
activeStreamGauge: GaugeValue
) {
this.streams = streams;
this.streamHealthyGauge = streamHealthyGauge;
this.activeStreamGauge = activeStreamGauge;
// Initialize all streams as unhealthy
for (const stream of streams) {
this.streamHealthyGauge.setLatestValue(0, { source: stream });
this.activeStreamGauge.setLatestValue(0, { source: stream });
}
}
// Record a message from a stream and return whether it should be forwarded
recordMessage(stream: string, slot: number): boolean {
const now = Date.now();
this.streamLastMessageTime.set(stream, now);
this.streamLastSlot.set(stream, slot);
this.streamHealthyGauge.setLatestValue(1, { source: stream });
if (this.activeStream === null) {
this.setActiveStream(stream);
return true;
}
// If this is from the active stream, forward it
if (stream === this.activeStream) {
// Reset leading times for other streams since active stream is still producing
for (const s of this.streams) {
if (s !== stream) {
this.streamLeadingSince.delete(s);
}
}
return true;
}
// This message is from a non-active stream
const activeSlot = this.streamLastSlot.get(this.activeStream) || 0;
const activeLastMessage =
this.streamLastMessageTime.get(this.activeStream) || 0;
// Check if active stream is stale
if (now - activeLastMessage > STREAM_STALE_THRESHOLD_MS) {
console.log(
`Active stream ${this.activeStream} is stale (no message for ${
now - activeLastMessage
}ms), switching to ${stream}`
);
this.setActiveStream(stream);
return true;
}
// Check if this stream has a more recent slot
if (slot > activeSlot) {
// Track how long this stream has been leading
if (!this.streamLeadingSince.has(stream)) {
this.streamLeadingSince.set(stream, now);
}
const leadingDuration = now - this.streamLeadingSince.get(stream)!;
if (leadingDuration >= STREAM_SWITCH_THRESHOLD_MS) {
console.log(
`Stream ${stream} has been leading for ${leadingDuration}ms, switching from ${this.activeStream}`
);
this.setActiveStream(stream);
return true;
}
} else {
// This stream is not leading, reset its leading time
this.streamLeadingSince.delete(stream);
}
// Don't forward messages from non-active streams that haven't proven themselves
return false;
}
private setActiveStream(stream: string) {
if (this.activeStream) {
this.activeStreamGauge.setLatestValue(0, { source: this.activeStream });
}
this.activeStream = stream;
this.activeStreamGauge.setLatestValue(1, { source: stream });
this.streamLeadingSince.clear();
console.log(`Active stream set to: ${stream}`);
}
getActiveStream(): string | null {
return this.activeStream;
}
checkHealth() {
const now = Date.now();
let anyHealthy = false;
for (const stream of this.streams) {
const lastMessage = this.streamLastMessageTime.get(stream);
const isHealthy =
lastMessage && now - lastMessage < STREAM_STALE_THRESHOLD_MS;
this.streamHealthyGauge.setLatestValue(isHealthy ? 1 : 0, {
source: stream,
});
if (isHealthy) {
anyHealthy = true;
}
}
// If active stream is no longer healthy, try to switch
if (this.activeStream) {
const activeLastMessage = this.streamLastMessageTime.get(
this.activeStream
);
if (
!activeLastMessage ||
now - activeLastMessage > STREAM_STALE_THRESHOLD_MS
) {
// Find a healthy stream to switch to
for (const stream of this.streams) {
const lastMessage = this.streamLastMessageTime.get(stream);
if (lastMessage && now - lastMessage < STREAM_STALE_THRESHOLD_MS) {
console.log(
`Active stream ${this.activeStream} is unhealthy, switching to ${stream}`
);
this.setActiveStream(stream);
break;
}
}
}
}
return anyHealthy;
}
}
// Set up env constants
require('dotenv').config();
@@ -34,6 +188,14 @@ const wsOrderbookSourceLastSlotGauge = metricsV2.addGauge(
'websocket_orderbook_source_last_slot',
'Last slot of orderbook messages from a source'
);
const wsStreamHealthyGauge = metricsV2.addGauge(
'websocket_stream_healthy',
'Whether a Redis stream source is healthy (1) or not (0). Alert if all sources are 0.'
);
const wsActiveStreamGauge = metricsV2.addGauge(
'websocket_active_stream',
'Whether a Redis stream source is currently active (1) or not (0). Only one source should be active at a time.'
);
metricsV2.finalizeObservables();
const server = http.createServer(app);
@@ -125,6 +287,32 @@ const getRedisChannelFromMessage = (message: any): string => {
async function main() {
const subscribedChannelToSlot: Map<string, number> = new Map();
// Create stream selector for managing failover between Redis sources
const streamSelector =
REDIS_CLIENTS.length > 1
? new StreamSelector(
REDIS_CLIENTS,
wsStreamHealthyGauge,
wsActiveStreamGauge
)
: null;
// If only one client, mark it as healthy and active
if (!streamSelector && REDIS_CLIENTS.length === 1) {
wsStreamHealthyGauge.setLatestValue(1, { source: REDIS_CLIENTS[0] });
wsActiveStreamGauge.setLatestValue(1, { source: REDIS_CLIENTS[0] });
}
// Periodic health check for stream selector
if (streamSelector) {
setInterval(() => {
const anyHealthy = streamSelector.checkHealth();
if (!anyHealthy) {
console.error('WARNING: No healthy Redis streams available!');
}
}, 1000);
}
const redisClients: Array<RedisClient> = [];
const lastMessageClients: Array<RedisClient> = [];
for (let i = 0; i < REDIS_CLIENTS.length; i++) {
@@ -157,12 +345,26 @@ async function main() {
source: channelPrefix,
});
const lastMessageSlot = subscribedChannelToSlot.get(sanitizedChannel);
if (!lastMessageSlot || lastMessageSlot <= messageSlot) {
subscribedChannelToSlot.set(sanitizedChannel, messageSlot);
} else if (lastMessageSlot > messageSlot) {
return;
if (streamSelector) {
const shouldForward = streamSelector.recordMessage(
channelPrefix,
messageSlot
);
if (!shouldForward) {
return;
}
} else {
const lastMessageSlot =
subscribedChannelToSlot.get(sanitizedChannel);
if (!lastMessageSlot || lastMessageSlot <= messageSlot) {
subscribedChannelToSlot.set(sanitizedChannel, messageSlot);
} else if (lastMessageSlot > messageSlot) {
return;
}
}
// Update slot tracking for the forwarded message
subscribedChannelToSlot.set(sanitizedChannel, messageSlot);
}
subscribers.forEach((ws) => {
if (