From 57104e1ca9105b1779b87b7c771be538fb109582 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 20 May 2026 06:40:06 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E9=A1=B5=20Redis=20=E8=81=9A=E5=90=88?= =?UTF-8?q?=E4=B8=8E=E8=BD=AE=E8=AF=A2=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[locale]/status/[slug]/page.tsx | 2 +- .../_components/public-status-timeline.tsx | 103 ++- .../status/_components/public-status-view.tsx | 49 +- src/lib/model-vendor-icons.tsx | 176 +---- src/lib/model-vendor-rules.ts | 124 +++ src/lib/public-status/aggregation-core.ts | 35 + src/lib/public-status/aggregation.ts | 37 +- src/lib/public-status/config-publisher.ts | 2 + src/lib/public-status/config-snapshot.ts | 109 ++- src/lib/public-status/read-store.ts | 219 +++++- src/lib/public-status/rebuild-hints.ts | 9 + src/lib/public-status/rebuild-worker.ts | 79 +- src/lib/public-status/redis-contract.ts | 72 +- src/lib/public-status/rollup-store.ts | 736 ++++++++++++++++++ src/lib/public-status/scheduler.ts | 4 +- src/lib/public-status/vendor-icon-key.ts | 2 +- src/repository/message.ts | 108 +++ .../public-status/config-publisher.test.ts | 1 + .../public-status/config-snapshot.test.ts | 50 ++ .../public-status/no-db-import-guard.test.ts | 4 + .../public-status/public-status-view.test.tsx | 109 ++- tests/unit/public-status/read-store.test.ts | 226 ++++++ .../unit/public-status/rebuild-worker.test.ts | 148 +++- .../unit/public-status/redis-contract.test.ts | 58 ++ tests/unit/public-status/rollup-store.test.ts | 324 ++++++++ .../message-public-status-rollup.test.ts | 199 +++++ 26 files changed, 2640 insertions(+), 345 deletions(-) create mode 100644 src/lib/model-vendor-rules.ts create mode 100644 src/lib/public-status/aggregation-core.ts create mode 100644 src/lib/public-status/rollup-store.ts create mode 100644 tests/unit/public-status/rollup-store.test.ts create mode 100644 tests/unit/repository/message-public-status-rollup.test.ts diff --git a/src/app/[locale]/status/[slug]/page.tsx b/src/app/[locale]/status/[slug]/page.tsx index 0ad1e3fb7..2190592df 100644 --- a/src/app/[locale]/status/[slug]/page.tsx +++ b/src/app/[locale]/status/[slug]/page.tsx @@ -46,7 +46,7 @@ export default async function PublicStatusGroupPage({ targetGroup, } = await loadGroupContext(slug); if (!targetGroup) { - notFound(); + return notFound(); } const filteredPayload = { ...initialPayload, groups: [targetGroup] }; diff --git a/src/app/[locale]/status/_components/public-status-timeline.tsx b/src/app/[locale]/status/_components/public-status-timeline.tsx index e31dca979..d41293847 100644 --- a/src/app/[locale]/status/_components/public-status-timeline.tsx +++ b/src/app/[locale]/status/_components/public-status-timeline.tsx @@ -1,6 +1,6 @@ "use client"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useMemo, useState } from "react"; import { cn } from "@/lib/utils"; import type { FilledTimelineCell } from "../_lib/fill-display-timeline"; import { formatTtfb } from "../_lib/format-ttfb"; @@ -74,8 +74,36 @@ export function PublicStatusTimeline({ locale, labels, }: PublicStatusTimelineProps) { + const [activeIndex, setActiveIndex] = useState(null); + const activeCell = activeIndex === null ? null : (cells[activeIndex] ?? null); + const activeBucket = activeCell?.bucket ?? null; + const activeIsPlaceholder = activeBucket?.bucketStart.startsWith("empty-") ?? false; + const activeSummary = useMemo(() => { + if (!activeBucket) { + return null; + } + + return { + range: activeIsPlaceholder + ? null + : formatRange(activeBucket.bucketStart, activeBucket.bucketEnd, locale, timeZone), + availability: + activeBucket.availabilityPct === null ? "—" : `${activeBucket.availabilityPct.toFixed(2)}%`, + ttfb: formatTtfb(activeBucket.ttfbMs), + tps: activeBucket.tps === null ? "—" : activeBucket.tps.toFixed(1), + }; + }, [activeBucket, activeIsPlaceholder, locale, timeZone]); + return ( - +
setActiveIndex(null)} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + setActiveIndex(null); + } + }} + >
{cells.map((cell, index) => { const { bucket } = cell; - const isPlaceholder = bucket.bucketStart.startsWith("empty-"); return ( - - -
- + {activeSummary ? ( +
+ {activeSummary.range ? ( +

{activeSummary.range}

+ ) : null} +
+ + {labels.availability}{" "} + {activeSummary.availability} + + + {labels.ttfb} {activeSummary.ttfb} + + + {labels.tps} {activeSummary.tps} + +
+
+ ) : null} +
); } diff --git a/src/app/[locale]/status/_components/public-status-view.tsx b/src/app/[locale]/status/_components/public-status-view.tsx index f1cc0ac51..6135b96cd 100644 --- a/src/app/[locale]/status/_components/public-status-view.tsx +++ b/src/app/[locale]/status/_components/public-status-view.tsx @@ -49,6 +49,10 @@ import { SortableGroupPanel } from "./sortable-group-panel"; import { StatusHero } from "./status-hero"; import { StatusToolbar } from "./status-toolbar"; +type ViewModelSnapshot = PublicStatusPayload["groups"][number]["models"][number] & { + timelineReusedFromPrevious?: boolean; +}; + interface PublicStatusViewProps { initialPayload: PublicStatusPayload; initialStatus?: PublicStatusRouteStatus; @@ -151,6 +155,13 @@ function aggregateByFailed(states: DisplayState[]): DisplayState { return "operational"; } +function deriveCurrentModelState(model: ViewModelSnapshot): DisplayState { + if (model.timelineReusedFromPrevious) { + return model.latestState; + } + return deriveLatestModelState(model); +} + export function PublicStatusView({ initialPayload, initialStatus, @@ -184,6 +195,7 @@ export function PublicStatusView({ if (filterSlug) { params.set("groupSlug", filterSlug); } + params.set("include", "meta,defaults,groups"); const requestUrl = params.size > 0 ? `/api/public-status?${params.toString()}` : "/api/public-status"; const response = await fetch(requestUrl, { cache: "no-store" }); @@ -195,7 +207,35 @@ export function PublicStatusView({ ? { ...next, groups: next.groups.filter((g) => g.publicGroupSlug === filterSlug) } : next; startTransition(() => { - setPayload(scoped); + setPayload((previous) => ({ + ...scoped, + groups: scoped.groups.map((group) => { + const previousGroup = previous.groups.find( + (candidate) => candidate.publicGroupSlug === group.publicGroupSlug + ); + if (!previousGroup) { + return group; + } + + return { + ...group, + models: group.models.map((model) => { + const previousModel = previousGroup.models.find( + (candidate) => candidate.publicModelKey === model.publicModelKey + ); + if (model.timeline.length > 0) { + return model; + } + + return { + ...model, + timeline: previousModel?.timeline ?? [], + timelineReusedFromPrevious: Boolean(previousModel?.timeline.length), + }; + }), + }; + }), + })); setRouteStatus(nextResponse.status); }); } catch { @@ -222,9 +262,12 @@ export function PublicStatusView({ const derivedModels = group.models.map((model) => { const filled = fillDisplayTimeline(model.timeline); const chartCells = sliceTimelineForChart(filled, CHART_BUCKETS); - const uptime24h = computeUptimePct(model.timeline); + const uptime24h = + (model as ViewModelSnapshot).timelineReusedFromPrevious && model.availabilityPct !== null + ? model.availabilityPct + : computeUptimePct(model.timeline); const ttfb24h = computeAvgTtfb(model.timeline); - const latest = deriveLatestModelState(model); + const latest = deriveCurrentModelState(model as ViewModelSnapshot); return { model, chartCells, uptime24h, ttfb24h, latest }; }); const issueCount = derivedModels.filter((d) => d.latest === "failed").length; diff --git a/src/lib/model-vendor-icons.tsx b/src/lib/model-vendor-icons.tsx index 069d0426c..e3de1c654 100644 --- a/src/lib/model-vendor-icons.tsx +++ b/src/lib/model-vendor-icons.tsx @@ -33,152 +33,50 @@ import { Yi, Zhipu, } from "@lobehub/icons"; +import { getModelVendor as getModelVendorRule, type ModelVendorRule } from "./model-vendor-rules"; -export interface ModelVendorEntry { - prefix: string; +export interface ModelVendorEntry extends ModelVendorRule { icon: React.ComponentType<{ className?: string }>; - hasColor: boolean; - i18nKey: string; - litellmProvider?: string; } -// Strictly sorted by prefix length descending to ensure longest-match-first. -// Within same length, sorted alphabetically. -const MODEL_VENDOR_RULES: ModelVendorEntry[] = [ - // 9 chars - { - prefix: "codestral", - icon: Mistral.Color, - hasColor: true, - i18nKey: "mistral", - litellmProvider: "mistral", - }, - { prefix: "sensenova", icon: SenseNova.Color, hasColor: true, i18nKey: "sensenova" }, - // 8 chars - { prefix: "baichuan", icon: Baichuan.Color, hasColor: true, i18nKey: "baichuan" }, - { - prefix: "deepseek", - icon: DeepSeek.Color, - hasColor: true, - i18nKey: "deepseek", - litellmProvider: "deepseek", - }, - { prefix: "internlm", icon: InternLM.Color, hasColor: true, i18nKey: "internlm" }, - { prefix: "moonshot", icon: Moonshot, hasColor: false, i18nKey: "moonshot" }, - // 7 chars - { - prefix: "chatglm", - icon: ChatGLM.Color, - hasColor: true, - i18nKey: "zhipuai", - litellmProvider: "zhipuai", - }, - { - prefix: "chatgpt", - icon: OpenAI, - hasColor: false, - i18nKey: "openai", - litellmProvider: "openai", - }, - { - prefix: "command", - icon: Cohere.Color, - hasColor: true, - i18nKey: "cohere", - litellmProvider: "cohere_chat", - }, - { prefix: "hunyuan", icon: Hunyuan.Color, hasColor: true, i18nKey: "hunyuan" }, - { prefix: "minimax", icon: Minimax.Color, hasColor: true, i18nKey: "minimax" }, - { - prefix: "mistral", - icon: Mistral.Color, - hasColor: true, - i18nKey: "mistral", - litellmProvider: "mistral", - }, - { - prefix: "mixtral", - icon: Mistral.Color, - hasColor: true, - i18nKey: "mistral", - litellmProvider: "mistral", - }, - { - prefix: "pixtral", - icon: Mistral.Color, - hasColor: true, - i18nKey: "mistral", - litellmProvider: "mistral", - }, - // 6 chars - { - prefix: "claude", - icon: Claude.Color, - hasColor: true, - i18nKey: "anthropic", - litellmProvider: "anthropic", - }, - { - prefix: "doubao", - icon: Doubao.Color, - hasColor: true, - i18nKey: "volcengine", - litellmProvider: "volcengine", - }, - { - prefix: "gemini", - icon: Gemini.Color, - hasColor: true, - i18nKey: "vertex", - litellmProvider: "vertex_ai-language-models", - }, - { prefix: "nvidia", icon: Nvidia.Color, hasColor: true, i18nKey: "nvidia" }, - { prefix: "wenxin", icon: Wenxin.Color, hasColor: true, i18nKey: "wenxin" }, - // 5 chars - { prefix: "ernie", icon: Wenxin.Color, hasColor: true, i18nKey: "wenxin" }, - { prefix: "gemma", icon: Gemma.Color, hasColor: true, i18nKey: "gemma" }, - { prefix: "llama", icon: Meta.Color, hasColor: true, i18nKey: "meta" }, - { prefix: "sonar", icon: Perplexity.Color, hasColor: true, i18nKey: "perplexity" }, - { prefix: "spark", icon: Spark.Color, hasColor: true, i18nKey: "spark" }, - // 4 chars - { prefix: "abab", icon: Minimax.Color, hasColor: true, i18nKey: "minimax" }, - { prefix: "grok", icon: Grok, hasColor: false, i18nKey: "xai", litellmProvider: "xai" }, - { prefix: "kimi", icon: Kimi.Color, hasColor: true, i18nKey: "kimi" }, - { prefix: "pplx", icon: Perplexity.Color, hasColor: true, i18nKey: "perplexity" }, - { prefix: "qwen", icon: Qwen.Color, hasColor: true, i18nKey: "qwen" }, - { - prefix: "seed", - icon: Doubao.Color, - hasColor: true, - i18nKey: "volcengine", - litellmProvider: "volcengine", - }, - { prefix: "step", icon: Stepfun.Color, hasColor: true, i18nKey: "stepfun" }, - // 3 chars - { - prefix: "glm", - icon: ChatGLM.Color, - hasColor: true, - i18nKey: "zhipuai", - litellmProvider: "zhipuai", - }, - { prefix: "gpt", icon: OpenAI, hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, - // 2 chars - { prefix: "o1", icon: OpenAI, hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, - { prefix: "o3", icon: OpenAI, hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, - { prefix: "o4", icon: OpenAI, hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, - { prefix: "yi", icon: Yi.Color, hasColor: true, i18nKey: "yi" }, -]; +const MODEL_VENDOR_ICON_BY_KEY: Record> = { + anthropic: Claude.Color, + baichuan: Baichuan.Color, + cohere: Cohere.Color, + deepseek: DeepSeek.Color, + gemma: Gemma.Color, + hunyuan: Hunyuan.Color, + internlm: InternLM.Color, + kimi: Kimi.Color, + meta: Meta.Color, + minimax: Minimax.Color, + mistral: Mistral.Color, + moonshot: Moonshot, + nvidia: Nvidia.Color, + openai: OpenAI, + perplexity: Perplexity.Color, + qwen: Qwen.Color, + sensenova: SenseNova.Color, + spark: Spark.Color, + stepfun: Stepfun.Color, + vertex: Gemini.Color, + volcengine: Doubao.Color, + wenxin: Wenxin.Color, + xai: Grok, + yi: Yi.Color, + zhipuai: ChatGLM.Color, +}; export function getModelVendor(modelId: string): ModelVendorEntry | null { - if (!modelId) return null; - const lower = modelId.toLowerCase(); - for (const rule of MODEL_VENDOR_RULES) { - if (lower.startsWith(rule.prefix)) { - return rule; - } + const rule = getModelVendorRule(modelId); + if (!rule) { + return null; } - return null; + + return { + ...rule, + icon: MODEL_VENDOR_ICON_BY_KEY[rule.i18nKey] ?? OpenAI, + }; } export const PRICE_FILTER_VENDORS: Array<{ diff --git a/src/lib/model-vendor-rules.ts b/src/lib/model-vendor-rules.ts new file mode 100644 index 000000000..a7584d73f --- /dev/null +++ b/src/lib/model-vendor-rules.ts @@ -0,0 +1,124 @@ +export interface ModelVendorRule { + prefix: string; + hasColor: boolean; + i18nKey: string; + litellmProvider?: string; +} + +// Strictly sorted by prefix length descending to ensure longest-match-first. +// Within same length, sorted alphabetically. +export const MODEL_VENDOR_RULES: ModelVendorRule[] = [ + { + prefix: "codestral", + hasColor: true, + i18nKey: "mistral", + litellmProvider: "mistral", + }, + { prefix: "sensenova", hasColor: true, i18nKey: "sensenova" }, + { prefix: "baichuan", hasColor: true, i18nKey: "baichuan" }, + { + prefix: "deepseek", + hasColor: true, + i18nKey: "deepseek", + litellmProvider: "deepseek", + }, + { prefix: "internlm", hasColor: true, i18nKey: "internlm" }, + { prefix: "moonshot", hasColor: false, i18nKey: "moonshot" }, + { + prefix: "chatglm", + hasColor: true, + i18nKey: "zhipuai", + litellmProvider: "zhipuai", + }, + { + prefix: "chatgpt", + hasColor: false, + i18nKey: "openai", + litellmProvider: "openai", + }, + { + prefix: "command", + hasColor: true, + i18nKey: "cohere", + litellmProvider: "cohere_chat", + }, + { prefix: "hunyuan", hasColor: true, i18nKey: "hunyuan" }, + { prefix: "minimax", hasColor: true, i18nKey: "minimax" }, + { + prefix: "mistral", + hasColor: true, + i18nKey: "mistral", + litellmProvider: "mistral", + }, + { + prefix: "mixtral", + hasColor: true, + i18nKey: "mistral", + litellmProvider: "mistral", + }, + { + prefix: "pixtral", + hasColor: true, + i18nKey: "mistral", + litellmProvider: "mistral", + }, + { + prefix: "claude", + hasColor: true, + i18nKey: "anthropic", + litellmProvider: "anthropic", + }, + { + prefix: "doubao", + hasColor: true, + i18nKey: "volcengine", + litellmProvider: "volcengine", + }, + { + prefix: "gemini", + hasColor: true, + i18nKey: "vertex", + litellmProvider: "vertex_ai-language-models", + }, + { prefix: "nvidia", hasColor: true, i18nKey: "nvidia" }, + { prefix: "wenxin", hasColor: true, i18nKey: "wenxin" }, + { prefix: "ernie", hasColor: true, i18nKey: "wenxin" }, + { prefix: "gemma", hasColor: true, i18nKey: "gemma" }, + { prefix: "llama", hasColor: true, i18nKey: "meta" }, + { prefix: "sonar", hasColor: true, i18nKey: "perplexity" }, + { prefix: "spark", hasColor: true, i18nKey: "spark" }, + { prefix: "abab", hasColor: true, i18nKey: "minimax" }, + { prefix: "grok", hasColor: false, i18nKey: "xai", litellmProvider: "xai" }, + { prefix: "kimi", hasColor: true, i18nKey: "kimi" }, + { prefix: "pplx", hasColor: true, i18nKey: "perplexity" }, + { prefix: "qwen", hasColor: true, i18nKey: "qwen" }, + { + prefix: "seed", + hasColor: true, + i18nKey: "volcengine", + litellmProvider: "volcengine", + }, + { prefix: "step", hasColor: true, i18nKey: "stepfun" }, + { + prefix: "glm", + hasColor: true, + i18nKey: "zhipuai", + litellmProvider: "zhipuai", + }, + { prefix: "gpt", hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, + { prefix: "o1", hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, + { prefix: "o3", hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, + { prefix: "o4", hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, + { prefix: "yi", hasColor: true, i18nKey: "yi" }, +]; + +export function getModelVendor(modelId: string): ModelVendorRule | null { + if (!modelId) return null; + const lower = modelId.toLowerCase(); + for (const rule of MODEL_VENDOR_RULES) { + if (lower.startsWith(rule.prefix)) { + return rule; + } + } + return null; +} diff --git a/src/lib/public-status/aggregation-core.ts b/src/lib/public-status/aggregation-core.ts new file mode 100644 index 000000000..a3b07d4d9 --- /dev/null +++ b/src/lib/public-status/aggregation-core.ts @@ -0,0 +1,35 @@ +export interface PublicStatusConfiguredGroup { + sourceGroupId?: number | null; + sourceGroupName: string; + publicGroupSlug: string; + displayName: string; + explanatoryCopy: string | null; + sortOrder: number; + models: Array<{ + publicModelKey: string; + label: string; + vendorIconKey: string; + requestTypeBadge: string; + }>; +} + +export function computeTokensPerSecond(input: { + outputTokens?: number | null; + durationMs?: number | null; + ttfbMs?: number | null; +}): number | null { + if (!input.outputTokens || input.outputTokens <= 0) { + return null; + } + + if (!input.durationMs || input.durationMs <= 0) { + return null; + } + + const generationMs = input.durationMs - (input.ttfbMs ?? 0); + if (generationMs <= 0) { + return null; + } + + return Number((input.outputTokens / (generationMs / 1000)).toFixed(4)); +} diff --git a/src/lib/public-status/aggregation.ts b/src/lib/public-status/aggregation.ts index 48f494c81..de36b4179 100644 --- a/src/lib/public-status/aggregation.ts +++ b/src/lib/public-status/aggregation.ts @@ -8,6 +8,7 @@ import { import { resolveProviderGroupsWithDefault } from "@/lib/utils/provider-group"; import { EXCLUDE_WARMUP_CONDITION } from "@/repository/_shared/message-request-conditions"; import type { ProviderChainItem } from "@/types/message"; +import { computeTokensPerSecond, type PublicStatusConfiguredGroup } from "./aggregation-core"; import type { InternalPublicStatusConfigSnapshot } from "./config-snapshot"; import type { PublicStatusPayload, PublicStatusTimelineBucket } from "./payload"; @@ -43,19 +44,7 @@ export interface PublicStatusRequestRow { providerChain?: PublicStatusRequestChainItem[] | null; } -export interface PublicStatusConfiguredGroup { - sourceGroupName: string; - publicGroupSlug: string; - displayName: string; - explanatoryCopy: string | null; - sortOrder: number; - models: Array<{ - publicModelKey: string; - label: string; - vendorIconKey: string; - requestTypeBadge: string; - }>; -} +export type { PublicStatusConfiguredGroup } from "./aggregation-core"; export interface PublicStatusAggregationResult { generatedAt: string; @@ -84,6 +73,7 @@ export function getConfiguredPublicStatusGroups( group.models.length > 0 ) .map((group) => ({ + sourceGroupId: group.sourceGroupId ?? null, sourceGroupName: group.sourceGroupName!.trim(), publicGroupSlug: group.slug, displayName: group.displayName, @@ -102,26 +92,7 @@ export function getConfiguredPublicStatusGroups( ); } -export function computeTokensPerSecond(input: { - outputTokens?: number | null; - durationMs?: number | null; - ttfbMs?: number | null; -}): number | null { - if (!input.outputTokens || input.outputTokens <= 0) { - return null; - } - - if (!input.durationMs || input.durationMs <= 0) { - return null; - } - - const generationMs = input.durationMs - (input.ttfbMs ?? 0); - if (generationMs <= 0) { - return null; - } - - return Number((input.outputTokens / (generationMs / 1000)).toFixed(4)); -} +export { computeTokensPerSecond } from "./aggregation-core"; export function isExcludedFromPublicStatusFailure(signal: PublicStatusFailureSignal): boolean { return ( diff --git a/src/lib/public-status/config-publisher.ts b/src/lib/public-status/config-publisher.ts index 24333619b..b19065c19 100644 --- a/src/lib/public-status/config-publisher.ts +++ b/src/lib/public-status/config-publisher.ts @@ -79,6 +79,7 @@ export async function publishCurrentPublicStatusConfigProjection(input: { settings.publicStatusAggregationIntervalMinutes ); const defaultRangeHours = normalizePublicRange(settings.publicStatusWindowHours); + const providerGroupIdByName = new Map(providerGroups.map((group) => [group.name, group.id])); const snapshot = buildPublicStatusConfigSnapshot({ configVersion: input.configVersion ?? `cfg-${Date.now()}`, @@ -119,6 +120,7 @@ export async function publishCurrentPublicStatusConfigProjection(input: { defaultIntervalMinutes: snapshot.defaultIntervalMinutes, defaultRangeHours: snapshot.defaultRangeHours, groups: enabledGroups.map((group) => ({ + sourceGroupId: providerGroupIdByName.get(group.groupName) ?? null, sourceGroupName: group.groupName, slug: group.publicGroupSlug, displayName: group.displayName, diff --git a/src/lib/public-status/config-snapshot.ts b/src/lib/public-status/config-snapshot.ts index c40b9736a..2818aad14 100644 --- a/src/lib/public-status/config-snapshot.ts +++ b/src/lib/public-status/config-snapshot.ts @@ -4,13 +4,14 @@ import { buildPublicStatusConfigSnapshotKey, buildPublicStatusConfigVersionPointerKey, buildPublicStatusInternalConfigSnapshotKey, + LEGACY_PUBLIC_STATUS_REDIS_PREFIX, } from "./redis-contract"; export const DEFAULT_PUBLIC_STATUS_SITE_DESCRIPTION = "Request-derived public status"; /** * TTL (seconds) applied to *versioned* config snapshot keys written by this - * module — i.e. `public-status:v1:config:` and the internal variant. + * module — i.e. versioned public-status config keys and the internal variant. * * 30 days matches `GENERATION_PROJECTION_TTL_SECONDS` in `rebuild-worker.ts`, * which already governs the manifest / series / snapshot keys for this @@ -45,6 +46,7 @@ export interface PublicStatusGroupSnapshot { } export interface InternalPublicStatusGroupSnapshot extends PublicStatusGroupSnapshot { + sourceGroupId?: number | null; sourceGroupName: string; } @@ -102,6 +104,11 @@ interface RedisReader { status?: string; } +interface ReadCurrentSnapshotOptions { + redis?: RedisReader | null; + allowLegacyFallback?: boolean; +} + function normalizePublicSiteDescription(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -162,7 +169,7 @@ function extractCurrentConfigVersion(pointerRaw: string | null): string | null { return pointer.configVersion; } if (pointer?.key) { - const match = pointer.key.match(/:config(?::internal)?:([^:]+)$/); + const match = pointer.key.match(/:config(?:-internal)?:([^:]+)$/); if (match?.[1]) { return decodeURIComponent(match[1]); } @@ -329,55 +336,93 @@ export async function publishCurrentPublicStatusConfigPointers(input: { export async function readCurrentPublicStatusConfigSnapshot(input?: { redis?: RedisReader | null; + allowLegacyFallback?: boolean; }): Promise { const redis = input?.redis ?? getRedisClient({ allowWhenRateLimitDisabled: true }); if (!redis || ("status" in redis && redis.status && redis.status !== "ready")) { return null; } - const currentVersion = extractCurrentConfigVersion( - await safeGet(redis, buildPublicStatusConfigVersionPointerKey()) - ); - if (currentVersion) { - const snapshotRaw = await safeGet(redis, buildPublicStatusConfigSnapshotKey(currentVersion)); - return safeParseJson(snapshotRaw); - } + const prefixes = + input?.allowLegacyFallback === false + ? [undefined] + : [undefined, LEGACY_PUBLIC_STATUS_REDIS_PREFIX]; + for (const prefix of prefixes) { + const currentVersion = extractCurrentConfigVersion( + await safeGet(redis, buildPublicStatusConfigVersionPointerKey({ prefix })) + ); + if (currentVersion) { + const snapshotRaw = await safeGet( + redis, + buildPublicStatusConfigSnapshotKey(currentVersion, { prefix }) + ); + const snapshot = safeParseJson(snapshotRaw); + if (snapshot) { + return snapshot; + } + } - const pointerRaw = await safeGet(redis, buildPublicStatusConfigSnapshotKey()); - const pointer = safeParseJson<{ key?: string }>(pointerRaw); - if (!pointer?.key) { - return null; + const pointerRaw = await safeGet( + redis, + buildPublicStatusConfigSnapshotKey("current", { prefix }) + ); + const pointer = safeParseJson<{ key?: string }>(pointerRaw); + if (!pointer?.key) { + continue; + } + const snapshot = safeParseJson(await safeGet(redis, pointer.key)); + if (snapshot) { + return snapshot; + } } - const snapshotRaw = await safeGet(redis, pointer.key); - return safeParseJson(snapshotRaw); + + return null; } -export async function readCurrentInternalPublicStatusConfigSnapshot(input?: { - redis?: RedisReader | null; -}): Promise { +export async function readCurrentInternalPublicStatusConfigSnapshot( + input?: ReadCurrentSnapshotOptions +): Promise { const redis = input?.redis ?? getRedisClient({ allowWhenRateLimitDisabled: true }); if (!redis || ("status" in redis && redis.status && redis.status !== "ready")) { return null; } - const currentVersion = extractCurrentConfigVersion( - await safeGet(redis, buildPublicStatusConfigVersionPointerKey()) - ); - if (currentVersion) { - const snapshotRaw = await safeGet( + const prefixes = + input?.allowLegacyFallback === false + ? [undefined] + : [undefined, LEGACY_PUBLIC_STATUS_REDIS_PREFIX]; + for (const prefix of prefixes) { + const currentVersion = extractCurrentConfigVersion( + await safeGet(redis, buildPublicStatusConfigVersionPointerKey({ prefix })) + ); + if (currentVersion) { + const snapshotRaw = await safeGet( + redis, + buildPublicStatusInternalConfigSnapshotKey(currentVersion, { prefix }) + ); + const snapshot = safeParseJson(snapshotRaw); + if (snapshot) { + return snapshot; + } + } + + const pointerRaw = await safeGet( redis, - buildPublicStatusInternalConfigSnapshotKey(currentVersion) + buildPublicStatusInternalConfigSnapshotKey("current", { prefix }) ); - return safeParseJson(snapshotRaw); + const pointer = safeParseJson<{ key?: string }>(pointerRaw); + if (!pointer?.key) { + continue; + } + const snapshot = safeParseJson( + await safeGet(redis, pointer.key) + ); + if (snapshot) { + return snapshot; + } } - const pointerRaw = await safeGet(redis, buildPublicStatusInternalConfigSnapshotKey()); - const pointer = safeParseJson<{ key?: string }>(pointerRaw); - if (!pointer?.key) { - return null; - } - const snapshotRaw = await safeGet(redis, pointer.key); - return safeParseJson(snapshotRaw); + return null; } export async function readPublicStatusSiteMetadata(input?: { diff --git a/src/lib/public-status/read-store.ts b/src/lib/public-status/read-store.ts index 7aeb7dced..4dec7e75d 100644 --- a/src/lib/public-status/read-store.ts +++ b/src/lib/public-status/read-store.ts @@ -9,6 +9,7 @@ import type { import { buildPublicStatusCurrentSnapshotKey, buildPublicStatusManifestKey, + LEGACY_PUBLIC_STATUS_REDIS_PREFIX, type PublicStatusManifest, resolvePublicStatusManifestState, } from "./redis-contract"; @@ -25,6 +26,21 @@ interface PublicStatusSnapshotRecord { groups: unknown; } +interface ProjectionReadResult { + prefix?: string; + selectedManifest: PublicStatusManifest; + resolution: ReturnType; + snapshot: PublicStatusSnapshotRecord; +} + +interface ProjectionReadMiss { + reason: "manifest-missing" | "snapshot-missing"; +} + +type ProjectionReadOutcome = + | { ok: true; projection: ProjectionReadResult } + | { ok: false; miss: ProjectionReadMiss }; + async function safeGet(redis: RedisReader, key: string): Promise { try { return await redis.get(key); @@ -178,6 +194,92 @@ function sanitizeGroupSnapshots(input: unknown): PublicStatusGroupSnapshot[] { }); } +async function readProjection(input: { + redis: RedisReader; + intervalMinutes: number; + rangeHours: number; + nowIso: string; + configVersion?: string; + prefix?: string; +}): Promise { + const manifestConfigVersion = input.configVersion ?? "current"; + const manifest = parseJson( + await safeGet( + input.redis, + buildPublicStatusManifestKey({ + configVersion: manifestConfigVersion, + intervalMinutes: input.intervalMinutes, + rangeHours: input.rangeHours, + prefix: input.prefix, + }) + ) + ); + const currentManifest = + manifestConfigVersion === "current" + ? manifest + : parseJson( + await safeGet( + input.redis, + buildPublicStatusManifestKey({ + configVersion: "current", + intervalMinutes: input.intervalMinutes, + rangeHours: input.rangeHours, + prefix: input.prefix, + }) + ) + ); + + let selectedManifest = manifest; + let resolution = resolvePublicStatusManifestState(selectedManifest, input.nowIso); + + if (!resolution.sourceGeneration && currentManifest) { + selectedManifest = currentManifest; + resolution = { + ...resolvePublicStatusManifestState(currentManifest, input.nowIso), + rebuildState: "stale", + }; + } + + if (!selectedManifest || !resolution.sourceGeneration) { + return { ok: false, miss: { reason: "manifest-missing" } }; + } + + const snapshotKey = buildPublicStatusCurrentSnapshotKey({ + intervalMinutes: input.intervalMinutes, + rangeHours: input.rangeHours, + generation: resolution.sourceGeneration, + prefix: input.prefix, + }); + const snapshot = parseJson(await safeGet(input.redis, snapshotKey)); + + if (!snapshot) { + return { ok: false, miss: { reason: "snapshot-missing" } }; + } + + return { + ok: true, + projection: { + prefix: input.prefix, + selectedManifest, + resolution, + snapshot, + }, + }; +} + +function projectionToPayload(input: { + projection: ProjectionReadResult; + rebuildState?: PublicStatusPayload["rebuildState"]; +}): PublicStatusPayload { + return { + rebuildState: input.rebuildState ?? input.projection.resolution.rebuildState, + sourceGeneration: input.projection.snapshot.sourceGeneration, + generatedAt: input.projection.snapshot.generatedAt, + freshUntil: input.projection.snapshot.freshUntil, + groups: sanitizeGroupSnapshots(input.projection.snapshot.groups), + }; +} + export async function readPublicStatusPayload(input: { intervalMinutes: number; rangeHours: number; @@ -197,64 +299,99 @@ export async function readPublicStatusPayload(input: { return buildRebuildingPayload(); } - const manifestKey = buildPublicStatusManifestKey({ - configVersion: input.configVersion ?? "current", - intervalMinutes: input.intervalMinutes, - rangeHours: input.rangeHours, - }); - const manifest = parseJson(await safeGet(redis, manifestKey)); - const currentManifestKey = buildPublicStatusManifestKey({ - configVersion: "current", + const primaryRead = await readProjection({ + redis, intervalMinutes: input.intervalMinutes, rangeHours: input.rangeHours, + nowIso: input.nowIso, + configVersion: input.configVersion, }); - const currentManifest = parseJson(await safeGet(redis, currentManifestKey)); - let selectedManifest = manifest; - let resolution = resolvePublicStatusManifestState(selectedManifest, input.nowIso); - - if (!resolution.sourceGeneration && currentManifest) { - selectedManifest = currentManifest; - resolution = { - ...resolvePublicStatusManifestState(currentManifest, input.nowIso), - rebuildState: "stale", - }; + let projection = primaryRead.ok ? primaryRead.projection : null; + let miss = primaryRead.ok ? null : primaryRead.miss; + + if (!projection) { + const legacyRead = await readProjection({ + redis, + intervalMinutes: input.intervalMinutes, + rangeHours: input.rangeHours, + nowIso: input.nowIso, + configVersion: input.configVersion, + prefix: LEGACY_PUBLIC_STATUS_REDIS_PREFIX, + }); + projection = legacyRead.ok ? legacyRead.projection : null; + if (!legacyRead.ok && miss?.reason !== "snapshot-missing") { + miss = legacyRead.miss; + } } - if (!resolution.sourceGeneration) { - await input.triggerRebuildHint("manifest-missing"); + if (!projection) { + await input.triggerRebuildHint(miss?.reason ?? "manifest-missing"); return buildRebuildingPayload(); } - const snapshotKey = buildPublicStatusCurrentSnapshotKey({ - intervalMinutes: input.intervalMinutes, - rangeHours: input.rangeHours, - generation: resolution.sourceGeneration, - }); - const snapshot = parseJson(await safeGet(redis, snapshotKey)); + if ( + projection.prefix !== LEGACY_PUBLIC_STATUS_REDIS_PREFIX && + projection.selectedManifest.rollupCoverageComplete === false + ) { + const legacyRead = await readProjection({ + redis, + intervalMinutes: input.intervalMinutes, + rangeHours: input.rangeHours, + nowIso: input.nowIso, + configVersion: input.configVersion, + prefix: LEGACY_PUBLIC_STATUS_REDIS_PREFIX, + }); + + if (legacyRead.ok) { + await input.triggerRebuildHint("rollup-coverage-incomplete"); + await input.triggerRebuildHint("legacy-generation"); + if ( + input.configVersion && + legacyRead.projection.selectedManifest.configVersion !== input.configVersion + ) { + await input.triggerRebuildHint("config-version-mismatch"); + } + return projectionToPayload({ + projection: legacyRead.projection, + rebuildState: "stale", + }); + } - if (!snapshot) { - await input.triggerRebuildHint("snapshot-missing"); - return buildRebuildingPayload(); + await input.triggerRebuildHint("rollup-coverage-incomplete"); + if (input.configVersion && projection.selectedManifest.configVersion !== input.configVersion) { + await input.triggerRebuildHint("config-version-mismatch"); + } + return projectionToPayload({ + projection, + rebuildState: "stale", + }); } - if (resolution.rebuildState !== "fresh") { + if ( + projection.resolution.rebuildState !== "fresh" || + projection.prefix === LEGACY_PUBLIC_STATUS_REDIS_PREFIX + ) { await input.triggerRebuildHint("stale-generation"); } - if (input.configVersion && selectedManifest?.configVersion !== input.configVersion) { + if (projection.prefix === LEGACY_PUBLIC_STATUS_REDIS_PREFIX) { + await input.triggerRebuildHint("legacy-generation"); + } + + if (input.configVersion && projection.selectedManifest.configVersion !== input.configVersion) { await input.triggerRebuildHint("config-version-mismatch"); - resolution = { - ...resolution, + return projectionToPayload({ + projection, rebuildState: "stale", - }; + }); } - return { - rebuildState: resolution.rebuildState, - sourceGeneration: snapshot.sourceGeneration, - generatedAt: snapshot.generatedAt, - freshUntil: snapshot.freshUntil, - groups: sanitizeGroupSnapshots(snapshot.groups), - }; + return projectionToPayload({ + projection, + rebuildState: + projection.prefix === LEGACY_PUBLIC_STATUS_REDIS_PREFIX + ? "stale" + : projection.resolution.rebuildState, + }); } diff --git a/src/lib/public-status/rebuild-hints.ts b/src/lib/public-status/rebuild-hints.ts index fe14f0f41..fd997f5ae 100644 --- a/src/lib/public-status/rebuild-hints.ts +++ b/src/lib/public-status/rebuild-hints.ts @@ -58,6 +58,15 @@ export async function schedulePublicStatusRebuild(input: { intervalMinutes: input.intervalMinutes, rangeHours: input.rangeHours, }); + const existingHintTtlMs = typeof redis.pttl === "function" ? Number(await redis.pttl(key)) : -1; + if (Number.isFinite(existingHintTtlMs) && existingHintTtlMs > 0) { + return { + accepted: true, + rebuildState: "rebuilding", + key, + }; + } + await redis.set( key, JSON.stringify({ diff --git a/src/lib/public-status/rebuild-worker.ts b/src/lib/public-status/rebuild-worker.ts index 56f09b9ae..7d800389a 100644 --- a/src/lib/public-status/rebuild-worker.ts +++ b/src/lib/public-status/rebuild-worker.ts @@ -1,9 +1,4 @@ import { getRedisClient } from "@/lib/redis"; -import { - buildPublicStatusPayloadFromRequests, - getConfiguredPublicStatusGroups, - queryPublicStatusRequests, -} from "./aggregation"; import { publishCurrentPublicStatusConfigProjection } from "./config-publisher"; import { readCurrentInternalPublicStatusConfigSnapshot } from "./config-snapshot"; import { @@ -12,9 +7,17 @@ import { buildPublicStatusCurrentSnapshotKey, buildPublicStatusManifestKey, buildPublicStatusRebuildLockKey, + buildPublicStatusRollupCoverageStartKey, buildPublicStatusSeriesChunkKey, buildPublicStatusTempKey, } from "./redis-contract"; +import { + buildPublicStatusPayloadFromRollups, + buildPublicStatusRollupBucketStarts, + getConfiguredPublicStatusGroupsFromSnapshot, + parsePublicStatusRollupField, + readPublicStatusRollupBuckets, +} from "./rollup-store"; interface PublicStatusRebuildResult { sourceGeneration: string; @@ -28,6 +31,7 @@ const GENERATION_PROJECTION_TTL_SECONDS = 60 * 60 * 24 * 30; interface RedisHintWriter { get?(key: string): Promise | string | null; + hgetall?(key: string): Promise> | Record; set(key: string, value: string, mode: "EX", seconds: number): Promise | unknown; set(key: string, value: string): Promise | unknown; del?(...keys: string[]): Promise | unknown; @@ -84,6 +88,23 @@ function shouldPromoteCurrentManifest( return true; } +function isRollupCoverageComplete(input: { + coverageStartedAt: string | null; + coveredFrom: string; +}): boolean { + if (!input.coverageStartedAt) { + return false; + } + + const coverageStartedAtMs = Date.parse(input.coverageStartedAt); + const coveredFromMs = Date.parse(input.coveredFrom); + return ( + Number.isFinite(coverageStartedAtMs) && + Number.isFinite(coveredFromMs) && + coverageStartedAtMs <= coveredFromMs + ); +} + async function publishPublicStatusProjection(input: { redis: RedisHintWriter; configVersion: string; @@ -93,6 +114,9 @@ async function publishPublicStatusProjection(input: { generatedAt: string; coveredFrom: string; coveredTo: string; + rollupCoverageStartedAt: string | null; + rollupCoverageComplete: boolean; + rollupSampleCount: number; groups: unknown; }): Promise { const snapshotKey = buildPublicStatusCurrentSnapshotKey({ @@ -149,6 +173,9 @@ async function publishPublicStatusProjection(input: { freshUntil: snapshotRecord.freshUntil, rebuildState: "idle" as const, lastCompleteGeneration: input.sourceGeneration, + rollupCoverageStartedAt: input.rollupCoverageStartedAt, + rollupCoverageComplete: input.rollupCoverageComplete, + rollupSampleCount: input.rollupSampleCount, }; await setWithTtl( @@ -287,12 +314,17 @@ export async function rebuildPublicStatusProjection(input: { if (typeof redis.get !== "function") { return { status: "disabled", reason: "redis-unavailable" }; } + if (typeof redis.hgetall !== "function") { + return { status: "disabled", reason: "redis-unavailable" }; + } const redisReader = redis as RedisHintWriter & { get(key: string): Promise | string | null; + hgetall(key: string): Promise> | Record; }; let configSnapshot = await readCurrentInternalPublicStatusConfigSnapshot({ redis: redisReader, + allowLegacyFallback: false, }); if (!configSnapshot) { try { @@ -302,6 +334,7 @@ export async function rebuildPublicStatusProjection(input: { if (publishResult.written) { configSnapshot = await readCurrentInternalPublicStatusConfigSnapshot({ redis: redisReader, + allowLegacyFallback: false, }); } } catch { @@ -312,7 +345,7 @@ export async function rebuildPublicStatusProjection(input: { return { status: "disabled", reason: "missing-config" }; } - const groups = getConfiguredPublicStatusGroups(configSnapshot); + const groups = getConfiguredPublicStatusGroupsFromSnapshot(configSnapshot); if (groups.length === 0) { return { status: "disabled", reason: "no-configured-groups" }; } @@ -348,17 +381,36 @@ export async function rebuildPublicStatusProjection(input: { } try { - const requests = await queryPublicStatusRequests({ - groups, - coveredFrom: new Date(coveredFrom), - coveredTo: new Date(coveredTo), + const rollupBuckets = await readPublicStatusRollupBuckets({ + redis: redisReader, + bucketStarts: buildPublicStatusRollupBucketStarts({ + rangeHours: input.rangeHours, + intervalMinutes: input.intervalMinutes, + now: new Date(coveredTo), + }), + }); + const rollupCoverageStartedAt = await redisReader.get( + buildPublicStatusRollupCoverageStartKey() + ); + const rollupSampleCount = rollupBuckets.reduce((sum, bucket) => { + for (const [field, value] of bucket.values) { + const parsedField = parsePublicStatusRollupField(field); + if (parsedField?.metric === "success" || parsedField?.metric === "failure") { + sum += value; + } + } + return sum; + }, 0); + const rollupCoverageComplete = isRollupCoverageComplete({ + coverageStartedAt: rollupCoverageStartedAt, + coveredFrom, }); - const aggregation = buildPublicStatusPayloadFromRequests({ + const aggregation = buildPublicStatusPayloadFromRollups({ rangeHours: input.rangeHours, intervalMinutes: input.intervalMinutes, now: new Date(coveredTo), groups, - requests, + rollupBuckets, }); await publishPublicStatusProjection({ @@ -370,6 +422,9 @@ export async function rebuildPublicStatusProjection(input: { generatedAt: aggregation.generatedAt, coveredFrom: aggregation.coveredFrom, coveredTo: aggregation.coveredTo, + rollupCoverageStartedAt, + rollupCoverageComplete, + rollupSampleCount, groups: aggregation.groups, }); diff --git a/src/lib/public-status/redis-contract.ts b/src/lib/public-status/redis-contract.ts index a0188ab77..a28bd03ba 100644 --- a/src/lib/public-status/redis-contract.ts +++ b/src/lib/public-status/redis-contract.ts @@ -1,6 +1,8 @@ import { createHash } from "node:crypto"; -const PUBLIC_STATUS_REDIS_PREFIX = "public-status:v1"; +export const PUBLIC_STATUS_REDIS_PREFIX = "public-status:v2"; +export const LEGACY_PUBLIC_STATUS_REDIS_PREFIX = "public-status:v1"; +export const PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES = 5; export type PublicStatusServeState = "fresh" | "stale" | "rebuilding" | "no-data"; @@ -16,6 +18,9 @@ export interface PublicStatusManifest { freshUntil: string; rebuildState: "idle" | "rebuilding"; lastCompleteGeneration: string | null; + rollupCoverageStartedAt?: string | null; + rollupCoverageComplete?: boolean; + rollupSampleCount?: number; } export interface PublicStatusManifestResolution { @@ -65,27 +70,38 @@ export function buildGenerationFingerprint(input: { return createHash("sha1").update(fingerprint).digest("hex").slice(0, 16); } -export function buildPublicStatusConfigSnapshotKey(configVersion = "current"): string { - return `${PUBLIC_STATUS_REDIS_PREFIX}:config:${encodeKeyPart(configVersion)}`; +function resolvePrefix(prefix?: string): string { + return prefix ?? PUBLIC_STATUS_REDIS_PREFIX; } -export function buildPublicStatusInternalConfigSnapshotKey(configVersion = "current"): string { - return `${PUBLIC_STATUS_REDIS_PREFIX}:config-internal:${encodeKeyPart(configVersion)}`; +export function buildPublicStatusConfigSnapshotKey( + configVersion = "current", + options?: { prefix?: string } +): string { + return `${resolvePrefix(options?.prefix)}:config:${encodeKeyPart(configVersion)}`; } -export function buildPublicStatusConfigVersionPointerKey(): string { - return `${PUBLIC_STATUS_REDIS_PREFIX}:config-version:current`; +export function buildPublicStatusInternalConfigSnapshotKey( + configVersion = "current", + options?: { prefix?: string } +): string { + return `${resolvePrefix(options?.prefix)}:config-internal:${encodeKeyPart(configVersion)}`; +} + +export function buildPublicStatusConfigVersionPointerKey(options?: { prefix?: string }): string { + return `${resolvePrefix(options?.prefix)}:config-version:current`; } export function buildPublicStatusManifestKey(input: { configVersion: string; intervalMinutes: number; rangeHours: number; + prefix?: string; }): string { assertPositiveInteger(input.intervalMinutes, "intervalMinutes"); assertPositiveInteger(input.rangeHours, "rangeHours"); return [ - PUBLIC_STATUS_REDIS_PREFIX, + resolvePrefix(input.prefix), "manifest", encodeKeyPart(input.configVersion), `${input.intervalMinutes}m`, @@ -97,11 +113,12 @@ export function buildPublicStatusCurrentSnapshotKey(input: { intervalMinutes: number; rangeHours: number; generation: string; + prefix?: string; }): string { assertPositiveInteger(input.intervalMinutes, "intervalMinutes"); assertPositiveInteger(input.rangeHours, "rangeHours"); return [ - PUBLIC_STATUS_REDIS_PREFIX, + resolvePrefix(input.prefix), "snapshot", encodeKeyPart(input.generation), `${input.intervalMinutes}m`, @@ -114,10 +131,11 @@ export function buildPublicStatusSeriesChunkKey(input: { generation: string; bucketStartIso: string; bucketEndIso: string; + prefix?: string; }): string { assertPositiveInteger(input.intervalMinutes, "intervalMinutes"); return [ - PUBLIC_STATUS_REDIS_PREFIX, + resolvePrefix(input.prefix), "series", encodeKeyPart(input.generation), `${input.intervalMinutes}m`, @@ -126,17 +144,45 @@ export function buildPublicStatusSeriesChunkKey(input: { ].join(":"); } -export function buildPublicStatusRebuildLockKey(flightKey: string): string { - return `${PUBLIC_STATUS_REDIS_PREFIX}:rebuild-lock:${encodeKeyPart(flightKey)}`; +export function buildPublicStatusRollupKey(input: { + bucketStartIso: string; + bucketMinutes?: number; + prefix?: string; +}): string { + const bucketMinutes = input.bucketMinutes ?? PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES; + assertPositiveInteger(bucketMinutes, "bucketMinutes"); + return [ + resolvePrefix(input.prefix), + "rollup", + `${bucketMinutes}m`, + encodeKeyPart(alignBucketStartUtc(input.bucketStartIso, bucketMinutes)), + ].join(":"); +} + +export function buildPublicStatusRollupCoverageStartKey(options?: { + bucketMinutes?: number; + prefix?: string; +}): string { + const bucketMinutes = options?.bucketMinutes ?? PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES; + assertPositiveInteger(bucketMinutes, "bucketMinutes"); + return `${resolvePrefix(options?.prefix)}:rollup:coverage-start:${bucketMinutes}m`; +} + +export function buildPublicStatusRebuildLockKey( + flightKey: string, + options?: { prefix?: string } +): string { + return `${resolvePrefix(options?.prefix)}:rebuild-lock:${encodeKeyPart(flightKey)}`; } export function buildPublicStatusRebuildHintKey(input: { intervalMinutes: number; rangeHours: number; + prefix?: string; }): string { assertPositiveInteger(input.intervalMinutes, "intervalMinutes"); assertPositiveInteger(input.rangeHours, "rangeHours"); - return `${PUBLIC_STATUS_REDIS_PREFIX}:rebuild-hint:${input.intervalMinutes}m:${input.rangeHours}h`; + return `${resolvePrefix(input.prefix)}:rebuild-hint:${input.intervalMinutes}m:${input.rangeHours}h`; } export function buildPublicStatusTempKey(baseKey: string, nonce: string): string { diff --git a/src/lib/public-status/rollup-store.ts b/src/lib/public-status/rollup-store.ts new file mode 100644 index 000000000..38610064f --- /dev/null +++ b/src/lib/public-status/rollup-store.ts @@ -0,0 +1,736 @@ +import { logger } from "@/lib/logger"; +import { getRedisClient } from "@/lib/redis"; +import { + classifyProviderChainItemOutcome, + resolveSuccessRateModelKey, +} from "@/lib/request-outcome"; +import { resolveProviderGroupsWithDefault } from "@/lib/utils/provider-group"; +import type { ProviderChainItem } from "@/types/message"; +import { computeTokensPerSecond, type PublicStatusConfiguredGroup } from "./aggregation-core"; +import { + type InternalPublicStatusConfigSnapshot, + readCurrentInternalPublicStatusConfigSnapshot, +} from "./config-snapshot"; +import type { PublicStatusPayload, PublicStatusTimelineBucket } from "./payload"; +import { + alignBucketStartUtc, + buildPublicStatusRollupCoverageStartKey, + buildPublicStatusRollupKey, + PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES, +} from "./redis-contract"; + +const ROLLUP_FIELD_SEPARATOR = "|"; +const ROLLUP_TTL_SECONDS = 60 * 60 * 24 * 32; +const CONFIGURED_GROUPS_CACHE_TTL_MS = 30_000; +const EMPTY_CONFIGURED_GROUPS_CACHE_TTL_MS = 5_000; + +export type PublicStatusRollupMetric = + | "success" + | "failure" + | "ttfb_sum" + | "ttfb_count" + | "tps_sum" + | "tps_count"; + +export interface PublicStatusRollupEvent { + createdAt: string | Date; + model?: string | null; + originalModel?: string | null; + durationMs?: number | null; + ttfbMs?: number | null; + outputTokens?: number | null; + providerChain?: ProviderChainItem[] | null; +} + +export interface PublicStatusRollupIncrement { + groupId: string; + modelKey: string; + metric: PublicStatusRollupMetric; + value: number; +} + +export interface PublicStatusRollupBucket { + bucketStart: string; + values: Map; +} + +export interface PublicStatusRollupAggregationResult { + generatedAt: string; + coveredFrom: string; + coveredTo: string; + groups: PublicStatusPayload["groups"]; +} + +interface RedisRollupWriter { + hincrbyfloat?(key: string, field: string, increment: number): Promise | unknown; + expire?(key: string, seconds: number): Promise | unknown; + pipeline?(): { + hincrbyfloat(key: string, field: string, increment: number): unknown; + set?(key: string, value: string, mode: "NX"): unknown; + expire(key: string, seconds: number): unknown; + exec(): Promise | unknown; + }; + set?(key: string, value: string, mode?: "NX"): Promise | unknown; + status?: string; +} + +interface RedisRollupReader { + hgetall(key: string): Promise> | Record; + pipeline?(): { + hgetall(key: string): unknown; + exec(): Promise | null>; + }; + status?: string; +} + +let cachedConfiguredGroups: { + configVersion: string; + groups: PublicStatusConfiguredGroup[]; + expiresAt: number; +} | null = null; + +function encodeRollupPart(value: string | number): string { + return encodeURIComponent(String(value)); +} + +function decodeRollupPart(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +export function buildPublicStatusRollupField(input: { + groupId: string | number; + modelKey: string; + metric: PublicStatusRollupMetric; +}): string { + return [ + encodeRollupPart(input.groupId), + encodeRollupPart(input.modelKey), + encodeRollupPart(input.metric), + ].join(ROLLUP_FIELD_SEPARATOR); +} + +export function parsePublicStatusRollupField( + field: string +): { groupId: string; modelKey: string; metric: PublicStatusRollupMetric } | null { + const parts = field.split(ROLLUP_FIELD_SEPARATOR); + if (parts.length !== 3) { + return null; + } + + const metric = decodeRollupPart(parts[2] ?? ""); + if ( + metric !== "success" && + metric !== "failure" && + metric !== "ttfb_sum" && + metric !== "ttfb_count" && + metric !== "tps_sum" && + metric !== "tps_count" + ) { + return null; + } + + return { + groupId: decodeRollupPart(parts[0] ?? ""), + modelKey: decodeRollupPart(parts[1] ?? ""), + metric, + }; +} + +function normalizeNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function getPublicStatusGroupId( + input: Pick +): string { + return input.sourceGroupId !== undefined && input.sourceGroupId !== null + ? String(input.sourceGroupId) + : input.sourceGroupName; +} + +function getPublicStatusGroupRollupIds( + input: Pick +): string[] { + const primary = getPublicStatusGroupId(input); + return [primary]; +} + +function buildConfiguredGroupLookups(groups: PublicStatusConfiguredGroup[]): { + modelToGroups: Map; + groupsBySourceName: Map; + groupsByRollupId: Map; +} { + const modelToGroups = new Map(); + const groupsBySourceName = new Map(); + const groupsByRollupId = new Map(); + + for (const group of groups) { + groupsBySourceName.set(group.sourceGroupName, group); + groupsByRollupId.set(getPublicStatusGroupId(group), group); + for (const model of group.models) { + const existing = modelToGroups.get(model.publicModelKey) ?? []; + existing.push(group); + modelToGroups.set(model.publicModelKey, existing); + } + } + + return { modelToGroups, groupsBySourceName, groupsByRollupId }; +} + +export function getConfiguredPublicStatusGroupsFromSnapshot( + snapshot: InternalPublicStatusConfigSnapshot +): PublicStatusConfiguredGroup[] { + return snapshot.groups + .filter( + (group) => + typeof group.sourceGroupName === "string" && + group.sourceGroupName.trim().length > 0 && + Array.isArray(group.models) && + group.models.length > 0 + ) + .map((group) => ({ + sourceGroupId: group.sourceGroupId ?? null, + sourceGroupName: group.sourceGroupName.trim(), + publicGroupSlug: group.slug, + displayName: group.displayName, + explanatoryCopy: group.description, + sortOrder: group.sortOrder, + models: group.models.map((model) => ({ + publicModelKey: model.publicModelKey, + label: model.label, + vendorIconKey: model.vendorIconKey, + requestTypeBadge: model.requestTypeBadge, + })), + })) + .sort( + (left, right) => + left.sortOrder - right.sortOrder || left.displayName.localeCompare(right.displayName) + ); +} + +export async function getConfiguredPublicStatusGroupsForRollup(): Promise< + PublicStatusConfiguredGroup[] +> { + const now = Date.now(); + if (cachedConfiguredGroups && cachedConfiguredGroups.expiresAt > now) { + return cachedConfiguredGroups.groups; + } + + const snapshot = await readCurrentInternalPublicStatusConfigSnapshot({ + allowLegacyFallback: false, + }); + if (!snapshot) { + cachedConfiguredGroups = { + configVersion: "", + groups: [], + expiresAt: now + EMPTY_CONFIGURED_GROUPS_CACHE_TTL_MS, + }; + return []; + } + + const groups = getConfiguredPublicStatusGroupsFromSnapshot(snapshot); + cachedConfiguredGroups = { + configVersion: snapshot.configVersion, + groups, + expiresAt: now + CONFIGURED_GROUPS_CACHE_TTL_MS, + }; + return groups; +} + +export function buildPublicStatusRollupIncrements(input: { + event: PublicStatusRollupEvent; + groups: PublicStatusConfiguredGroup[]; +}): PublicStatusRollupIncrement[] { + const modelKey = resolveSuccessRateModelKey({ + originalModel: input.event.originalModel, + model: input.event.model, + }); + if (!modelKey) { + return []; + } + + const { modelToGroups, groupsBySourceName, groupsByRollupId } = buildConfiguredGroupLookups( + input.groups + ); + const configuredGroups = modelToGroups.get(modelKey); + if (!configuredGroups || configuredGroups.length === 0) { + return []; + } + + const groupOutcome = new Map(); + for (const item of input.event.providerChain ?? []) { + const outcome = classifyProviderChainItemOutcome({ + statusCode: item.statusCode ?? undefined, + reason: item.reason ?? undefined, + errorMessage: item.errorMessage ?? undefined, + errorDetails: item.errorDetails, + })?.outcome; + if (!outcome) { + continue; + } + + const itemGroups = Array.from(new Set(resolveProviderGroupsWithDefault(item.groupTag))); + for (const sourceGroupName of itemGroups) { + if (!groupsBySourceName.has(sourceGroupName)) { + continue; + } + + const existing = groupOutcome.get(sourceGroupName); + if (existing === "success") { + continue; + } + + if (outcome === "success") { + groupOutcome.set(sourceGroupName, "success"); + continue; + } + + if (!existing || existing === "excluded") { + groupOutcome.set(sourceGroupName, outcome); + } + } + } + + const ttfbMs = normalizeNumber(input.event.ttfbMs); + const tps = computeTokensPerSecond({ + outputTokens: input.event.outputTokens, + durationMs: input.event.durationMs, + ttfbMs, + }); + const increments: PublicStatusRollupIncrement[] = []; + + for (const [sourceGroupName, outcome] of groupOutcome.entries()) { + if (outcome === "excluded") { + continue; + } + + const group = groupsBySourceName.get(sourceGroupName); + if (!group?.models.some((model) => model.publicModelKey === modelKey)) { + continue; + } + + const groupId = getPublicStatusGroupId(group); + if (groupsByRollupId.get(groupId) !== group) { + continue; + } + increments.push({ + groupId, + modelKey, + metric: outcome === "success" ? "success" : "failure", + value: 1, + }); + if (ttfbMs !== null) { + increments.push( + { groupId, modelKey, metric: "ttfb_sum", value: ttfbMs }, + { groupId, modelKey, metric: "ttfb_count", value: 1 } + ); + } + if (tps !== null) { + increments.push( + { groupId, modelKey, metric: "tps_sum", value: tps }, + { groupId, modelKey, metric: "tps_count", value: 1 } + ); + } + } + + return increments; +} + +export async function writePublicStatusRollupEvent(input: { + event: PublicStatusRollupEvent; + groups: PublicStatusConfiguredGroup[]; + redis?: RedisRollupWriter | null; +}): Promise<{ written: boolean; incrementCount: number; key: string | null }> { + const increments = buildPublicStatusRollupIncrements(input); + if (increments.length === 0) { + return { written: false, incrementCount: 0, key: null }; + } + + const createdAtIso = + input.event.createdAt instanceof Date + ? input.event.createdAt.toISOString() + : input.event.createdAt; + const key = buildPublicStatusRollupKey({ bucketStartIso: createdAtIso }); + const coverageStartKey = buildPublicStatusRollupCoverageStartKey(); + const bucketStartIso = alignBucketStartUtc(createdAtIso, PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES); + const redis = input.redis ?? getRedisClient({ allowWhenRateLimitDisabled: true }); + if ( + !redis || + ("status" in redis && redis.status && redis.status !== "ready") || + typeof redis.hincrbyfloat !== "function" + ) { + return { written: false, incrementCount: increments.length, key }; + } + + if (typeof redis.pipeline === "function") { + const pipeline = redis.pipeline(); + for (const increment of increments) { + pipeline.hincrbyfloat(key, buildPublicStatusRollupField(increment), increment.value); + } + if (typeof pipeline.set === "function") { + pipeline.set(coverageStartKey, bucketStartIso, "NX"); + } + pipeline.expire(key, ROLLUP_TTL_SECONDS); + await pipeline.exec(); + } else { + for (const increment of increments) { + const field = buildPublicStatusRollupField(increment); + await redis.hincrbyfloat(key, field, increment.value); + } + if (typeof redis.set === "function") { + await redis.set(coverageStartKey, bucketStartIso, "NX"); + } + await redis.expire?.(key, ROLLUP_TTL_SECONDS); + } + + return { written: true, incrementCount: increments.length, key }; +} + +export function queuePublicStatusRollupWrite(input: { + event: PublicStatusRollupEvent; + groups: PublicStatusConfiguredGroup[]; +}): Promise<{ written: boolean; incrementCount: number; key: string | null }> { + return writePublicStatusRollupEvent(input).catch((error) => { + logger.warn("[PublicStatus] Failed to write rollup event", { + error: error instanceof Error ? error.message : String(error), + }); + return { written: false, incrementCount: 0, key: null }; + }); +} + +export async function readPublicStatusRollupBuckets(input: { + redis: RedisRollupReader; + bucketStarts: string[]; +}): Promise { + const parseBucket = (bucketStart: string, raw: unknown): PublicStatusRollupBucket => { + const values = new Map(); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return { bucketStart, values }; + } + + for (const [field, rawValue] of Object.entries(raw as Record)) { + const value = Number(rawValue); + if (Number.isFinite(value)) { + values.set(field, value); + } + } + return { bucketStart, values }; + }; + + if (typeof input.redis.pipeline === "function") { + const buckets: PublicStatusRollupBucket[] = []; + const batchSize = 200; + for (let index = 0; index < input.bucketStarts.length; index += batchSize) { + const batchStarts = input.bucketStarts.slice(index, index + batchSize); + const pipeline = input.redis.pipeline(); + for (const bucketStart of batchStarts) { + pipeline.hgetall(buildPublicStatusRollupKey({ bucketStartIso: bucketStart })); + } + + const results = await pipeline.exec(); + for (let batchIndex = 0; batchIndex < batchStarts.length; batchIndex++) { + const [error, raw] = results?.[batchIndex] ?? [null, null]; + buckets.push(parseBucket(batchStarts[batchIndex]!, error ? null : raw)); + } + } + return buckets; + } + + const buckets: PublicStatusRollupBucket[] = []; + const concurrency = 32; + for (let index = 0; index < input.bucketStarts.length; index += concurrency) { + const batchStarts = input.bucketStarts.slice(index, index + concurrency); + const batchBuckets = await Promise.all( + batchStarts.map(async (bucketStart) => + parseBucket( + bucketStart, + await input.redis.hgetall(buildPublicStatusRollupKey({ bucketStartIso: bucketStart })) + ) + ) + ); + buckets.push(...batchBuckets); + } + + return buckets; +} + +function getRollupValue(input: { + bucket: PublicStatusRollupBucket; + groupId: string; + modelKey: string; + metric: PublicStatusRollupMetric; +}): number { + return ( + input.bucket.values.get( + buildPublicStatusRollupField({ + groupId: input.groupId, + modelKey: input.modelKey, + metric: input.metric, + }) + ) ?? 0 + ); +} + +function applyBoundedGapFill(input: { + timeline: Array<"operational" | "failed" | null>; + maxGapBuckets?: number; +}): Array<"operational" | "failed" | null> { + const result = [...input.timeline]; + const maxGapBuckets = input.maxGapBuckets ?? 3; + + let lastKnownIndex = -1; + for (let index = 0; index < input.timeline.length; index++) { + const current = input.timeline[index]; + if (current === null) { + continue; + } + + if (lastKnownIndex >= 0) { + const previous = input.timeline[lastKnownIndex]; + const gapBuckets = index - lastKnownIndex - 1; + if ( + gapBuckets > 0 && + gapBuckets <= maxGapBuckets && + previous !== null && + previous === current + ) { + for (let fillIndex = lastKnownIndex + 1; fillIndex < index; fillIndex++) { + result[fillIndex] = previous; + } + } + } + + lastKnownIndex = index; + } + + return result; +} + +function average(sum: number, count: number): number | null { + if (!Number.isFinite(sum) || !Number.isFinite(count) || count <= 0) { + return null; + } + return Number((sum / count).toFixed(4)); +} + +function buildBucketStarts(input: { + now: string | Date; + rangeHours: number; + intervalMinutes: number; +}): { coveredFrom: string; coveredTo: string; bucketStarts: string[] } { + const now = input.now instanceof Date ? input.now : new Date(input.now); + const baseBucketMs = PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES * 60 * 1000; + const bucketCount = Math.ceil((input.rangeHours * 60) / PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES); + const coveredTo = alignBucketStartUtc(now.toISOString(), input.intervalMinutes); + const coveredToMs = Date.parse(coveredTo); + const coveredFromMs = coveredToMs - bucketCount * baseBucketMs; + + return { + coveredFrom: new Date(coveredFromMs).toISOString(), + coveredTo, + bucketStarts: Array.from({ length: bucketCount }, (_, index) => + new Date(coveredFromMs + index * baseBucketMs).toISOString() + ), + }; +} + +export function buildPublicStatusPayloadFromRollups(input: { + rangeHours: number; + intervalMinutes: number; + now: string | Date; + groups: PublicStatusConfiguredGroup[]; + rollupBuckets: PublicStatusRollupBucket[]; +}): PublicStatusRollupAggregationResult { + const { coveredFrom, coveredTo, bucketStarts } = buildBucketStarts(input); + const bucketByStart = new Map(input.rollupBuckets.map((bucket) => [bucket.bucketStart, bucket])); + const intervalFactor = Math.max( + 1, + Math.round(input.intervalMinutes / PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES) + ); + const displayBuckets = bucketStarts.flatMap((bucketStart, index) => + index % intervalFactor === 0 ? [{ bucketStart, index }] : [] + ); + + const groups = input.groups.map((group) => { + const groupIds = getPublicStatusGroupRollupIds(group); + const models = group.models.map((model) => { + const aggregateBuckets = displayBuckets.map((displayBucket) => { + const slice = bucketStarts.slice(displayBucket.index, displayBucket.index + intervalFactor); + return slice.reduce( + (acc, bucketStart) => { + const bucket = bucketByStart.get(bucketStart); + if (!bucket) { + return acc; + } + + for (const groupId of groupIds) { + acc.successCount += getRollupValue({ + bucket, + groupId, + modelKey: model.publicModelKey, + metric: "success", + }); + acc.failureCount += getRollupValue({ + bucket, + groupId, + modelKey: model.publicModelKey, + metric: "failure", + }); + acc.ttfbSum += getRollupValue({ + bucket, + groupId, + modelKey: model.publicModelKey, + metric: "ttfb_sum", + }); + acc.ttfbCount += getRollupValue({ + bucket, + groupId, + modelKey: model.publicModelKey, + metric: "ttfb_count", + }); + acc.tpsSum += getRollupValue({ + bucket, + groupId, + modelKey: model.publicModelKey, + metric: "tps_sum", + }); + acc.tpsCount += getRollupValue({ + bucket, + groupId, + modelKey: model.publicModelKey, + metric: "tps_count", + }); + } + return acc; + }, + { + bucketStart: displayBucket.bucketStart, + successCount: 0, + failureCount: 0, + ttfbSum: 0, + ttfbCount: 0, + tpsSum: 0, + tpsCount: 0, + } + ); + }); + + const rawTimeline = aggregateBuckets.map((bucket) => { + const total = bucket.successCount + bucket.failureCount; + if (total <= 0) { + return null; + } + return bucket.successCount > 0 ? "operational" : "failed"; + }); + const filledTimeline = applyBoundedGapFill({ timeline: rawTimeline }); + + let latestTtfbMs: number | null = null; + let latestTps: number | null = null; + const timeline: PublicStatusTimelineBucket[] = aggregateBuckets.map((bucket, index) => { + const bucketStartMs = Date.parse(bucket.bucketStart); + const total = bucket.successCount + bucket.failureCount; + const availabilityPct = + total <= 0 + ? filledTimeline[index] === "operational" + ? 100 + : filledTimeline[index] === "failed" + ? 0 + : null + : Number(((bucket.successCount / total) * 100).toFixed(2)); + const ttfbMs = average(bucket.ttfbSum, bucket.ttfbCount); + const tps = average(bucket.tpsSum, bucket.tpsCount); + + if (ttfbMs !== null) { + latestTtfbMs = ttfbMs; + } + if (tps !== null) { + latestTps = tps; + } + + return { + bucketStart: bucket.bucketStart, + bucketEnd: new Date(bucketStartMs + input.intervalMinutes * 60 * 1000).toISOString(), + state: + filledTimeline[index] === "operational" + ? "operational" + : filledTimeline[index] === "failed" + ? "failed" + : "no_data", + availabilityPct, + ttfbMs, + tps, + sampleCount: total, + }; + }); + + const totalSuccess = aggregateBuckets.reduce((sum, bucket) => sum + bucket.successCount, 0); + const totalFailure = aggregateBuckets.reduce((sum, bucket) => sum + bucket.failureCount, 0); + const totalCount = totalSuccess + totalFailure; + const availabilityPct = + totalCount <= 0 ? null : Number(((totalSuccess / totalCount) * 100).toFixed(2)); + const latestKnownBucket = + [...aggregateBuckets].reverse().find((bucket) => { + const total = bucket.successCount + bucket.failureCount; + return total > 0; + }) ?? null; + const latestBucketAvailabilityPct = latestKnownBucket + ? (latestKnownBucket.successCount / + (latestKnownBucket.successCount + latestKnownBucket.failureCount)) * + 100 + : null; + const latestStateRaw = + latestKnownBucket && latestKnownBucket.successCount <= 0 + ? "failed" + : latestBucketAvailabilityPct !== null && latestBucketAvailabilityPct < 50 + ? "degraded" + : ([...filledTimeline].reverse().find((state) => state !== null) ?? null); + + return { + publicModelKey: model.publicModelKey, + label: model.label, + vendorIconKey: model.vendorIconKey, + requestTypeBadge: model.requestTypeBadge, + latestState: + latestStateRaw === "operational" + ? "operational" + : latestStateRaw === "degraded" + ? "degraded" + : latestStateRaw === "failed" + ? "failed" + : "no_data", + availabilityPct, + latestTtfbMs, + latestTps, + timeline, + } satisfies PublicStatusPayload["groups"][number]["models"][number]; + }); + + return { + publicGroupSlug: group.publicGroupSlug, + displayName: group.displayName, + explanatoryCopy: group.explanatoryCopy, + models, + } satisfies PublicStatusPayload["groups"][number]; + }); + + return { + generatedAt: coveredTo, + coveredFrom, + coveredTo, + groups, + }; +} + +export function buildPublicStatusRollupBucketStarts(input: { + now: string | Date; + rangeHours: number; + intervalMinutes: number; +}): string[] { + return buildBucketStarts(input).bucketStarts; +} + +export { PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES }; diff --git a/src/lib/public-status/scheduler.ts b/src/lib/public-status/scheduler.ts index 1bfa8e6de..1edd2e378 100644 --- a/src/lib/public-status/scheduler.ts +++ b/src/lib/public-status/scheduler.ts @@ -10,12 +10,12 @@ import { getRedisClient } from "@/lib/redis"; import { scanPattern } from "@/lib/redis/scan-helper"; import { readCurrentInternalPublicStatusConfigSnapshot } from "./config-snapshot"; import { rebuildPublicStatusProjection } from "./rebuild-worker"; -import { buildPublicStatusManifestKey } from "./redis-contract"; +import { buildPublicStatusManifestKey, PUBLIC_STATUS_REDIS_PREFIX } from "./redis-contract"; const LOCK_KEY = "locks:public-status-rebuild-scheduler"; const TICK_INTERVAL_MS = 30_000; const LOCK_TTL_MS = 30_000; -const REBUILD_HINT_PATTERN = "public-status:v1:rebuild-hint:*"; +const REBUILD_HINT_PATTERN = `${PUBLIC_STATUS_REDIS_PREFIX}:rebuild-hint:*`; const schedulerState = globalThis as unknown as { __CCH_PUBLIC_STATUS_REBUILD_SCHEDULER_STARTED__?: boolean; diff --git a/src/lib/public-status/vendor-icon-key.ts b/src/lib/public-status/vendor-icon-key.ts index f3b03ac49..b283d5e11 100644 --- a/src/lib/public-status/vendor-icon-key.ts +++ b/src/lib/public-status/vendor-icon-key.ts @@ -1,4 +1,4 @@ -import { getModelVendor } from "@/lib/model-vendor-icons"; +import { getModelVendor } from "@/lib/model-vendor-rules"; import type { ProviderType } from "@/types/provider"; export const PUBLIC_STATUS_VENDOR_ICON_KEYS = [ diff --git a/src/repository/message.ts b/src/repository/message.ts index 5ad4467b4..ec13ded4d 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -5,6 +5,11 @@ import { db } from "@/drizzle/db"; import { keys as keysTable, messageRequest, providers, usageLedger, users } from "@/drizzle/schema"; import { getEnvConfig } from "@/lib/config/env.schema"; import { isLedgerOnlyMode } from "@/lib/ledger-fallback"; +import { logger } from "@/lib/logger"; +import { + getConfiguredPublicStatusGroupsForRollup, + queuePublicStatusRollupWrite, +} from "@/lib/public-status/rollup-store"; import { formatCostForStorage } from "@/lib/utils/currency"; import type { StoredCostBreakdown } from "@/types/cost-breakdown"; import type { CreateMessageRequestData, MessageRequest, ProviderChainItem } from "@/types/message"; @@ -14,6 +19,92 @@ import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; import { toMessageRequest } from "./_shared/transformers"; import { enqueueMessageRequestUpdate } from "./message-write-buffer"; +type PublicStatusRequestSeed = { + createdAt: Date; + model?: string | null; + originalModel?: string | null; + durationMs?: number | null; +}; + +type PublicStatusFinalDetails = { + statusCode?: number; + outputTokens?: number; + ttfbMs?: number | null; + providerChain?: CreateMessageRequestData["provider_chain"]; + errorMessage?: string; + model?: string; +}; + +const publicStatusRequestSeedCache = new Map(); +const PUBLIC_STATUS_REQUEST_SEED_CACHE_MAX_SIZE = 10_000; + +function rememberPublicStatusRequestSeed(id: number, seed: PublicStatusRequestSeed): void { + publicStatusRequestSeedCache.set(id, seed); + if (publicStatusRequestSeedCache.size <= PUBLIC_STATUS_REQUEST_SEED_CACHE_MAX_SIZE) { + return; + } + + const firstKey = publicStatusRequestSeedCache.keys().next().value as number | undefined; + if (firstKey !== undefined) { + publicStatusRequestSeedCache.delete(firstKey); + } +} + +function consumePublicStatusRequestSeed(id: number): PublicStatusRequestSeed | null { + const seed = publicStatusRequestSeedCache.get(id) ?? null; + publicStatusRequestSeedCache.delete(id); + return seed; +} + +function updatePublicStatusRequestSeed(id: number, patch: Partial): void { + const seed = publicStatusRequestSeedCache.get(id); + if (!seed) { + return; + } + publicStatusRequestSeedCache.set(id, { ...seed, ...patch }); +} + +function isPublicStatusFinalDetails(details: PublicStatusFinalDetails): boolean { + return details.providerChain !== undefined && details.statusCode !== undefined; +} + +function queuePublicStatusRollupForFinalDetails( + id: number, + details: PublicStatusFinalDetails +): void { + const seed = consumePublicStatusRequestSeed(id); + if (!seed || !isPublicStatusFinalDetails(details)) { + return; + } + + void (async () => { + try { + const groups = await getConfiguredPublicStatusGroupsForRollup(); + if (groups.length === 0) { + return; + } + + await queuePublicStatusRollupWrite({ + groups, + event: { + createdAt: seed.createdAt, + model: details.model ?? seed.model, + originalModel: seed.originalModel, + durationMs: seed.durationMs, + ttfbMs: details.ttfbMs, + outputTokens: details.outputTokens, + providerChain: details.providerChain, + }, + }); + } catch (error) { + logger.warn("[MessageRequest] Failed to queue public status rollup", { + error: error instanceof Error ? error.message : String(error), + messageRequestId: id, + }); + } + })(); +} + /** * 创建消息请求记录 */ @@ -72,6 +163,13 @@ export async function createMessageRequest( deletedAt: messageRequest.deletedAt, }); + rememberPublicStatusRequestSeed(result.id, { + createdAt: result.createdAt!, + model: result.model, + originalModel: result.originalModel, + durationMs: result.durationMs, + }); + return toMessageRequest(result); } @@ -79,6 +177,7 @@ export async function createMessageRequest( * 更新消息请求的耗时 */ export async function updateMessageRequestDuration(id: number, durationMs: number): Promise { + updatePublicStatusRequestSeed(id, { durationMs }); if (getEnvConfig().MESSAGE_REQUEST_WRITE_MODE === "async") { enqueueMessageRequestUpdate(id, { durationMs }); return; @@ -177,8 +276,14 @@ export async function updateMessageRequestDetails( specialSettings?: CreateMessageRequestData["special_settings"]; // 特殊设置(审计/展示) } ): Promise { + const shouldQueuePublicStatusRollup = + details.providerChain !== undefined && details.statusCode !== undefined; + if (getEnvConfig().MESSAGE_REQUEST_WRITE_MODE === "async") { enqueueMessageRequestUpdate(id, details); + if (shouldQueuePublicStatusRollup) { + queuePublicStatusRollupForFinalDetails(id, details); + } return; } @@ -245,6 +350,9 @@ export async function updateMessageRequestDetails( } await db.update(messageRequest).set(updateData).where(eq(messageRequest.id, id)); + if (shouldQueuePublicStatusRollup) { + queuePublicStatusRollupForFinalDetails(id, details); + } } /** diff --git a/tests/unit/public-status/config-publisher.test.ts b/tests/unit/public-status/config-publisher.test.ts index b498880db..b537c69f4 100644 --- a/tests/unit/public-status/config-publisher.test.ts +++ b/tests/unit/public-status/config-publisher.test.ts @@ -255,6 +255,7 @@ describe("public-status config publisher", () => { snapshot: expect.objectContaining({ groups: [ expect.objectContaining({ + sourceGroupId: 2, sourceGroupName: "default", slug: "platform", displayName: "Platform", diff --git a/tests/unit/public-status/config-snapshot.test.ts b/tests/unit/public-status/config-snapshot.test.ts index ea2bcc7b3..2cbf2ab9a 100644 --- a/tests/unit/public-status/config-snapshot.test.ts +++ b/tests/unit/public-status/config-snapshot.test.ts @@ -44,6 +44,16 @@ interface ConfigSnapshotModule { get: (key: string) => Promise; }; }): Promise<{ siteTitle: string; siteDescription: string } | null>; + readCurrentInternalPublicStatusConfigSnapshot(input: { + redis: { + status: string; + get: (key: string) => Promise; + }; + allowLegacyFallback?: boolean; + }): Promise<{ + configVersion: string; + groups: unknown[]; + } | null>; publishPublicStatusConfigSnapshot(input: { reason: string; snapshot?: { @@ -167,6 +177,46 @@ describe("public-status config snapshot", () => { }); }); + it("can disable legacy config fallback for v2 rollup writers", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/config-snapshot" + ); + + const redis = { + status: "ready", + get: vi.fn(async (key: string) => { + if (key === "public-status:v1:config-version:current") { + return "cfg-v1"; + } + if (key === "public-status:v1:config-internal:cfg-v1") { + return JSON.stringify({ + configVersion: "cfg-v1", + siteTitle: "Legacy", + siteDescription: "Legacy", + defaultIntervalMinutes: 5, + defaultRangeHours: 24, + groups: [], + }); + } + return null; + }), + }; + + await expect( + mod.readCurrentInternalPublicStatusConfigSnapshot({ + redis, + allowLegacyFallback: false, + }) + ).resolves.toBeNull(); + expect(redis.get).not.toHaveBeenCalledWith("public-status:v1:config-version:current"); + + await expect( + mod.readCurrentInternalPublicStatusConfigSnapshot({ + redis, + }) + ).resolves.toMatchObject({ configVersion: "cfg-v1" }); + }); + it("does not let an older configVersion overwrite the current pointer", async () => { const mod = await importPublicStatusModule( "@/lib/public-status/config-snapshot" diff --git a/tests/unit/public-status/no-db-import-guard.test.ts b/tests/unit/public-status/no-db-import-guard.test.ts index ea04617a4..018f21d12 100644 --- a/tests/unit/public-status/no-db-import-guard.test.ts +++ b/tests/unit/public-status/no-db-import-guard.test.ts @@ -13,6 +13,8 @@ const guardedFiles = [ "src/app/[locale]/layout.tsx", "src/lib/public-status/public-api-loader.ts", "src/lib/public-status/read-store.ts", + "src/lib/public-status/rollup-store.ts", + "src/lib/public-status/aggregation-core.ts", "src/lib/public-status/config-snapshot.ts", "src/lib/public-status/layout-metadata.ts", ]; @@ -34,6 +36,8 @@ const directTokenGuardFiles = new Set([ "src/app/[locale]/layout.tsx", "src/lib/public-status/public-api-loader.ts", "src/lib/public-status/read-store.ts", + "src/lib/public-status/rollup-store.ts", + "src/lib/public-status/aggregation-core.ts", ]); describe("public-status no-db import guard", () => { diff --git a/tests/unit/public-status/public-status-view.test.tsx b/tests/unit/public-status/public-status-view.test.tsx index 74d2707bc..c25dd1291 100644 --- a/tests/unit/public-status/public-status-view.test.tsx +++ b/tests/unit/public-status/public-status-view.test.tsx @@ -307,9 +307,10 @@ describe("public-status view", () => { await Promise.resolve(); }); - expect(fetchMock).toHaveBeenCalledWith("/api/public-status?interval=5&rangeHours=24", { - cache: "no-store", - }); + expect(fetchMock).toHaveBeenCalledWith( + "/api/public-status?interval=5&rangeHours=24&include=meta%2Cdefaults%2Cgroups", + { cache: "no-store" } + ); await act(async () => { vi.advanceTimersByTime(30_000); @@ -492,9 +493,10 @@ describe("public-status view", () => { await Promise.resolve(); }); - expect(fetchMock).toHaveBeenCalledWith("/api/public-status?groupSlug=platform", { - cache: "no-store", - }); + expect(fetchMock).toHaveBeenCalledWith( + "/api/public-status?groupSlug=platform&include=meta%2Cdefaults%2Cgroups", + { cache: "no-store" } + ); await act(async () => { vi.advanceTimersByTime(30_000); @@ -509,4 +511,99 @@ describe("public-status view", () => { vi.useRealTimers(); unmount(); }); + + it("updates summary state from polling payload even when timeline is reused", async () => { + vi.useFakeTimers(); + + const fetchMock = vi.fn(async () => ({ + status: 200, + json: async () => + buildRouteResponse({ + groups: [ + { + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: "Primary models", + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + latestState: "failed", + availabilityPct: 0, + latestTtfbMs: null, + latestTps: null, + timeline: [], + }, + ], + }, + ], + }), + })); + global.fetch = fetchMock as typeof global.fetch; + + const { container, unmount } = render( + + ); + + expect(container.textContent).toContain("Operational"); + expect(container.querySelector('[data-testid="public-status-timeline"]')?.textContent).toBe( + "1" + ); + + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + }); + + expect(container.textContent).toContain("Failed"); + expect(container.textContent).toContain("0.00%"); + expect(container.querySelector('[data-testid="public-status-timeline"]')?.textContent).toBe( + "1" + ); + + vi.useRealTimers(); + unmount(); + }); }); diff --git a/tests/unit/public-status/read-store.test.ts b/tests/unit/public-status/read-store.test.ts index dfbb41cdb..fca5ca0c1 100644 --- a/tests/unit/public-status/read-store.test.ts +++ b/tests/unit/public-status/read-store.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + LEGACY_PUBLIC_STATUS_REDIS_PREFIX, buildPublicStatusCurrentSnapshotKey, buildPublicStatusManifestKey, } from "@/lib/public-status/redis-contract"; @@ -138,6 +139,231 @@ describe("readPublicStatusPayload", () => { expect(triggerRebuildHint).toHaveBeenCalledWith("snapshot-missing"); }); + it("falls back to legacy v1 projections during v2 rollout and schedules a rebuild", async () => { + const triggerRebuildHint = vi.fn(); + const redis = createRedisReader({ + [buildPublicStatusManifestKey({ + configVersion: "current", + intervalMinutes: 5, + rangeHours: 24, + prefix: LEGACY_PUBLIC_STATUS_REDIS_PREFIX, + })]: { + configVersion: "cfg-v1", + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v1", + sourceGeneration: "gen-v1", + coveredFrom: "2026-04-20T10:00:00.000Z", + coveredTo: "2026-04-21T10:00:00.000Z", + generatedAt: "2026-04-21T09:59:00.000Z", + freshUntil: "2026-04-21T10:04:00.000Z", + rebuildState: "idle", + lastCompleteGeneration: "gen-v1", + }, + [buildPublicStatusCurrentSnapshotKey({ + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v1", + prefix: LEGACY_PUBLIC_STATUS_REDIS_PREFIX, + })]: { + sourceGeneration: "gen-v1", + generatedAt: "2026-04-21T09:59:00.000Z", + freshUntil: "2026-04-21T10:04:00.000Z", + groups: [ + { + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: null, + models: [], + }, + ], + }, + }); + + const payload = await readPublicStatusPayload({ + intervalMinutes: 5, + rangeHours: 24, + nowIso: "2026-04-21T10:00:00.000Z", + configVersion: "cfg-v2", + hasConfiguredGroups: true, + redis, + triggerRebuildHint, + }); + + expect(payload).toMatchObject({ + rebuildState: "stale", + sourceGeneration: "gen-v1", + generatedAt: "2026-04-21T09:59:00.000Z", + }); + expect(triggerRebuildHint.mock.calls.map(([reason]) => reason)).toEqual([ + "stale-generation", + "legacy-generation", + "config-version-mismatch", + ]); + }); + + it("keeps serving legacy projections while a new v2 rollup window is incomplete", async () => { + const triggerRebuildHint = vi.fn(); + const redis = createRedisReader({ + [buildPublicStatusManifestKey({ + configVersion: "cfg-v2", + intervalMinutes: 5, + rangeHours: 24, + })]: { + configVersion: "cfg-v2", + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v2", + sourceGeneration: "gen-v2", + coveredFrom: "2026-04-20T10:00:00.000Z", + coveredTo: "2026-04-21T10:00:00.000Z", + generatedAt: "2026-04-21T10:00:00.000Z", + freshUntil: "2026-04-21T10:05:00.000Z", + rebuildState: "idle", + lastCompleteGeneration: "gen-v2", + rollupCoverageStartedAt: "2026-04-21T09:55:00.000Z", + rollupCoverageComplete: false, + rollupSampleCount: 1, + }, + [buildPublicStatusCurrentSnapshotKey({ + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v2", + })]: { + sourceGeneration: "gen-v2", + generatedAt: "2026-04-21T10:00:00.000Z", + freshUntil: "2026-04-21T10:05:00.000Z", + groups: [ + { + publicGroupSlug: "openai-v2", + displayName: "OpenAI v2", + explanatoryCopy: null, + models: [], + }, + ], + }, + [buildPublicStatusManifestKey({ + configVersion: "current", + intervalMinutes: 5, + rangeHours: 24, + prefix: LEGACY_PUBLIC_STATUS_REDIS_PREFIX, + })]: { + configVersion: "cfg-v1", + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v1", + sourceGeneration: "gen-v1", + coveredFrom: "2026-04-20T10:00:00.000Z", + coveredTo: "2026-04-21T10:00:00.000Z", + generatedAt: "2026-04-21T09:59:00.000Z", + freshUntil: "2026-04-21T10:04:00.000Z", + rebuildState: "idle", + lastCompleteGeneration: "gen-v1", + }, + [buildPublicStatusCurrentSnapshotKey({ + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v1", + prefix: LEGACY_PUBLIC_STATUS_REDIS_PREFIX, + })]: { + sourceGeneration: "gen-v1", + generatedAt: "2026-04-21T09:59:00.000Z", + freshUntil: "2026-04-21T10:04:00.000Z", + groups: [ + { + publicGroupSlug: "openai-v1", + displayName: "OpenAI v1", + explanatoryCopy: null, + models: [], + }, + ], + }, + }); + + const payload = await readPublicStatusPayload({ + intervalMinutes: 5, + rangeHours: 24, + nowIso: "2026-04-21T10:00:00.000Z", + configVersion: "cfg-v2", + hasConfiguredGroups: true, + redis, + triggerRebuildHint, + }); + + expect(payload).toMatchObject({ + rebuildState: "stale", + sourceGeneration: "gen-v1", + groups: [expect.objectContaining({ publicGroupSlug: "openai-v1" })], + }); + expect(triggerRebuildHint.mock.calls.map(([reason]) => reason)).toEqual([ + "rollup-coverage-incomplete", + "legacy-generation", + "config-version-mismatch", + ]); + }); + + it("serves a stale v2 projection when rollup coverage is incomplete and legacy data is absent", async () => { + const triggerRebuildHint = vi.fn(); + const redis = createRedisReader({ + [buildPublicStatusManifestKey({ + configVersion: "cfg-v2", + intervalMinutes: 5, + rangeHours: 24, + })]: { + configVersion: "cfg-v2", + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v2", + sourceGeneration: "gen-v2", + coveredFrom: "2026-04-20T10:00:00.000Z", + coveredTo: "2026-04-21T10:00:00.000Z", + generatedAt: "2026-04-21T10:00:00.000Z", + freshUntil: "2026-04-21T10:05:00.000Z", + rebuildState: "idle", + lastCompleteGeneration: "gen-v2", + rollupCoverageStartedAt: "2026-04-21T09:55:00.000Z", + rollupCoverageComplete: false, + rollupSampleCount: 1, + }, + [buildPublicStatusCurrentSnapshotKey({ + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v2", + })]: { + sourceGeneration: "gen-v2", + generatedAt: "2026-04-21T10:00:00.000Z", + freshUntil: "2026-04-21T10:05:00.000Z", + groups: [ + { + publicGroupSlug: "openai-v2", + displayName: "OpenAI v2", + explanatoryCopy: null, + models: [], + }, + ], + }, + }); + + const payload = await readPublicStatusPayload({ + intervalMinutes: 5, + rangeHours: 24, + nowIso: "2026-04-21T10:00:00.000Z", + configVersion: "cfg-v2", + hasConfiguredGroups: true, + redis, + triggerRebuildHint, + }); + + expect(payload).toMatchObject({ + rebuildState: "stale", + sourceGeneration: "gen-v2", + groups: [expect.objectContaining({ publicGroupSlug: "openai-v2" })], + }); + expect(triggerRebuildHint.mock.calls.map(([reason]) => reason)).toEqual([ + "rollup-coverage-incomplete", + ]); + }); + it("marks config-version drift as stale and strips unexpected fields from redis snapshots", async () => { const triggerRebuildHint = vi.fn(); const redis = createRedisReader({ diff --git a/tests/unit/public-status/rebuild-worker.test.ts b/tests/unit/public-status/rebuild-worker.test.ts index 9edc7e445..c9d3343b7 100644 --- a/tests/unit/public-status/rebuild-worker.test.ts +++ b/tests/unit/public-status/rebuild-worker.test.ts @@ -1,19 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + PUBLIC_STATUS_REDIS_PREFIX, buildPublicStatusCurrentSnapshotKey, buildPublicStatusManifestKey, buildPublicStatusRebuildHintKey, + buildPublicStatusRollupCoverageStartKey, + buildPublicStatusRollupKey, } from "@/lib/public-status/redis-contract"; +import { buildPublicStatusRollupField } from "@/lib/public-status/rollup-store"; import { importPublicStatusModule } from "../../helpers/public-status-test-helpers"; const mockRedisSet = vi.hoisted(() => vi.fn()); const mockRedisDel = vi.hoisted(() => vi.fn()); const mockRedisGet = vi.hoisted(() => vi.fn()); +const mockRedisHgetall = vi.hoisted(() => vi.fn()); const mockRedisEval = vi.hoisted(() => vi.fn()); const mockRedisPttl = vi.hoisted(() => vi.fn()); const mockReadCurrentInternalPublicStatusConfigSnapshot = vi.hoisted(() => vi.fn()); -const mockQueryPublicStatusRequests = vi.hoisted(() => vi.fn()); -const mockBuildPublicStatusPayloadFromRequests = vi.hoisted(() => vi.fn()); const mockPublishCurrentPublicStatusConfigProjection = vi.hoisted(() => vi.fn()); async function importAggregationModule() { @@ -79,6 +82,7 @@ async function importRebuildWorkerModule() { vi.doMock("@/lib/redis", () => ({ getRedisClient: () => ({ get: mockRedisGet, + hgetall: mockRedisHgetall, pttl: mockRedisPttl, set: mockRedisSet, del: mockRedisDel, @@ -95,8 +99,6 @@ async function importRebuildWorkerModule() { })); vi.doMock("@/lib/public-status/aggregation", () => ({ getConfiguredPublicStatusGroups: (snapshot: { groups: unknown[] }) => snapshot.groups, - queryPublicStatusRequests: mockQueryPublicStatusRequests, - buildPublicStatusPayloadFromRequests: mockBuildPublicStatusPayloadFromRequests, })); return importPublicStatusModule<{ @@ -127,6 +129,7 @@ async function importRebuildHintsModule() { vi.doMock("@/lib/redis", () => ({ getRedisClient: () => ({ get: mockRedisGet, + hgetall: mockRedisHgetall, pttl: mockRedisPttl, set: mockRedisSet, del: mockRedisDel, @@ -156,6 +159,7 @@ describe("public-status rebuild worker", () => { beforeEach(() => { vi.clearAllMocks(); mockRedisGet.mockResolvedValue(null); + mockRedisHgetall.mockResolvedValue({}); mockRedisEval.mockResolvedValue(1); mockRedisPttl.mockResolvedValue(-1); mockPublishCurrentPublicStatusConfigProjection.mockResolvedValue({ @@ -422,13 +426,20 @@ describe("public-status rebuild worker", () => { }, ], }); - mockQueryPublicStatusRequests.mockResolvedValue([]); - mockBuildPublicStatusPayloadFromRequests.mockReturnValue({ - generatedAt: "2026-04-21T10:00:00.000Z", - coveredFrom: "2026-04-20T10:00:00.000Z", - coveredTo: "2026-04-21T10:00:00.000Z", - groups: [], + const rollupKey = buildPublicStatusRollupKey({ + bucketStartIso: "2026-04-21T09:55:00.000Z", }); + mockRedisHgetall.mockImplementation(async (key: string) => + key === rollupKey + ? { + [buildPublicStatusRollupField({ + groupId: "openai", + modelKey: "gpt-4.1", + metric: "success", + })]: "1", + } + : {} + ); mockRedisSet.mockReset(); mockRedisSet.mockResolvedValueOnce("OK"); @@ -453,10 +464,12 @@ describe("public-status rebuild worker", () => { const manifestValue = JSON.parse(String(versionedManifestCall?.[1])); expect(manifestValue.configVersion).toBe("cfg-1"); expect(manifestValue.lastCompleteGeneration).toBeTruthy(); + expect(manifestValue.rollupCoverageComplete).toBe(false); + expect(manifestValue.rollupSampleCount).toBe(1); expect(mockRedisEval).toHaveBeenCalledWith( expect.stringContaining("redis.call('DEL', KEYS[1])"), 1, - expect.stringContaining("public-status:v1:rebuild-lock:"), + expect.stringContaining(`${PUBLIC_STATUS_REDIS_PREFIX}:rebuild-lock:`), expect.any(String) ); expect(mockRedisDel).toHaveBeenCalled(); @@ -474,6 +487,63 @@ describe("public-status rebuild worker", () => { ); }); + it("marks rebuilt generations as fully covered once rollups cover the whole window", async () => { + const mod = await importRebuildWorkerModule(); + + mockReadCurrentInternalPublicStatusConfigSnapshot.mockResolvedValue({ + configVersion: "cfg-1", + generatedAt: "2026-04-21T10:00:00.000Z", + siteTitle: "Claude Code Hub Status", + siteDescription: "Request-derived public status", + defaultIntervalMinutes: 5, + defaultRangeHours: 24, + groups: [ + { + sourceGroupId: 42, + sourceGroupName: "openai", + slug: "openai", + displayName: "OpenAI", + description: "Primary fleet", + sortOrder: 1, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], + }, + ], + }); + mockRedisGet.mockImplementation(async (key: string) => + key === buildPublicStatusRollupCoverageStartKey() ? "2026-04-20T10:00:00.000Z" : null + ); + mockRedisHgetall.mockResolvedValue({}); + mockRedisSet.mockReset(); + mockRedisSet.mockResolvedValueOnce("OK"); + + const result = await mod.rebuildPublicStatusProjection({ + intervalMinutes: 5, + rangeHours: 24, + now: new Date("2026-04-21T10:02:00.000Z"), + }); + + expect(result.status).toBe("updated"); + const versionedManifestCall = mockRedisSet.mock.calls.find( + (call) => + call[0] === + buildPublicStatusManifestKey({ + configVersion: "cfg-1", + intervalMinutes: 5, + rangeHours: 24, + }) + ); + const manifestValue = JSON.parse(String(versionedManifestCall?.[1])); + expect(manifestValue.rollupCoverageComplete).toBe(true); + expect(manifestValue.rollupCoverageStartedAt).toBe("2026-04-20T10:00:00.000Z"); + }); + it("re-publishes config projection before rebuild when redis config keys are missing", async () => { const mod = await importRebuildWorkerModule(); @@ -504,13 +574,6 @@ describe("public-status rebuild worker", () => { }, ], }); - mockQueryPublicStatusRequests.mockResolvedValue([]); - mockBuildPublicStatusPayloadFromRequests.mockReturnValue({ - generatedAt: "2026-04-21T10:00:00.000Z", - coveredFrom: "2026-04-20T10:00:00.000Z", - coveredTo: "2026-04-21T10:00:00.000Z", - groups: [], - }); mockRedisSet.mockReset(); mockRedisSet.mockResolvedValueOnce("OK"); @@ -524,6 +587,14 @@ describe("public-status rebuild worker", () => { expect(mockPublishCurrentPublicStatusConfigProjection).toHaveBeenCalledWith({ reason: "rebuild-bootstrap", }); + expect(mockReadCurrentInternalPublicStatusConfigSnapshot).toHaveBeenNthCalledWith(1, { + redis: expect.any(Object), + allowLegacyFallback: false, + }); + expect(mockReadCurrentInternalPublicStatusConfigSnapshot).toHaveBeenNthCalledWith(2, { + redis: expect.any(Object), + allowLegacyFallback: false, + }); }); it("writes rebuild hints with ttl and reason payload", async () => { @@ -547,6 +618,30 @@ describe("public-status rebuild worker", () => { ); }); + it("does not rewrite rebuild hints while an existing hint is still live", async () => { + const mod = await importRebuildHintsModule(); + + mockRedisPttl.mockResolvedValueOnce(120_000); + + await expect( + mod.schedulePublicStatusRebuild({ + intervalMinutes: 15, + rangeHours: 48, + reason: "stale-generation", + }) + ).resolves.toMatchObject({ + accepted: true, + rebuildState: "rebuilding", + key: buildPublicStatusRebuildHintKey({ + intervalMinutes: 15, + rangeHours: 48, + }), + }); + + expect(mockRedisSet).not.toHaveBeenCalled(); + expect(mockRedisGet).not.toHaveBeenCalled(); + }); + it("preserves manifest ttl when marking rebuildState as rebuilding", async () => { const mod = await importRebuildHintsModule(); @@ -566,7 +661,10 @@ describe("public-status rebuild worker", () => { rebuildState: "idle", }) ); - mockRedisPttl.mockResolvedValueOnce(2_592_000_000).mockResolvedValueOnce(-1); + mockRedisPttl + .mockResolvedValueOnce(-1) + .mockResolvedValueOnce(2_592_000_000) + .mockResolvedValueOnce(-1); await mod.schedulePublicStatusRebuild({ intervalMinutes: 5, @@ -575,13 +673,21 @@ describe("public-status rebuild worker", () => { }); expect(mockRedisSet).toHaveBeenCalledWith( - "public-status:v1:manifest:cfg-1:5m:24h", + buildPublicStatusManifestKey({ + configVersion: "cfg-1", + intervalMinutes: 5, + rangeHours: 24, + }), expect.stringContaining('"rebuildState":"rebuilding"'), "PX", 2_592_000_000 ); expect(mockRedisSet).toHaveBeenCalledWith( - "public-status:v1:manifest:current:5m:24h", + buildPublicStatusManifestKey({ + configVersion: "current", + intervalMinutes: 5, + rangeHours: 24, + }), expect.stringContaining('"rebuildState":"rebuilding"') ); }); diff --git a/tests/unit/public-status/redis-contract.test.ts b/tests/unit/public-status/redis-contract.test.ts index 95cdd4dda..8875580a4 100644 --- a/tests/unit/public-status/redis-contract.test.ts +++ b/tests/unit/public-status/redis-contract.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest"; import { importPublicStatusModule } from "../../helpers/public-status-test-helpers"; interface RedisContractModule { + PUBLIC_STATUS_REDIS_PREFIX: string; + LEGACY_PUBLIC_STATUS_REDIS_PREFIX: string; + PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES: number; buildGenerationFingerprint(input: { configVersion: string; intervalMinutes: number; @@ -17,9 +20,64 @@ interface RedisContractModule { } | null, nowIso: string ): { rebuildState: string; sourceGeneration: string | null }; + buildPublicStatusManifestKey(input: { + configVersion: string; + intervalMinutes: number; + rangeHours: number; + prefix?: string; + }): string; + buildPublicStatusRollupKey(input: { + bucketStartIso: string; + bucketMinutes?: number; + prefix?: string; + }): string; + buildPublicStatusRollupCoverageStartKey(input?: { + bucketMinutes?: number; + prefix?: string; + }): string; } describe("public-status redis contract", () => { + it("uses v2 keys by default while keeping explicit v1 builders for upgrade fallback", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/redis-contract" + ); + + expect(mod.PUBLIC_STATUS_REDIS_PREFIX).toBe("public-status:v2"); + expect(mod.LEGACY_PUBLIC_STATUS_REDIS_PREFIX).toBe("public-status:v1"); + expect( + mod.buildPublicStatusManifestKey({ + configVersion: "cfg-1", + intervalMinutes: 5, + rangeHours: 24, + }) + ).toBe("public-status:v2:manifest:cfg-1:5m:24h"); + expect( + mod.buildPublicStatusManifestKey({ + configVersion: "cfg-1", + intervalMinutes: 5, + rangeHours: 24, + prefix: mod.LEGACY_PUBLIC_STATUS_REDIS_PREFIX, + }) + ).toBe("public-status:v1:manifest:cfg-1:5m:24h"); + }); + + it("builds one aligned 5m rollup key per base bucket", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/redis-contract" + ); + + expect(mod.PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES).toBe(5); + expect( + mod.buildPublicStatusRollupKey({ + bucketStartIso: "2026-04-21T10:07:31.000Z", + }) + ).toBe("public-status:v2:rollup:5m:2026-04-21T10%3A05%3A00.000Z"); + expect(mod.buildPublicStatusRollupCoverageStartKey()).toBe( + "public-status:v2:rollup:coverage-start:5m" + ); + }); + it("changes generation fingerprint when interval changes", async () => { const mod = await importPublicStatusModule( "@/lib/public-status/redis-contract" diff --git a/tests/unit/public-status/rollup-store.test.ts b/tests/unit/public-status/rollup-store.test.ts new file mode 100644 index 000000000..201351ee6 --- /dev/null +++ b/tests/unit/public-status/rollup-store.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildPublicStatusPayloadFromRollups, + readPublicStatusRollupBuckets, + buildPublicStatusRollupField, + buildPublicStatusRollupIncrements, + buildPublicStatusRollupBucketStarts, + parsePublicStatusRollupField, + writePublicStatusRollupEvent, + type PublicStatusRollupBucket, +} from "@/lib/public-status/rollup-store"; +import { + buildPublicStatusRollupCoverageStartKey, + buildPublicStatusRollupKey, +} from "@/lib/public-status/redis-contract"; +import type { PublicStatusConfiguredGroup } from "@/lib/public-status/aggregation-core"; + +const groups: PublicStatusConfiguredGroup[] = [ + { + sourceGroupId: 42, + sourceGroupName: "openai", + publicGroupSlug: "openai-public", + displayName: "OpenAI", + explanatoryCopy: null, + sortOrder: 1, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], + }, +]; + +describe("public-status rollup store", () => { + it("builds rollup increments by stable provider group id and public model key", () => { + const increments = buildPublicStatusRollupIncrements({ + groups, + event: { + createdAt: "2026-04-21T10:02:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1200, + ttfbMs: 200, + outputTokens: 50, + providerChain: [ + { + id: 7, + name: "internal-provider", + endpointUrl: "https://private.example.com", + groupTag: "openai", + reason: "request_success", + statusCode: 200, + }, + ], + }, + }); + + expect(increments).toEqual( + expect.arrayContaining([ + { groupId: "42", modelKey: "gpt-4.1", metric: "success", value: 1 }, + { groupId: "42", modelKey: "gpt-4.1", metric: "ttfb_sum", value: 200 }, + { groupId: "42", modelKey: "gpt-4.1", metric: "ttfb_count", value: 1 }, + { groupId: "42", modelKey: "gpt-4.1", metric: "tps_sum", value: 50 }, + { groupId: "42", modelKey: "gpt-4.1", metric: "tps_count", value: 1 }, + ]) + ); + + for (const increment of increments) { + expect(increment.groupId).toBe("42"); + expect(JSON.stringify(increment)).not.toContain("private.example.com"); + } + }); + + it("excludes local/client failures from rollup counts", () => { + const increments = buildPublicStatusRollupIncrements({ + groups, + event: { + createdAt: "2026-04-21T10:02:00.000Z", + originalModel: "gpt-4.1", + providerChain: [ + { + id: 7, + name: "internal-provider", + groupTag: "openai", + reason: "client_abort", + statusCode: 499, + }, + ], + }, + }); + + expect(increments).toEqual([]); + }); + + it("writes one 5m bucket hash instead of endpoint multiplied keys", async () => { + const fields = new Map(); + const pipeline = { + hincrbyfloat: vi.fn((_key: string, field: string, increment: number) => { + fields.set(field, (fields.get(field) ?? 0) + increment); + }), + set: vi.fn(), + expire: vi.fn(), + exec: vi.fn(async () => null), + }; + const redis = { + status: "ready", + hincrbyfloat: vi.fn(async (_key: string, field: string, increment: number) => { + fields.set(field, (fields.get(field) ?? 0) + increment); + }), + expire: vi.fn(), + pipeline: vi.fn(() => pipeline), + }; + + const result = await writePublicStatusRollupEvent({ + redis, + groups, + event: { + createdAt: "2026-04-21T10:02:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1200, + ttfbMs: 200, + outputTokens: 50, + providerChain: [ + { + id: 7, + name: "internal-provider", + endpointUrl: "https://private.example.com", + groupTag: "openai", + reason: "request_success", + statusCode: 200, + }, + ], + }, + }); + + expect(result).toMatchObject({ + written: true, + key: buildPublicStatusRollupKey({ bucketStartIso: "2026-04-21T10:02:00.000Z" }), + }); + expect(redis.hincrbyfloat).not.toHaveBeenCalled(); + expect(pipeline.hincrbyfloat.mock.calls.map(([key]) => key)).toEqual([ + result.key, + result.key, + result.key, + result.key, + result.key, + ]); + expect(pipeline.set).toHaveBeenCalledWith( + buildPublicStatusRollupCoverageStartKey(), + "2026-04-21T10:00:00.000Z", + "NX" + ); + expect(pipeline.expire).toHaveBeenCalledWith(result.key, 60 * 60 * 24 * 32); + expect(pipeline.exec).toHaveBeenCalledTimes(1); + expect(pipeline.hincrbyfloat.mock.calls.map((call) => call.join("|")).join("\n")).not.toContain( + "private.example.com" + ); + expect( + fields.get( + buildPublicStatusRollupField({ + groupId: 42, + modelKey: "gpt-4.1", + metric: "success", + }) + ) + ).toBe(1); + }); + + it("reads rollup buckets through batched Redis pipelines when available", async () => { + const bucketStarts = buildPublicStatusRollupBucketStarts({ + now: "2026-04-21T11:00:00.000Z", + rangeHours: 1, + intervalMinutes: 5, + }); + const pipelineExec = vi.fn(async () => + bucketStarts.map((_, index) => [ + null, + { + [buildPublicStatusRollupField({ + groupId: 42, + modelKey: "gpt-4.1", + metric: "success", + })]: String(index + 1), + }, + ]) + ); + const pipeline = { + hgetall: vi.fn(), + exec: pipelineExec, + }; + const redis = { + hgetall: vi.fn(), + pipeline: vi.fn(() => pipeline), + }; + + const buckets = await readPublicStatusRollupBuckets({ + redis, + bucketStarts, + }); + + expect(redis.pipeline).toHaveBeenCalledTimes(1); + expect(redis.hgetall).not.toHaveBeenCalled(); + expect(pipeline.hgetall).toHaveBeenCalledTimes(bucketStarts.length); + expect(buckets).toHaveLength(bucketStarts.length); + expect( + buckets[0]?.values.get( + buildPublicStatusRollupField({ + groupId: 42, + modelKey: "gpt-4.1", + metric: "success", + }) + ) + ).toBe(1); + }); + + it("builds interval snapshots from 5m rollups by stable group id", () => { + const bucketStarts = buildPublicStatusRollupBucketStarts({ + now: "2026-04-21T11:00:00.000Z", + rangeHours: 1, + intervalMinutes: 15, + }); + const makeBucket = ( + bucketStart: string, + entries: Array<{ groupId: string | number; metric: "success" | "failure"; value: number }> + ): PublicStatusRollupBucket => ({ + bucketStart, + values: new Map( + entries.map((entry) => [ + buildPublicStatusRollupField({ + groupId: entry.groupId, + modelKey: "gpt-4.1", + metric: entry.metric, + }), + entry.value, + ]) + ), + }); + + const result = buildPublicStatusPayloadFromRollups({ + rangeHours: 1, + intervalMinutes: 15, + now: "2026-04-21T11:00:00.000Z", + groups, + rollupBuckets: [ + makeBucket(bucketStarts[0]!, [{ groupId: 42, metric: "success", value: 1 }]), + makeBucket(bucketStarts[1]!, [{ groupId: 42, metric: "failure", value: 1 }]), + makeBucket(bucketStarts[2]!, [{ groupId: "openai", metric: "success", value: 1 }]), + ], + }); + + const model = result.groups[0]?.models[0]; + expect(result.coveredFrom).toBe("2026-04-21T10:00:00.000Z"); + expect(result.coveredTo).toBe("2026-04-21T11:00:00.000Z"); + expect(model?.timeline).toHaveLength(4); + expect(model?.timeline[0]).toMatchObject({ + bucketStart: "2026-04-21T10:00:00.000Z", + sampleCount: 2, + availabilityPct: 50, + state: "operational", + }); + expect(model?.availabilityPct).toBe(50); + }); + + it("marks the latest partially failing bucket as degraded instead of fully operational", () => { + const bucketStarts = buildPublicStatusRollupBucketStarts({ + now: "2026-04-21T11:00:00.000Z", + rangeHours: 1, + intervalMinutes: 15, + }); + const makeBucket = ( + bucketStart: string, + entries: Array<{ metric: "success" | "failure"; value: number }> + ): PublicStatusRollupBucket => ({ + bucketStart, + values: new Map( + entries.map((entry) => [ + buildPublicStatusRollupField({ + groupId: 42, + modelKey: "gpt-4.1", + metric: entry.metric, + }), + entry.value, + ]) + ), + }); + + const result = buildPublicStatusPayloadFromRollups({ + rangeHours: 1, + intervalMinutes: 15, + now: "2026-04-21T11:00:00.000Z", + groups, + rollupBuckets: [ + makeBucket(bucketStarts[9]!, [ + { metric: "success", value: 1 }, + { metric: "failure", value: 2 }, + ]), + ], + }); + + const model = result.groups[0]?.models[0]; + expect(model?.latestState).toBe("degraded"); + expect(model?.timeline[3]).toMatchObject({ + availabilityPct: 33.33, + state: "operational", + sampleCount: 3, + }); + }); + + it("round-trips escaped rollup field parts", () => { + const field = buildPublicStatusRollupField({ + groupId: "group|42", + modelKey: "vendor/model|v1", + metric: "failure", + }); + + expect(parsePublicStatusRollupField(field)).toEqual({ + groupId: "group|42", + modelKey: "vendor/model|v1", + metric: "failure", + }); + }); +}); diff --git a/tests/unit/repository/message-public-status-rollup.test.ts b/tests/unit/repository/message-public-status-rollup.test.ts new file mode 100644 index 000000000..73dd8569f --- /dev/null +++ b/tests/unit/repository/message-public-status-rollup.test.ts @@ -0,0 +1,199 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockDbInsertValues = vi.hoisted(() => vi.fn()); +const mockDbInsertReturning = vi.hoisted(() => vi.fn()); +const mockDbUpdateSet = vi.hoisted(() => vi.fn()); +const mockDbUpdateWhere = vi.hoisted(() => vi.fn()); +const mockQueuePublicStatusRollupWrite = vi.hoisted(() => vi.fn()); +const mockGetConfiguredPublicStatusGroupsForRollup = vi.hoisted(() => vi.fn()); +const mockGetEnvConfig = vi.hoisted(() => vi.fn()); + +vi.mock("@/drizzle/schema", () => ({ + keys: {}, + messageRequest: { + id: "id", + providerId: "providerId", + userId: "userId", + key: "key", + model: "model", + originalModel: "originalModel", + durationMs: "durationMs", + costUsd: "costUsd", + costMultiplier: "costMultiplier", + sessionId: "sessionId", + requestSequence: "requestSequence", + userAgent: "userAgent", + clientIp: "clientIp", + endpoint: "endpoint", + messagesCount: "messagesCount", + cacheTtlApplied: "cacheTtlApplied", + cacheCreationInputTokens: "cacheCreationInputTokens", + cacheCreation5mInputTokens: "cacheCreation5mInputTokens", + cacheCreation1hInputTokens: "cacheCreation1hInputTokens", + cacheReadInputTokens: "cacheReadInputTokens", + specialSettings: "specialSettings", + createdAt: "createdAt", + updatedAt: "updatedAt", + deletedAt: "deletedAt", + }, + providers: {}, + usageLedger: {}, + users: {}, +})); + +vi.mock("@/drizzle/db", () => ({ + db: { + insert: vi.fn(() => ({ + values: mockDbInsertValues, + })), + update: vi.fn(() => ({ + set: mockDbUpdateSet, + })), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn(() => ({ + limit: vi.fn(async () => []), + })), + limit: vi.fn(async () => []), + })), + })), + })), + }, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: mockGetEnvConfig, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + warn: vi.fn(), + }, +})); + +vi.mock("@/lib/public-status/rollup-store", () => ({ + getConfiguredPublicStatusGroupsForRollup: mockGetConfiguredPublicStatusGroupsForRollup, + queuePublicStatusRollupWrite: mockQueuePublicStatusRollupWrite, +})); + +vi.mock("@/repository/message-write-buffer", () => ({ + enqueueMessageRequestUpdate: vi.fn(), +})); + +function flushMicrotasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("repository/message public status rollup hook", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + mockGetEnvConfig.mockReturnValue({ MESSAGE_REQUEST_WRITE_MODE: "sync" }); + mockDbInsertValues.mockReturnValue({ returning: mockDbInsertReturning }); + mockDbUpdateSet.mockReturnValue({ where: mockDbUpdateWhere }); + mockDbUpdateWhere.mockResolvedValue(undefined); + mockGetConfiguredPublicStatusGroupsForRollup.mockResolvedValue([ + { + sourceGroupId: 42, + sourceGroupName: "openai", + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: null, + sortOrder: 1, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], + }, + ]); + mockQueuePublicStatusRollupWrite.mockResolvedValue({ + written: true, + incrementCount: 1, + key: "public-status:v2:rollup:5m:2026-04-21T10%3A00%3A00.000Z", + }); + }); + + it("consumes the in-memory request seed before async rollup write to avoid double counting", async () => { + mockDbInsertReturning.mockResolvedValue([ + { + id: 101, + providerId: 1, + userId: 2, + key: "sk-1", + model: "gpt-4.1", + originalModel: "gpt-4.1", + durationMs: null, + costUsd: null, + costMultiplier: null, + sessionId: "session-1", + requestSequence: 1, + userAgent: null, + clientIp: null, + endpoint: "/v1/messages", + messagesCount: 1, + cacheTtlApplied: null, + cacheCreationInputTokens: null, + cacheCreation5mInputTokens: null, + cacheCreation1hInputTokens: null, + cacheReadInputTokens: null, + specialSettings: null, + createdAt: new Date("2026-04-21T10:02:00.000Z"), + updatedAt: new Date("2026-04-21T10:02:00.000Z"), + deletedAt: null, + }, + ]); + + const { createMessageRequest, updateMessageRequestDetails, updateMessageRequestDuration } = + await import("@/repository/message"); + + await createMessageRequest({ + provider_id: 1, + user_id: 2, + key: "sk-1", + model: "gpt-4.1", + original_model: "gpt-4.1", + }); + await updateMessageRequestDuration(101, 1200); + + const finalDetails = { + statusCode: 200, + ttfbMs: 200, + outputTokens: 50, + providerChain: [ + { + id: 1, + name: "provider-a", + groupTag: "openai", + reason: "request_success" as const, + statusCode: 200, + }, + ], + model: "gpt-4.1", + }; + + await Promise.all([ + updateMessageRequestDetails(101, finalDetails), + updateMessageRequestDetails(101, finalDetails), + ]); + await flushMicrotasks(); + + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledTimes(1); + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + createdAt: new Date("2026-04-21T10:02:00.000Z"), + durationMs: 1200, + originalModel: "gpt-4.1", + model: "gpt-4.1", + outputTokens: 50, + ttfbMs: 200, + }), + }) + ); + }); +}); From d75f47279b94362a4dbb56631e9f09215f8e93ec Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 20 May 2026 07:38:41 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E9=A1=B5=20rollup=20=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/model-vendor-icons.test.ts | 31 +++++++++- src/lib/model-vendor-icons.tsx | 7 ++- src/lib/public-status/redis-contract.ts | 1 + src/lib/public-status/rollup-store.ts | 2 + src/repository/message.ts | 59 ++++++++++++++++++- tests/unit/public-status/rollup-store.test.ts | 4 ++ .../message-public-status-rollup.test.ts | 49 ++++++++++++++- 7 files changed, 148 insertions(+), 5 deletions(-) diff --git a/src/lib/model-vendor-icons.test.ts b/src/lib/model-vendor-icons.test.ts index 327f32997..a7cce730c 100644 --- a/src/lib/model-vendor-icons.test.ts +++ b/src/lib/model-vendor-icons.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { getModelVendor, PRICE_FILTER_VENDORS } from "./model-vendor-icons"; describe("getModelVendor", () => { @@ -119,6 +119,35 @@ describe("getModelVendor", () => { expect(getModelVendor("o1")?.i18nKey).toBe("openai"); expect(getModelVendor("yi")?.i18nKey).toBe("yi"); }); + + it("warns in development when a vendor rule has no registered icon", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + vi.resetModules(); + vi.doMock("./model-vendor-rules", () => ({ + getModelVendor: () => ({ + prefix: "missing", + hasColor: false, + i18nKey: "missing-vendor", + }), + })); + vi.stubEnv("NODE_ENV", "development"); + + try { + const { getModelVendor: getMockedModelVendor } = await import("./model-vendor-icons"); + const result = getMockedModelVendor("missing-model"); + + expect(result?.i18nKey).toBe("missing-vendor"); + expect(warnSpy).toHaveBeenCalledWith( + '[model-vendor-icons] No icon registered for i18nKey "missing-vendor"' + ); + } finally { + vi.unstubAllEnvs(); + warnSpy.mockRestore(); + vi.doUnmock("./model-vendor-rules"); + vi.resetModules(); + } + }); }); describe("PRICE_FILTER_VENDORS", () => { diff --git a/src/lib/model-vendor-icons.tsx b/src/lib/model-vendor-icons.tsx index e3de1c654..66fa2389c 100644 --- a/src/lib/model-vendor-icons.tsx +++ b/src/lib/model-vendor-icons.tsx @@ -73,9 +73,14 @@ export function getModelVendor(modelId: string): ModelVendorEntry | null { return null; } + const icon = MODEL_VENDOR_ICON_BY_KEY[rule.i18nKey]; + if (!icon && process.env.NODE_ENV !== "production") { + console.warn(`[model-vendor-icons] No icon registered for i18nKey "${rule.i18nKey}"`); + } + return { ...rule, - icon: MODEL_VENDOR_ICON_BY_KEY[rule.i18nKey] ?? OpenAI, + icon: icon ?? OpenAI, }; } diff --git a/src/lib/public-status/redis-contract.ts b/src/lib/public-status/redis-contract.ts index a28bd03ba..b3e01e857 100644 --- a/src/lib/public-status/redis-contract.ts +++ b/src/lib/public-status/redis-contract.ts @@ -151,6 +151,7 @@ export function buildPublicStatusRollupKey(input: { }): string { const bucketMinutes = input.bucketMinutes ?? PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES; assertPositiveInteger(bucketMinutes, "bucketMinutes"); + // alignBucketStartUtc 会按 UTC 向下对齐到最近的 bucketMinutes 边界。 return [ resolvePrefix(input.prefix), "rollup", diff --git a/src/lib/public-status/rollup-store.ts b/src/lib/public-status/rollup-store.ts index 38610064f..324b4eda9 100644 --- a/src/lib/public-status/rollup-store.ts +++ b/src/lib/public-status/rollup-store.ts @@ -373,6 +373,7 @@ export async function writePublicStatusRollupEvent(input: { } if (typeof pipeline.set === "function") { pipeline.set(coverageStartKey, bucketStartIso, "NX"); + pipeline.expire(coverageStartKey, ROLLUP_TTL_SECONDS); } pipeline.expire(key, ROLLUP_TTL_SECONDS); await pipeline.exec(); @@ -385,6 +386,7 @@ export async function writePublicStatusRollupEvent(input: { await redis.set(coverageStartKey, bucketStartIso, "NX"); } await redis.expire?.(key, ROLLUP_TTL_SECONDS); + await redis.expire?.(coverageStartKey, ROLLUP_TTL_SECONDS); } return { written: true, incrementCount: increments.length, key }; diff --git a/src/repository/message.ts b/src/repository/message.ts index ec13ded4d..ffb4a3834 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -37,6 +37,8 @@ type PublicStatusFinalDetails = { const publicStatusRequestSeedCache = new Map(); const PUBLIC_STATUS_REQUEST_SEED_CACHE_MAX_SIZE = 10_000; +const publicStatusFinalizedRequestCache = new Map(); +const PUBLIC_STATUS_FINALIZED_REQUEST_CACHE_MAX_SIZE = 10_000; function rememberPublicStatusRequestSeed(id: number, seed: PublicStatusRequestSeed): void { publicStatusRequestSeedCache.set(id, seed); @@ -56,6 +58,23 @@ function consumePublicStatusRequestSeed(id: number): PublicStatusRequestSeed | n return seed; } +function claimPublicStatusFinalization(id: number): boolean { + if (publicStatusFinalizedRequestCache.has(id)) { + return false; + } + + publicStatusFinalizedRequestCache.set(id, true); + if (publicStatusFinalizedRequestCache.size <= PUBLIC_STATUS_FINALIZED_REQUEST_CACHE_MAX_SIZE) { + return true; + } + + const firstKey = publicStatusFinalizedRequestCache.keys().next().value as number | undefined; + if (firstKey !== undefined) { + publicStatusFinalizedRequestCache.delete(firstKey); + } + return true; +} + function updatePublicStatusRequestSeed(id: number, patch: Partial): void { const seed = publicStatusRequestSeedCache.get(id); if (!seed) { @@ -68,17 +87,53 @@ function isPublicStatusFinalDetails(details: PublicStatusFinalDetails): boolean return details.providerChain !== undefined && details.statusCode !== undefined; } +async function readPublicStatusRequestSeedFallback( + id: number +): Promise { + const [row] = await db + .select({ + createdAt: messageRequest.createdAt, + model: messageRequest.model, + originalModel: messageRequest.originalModel, + durationMs: messageRequest.durationMs, + }) + .from(messageRequest) + .where( + and(eq(messageRequest.id, id), isNull(messageRequest.deletedAt), EXCLUDE_WARMUP_CONDITION) + ) + .limit(1); + + if (!row?.createdAt) { + return null; + } + + return { + createdAt: row.createdAt, + model: row.model, + originalModel: row.originalModel, + durationMs: row.durationMs, + }; +} + function queuePublicStatusRollupForFinalDetails( id: number, details: PublicStatusFinalDetails ): void { - const seed = consumePublicStatusRequestSeed(id); - if (!seed || !isPublicStatusFinalDetails(details)) { + if (!isPublicStatusFinalDetails(details) || !claimPublicStatusFinalization(id)) { return; } void (async () => { try { + const seed = + consumePublicStatusRequestSeed(id) ?? (await readPublicStatusRequestSeedFallback(id)); + if (!seed) { + logger.warn("[MessageRequest] Missing public status rollup request seed", { + messageRequestId: id, + }); + return; + } + const groups = await getConfiguredPublicStatusGroupsForRollup(); if (groups.length === 0) { return; diff --git a/tests/unit/public-status/rollup-store.test.ts b/tests/unit/public-status/rollup-store.test.ts index 201351ee6..7dacd1b2a 100644 --- a/tests/unit/public-status/rollup-store.test.ts +++ b/tests/unit/public-status/rollup-store.test.ts @@ -153,6 +153,10 @@ describe("public-status rollup store", () => { "NX" ); expect(pipeline.expire).toHaveBeenCalledWith(result.key, 60 * 60 * 24 * 32); + expect(pipeline.expire).toHaveBeenCalledWith( + buildPublicStatusRollupCoverageStartKey(), + 60 * 60 * 24 * 32 + ); expect(pipeline.exec).toHaveBeenCalledTimes(1); expect(pipeline.hincrbyfloat.mock.calls.map((call) => call.join("|")).join("\n")).not.toContain( "private.example.com" diff --git a/tests/unit/repository/message-public-status-rollup.test.ts b/tests/unit/repository/message-public-status-rollup.test.ts index 73dd8569f..1c44b33db 100644 --- a/tests/unit/repository/message-public-status-rollup.test.ts +++ b/tests/unit/repository/message-public-status-rollup.test.ts @@ -4,6 +4,7 @@ const mockDbInsertValues = vi.hoisted(() => vi.fn()); const mockDbInsertReturning = vi.hoisted(() => vi.fn()); const mockDbUpdateSet = vi.hoisted(() => vi.fn()); const mockDbUpdateWhere = vi.hoisted(() => vi.fn()); +const mockDbSelectLimit = vi.hoisted(() => vi.fn()); const mockQueuePublicStatusRollupWrite = vi.hoisted(() => vi.fn()); const mockGetConfiguredPublicStatusGroupsForRollup = vi.hoisted(() => vi.fn()); const mockGetEnvConfig = vi.hoisted(() => vi.fn()); @@ -26,6 +27,7 @@ vi.mock("@/drizzle/schema", () => ({ clientIp: "clientIp", endpoint: "endpoint", messagesCount: "messagesCount", + blockedBy: "blockedBy", cacheTtlApplied: "cacheTtlApplied", cacheCreationInputTokens: "cacheCreationInputTokens", cacheCreation5mInputTokens: "cacheCreation5mInputTokens", @@ -55,7 +57,7 @@ vi.mock("@/drizzle/db", () => ({ orderBy: vi.fn(() => ({ limit: vi.fn(async () => []), })), - limit: vi.fn(async () => []), + limit: mockDbSelectLimit, })), })), })), @@ -93,6 +95,7 @@ describe("repository/message public status rollup hook", () => { mockDbInsertValues.mockReturnValue({ returning: mockDbInsertReturning }); mockDbUpdateSet.mockReturnValue({ where: mockDbUpdateWhere }); mockDbUpdateWhere.mockResolvedValue(undefined); + mockDbSelectLimit.mockResolvedValue([]); mockGetConfiguredPublicStatusGroupsForRollup.mockResolvedValue([ { sourceGroupId: 42, @@ -196,4 +199,48 @@ describe("repository/message public status rollup hook", () => { }) ); }); + + it("falls back to the persisted request seed when the in-memory seed is missing", async () => { + mockDbSelectLimit.mockResolvedValueOnce([ + { + createdAt: new Date("2026-04-21T10:03:00.000Z"), + model: "gpt-4.1", + originalModel: "gpt-4.1", + durationMs: 1500, + }, + ]); + + const { updateMessageRequestDetails } = await import("@/repository/message"); + + await updateMessageRequestDetails(202, { + statusCode: 200, + ttfbMs: 250, + outputTokens: 75, + providerChain: [ + { + id: 1, + name: "provider-a", + groupTag: "openai", + reason: "request_success", + statusCode: 200, + }, + ], + model: "gpt-4.1", + }); + await flushMicrotasks(); + + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledTimes(1); + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + createdAt: new Date("2026-04-21T10:03:00.000Z"), + durationMs: 1500, + originalModel: "gpt-4.1", + model: "gpt-4.1", + outputTokens: 75, + ttfbMs: 250, + }), + }) + ); + }); }); From 2b9e35c6fc297fad28ece6c824d48bacd1d29db3 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 20 May 2026 08:54:06 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E9=A1=B5=20rollup=20=E5=8F=AF=E9=9D=A0?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../status/_components/public-status-view.tsx | 12 +- src/lib/model-vendor-icons.test.ts | 4 +- src/lib/model-vendor-icons.tsx | 9 +- src/lib/model-vendor-rules.ts | 13 +- src/lib/public-status/aggregation.ts | 4 +- src/lib/public-status/rebuild-worker.ts | 2 + src/lib/public-status/rollup-store.ts | 137 ++++++-- src/repository/message.ts | 61 +++- tests/unit/public-status/aggregation.test.ts | 86 +++++ .../public-status/public-status-view.test.tsx | 69 ++++ tests/unit/public-status/rollup-store.test.ts | 213 +++++++++++- .../message-public-status-rollup.test.ts | 310 ++++++++++++++++-- 12 files changed, 840 insertions(+), 80 deletions(-) diff --git a/src/app/[locale]/status/_components/public-status-view.tsx b/src/app/[locale]/status/_components/public-status-view.tsx index 6135b96cd..36be96a78 100644 --- a/src/app/[locale]/status/_components/public-status-view.tsx +++ b/src/app/[locale]/status/_components/public-status-view.tsx @@ -156,12 +156,16 @@ function aggregateByFailed(states: DisplayState[]): DisplayState { } function deriveCurrentModelState(model: ViewModelSnapshot): DisplayState { - if (model.timelineReusedFromPrevious) { + if (model.timeline.length === 0 || model.timelineReusedFromPrevious) { return model.latestState; } return deriveLatestModelState(model); } +function shouldUseServerModelSummary(model: ViewModelSnapshot): boolean { + return model.timeline.length === 0 || Boolean(model.timelineReusedFromPrevious); +} + export function PublicStatusView({ initialPayload, initialStatus, @@ -262,12 +266,14 @@ export function PublicStatusView({ const derivedModels = group.models.map((model) => { const filled = fillDisplayTimeline(model.timeline); const chartCells = sliceTimelineForChart(filled, CHART_BUCKETS); + const viewModel = model as ViewModelSnapshot; + const useServerSummary = shouldUseServerModelSummary(viewModel); const uptime24h = - (model as ViewModelSnapshot).timelineReusedFromPrevious && model.availabilityPct !== null + useServerSummary && model.availabilityPct !== null ? model.availabilityPct : computeUptimePct(model.timeline); const ttfb24h = computeAvgTtfb(model.timeline); - const latest = deriveCurrentModelState(model as ViewModelSnapshot); + const latest = deriveCurrentModelState(viewModel); return { model, chartCells, uptime24h, ttfb24h, latest }; }); const issueCount = derivedModels.filter((d) => d.latest === "failed").length; diff --git a/src/lib/model-vendor-icons.test.ts b/src/lib/model-vendor-icons.test.ts index a7cce730c..053e8eeb4 100644 --- a/src/lib/model-vendor-icons.test.ts +++ b/src/lib/model-vendor-icons.test.ts @@ -124,7 +124,7 @@ describe("getModelVendor", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.resetModules(); - vi.doMock("./model-vendor-rules", () => ({ + vi.doMock("@/lib/model-vendor-rules", () => ({ getModelVendor: () => ({ prefix: "missing", hasColor: false, @@ -144,7 +144,7 @@ describe("getModelVendor", () => { } finally { vi.unstubAllEnvs(); warnSpy.mockRestore(); - vi.doUnmock("./model-vendor-rules"); + vi.doUnmock("@/lib/model-vendor-rules"); vi.resetModules(); } }); diff --git a/src/lib/model-vendor-icons.tsx b/src/lib/model-vendor-icons.tsx index 66fa2389c..e2f2dad7c 100644 --- a/src/lib/model-vendor-icons.tsx +++ b/src/lib/model-vendor-icons.tsx @@ -33,11 +33,14 @@ import { Yi, Zhipu, } from "@lobehub/icons"; -import { getModelVendor as getModelVendorRule, type ModelVendorRule } from "./model-vendor-rules"; +import { + getModelVendor as getModelVendorRule, + type ModelVendorRule, +} from "@/lib/model-vendor-rules"; -export interface ModelVendorEntry extends ModelVendorRule { +export type ModelVendorEntry = ModelVendorRule & { icon: React.ComponentType<{ className?: string }>; -} +}; const MODEL_VENDOR_ICON_BY_KEY: Record> = { anthropic: Claude.Color, diff --git a/src/lib/model-vendor-rules.ts b/src/lib/model-vendor-rules.ts index a7584d73f..2ad4ff0b4 100644 --- a/src/lib/model-vendor-rules.ts +++ b/src/lib/model-vendor-rules.ts @@ -1,13 +1,6 @@ -export interface ModelVendorRule { - prefix: string; - hasColor: boolean; - i18nKey: string; - litellmProvider?: string; -} - // Strictly sorted by prefix length descending to ensure longest-match-first. // Within same length, sorted alphabetically. -export const MODEL_VENDOR_RULES: ModelVendorRule[] = [ +export const MODEL_VENDOR_RULES = [ { prefix: "codestral", hasColor: true, @@ -110,7 +103,9 @@ export const MODEL_VENDOR_RULES: ModelVendorRule[] = [ { prefix: "o3", hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, { prefix: "o4", hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, { prefix: "yi", hasColor: true, i18nKey: "yi" }, -]; +] as const; + +export type ModelVendorRule = (typeof MODEL_VENDOR_RULES)[number]; export function getModelVendor(modelId: string): ModelVendorRule | null { if (!modelId) return null; diff --git a/src/lib/public-status/aggregation.ts b/src/lib/public-status/aggregation.ts index de36b4179..2ed6fdf22 100644 --- a/src/lib/public-status/aggregation.ts +++ b/src/lib/public-status/aggregation.ts @@ -323,10 +323,10 @@ export function buildPublicStatusPayloadFromRequests(input: { bucket.failureCount += 1; } - if (typeof request.ttfbMs === "number") { + if (outcome === "success" && typeof request.ttfbMs === "number") { bucket.ttfbValues.push(request.ttfbMs); } - if (typeof tps === "number") { + if (outcome === "success" && typeof tps === "number") { bucket.tpsValues.push(tps); } } diff --git a/src/lib/public-status/rebuild-worker.ts b/src/lib/public-status/rebuild-worker.ts index 7d800389a..95e36e5ee 100644 --- a/src/lib/public-status/rebuild-worker.ts +++ b/src/lib/public-status/rebuild-worker.ts @@ -12,6 +12,7 @@ import { buildPublicStatusTempKey, } from "./redis-contract"; import { + assertSupportedPublicStatusRollupInterval, buildPublicStatusPayloadFromRollups, buildPublicStatusRollupBucketStarts, getConfiguredPublicStatusGroupsFromSnapshot, @@ -307,6 +308,7 @@ export async function rebuildPublicStatusProjection(input: { | { status: "skipped"; reason: "distributed-lock-held"; sourceGeneration: string } | { status: "updated"; sourceGeneration: string } > { + assertSupportedPublicStatusRollupInterval(input.intervalMinutes); const redis = getReadyRedisClient(input.redis); if (!redis) { return { status: "disabled", reason: "redis-unavailable" }; diff --git a/src/lib/public-status/rollup-store.ts b/src/lib/public-status/rollup-store.ts index 324b4eda9..275e15edc 100644 --- a/src/lib/public-status/rollup-store.ts +++ b/src/lib/public-status/rollup-store.ts @@ -1,23 +1,25 @@ import { logger } from "@/lib/logger"; -import { getRedisClient } from "@/lib/redis"; -import { - classifyProviderChainItemOutcome, - resolveSuccessRateModelKey, -} from "@/lib/request-outcome"; -import { resolveProviderGroupsWithDefault } from "@/lib/utils/provider-group"; -import type { ProviderChainItem } from "@/types/message"; -import { computeTokensPerSecond, type PublicStatusConfiguredGroup } from "./aggregation-core"; +import type { PublicStatusConfiguredGroup } from "@/lib/public-status/aggregation-core"; +import { computeTokensPerSecond } from "@/lib/public-status/aggregation-core"; import { type InternalPublicStatusConfigSnapshot, readCurrentInternalPublicStatusConfigSnapshot, -} from "./config-snapshot"; -import type { PublicStatusPayload, PublicStatusTimelineBucket } from "./payload"; +} from "@/lib/public-status/config-snapshot"; +import { PUBLIC_STATUS_INTERVAL_OPTIONS } from "@/lib/public-status/constants"; +import type { PublicStatusPayload, PublicStatusTimelineBucket } from "@/lib/public-status/payload"; import { alignBucketStartUtc, buildPublicStatusRollupCoverageStartKey, buildPublicStatusRollupKey, PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES, -} from "./redis-contract"; +} from "@/lib/public-status/redis-contract"; +import { getRedisClient } from "@/lib/redis"; +import { + classifyProviderChainItemOutcome, + resolveSuccessRateModelKey, +} from "@/lib/request-outcome"; +import { resolveProviderGroupsWithDefault } from "@/lib/utils/provider-group"; +import type { ProviderChainItem } from "@/types/message"; const ROLLUP_FIELD_SEPARATOR = "|"; const ROLLUP_TTL_SECONDS = 60 * 60 * 24 * 32; @@ -61,6 +63,21 @@ export interface PublicStatusRollupAggregationResult { groups: PublicStatusPayload["groups"]; } +export type PublicStatusRollupWriteResult = + | { + written: true; + retryable: false; + incrementCount: number; + key: string; + } + | { + written: false; + retryable: boolean; + reason: "ignored" | "redis-unavailable" | "write-failed"; + incrementCount: number; + key: string | null; + }; + interface RedisRollupWriter { hincrbyfloat?(key: string, field: string, increment: number): Promise | unknown; expire?(key: string, seconds: number): Promise | unknown; @@ -68,7 +85,7 @@ interface RedisRollupWriter { hincrbyfloat(key: string, field: string, increment: number): unknown; set?(key: string, value: string, mode: "NX"): unknown; expire(key: string, seconds: number): unknown; - exec(): Promise | unknown; + exec(): Promise | null> | Array<[Error | null, unknown]> | null; }; set?(key: string, value: string, mode?: "NX"): Promise | unknown; status?: string; @@ -86,6 +103,7 @@ interface RedisRollupReader { let cachedConfiguredGroups: { configVersion: string; groups: PublicStatusConfiguredGroup[]; + retryable: boolean; expiresAt: number; } | null = null; @@ -101,6 +119,20 @@ function decodeRollupPart(value: string): string { } } +export function assertSupportedPublicStatusRollupInterval(intervalMinutes: number): void { + if ( + !PUBLIC_STATUS_INTERVAL_OPTIONS.includes( + intervalMinutes as (typeof PUBLIC_STATUS_INTERVAL_OPTIONS)[number] + ) + ) { + throw new Error( + `Unsupported public status rollup intervalMinutes: ${intervalMinutes}. Supported values: ${PUBLIC_STATUS_INTERVAL_OPTIONS.join( + ", " + )}` + ); + } +} + export function buildPublicStatusRollupField(input: { groupId: string | number; modelKey: string; @@ -212,12 +244,16 @@ export function getConfiguredPublicStatusGroupsFromSnapshot( ); } -export async function getConfiguredPublicStatusGroupsForRollup(): Promise< - PublicStatusConfiguredGroup[] -> { +export async function getConfiguredPublicStatusGroupsForRollupResolution(): Promise<{ + groups: PublicStatusConfiguredGroup[]; + retryable: boolean; +}> { const now = Date.now(); if (cachedConfiguredGroups && cachedConfiguredGroups.expiresAt > now) { - return cachedConfiguredGroups.groups; + return { + groups: cachedConfiguredGroups.groups, + retryable: cachedConfiguredGroups.retryable, + }; } const snapshot = await readCurrentInternalPublicStatusConfigSnapshot({ @@ -227,18 +263,26 @@ export async function getConfiguredPublicStatusGroupsForRollup(): Promise< cachedConfiguredGroups = { configVersion: "", groups: [], + retryable: true, expiresAt: now + EMPTY_CONFIGURED_GROUPS_CACHE_TTL_MS, }; - return []; + return { groups: [], retryable: true }; } const groups = getConfiguredPublicStatusGroupsFromSnapshot(snapshot); cachedConfiguredGroups = { configVersion: snapshot.configVersion, groups, + retryable: false, expiresAt: now + CONFIGURED_GROUPS_CACHE_TTL_MS, }; - return groups; + return { groups, retryable: false }; +} + +export async function getConfiguredPublicStatusGroupsForRollup(): Promise< + PublicStatusConfiguredGroup[] +> { + return (await getConfiguredPublicStatusGroupsForRollupResolution()).groups; } export function buildPublicStatusRollupIncrements(input: { @@ -323,13 +367,13 @@ export function buildPublicStatusRollupIncrements(input: { metric: outcome === "success" ? "success" : "failure", value: 1, }); - if (ttfbMs !== null) { + if (outcome === "success" && ttfbMs !== null) { increments.push( { groupId, modelKey, metric: "ttfb_sum", value: ttfbMs }, { groupId, modelKey, metric: "ttfb_count", value: 1 } ); } - if (tps !== null) { + if (outcome === "success" && tps !== null) { increments.push( { groupId, modelKey, metric: "tps_sum", value: tps }, { groupId, modelKey, metric: "tps_count", value: 1 } @@ -344,10 +388,16 @@ export async function writePublicStatusRollupEvent(input: { event: PublicStatusRollupEvent; groups: PublicStatusConfiguredGroup[]; redis?: RedisRollupWriter | null; -}): Promise<{ written: boolean; incrementCount: number; key: string | null }> { +}): Promise { const increments = buildPublicStatusRollupIncrements(input); if (increments.length === 0) { - return { written: false, incrementCount: 0, key: null }; + return { + written: false, + retryable: false, + reason: "ignored", + incrementCount: 0, + key: null, + }; } const createdAtIso = @@ -363,20 +413,44 @@ export async function writePublicStatusRollupEvent(input: { ("status" in redis && redis.status && redis.status !== "ready") || typeof redis.hincrbyfloat !== "function" ) { - return { written: false, incrementCount: increments.length, key }; + return { + written: false, + retryable: true, + reason: "redis-unavailable", + incrementCount: increments.length, + key, + }; } if (typeof redis.pipeline === "function") { const pipeline = redis.pipeline(); + const pipelineOperationLabels: string[] = []; for (const increment of increments) { - pipeline.hincrbyfloat(key, buildPublicStatusRollupField(increment), increment.value); + const field = buildPublicStatusRollupField(increment); + pipeline.hincrbyfloat(key, field, increment.value); + pipelineOperationLabels.push(field); } if (typeof pipeline.set === "function") { pipeline.set(coverageStartKey, bucketStartIso, "NX"); + pipelineOperationLabels.push(coverageStartKey); pipeline.expire(coverageStartKey, ROLLUP_TTL_SECONDS); + pipelineOperationLabels.push(`${coverageStartKey}:expire`); } pipeline.expire(key, ROLLUP_TTL_SECONDS); - await pipeline.exec(); + pipelineOperationLabels.push(`${key}:expire`); + const results = await pipeline.exec(); + if (!results) { + throw new Error(`Public status rollup pipeline failed for ${key}: empty exec result`); + } + const failures = results?.flatMap(([error], index) => (error ? [{ error, index }] : [])) ?? []; + if (failures.length > 0) { + const firstFailure = failures[0]!; + const failedField = + pipelineOperationLabels[firstFailure.index] ?? `${key}:pipeline:${firstFailure.index}`; + throw new Error( + `Public status rollup pipeline failed for ${failedField}: ${firstFailure.error.message}` + ); + } } else { for (const increment of increments) { const field = buildPublicStatusRollupField(increment); @@ -389,18 +463,24 @@ export async function writePublicStatusRollupEvent(input: { await redis.expire?.(coverageStartKey, ROLLUP_TTL_SECONDS); } - return { written: true, incrementCount: increments.length, key }; + return { written: true, retryable: false, incrementCount: increments.length, key }; } export function queuePublicStatusRollupWrite(input: { event: PublicStatusRollupEvent; groups: PublicStatusConfiguredGroup[]; -}): Promise<{ written: boolean; incrementCount: number; key: string | null }> { +}): Promise { return writePublicStatusRollupEvent(input).catch((error) => { logger.warn("[PublicStatus] Failed to write rollup event", { error: error instanceof Error ? error.message : String(error), }); - return { written: false, incrementCount: 0, key: null }; + return { + written: false, + retryable: true, + reason: "write-failed", + incrementCount: 0, + key: null, + }; }); } @@ -524,6 +604,7 @@ function buildBucketStarts(input: { rangeHours: number; intervalMinutes: number; }): { coveredFrom: string; coveredTo: string; bucketStarts: string[] } { + assertSupportedPublicStatusRollupInterval(input.intervalMinutes); const now = input.now instanceof Date ? input.now : new Date(input.now); const baseBucketMs = PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES * 60 * 1000; const bucketCount = Math.ceil((input.rangeHours * 60) / PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES); diff --git a/src/repository/message.ts b/src/repository/message.ts index ffb4a3834..e4e925074 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -7,7 +7,7 @@ import { getEnvConfig } from "@/lib/config/env.schema"; import { isLedgerOnlyMode } from "@/lib/ledger-fallback"; import { logger } from "@/lib/logger"; import { - getConfiguredPublicStatusGroupsForRollup, + getConfiguredPublicStatusGroupsForRollupResolution, queuePublicStatusRollupWrite, } from "@/lib/public-status/rollup-store"; import { formatCostForStorage } from "@/lib/utils/currency"; @@ -39,6 +39,7 @@ const publicStatusRequestSeedCache = new Map(); const PUBLIC_STATUS_REQUEST_SEED_CACHE_MAX_SIZE = 10_000; const publicStatusFinalizedRequestCache = new Map(); const PUBLIC_STATUS_FINALIZED_REQUEST_CACHE_MAX_SIZE = 10_000; +const publicStatusInFlightRequestCache = new Set(); function rememberPublicStatusRequestSeed(id: number, seed: PublicStatusRequestSeed): void { publicStatusRequestSeedCache.set(id, seed); @@ -52,10 +53,12 @@ function rememberPublicStatusRequestSeed(id: number, seed: PublicStatusRequestSe } } -function consumePublicStatusRequestSeed(id: number): PublicStatusRequestSeed | null { - const seed = publicStatusRequestSeedCache.get(id) ?? null; +function peekPublicStatusRequestSeed(id: number): PublicStatusRequestSeed | null { + return publicStatusRequestSeedCache.get(id) ?? null; +} + +function consumePublicStatusRequestSeed(id: number): void { publicStatusRequestSeedCache.delete(id); - return seed; } function claimPublicStatusFinalization(id: number): boolean { @@ -75,6 +78,22 @@ function claimPublicStatusFinalization(id: number): boolean { return true; } +function unclaimPublicStatusFinalization(id: number): void { + publicStatusFinalizedRequestCache.delete(id); +} + +function markPublicStatusRequestInFlight(id: number): boolean { + if (publicStatusInFlightRequestCache.has(id)) { + return false; + } + publicStatusInFlightRequestCache.add(id); + return true; +} + +function clearPublicStatusRequestInFlight(id: number): void { + publicStatusInFlightRequestCache.delete(id); +} + function updatePublicStatusRequestSeed(id: number, patch: Partial): void { const seed = publicStatusRequestSeedCache.get(id); if (!seed) { @@ -119,28 +138,38 @@ function queuePublicStatusRollupForFinalDetails( id: number, details: PublicStatusFinalDetails ): void { - if (!isPublicStatusFinalDetails(details) || !claimPublicStatusFinalization(id)) { + if (!isPublicStatusFinalDetails(details) || !markPublicStatusRequestInFlight(id)) { + return; + } + if (!claimPublicStatusFinalization(id)) { + clearPublicStatusRequestInFlight(id); return; } void (async () => { try { const seed = - consumePublicStatusRequestSeed(id) ?? (await readPublicStatusRequestSeedFallback(id)); + peekPublicStatusRequestSeed(id) ?? (await readPublicStatusRequestSeedFallback(id)); if (!seed) { logger.warn("[MessageRequest] Missing public status rollup request seed", { messageRequestId: id, }); + unclaimPublicStatusFinalization(id); return; } - const groups = await getConfiguredPublicStatusGroupsForRollup(); - if (groups.length === 0) { + const groupResolution = await getConfiguredPublicStatusGroupsForRollupResolution(); + if (groupResolution.groups.length === 0) { + if (groupResolution.retryable) { + unclaimPublicStatusFinalization(id); + } else { + consumePublicStatusRequestSeed(id); + } return; } - await queuePublicStatusRollupWrite({ - groups, + const result = await queuePublicStatusRollupWrite({ + groups: groupResolution.groups, event: { createdAt: seed.createdAt, model: details.model ?? seed.model, @@ -151,11 +180,23 @@ function queuePublicStatusRollupForFinalDetails( providerChain: details.providerChain, }, }); + if (!result.written) { + if (result.retryable) { + unclaimPublicStatusFinalization(id); + } else { + consumePublicStatusRequestSeed(id); + } + return; + } + consumePublicStatusRequestSeed(id); } catch (error) { + unclaimPublicStatusFinalization(id); logger.warn("[MessageRequest] Failed to queue public status rollup", { error: error instanceof Error ? error.message : String(error), messageRequestId: id, }); + } finally { + clearPublicStatusRequestInFlight(id); } })(); } diff --git a/tests/unit/public-status/aggregation.test.ts b/tests/unit/public-status/aggregation.test.ts index aa0c3eb0a..ff10ffa4e 100644 --- a/tests/unit/public-status/aggregation.test.ts +++ b/tests/unit/public-status/aggregation.test.ts @@ -183,6 +183,92 @@ describe("public-status aggregation", () => { expect(model?.timeline.every((bucket) => bucket.sampleCount === 0)).toBe(true); }); + it("attributes latency and throughput only to the successful fallback group", () => { + const result = buildPublicStatusPayloadFromRequests({ + rangeHours: 1, + intervalMinutes: 15, + now: "2026-04-21T11:00:00.000Z", + groups: [ + { + sourceGroupName: "openai", + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: null, + sortOrder: 1, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], + }, + { + sourceGroupName: "backup", + publicGroupSlug: "backup", + displayName: "Backup", + explanatoryCopy: null, + sortOrder: 2, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], + }, + ], + requests: [ + { + id: 40, + createdAt: "2026-04-21T10:10:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1200, + ttfbMs: 200, + outputTokens: 50, + providerChain: [ + { + id: 401, + name: "failed-provider", + groupTag: "openai", + reason: "retry_failed", + statusCode: 500, + }, + { + id: 402, + name: "successful-provider", + groupTag: "backup", + reason: "request_success", + statusCode: 200, + }, + ], + }, + ], + }); + + const failedModel = result.groups[0]?.models[0]; + const successfulModel = result.groups[1]?.models[0]; + + expect(failedModel?.availabilityPct).toBe(0); + expect(failedModel?.latestTtfbMs).toBeNull(); + expect(failedModel?.latestTps).toBeNull(); + expect(failedModel?.timeline.find((bucket) => bucket.sampleCount > 0)).toMatchObject({ + sampleCount: 1, + ttfbMs: null, + tps: null, + }); + expect(successfulModel?.availabilityPct).toBe(100); + expect(successfulModel?.latestTtfbMs).toBe(200); + expect(successfulModel?.latestTps).toBe(50); + expect(successfulModel?.timeline.find((bucket) => bucket.sampleCount > 0)).toMatchObject({ + sampleCount: 1, + ttfbMs: 200, + tps: 50, + }); + }); + it("uses originalModel before redirected model for grouping", () => { const result = buildPublicStatusPayloadFromRequests({ rangeHours: 1, diff --git a/tests/unit/public-status/public-status-view.test.tsx b/tests/unit/public-status/public-status-view.test.tsx index c25dd1291..e26776ec8 100644 --- a/tests/unit/public-status/public-status-view.test.tsx +++ b/tests/unit/public-status/public-status-view.test.tsx @@ -606,4 +606,73 @@ describe("public-status view", () => { vi.useRealTimers(); unmount(); }); + + it("uses server summary metrics when polling returns a new model without timeline", async () => { + vi.useFakeTimers(); + + const fetchMock = vi.fn(async () => ({ + status: 200, + json: async () => + buildRouteResponse({ + groups: [ + { + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: "Primary models", + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + latestState: "operational", + availabilityPct: 100, + latestTtfbMs: 420, + latestTps: null, + timeline: [], + }, + { + publicModelKey: "gpt-4.2", + label: "GPT-4.2", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + latestState: "failed", + availabilityPct: 0, + latestTtfbMs: null, + latestTps: null, + timeline: [], + }, + ], + }, + ], + }), + })); + global.fetch = fetchMock as typeof global.fetch; + + const { container, unmount } = render( + + ); + + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + }); + + const text = container.textContent || ""; + expect(text).toContain("GPT-4.2"); + expect(text).toContain("Failed"); + expect(text).toContain("0.00%"); + + vi.useRealTimers(); + unmount(); + }); }); diff --git a/tests/unit/public-status/rollup-store.test.ts b/tests/unit/public-status/rollup-store.test.ts index 7dacd1b2a..44c9f7a05 100644 --- a/tests/unit/public-status/rollup-store.test.ts +++ b/tests/unit/public-status/rollup-store.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { buildPublicStatusPayloadFromRollups, - readPublicStatusRollupBuckets, buildPublicStatusRollupField, buildPublicStatusRollupIncrements, buildPublicStatusRollupBucketStarts, parsePublicStatusRollupField, + readPublicStatusRollupBuckets, writePublicStatusRollupEvent, type PublicStatusRollupBucket, } from "@/lib/public-status/rollup-store"; @@ -94,6 +94,102 @@ describe("public-status rollup store", () => { expect(increments).toEqual([]); }); + it("marks unmatched events as ignored instead of retryable write failures", async () => { + const redis = { + status: "ready", + hincrbyfloat: vi.fn(), + pipeline: vi.fn(), + }; + + const result = await writePublicStatusRollupEvent({ + redis, + groups, + event: { + createdAt: "2026-04-21T10:02:00.000Z", + originalModel: "not-public-model", + providerChain: [ + { + id: 7, + name: "internal-provider", + groupTag: "openai", + reason: "request_success", + statusCode: 200, + }, + ], + }, + }); + + expect(result).toEqual({ + written: false, + retryable: false, + reason: "ignored", + incrementCount: 0, + key: null, + }); + expect(redis.pipeline).not.toHaveBeenCalled(); + }); + + it("records latency and throughput only for the group that actually succeeds", () => { + const fallbackGroups: PublicStatusConfiguredGroup[] = [ + groups[0]!, + { + sourceGroupId: 43, + sourceGroupName: "backup", + publicGroupSlug: "backup-public", + displayName: "Backup", + explanatoryCopy: null, + sortOrder: 2, + models: groups[0]!.models, + }, + ]; + + const increments = buildPublicStatusRollupIncrements({ + groups: fallbackGroups, + event: { + createdAt: "2026-04-21T10:02:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1200, + ttfbMs: 200, + outputTokens: 50, + providerChain: [ + { + id: 7, + name: "failed-provider", + groupTag: "openai", + reason: "retry_failed", + statusCode: 500, + }, + { + id: 8, + name: "successful-provider", + groupTag: "backup", + reason: "request_success", + statusCode: 200, + }, + ], + }, + }); + + expect(increments).toEqual( + expect.arrayContaining([ + { groupId: "42", modelKey: "gpt-4.1", metric: "failure", value: 1 }, + { groupId: "43", modelKey: "gpt-4.1", metric: "success", value: 1 }, + { groupId: "43", modelKey: "gpt-4.1", metric: "ttfb_sum", value: 200 }, + { groupId: "43", modelKey: "gpt-4.1", metric: "ttfb_count", value: 1 }, + { groupId: "43", modelKey: "gpt-4.1", metric: "tps_sum", value: 50 }, + { groupId: "43", modelKey: "gpt-4.1", metric: "tps_count", value: 1 }, + ]) + ); + expect(increments).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ groupId: "42", metric: "ttfb_sum" }), + expect.objectContaining({ groupId: "42", metric: "ttfb_count" }), + expect.objectContaining({ groupId: "42", metric: "tps_sum" }), + expect.objectContaining({ groupId: "42", metric: "tps_count" }), + ]) + ); + }); + it("writes one 5m bucket hash instead of endpoint multiplied keys", async () => { const fields = new Map(); const pipeline = { @@ -102,7 +198,16 @@ describe("public-status rollup store", () => { }), set: vi.fn(), expire: vi.fn(), - exec: vi.fn(async () => null), + exec: vi.fn(async () => [ + [null, "1"], + [null, "1"], + [null, "1"], + [null, "1"], + [null, "1"], + [null, "OK"], + [null, 1], + [null, 1], + ]), }; const redis = { status: "ready", @@ -137,6 +242,7 @@ describe("public-status rollup store", () => { expect(result).toMatchObject({ written: true, + retryable: false, key: buildPublicStatusRollupKey({ bucketStartIso: "2026-04-21T10:02:00.000Z" }), }); expect(redis.hincrbyfloat).not.toHaveBeenCalled(); @@ -172,6 +278,90 @@ describe("public-status rollup store", () => { ).toBe(1); }); + it("rejects the rollup write when a Redis pipeline command fails", async () => { + const pipelineError = new Error("ERR hash command failed"); + const pipeline = { + hincrbyfloat: vi.fn(), + set: vi.fn(), + expire: vi.fn(), + exec: vi.fn(async () => [ + [pipelineError, null], + [null, "1"], + [null, "1"], + [null, "1"], + [null, "1"], + [null, "OK"], + [null, 1], + [null, 1], + ]), + }; + const redis = { + status: "ready", + hincrbyfloat: vi.fn(), + pipeline: vi.fn(() => pipeline), + }; + + await expect( + writePublicStatusRollupEvent({ + redis, + groups, + event: { + createdAt: "2026-04-21T10:02:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1200, + ttfbMs: 200, + outputTokens: 50, + providerChain: [ + { + id: 7, + name: "internal-provider", + groupTag: "openai", + reason: "request_success", + statusCode: 200, + }, + ], + }, + }) + ).rejects.toThrow("Public status rollup pipeline failed"); + }); + + it("rejects the rollup write when Redis pipeline returns no result", async () => { + const pipeline = { + hincrbyfloat: vi.fn(), + set: vi.fn(), + expire: vi.fn(), + exec: vi.fn(async () => null), + }; + const redis = { + status: "ready", + hincrbyfloat: vi.fn(), + pipeline: vi.fn(() => pipeline), + }; + + await expect( + writePublicStatusRollupEvent({ + redis, + groups, + event: { + createdAt: "2026-04-21T10:02:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1200, + ttfbMs: 200, + outputTokens: 50, + providerChain: [ + { + id: 7, + name: "internal-provider", + groupTag: "openai", + reason: "request_success", + statusCode: 200, + }, + ], + }, + }) + ).rejects.toThrow("empty exec result"); + }); + it("reads rollup buckets through batched Redis pipelines when available", async () => { const bucketStarts = buildPublicStatusRollupBucketStarts({ now: "2026-04-21T11:00:00.000Z", @@ -267,6 +457,25 @@ describe("public-status rollup store", () => { expect(model?.availabilityPct).toBe(50); }); + it("rejects unsupported display intervals instead of silently rounding boundaries", () => { + expect(() => + buildPublicStatusRollupBucketStarts({ + now: "2026-04-21T11:00:00.000Z", + rangeHours: 1, + intervalMinutes: 16, + }) + ).toThrow("Unsupported public status rollup intervalMinutes: 16"); + expect(() => + buildPublicStatusPayloadFromRollups({ + rangeHours: 1, + intervalMinutes: 16, + now: "2026-04-21T11:00:00.000Z", + groups, + rollupBuckets: [], + }) + ).toThrow("Unsupported public status rollup intervalMinutes: 16"); + }); + it("marks the latest partially failing bucket as degraded instead of fully operational", () => { const bucketStarts = buildPublicStatusRollupBucketStarts({ now: "2026-04-21T11:00:00.000Z", diff --git a/tests/unit/repository/message-public-status-rollup.test.ts b/tests/unit/repository/message-public-status-rollup.test.ts index 1c44b33db..2046ec89e 100644 --- a/tests/unit/repository/message-public-status-rollup.test.ts +++ b/tests/unit/repository/message-public-status-rollup.test.ts @@ -6,7 +6,7 @@ const mockDbUpdateSet = vi.hoisted(() => vi.fn()); const mockDbUpdateWhere = vi.hoisted(() => vi.fn()); const mockDbSelectLimit = vi.hoisted(() => vi.fn()); const mockQueuePublicStatusRollupWrite = vi.hoisted(() => vi.fn()); -const mockGetConfiguredPublicStatusGroupsForRollup = vi.hoisted(() => vi.fn()); +const mockGetConfiguredPublicStatusGroupsForRollupResolution = vi.hoisted(() => vi.fn()); const mockGetEnvConfig = vi.hoisted(() => vi.fn()); vi.mock("@/drizzle/schema", () => ({ @@ -75,7 +75,8 @@ vi.mock("@/lib/logger", () => ({ })); vi.mock("@/lib/public-status/rollup-store", () => ({ - getConfiguredPublicStatusGroupsForRollup: mockGetConfiguredPublicStatusGroupsForRollup, + getConfiguredPublicStatusGroupsForRollupResolution: + mockGetConfiguredPublicStatusGroupsForRollupResolution, queuePublicStatusRollupWrite: mockQueuePublicStatusRollupWrite, })); @@ -96,32 +97,36 @@ describe("repository/message public status rollup hook", () => { mockDbUpdateSet.mockReturnValue({ where: mockDbUpdateWhere }); mockDbUpdateWhere.mockResolvedValue(undefined); mockDbSelectLimit.mockResolvedValue([]); - mockGetConfiguredPublicStatusGroupsForRollup.mockResolvedValue([ - { - sourceGroupId: 42, - sourceGroupName: "openai", - publicGroupSlug: "openai", - displayName: "OpenAI", - explanatoryCopy: null, - sortOrder: 1, - models: [ - { - publicModelKey: "gpt-4.1", - label: "GPT-4.1", - vendorIconKey: "openai", - requestTypeBadge: "openaiCompatible", - }, - ], - }, - ]); + mockGetConfiguredPublicStatusGroupsForRollupResolution.mockResolvedValue({ + retryable: false, + groups: [ + { + sourceGroupId: 42, + sourceGroupName: "openai", + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: null, + sortOrder: 1, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], + }, + ], + }); mockQueuePublicStatusRollupWrite.mockResolvedValue({ written: true, + retryable: false, incrementCount: 1, key: "public-status:v2:rollup:5m:2026-04-21T10%3A00%3A00.000Z", }); }); - it("consumes the in-memory request seed before async rollup write to avoid double counting", async () => { + it("queues one rollup for duplicate terminal updates without double counting", async () => { mockDbInsertReturning.mockResolvedValue([ { id: 101, @@ -243,4 +248,267 @@ describe("repository/message public status rollup hook", () => { }) ); }); + + it("keeps the seed retryable when the first rollup write fails", async () => { + mockDbInsertReturning.mockResolvedValue([ + { + id: 303, + providerId: 1, + userId: 2, + key: "sk-1", + model: "gpt-4.1", + originalModel: "gpt-4.1", + durationMs: null, + costUsd: null, + costMultiplier: null, + sessionId: "session-1", + requestSequence: 1, + userAgent: null, + clientIp: null, + endpoint: "/v1/messages", + messagesCount: 1, + cacheTtlApplied: null, + cacheCreationInputTokens: null, + cacheCreation5mInputTokens: null, + cacheCreation1hInputTokens: null, + cacheReadInputTokens: null, + specialSettings: null, + createdAt: new Date("2026-04-21T10:04:00.000Z"), + updatedAt: new Date("2026-04-21T10:04:00.000Z"), + deletedAt: null, + }, + ]); + mockQueuePublicStatusRollupWrite + .mockResolvedValueOnce({ + written: false, + retryable: true, + reason: "redis-unavailable", + incrementCount: 1, + key: "rollup-key", + }) + .mockResolvedValueOnce({ + written: true, + retryable: false, + incrementCount: 1, + key: "rollup-key", + }); + + const { createMessageRequest, updateMessageRequestDetails, updateMessageRequestDuration } = + await import("@/repository/message"); + + await createMessageRequest({ + provider_id: 1, + user_id: 2, + key: "sk-1", + model: "gpt-4.1", + original_model: "gpt-4.1", + }); + await updateMessageRequestDuration(303, 1800); + + const finalDetails = { + statusCode: 200, + ttfbMs: 300, + outputTokens: 90, + providerChain: [ + { + id: 1, + name: "provider-a", + groupTag: "openai", + reason: "request_success" as const, + statusCode: 200, + }, + ], + model: "gpt-4.1", + }; + + await updateMessageRequestDetails(303, finalDetails); + await flushMicrotasks(); + await updateMessageRequestDetails(303, finalDetails); + await flushMicrotasks(); + + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledTimes(2); + expect(mockQueuePublicStatusRollupWrite).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + event: expect.objectContaining({ + createdAt: new Date("2026-04-21T10:04:00.000Z"), + durationMs: 1800, + outputTokens: 90, + ttfbMs: 300, + }), + }) + ); + }); + + it("consumes the seed when the request is not part of the public status projection", async () => { + mockDbInsertReturning.mockResolvedValue([ + { + id: 404, + providerId: 1, + userId: 2, + key: "sk-1", + model: "private-model", + originalModel: "private-model", + durationMs: null, + costUsd: null, + costMultiplier: null, + sessionId: "session-1", + requestSequence: 1, + userAgent: null, + clientIp: null, + endpoint: "/v1/messages", + messagesCount: 1, + cacheTtlApplied: null, + cacheCreationInputTokens: null, + cacheCreation5mInputTokens: null, + cacheCreation1hInputTokens: null, + cacheReadInputTokens: null, + specialSettings: null, + createdAt: new Date("2026-04-21T10:05:00.000Z"), + updatedAt: new Date("2026-04-21T10:05:00.000Z"), + deletedAt: null, + }, + ]); + mockQueuePublicStatusRollupWrite.mockResolvedValue({ + written: false, + retryable: false, + reason: "ignored", + incrementCount: 0, + key: null, + }); + + const { createMessageRequest, updateMessageRequestDetails } = await import( + "@/repository/message" + ); + + await createMessageRequest({ + provider_id: 1, + user_id: 2, + key: "sk-1", + model: "private-model", + original_model: "private-model", + }); + + const finalDetails = { + statusCode: 200, + ttfbMs: 300, + outputTokens: 90, + providerChain: [ + { + id: 1, + name: "provider-a", + groupTag: "openai", + reason: "request_success" as const, + statusCode: 200, + }, + ], + model: "private-model", + }; + + await updateMessageRequestDetails(404, finalDetails); + await flushMicrotasks(); + await updateMessageRequestDetails(404, finalDetails); + await flushMicrotasks(); + + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledTimes(1); + }); + + it("keeps the seed retryable when public status config is temporarily unavailable", async () => { + mockDbInsertReturning.mockResolvedValue([ + { + id: 505, + providerId: 1, + userId: 2, + key: "sk-1", + model: "gpt-4.1", + originalModel: "gpt-4.1", + durationMs: null, + costUsd: null, + costMultiplier: null, + sessionId: "session-1", + requestSequence: 1, + userAgent: null, + clientIp: null, + endpoint: "/v1/messages", + messagesCount: 1, + cacheTtlApplied: null, + cacheCreationInputTokens: null, + cacheCreation5mInputTokens: null, + cacheCreation1hInputTokens: null, + cacheReadInputTokens: null, + specialSettings: null, + createdAt: new Date("2026-04-21T10:06:00.000Z"), + updatedAt: new Date("2026-04-21T10:06:00.000Z"), + deletedAt: null, + }, + ]); + mockGetConfiguredPublicStatusGroupsForRollupResolution + .mockResolvedValueOnce({ groups: [], retryable: true }) + .mockResolvedValueOnce({ + retryable: false, + groups: [ + { + sourceGroupId: 42, + sourceGroupName: "openai", + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: null, + sortOrder: 1, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], + }, + ], + }); + + const { createMessageRequest, updateMessageRequestDetails, updateMessageRequestDuration } = + await import("@/repository/message"); + + await createMessageRequest({ + provider_id: 1, + user_id: 2, + key: "sk-1", + model: "gpt-4.1", + original_model: "gpt-4.1", + }); + await updateMessageRequestDuration(505, 1900); + + const finalDetails = { + statusCode: 200, + ttfbMs: 320, + outputTokens: 95, + providerChain: [ + { + id: 1, + name: "provider-a", + groupTag: "openai", + reason: "request_success" as const, + statusCode: 200, + }, + ], + model: "gpt-4.1", + }; + + await updateMessageRequestDetails(505, finalDetails); + await flushMicrotasks(); + await updateMessageRequestDetails(505, finalDetails); + await flushMicrotasks(); + + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledTimes(1); + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + createdAt: new Date("2026-04-21T10:06:00.000Z"), + durationMs: 1900, + outputTokens: 95, + ttfbMs: 320, + }), + }) + ); + }); }); From 4da03aa142d3da825d5f07b80bdbc3bb222b8465 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 20 May 2026 09:03:25 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E9=A1=B5=E8=BD=AE=E8=AF=A2=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../public-status/public-status-view.test.tsx | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/tests/unit/public-status/public-status-view.test.tsx b/tests/unit/public-status/public-status-view.test.tsx index e26776ec8..306d9b72a 100644 --- a/tests/unit/public-status/public-status-view.test.tsx +++ b/tests/unit/public-status/public-status-view.test.tsx @@ -592,19 +592,21 @@ describe("public-status view", () => { "1" ); - await act(async () => { - vi.advanceTimersByTime(30_000); - await Promise.resolve(); - }); - - expect(container.textContent).toContain("Failed"); - expect(container.textContent).toContain("0.00%"); - expect(container.querySelector('[data-testid="public-status-timeline"]')?.textContent).toBe( - "1" - ); - - vi.useRealTimers(); - unmount(); + try { + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + }); + + expect(container.textContent).toContain("Failed"); + expect(container.textContent).toContain("0.00%"); + expect(container.querySelector('[data-testid="public-status-timeline"]')?.textContent).toBe( + "1" + ); + } finally { + vi.useRealTimers(); + unmount(); + } }); it("uses server summary metrics when polling returns a new model without timeline", async () => { @@ -662,17 +664,19 @@ describe("public-status view", () => { /> ); - await act(async () => { - vi.advanceTimersByTime(30_000); - await Promise.resolve(); - }); - - const text = container.textContent || ""; - expect(text).toContain("GPT-4.2"); - expect(text).toContain("Failed"); - expect(text).toContain("0.00%"); - - vi.useRealTimers(); - unmount(); + try { + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + }); + + const text = container.textContent || ""; + expect(text).toContain("GPT-4.2"); + expect(text).toContain("Failed"); + expect(text).toContain("0.00%"); + } finally { + vi.useRealTimers(); + unmount(); + } }); }); From add2a1cb5c72d4ac492790fbad44a988fc584070 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 20 May 2026 09:28:39 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E9=A1=B5=E8=BD=AE=E8=AF=A2=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../public-status/public-status-view.test.tsx | 174 +++++++++--------- 1 file changed, 92 insertions(+), 82 deletions(-) diff --git a/tests/unit/public-status/public-status-view.test.tsx b/tests/unit/public-status/public-status-view.test.tsx index 306d9b72a..72d8bd3e8 100644 --- a/tests/unit/public-status/public-status-view.test.tsx +++ b/tests/unit/public-status/public-status-view.test.tsx @@ -287,41 +287,46 @@ describe("public-status view", () => { })); global.fetch = fetchMock as typeof global.fetch; - const { container, unmount } = render( - - ); + let unmount: (() => void) | undefined; - await act(async () => { - await Promise.resolve(); - }); + try { + const { container, unmount: cleanup } = render( + + ); + unmount = cleanup; - expect(fetchMock).toHaveBeenCalledWith( - "/api/public-status?interval=5&rangeHours=24&include=meta%2Cdefaults%2Cgroups", - { cache: "no-store" } - ); + await act(async () => { + await Promise.resolve(); + }); - await act(async () => { - vi.advanceTimersByTime(30_000); - await Promise.resolve(); - }); + expect(fetchMock).toHaveBeenCalledWith( + "/api/public-status?interval=5&rangeHours=24&include=meta%2Cdefaults%2Cgroups", + { cache: "no-store" } + ); - expect(container.textContent).toContain("Refresh delayed"); - expect(container.textContent).toContain("Preparing first snapshot"); + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + }); - vi.useRealTimers(); - unmount(); + expect(container.textContent).toContain("Refresh delayed"); + expect(container.textContent).toContain("Preparing first snapshot"); + } finally { + vi.useRealTimers(); + unmount?.(); + } }); it("falls back to shared model-prefix vendor icons when payload vendorIconKey is generic", () => { @@ -453,63 +458,68 @@ describe("public-status view", () => { })); global.fetch = fetchMock as typeof global.fetch; - const { container, unmount } = render( - - ); + let unmount: (() => void) | undefined; - await act(async () => { - await Promise.resolve(); - }); + try { + const { container, unmount: cleanup } = render( + + ); + unmount = cleanup; - expect(fetchMock).toHaveBeenCalledWith( - "/api/public-status?groupSlug=platform&include=meta%2Cdefaults%2Cgroups", - { cache: "no-store" } - ); + await act(async () => { + await Promise.resolve(); + }); - await act(async () => { - vi.advanceTimersByTime(30_000); - await Promise.resolve(); - }); + expect(fetchMock).toHaveBeenCalledWith( + "/api/public-status?groupSlug=platform&include=meta%2Cdefaults%2Cgroups", + { cache: "no-store" } + ); - const text = container.textContent || ""; - expect(text).toContain("Platform Model"); - expect(text).not.toContain("OpenAI Model"); - expect(container.querySelectorAll('[data-testid="sortable-group-panel"]')).toHaveLength(1); + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + }); - vi.useRealTimers(); - unmount(); + const text = container.textContent || ""; + expect(text).toContain("Platform Model"); + expect(text).not.toContain("OpenAI Model"); + expect(container.querySelectorAll('[data-testid="sortable-group-panel"]')).toHaveLength(1); + } finally { + vi.useRealTimers(); + unmount?.(); + } }); it("updates summary state from polling payload even when timeline is reused", async () => {