Skip to content

Commit a784405

Browse files
authored
Merge pull request #104 from usherlabs/feature/account-stream-supervisor
feat: supervise configured user data streams
2 parents c04ffdc + fed8c57 commit a784405

17 files changed

Lines changed: 2669 additions & 77 deletions

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,12 @@ Every successful `CreateOrder` response, successful `GetOrderDetails` response,
737737

738738
Use metrics for aggregations and alerts. For the durable execution audit trail, the broker archives every order lifecycle event to `broker_execution.order_events` (and pre-order top-of-book to `broker_execution.market_metadata_snapshots`) through the **archive forwarder** — the same HTTP `/archive` → ClickHouse path used for `market_data.*`. Set `CEX_BROKER_ARCHIVE_ENABLED=true`, an explicit HTTP(S) `CEX_BROKER_ARCHIVE_FORWARDER_URL`, and a writable durable JSONL path in `CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH`; startup fails if either required sink configuration is missing or invalid. In production, that path must be on persistent writable storage or a mounted volume rather than the container's ephemeral filesystem. Queue shedding and rows that remain undeliverable during shutdown are written to that loss journal with their original `{table,row}` payload before being discarded. Setting `CEX_BROKER_ARCHIVE_OTEL_LOGS_ENABLED=true` additionally mirrors execution rows to OTel logs for observability, but OTel is never the archive sink of record. Analysts join Maker action rows to `broker_execution.order_events` using `maker_action_id`, `idempotency_id`, `client_order_id`, or the exchange `order_id`, then compare Maker propAMM execution price against `average_execution_price` and fees. Failed CreateOrder rows keep bounded exchange error detail in `error_message`; their telemetry-shaped `payload_json`, metrics, and ordinary telemetry logs remain redacted. The broker does not emit raw exchange payloads, API keys, secrets, or credentials in telemetry fields.
739739

740+
### User-stream health archive contract
741+
742+
`broker_stream_health.snapshots` is a dedicated, source-authoritative archive path for the configured-account user-stream registry. The publisher must emit every active stream for one registry revision in one batch; retired entries may remain as explicit lifecycle evidence. The forwarder verifies total and active counts, unique normalized stream identities, and one recomputed `batch_id` before making one ClickHouse write. It records connection state, authentication and received-event watermarks, counters, and explicit failure kinds; a quiet event-driven stream is represented by `last_received_at = NULL`, not by missing health data. Failure reasons are bounded diagnostics and are redacted before storage; state, failure kind, and traffic mode remain the machine-readable fields.
743+
744+
The archive forwarder must be a single active deployment for this table. Replays are accepted only when the whole batch is already present with the same canonical hashes. A partial batch, a same-id different payload, or more than one existing hash for an ID is persisted to `broker_stream_health.replay_conflicts` and the candidate batch is rejected. Readers must deduplicate exact race duplicates by `(snapshot_id, payload_sha256)` and treat more than one hash for one `snapshot_id` as an integrity incident rather than selecting a winner. Neither health table has a TTL.
745+
740746
### Telemetry Test Harness
741747

