Skip to content

Commit fe67239

Browse files
authored
Merge pull request #73 from usherlabs/fix/ohlcv-subscribe-watch-stream
Fix OHLCV subscriptions: stream via watchOHLCV and guard bar re-emits
2 parents e5048b2 + b98981d commit fe67239

6 files changed

Lines changed: 102 additions & 17 deletions

File tree

src/handlers/subscribe/handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -679,7 +679,7 @@ export function createSubscribeHandler(deps: SubscribeDeps) {
679679
}
680680
}
681681
while (ohlcvStreamActive && !isStreamClosed()) {
682-
const data = await broker.fetchOHLCVWs(resolvedSymbol, timeframe);
682+
const data = await broker.watchOHLCV(resolvedSymbol, timeframe);
683683
const receivedTimestamp = Date.now();
684684
if (
685685
!(await writeSubscribeFrame(call, isStreamClosed, {

src/helpers/broker-execution-archive/writer.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const DEFAULT_MAX_QUEUE_SIZE = 10_000;
4141
const DEFAULT_BATCH_SIZE = 10;
4242
const DEFAULT_FLUSH_INTERVAL_MS = 1_000;
4343
const DEFAULT_FORWARDER_TIMEOUT_MS = 3_000;
44+
const SHED_WARN_INTERVAL_MS = 60_000;
4445
const DEFAULT_ARCHIVE_FORWARDER_PATH = "/archive";
4546
const DEFAULT_ARCHIVE_FORWARDER_PORT = 8090;
4647

@@ -95,6 +96,7 @@ export class BrokerExecutionArchiver {
9596
};
9697
private flushTimer: ReturnType<typeof setInterval> | null = null;
9798
private flushInFlight: Promise<void> | null = null;
99+
private lastShedWarnAtMs = 0;
98100
private closed = false;
99101
private loggedMissingMarketForwarder = false;
100102
private readonly enabled: boolean;
@@ -197,6 +199,17 @@ export class BrokerExecutionArchiver {
197199
void this.recordArchiveMetric("cex_archive_rows_shed_total", {
198200
table: row.table,
199201
});
202+
// Shedding means silent archive data loss; the metric alone is invisible
203+
// when OTel is disabled, so surface it in logs (rate-limited per row burst).
204+
const now = Date.now();
205+
if (now - this.lastShedWarnAtMs >= SHED_WARN_INTERVAL_MS) {
206+
log.warn("Archive queue full: shedding oldest rows", {
207+
shed_total: this.stats.shed,
208+
queue_max: this.maxQueueSize,
209+
table: row.table,
210+
});
211+
this.lastShedWarnAtMs = now;
212+
}
200213
}
201214
this.queue.push(row);
202215
this.stats.enqueued += 1;

src/helpers/market-data-archive/ohlcv-bar-tracker.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -116,21 +116,27 @@ export class OhlcvBarTracker {
116116
if (!firstBar || !lastBar) {
117117
return [];
118118
}
119+
const lastOpenTimeMs = this.lastOpenTimeMs;
119120

120-
if (
121-
this.lastOpenTimeMs !== null &&
122-
lastBar.openTimeMs < this.lastOpenTimeMs
123-
) {
121+
if (lastOpenTimeMs !== null && lastBar.openTimeMs < lastOpenTimeMs) {
122+
return [];
123+
}
124+
const barsToProcess =
125+
lastOpenTimeMs === null
126+
? bars
127+
: bars.filter((bar) => bar.openTimeMs >= lastOpenTimeMs);
128+
const firstBarToProcess = barsToProcess[0];
129+
const lastBarToProcess = barsToProcess[barsToProcess.length - 1];
130+
if (!firstBarToProcess || !lastBarToProcess) {
124131
return [];
125132
}
126133

127134
const candidates: OhlcvArchiveCandidate[] = [];
128135

129136
if (
130137
this.lastBar !== null &&
131-
this.lastOpenTimeMs !== null &&
132-
!bars.some((bar) => bar.openTimeMs === this.lastOpenTimeMs) &&
133-
this.lastOpenTimeMs < firstBar.openTimeMs
138+
lastOpenTimeMs !== null &&
139+
lastOpenTimeMs < firstBarToProcess.openTimeMs
134140
) {
135141
candidates.push({
136142
bar: this.lastBar,
@@ -139,8 +145,8 @@ export class OhlcvBarTracker {
139145
});
140146
}
141147

142-
for (let index = 0; index < bars.length - 1; index += 1) {
143-
const bar = bars[index];
148+
for (let index = 0; index < barsToProcess.length - 1; index += 1) {
149+
const bar = barsToProcess[index];
144150
if (bar) {
145151
candidates.push({
146152
bar,
@@ -151,13 +157,13 @@ export class OhlcvBarTracker {
151157
}
152158

153159
candidates.push({
154-
bar: lastBar,
160+
bar: lastBarToProcess,
155161
isClosed: false,
156162
brokerVersion,
157163
});
158164

159-
this.lastOpenTimeMs = lastBar.openTimeMs;
160-
this.lastBar = lastBar;
165+
this.lastOpenTimeMs = lastBarToProcess.openTimeMs;
166+
this.lastBar = lastBarToProcess;
161167
return candidates;
162168
}
163169
}

test/fixtures/ohlcv-collector-fake-exchange.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ class ShutdownTestExchange {
1010

1111
extendExchangeOptions(): void {}
1212

13-
async fetchOHLCVWs(): Promise<number[][]> {
13+
async watchOHLCV(): Promise<number[][]> {
1414
this.#fetchCount += 1;
1515
const countPath = process.env.OHLCV_TEST_EXCHANGE_COUNT_PATH;
1616
if (countPath) {

test/market-data-archive.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,15 @@ describe("orderbook sampler", () => {
205205
});
206206

207207
describe("ohlcv bar tracker", () => {
208+
const snapshot = Array.from({ length: 500 }, (_, index) => [
209+
1_700_000_000_000 + index * 60_000,
210+
1,
211+
2,
212+
0.5,
213+
1.5,
214+
10,
215+
]);
216+
208217
test("parseOhlcvBar accepts CCXT tuple shape", () => {
209218
expect(parseOhlcvBar([1_000, 1, 2, 0.5, 1.5, 10, 15])).toEqual({
210219
openTimeMs: 1_000,
@@ -280,6 +289,63 @@ describe("ohlcv bar tracker", () => {
280289
},
281290
]);
282291
});
292+
293+
test("preserves all bars in the first batch and leaves the newest open", () => {
294+
const tracker = new OhlcvBarTracker();
295+
296+
const candidates = tracker.process(snapshot, 100);
297+
298+
expect(candidates).toHaveLength(500);
299+
expect(
300+
candidates.map(({ bar, isClosed }) => ({
301+
openTimeMs: bar.openTimeMs,
302+
isClosed,
303+
})),
304+
).toEqual(
305+
snapshot.map(([openTimeMs], index) => ({
306+
openTimeMs,
307+
isClosed: index < snapshot.length - 1,
308+
})),
309+
);
310+
});
311+
312+
test("repeated snapshot only re-emits the open bar update", () => {
313+
const tracker = new OhlcvBarTracker();
314+
tracker.process(snapshot, 100);
315+
316+
const candidates = tracker.process(snapshot, 200);
317+
318+
expect(candidates).toHaveLength(1);
319+
expect(candidates[0]).toMatchObject({
320+
bar: { openTimeMs: snapshot.at(-1)?.[0] },
321+
isClosed: false,
322+
brokerVersion: 200,
323+
});
324+
});
325+
326+
test("overlapping snapshot closes the previous open bar and emits the new open bar", () => {
327+
const tracker = new OhlcvBarTracker();
328+
tracker.process(snapshot, 100);
329+
const previousOpenTimeMs = snapshot.at(-1)?.[0] ?? 0;
330+
const nextOpenTimeMs = previousOpenTimeMs + 60_000;
331+
const overlappingSnapshot = [
332+
...snapshot.slice(1),
333+
[nextOpenTimeMs, 1.5, 2.5, 1, 2, 12],
334+
];
335+
336+
const candidates = tracker.process(overlappingSnapshot, 200);
337+
338+
expect(
339+
candidates.map(({ bar, isClosed, brokerVersion }) => ({
340+
openTimeMs: bar.openTimeMs,
341+
isClosed,
342+
brokerVersion,
343+
})),
344+
).toEqual([
345+
{ openTimeMs: previousOpenTimeMs, isClosed: true, brokerVersion: 200 },
346+
{ openTimeMs: nextOpenTimeMs, isClosed: false, brokerVersion: 200 },
347+
]);
348+
});
283349
});
284350

285351
describe("ohlcv bootstrap limit", () => {

test/subscribe-handler.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ type ThrowingSubscriptionMethod =
172172
| "watchOrderBook"
173173
| "watchTrades"
174174
| "watchTicker"
175-
| "fetchOHLCVWs"
175+
| "watchOHLCV"
176176
| "watchBalance"
177177
| "watchOrders";
178178

@@ -267,7 +267,7 @@ describe("subscribe handler", () => {
267267
},
268268
{
269269
type: SubscriptionType.OHLCV,
270-
method: "fetchOHLCVWs",
270+
method: "watchOHLCV",
271271
errorMessage: "ohlcv boom",
272272
expectedError: "Failed to fetch OHLCV: ohlcv boom",
273273
},
@@ -456,7 +456,7 @@ describe("subscribe handler", () => {
456456
try {
457457
const controlledWatch = createControlledWatch();
458458
const exchange = {
459-
fetchOHLCVWs: controlledWatch.watch,
459+
watchOHLCV: controlledWatch.watch,
460460
} as unknown as Exchange;
461461
const archiver = BrokerExecutionArchiver.create({
462462
forwarderUrl: server.url,

0 commit comments

Comments
 (0)