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-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}
|
||||
|
|
|
|||
10
.env.example
10
.env.example
|
|
@ -58,8 +58,8 @@ API_DELIVER_POLICY=new
|
|||
API_CONSUMER_RESET=false
|
||||
NBBO_MAX_AGE_MS=1000
|
||||
NEXT_PUBLIC_NBBO_MAX_AGE_MS=1000
|
||||
NEXT_PUBLIC_LIVE_HOT_WINDOW=2000
|
||||
NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS=25000
|
||||
NEXT_PUBLIC_LIVE_HOT_WINDOW=600
|
||||
NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS=1200
|
||||
NEXT_PUBLIC_PINNED_EVIDENCE_TTL_MS=1200000
|
||||
NEXT_PUBLIC_PINNED_EVIDENCE_MAX_ITEMS=4000
|
||||
ROLLING_WINDOW_SIZE=50
|
||||
|
|
@ -100,12 +100,12 @@ REPLAY_BATCH_SIZE=200
|
|||
REPLAY_LOG_EVERY=1000
|
||||
|
||||
# API live retention (generic channels)
|
||||
LIVE_LIMIT_OPTIONS=10000
|
||||
LIVE_LIMIT_OPTIONS=2000
|
||||
LIVE_LIMIT_NBBO=10000
|
||||
LIVE_LIMIT_EQUITIES=10000
|
||||
LIVE_LIMIT_EQUITIES=2000
|
||||
LIVE_LIMIT_EQUITY_QUOTES=10000
|
||||
LIVE_LIMIT_EQUITY_JOINS=10000
|
||||
LIVE_LIMIT_FLOW=10000
|
||||
LIVE_LIMIT_FLOW=2000
|
||||
LIVE_LIMIT_CLASSIFIER_HITS=10000
|
||||
LIVE_LIMIT_ALERTS=10000
|
||||
LIVE_LIMIT_INFERRED_DARK=10000
|
||||
|
|
|
|||
|
|
@ -967,6 +967,11 @@ h3 {
|
|||
min-width: 980px;
|
||||
}
|
||||
|
||||
.data-table-body {
|
||||
position: relative;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.data-table-options {
|
||||
min-width: 1280px;
|
||||
}
|
||||
|
|
@ -1024,10 +1029,16 @@ h3 {
|
|||
text-align: left;
|
||||
}
|
||||
|
||||
.data-table-row:nth-child(even) {
|
||||
.data-table-row.is-even {
|
||||
background: rgba(255, 255, 255, 0.022);
|
||||
}
|
||||
|
||||
.data-table-virtual-row {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-table-row:hover,
|
||||
.data-table-row:focus-visible {
|
||||
outline: none;
|
||||
|
|
|
|||
|
|
@ -5,12 +5,15 @@ import {
|
|||
appendHistoryTail,
|
||||
buildDefaultFlowFilters,
|
||||
classifierToneForFamily,
|
||||
composeTapeItems,
|
||||
deriveAlertDirection,
|
||||
countActiveFlowFilterGroups,
|
||||
findAnchorRestoreIndex,
|
||||
formatCompactUsd,
|
||||
formatOptionContractLabel,
|
||||
flushPausableTapeData,
|
||||
getAlertWindowAnchorTs,
|
||||
getHotChannelFeedStatus,
|
||||
getScopedLiveAutoHydrationChannels,
|
||||
getLiveHistoryRetentionCap,
|
||||
getOptionTableSnapshot,
|
||||
|
|
@ -246,6 +249,37 @@ describe("live tape pausable 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", () => {
|
||||
const currentHot = [makeItem("hot-3", 3, 300), makeItem("hot-2", 2, 200), makeItem("hot-1", 1, 100)];
|
||||
const incoming = [makeItem("hot-4", 4, 400)];
|
||||
|
|
@ -362,6 +396,21 @@ describe("live tape history helpers", () => {
|
|||
}, {})
|
||||
).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", () => {
|
||||
|
|
@ -533,4 +582,13 @@ describe("signals helpers", () => {
|
|||
expect(statusLabel("connected", false, "live")).toBe("Connected");
|
||||
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": {
|
||||
"@islandflow/types": "workspace:*",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"lightweight-charts": "^4.2.0",
|
||||
"next": "^14.2.4",
|
||||
"react": "^18.3.1",
|
||||
|
|
|
|||
5
bun.lock
5
bun.lock
|
|
@ -12,6 +12,7 @@
|
|||
"name": "@islandflow/web",
|
||||
"dependencies": {
|
||||
"@islandflow/types": "workspace:*",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"lightweight-charts": "^4.2.0",
|
||||
"next": "^14.2.4",
|
||||
"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=="],
|
||||
|
||||
"@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/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 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", [
|
||||
z.object({
|
||||
|
|
@ -152,7 +170,8 @@ export const LiveClientMessageSchema = z.discriminatedUnion("op", [
|
|||
export type LiveClientMessage = z.infer<typeof LiveClientMessageSchema>;
|
||||
|
||||
export const LiveReadyMessageSchema = z.object({
|
||||
op: z.literal("ready")
|
||||
op: z.literal("ready"),
|
||||
channel_health: LiveHotChannelHealthSchema
|
||||
});
|
||||
|
||||
export type LiveReadyMessage = z.infer<typeof LiveReadyMessageSchema>;
|
||||
|
|
@ -175,7 +194,8 @@ export type LiveEventMessage = z.infer<typeof LiveEventMessageSchema>;
|
|||
|
||||
export const LiveHeartbeatMessageSchema = z.object({
|
||||
op: z.literal("heartbeat"),
|
||||
ts: z.number().int().nonnegative()
|
||||
ts: z.number().int().nonnegative(),
|
||||
channel_health: LiveHotChannelHealthSchema
|
||||
});
|
||||
|
||||
export type LiveHeartbeatMessage = z.infer<typeof LiveHeartbeatMessageSchema>;
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ import {
|
|||
} from "@islandflow/types";
|
||||
import { createClient } from "redis";
|
||||
import { z } from "zod";
|
||||
import { LiveStateManager, shouldFanoutLiveEvent } from "./live";
|
||||
import { HOT_LIVE_REDIS_KEYS, LiveStateManager, shouldFanoutLiveEvent } from "./live";
|
||||
|
||||
const service = "api";
|
||||
const logger = createLogger({ service });
|
||||
|
|
@ -138,13 +138,6 @@ const state = {
|
|||
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 => {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
};
|
||||
|
|
@ -908,6 +901,7 @@ const run = async () => {
|
|||
};
|
||||
const liveStateMetricsTimer = setInterval(() => {
|
||||
const snapshot = liveState.getStatsSnapshot();
|
||||
const hotFeedHealth = liveState.getHotChannelHealth();
|
||||
const hotFeedLagMs = {
|
||||
options: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.options] ?? null,
|
||||
equities: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.equities] ?? null,
|
||||
|
|
@ -916,7 +910,12 @@ const run = async () => {
|
|||
};
|
||||
logger.info("live cache metrics", {
|
||||
...snapshot,
|
||||
hotFeedLagMs
|
||||
hotFeedLagMs,
|
||||
hotFeedHealth,
|
||||
snapshotSourceCounts: {
|
||||
generic_cache_snapshot: snapshot.genericCacheSnapshots,
|
||||
scoped_clickhouse_snapshot: snapshot.scopedClickHouseSnapshots
|
||||
}
|
||||
});
|
||||
warnLiveLag("options", hotFeedLagMs.options);
|
||||
warnLiveLag("equities", hotFeedLagMs.equities);
|
||||
|
|
@ -1892,9 +1891,13 @@ const run = async () => {
|
|||
websocket: {
|
||||
open: (socket: any) => {
|
||||
if (socket.data.channel === "live") {
|
||||
sendLiveMessage(socket, { op: "ready" });
|
||||
sendLiveMessage(socket, { op: "ready", channel_health: liveState.getHotChannelHealth() });
|
||||
const heartbeat = setInterval(() => {
|
||||
sendLiveMessage(socket, { op: "heartbeat", ts: Date.now() });
|
||||
sendLiveMessage(socket, {
|
||||
op: "heartbeat",
|
||||
ts: Date.now(),
|
||||
channel_health: liveState.getHotChannelHealth()
|
||||
});
|
||||
}, 15000);
|
||||
liveHeartbeats.set(socket, heartbeat);
|
||||
} else if (socket.data.channel === "options") {
|
||||
|
|
@ -1935,7 +1938,11 @@ const run = async () => {
|
|||
: new TextDecoder().decode(message instanceof Uint8Array ? message : new Uint8Array(message));
|
||||
const parsed = LiveClientMessageSchema.parse(JSON.parse(payload));
|
||||
if (parsed.op === "ping") {
|
||||
sendLiveMessage(socket, { op: "heartbeat", ts: Date.now() });
|
||||
sendLiveMessage(socket, {
|
||||
op: "heartbeat",
|
||||
ts: Date.now(),
|
||||
channel_health: liveState.getHotChannelHealth()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ import {
|
|||
FeedSnapshot,
|
||||
FlowPacketSchema,
|
||||
InferredDarkEventSchema,
|
||||
LiveChannelHealth,
|
||||
LiveGenericChannel,
|
||||
LiveHotChannel,
|
||||
LiveHotChannelHealthMap,
|
||||
LiveSubscription,
|
||||
matchesFlowPacketFilters,
|
||||
matchesOptionPrintFilters,
|
||||
|
|
@ -81,6 +84,13 @@ export const LIVE_FRESHNESS_THRESHOLDS: Partial<Record<LiveGenericChannel, numbe
|
|||
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>;
|
||||
|
||||
const parseGenericLimit = (
|
||||
|
|
@ -357,6 +367,8 @@ export class LiveStateManager {
|
|||
private readonly stats = {
|
||||
genericHydrateFromRedis: 0,
|
||||
genericHydrateFromClickHouse: 0,
|
||||
genericCacheSnapshots: 0,
|
||||
scopedClickHouseSnapshots: 0,
|
||||
trimOperations: 0,
|
||||
cacheDepthByKey: new Map<string, number>(),
|
||||
freshnessAgeMsByKey: new Map<string, number>()
|
||||
|
|
@ -373,6 +385,8 @@ export class LiveStateManager {
|
|||
getStatsSnapshot(): {
|
||||
genericHydrateFromRedis: number;
|
||||
genericHydrateFromClickHouse: number;
|
||||
genericCacheSnapshots: number;
|
||||
scopedClickHouseSnapshots: number;
|
||||
trimOperations: number;
|
||||
cacheDepthByKey: Record<string, number>;
|
||||
freshnessAgeMsByKey: Record<string, number>;
|
||||
|
|
@ -380,12 +394,37 @@ export class LiveStateManager {
|
|||
return {
|
||||
genericHydrateFromRedis: this.stats.genericHydrateFromRedis,
|
||||
genericHydrateFromClickHouse: this.stats.genericHydrateFromClickHouse,
|
||||
genericCacheSnapshots: this.stats.genericCacheSnapshots,
|
||||
scopedClickHouseSnapshots: this.stats.scopedClickHouseSnapshots,
|
||||
trimOperations: this.stats.trimOperations,
|
||||
cacheDepthByKey: Object.fromEntries(this.stats.cacheDepthByKey),
|
||||
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 {
|
||||
const ts =
|
||||
channel === "equity-candles" || channel === "equity-overlay"
|
||||
|
|
@ -448,6 +487,7 @@ export class LiveStateManager {
|
|||
const scoped =
|
||||
Boolean(subscription.underlying_ids?.length) || Boolean(subscription.option_contract_id);
|
||||
if (subscription.filters?.view === "raw" || scoped) {
|
||||
this.stats.scopedClickHouseSnapshots += 1;
|
||||
const limit = snapshotLimitFor(subscription, this.generic.options.limit);
|
||||
const storageFilters: OptionPrintQueryFilters = {
|
||||
view: subscription.filters?.view ?? "signal",
|
||||
|
|
@ -476,6 +516,7 @@ export class LiveStateManager {
|
|||
}
|
||||
|
||||
const config = this.generic.options;
|
||||
this.stats.genericCacheSnapshots += 1;
|
||||
const limit = snapshotLimitFor(subscription, config.limit);
|
||||
const items = (this.genericItems.get("options") ?? []).filter((item) =>
|
||||
matchesOptionPrintFilters(item, subscription.filters)
|
||||
|
|
@ -489,6 +530,7 @@ export class LiveStateManager {
|
|||
}
|
||||
case "flow": {
|
||||
const config = this.generic.flow;
|
||||
this.stats.genericCacheSnapshots += 1;
|
||||
const limit = snapshotLimitFor(subscription, config.limit);
|
||||
const items = (this.genericItems.get("flow") ?? []).filter((item) =>
|
||||
matchesFlowPacketFilters(item, subscription.filters)
|
||||
|
|
@ -504,6 +546,7 @@ export class LiveStateManager {
|
|||
const config = this.generic.equities;
|
||||
const limit = snapshotLimitFor(subscription, config.limit);
|
||||
if (subscription.underlying_ids?.length) {
|
||||
this.stats.scopedClickHouseSnapshots += 1;
|
||||
const filters: EquityPrintQueryFilters = {
|
||||
underlyingIds: subscription.underlying_ids
|
||||
};
|
||||
|
|
@ -515,6 +558,7 @@ export class LiveStateManager {
|
|||
next_before: nextBeforeForItems(items, config.cursor)
|
||||
};
|
||||
}
|
||||
this.stats.genericCacheSnapshots += 1;
|
||||
const items = (this.genericItems.get("equities") ?? []).slice(0, limit);
|
||||
return {
|
||||
subscription,
|
||||
|
|
@ -553,6 +597,7 @@ export class LiveStateManager {
|
|||
}
|
||||
default: {
|
||||
const config = this.generic[subscription.channel];
|
||||
this.stats.genericCacheSnapshots += 1;
|
||||
const limit = snapshotLimitFor(subscription, config.limit);
|
||||
const items = (this.genericItems.get(subscription.channel) ?? []).slice(0, limit);
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
import type { ClickHouseClient } from "@islandflow/storage";
|
||||
import {
|
||||
HOT_LIVE_REDIS_KEYS,
|
||||
LiveStateManager,
|
||||
isLiveItemFresh,
|
||||
resolveGenericLiveLimits,
|
||||
|
|
@ -729,6 +730,122 @@ describe("LiveStateManager", () => {
|
|||
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", () => {
|
||||
expect(isLiveItemFresh("options", { ts: 1000 }, 1010)).toBe(true);
|
||||
expect(isLiveItemFresh("options", { ts: 1000 }, 20_001)).toBe(false);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue