Stabilize tape virtualization and scoped live health
This commit is contained in:
parent
034d24f8ac
commit
e69bf295c8
11 changed files with 866 additions and 273 deletions
|
|
@ -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}
|
||||||
|
|
|
||||||
10
.env.example
10
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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",
|
||||||
|
|
|
||||||
5
bun.lock
5
bun.lock
|
|
@ -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=="],
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue