Skip to content

Commit d616e38

Browse files
authored
Merge pull request #111 from usherlabs/feat/deposit-poller-liveness-signal
feat: report deposit poller liveness and bound the venue call
2 parents dca6e34 + 44f12e2 commit d616e38

2 files changed

Lines changed: 177 additions & 7 deletions

File tree

src/helpers/deposit-archive-poller.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export type DepositArchivePollerConfig = {
2828
// Constant defaults (no env vars): every broker env var must be allowlisted in a
2929
// Gramine manifest in another repo, so the poller intentionally introduces none.
3030
pollIntervalMs: number;
31+
// Bounds the venue call. A fetchDeposits promise that never settles would
32+
// strand #pollOne forever: no error, no metric, no reschedule — the one
33+
// poller death mode that leaves no trace at all. The bound converts it into
34+
// an ordinary poll failure, which is already observable.
35+
fetchTimeoutMs: number;
3136
// How far back the first poll of an account reaches. A restart loses the
3237
// in-memory cursor and re-scans this window; duplicate rows are acceptable
3338
// because transfer_events is plain MergeTree and consumers deduplicate at read
@@ -39,10 +44,27 @@ export type DepositArchivePollerConfig = {
3944

4045
const DEFAULT_CONFIG: DepositArchivePollerConfig = {
4146
pollIntervalMs: 60_000,
47+
fetchTimeoutMs: 30_000,
4248
lookbackMs: 24 * 60 * 60 * 1000,
4349
depositsLimit: 50,
4450
};
4551

52+
function withTimeout<T>(
53+
promise: Promise<T>,
54+
timeoutMs: number,
55+
label: string,
56+
): Promise<T> {
57+
let timer: ReturnType<typeof setTimeout> | undefined;
58+
const expiry = new Promise<never>((_resolve, reject) => {
59+
timer = setTimeout(
60+
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
61+
timeoutMs,
62+
);
63+
timer.unref?.();
64+
});
65+
return Promise.race([promise, expiry]).finally(() => clearTimeout(timer));
66+
}
67+
4668
const ALL_CURRENCIES_CODE = "*";
4769

4870
type DepositPollTarget = {
@@ -51,6 +73,8 @@ type DepositPollTarget = {
5173
code: typeof ALL_CURRENCIES_CODE;
5274
};
5375

76+
type PollOutcome = "ok" | "error" | "unsupported";
77+
5478
type LastArchivedDeposit = {
5579
status: string | undefined;
5680
timestamp: number | undefined;
@@ -218,7 +242,24 @@ export class DepositArchivePoller {
218242
return true;
219243
}
220244

245+
// The heartbeat is the only signal that separates a healthy-idle poller from a
246+
// hung one: the archive and error counters are both silent on a quiet venue.
247+
// It therefore records on every exit, including an unexpected throw, which is
248+
// why the outcome starts pessimistic and is only narrowed by a completed poll.
221249
async #pollOne(target: DepositPollTarget): Promise<void> {
250+
let outcome: PollOutcome = "error";
251+
try {
252+
outcome = await this.#pollTarget(target);
253+
} finally {
254+
void this.params.metrics?.recordCounter(
255+
"cex_deposit_poller_polls_total",
256+
1,
257+
{ exchange: target.exchangeId, outcome },
258+
);
259+
}
260+
}
261+
262+
async #pollTarget(target: DepositPollTarget): Promise<PollOutcome> {
222263
const exchange = target.account.exchange as unknown as ExchangeWithDeposits;
223264
const key = this.#targetKey(target);
224265
if (
@@ -232,17 +273,17 @@ export class DepositArchivePoller {
232273
account: target.account.label,
233274
});
234275
}
235-
return;
276+
return "unsupported";
236277
}
237278

238279
const since =
239280
this.#cursors.get(key) ?? Date.now() - this.#config.lookbackMs;
240281
let deposits: unknown[];
241282
try {
242-
deposits = await exchange.fetchDeposits(
243-
undefined,
244-
since,
245-
this.#config.depositsLimit,
283+
deposits = await withTimeout(
284+
exchange.fetchDeposits(undefined, since, this.#config.depositsLimit),
285+
this.#config.fetchTimeoutMs,
286+
"fetchDeposits",
246287
);
247288
} catch (error) {
248289
void this.params.metrics?.recordCounter(
@@ -255,10 +296,10 @@ export class DepositArchivePoller {
255296
account: target.account.label,
256297
error,
257298
});
258-
return;
299+
return "error";
259300
}
260301
if (!Array.isArray(deposits) || deposits.length === 0) {
261-
return;
302+
return "ok";
262303
}
263304

264305
let archived = 0;
@@ -359,6 +400,7 @@ export class DepositArchivePoller {
359400
this.#lastArchivedByTarget.delete(key);
360401
}
361402
}
403+
return "ok";
362404
}
363405

364406
#targetKey(target: DepositPollTarget): string {

test/deposit-archive-poller.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,26 @@ function poolWith(exchange: unknown): Record<string, BrokerPoolEntry> {
2727
};
2828
}
2929

30+
type RecordedCounter = {
31+
name: string;
32+
value: number;
33+
labels: Record<string, string | number>;
34+
};
35+
36+
function fakeMetrics(sink: RecordedCounter[]) {
37+
return {
38+
recordCounter: async (
39+
name: string,
40+
value: number,
41+
labels: Record<string, string | number>,
42+
) => {
43+
sink.push({ name, value, labels });
44+
},
45+
} as unknown as ConstructorParameters<
46+
typeof DepositArchivePoller
47+
>[0]["metrics"];
48+
}
49+
3050
function fakeArchiver(sink: BrokerArchiveRow[]): BrokerExecutionArchiver {
3151
return {
3252
isEnabled: () => true,
@@ -392,3 +412,111 @@ describe("DepositArchivePoller.pollAllOnce", () => {
392412
}
393413
});
394414
});
415+
416+
describe("DepositArchivePoller liveness signal", () => {
417+
test("records a heartbeat for a successful poll", async () => {
418+
const counters: RecordedCounter[] = [];
419+
const exchange = {
420+
has: { fetchDeposits: true },
421+
fetchDeposits: async () => [],
422+
};
423+
const poller = new DepositArchivePoller({
424+
brokers: poolWith(exchange),
425+
archiver: fakeArchiver([]),
426+
metrics: fakeMetrics(counters),
427+
});
428+
429+
await poller.pollAllOnce();
430+
431+
expect(counters).toEqual([
432+
{
433+
name: "cex_deposit_poller_polls_total",
434+
value: 1,
435+
labels: { exchange: "binance", outcome: "ok" },
436+
},
437+
]);
438+
});
439+
440+
test("records a heartbeat for an account without fetchDeposits", async () => {
441+
const info = spyOn(log, "info").mockImplementation(() => {});
442+
try {
443+
const counters: RecordedCounter[] = [];
444+
const poller = new DepositArchivePoller({
445+
brokers: poolWith({ has: { fetchDeposits: false } }),
446+
archiver: fakeArchiver([]),
447+
metrics: fakeMetrics(counters),
448+
});
449+
450+
await poller.pollAllOnce();
451+
452+
expect(counters).toEqual([
453+
{
454+
name: "cex_deposit_poller_polls_total",
455+
value: 1,
456+
labels: { exchange: "binance", outcome: "unsupported" },
457+
},
458+
]);
459+
} finally {
460+
info.mockRestore();
461+
}
462+
});
463+
464+
test("counts a hung fetchDeposits as a failed poll and polls again", async () => {
465+
const warn = spyOn(log, "warn").mockImplementation(() => {});
466+
try {
467+
let calls = 0;
468+
const exchange = {
469+
has: { fetchDeposits: true },
470+
fetchDeposits: async () => {
471+
calls += 1;
472+
if (calls === 1) {
473+
// Never settles: the silent-death mode the timeout exists for.
474+
return new Promise<unknown[]>(() => {});
475+
}
476+
return [
477+
{
478+
txid: "0xafter-hang",
479+
currency: "USDC",
480+
amount: "5",
481+
status: "ok",
482+
timestamp: Date.now() + 10_000,
483+
},
484+
];
485+
},
486+
};
487+
const counters: RecordedCounter[] = [];
488+
const sink: BrokerArchiveRow[] = [];
489+
const poller = new DepositArchivePoller({
490+
brokers: poolWith(exchange),
491+
archiver: fakeArchiver(sink),
492+
metrics: fakeMetrics(counters),
493+
config: { fetchTimeoutMs: 10 },
494+
});
495+
496+
expect(await poller.pollAllOnce()).toBe(true);
497+
498+
expect(warn.mock.calls[0]?.[0]).toBe("Deposit archive poll failed");
499+
expect(
500+
String((warn.mock.calls[0]?.[1] as { error: unknown }).error),
501+
).toContain("fetchDeposits timed out after 10ms");
502+
expect(counters).toEqual([
503+
{
504+
name: "cex_deposit_poller_errors_total",
505+
value: 1,
506+
labels: { exchange: "binance" },
507+
},
508+
{
509+
name: "cex_deposit_poller_polls_total",
510+
value: 1,
511+
labels: { exchange: "binance", outcome: "error" },
512+
},
513+
]);
514+
515+
expect(await poller.pollAllOnce()).toBe(true);
516+
expect(sink).toHaveLength(1);
517+
expect(sink[0]?.row).toMatchObject({ external_id: "0xafter-hang" });
518+
} finally {
519+
warn.mockRestore();
520+
}
521+
});
522+
});

0 commit comments

Comments
 (0)