Skip to content

Commit cd74d43

Browse files
committed
fix(market-data): refuse to start when production capture identity is incomplete
1 parent 98156c8 commit cd74d43

5 files changed

Lines changed: 72 additions & 21 deletions

File tree

src/helpers/market-data-archive/capture-context.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,33 @@ export function resolveMarketCaptureArchiveState(input: {
9898
return { enabled: true };
9999
}
100100

101+
/**
102+
* Throws when archival was requested but its capture identity is incomplete.
103+
*
104+
* `archive_disabled` and `market_archive_disabled` are deliberate opt-outs and
105+
* must keep the broker available — that is the whole point of resolving to a
106+
* typed state instead of throwing. Every other disabled reason means archival
107+
* was asked for and cannot be honoured, which is a misconfiguration: a broker
108+
* started in that state serves RPC and passes health while writing no
109+
* market-data row at all, and the only trace is one line at boot.
110+
*/
111+
export function assertMarketCaptureArchiveStartable(
112+
state: MarketCaptureArchiveState,
113+
): void {
114+
if (
115+
state.enabled ||
116+
state.reason === "archive_disabled" ||
117+
state.reason === "market_archive_disabled"
118+
) {
119+
return;
120+
}
121+
throw new Error(
122+
`Refusing to start: canonical market-data archival was requested but its capture identity is incomplete (${state.reason}). ` +
123+
"Set CEX_BROKER_MARKET_CAPTURE_ENVIRONMENT, CEX_BROKER_DEPLOYMENT_ID and CEX_BROKER_CAPTURE_BUNDLE_ID, " +
124+
"or disable archival explicitly via CEX_BROKER_MARKET_ARCHIVE_ENABLED.",
125+
);
126+
}
127+
101128
export function validateExternalFallbackContext(input: {
102129
configuredExchange: string;
103130
configuredSymbol: string;

src/helpers/market-data-archive/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export {
1515
createOrderbookTobSampler,
1616
} from "./capture";
1717
export {
18+
assertMarketCaptureArchiveStartable,
1819
captureEnvironmentFromEnv,
1920
createMarketCaptureContext,
2021
type MarketCaptureArchiveDisabledReason,

src/index.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ import {
1919
import { DepositArchivePoller } from "./helpers/deposit-archive-poller";
2020
import { FillArchivePoller } from "./helpers/fill-archive-poller";
2121
import { log } from "./helpers/logger";
22-
import { resolveMarketCaptureArchiveState } from "./helpers/market-data-archive/capture-context";
22+
import {
23+
assertMarketCaptureArchiveStartable,
24+
resolveMarketCaptureArchiveState,
25+
} from "./helpers/market-data-archive/capture-context";
2326
import { isMarketArchiveEnabled } from "./helpers/market-data-archive/orderbook-sampler";
2427
import { OrderActivityTracker } from "./helpers/order-activity-tracker";
2528
import {
@@ -336,16 +339,7 @@ export default class CEXBroker {
336339
deploymentId: this.brokerArchiver?.getDeploymentId(),
337340
captureBundleId: process.env.CEX_BROKER_CAPTURE_BUNDLE_ID,
338341
});
339-
if (
340-
!marketArchiveState.enabled &&
341-
marketArchiveState.reason !== "archive_disabled" &&
342-
marketArchiveState.reason !== "market_archive_disabled"
343-
) {
344-
log.warn(
345-
"Canonical market-data archival is disabled; broker RPC service remains available",
346-
{ reason: marketArchiveState.reason },
347-
);
348-
}
342+
assertMarketCaptureArchiveStartable(marketArchiveState);
349343
if (this.server) {
350344
await this.server.forceShutdown();
351345
}

test/canonical-market-data-contract.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
OrderBookValidationError,
55
} from "../src/helpers/market-data-archive/canonical-orderbook";
66
import {
7+
assertMarketCaptureArchiveStartable,
78
createMarketCaptureContext,
89
resolveMarketCaptureArchiveState,
910
validateExternalFallbackContext,
@@ -221,6 +222,36 @@ describe("canonical market capture contract", () => {
221222
).toEqual({ enabled: true });
222223
});
223224

225+
test("startup fails closed when archival was requested but capture identity is incomplete", () => {
226+
// The resolver stays non-throwing (above); the START decision is what must
227+
// fail closed. Without this, a production broker missing its deployment or
228+
// bundle id runs healthy, serves RPC, and writes no market-data row at all —
229+
// one warn line at boot and no counter, metric or alarm anywhere.
230+
for (const reason of [
231+
"invalid_capture_environment",
232+
"missing_deployment_id",
233+
"missing_capture_bundle_id",
234+
] as const) {
235+
expect(() =>
236+
assertMarketCaptureArchiveStartable({ enabled: false, reason }),
237+
).toThrow(reason);
238+
}
239+
240+
// Deliberate opt-outs must keep the broker available — that is the whole
241+
// point of resolving to a typed state rather than throwing outright.
242+
for (const reason of [
243+
"archive_disabled",
244+
"market_archive_disabled",
245+
] as const) {
246+
expect(() =>
247+
assertMarketCaptureArchiveStartable({ enabled: false, reason }),
248+
).not.toThrow();
249+
}
250+
expect(() =>
251+
assertMarketCaptureArchiveStartable({ enabled: true }),
252+
).not.toThrow();
253+
});
254+
224255
test("external fallback rows cannot cross venue or omit their reason", () => {
225256
expect(() =>
226257
validateExternalFallbackContext({

test/production-market-capture-startup.test.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,16 @@ test("production broker starts its full RPC service without archive configuratio
128128
}
129129
});
130130

131-
test("incomplete production market provenance does not gate the full RPC service", async () => {
131+
test("incomplete production market provenance refuses to start", async () => {
132+
// A production broker that asked for archival but cannot identify its capture
133+
// must NOT come up. Starting is the dangerous outcome, not a degraded one: it
134+
// serves RPC and passes health while writing no market-data row, leaving one
135+
// warn line at boot as the only trace. Availability is still preserved for
136+
// DELIBERATE opt-outs (archive disabled / market archive disabled), which the
137+
// surrounding tests cover.
132138
const original = captureEnvironment();
133139
const deadLetterPath = `/tmp/cex-broker-production-ineligible-${crypto.randomUUID()}.jsonl`;
134140
let broker: CEXBroker | undefined;
135-
let client: FullBrokerClient | undefined;
136141
try {
137142
const port = await reservePort();
138143
process.env.CEX_BROKER_MARKET_CAPTURE_ENVIRONMENT = "production";
@@ -145,15 +150,8 @@ test("incomplete production market provenance does not gate the full RPC service
145150

146151
broker = new CEXBroker({}, policy);
147152
broker.port = port;
148-
await broker.run();
149-
client = new grpcObject.cex_broker.cex_service(
150-
`127.0.0.1:${port}`,
151-
grpc.credentials.createInsecure(),
152-
);
153-
await waitForReady(client);
154-
await expectFullRpcService(client);
153+
await expect(broker.run()).rejects.toThrow("missing_deployment_id");
155154
} finally {
156-
client?.close();
157155
await broker?.stop();
158156
restoreEnvironment(original);
159157
if (await Bun.file(deadLetterPath).exists()) {

0 commit comments

Comments
 (0)