742748
Order telemetry tests use `test/order-telemetry-fixtures.ts` to run the real gRPC server with mocked CCXT exchanges. The fixture can simulate create-order responses, order-detail responses, partial fills, rejected orders, failed create-order calls, and fee/no-fee exchange payloads without live credentials.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
-- Durable, source-authoritative CEX user-stream health snapshots.
2+
-- NO TTL: quiet, disconnected, and failed states are replay evidence.
3+
4+
CREATE DATABASE IF NOT EXISTS broker_stream_health;
5+
6+
CREATE TABLE IF NOT EXISTS broker_stream_health.snapshots
7+
(
8+
schema_version LowCardinality(String),
9+
source LowCardinality(String),
10+
deployment_id LowCardinality(String),
11+
producer_id LowCardinality(String),
12+
producer_epoch UInt64,
13+
run_id UUID,
14+
batch_id FixedString(64),
15+
batch_sequence UInt64,
16+
batch_snapshot_count UInt32,
17+
batch_active_stream_count UInt32,
18+
registry_revision FixedString(64),
19+
registry_status Enum8('active' = 1, 'retired' = 2),
20+
retired_at Nullable(DateTime64(3, 'UTC')),
21+
snapshot_id FixedString(64),
22+
stream_key String,
23+
exchange LowCardinality(String),
24+
account_selector LowCardinality(String),
25+
account_role LowCardinality(Nullable(String)),
26+
stream_kind LowCardinality(String),
27+
account_scope LowCardinality(String),
28+
sequence UInt64,
29+
state Enum8('connecting' = 1, 'connected' = 2, 'disconnected' = 3, 'error' = 4),
30+
state_changed_at DateTime64(3, 'UTC'),
31+
last_connected_at Nullable(DateTime64(3, 'UTC')),
32+
last_authenticated_at Nullable(DateTime64(3, 'UTC')),
33+
last_received_at Nullable(DateTime64(3, 'UTC')),
34+
heartbeat_at DateTime64(3, 'UTC'),
35+
connect_attempt_count UInt64,
36+
reconnect_count UInt64,
37+
error_count UInt64,
38+
last_failure_kind Enum8(
39+
'none' = 0,
40+
'auth_failed' = 1,
41+
'transport_error' = 2,
42+
'remote_closed' = 3,
43+
'protocol_error' = 4,
44+
'backpressure' = 5,
45+
'unsupported_connector' = 6,
46+
'shutdown' = 7
47+
),
48+
last_failure_reason String,
49+
traffic_mode Enum8('event_driven' = 1, 'continuous' = 2, 'unknown' = 3),
50+
source_watermark Nullable(String),
51+
payload_sha256 FixedString(64),
52+
payload_json String,
53+
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
54+
)
55+
ENGINE = MergeTree
56+
PARTITION BY toYYYYMM(heartbeat_at)
57+
ORDER BY (producer_id, producer_epoch, run_id, stream_key, sequence, snapshot_id);
58+
59+
-- A collision is never reconciled by a reader. The forwarder records the
60+
-- evidence separately and fails the whole candidate batch closed.
61+
CREATE TABLE IF NOT EXISTS broker_stream_health.replay_conflicts
62+
(
63+
detected_at DateTime64(3, 'UTC') DEFAULT now64(3),
64+
batch_id FixedString(64),
65+
snapshot_id FixedString(64),
66+
conflict_kind Enum8(
67+
'payload_mismatch' = 1,
68+
'partial_batch' = 2,
69+
'multiple_existing_hashes' = 3
70+
),
71+
existing_payload_sha256 String,
72+
incoming_payload_sha256 FixedString(64),
73+
existing_payload_json String,
74+
incoming_payload_json String
75+
)
76+
ENGINE = MergeTree
77+
PARTITION BY toYYYYMM(detected_at)
78+
ORDER BY (snapshot_id, detected_at);

