Stabilize tape virtualization and scoped live health

This commit is contained in:
dirtydishes 2026-05-07 01:52:20 -04:00
parent 034d24f8ac
commit e69bf295c8
11 changed files with 866 additions and 273 deletions

View file

@ -1,3 +1,4 @@
{"_type":"issue","id":"islandflow-2ij","title":"Harden tape virtualization, scoped focus, and live feed health","description":"Implement the coordinated tape stability plan across web and API.\n\nScope:\n- replace fixed-height tape virtualization with measured virtualization and virtual-end history loading\n- replace scrollHeight anchoring with key-based anchor restore\n- compose canonical tape lists across seed/live/history sources\n- preserve clicked contract/ticker context during scoped focus transitions\n- separate backend hot-channel health from scoped quiet empty states\n- shrink browser hot windows and modestly reduce server cache limits\n- add regression tests and development instrumentation\n\nAcceptance:\n- no giant blank spacer gaps during tape scrolling\n- scroll remains stable while live data and history mutate the list\n- clicked deep-history option/equity rows remain visible immediately after focus\n- narrow scopes do not surface Feed behind unless backend channel health is stale\n","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-07T05:35:18Z","created_by":"dirtydishes","updated_at":"2026-05-07T05:52:14Z","started_at":"2026-05-07T05:35:21Z","closed_at":"2026-05-07T05:52:14Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-uj7","title":"Fix home to tape navigation","description":"Home rail Tape navigation was not reliably switching to the tape route. Use browser-native top-level navigation for Home/Tape rail links so /tape remains reachable even if client router handling stalls.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-07T03:18:14Z","created_by":"dirtydishes","updated_at":"2026-05-07T03:18:21Z","started_at":"2026-05-07T03:18:20Z","closed_at":"2026-05-07T03:18:21Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-uj7","title":"Fix home to tape navigation","description":"Home rail Tape navigation was not reliably switching to the tape route. Use browser-native top-level navigation for Home/Tape rail links so /tape remains reachable even if client router handling stalls.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-07T03:18:14Z","created_by":"dirtydishes","updated_at":"2026-05-07T03:18:21Z","started_at":"2026-05-07T03:18:20Z","closed_at":"2026-05-07T03:18:21Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-84s","title":"Implement seamless /tape live-to-history scroll gate","description":"Implement seamless live-to-ClickHouse scroll-gated history for /tape panes, including split live/history buffers in the web client, snapshot_limit support on live subscriptions, a bundled options support lookup endpoint, ClickHouse helpers for parity hydration, and test coverage for live head retention, background history loading, scoped options deep-hydration, and historical options decor restoration.\n","status":"in_progress","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-07T02:10:43Z","created_by":"dirtydishes","updated_at":"2026-05-07T02:10:47Z","started_at":"2026-05-07T02:10:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-84s","title":"Implement seamless /tape live-to-history scroll gate","description":"Implement seamless live-to-ClickHouse scroll-gated history for /tape panes, including split live/history buffers in the web client, snapshot_limit support on live subscriptions, a bundled options support lookup endpoint, ClickHouse helpers for parity hydration, and test coverage for live head retention, background history loading, scoped options deep-hydration, and historical options decor restoration.\n","status":"in_progress","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-07T02:10:43Z","created_by":"dirtydishes","updated_at":"2026-05-07T02:10:47Z","started_at":"2026-05-07T02:10:47Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-sh1","title":"Fix live websocket stale lag and reconnect loop","description":"Investigate and fix API live consumer lag causing stale timestamps, feed-behind status, and reconnect loops. Optimize live cache persistence path, add lag telemetry/alerts, and validate in runtime.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-04T17:04:34Z","created_by":"dirtydishes","updated_at":"2026-05-04T17:09:44Z","started_at":"2026-05-04T17:04:38Z","closed_at":"2026-05-04T17:09:44Z","close_reason":"Completed: optimized live cache persistence path, added lag telemetry, deployed api via docker compose on di, verified ws freshness and low hotFeedLagMs","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-sh1","title":"Fix live websocket stale lag and reconnect loop","description":"Investigate and fix API live consumer lag causing stale timestamps, feed-behind status, and reconnect loops. Optimize live cache persistence path, add lag telemetry/alerts, and validate in runtime.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-04T17:04:34Z","created_by":"dirtydishes","updated_at":"2026-05-04T17:09:44Z","started_at":"2026-05-04T17:04:38Z","closed_at":"2026-05-04T17:09:44Z","close_reason":"Completed: optimized live cache persistence path, added lag telemetry, deployed api via docker compose on di, verified ws freshness and low hotFeedLagMs","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -58,8 +58,8 @@ API_DELIVER_POLICY=new
API_CONSUMER_RESET=false API_CONSUMER_RESET=false
NBBO_MAX_AGE_MS=1000 NBBO_MAX_AGE_MS=1000
NEXT_PUBLIC_NBBO_MAX_AGE_MS=1000 NEXT_PUBLIC_NBBO_MAX_AGE_MS=1000
NEXT_PUBLIC_LIVE_HOT_WINDOW=2000 NEXT_PUBLIC_LIVE_HOT_WINDOW=600
NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS=25000 NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS=1200
NEXT_PUBLIC_PINNED_EVIDENCE_TTL_MS=1200000 NEXT_PUBLIC_PINNED_EVIDENCE_TTL_MS=1200000
NEXT_PUBLIC_PINNED_EVIDENCE_MAX_ITEMS=4000 NEXT_PUBLIC_PINNED_EVIDENCE_MAX_ITEMS=4000
ROLLING_WINDOW_SIZE=50 ROLLING_WINDOW_SIZE=50
@ -100,12 +100,12 @@ REPLAY_BATCH_SIZE=200
REPLAY_LOG_EVERY=1000 REPLAY_LOG_EVERY=1000
# API live retention (generic channels) # API live retention (generic channels)
LIVE_LIMIT_OPTIONS=10000 LIVE_LIMIT_OPTIONS=2000
LIVE_LIMIT_NBBO=10000 LIVE_LIMIT_NBBO=10000
LIVE_LIMIT_EQUITIES=10000 LIVE_LIMIT_EQUITIES=2000
LIVE_LIMIT_EQUITY_QUOTES=10000 LIVE_LIMIT_EQUITY_QUOTES=10000
LIVE_LIMIT_EQUITY_JOINS=10000 LIVE_LIMIT_EQUITY_JOINS=10000
LIVE_LIMIT_FLOW=10000 LIVE_LIMIT_FLOW=2000
LIVE_LIMIT_CLASSIFIER_HITS=10000 LIVE_LIMIT_CLASSIFIER_HITS=10000
LIVE_LIMIT_ALERTS=10000 LIVE_LIMIT_ALERTS=10000
LIVE_LIMIT_INFERRED_DARK=10000 LIVE_LIMIT_INFERRED_DARK=10000

View file

@ -967,6 +967,11 @@ h3 {
min-width: 980px; min-width: 980px;
} }
.data-table-body {
position: relative;
min-width: 100%;
}
.data-table-options { .data-table-options {
min-width: 1280px; min-width: 1280px;
} }
@ -1024,10 +1029,16 @@ h3 {
text-align: left; text-align: left;
} }
.data-table-row:nth-child(even) { .data-table-row.is-even {
background: rgba(255, 255, 255, 0.022); background: rgba(255, 255, 255, 0.022);
} }
.data-table-virtual-row {
position: absolute;
left: 0;
width: 100%;
}
.data-table-row:hover, .data-table-row:hover,
.data-table-row:focus-visible { .data-table-row:focus-visible {
outline: none; outline: none;

View file

@ -5,12 +5,15 @@ import {
appendHistoryTail, appendHistoryTail,
buildDefaultFlowFilters, buildDefaultFlowFilters,
classifierToneForFamily, classifierToneForFamily,
composeTapeItems,
deriveAlertDirection, deriveAlertDirection,
countActiveFlowFilterGroups, countActiveFlowFilterGroups,
findAnchorRestoreIndex,
formatCompactUsd, formatCompactUsd,
formatOptionContractLabel, formatOptionContractLabel,
flushPausableTapeData, flushPausableTapeData,
getAlertWindowAnchorTs, getAlertWindowAnchorTs,
getHotChannelFeedStatus,
getScopedLiveAutoHydrationChannels, getScopedLiveAutoHydrationChannels,
getLiveHistoryRetentionCap, getLiveHistoryRetentionCap,
getOptionTableSnapshot, getOptionTableSnapshot,
@ -246,6 +249,37 @@ describe("live tape pausable helpers", () => {
}); });
describe("live tape history helpers", () => { describe("live tape history helpers", () => {
it("composes tape items across seed, live, and history without seam duplicates", () => {
const seed = [makeItem("seed", 1, 100), makeItem("dup", 2, 200)];
const live = [makeItem("live", 5, 500), makeItem("dup", 2, 200)];
const history = [makeItem("old", 0, 50), makeItem("mid", 3, 300)];
expect(composeTapeItems(seed, live, history).map((item) => item.trace_id)).toEqual([
"live",
"mid",
"dup",
"seed",
"old"
]);
});
it("keeps a clicked seed row visible before scoped live and history arrive", () => {
const clicked = makeItem("clicked", 3, 300);
expect(composeTapeItems([clicked], [], []).map((item) => item.trace_id)).toEqual(["clicked"]);
});
it("drops focus seed duplicates once equivalent live or history rows arrive", () => {
const clicked = makeItem("clicked", 3, 300);
const live = [makeItem("new", 4, 400)];
const history = [makeItem("clicked", 3, 300)];
expect(composeTapeItems([clicked], live, history).map((item) => item.trace_id)).toEqual([
"new",
"clicked"
]);
});
it("promotes hot-window overflow into the history tail", () => { it("promotes hot-window overflow into the history tail", () => {
const currentHot = [makeItem("hot-3", 3, 300), makeItem("hot-2", 2, 200), makeItem("hot-1", 1, 100)]; const currentHot = [makeItem("hot-3", 3, 300), makeItem("hot-2", 2, 200), makeItem("hot-1", 1, 100)];
const incoming = [makeItem("hot-4", 4, 400)]; const incoming = [makeItem("hot-4", 4, 400)];
@ -362,6 +396,21 @@ describe("live tape history helpers", () => {
}, {}) }, {})
).toEqual(["options"]); ).toEqual(["options"]);
}); });
it("restores the same anchor key after live insertions at the top", () => {
const nextKeys = ["new-1", "new-2", "anchor", "after-1", "after-2"];
expect(findAnchorRestoreIndex(nextKeys, "anchor", ["anchor", "after-1", "after-2"])).toBe(2);
});
it("falls forward to the nearest surviving key when the anchor is evicted", () => {
const nextKeys = ["new-1", "after-1", "after-2"];
expect(findAnchorRestoreIndex(nextKeys, "anchor", ["anchor", "after-1", "after-2"])).toBe(1);
});
it("keeps the same anchor when history is appended at the bottom", () => {
const nextKeys = ["anchor", "after-1", "after-2", "older-1", "older-2"];
expect(findAnchorRestoreIndex(nextKeys, "anchor", ["anchor", "after-1", "after-2"])).toBe(0);
});
}); });
describe("options display formatters", () => { describe("options display formatters", () => {
@ -533,4 +582,13 @@ describe("signals helpers", () => {
expect(statusLabel("connected", false, "live")).toBe("Connected"); expect(statusLabel("connected", false, "live")).toBe("Connected");
expect(statusLabel("stale", false, "live")).toBe("Feed behind"); expect(statusLabel("stale", false, "live")).toBe("Feed behind");
}); });
it("treats healthy scoped channels as connected even when no matching rows are visible", () => {
expect(getHotChannelFeedStatus("connected", { healthy: true })).toBe("connected");
});
it("surfaces feed behind only when the backend channel health is stale", () => {
expect(getHotChannelFeedStatus("connected", { healthy: false })).toBe("stale");
expect(getHotChannelFeedStatus("disconnected", { healthy: true })).toBe("disconnected");
});
}); });

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"@islandflow/types": "workspace:*", "@islandflow/types": "workspace:*",
"@tanstack/react-virtual": "^3.13.24",
"lightweight-charts": "^4.2.0", "lightweight-charts": "^4.2.0",
"next": "^14.2.4", "next": "^14.2.4",
"react": "^18.3.1", "react": "^18.3.1",

