Skip to content

Commit 8b4f8d3

Browse files
committed
Keep market subscriptions alive until cancellation
1 parent 5711641 commit 8b4f8d3

4 files changed

Lines changed: 78 additions & 8 deletions

File tree

src/handlers/subscribe/handler.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,8 @@ export function createSubscribeHandler(deps: SubscribeDeps) {
320320
const markStreamClosed = () => {
321321
streamClosed = true;
322322
};
323-
const isStreamClosed = () => streamClosed || call.destroyed;
323+
const isStreamClosed = () =>
324+
streamClosed || call.cancelled || call.writableEnded;
324325
const closeOwnedBroker = (): Promise<void> => {
325326
if (ownedBrokerClosePromise) {
326327
return ownedBrokerClosePromise;
@@ -337,9 +338,7 @@ export function createSubscribeHandler(deps: SubscribeDeps) {
337338
void closeOwnedBroker();
338339
};
339340

340-
call.once("close", markStreamClosed);
341341
call.once("cancelled", markStreamClosed);
342-
call.once("close", closeOwnedBrokerOnCallEnd);
343342
call.once("cancelled", closeOwnedBrokerOnCallEnd);
344343
call.once("error", closeOwnedBrokerOnCallEnd);
345344
call.once("end", () => {
@@ -800,7 +799,6 @@ export function createSubscribeHandler(deps: SubscribeDeps) {
800799
type: subscriptionType,
801800
});
802801
} finally {
803-
call.off("close", closeOwnedBrokerOnCallEnd);
804802
call.off("cancelled", closeOwnedBrokerOnCallEnd);
805803
call.off("error", closeOwnedBrokerOnCallEnd);
806804
await closeOwnedBroker();

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,16 @@ class ShutdownTestExchange {
66
enableRateLimit = false;
77
timeout = 0;
88
#keepAlive: ReturnType<typeof setInterval> | undefined;
9+
#fetchCount = 0;
910

1011
extendExchangeOptions(): void {}
1112

1213
async fetchOHLCVWs(): Promise<number[][]> {
14+
this.#fetchCount += 1;
15+
const countPath = process.env.OHLCV_TEST_EXCHANGE_COUNT_PATH;
16+
if (countPath) {
17+
await Bun.write(countPath, String(this.#fetchCount));
18+
}
1319
if (!this.#keepAlive) {
1420
this.#keepAlive = setInterval(() => {}, 1_000);
1521
const activePath = process.env.OHLCV_TEST_EXCHANGE_ACTIVE_PATH;

test/ohlcv-collector-shutdown.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,33 @@ async function waitForFile(filePath: string): Promise<void> {
1111
throw new Error(`Timed out waiting for ${filePath}`);
1212
}
1313

14+
async function waitForFetchCount(
15+
filePath: string,
16+
minimum: number,
17+
): Promise<number> {
18+
for (let attempt = 0; attempt < 200; attempt += 1) {
19+
if (await Bun.file(filePath).exists()) {
20+
const count = Number(await Bun.file(filePath).text());
21+
if (count >= minimum) {
22+
return count;
23+
}
24+
}
25+
await Bun.sleep(10);
26+
}
27+
throw new Error(`Timed out waiting for ${minimum} fetches in ${filePath}`);
28+
}
29+
1430
async function runShutdownCase(exchangeCloseHangs: boolean): Promise<{
1531
exitCode: number;
1632
closeMarker: string;
33+
countBeforeShutdown: number;
34+
countAtShutdown: number;
1735
output: string;
1836
}> {
1937
const fixtureId = crypto.randomUUID();
2038
const configPath = `/tmp/ohlcv-shutdown-${fixtureId}.json`;
2139
const activePath = `/tmp/ohlcv-shutdown-${fixtureId}.active`;
40+
const countPath = `/tmp/ohlcv-shutdown-${fixtureId}.count`;
2241
const closedPath = `/tmp/ohlcv-shutdown-${fixtureId}.closed`;
2342
await Bun.write(
2443
configPath,
@@ -38,6 +57,7 @@ async function runShutdownCase(exchangeCloseHangs: boolean): Promise<{
3857
CEX_BROKER_OHLCV_COLLECTOR_CONFIG: configPath,
3958
CEX_BROKER_OHLCV_ARCHIVE_BOOTSTRAP_LIMIT: "0",
4059
OHLCV_TEST_EXCHANGE_ACTIVE_PATH: activePath,
60+
OHLCV_TEST_EXCHANGE_COUNT_PATH: countPath,
4161
OHLCV_TEST_EXCHANGE_CLOSED_PATH: closedPath,
4262
OHLCV_TEST_EXCHANGE_CLOSE_HANG: String(exchangeCloseHangs),
4363
},
@@ -49,6 +69,14 @@ async function runShutdownCase(exchangeCloseHangs: boolean): Promise<{
4969

5070
try {
5171
await waitForFile(activePath);
72+
const countBeforeShutdown = await waitForFetchCount(countPath, 5);
73+
expect(await Bun.file(closedPath).exists()).toBe(false);
74+
await Bun.sleep(100);
75+
const countAtShutdown = await waitForFetchCount(
76+
countPath,
77+
countBeforeShutdown + 1,
78+
);
79+
expect(await Bun.file(closedPath).exists()).toBe(false);
5280
child.kill("SIGTERM");
5381
const result = await Promise.race([
5482
child.exited.then((exitCode) => ({ exitCode })),
@@ -64,6 +92,8 @@ async function runShutdownCase(exchangeCloseHangs: boolean): Promise<{
6492
return {
6593
exitCode: result.exitCode,
6694
closeMarker: await Bun.file(closedPath).text(),
95+
countBeforeShutdown,
96+
countAtShutdown,
6797
output: `${await stdout}\n${await stderr}`,
6898
};
6999
} finally {
@@ -72,7 +102,7 @@ async function runShutdownCase(exchangeCloseHangs: boolean): Promise<{
72102
await child.exited;
73103
}
74104
await Promise.all(
75-
[configPath, activePath, closedPath].map(async (filePath) => {
105+
[configPath, activePath, countPath, closedPath].map(async (filePath) => {
76106
if (await Bun.file(filePath).exists()) {
77107
await Bun.file(filePath).delete();
78108
}
@@ -85,6 +115,7 @@ test("entrypoint exits promptly on SIGTERM after an exchange stream opens", asyn
85115
const result = await runShutdownCase(false);
86116
expect(result.exitCode).toBe(0);
87117
expect(result.closeMarker).toBe("closed");
118+
expect(result.countAtShutdown).toBeGreaterThan(result.countBeforeShutdown);
88119
});
89120

90121
test("entrypoint bounds shutdown when an exchange close does not resolve", async () => {

test/subscribe-handler.test.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function createSubscribeCall(
3333
};
3434
const writeResults = [...(options.writeResults ?? [])];
3535
const call = Object.assign(emitter, {
36+
cancelled: false,
3637
metadata: new grpc.Metadata(),
3738
request,
3839
getPeer: () => "127.0.0.1:1234",
@@ -65,6 +66,14 @@ function createSubscribeCall(
6566
};
6667
}
6768

69+
function cancelSubscribeCall(
70+
call: grpc.ServerWritableStream<SubscribeRequest, SubscribeResponse>,
71+
): void {
72+
call.cancelled = true;
73+
call.emit("cancelled", "cancelled");
74+
call.destroy();
75+
}
76+
6877
function nextTick(): Promise<void> {
6978
return new Promise((resolve) => setTimeout(resolve, 0));
7079
}
@@ -140,7 +149,7 @@ async function expectBackpressureWaitsForDrain({
140149

141150
call.emit("drain");
142151
await waitFor(() => controlledWatch.calls.length === 2);
143-
call.emit("close");
152+
cancelSubscribeCall(call);
144153
controlledWatch.resolvers[1]?.(secondValue);
145154
await handlerPromise;
146155

@@ -179,6 +188,32 @@ function createThrowingExchange(
179188
}
180189

181190
describe("subscribe handler", () => {
191+
test("keeps a subscription active when close fires without cancellation", async () => {
192+
const controlledWatch = createControlledWatch();
193+
const exchange = {
194+
watchTrades: controlledWatch.watch,
195+
} as unknown as Exchange;
196+
const { call } = createSubscribeCall({
197+
cex: "binance",
198+
symbol: "BTC/USDT",
199+
type: SubscriptionType.TRADES,
200+
});
201+
const handler = createSubscribeHandler({
202+
brokers: createPool(exchange),
203+
whitelistIps: ["*"],
204+
});
205+
const handlerPromise = handler(call);
206+
207+
await waitFor(() => controlledWatch.calls.length === 1);
208+
call.emit("close");
209+
controlledWatch.resolvers[0]?.([{ id: "trade-1" }]);
210+
await waitFor(() => controlledWatch.calls.length === 2);
211+
212+
cancelSubscribeCall(call);
213+
controlledWatch.resolvers[1]?.([{ id: "trade-2" }]);
214+
await handlerPromise;
215+
});
216+
182217
test.each([
183218
{
184219
type: SubscriptionType.TRADES,
@@ -360,7 +395,7 @@ describe("subscribe handler", () => {
360395
// forwarder over the real transport rather than racing the round trip.
361396
await waitFor(() => archiver.getStats().flushed >= 1);
362397
await waitFor(() => controlledWatch.calls.length >= 2);
363-
call.emit("close");
398+
cancelSubscribeCall(call);
364399
controlledWatch.resolvers[1]?.({
365400
bids: [[100, 1.5]],
366401
asks: [[101, 2]],
@@ -449,7 +484,7 @@ describe("subscribe handler", () => {
449484
// forwarder over the real transport rather than racing the round trip.
450485
await waitFor(() => archiver.getStats().flushed >= 1);
451486
await waitFor(() => controlledWatch.calls.length >= 2);
452-
call.emit("close");
487+
cancelSubscribeCall(call);
453488
controlledWatch.resolvers[1]?.([[1_700_000_000_000, 1, 2, 0.5, 1.5, 10]]);
454489
await handlerPromise;
455490

0 commit comments

Comments
 (0)