services/archive-forwarder/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createClickHouseInserter } from "./insert";
44
import { evaluateForwarderHealth, pingClickHouse } from "./health";
55
import { handleArchiveRequest } from "./request";
66
import { ensureArchiveSchema } from "./schema";
7+
import { createClickHouseStreamHealthReplayStore } from "./stream-health-contract";
78
import { createArchiveForwarderTelemetry } from "./telemetry";
89
import {
910
StrategyArchiveSpool,
@@ -25,6 +26,7 @@ const clickhouse = createClient({
2526
clickhouse_settings: { date_time_input_format: "best_effort" },
2627
});
2728
const inserter = createClickHouseInserter(clickhouse);
29+
const streamHealthStore = createClickHouseStreamHealthReplayStore(clickhouse);
2830
const telemetry = createArchiveForwarderTelemetry();
2931
let spool: StrategyArchiveSpool | undefined;
3032
let worker: StrategySpoolWorker | undefined;
@@ -46,7 +48,7 @@ async function ensureSchemaAndStartDrainage(): Promise<void> {
4648
schemaReady = true;
4749
worker?.start();
4850
console.log(
49-
"ClickHouse archive schema ensured (market_data, broker_execution, broker_account, strategy_data)",
51+
"ClickHouse archive schema ensured (market_data, broker_execution, broker_account, broker_stream_health, strategy_data)",
5052
);
5153
} catch (error) {
5254
schemaReady = false;
@@ -102,6 +104,7 @@ const server = Bun.serve({
102104
authToken: config.authToken,
103105
inserter,
104106
spool,
107+
streamHealthStore,
105108
telemetry,
106109
});
107110
}

services/archive-forwarder/request.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ import {
1111
classifyStrategyArchiveBatch,
1212
validateStrategyArchiveBatch,
1313
} from "./strategy-contract";
14+
import {
15+
classifyStreamHealthArchiveBatch,
16+
insertStreamHealthArchiveBatch,
17+
type StreamHealthReplayStore,
18+
validateStreamHealthArchiveBatch,
19+
} from "./stream-health-contract";
1420
import {
1521
StrategySpoolQuotaError,
1622
StrategySpoolUnavailableError,
@@ -22,6 +28,7 @@ export type ArchiveRequestDependencies = {
2228
authToken?: string;
2329
inserter: RowInserter;
2430
spool?: Pick<StrategyArchiveSpool, "admit">;
31+
streamHealthStore?: StreamHealthReplayStore;
2532
telemetry: ArchiveForwarderTelemetry;
2633
};
2734

@@ -57,6 +64,24 @@ export async function handleArchiveRequest(
5764
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5865
}
5966

67+
const streamHealthClassification = classifyStreamHealthArchiveBatch(body);
68+
if (
69+
streamHealthClassification === "invalid_stream_health_mix" ||
70+
streamHealthClassification === "invalid_stream_health_source"
71+
) {
72+
return Response.json(
73+
{ error: "Invalid stream health archive source or table mix" },
74+
{ status: 400 },
75+
);
76+
}
77+
const streamHealthValidation =
78+
streamHealthClassification === "stream_health"
79+
? validateStreamHealthArchiveBatch(body)
80+
: undefined;
81+
if (streamHealthValidation && !streamHealthValidation.ok) {
82+
return Response.json({ error: streamHealthValidation.error }, { status: 400 });
83+
}
84+
6085
const strategyClassification = classifyStrategyArchiveBatch(body);
6186
if (
6287
strategyClassification === "invalid_strategy_mix" ||
@@ -125,6 +150,31 @@ export async function handleArchiveRequest(
125150
);
126151
}
127152

153+
if (streamHealthValidation?.ok) {
154+
if (!dependencies.streamHealthStore) {
155+
return Response.json(
156+
{ error: "Stream health replay store unavailable" },
157+
{ status: 503 },
158+
);
159+
}
160+
const result = await insertStreamHealthArchiveBatch(
161+
dependencies.inserter,
162+
dependencies.streamHealthStore,
163+
streamHealthValidation,
164+
);
165+
if (!result.ok) {
166+
return Response.json({ error: result.error }, { status: result.status });
167+
}
168+
if (result.inserted > 0) {
169+
dependencies.telemetry.recordRowsInserted(
170+
"broker_stream_health.snapshots",
171+
result.inserted,
172+
);
173+
dependencies.telemetry.recordSuccessfulFlush();
174+
}
175+
return Response.json(result);
176+
}
177+
128178
if (strategyClassification === "strategy") {
129179
if (!dependencies.spool) {
130180
dependencies.telemetry.recordStrategyAdmissionRejected("spool_unavailable");

services/archive-forwarder/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const ARCHIVE_SCHEMA_FILES = [
5757
"market_data.sql",
5858
"broker_execution.sql",
5959
"broker_account.sql",
60+
"broker_stream_health.sql",
6061
"strategy_data.sql",
6162
] as const;
6263

0 commit comments

Comments
 (0)