View file

@ -12,6 +12,7 @@
"name": "@islandflow/web", "name": "@islandflow/web",
"dependencies": { "dependencies": {
"@islandflow/types": "workspace:*", "@islandflow/types": "workspace:*",
"@tanstack/react-virtual": "^3.13.24",
"lightweight-charts": "^4.2.0", "lightweight-charts": "^4.2.0",
"next": "^14.2.4", "next": "^14.2.4",
"react": "^18.3.1", "react": "^18.3.1",
@ -208,6 +209,10 @@
"@swc/helpers": ["@swc/helpers@0.5.5", "", { "dependencies": { "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A=="], "@swc/helpers": ["@swc/helpers@0.5.5", "", { "dependencies": { "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.24", "", { "dependencies": { "@tanstack/virtual-core": "3.14.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.14.0", "", {}, "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q=="],
"@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="], "@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],

View file

@ -54,6 +54,24 @@ export const LiveChannelSchema = z.enum([
export type LiveChannel = z.infer<typeof LiveChannelSchema>; export type LiveChannel = z.infer<typeof LiveChannelSchema>;
export type LiveGenericChannel = z.infer<typeof LiveGenericChannelSchema>; export type LiveGenericChannel = z.infer<typeof LiveGenericChannelSchema>;
export const LiveHotChannelSchema = z.enum(["options", "nbbo", "equities", "flow"]);
export type LiveHotChannel = z.infer<typeof LiveHotChannelSchema>;
export const LiveChannelHealthSchema = z.object({
freshness_age_ms: z.number().int().nonnegative().nullable(),
healthy: z.boolean()
});
export type LiveChannelHealth = z.infer<typeof LiveChannelHealthSchema>;
export const LiveHotChannelHealthSchema = z.object({
options: LiveChannelHealthSchema,
nbbo: LiveChannelHealthSchema,
equities: LiveChannelHealthSchema,
flow: LiveChannelHealthSchema
});
export type LiveHotChannelHealthMap = z.infer<typeof LiveHotChannelHealthSchema>;
export const LiveSubscriptionSchema = z.discriminatedUnion("channel", [ export const LiveSubscriptionSchema = z.discriminatedUnion("channel", [
z.object({ z.object({
@ -152,7 +170,8 @@ export const LiveClientMessageSchema = z.discriminatedUnion("op", [
export type LiveClientMessage = z.infer<typeof LiveClientMessageSchema>; export type LiveClientMessage = z.infer<typeof LiveClientMessageSchema>;
export const LiveReadyMessageSchema = z.object({ export const LiveReadyMessageSchema = z.object({
op: z.literal("ready") op: z.literal("ready"),
channel_health: LiveHotChannelHealthSchema
}); });
export type LiveReadyMessage = z.infer<typeof LiveReadyMessageSchema>; export type LiveReadyMessage = z.infer<typeof LiveReadyMessageSchema>;
@ -175,7 +194,8 @@ export type LiveEventMessage = z.infer<typeof LiveEventMessageSchema>;
export const LiveHeartbeatMessageSchema = z.object({ export const LiveHeartbeatMessageSchema = z.object({
op: z.literal("heartbeat"), op: z.literal("heartbeat"),
ts: z.number().int().nonnegative() ts: z.number().int().nonnegative(),
channel_health: LiveHotChannelHealthSchema
}); });
export type LiveHeartbeatMessage = z.infer<typeof LiveHeartbeatMessageSchema>; export type LiveHeartbeatMessage = z.infer<typeof LiveHeartbeatMessageSchema>;

View file

@ -112,7 +112,7 @@ import {
} from "@islandflow/types"; } from "@islandflow/types";
import { createClient } from "redis"; import { createClient } from "redis";
import { z } from "zod"; import { z } from "zod";
import { LiveStateManager, shouldFanoutLiveEvent } from "./live"; import { HOT_LIVE_REDIS_KEYS, LiveStateManager, shouldFanoutLiveEvent } from "./live";
const service = "api"; const service = "api";
const logger = createLogger({ service }); const logger = createLogger({ service });
@ -138,13 +138,6 @@ const state = {
shutdownPromise: null as Promise<void> | null shutdownPromise: null as Promise<void> | null
}; };
const HOT_LIVE_REDIS_KEYS = {
options: "live:options",
equities: "live:equities",
flow: "live:flow",
nbbo: "live:nbbo"
} as const;
const getErrorMessage = (error: unknown): string => { const getErrorMessage = (error: unknown): string => {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
}; };
@ -908,6 +901,7 @@ const run = async () => {
}; };
const liveStateMetricsTimer = setInterval(() => { const liveStateMetricsTimer = setInterval(() => {
const snapshot = liveState.getStatsSnapshot(); const snapshot = liveState.getStatsSnapshot();
const hotFeedHealth = liveState.getHotChannelHealth();
const hotFeedLagMs = { const hotFeedLagMs = {
options: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.options] ?? null, options: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.options] ?? null,
equities: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.equities] ?? null, equities: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.equities] ?? null,
@ -916,7 +910,12 @@ const run = async () => {
}; };
logger.info("live cache metrics", { logger.info("live cache metrics", {
...snapshot, ...snapshot,
hotFeedLagMs hotFeedLagMs,
hotFeedHealth,
snapshotSourceCounts: {
generic_cache_snapshot: snapshot.genericCacheSnapshots,
scoped_clickhouse_snapshot: snapshot.scopedClickHouseSnapshots
}
}); });
warnLiveLag("options", hotFeedLagMs.options); warnLiveLag("options", hotFeedLagMs.options);
warnLiveLag("equities", hotFeedLagMs.equities); warnLiveLag("equities", hotFeedLagMs.equities);
@ -1892,9 +1891,13 @@ const run = async () => {
websocket: { websocket: {
open: (socket: any) => { open: (socket: any) => {
if (socket.data.channel === "live") { if (socket.data.channel === "live") {
sendLiveMessage(socket, { op: "ready" }); sendLiveMessage(socket, { op: "ready", channel_health: liveState.getHotChannelHealth() });
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
sendLiveMessage(socket, { op: "heartbeat", ts: Date.now() }); sendLiveMessage(socket, {
op: "heartbeat",
ts: Date.now(),
channel_health: liveState.getHotChannelHealth()
});
}, 15000); }, 15000);
liveHeartbeats.set(socket, heartbeat); liveHeartbeats.set(socket, heartbeat);
} else if (socket.data.channel === "options") { } else if (socket.data.channel === "options") {
@ -1935,7 +1938,11 @@ const run = async () => {
: new TextDecoder().decode(message instanceof Uint8Array ? message : new Uint8Array(message)); : new TextDecoder().decode(message instanceof Uint8Array ? message : new Uint8Array(message));
const parsed = LiveClientMessageSchema.parse(JSON.parse(payload)); const parsed = LiveClientMessageSchema.parse(JSON.parse(payload));
if (parsed.op === "ping") { if (parsed.op === "ping") {
sendLiveMessage(socket, { op: "heartbeat", ts: Date.now() }); sendLiveMessage(socket, {
op: "heartbeat",
ts: Date.now(),
channel_health: liveState.getHotChannelHealth()
});
return; return;
} }

View file

@ -25,7 +25,10 @@ import {
FeedSnapshot, FeedSnapshot,
FlowPacketSchema, FlowPacketSchema,
InferredDarkEventSchema, InferredDarkEventSchema,
LiveChannelHealth,
LiveGenericChannel, LiveGenericChannel,
LiveHotChannel,
LiveHotChannelHealthMap,
LiveSubscription, LiveSubscription,
matchesFlowPacketFilters, matchesFlowPacketFilters,
matchesOptionPrintFilters, matchesOptionPrintFilters,
@ -81,6 +84,13 @@ export const LIVE_FRESHNESS_THRESHOLDS: Partial<Record<LiveGenericChannel, numbe
flow: 30_000 flow: 30_000
}; };
export const HOT_LIVE_REDIS_KEYS = {
options: "live:options",
equities: "live:equities",
flow: "live:flow",
nbbo: "live:nbbo"
} as const satisfies Record<LiveHotChannel, string>;
export type GenericLiveLimits = Record<LiveGenericChannel, number>; export type GenericLiveLimits = Record<LiveGenericChannel, number>;
const parseGenericLimit = ( const parseGenericLimit = (
@ -357,6 +367,8 @@ export class LiveStateManager {
private readonly stats = { private readonly stats = {
genericHydrateFromRedis: 0, genericHydrateFromRedis: 0,
genericHydrateFromClickHouse: 0, genericHydrateFromClickHouse: 0,
genericCacheSnapshots: 0,
scopedClickHouseSnapshots: 0,
trimOperations: 0, trimOperations: 0,
cacheDepthByKey: new Map<string, number>(), cacheDepthByKey: new Map<string, number>(),
freshnessAgeMsByKey: new Map<string, number>() freshnessAgeMsByKey: new Map<string, number>()
@ -373,6 +385,8 @@ export class LiveStateManager {
getStatsSnapshot(): { getStatsSnapshot(): {
genericHydrateFromRedis: number; genericHydrateFromRedis: number;
genericHydrateFromClickHouse: number; genericHydrateFromClickHouse: number;
genericCacheSnapshots: number;
scopedClickHouseSnapshots: number;
trimOperations: number; trimOperations: number;
cacheDepthByKey: Record<string, number>; cacheDepthByKey: Record<string, number>;
freshnessAgeMsByKey: Record<string, number>; freshnessAgeMsByKey: Record<string, number>;
@ -380,12 +394,37 @@ export class LiveStateManager {
return { return {
genericHydrateFromRedis: this.stats.genericHydrateFromRedis, genericHydrateFromRedis: this.stats.genericHydrateFromRedis,
genericHydrateFromClickHouse: this.stats.genericHydrateFromClickHouse, genericHydrateFromClickHouse: this.stats.genericHydrateFromClickHouse,
genericCacheSnapshots: this.stats.genericCacheSnapshots,
scopedClickHouseSnapshots: this.stats.scopedClickHouseSnapshots,
trimOperations: this.stats.trimOperations, trimOperations: this.stats.trimOperations,
cacheDepthByKey: Object.fromEntries(this.stats.cacheDepthByKey), cacheDepthByKey: Object.fromEntries(this.stats.cacheDepthByKey),
freshnessAgeMsByKey: Object.fromEntries(this.stats.freshnessAgeMsByKey) freshnessAgeMsByKey: Object.fromEntries(this.stats.freshnessAgeMsByKey)
}; };
} }
getHotChannelHealth(): LiveHotChannelHealthMap {
return {
options: this.getChannelHealth("options"),
nbbo: this.getChannelHealth("nbbo"),
equities: this.getChannelHealth("equities"),
flow: this.getChannelHealth("flow")
};
}
private getChannelHealth(channel: LiveHotChannel): LiveChannelHealth {
const listKey = HOT_LIVE_REDIS_KEYS[channel];
const thresholdMs = LIVE_FRESHNESS_THRESHOLDS[channel];
const freshnessAgeMs = this.stats.freshnessAgeMsByKey.get(listKey) ?? null;
return {
freshness_age_ms: freshnessAgeMs,
healthy:
freshnessAgeMs !== null &&
typeof thresholdMs === "number" &&
Number.isFinite(freshnessAgeMs) &&
freshnessAgeMs <= thresholdMs
};
}
private updateFreshnessMetric(listKey: string, channel: LiveChannel, item: unknown, now = Date.now()): void { private updateFreshnessMetric(listKey: string, channel: LiveChannel, item: unknown, now = Date.now()): void {
const ts = const ts =
channel === "equity-candles" || channel === "equity-overlay" channel === "equity-candles" || channel === "equity-overlay"
@ -448,6 +487,7 @@ export class LiveStateManager {
const scoped = const scoped =
Boolean(subscription.underlying_ids?.length) || Boolean(subscription.option_contract_id); Boolean(subscription.underlying_ids?.length) || Boolean(subscription.option_contract_id);
if (subscription.filters?.view === "raw" || scoped) { if (subscription.filters?.view === "raw" || scoped) {
this.stats.scopedClickHouseSnapshots += 1;
const limit = snapshotLimitFor(subscription, this.generic.options.limit); const limit = snapshotLimitFor(subscription, this.generic.options.limit);
const storageFilters: OptionPrintQueryFilters = { const storageFilters: OptionPrintQueryFilters = {
view: subscription.filters?.view ?? "signal", view: subscription.filters?.view ?? "signal",
@ -476,6 +516,7 @@ export class LiveStateManager {
} }
const config = this.generic.options; const config = this.generic.options;
this.stats.genericCacheSnapshots += 1;
const limit = snapshotLimitFor(subscription, config.limit); const limit = snapshotLimitFor(subscription, config.limit);
const items = (this.genericItems.get("options") ?? []).filter((item) => const items = (this.genericItems.get("options") ?? []).filter((item) =>
matchesOptionPrintFilters(item, subscription.filters) matchesOptionPrintFilters(item, subscription.filters)
@ -489,6 +530,7 @@ export class LiveStateManager {
} }
case "flow": { case "flow": {
const config = this.generic.flow; const config = this.generic.flow;
this.stats.genericCacheSnapshots += 1;
const limit = snapshotLimitFor(subscription, config.limit); const limit = snapshotLimitFor(subscription, config.limit);
const items = (this.genericItems.get("flow") ?? []).filter((item) => const items = (this.genericItems.get("flow") ?? []).filter((item) =>
matchesFlowPacketFilters(item, subscription.filters) matchesFlowPacketFilters(item, subscription.filters)
@ -504,6 +546,7 @@ export class LiveStateManager {
const config = this.generic.equities; const config = this.generic.equities;
const limit = snapshotLimitFor(subscription, config.limit); const limit = snapshotLimitFor(subscription, config.limit);
if (subscription.underlying_ids?.length) { if (subscription.underlying_ids?.length) {
this.stats.scopedClickHouseSnapshots += 1;
const filters: EquityPrintQueryFilters = { const filters: EquityPrintQueryFilters = {
underlyingIds: subscription.underlying_ids underlyingIds: subscription.underlying_ids
}; };
@ -515,6 +558,7 @@ export class LiveStateManager {
next_before: nextBeforeForItems(items, config.cursor) next_before: nextBeforeForItems(items, config.cursor)
}; };
} }
this.stats.genericCacheSnapshots += 1;
const items = (this.genericItems.get("equities") ?? []).slice(0, limit); const items = (this.genericItems.get("equities") ?? []).slice(0, limit);
return { return {
subscription, subscription,
@ -553,6 +597,7 @@ export class LiveStateManager {
} }
default: { default: {
const config = this.generic[subscription.channel]; const config = this.generic[subscription.channel];
this.stats.genericCacheSnapshots += 1;
const limit = snapshotLimitFor(subscription, config.limit); const limit = snapshotLimitFor(subscription, config.limit);
const items = (this.genericItems.get(subscription.channel) ?? []).slice(0, limit); const items = (this.genericItems.get(subscription.channel) ?? []).slice(0, limit);
return { return {

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import type { ClickHouseClient } from "@islandflow/storage"; import type { ClickHouseClient } from "@islandflow/storage";
import { import {
HOT_LIVE_REDIS_KEYS,
LiveStateManager, LiveStateManager,
isLiveItemFresh, isLiveItemFresh,
resolveGenericLiveLimits, resolveGenericLiveLimits,
@ -729,6 +730,122 @@ describe("LiveStateManager", () => {
expect(persisted).toHaveLength(1); expect(persisted).toHaveLength(1);
}); });
it("includes hot-channel health for options, nbbo, equities, and flow", async () => {
const manager = new LiveStateManager(makeClickHouse(), null);
const now = Date.now();
await manager.ingest("options", {
source_ts: now,
ingest_ts: now + 1,
seq: 1,
trace_id: "opt-health",
ts: now,
option_contract_id: "AAPL-2025-01-17-200-C",
price: 1,
size: 10,
exchange: "X"
});
await manager.ingest("nbbo", {
source_ts: now,
ingest_ts: now + 1,
seq: 1,
trace_id: "nbbo-health",
ts: now,
option_contract_id: "AAPL-2025-01-17-200-C",
bid: 1,
ask: 1.1,
bidSize: 10,
askSize: 10
});
await manager.ingest("equities", {
source_ts: now,
ingest_ts: now + 1,
seq: 1,
trace_id: "eq-health",
ts: now,
underlying_id: "AAPL",
price: 100,
size: 10,
exchange: "X",
offExchangeFlag: false
});
await manager.ingest("flow", {
source_ts: now,
ingest_ts: now + 1,
seq: 1,
trace_id: "flow-health",
id: "flow-health",
members: [],
features: {},
join_quality: {}
});
const health = manager.getHotChannelHealth();
expect(health.options.healthy).toBe(true);
expect(health.nbbo.healthy).toBe(true);
expect(health.equities.healthy).toBe(true);
expect(health.flow.healthy).toBe(true);
expect(health.options.freshness_age_ms).not.toBeNull();
expect(health.nbbo.freshness_age_ms).not.toBeNull();
expect(health.equities.freshness_age_ms).not.toBeNull();
expect(health.flow.freshness_age_ms).not.toBeNull();
});
it("tracks generic cache and scoped clickhouse snapshot sources separately", async () => {
const manager = new LiveStateManager(makeClickHouse(() => []), null);
const now = Date.now();
await manager.ingest("options", {
source_ts: now,
ingest_ts: now + 1,
seq: 1,
trace_id: "opt-snapshot",
ts: now,
option_contract_id: "SPY-2025-01-17-500-C",
price: 1,
size: 10,
exchange: "X"
});
await manager.getSnapshot({ channel: "options" });
await manager.getSnapshot({
channel: "options",
underlying_ids: ["QQQ"],
option_contract_id: "QQQ-2025-01-17-400-C"
});
const stats = manager.getStatsSnapshot();
expect(stats.genericCacheSnapshots).toBe(1);
expect(stats.scopedClickHouseSnapshots).toBe(1);
});
it("keeps backend channel health healthy when a scoped query is quiet", async () => {
const manager = new LiveStateManager(makeClickHouse(() => []), null);
const now = Date.now();
await manager.ingest("options", {
source_ts: now,
ingest_ts: now + 1,
seq: 1,
trace_id: "opt-global",
ts: now,
option_contract_id: "SPY-2025-01-17-500-C",
price: 1,
size: 10,
exchange: "X"
});
const quietSnapshot = await manager.getSnapshot({
channel: "options",
underlying_ids: ["QQQ"],
option_contract_id: "QQQ-2025-01-17-400-C"
});
expect(quietSnapshot.items).toEqual([]);
expect(manager.getHotChannelHealth().options.healthy).toBe(true);
expect(manager.getStatsSnapshot().freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.options]).toBeLessThanOrEqual(50);
});
it("exposes freshness helper for feed status", () => { it("exposes freshness helper for feed status", () => {
expect(isLiveItemFresh("options", { ts: 1000 }, 1010)).toBe(true); expect(isLiveItemFresh("options", { ts: 1000 }, 1010)).toBe(true);
expect(isLiveItemFresh("options", { ts: 1000 }, 20_001)).toBe(false); expect(isLiveItemFresh("options", { ts: 1000 }, 20_001)).toBe(false);