From 2e4936fc8c9b1ead95adbb8b7178230b6ec3f378 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 13 Apr 2026 18:51:14 +0800 Subject: [PATCH 01/28] =?UTF-8?q?fix:=20=E5=8F=AF=E7=94=A8=E6=80=A7?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E4=BB=85=E7=BB=9F=E8=AE=A1=E7=BB=88=E6=80=81?= =?UTF-8?q?=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 10 +- tests/unit/lib/availability-service.test.ts | 225 +++++++++++++++++++ 2 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 tests/unit/lib/availability-service.test.ts diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 54e3fd658..6dec25fe7 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -4,7 +4,7 @@ * Simple two-tier status: success (green) or failure (red) */ -import { and, desc, eq, gte, inArray, isNull, lte } from "drizzle-orm"; +import { and, desc, eq, gte, inArray, isNotNull, isNull, lte } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { messageRequest, providers } from "@/drizzle/schema"; import { logger } from "@/lib/logger"; @@ -151,6 +151,7 @@ export async function queryProviderAvailability( gte(messageRequest.createdAt, startDate), lte(messageRequest.createdAt, endDate), isNull(messageRequest.deletedAt), + isNotNull(messageRequest.durationMs), ]; const requests = await db @@ -206,7 +207,7 @@ export async function queryProviderAvailability( // Process requests for (const req of requests) { - if (!req.createdAt) continue; + if (!req.createdAt || req.durationMs == null) continue; const bucketStart = new Date(Math.floor(req.createdAt.getTime() / bucketSizeMs) * bucketSizeMs); const bucketKey = bucketStart.toISOString(); @@ -381,7 +382,8 @@ export async function getCurrentProviderStatus(): Promise< and( inArray(messageRequest.providerId, providerIdList), gte(messageRequest.createdAt, fifteenMinutesAgo), - isNull(messageRequest.deletedAt) + isNull(messageRequest.deletedAt), + isNotNull(messageRequest.durationMs) ) ) .orderBy(desc(messageRequest.createdAt)); @@ -405,6 +407,8 @@ export async function getCurrentProviderStatus(): Promise< } for (const req of requests) { + if (req.durationMs == null) continue; + const stats = providerStats.get(req.providerId); if (!stats) continue; diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts new file mode 100644 index 000000000..0c55317c4 --- /dev/null +++ b/tests/unit/lib/availability-service.test.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +function sqlToString(sqlObj: unknown): string { + const visited = new Set(); + + const walk = (node: unknown): string => { + if (!node || visited.has(node)) return ""; + visited.add(node); + + if (typeof node === "string") return node; + + if (typeof node === "object") { + const anyNode = node as { + value?: unknown; + queryChunks?: unknown; + }; + + if (Array.isArray(anyNode)) { + return anyNode.map(walk).join(""); + } + + if (anyNode.value) { + if (Array.isArray(anyNode.value)) { + return anyNode.value.map(String).join(""); + } + return String(anyNode.value); + } + + if (anyNode.queryChunks) { + return walk(anyNode.queryChunks); + } + } + + return ""; + }; + + return walk(sqlObj); +} + +function createThenableQuery(result: T, whereArgs?: unknown[]) { + const query: { + from: ReturnType; + where: ReturnType; + orderBy: ReturnType; + limit: ReturnType; + then: Promise["then"]; + catch: Promise["catch"]; + finally: Promise["finally"]; + } & Promise = Promise.resolve(result) as never; + + query.from = vi.fn(() => query); + query.where = vi.fn((arg: unknown) => { + whereArgs?.push(arg); + return query; + }); + query.orderBy = vi.fn(() => query); + query.limit = vi.fn(() => query); + + return query; +} + +function mockLogger() { + vi.doMock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, + })); +} + +describe("availability-service", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("queryProviderAvailability 只统计已获得最终结果的请求", async () => { + const requestWhereArgs: unknown[] = []; + const selectQueue = [ + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]), + createThenableQuery( + [ + { + id: 100, + providerId: 1, + statusCode: null, + durationMs: null, + errorMessage: null, + createdAt: new Date("2026-04-13T08:00:00.000Z"), + }, + { + id: 101, + providerId: 1, + statusCode: 200, + durationMs: 120, + errorMessage: null, + createdAt: new Date("2026-04-13T08:01:00.000Z"), + }, + { + id: 102, + providerId: 1, + statusCode: 500, + durationMs: 240, + errorMessage: "HTTP 500", + createdAt: new Date("2026-04-13T08:02:00.000Z"), + }, + ], + requestWhereArgs + ), + ]; + + const fallbackQuery = createThenableQuery([]); + const selectMock = vi.fn(() => selectQueue.shift() ?? fallbackQuery); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + }, + })); + mockLogger(); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + const result = await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 60, + }); + + expect(selectMock).toHaveBeenCalledTimes(2); + expect(result.providers).toHaveLength(1); + expect(result.providers[0]).toMatchObject({ + providerId: 1, + totalRequests: 2, + currentAvailability: 0.5, + successRate: 0.5, + currentStatus: "green", + }); + expect(result.providers[0]?.timeBuckets).toHaveLength(1); + expect(result.providers[0]?.timeBuckets[0]).toMatchObject({ + totalRequests: 2, + greenCount: 1, + redCount: 1, + availabilityScore: 0.5, + }); + + expect(requestWhereArgs).toHaveLength(1); + const whereSql = sqlToString(requestWhereArgs[0]).toLowerCase(); + expect(whereSql).toContain("is not null"); + }); + + it("getCurrentProviderStatus 只统计已获得最终结果的请求", async () => { + const requestWhereArgs: unknown[] = []; + const selectQueue = [ + createThenableQuery([ + { + id: 1, + name: "Provider A", + }, + ]), + createThenableQuery( + [ + { + providerId: 1, + statusCode: null, + durationMs: null, + createdAt: new Date("2026-04-13T08:03:00.000Z"), + }, + { + providerId: 1, + statusCode: 503, + durationMs: 300, + createdAt: new Date("2026-04-13T08:02:00.000Z"), + }, + { + providerId: 1, + statusCode: 200, + durationMs: 120, + createdAt: new Date("2026-04-13T08:01:00.000Z"), + }, + ], + requestWhereArgs + ), + ]; + + const fallbackQuery = createThenableQuery([]); + const selectMock = vi.fn(() => selectQueue.shift() ?? fallbackQuery); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + }, + })); + mockLogger(); + + const { getCurrentProviderStatus } = await import("@/lib/availability/availability-service"); + const result = await getCurrentProviderStatus(); + + expect(selectMock).toHaveBeenCalledTimes(2); + expect(result).toEqual([ + { + providerId: 1, + providerName: "Provider A", + status: "green", + availability: 0.5, + requestCount: 2, + lastRequestAt: "2026-04-13T08:02:00.000Z", + }, + ]); + + expect(requestWhereArgs).toHaveLength(1); + const whereSql = sqlToString(requestWhereArgs[0]).toLowerCase(); + expect(whereSql).toContain("is not null"); + }); +}); From 602d9b9a5f4927dded2c027a8667226a249ceeaa Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 13 Apr 2026 19:23:37 +0800 Subject: [PATCH 02/28] =?UTF-8?q?fix:=20=E5=8F=AF=E7=94=A8=E6=80=A7?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E6=8C=89=E7=8A=B6=E6=80=81=E7=A0=81=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E7=BB=88=E6=80=81=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 14 +- tests/unit/lib/availability-service.test.ts | 170 +++++++------------ 2 files changed, 72 insertions(+), 112 deletions(-) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 6dec25fe7..629b13e82 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -151,7 +151,8 @@ export async function queryProviderAvailability( gte(messageRequest.createdAt, startDate), lte(messageRequest.createdAt, endDate), isNull(messageRequest.deletedAt), - isNotNull(messageRequest.durationMs), + // 终态应以 statusCode 为准;Gemini passthrough 等路径可能已结束但尚未回填 durationMs。 + isNotNull(messageRequest.statusCode), ]; const requests = await db @@ -207,7 +208,8 @@ export async function queryProviderAvailability( // Process requests for (const req of requests) { - if (!req.createdAt || req.durationMs == null) continue; + // 防御性兜底:异步写缓冲短暂不一致时,仍跳过尚未写入最终状态码的记录。 + if (!req.createdAt || req.statusCode == null) continue; const bucketStart = new Date(Math.floor(req.createdAt.getTime() / bucketSizeMs) * bucketSizeMs); const bucketKey = bucketStart.toISOString(); @@ -281,7 +283,7 @@ export async function queryProviderAvailability( }); // Track last request time - if (bucket.latencies.length > 0) { + if (bucket.greenCount + bucket.redCount > 0) { lastRequestAt = bucketEnd.toISOString(); } } @@ -383,7 +385,8 @@ export async function getCurrentProviderStatus(): Promise< inArray(messageRequest.providerId, providerIdList), gte(messageRequest.createdAt, fifteenMinutesAgo), isNull(messageRequest.deletedAt), - isNotNull(messageRequest.durationMs) + // 终态应以 statusCode 为准;Gemini passthrough 等路径可能已结束但尚未回填 durationMs。 + isNotNull(messageRequest.statusCode) ) ) .orderBy(desc(messageRequest.createdAt)); @@ -407,7 +410,8 @@ export async function getCurrentProviderStatus(): Promise< } for (const req of requests) { - if (req.durationMs == null) continue; + // 防御性兜底:异步写缓冲短暂不一致时,仍跳过尚未写入最终状态码的记录。 + if (req.statusCode == null) continue; const stats = providerStats.get(req.providerId); if (!stats) continue; diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index 0c55317c4..3fbe7ffbf 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -1,42 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -function sqlToString(sqlObj: unknown): string { - const visited = new Set(); - - const walk = (node: unknown): string => { - if (!node || visited.has(node)) return ""; - visited.add(node); - - if (typeof node === "string") return node; - - if (typeof node === "object") { - const anyNode = node as { - value?: unknown; - queryChunks?: unknown; - }; - - if (Array.isArray(anyNode)) { - return anyNode.map(walk).join(""); - } - - if (anyNode.value) { - if (Array.isArray(anyNode.value)) { - return anyNode.value.map(String).join(""); - } - return String(anyNode.value); - } - - if (anyNode.queryChunks) { - return walk(anyNode.queryChunks); - } - } - - return ""; - }; - - return walk(sqlObj); -} - function createThenableQuery(result: T, whereArgs?: unknown[]) { const query: { from: ReturnType; @@ -78,8 +41,7 @@ describe("availability-service", () => { vi.clearAllMocks(); }); - it("queryProviderAvailability 只统计已获得最终结果的请求", async () => { - const requestWhereArgs: unknown[] = []; + it("queryProviderAvailability 只统计已获得最终状态码的请求", async () => { const selectQueue = [ createThenableQuery([ { @@ -89,35 +51,40 @@ describe("availability-service", () => { enabled: true, }, ]), - createThenableQuery( - [ - { - id: 100, - providerId: 1, - statusCode: null, - durationMs: null, - errorMessage: null, - createdAt: new Date("2026-04-13T08:00:00.000Z"), - }, - { - id: 101, - providerId: 1, - statusCode: 200, - durationMs: 120, - errorMessage: null, - createdAt: new Date("2026-04-13T08:01:00.000Z"), - }, - { - id: 102, - providerId: 1, - statusCode: 500, - durationMs: 240, - errorMessage: "HTTP 500", - createdAt: new Date("2026-04-13T08:02:00.000Z"), - }, - ], - requestWhereArgs - ), + createThenableQuery([ + { + id: 100, + providerId: 1, + statusCode: null, + durationMs: null, + errorMessage: null, + createdAt: new Date("2026-04-13T08:00:00.000Z"), + }, + { + id: 101, + providerId: 1, + statusCode: 200, + durationMs: null, + errorMessage: null, + createdAt: new Date("2026-04-13T08:01:00.000Z"), + }, + { + id: 102, + providerId: 1, + statusCode: 500, + durationMs: 240, + errorMessage: "HTTP 500", + createdAt: new Date("2026-04-13T08:02:00.000Z"), + }, + { + id: 103, + providerId: 1, + statusCode: 200, + durationMs: 120, + errorMessage: null, + createdAt: new Date("2026-04-13T08:03:00.000Z"), + }, + ]), ]; const fallbackQuery = createThenableQuery([]); @@ -141,26 +108,22 @@ describe("availability-service", () => { expect(result.providers).toHaveLength(1); expect(result.providers[0]).toMatchObject({ providerId: 1, - totalRequests: 2, - currentAvailability: 0.5, - successRate: 0.5, + totalRequests: 3, + currentAvailability: 2 / 3, + successRate: 2 / 3, currentStatus: "green", + lastRequestAt: "2026-04-13T09:00:00.000Z", }); expect(result.providers[0]?.timeBuckets).toHaveLength(1); expect(result.providers[0]?.timeBuckets[0]).toMatchObject({ - totalRequests: 2, - greenCount: 1, + totalRequests: 3, + greenCount: 2, redCount: 1, - availabilityScore: 0.5, + availabilityScore: 2 / 3, }); - - expect(requestWhereArgs).toHaveLength(1); - const whereSql = sqlToString(requestWhereArgs[0]).toLowerCase(); - expect(whereSql).toContain("is not null"); }); - it("getCurrentProviderStatus 只统计已获得最终结果的请求", async () => { - const requestWhereArgs: unknown[] = []; + it("getCurrentProviderStatus 只统计已获得最终状态码的请求", async () => { const selectQueue = [ createThenableQuery([ { @@ -168,29 +131,26 @@ describe("availability-service", () => { name: "Provider A", }, ]), - createThenableQuery( - [ - { - providerId: 1, - statusCode: null, - durationMs: null, - createdAt: new Date("2026-04-13T08:03:00.000Z"), - }, - { - providerId: 1, - statusCode: 503, - durationMs: 300, - createdAt: new Date("2026-04-13T08:02:00.000Z"), - }, - { - providerId: 1, - statusCode: 200, - durationMs: 120, - createdAt: new Date("2026-04-13T08:01:00.000Z"), - }, - ], - requestWhereArgs - ), + createThenableQuery([ + { + providerId: 1, + statusCode: null, + durationMs: null, + createdAt: new Date("2026-04-13T08:03:00.000Z"), + }, + { + providerId: 1, + statusCode: 503, + durationMs: null, + createdAt: new Date("2026-04-13T08:02:00.000Z"), + }, + { + providerId: 1, + statusCode: 200, + durationMs: 120, + createdAt: new Date("2026-04-13T08:01:00.000Z"), + }, + ]), ]; const fallbackQuery = createThenableQuery([]); @@ -217,9 +177,5 @@ describe("availability-service", () => { lastRequestAt: "2026-04-13T08:02:00.000Z", }, ]); - - expect(requestWhereArgs).toHaveLength(1); - const whereSql = sqlToString(requestWhereArgs[0]).toLowerCase(); - expect(whereSql).toContain("is not null"); }); }); From 0c52f5c877192fb8ba5801d791a05feda162f8aa Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 13 Apr 2026 20:04:37 +0800 Subject: [PATCH 03/28] =?UTF-8?q?fix:=20=E5=8F=AF=E7=94=A8=E6=80=A7?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E5=AE=8C=E5=96=84=E7=BB=88=E6=80=81=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 43 +++++++++----------- tests/unit/lib/availability-service.test.ts | 16 +++++++- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 629b13e82..c7f0b6187 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -21,19 +21,10 @@ import type { const MAX_REQUESTS_PER_QUERY = 100000; /** - * Classify a single request's status + * Classify a single finalized request's status * Simple: success (2xx/3xx) = green, failure = red */ -export function classifyRequestStatus(statusCode: number | null): RequestStatusClassification { - // No status code means network error or timeout - if (statusCode === null) { - return { - status: "red", - isSuccess: false, - isError: true, - }; - } - +export function classifyRequestStatus(statusCode: number): RequestStatusClassification { // HTTP error (4xx/5xx) if (statusCode >= 400) { return { @@ -151,7 +142,9 @@ export async function queryProviderAvailability( gte(messageRequest.createdAt, startDate), lte(messageRequest.createdAt, endDate), isNull(messageRequest.deletedAt), - // 终态应以 statusCode 为准;Gemini passthrough 等路径可能已结束但尚未回填 durationMs。 + // 可用性分类必须等最终 statusCode 落库: + // 1. sync 写路径会先写 durationMs,再写 statusCode,durationMs-only 仍属于持久化中间态; + // 2. Gemini passthrough 等路径又可能先拿到 statusCode,而 durationMs 暂未回填。 isNotNull(messageRequest.statusCode), ]; @@ -200,15 +193,17 @@ export async function queryProviderAvailability( } > >(); + const providerLastRequestAt = new Map(); // Initialize provider buckets for (const provider of providerList) { providerBuckets.set(provider.id, new Map()); + providerLastRequestAt.set(provider.id, 0); } // Process requests for (const req of requests) { - // 防御性兜底:异步写缓冲短暂不一致时,仍跳过尚未写入最终状态码的记录。 + // 防御性兜底:即使查询条件被未来改动,仍不把仅有 durationMs 的中间态误计为失败。 if (!req.createdAt || req.statusCode == null) continue; const bucketStart = new Date(Math.floor(req.createdAt.getTime() / bucketSizeMs) * bucketSizeMs); @@ -234,6 +229,12 @@ export async function queryProviderAvailability( bucket.redCount++; } + const currentLastRequestAt = providerLastRequestAt.get(req.providerId) ?? 0; + providerLastRequestAt.set( + req.providerId, + Math.max(currentLastRequestAt, req.createdAt.getTime()) + ); + if (req.durationMs !== null) { bucket.latencies.push(req.durationMs); } @@ -249,8 +250,6 @@ export async function queryProviderAvailability( let totalGreen = 0; let totalRed = 0; const allLatencies: number[] = []; - let lastRequestAt: string | null = null; - // Sort buckets by time and limit const sortedBucketKeys = Array.from(bucketData.keys()).sort().slice(-maxBuckets); @@ -281,15 +280,11 @@ export async function queryProviderAvailability( p95LatencyMs: calculatePercentile(sortedLatencies, 95), p99LatencyMs: calculatePercentile(sortedLatencies, 99), }); - - // Track last request time - if (bucket.greenCount + bucket.redCount > 0) { - lastRequestAt = bucketEnd.toISOString(); - } } const totalRequests = totalGreen + totalRed; const sortedAllLatencies = allLatencies.sort((a, b) => a - b); + const lastRequestAtTime = providerLastRequestAt.get(provider.id) ?? 0; // Determine current status based on last few buckets // IMPORTANT: No data = 'unknown', NOT 'green'! Must be honest. @@ -316,7 +311,7 @@ export async function queryProviderAvailability( sortedAllLatencies.length > 0 ? sortedAllLatencies.reduce((a, b) => a + b, 0) / sortedAllLatencies.length : 0, - lastRequestAt, + lastRequestAt: lastRequestAtTime > 0 ? new Date(lastRequestAtTime).toISOString() : null, timeBuckets, }); } @@ -385,7 +380,9 @@ export async function getCurrentProviderStatus(): Promise< inArray(messageRequest.providerId, providerIdList), gte(messageRequest.createdAt, fifteenMinutesAgo), isNull(messageRequest.deletedAt), - // 终态应以 statusCode 为准;Gemini passthrough 等路径可能已结束但尚未回填 durationMs。 + // 可用性分类必须等最终 statusCode 落库: + // 1. sync 写路径会先写 durationMs,再写 statusCode,durationMs-only 仍属于持久化中间态; + // 2. Gemini passthrough 等路径又可能先拿到 statusCode,而 durationMs 暂未回填。 isNotNull(messageRequest.statusCode) ) ) @@ -410,7 +407,7 @@ export async function getCurrentProviderStatus(): Promise< } for (const req of requests) { - // 防御性兜底:异步写缓冲短暂不一致时,仍跳过尚未写入最终状态码的记录。 + // 防御性兜底:即使查询条件被未来改动,仍不把仅有 durationMs 的中间态误计为失败。 if (req.statusCode == null) continue; const stats = providerStats.get(req.providerId); diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index 3fbe7ffbf..452d48eb5 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -84,6 +84,14 @@ describe("availability-service", () => { errorMessage: null, createdAt: new Date("2026-04-13T08:03:00.000Z"), }, + { + id: 104, + providerId: 1, + statusCode: null, + durationMs: 360, + errorMessage: "TIMEOUT", + createdAt: new Date("2026-04-13T08:04:00.000Z"), + }, ]), ]; @@ -112,7 +120,7 @@ describe("availability-service", () => { currentAvailability: 2 / 3, successRate: 2 / 3, currentStatus: "green", - lastRequestAt: "2026-04-13T09:00:00.000Z", + lastRequestAt: "2026-04-13T08:03:00.000Z", }); expect(result.providers[0]?.timeBuckets).toHaveLength(1); expect(result.providers[0]?.timeBuckets[0]).toMatchObject({ @@ -150,6 +158,12 @@ describe("availability-service", () => { durationMs: 120, createdAt: new Date("2026-04-13T08:01:00.000Z"), }, + { + providerId: 1, + statusCode: null, + durationMs: 360, + createdAt: new Date("2026-04-13T08:04:00.000Z"), + }, ]), ]; From 7598a357cb85746b3a849dd37be4cf06d8ecbe8a Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 13 Apr 2026 20:27:25 +0800 Subject: [PATCH 04/28] =?UTF-8?q?fix:=20=E5=8F=AF=E7=94=A8=E6=80=A7?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E6=94=B6=E6=95=9B=E7=BB=88=E6=80=81=E5=88=A4?= =?UTF-8?q?=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 35 ++++++++++++-------- tests/unit/lib/availability-service.test.ts | 17 ++++++++++ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index c7f0b6187..cca1d1d91 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -20,25 +20,34 @@ import type { // Maximum requests to load per query to prevent OOM const MAX_REQUESTS_PER_QUERY = 100000; +function buildAvailabilityFinalizedCondition() { + return isNotNull(messageRequest.statusCode); +} + +function hasAvailabilityFinalizedStatusCode( + request: T +): request is T & { statusCode: number } { + return request.statusCode != null; +} + /** * Classify a single finalized request's status * Simple: success (2xx/3xx) = green, failure = red */ export function classifyRequestStatus(statusCode: number): RequestStatusClassification { - // HTTP error (4xx/5xx) - if (statusCode >= 400) { + // 仅把 2xx/3xx 视为成功;1xx 不应在可用性里被计为绿色。 + if (statusCode >= 200 && statusCode < 400) { return { - status: "red", - isSuccess: false, - isError: true, + status: "green", + isSuccess: true, + isError: false, }; } - // HTTP success (2xx/3xx) - all successful requests are green return { - status: "green", - isSuccess: true, - isError: false, + status: "red", + isSuccess: false, + isError: true, }; } @@ -145,7 +154,7 @@ export async function queryProviderAvailability( // 可用性分类必须等最终 statusCode 落库: // 1. sync 写路径会先写 durationMs,再写 statusCode,durationMs-only 仍属于持久化中间态; // 2. Gemini passthrough 等路径又可能先拿到 statusCode,而 durationMs 暂未回填。 - isNotNull(messageRequest.statusCode), + buildAvailabilityFinalizedCondition(), ]; const requests = await db @@ -204,7 +213,7 @@ export async function queryProviderAvailability( // Process requests for (const req of requests) { // 防御性兜底:即使查询条件被未来改动,仍不把仅有 durationMs 的中间态误计为失败。 - if (!req.createdAt || req.statusCode == null) continue; + if (!req.createdAt || !hasAvailabilityFinalizedStatusCode(req)) continue; const bucketStart = new Date(Math.floor(req.createdAt.getTime() / bucketSizeMs) * bucketSizeMs); const bucketKey = bucketStart.toISOString(); @@ -383,7 +392,7 @@ export async function getCurrentProviderStatus(): Promise< // 可用性分类必须等最终 statusCode 落库: // 1. sync 写路径会先写 durationMs,再写 statusCode,durationMs-only 仍属于持久化中间态; // 2. Gemini passthrough 等路径又可能先拿到 statusCode,而 durationMs 暂未回填。 - isNotNull(messageRequest.statusCode) + buildAvailabilityFinalizedCondition() ) ) .orderBy(desc(messageRequest.createdAt)); @@ -408,7 +417,7 @@ export async function getCurrentProviderStatus(): Promise< for (const req of requests) { // 防御性兜底:即使查询条件被未来改动,仍不把仅有 durationMs 的中间态误计为失败。 - if (req.statusCode == null) continue; + if (!hasAvailabilityFinalizedStatusCode(req)) continue; const stats = providerStats.get(req.providerId); if (!stats) continue; diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index 452d48eb5..27f3bbeb1 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -41,6 +41,23 @@ describe("availability-service", () => { vi.clearAllMocks(); }); + it("classifyRequestStatus 不应把 1xx 当成成功", async () => { + vi.doMock("@/drizzle/db", () => ({ + db: { + select: vi.fn(), + }, + })); + mockLogger(); + + const { classifyRequestStatus } = await import("@/lib/availability/availability-service"); + + expect(classifyRequestStatus(101)).toEqual({ + status: "red", + isSuccess: false, + isError: true, + }); + }); + it("queryProviderAvailability 只统计已获得最终状态码的请求", async () => { const selectQueue = [ createThenableQuery([ From 0142337aabd426f36c5a7f3187a0ee1dc0e5fdbd Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 00:08:53 +0800 Subject: [PATCH 05/28] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E4=BE=9B?= =?UTF-8?q?=E5=BA=94=E5=95=86=E5=8F=AF=E7=94=A8=E6=80=A7=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- drizzle/0088_amazing_energizer.sql | 1 + drizzle/meta/0088_snapshot.json | 4045 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/drizzle/schema.ts | 6 + src/lib/availability/availability-service.ts | 422 +- tests/unit/lib/availability-service.test.ts | 245 +- 6 files changed, 4437 insertions(+), 289 deletions(-) create mode 100644 drizzle/0088_amazing_energizer.sql create mode 100644 drizzle/meta/0088_snapshot.json diff --git a/drizzle/0088_amazing_energizer.sql b/drizzle/0088_amazing_energizer.sql new file mode 100644 index 000000000..9f290d9e2 --- /dev/null +++ b/drizzle/0088_amazing_energizer.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS "idx_message_request_provider_created_at_finalized_active" ON "message_request" USING btree ("provider_id","created_at" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL AND "message_request"."status_code" IS NOT NULL; diff --git a/drizzle/meta/0088_snapshot.json b/drizzle/meta/0088_snapshot.json new file mode 100644 index 000000000..54a1ac67a --- /dev/null +++ b/drizzle/meta/0088_snapshot.json @@ -0,0 +1,4045 @@ +{ + "id": "dae021f8-eb59-4ada-a30b-805aad37c932", + "prevId": "72847d01-0503-4979-a1c9-17a8e0cd93b6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_finalized_active": { + "name": "idx_message_request_provider_created_at_finalized_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disable_session_reuse": { + "name": "disable_session_reuse", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_service_tier_preference": { + "name": "codex_service_tier_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rule_mode": { + "name": "rule_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "execution_phase": { + "name": "execution_phase", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'guard'" + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_phase": { + "name": "idx_request_filters_phase", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_phase", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "codex_priority_billing_source": { + "name": "codex_priority_billing_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_high_concurrency_mode": { + "name": "enable_high_concurrency_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_input_rectifier": { + "name": "enable_response_input_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_provider_id": { + "name": "final_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_success": { + "name": "is_success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_ledger_request_id": { + "name": "idx_usage_ledger_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_created_at": { + "name": "idx_usage_ledger_user_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at": { + "name": "idx_usage_ledger_key_created_at", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_created_at": { + "name": "idx_usage_ledger_provider_created_at", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_minute": { + "name": "idx_usage_ledger_created_at_minute", + "columns": [ + { + "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_desc_id": { + "name": "idx_usage_ledger_created_at_desc_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_id": { + "name": "idx_usage_ledger_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_model": { + "name": "idx_usage_ledger_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_cost": { + "name": "idx_usage_ledger_key_cost", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_cost_cover": { + "name": "idx_usage_ledger_user_cost_cover", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_cost_cover": { + "name": "idx_usage_ledger_provider_cost_cover", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at_desc_cover": { + "name": "idx_usage_ledger_key_created_at_desc_cover", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert", + "cache_hit_rate_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 549a95ad2..c3187481e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -617,6 +617,13 @@ "when": 1774678770349, "tag": "0087_nappy_lady_mastermind", "breakpoints": true + }, + { + "idx": 88, + "version": "7", + "when": 1776095902010, + "tag": "0088_amazing_energizer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index b8887727b..f390ba631 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -511,6 +511,12 @@ export const messageRequest = pgTable('message_request', { messageRequestProviderCreatedAtActiveIdx: index('idx_message_request_provider_created_at_active') .on(table.providerId, table.createdAt) .where(sql`${table.deletedAt} IS NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')`), + // #slow-query: availability 终态聚合热路径(provider + 时间范围 + status_code 已落库) + messageRequestProviderCreatedAtFinalizedActiveIdx: index( + 'idx_message_request_provider_created_at_finalized_active' + ) + .on(table.providerId, table.createdAt.desc()) + .where(sql`${table.deletedAt} IS NULL AND ${table.statusCode} IS NOT NULL`), // Session 查询索引(按 session 聚合查看对话) messageRequestSessionIdIdx: index('idx_message_request_session_id').on(table.sessionId).where(sql`${table.deletedAt} IS NULL`), // Session ID 前缀查询索引(LIKE 'prefix%',可稳定命中 B-tree) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index cca1d1d91..1f89b296e 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -4,10 +4,9 @@ * Simple two-tier status: success (green) or failure (red) */ -import { and, desc, eq, gte, inArray, isNotNull, isNull, lte } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { messageRequest, providers } from "@/drizzle/schema"; -import { logger } from "@/lib/logger"; import type { AvailabilityQueryOptions, AvailabilityQueryResult, @@ -17,17 +16,77 @@ import type { TimeBucketMetrics, } from "./types"; -// Maximum requests to load per query to prevent OOM -const MAX_REQUESTS_PER_QUERY = 100000; +type AggregatedAvailabilityBucketRow = { + providerId: number; + bucketStart: Date; + totalRequests: number; + greenCount: number; + redCount: number; + latencyCount: number; + latencySumMs: number; + avgLatencyMs: number; + p50LatencyMs: number; + p95LatencyMs: number; + p99LatencyMs: number; + lastRequestAt: Date | null; +}; + +type AggregatedCurrentProviderStatusRow = { + providerId: number; + greenCount: number; + redCount: number; + lastRequestAt: Date | null; +}; function buildAvailabilityFinalizedCondition() { return isNotNull(messageRequest.statusCode); } -function hasAvailabilityFinalizedStatusCode( - request: T -): request is T & { statusCode: number } { - return request.statusCode != null; +function buildTimestampLowerBound(column: typeof messageRequest.createdAt, date: Date) { + return sql`${column} >= CAST(${date.toISOString()} AS timestamptz)`; +} + +function buildTimestampUpperBound(column: typeof messageRequest.createdAt, date: Date) { + return sql`${column} <= CAST(${date.toISOString()} AS timestamptz)`; +} + +function buildAvailabilityRequestConditions(input: { + providerIds: number[]; + startDate: Date; + endDate?: Date; +}) { + const conditions = [ + inArray(messageRequest.providerId, input.providerIds), + buildTimestampLowerBound(messageRequest.createdAt, input.startDate), + isNull(messageRequest.deletedAt), + buildAvailabilityFinalizedCondition(), + ]; + + if (input.endDate) { + conditions.push(buildTimestampUpperBound(messageRequest.createdAt, input.endDate)); + } + + return and(...conditions); +} + +function toFiniteNumber(value: number | string | null | undefined): number { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +} + +function toIsoString(value: Date | string | null | undefined): string | null { + if (!value) return null; + if (value instanceof Date) return value.toISOString(); + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +function getTimeValue(value: Date | string | null | undefined): number { + if (!value) return 0; + const parsed = value instanceof Date ? value : new Date(value); + const timestamp = parsed.getTime(); + return Number.isFinite(timestamp) ? timestamp : 0; } /** @@ -61,15 +120,6 @@ export function calculateAvailabilityScore(greenCount: number, redCount: number) return greenCount / total; } -/** - * Calculate percentile from sorted array - */ -function calculatePercentile(sortedValues: number[], percentile: number): number { - if (sortedValues.length === 0) return 0; - const index = Math.ceil((percentile / 100) * sortedValues.length) - 1; - return sortedValues[Math.max(0, Math.min(index, sortedValues.length - 1))]; -} - /** * Determine optimal time bucket size based on data density */ @@ -143,157 +193,165 @@ export async function queryProviderAvailability( }; } - const providerIdList = providerList.map((p) => p.id); - - // Query raw request data - const requestConditions = [ - inArray(messageRequest.providerId, providerIdList), - gte(messageRequest.createdAt, startDate), - lte(messageRequest.createdAt, endDate), - isNull(messageRequest.deletedAt), - // 可用性分类必须等最终 statusCode 落库: - // 1. sync 写路径会先写 durationMs,再写 statusCode,durationMs-only 仍属于持久化中间态; - // 2. Gemini passthrough 等路径又可能先拿到 statusCode,而 durationMs 暂未回填。 - buildAvailabilityFinalizedCondition(), - ]; - - const requests = await db - .select({ - id: messageRequest.id, - providerId: messageRequest.providerId, - statusCode: messageRequest.statusCode, - durationMs: messageRequest.durationMs, - errorMessage: messageRequest.errorMessage, - createdAt: messageRequest.createdAt, - }) - .from(messageRequest) - .where(and(...requestConditions)) - .orderBy(messageRequest.createdAt) - .limit(MAX_REQUESTS_PER_QUERY); - - // Warn if query hit the limit - results may be incomplete - if (requests.length === MAX_REQUESTS_PER_QUERY) { - logger.warn("[Availability] Query hit max request limit, results may be incomplete", { - limit: MAX_REQUESTS_PER_QUERY, - startTime: startDate.toISOString(), - endTime: endDate.toISOString(), - }); - } + const providerIdList = providerList.map((provider) => provider.id); - // Determine bucket size if not explicitly specified // Ensure minimum bucket size of 0.25 minutes (15 seconds) to prevent division by zero // Handle NaN case (nullish coalescing doesn't catch NaN from invalid parseFloat input) - const rawBucketSize = - explicitBucketSize ?? determineOptimalBucketSize(requests.length, timeRangeMinutes); + const rawBucketSize = explicitBucketSize ?? determineOptimalBucketSize(0, timeRangeMinutes); const bucketSizeMinutes = Number.isNaN(rawBucketSize) - ? determineOptimalBucketSize(requests.length, timeRangeMinutes) + ? determineOptimalBucketSize(0, timeRangeMinutes) : Math.max(0.25, rawBucketSize); const bucketSizeMs = bucketSizeMinutes * 60 * 1000; + const bucketSizeSeconds = bucketSizeMinutes * 60; + const requestConditions = buildAvailabilityRequestConditions({ + providerIds: providerIdList, + startDate, + endDate, + }); + const shouldLimitBuckets = Number.isFinite(maxBuckets) && maxBuckets > 0; + + const availabilityAggregationCtes = sql` + finalized_requests AS ( + SELECT + ${messageRequest.providerId} AS "providerId", + ${messageRequest.createdAt} AS "createdAt", + ${messageRequest.statusCode} AS "statusCode", + ${messageRequest.durationMs} AS "durationMs", + to_timestamp( + floor(extract(epoch from ${messageRequest.createdAt}) / ${bucketSizeSeconds}) * ${bucketSizeSeconds} + ) AS "bucketStart" + FROM ${messageRequest} + WHERE ${requestConditions} + ), + provider_bucket_stats AS ( + SELECT + "providerId", + "bucketStart", + COUNT(*)::int AS "totalRequests", + COUNT(*) FILTER (WHERE "statusCode" >= 200 AND "statusCode" < 400)::int AS "greenCount", + COUNT(*) FILTER (WHERE "statusCode" < 200 OR "statusCode" >= 400)::int AS "redCount", + COUNT("durationMs")::int AS "latencyCount", + COALESCE(SUM("durationMs")::double precision, 0) AS "latencySumMs", + COALESCE(AVG("durationMs")::double precision, 0) AS "avgLatencyMs", + COALESCE( + percentile_cont(0.5) WITHIN GROUP (ORDER BY "durationMs"::double precision) + FILTER (WHERE "durationMs" IS NOT NULL), + 0 + )::double precision AS "p50LatencyMs", + COALESCE( + percentile_cont(0.95) WITHIN GROUP (ORDER BY "durationMs"::double precision) + FILTER (WHERE "durationMs" IS NOT NULL), + 0 + )::double precision AS "p95LatencyMs", + COALESCE( + percentile_cont(0.99) WITHIN GROUP (ORDER BY "durationMs"::double precision) + FILTER (WHERE "durationMs" IS NOT NULL), + 0 + )::double precision AS "p99LatencyMs", + MAX("createdAt") AS "lastRequestAt" + FROM finalized_requests + GROUP BY "providerId", "bucketStart" + ) + `; + + const bucketQuery = shouldLimitBuckets + ? sql` + WITH + ${availabilityAggregationCtes}, + limited_provider_bucket_stats AS ( + SELECT + *, + ROW_NUMBER() OVER (PARTITION BY "providerId" ORDER BY "bucketStart" DESC) AS rn + FROM provider_bucket_stats + ) + SELECT + "providerId", + "bucketStart", + "totalRequests", + "greenCount", + "redCount", + "latencyCount", + "latencySumMs", + "avgLatencyMs", + "p50LatencyMs", + "p95LatencyMs", + "p99LatencyMs", + "lastRequestAt" + FROM limited_provider_bucket_stats + WHERE rn <= ${Math.floor(maxBuckets)} + ORDER BY "providerId" ASC, "bucketStart" ASC + ` + : sql` + WITH ${availabilityAggregationCtes} + SELECT + "providerId", + "bucketStart", + "totalRequests", + "greenCount", + "redCount", + "latencyCount", + "latencySumMs", + "avgLatencyMs", + "p50LatencyMs", + "p95LatencyMs", + "p99LatencyMs", + "lastRequestAt" + FROM provider_bucket_stats + ORDER BY "providerId" ASC, "bucketStart" ASC + `; + + const bucketRows = Array.from(await db.execute(bucketQuery)) as AggregatedAvailabilityBucketRow[]; + const providerBuckets = new Map(); - // Group requests by provider and time bucket - const providerBuckets = new Map< - number, - Map< - string, - { - greenCount: number; - redCount: number; - latencies: number[]; - } - > - >(); - const providerLastRequestAt = new Map(); - - // Initialize provider buckets for (const provider of providerList) { - providerBuckets.set(provider.id, new Map()); - providerLastRequestAt.set(provider.id, 0); + providerBuckets.set(provider.id, []); } - // Process requests - for (const req of requests) { - // 防御性兜底:即使查询条件被未来改动,仍不把仅有 durationMs 的中间态误计为失败。 - if (!req.createdAt || !hasAvailabilityFinalizedStatusCode(req)) continue; - - const bucketStart = new Date(Math.floor(req.createdAt.getTime() / bucketSizeMs) * bucketSizeMs); - const bucketKey = bucketStart.toISOString(); - - const providerData = providerBuckets.get(req.providerId); - if (!providerData) continue; - - if (!providerData.has(bucketKey)) { - providerData.set(bucketKey, { - greenCount: 0, - redCount: 0, - latencies: [], - }); - } - - const bucket = providerData.get(bucketKey)!; - const classification = classifyRequestStatus(req.statusCode); - - if (classification.status === "green") { - bucket.greenCount++; - } else { - bucket.redCount++; - } - - const currentLastRequestAt = providerLastRequestAt.get(req.providerId) ?? 0; - providerLastRequestAt.set( - req.providerId, - Math.max(currentLastRequestAt, req.createdAt.getTime()) - ); - - if (req.durationMs !== null) { - bucket.latencies.push(req.durationMs); - } + for (const row of bucketRows) { + providerBuckets.get(row.providerId)?.push(row); } // Build provider summaries const providerSummaries: ProviderAvailabilitySummary[] = []; for (const provider of providerList) { - const bucketData = providerBuckets.get(provider.id)!; + const bucketRowsForProvider = providerBuckets.get(provider.id) ?? []; const timeBuckets: TimeBucketMetrics[] = []; let totalGreen = 0; let totalRed = 0; - const allLatencies: number[] = []; - // Sort buckets by time and limit - const sortedBucketKeys = Array.from(bucketData.keys()).sort().slice(-maxBuckets); - - for (const bucketKey of sortedBucketKeys) { - const bucket = bucketData.get(bucketKey)!; - const bucketStart = new Date(bucketKey); + let totalLatencyCount = 0; + let totalLatencySumMs = 0; + let lastRequestAtTime = 0; + + for (const bucket of bucketRowsForProvider) { + totalGreen += toFiniteNumber(bucket.greenCount); + totalRed += toFiniteNumber(bucket.redCount); + totalLatencyCount += toFiniteNumber(bucket.latencyCount); + totalLatencySumMs += toFiniteNumber(bucket.latencySumMs); + lastRequestAtTime = Math.max(lastRequestAtTime, getTimeValue(bucket.lastRequestAt)); + + const bucketStart = new Date(bucket.bucketStart); const bucketEnd = new Date(bucketStart.getTime() + bucketSizeMs); - totalGreen += bucket.greenCount; - totalRed += bucket.redCount; - allLatencies.push(...bucket.latencies); - - const sortedLatencies = [...bucket.latencies].sort((a, b) => a - b); - const total = bucket.greenCount + bucket.redCount; - timeBuckets.push({ bucketStart: bucketStart.toISOString(), bucketEnd: bucketEnd.toISOString(), - totalRequests: total, - greenCount: bucket.greenCount, - redCount: bucket.redCount, - availabilityScore: calculateAvailabilityScore(bucket.greenCount, bucket.redCount), - avgLatencyMs: - sortedLatencies.length > 0 - ? sortedLatencies.reduce((a, b) => a + b, 0) / sortedLatencies.length - : 0, - p50LatencyMs: calculatePercentile(sortedLatencies, 50), - p95LatencyMs: calculatePercentile(sortedLatencies, 95), - p99LatencyMs: calculatePercentile(sortedLatencies, 99), + totalRequests: toFiniteNumber(bucket.totalRequests), + greenCount: toFiniteNumber(bucket.greenCount), + redCount: toFiniteNumber(bucket.redCount), + availabilityScore: calculateAvailabilityScore( + toFiniteNumber(bucket.greenCount), + toFiniteNumber(bucket.redCount) + ), + avgLatencyMs: toFiniteNumber(bucket.avgLatencyMs), + p50LatencyMs: toFiniteNumber(bucket.p50LatencyMs), + p95LatencyMs: toFiniteNumber(bucket.p95LatencyMs), + p99LatencyMs: toFiniteNumber(bucket.p99LatencyMs), }); } const totalRequests = totalGreen + totalRed; - const sortedAllLatencies = allLatencies.sort((a, b) => a - b); - const lastRequestAtTime = providerLastRequestAt.get(provider.id) ?? 0; // Determine current status based on last few buckets // IMPORTANT: No data = 'unknown', NOT 'green'! Must be honest. @@ -301,7 +359,8 @@ export async function queryProviderAvailability( if (timeBuckets.length > 0) { const recentBuckets = timeBuckets.slice(-3); // Last 3 buckets const recentScore = - recentBuckets.reduce((sum, b) => sum + b.availabilityScore, 0) / recentBuckets.length; + recentBuckets.reduce((sum, bucket) => sum + bucket.availabilityScore, 0) / + recentBuckets.length; // Simple: >= 50% success = green, otherwise red currentStatus = recentScore >= 0.5 ? "green" : "red"; @@ -316,21 +375,23 @@ export async function queryProviderAvailability( currentAvailability: calculateAvailabilityScore(totalGreen, totalRed), totalRequests, successRate: totalRequests > 0 ? totalGreen / totalRequests : 0, - avgLatencyMs: - sortedAllLatencies.length > 0 - ? sortedAllLatencies.reduce((a, b) => a + b, 0) / sortedAllLatencies.length - : 0, + avgLatencyMs: totalLatencyCount > 0 ? totalLatencySumMs / totalLatencyCount : 0, lastRequestAt: lastRequestAtTime > 0 ? new Date(lastRequestAtTime).toISOString() : null, timeBuckets, }); } // Calculate system-wide availability - const totalSystemRequests = providerSummaries.reduce((sum, p) => sum + p.totalRequests, 0); + const totalSystemRequests = providerSummaries.reduce( + (sum, provider) => sum + provider.totalRequests, + 0 + ); const weightedSystemAvailability = totalSystemRequests > 0 - ? providerSummaries.reduce((sum, p) => sum + p.currentAvailability * p.totalRequests, 0) / - totalSystemRequests + ? providerSummaries.reduce( + (sum, provider) => sum + provider.currentAvailability * provider.totalRequests, + 0 + ) / totalSystemRequests : 0; return { @@ -373,31 +434,26 @@ export async function getCurrentProviderStatus(): Promise< return []; } - const providerIdList = providerList.map((p) => p.id); - - // Query recent requests - const requests = await db - .select({ - providerId: messageRequest.providerId, - statusCode: messageRequest.statusCode, - durationMs: messageRequest.durationMs, - createdAt: messageRequest.createdAt, - }) - .from(messageRequest) - .where( - and( - inArray(messageRequest.providerId, providerIdList), - gte(messageRequest.createdAt, fifteenMinutesAgo), - isNull(messageRequest.deletedAt), - // 可用性分类必须等最终 statusCode 落库: - // 1. sync 写路径会先写 durationMs,再写 statusCode,durationMs-only 仍属于持久化中间态; - // 2. Gemini passthrough 等路径又可能先拿到 statusCode,而 durationMs 暂未回填。 - buildAvailabilityFinalizedCondition() - ) - ) - .orderBy(desc(messageRequest.createdAt)); + const providerIdList = providerList.map((provider) => provider.id); + const requestConditions = buildAvailabilityRequestConditions({ + providerIds: providerIdList, + startDate: fifteenMinutesAgo, + }); - // Aggregate by provider + const aggregateQuery = sql` + SELECT + ${messageRequest.providerId} AS "providerId", + COUNT(*) FILTER (WHERE ${messageRequest.statusCode} >= 200 AND ${messageRequest.statusCode} < 400)::int AS "greenCount", + COUNT(*) FILTER (WHERE ${messageRequest.statusCode} < 200 OR ${messageRequest.statusCode} >= 400)::int AS "redCount", + MAX(${messageRequest.createdAt}) AS "lastRequestAt" + FROM ${messageRequest} + WHERE ${requestConditions} + GROUP BY ${messageRequest.providerId} + `; + + const aggregateRows = Array.from( + await db.execute(aggregateQuery) + ) as AggregatedCurrentProviderStatusRow[]; const providerStats = new Map< number, { @@ -415,24 +471,12 @@ export async function getCurrentProviderStatus(): Promise< }); } - for (const req of requests) { - // 防御性兜底:即使查询条件被未来改动,仍不把仅有 durationMs 的中间态误计为失败。 - if (!hasAvailabilityFinalizedStatusCode(req)) continue; - - const stats = providerStats.get(req.providerId); - if (!stats) continue; - - const classification = classifyRequestStatus(req.statusCode); - - if (classification.status === "green") { - stats.greenCount++; - } else { - stats.redCount++; - } - - if (!stats.lastRequestAt && req.createdAt) { - stats.lastRequestAt = req.createdAt.toISOString(); - } + for (const row of aggregateRows) { + providerStats.set(row.providerId, { + greenCount: toFiniteNumber(row.greenCount), + redCount: toFiniteNumber(row.redCount), + lastRequestAt: toIsoString(row.lastRequestAt), + }); } return providerList.map((provider) => { diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index 27f3bbeb1..84ee263f7 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -function createThenableQuery(result: T, whereArgs?: unknown[]) { +function createThenableQuery(result: T) { const query: { from: ReturnType; where: ReturnType; @@ -12,27 +12,58 @@ function createThenableQuery(result: T, whereArgs?: unknown[]) { } & Promise = Promise.resolve(result) as never; query.from = vi.fn(() => query); - query.where = vi.fn((arg: unknown) => { - whereArgs?.push(arg); - return query; - }); + query.where = vi.fn(() => query); query.orderBy = vi.fn(() => query); query.limit = vi.fn(() => query); return query; } -function mockLogger() { - vi.doMock("@/lib/logger", () => ({ - logger: { - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - fatal: vi.fn(), - }, - })); +function sqlToString(sqlObject: unknown): string { + const visited = new Set(); + + const walk = (node: unknown): string => { + if (node === null || node === undefined || visited.has(node)) return ""; + + if (typeof node === "string") return node; + if (typeof node === "number" || typeof node === "boolean" || typeof node === "bigint") { + return String(node); + } + + if (typeof node === "object") { + visited.add(node); + + const anyNode = node as { + name?: unknown; + value?: unknown; + queryChunks?: unknown[]; + }; + + if (Array.isArray(anyNode)) { + return anyNode.map(walk).join(""); + } + + if (typeof anyNode.name === "string") { + return anyNode.name; + } + + if (Array.isArray(anyNode.value)) { + return anyNode.value.map(walk).join(""); + } + + if (typeof anyNode.value === "string") { + return anyNode.value; + } + + if (anyNode.queryChunks) { + return walk(anyNode.queryChunks); + } + } + + return ""; + }; + + return walk(sqlObject); } describe("availability-service", () => { @@ -45,9 +76,9 @@ describe("availability-service", () => { vi.doMock("@/drizzle/db", () => ({ db: { select: vi.fn(), + execute: vi.fn(), }, })); - mockLogger(); const { classifyRequestStatus } = await import("@/lib/availability/availability-service"); @@ -58,8 +89,8 @@ describe("availability-service", () => { }); }); - it("queryProviderAvailability 只统计已获得最终状态码的请求", async () => { - const selectQueue = [ + it("queryProviderAvailability 改为数据库聚合后仍只统计终态请求", async () => { + const selectMock = vi.fn(() => createThenableQuery([ { id: 1, @@ -67,60 +98,31 @@ describe("availability-service", () => { providerType: "claude", enabled: true, }, - ]), - createThenableQuery([ - { - id: 100, - providerId: 1, - statusCode: null, - durationMs: null, - errorMessage: null, - createdAt: new Date("2026-04-13T08:00:00.000Z"), - }, - { - id: 101, - providerId: 1, - statusCode: 200, - durationMs: null, - errorMessage: null, - createdAt: new Date("2026-04-13T08:01:00.000Z"), - }, - { - id: 102, - providerId: 1, - statusCode: 500, - durationMs: 240, - errorMessage: "HTTP 500", - createdAt: new Date("2026-04-13T08:02:00.000Z"), - }, - { - id: 103, - providerId: 1, - statusCode: 200, - durationMs: 120, - errorMessage: null, - createdAt: new Date("2026-04-13T08:03:00.000Z"), - }, - { - id: 104, - providerId: 1, - statusCode: null, - durationMs: 360, - errorMessage: "TIMEOUT", - createdAt: new Date("2026-04-13T08:04:00.000Z"), - }, - ]), - ]; - - const fallbackQuery = createThenableQuery([]); - const selectMock = vi.fn(() => selectQueue.shift() ?? fallbackQuery); + ]) + ); + const executeMock = vi.fn(async () => [ + { + providerId: 1, + bucketStart: new Date("2026-04-13T08:00:00.000Z"), + totalRequests: 3, + greenCount: 2, + redCount: 1, + latencyCount: 2, + latencySumMs: 360, + avgLatencyMs: 180, + p50LatencyMs: 120, + p95LatencyMs: 240, + p99LatencyMs: 240, + lastRequestAt: new Date("2026-04-13T08:03:00.000Z"), + }, + ]); vi.doMock("@/drizzle/db", () => ({ db: { select: selectMock, + execute: executeMock, }, })); - mockLogger(); const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); const result = await queryProviderAvailability({ @@ -129,7 +131,8 @@ describe("availability-service", () => { bucketSizeMinutes: 60, }); - expect(selectMock).toHaveBeenCalledTimes(2); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).toHaveBeenCalledTimes(1); expect(result.providers).toHaveLength(1); expect(result.providers[0]).toMatchObject({ providerId: 1, @@ -137,6 +140,7 @@ describe("availability-service", () => { currentAvailability: 2 / 3, successRate: 2 / 3, currentStatus: "green", + avgLatencyMs: 180, lastRequestAt: "2026-04-13T08:03:00.000Z", }); expect(result.providers[0]?.timeBuckets).toHaveLength(1); @@ -145,59 +149,94 @@ describe("availability-service", () => { greenCount: 2, redCount: 1, availabilityScore: 2 / 3, + avgLatencyMs: 180, + p50LatencyMs: 120, + p95LatencyMs: 240, + p99LatencyMs: 240, }); + + const queryText = sqlToString(executeMock.mock.calls[0]?.[0]).toLowerCase(); + expect(queryText).toContain("statuscode"); + expect(queryText).toContain("is not null"); + expect(queryText).toContain("group by"); + expect(queryText).toContain("percentile_cont(0.95)"); + expect(queryText).toContain("row_number() over"); }); - it("getCurrentProviderStatus 只统计已获得最终状态码的请求", async () => { - const selectQueue = [ + it("queryProviderAvailability 在无聚合数据时仍返回 unknown 提供商状态", async () => { + const selectMock = vi.fn(() => createThenableQuery([ { id: 1, name: "Provider A", + providerType: "claude", + enabled: true, }, - ]), + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + const result = await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 60, + }); + + expect(result.providers).toEqual([ + { + providerId: 1, + providerName: "Provider A", + providerType: "claude", + isEnabled: true, + currentStatus: "unknown", + currentAvailability: 0, + totalRequests: 0, + successRate: 0, + avgLatencyMs: 0, + lastRequestAt: null, + timeBuckets: [], + }, + ]); + }); + + it("getCurrentProviderStatus 改为数据库聚合后仍只统计终态请求", async () => { + const selectMock = vi.fn(() => createThenableQuery([ { - providerId: 1, - statusCode: null, - durationMs: null, - createdAt: new Date("2026-04-13T08:03:00.000Z"), - }, - { - providerId: 1, - statusCode: 503, - durationMs: null, - createdAt: new Date("2026-04-13T08:02:00.000Z"), - }, - { - providerId: 1, - statusCode: 200, - durationMs: 120, - createdAt: new Date("2026-04-13T08:01:00.000Z"), - }, - { - providerId: 1, - statusCode: null, - durationMs: 360, - createdAt: new Date("2026-04-13T08:04:00.000Z"), + id: 1, + name: "Provider A", }, - ]), - ]; - - const fallbackQuery = createThenableQuery([]); - const selectMock = vi.fn(() => selectQueue.shift() ?? fallbackQuery); + ]) + ); + const executeMock = vi.fn(async () => [ + { + providerId: 1, + greenCount: 1, + redCount: 1, + lastRequestAt: new Date("2026-04-13T08:02:00.000Z"), + }, + ]); vi.doMock("@/drizzle/db", () => ({ db: { select: selectMock, + execute: executeMock, }, })); - mockLogger(); const { getCurrentProviderStatus } = await import("@/lib/availability/availability-service"); const result = await getCurrentProviderStatus(); - expect(selectMock).toHaveBeenCalledTimes(2); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).toHaveBeenCalledTimes(1); expect(result).toEqual([ { providerId: 1, @@ -208,5 +247,11 @@ describe("availability-service", () => { lastRequestAt: "2026-04-13T08:02:00.000Z", }, ]); + + const queryText = sqlToString(executeMock.mock.calls[0]?.[0]).toLowerCase(); + expect(queryText).toContain("status_code"); + expect(queryText).toContain("is not null"); + expect(queryText).toContain("count(*) filter"); + expect(queryText).toContain("max("); }); }); From 759d63f9d2af4ca8b7e8fab08684e0e25c8c9d7d Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 00:13:32 +0800 Subject: [PATCH 06/28] =?UTF-8?q?chore:=20=E8=A7=A6=E5=8F=91=20CI=20?= =?UTF-8?q?=E9=87=8D=E8=B7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 42d1d6753bef57fc80e3cfb65079b64eada1daa8 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 00:51:15 +0800 Subject: [PATCH 07/28] =?UTF-8?q?test:=20=E6=94=B6=E7=B4=A7=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E7=BB=88=E6=80=81=E6=9F=A5=E8=AF=A2=E6=96=AD?= =?UTF-8?q?=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 35 ++-- tests/unit/lib/availability-service.test.ts | 174 ++++++++++++++----- 2 files changed, 147 insertions(+), 62 deletions(-) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 1f89b296e..5a5070830 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -19,7 +19,6 @@ import type { type AggregatedAvailabilityBucketRow = { providerId: number; bucketStart: Date; - totalRequests: number; greenCount: number; redCount: number; latencyCount: number; @@ -38,6 +37,12 @@ type AggregatedCurrentProviderStatusRow = { lastRequestAt: Date | null; }; +/** + * 当前版本把“已终态”收敛为 `statusCode` 已落库。 + * + * 已知限制:如果未来出现 `durationMs` / `errorMessage` 已落库、但 `statusCode` 仍为空且已稳定结束的写路径, + * 这些记录会被当前可用性统计排除。届时应引入独立的 finalized 谓词,而不是直接放宽为 `durationMs IS NOT NULL`。 + */ function buildAvailabilityFinalizedCondition() { return isNotNull(messageRequest.statusCode); } @@ -227,7 +232,6 @@ export async function queryProviderAvailability( SELECT "providerId", "bucketStart", - COUNT(*)::int AS "totalRequests", COUNT(*) FILTER (WHERE "statusCode" >= 200 AND "statusCode" < 400)::int AS "greenCount", COUNT(*) FILTER (WHERE "statusCode" < 200 OR "statusCode" >= 400)::int AS "redCount", COUNT("durationMs")::int AS "latencyCount", @@ -267,7 +271,6 @@ export async function queryProviderAvailability( SELECT "providerId", "bucketStart", - "totalRequests", "greenCount", "redCount", "latencyCount", @@ -286,7 +289,6 @@ export async function queryProviderAvailability( SELECT "providerId", "bucketStart", - "totalRequests", "greenCount", "redCount", "latencyCount", @@ -325,10 +327,16 @@ export async function queryProviderAvailability( let lastRequestAtTime = 0; for (const bucket of bucketRowsForProvider) { - totalGreen += toFiniteNumber(bucket.greenCount); - totalRed += toFiniteNumber(bucket.redCount); - totalLatencyCount += toFiniteNumber(bucket.latencyCount); - totalLatencySumMs += toFiniteNumber(bucket.latencySumMs); + const greenCount = toFiniteNumber(bucket.greenCount); + const redCount = toFiniteNumber(bucket.redCount); + const totalRequests = greenCount + redCount; + const latencyCount = toFiniteNumber(bucket.latencyCount); + const latencySumMs = toFiniteNumber(bucket.latencySumMs); + + totalGreen += greenCount; + totalRed += redCount; + totalLatencyCount += latencyCount; + totalLatencySumMs += latencySumMs; lastRequestAtTime = Math.max(lastRequestAtTime, getTimeValue(bucket.lastRequestAt)); const bucketStart = new Date(bucket.bucketStart); @@ -337,13 +345,10 @@ export async function queryProviderAvailability( timeBuckets.push({ bucketStart: bucketStart.toISOString(), bucketEnd: bucketEnd.toISOString(), - totalRequests: toFiniteNumber(bucket.totalRequests), - greenCount: toFiniteNumber(bucket.greenCount), - redCount: toFiniteNumber(bucket.redCount), - availabilityScore: calculateAvailabilityScore( - toFiniteNumber(bucket.greenCount), - toFiniteNumber(bucket.redCount) - ), + totalRequests, + greenCount, + redCount, + availabilityScore: calculateAvailabilityScore(greenCount, redCount), avgLatencyMs: toFiniteNumber(bucket.avgLatencyMs), p50LatencyMs: toFiniteNumber(bucket.p50LatencyMs), p95LatencyMs: toFiniteNumber(bucket.p95LatencyMs), diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index 84ee263f7..65e41272b 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -1,3 +1,5 @@ +import type { SQL } from "drizzle-orm"; +import { CasingCache } from "drizzle-orm/casing"; import { beforeEach, describe, expect, it, vi } from "vitest"; function createThenableQuery(result: T) { @@ -20,50 +22,28 @@ function createThenableQuery(result: T) { } function sqlToString(sqlObject: unknown): string { - const visited = new Set(); - - const walk = (node: unknown): string => { - if (node === null || node === undefined || visited.has(node)) return ""; - - if (typeof node === "string") return node; - if (typeof node === "number" || typeof node === "boolean" || typeof node === "bigint") { - return String(node); - } - - if (typeof node === "object") { - visited.add(node); - - const anyNode = node as { - name?: unknown; - value?: unknown; - queryChunks?: unknown[]; - }; - - if (Array.isArray(anyNode)) { - return anyNode.map(walk).join(""); - } - - if (typeof anyNode.name === "string") { - return anyNode.name; - } - - if (Array.isArray(anyNode.value)) { - return anyNode.value.map(walk).join(""); - } + return (sqlObject as SQL).toQuery({ + escapeName: (name: string) => `"${name}"`, + escapeParam: (num: number, _value: unknown) => `$${num}`, + escapeString: (value: string) => `'${value}'`, + casing: new CasingCache(), + paramStartIndex: { value: 1 }, + }).sql; +} - if (typeof anyNode.value === "string") { - return anyNode.value; - } +function normalizeSql(sqlObject: unknown): string { + return sqlToString(sqlObject).replace(/\s+/g, " ").trim().toLowerCase(); +} - if (anyNode.queryChunks) { - return walk(anyNode.queryChunks); - } - } +function extractFinalizedRequestsSql(queryText: string): string { + const start = queryText.indexOf("finalized_requests as"); + const end = queryText.indexOf("provider_bucket_stats as"); - return ""; - }; + if (start === -1 || end === -1 || end <= start) { + return queryText; + } - return walk(sqlObject); + return queryText.slice(start, end); } describe("availability-service", () => { @@ -104,7 +84,6 @@ describe("availability-service", () => { { providerId: 1, bucketStart: new Date("2026-04-13T08:00:00.000Z"), - totalRequests: 3, greenCount: 2, redCount: 1, latencyCount: 2, @@ -155,14 +134,116 @@ describe("availability-service", () => { p99LatencyMs: 240, }); - const queryText = sqlToString(executeMock.mock.calls[0]?.[0]).toLowerCase(); - expect(queryText).toContain("statuscode"); - expect(queryText).toContain("is not null"); + const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); + const finalizedRequestsSql = extractFinalizedRequestsSql(queryText); + expect(finalizedRequestsSql).toMatch(/where .*status_?code.*is not null/); expect(queryText).toContain("group by"); expect(queryText).toContain("percentile_cont(0.95)"); expect(queryText).toContain("row_number() over"); }); + it("queryProviderAvailability 会排除进行中请求(statusCode=null 且 durationMs=null)", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 60, + }); + + const finalizedRequestsSql = extractFinalizedRequestsSql( + normalizeSql(executeMock.mock.calls[0]?.[0]) + ); + expect(finalizedRequestsSql).toMatch(/where .*status_?code.*is not null/); + }); + + it("queryProviderAvailability 会保留 Gemini passthrough 终态(statusCode!=null 且 durationMs=null)", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 60, + }); + + const finalizedRequestsSql = extractFinalizedRequestsSql( + normalizeSql(executeMock.mock.calls[0]?.[0]) + ); + expect(finalizedRequestsSql).not.toMatch(/where .*duration_?ms.*is not null/); + }); + + it("queryProviderAvailability 当前不会把中间持久化状态(statusCode=null 且 durationMs!=null)误算为 red", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 60, + }); + + const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); + const finalizedRequestsSql = extractFinalizedRequestsSql(queryText); + + expect(finalizedRequestsSql).toMatch(/where .*status_?code.*is not null/); + expect(queryText).toMatch( + /count\(\*\) filter \(where .*status_?code.*< 200 .*or .*status_?code.*>= 400\)/ + ); + }); + it("queryProviderAvailability 在无聚合数据时仍返回 unknown 提供商状态", async () => { const selectMock = vi.fn(() => createThenableQuery([ @@ -248,9 +329,8 @@ describe("availability-service", () => { }, ]); - const queryText = sqlToString(executeMock.mock.calls[0]?.[0]).toLowerCase(); - expect(queryText).toContain("status_code"); - expect(queryText).toContain("is not null"); + const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); + expect(queryText).toMatch(/where .*status_?code.*is not null/); expect(queryText).toContain("count(*) filter"); expect(queryText).toContain("max("); }); From 04d9ac1d1fc442cd612662628e50678e9376b065 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 01:21:34 +0800 Subject: [PATCH 08/28] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E6=9F=A5=E8=AF=A2=E5=8F=82=E6=95=B0=E5=81=A5?= =?UTF-8?q?=E5=A3=AE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 146 +++++++++++-------- tests/unit/lib/availability-service.test.ts | 111 +++++++++++++- 2 files changed, 195 insertions(+), 62 deletions(-) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 5a5070830..2934cf4a5 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -37,6 +37,9 @@ type AggregatedCurrentProviderStatusRow = { lastRequestAt: Date | null; }; +const MIN_BUCKET_SIZE_MINUTES = 0.25; +const DEFAULT_MAX_BUCKETS = 100; + /** * 当前版本把“已终态”收敛为 `statusCode` 已落库。 * @@ -47,12 +50,32 @@ function buildAvailabilityFinalizedCondition() { return isNotNull(messageRequest.statusCode); } -function buildTimestampLowerBound(column: typeof messageRequest.createdAt, date: Date) { - return sql`${column} >= CAST(${date.toISOString()} AS timestamptz)`; +function assertValidDate(date: Date, fieldName: string): Date { + if (!Number.isFinite(date.getTime())) { + throw new Error(`Invalid ${fieldName}: expected a valid Date or ISO timestamp`); + } + + return date; +} + +function parseAvailabilityDate(value: Date | string, fieldName: string): Date { + return assertValidDate(typeof value === "string" ? new Date(value) : value, fieldName); +} + +function buildTimestampLowerBound( + column: typeof messageRequest.createdAt, + date: Date, + fieldName: string +) { + return sql`${column} >= CAST(${assertValidDate(date, fieldName).toISOString()} AS timestamptz)`; } -function buildTimestampUpperBound(column: typeof messageRequest.createdAt, date: Date) { - return sql`${column} <= CAST(${date.toISOString()} AS timestamptz)`; +function buildTimestampUpperBound( + column: typeof messageRequest.createdAt, + date: Date, + fieldName: string +) { + return sql`${column} <= CAST(${assertValidDate(date, fieldName).toISOString()} AS timestamptz)`; } function buildAvailabilityRequestConditions(input: { @@ -62,13 +85,13 @@ function buildAvailabilityRequestConditions(input: { }) { const conditions = [ inArray(messageRequest.providerId, input.providerIds), - buildTimestampLowerBound(messageRequest.createdAt, input.startDate), + buildTimestampLowerBound(messageRequest.createdAt, input.startDate, "startTime"), isNull(messageRequest.deletedAt), buildAvailabilityFinalizedCondition(), ]; if (input.endDate) { - conditions.push(buildTimestampUpperBound(messageRequest.createdAt, input.endDate)); + conditions.push(buildTimestampUpperBound(messageRequest.createdAt, input.endDate, "endTime")); } return and(...conditions); @@ -148,6 +171,33 @@ export function determineOptimalBucketSize( return 1440; // Default to daily for very long ranges } +function sanitizeBucketSizeMinutes( + explicitBucketSize: number | undefined, + timeRangeMinutes: number +): number { + const fallbackBucketSize = determineOptimalBucketSize(0, timeRangeMinutes); + const safeFallbackBucketSize = + Number.isFinite(fallbackBucketSize) && fallbackBucketSize > 0 ? fallbackBucketSize : 60; + + if ( + typeof explicitBucketSize !== "number" || + !Number.isFinite(explicitBucketSize) || + explicitBucketSize <= 0 + ) { + return Math.max(MIN_BUCKET_SIZE_MINUTES, safeFallbackBucketSize); + } + + return Math.max(MIN_BUCKET_SIZE_MINUTES, explicitBucketSize); +} + +function sanitizeMaxBuckets(maxBuckets: number | undefined): number { + if (typeof maxBuckets !== "number" || !Number.isFinite(maxBuckets) || maxBuckets <= 0) { + return DEFAULT_MAX_BUCKETS; + } + + return Math.max(1, Math.floor(maxBuckets)); +} + /** * Query availability data for providers */ @@ -164,9 +214,13 @@ export async function queryProviderAvailability( maxBuckets = 100, } = options; - const startDate = typeof startTime === "string" ? new Date(startTime) : startTime; - const endDate = typeof endTime === "string" ? new Date(endTime) : endTime; + const startDate = parseAvailabilityDate(startTime, "startTime"); + const endDate = parseAvailabilityDate(endTime, "endTime"); const timeRangeMinutes = (endDate.getTime() - startDate.getTime()) / (1000 * 60); + const bucketSizeMinutes = sanitizeBucketSizeMinutes(explicitBucketSize, timeRangeMinutes); + const bucketSizeMs = bucketSizeMinutes * 60 * 1000; + const bucketSizeSeconds = bucketSizeMinutes * 60; + const sanitizedMaxBuckets = sanitizeMaxBuckets(maxBuckets); // Get provider list const providerConditions = [isNull(providers.deletedAt)]; @@ -192,28 +246,18 @@ export async function queryProviderAvailability( queriedAt: now.toISOString(), startTime: startDate.toISOString(), endTime: endDate.toISOString(), - bucketSizeMinutes: explicitBucketSize ?? 60, + bucketSizeMinutes, providers: [], systemAvailability: 0, }; } const providerIdList = providerList.map((provider) => provider.id); - - // Ensure minimum bucket size of 0.25 minutes (15 seconds) to prevent division by zero - // Handle NaN case (nullish coalescing doesn't catch NaN from invalid parseFloat input) - const rawBucketSize = explicitBucketSize ?? determineOptimalBucketSize(0, timeRangeMinutes); - const bucketSizeMinutes = Number.isNaN(rawBucketSize) - ? determineOptimalBucketSize(0, timeRangeMinutes) - : Math.max(0.25, rawBucketSize); - const bucketSizeMs = bucketSizeMinutes * 60 * 1000; - const bucketSizeSeconds = bucketSizeMinutes * 60; const requestConditions = buildAvailabilityRequestConditions({ providerIds: providerIdList, startDate, endDate, }); - const shouldLimitBuckets = Number.isFinite(maxBuckets) && maxBuckets > 0; const availabilityAggregationCtes = sql` finalized_requests AS ( @@ -258,49 +302,31 @@ export async function queryProviderAvailability( ) `; - const bucketQuery = shouldLimitBuckets - ? sql` - WITH - ${availabilityAggregationCtes}, - limited_provider_bucket_stats AS ( - SELECT - *, - ROW_NUMBER() OVER (PARTITION BY "providerId" ORDER BY "bucketStart" DESC) AS rn - FROM provider_bucket_stats - ) + const bucketQuery = sql` + WITH + ${availabilityAggregationCtes}, + limited_provider_bucket_stats AS ( SELECT - "providerId", - "bucketStart", - "greenCount", - "redCount", - "latencyCount", - "latencySumMs", - "avgLatencyMs", - "p50LatencyMs", - "p95LatencyMs", - "p99LatencyMs", - "lastRequestAt" - FROM limited_provider_bucket_stats - WHERE rn <= ${Math.floor(maxBuckets)} - ORDER BY "providerId" ASC, "bucketStart" ASC - ` - : sql` - WITH ${availabilityAggregationCtes} - SELECT - "providerId", - "bucketStart", - "greenCount", - "redCount", - "latencyCount", - "latencySumMs", - "avgLatencyMs", - "p50LatencyMs", - "p95LatencyMs", - "p99LatencyMs", - "lastRequestAt" + *, + ROW_NUMBER() OVER (PARTITION BY "providerId" ORDER BY "bucketStart" DESC) AS rn FROM provider_bucket_stats - ORDER BY "providerId" ASC, "bucketStart" ASC - `; + ) + SELECT + "providerId", + "bucketStart", + "greenCount", + "redCount", + "latencyCount", + "latencySumMs", + "avgLatencyMs", + "p50LatencyMs", + "p95LatencyMs", + "p99LatencyMs", + "lastRequestAt" + FROM limited_provider_bucket_stats + WHERE rn <= ${sanitizedMaxBuckets} + ORDER BY "providerId" ASC, "bucketStart" ASC + `; const bucketRows = Array.from(await db.execute(bucketQuery)) as AggregatedAvailabilityBucketRow[]; const providerBuckets = new Map(); diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index 65e41272b..64ab08dd7 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -21,14 +21,18 @@ function createThenableQuery(result: T) { return query; } -function sqlToString(sqlObject: unknown): string { +function sqlToQuery(sqlObject: unknown) { return (sqlObject as SQL).toQuery({ escapeName: (name: string) => `"${name}"`, escapeParam: (num: number, _value: unknown) => `$${num}`, escapeString: (value: string) => `'${value}'`, casing: new CasingCache(), paramStartIndex: { value: 1 }, - }).sql; + }); +} + +function sqlToString(sqlObject: unknown): string { + return sqlToQuery(sqlObject).sql; } function normalizeSql(sqlObject: unknown): string { @@ -69,6 +73,35 @@ describe("availability-service", () => { }); }); + it("queryProviderAvailability 在非法时间参数时抛出明确错误且不访问数据库", async () => { + const selectMock = vi.fn(() => createThenableQuery([])); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + + await expect( + queryProviderAvailability({ + startTime: "invalid-start-time", + }) + ).rejects.toThrow("Invalid startTime"); + + await expect( + queryProviderAvailability({ + endTime: new Date("invalid-end-time"), + }) + ).rejects.toThrow("Invalid endTime"); + + expect(selectMock).not.toHaveBeenCalled(); + expect(executeMock).not.toHaveBeenCalled(); + }); + it("queryProviderAvailability 改为数据库聚合后仍只统计终态请求", async () => { const selectMock = vi.fn(() => createThenableQuery([ @@ -142,6 +175,42 @@ describe("availability-service", () => { expect(queryText).toContain("row_number() over"); }); + it("queryProviderAvailability 在 bucketSizeMinutes 为 Infinity 时回退到自动分桶", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + const result = await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: Number.POSITIVE_INFINITY, + }); + + const query = sqlToQuery(executeMock.mock.calls[0]?.[0]); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).toHaveBeenCalledTimes(1); + expect(result.bucketSizeMinutes).toBe(5); + expect(query.params).toContain(300); + expect(query.params).not.toContain(Number.POSITIVE_INFINITY); + }); + it("queryProviderAvailability 会排除进行中请求(statusCode=null 且 durationMs=null)", async () => { const selectMock = vi.fn(() => createThenableQuery([ @@ -244,6 +313,44 @@ describe("availability-service", () => { ); }); + it("queryProviderAvailability 在 maxBuckets 为 Infinity 时仍使用默认桶上限", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 60, + maxBuckets: Number.POSITIVE_INFINITY, + }); + + const query = sqlToQuery(executeMock.mock.calls[0]?.[0]); + const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).toHaveBeenCalledTimes(1); + expect(queryText).toContain("row_number() over"); + expect(queryText).toContain("where rn <="); + expect(query.params.at(-1)).toBe(100); + }); + it("queryProviderAvailability 在无聚合数据时仍返回 unknown 提供商状态", async () => { const selectMock = vi.fn(() => createThenableQuery([ From d58d9dfa0c0f84daaf424af29152b9dd6eb362c2 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 01:57:38 +0800 Subject: [PATCH 09/28] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E6=8E=A5=E5=8F=A3=E5=8F=82=E6=95=B0=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/availability/route.ts | 92 ++++++++-- src/lib/availability/availability-service.ts | 20 ++- src/lib/availability/index.ts | 8 +- src/lib/availability/types.ts | 4 +- tests/unit/api/availability-route.test.ts | 166 +++++++++++++++++++ tests/unit/lib/availability-service.test.ts | 24 +++ 6 files changed, 291 insertions(+), 23 deletions(-) create mode 100644 tests/unit/api/availability-route.test.ts diff --git a/src/app/api/availability/route.ts b/src/app/api/availability/route.ts index a889ac1a2..925c4ec82 100644 --- a/src/app/api/availability/route.ts +++ b/src/app/api/availability/route.ts @@ -13,7 +13,54 @@ import { type NextRequest, NextResponse } from "next/server"; import { getSession } from "@/lib/auth"; -import { type AvailabilityQueryOptions, queryProviderAvailability } from "@/lib/availability"; +import { + type AvailabilityQueryOptions, + AvailabilityQueryValidationError, + queryProviderAvailability, +} from "@/lib/availability"; + +function parseBooleanQueryParam(value: string, fieldName: string): boolean { + if (value === "true") return true; + if (value === "false") return false; + + throw new AvailabilityQueryValidationError(`Invalid ${fieldName}: expected true or false`); +} + +function parsePositiveIntegerQueryParam(value: string, fieldName: string): number { + const normalizedValue = value.trim(); + if (!/^\d+$/.test(normalizedValue)) { + throw new AvailabilityQueryValidationError(`Invalid ${fieldName}: expected a positive integer`); + } + + const parsed = Number(normalizedValue); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new AvailabilityQueryValidationError(`Invalid ${fieldName}: expected a positive integer`); + } + + return parsed; +} + +function parsePositiveNumberQueryParam(value: string, fieldName: string): number { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new AvailabilityQueryValidationError(`Invalid ${fieldName}: expected a positive number`); + } + + return parsed; +} + +function parseProviderIdsQueryParam(value: string): number[] { + const tokens = value.split(",").map((token) => token.trim()); + if (tokens.length === 0 || tokens.some((token) => token.length === 0)) { + throw new AvailabilityQueryValidationError( + "Invalid providerIds: expected comma-separated positive integers" + ); + } + + const providerIds = tokens.map((token) => parsePositiveIntegerQueryParam(token, "providerIds")); + + return Array.from(new Set(providerIds)); +} /** * GET /api/availability @@ -32,45 +79,56 @@ export async function GET(request: NextRequest) { const options: AvailabilityQueryOptions = {}; const startTime = searchParams.get("startTime"); - if (startTime) { + if (startTime !== null) { + if (!startTime.trim()) { + throw new AvailabilityQueryValidationError( + "Invalid startTime: expected a valid Date or ISO timestamp" + ); + } options.startTime = startTime; } const endTime = searchParams.get("endTime"); - if (endTime) { + if (endTime !== null) { + if (!endTime.trim()) { + throw new AvailabilityQueryValidationError( + "Invalid endTime: expected a valid Date or ISO timestamp" + ); + } options.endTime = endTime; } const providerIds = searchParams.get("providerIds"); - if (providerIds) { - options.providerIds = providerIds - .split(",") - .map((id) => parseInt(id.trim(), 10)) - .filter((id) => !Number.isNaN(id)); + if (providerIds !== null) { + options.providerIds = parseProviderIdsQueryParam(providerIds); } const bucketSizeMinutes = searchParams.get("bucketSizeMinutes"); - if (bucketSizeMinutes) { - // Use parseFloat to support sub-minute bucket sizes (e.g., 0.25 for 15 seconds) - const parsed = parseFloat(bucketSizeMinutes); - // Ensure bucket size is valid and at least 0.25 minutes (15 seconds) to prevent division by zero - options.bucketSizeMinutes = Number.isNaN(parsed) ? 0.25 : Math.max(0.25, parsed); + if (bucketSizeMinutes !== null) { + options.bucketSizeMinutes = parsePositiveNumberQueryParam( + bucketSizeMinutes, + "bucketSizeMinutes" + ); } const includeDisabled = searchParams.get("includeDisabled"); - if (includeDisabled) { - options.includeDisabled = includeDisabled === "true"; + if (includeDisabled !== null) { + options.includeDisabled = parseBooleanQueryParam(includeDisabled, "includeDisabled"); } const maxBuckets = searchParams.get("maxBuckets"); - if (maxBuckets) { - options.maxBuckets = parseInt(maxBuckets, 10); + if (maxBuckets !== null) { + options.maxBuckets = parsePositiveIntegerQueryParam(maxBuckets, "maxBuckets"); } const result = await queryProviderAvailability(options); return NextResponse.json(result); } catch (error) { + if (error instanceof AvailabilityQueryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("Availability API error:", error); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 2934cf4a5..779132bd6 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -40,6 +40,13 @@ type AggregatedCurrentProviderStatusRow = { const MIN_BUCKET_SIZE_MINUTES = 0.25; const DEFAULT_MAX_BUCKETS = 100; +export class AvailabilityQueryValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "AvailabilityQueryValidationError"; + } +} + /** * 当前版本把“已终态”收敛为 `statusCode` 已落库。 * @@ -52,7 +59,9 @@ function buildAvailabilityFinalizedCondition() { function assertValidDate(date: Date, fieldName: string): Date { if (!Number.isFinite(date.getTime())) { - throw new Error(`Invalid ${fieldName}: expected a valid Date or ISO timestamp`); + throw new AvailabilityQueryValidationError( + `Invalid ${fieldName}: expected a valid Date or ISO timestamp` + ); } return date; @@ -198,6 +207,14 @@ function sanitizeMaxBuckets(maxBuckets: number | undefined): number { return Math.max(1, Math.floor(maxBuckets)); } +function validateAvailabilityTimeRange(startDate: Date, endDate: Date): void { + if (endDate.getTime() < startDate.getTime()) { + throw new AvailabilityQueryValidationError( + "Invalid time range: endTime must be greater than or equal to startTime" + ); + } +} + /** * Query availability data for providers */ @@ -216,6 +233,7 @@ export async function queryProviderAvailability( const startDate = parseAvailabilityDate(startTime, "startTime"); const endDate = parseAvailabilityDate(endTime, "endTime"); + validateAvailabilityTimeRange(startDate, endDate); const timeRangeMinutes = (endDate.getTime() - startDate.getTime()) / (1000 * 60); const bucketSizeMinutes = sanitizeBucketSizeMinutes(explicitBucketSize, timeRangeMinutes); const bucketSizeMs = bucketSizeMinutes * 60 * 1000; diff --git a/src/lib/availability/index.ts b/src/lib/availability/index.ts index afb65c59c..5400387aa 100644 --- a/src/lib/availability/index.ts +++ b/src/lib/availability/index.ts @@ -2,17 +2,19 @@ * Provider Availability Module * * This module provides availability monitoring based on request log data. - * Simple two-tier validation: success or failure. + * Availability is calculated only from finalized requests that already have a persisted + * `statusCode`. In-flight / intermediate records are excluded upstream. * - * 1. HTTP Status Check: 2xx/3xx = success (green), 4xx/5xx or error = failure (red) + * 1. HTTP Status Check: 2xx/3xx = success (green), other finalized HTTP status codes = failure (red) * * Availability scoring: * - GREEN (1.0): Successful requests (any HTTP 2xx/3xx) - * - RED (0.0): Failed requests (HTTP 4xx/5xx or network error) + * - RED (0.0): Failed finalized requests (non-2xx/3xx HTTP status codes) * - UNKNOWN: No data available */ export { + AvailabilityQueryValidationError, calculateAvailabilityScore, classifyRequestStatus, determineOptimalBucketSize, diff --git a/src/lib/availability/types.ts b/src/lib/availability/types.ts index 895c630fd..529697f1b 100644 --- a/src/lib/availability/types.ts +++ b/src/lib/availability/types.ts @@ -6,7 +6,7 @@ /** * Status values for availability calculation * - GREEN (1.0): HTTP 2xx/3xx (all successful requests) - * - RED (0.0): HTTP 4xx/5xx or error + * - RED (0.0): finalized requests with non-2xx/3xx HTTP status codes * - UNKNOWN (-1): No data available (must be displayed honestly as "no data") */ export type AvailabilityStatus = "green" | "red" | "unknown"; @@ -57,7 +57,7 @@ export interface TimeBucketMetrics { totalRequests: number; /** Successful requests (2xx/3xx) */ greenCount: number; - /** Failed requests (4xx/5xx or error) */ + /** Failed finalized requests (non-2xx/3xx status codes) */ redCount: number; /** Weighted availability score (0.0-1.0) */ availabilityScore: number; diff --git a/tests/unit/api/availability-route.test.ts b/tests/unit/api/availability-route.test.ts new file mode 100644 index 000000000..2bfeb1aba --- /dev/null +++ b/tests/unit/api/availability-route.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mockGetSession = vi.hoisted(() => vi.fn()); +const mockQueryProviderAvailability = vi.hoisted(() => vi.fn()); +const MockAvailabilityQueryValidationError = vi.hoisted( + () => + class AvailabilityQueryValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "AvailabilityQueryValidationError"; + } + } +); + +vi.mock("@/lib/auth", () => ({ + getSession: mockGetSession, +})); + +vi.mock("@/lib/availability", () => ({ + AvailabilityQueryValidationError: MockAvailabilityQueryValidationError, + queryProviderAvailability: mockQueryProviderAvailability, +})); + +function makeRequest(query = ""): NextRequest { + const suffix = query ? `?${query}` : ""; + return new NextRequest(`http://localhost/api/availability${suffix}`); +} + +describe("GET /api/availability", () => { + let GET: (request: NextRequest) => Promise; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + mockGetSession.mockResolvedValue({ + user: { + id: 1, + role: "admin", + }, + }); + mockQueryProviderAvailability.mockResolvedValue({ + queriedAt: "2026-04-13T09:00:00.000Z", + startTime: "2026-04-13T08:00:00.000Z", + endTime: "2026-04-13T09:00:00.000Z", + bucketSizeMinutes: 5, + providers: [], + systemAvailability: 0, + }); + + const mod = await import("@/app/api/availability/route"); + GET = mod.GET; + }); + + it("未认证时返回 401", async () => { + mockGetSession.mockResolvedValueOnce(null); + + const res = await GET(makeRequest()); + + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "Unauthorized" }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + + it("参数合法时将规范化后的查询参数传给 service", async () => { + const res = await GET( + makeRequest( + [ + "startTime=2026-04-13T08:00:00.000Z", + "endTime=2026-04-13T09:00:00.000Z", + "providerIds=2,1,2", + "bucketSizeMinutes=0.5", + "includeDisabled=true", + "maxBuckets=60", + ].join("&") + ) + ); + + expect(res.status).toBe(200); + expect(mockQueryProviderAvailability).toHaveBeenCalledTimes(1); + expect(mockQueryProviderAvailability).toHaveBeenCalledWith({ + startTime: "2026-04-13T08:00:00.000Z", + endTime: "2026-04-13T09:00:00.000Z", + providerIds: [2, 1], + bucketSizeMinutes: 0.5, + includeDisabled: true, + maxBuckets: 60, + }); + }); + + it("providerIds 非法时返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("providerIds=1,foo")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid providerIds: expected a positive integer", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + + it("providerIds 存在空 token 时返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("providerIds=1,,2")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid providerIds: expected comma-separated positive integers", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + + it("includeDisabled 非法时返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("includeDisabled=yes")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid includeDisabled: expected true or false", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + + it("bucketSizeMinutes 为 Infinity 时返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("bucketSizeMinutes=Infinity")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid bucketSizeMinutes: expected a positive number", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + + it("空的 startTime 参数返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("startTime=")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid startTime: expected a valid Date or ISO timestamp", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + + it("service 抛出参数校验错误时映射为 400", async () => { + mockQueryProviderAvailability.mockRejectedValueOnce( + new MockAvailabilityQueryValidationError( + "Invalid time range: endTime must be greater than or equal to startTime" + ) + ); + + const res = await GET( + makeRequest("startTime=2026-04-13T09:00:00.000Z&endTime=2026-04-13T08:00:00.000Z") + ); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid time range: endTime must be greater than or equal to startTime", + }); + }); + + it("service 抛出非校验错误时返回 500", async () => { + mockQueryProviderAvailability.mockRejectedValueOnce(new Error("db down")); + + const res = await GET(makeRequest()); + + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "Internal server error" }); + }); +}); diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index 64ab08dd7..5e9ec4ff7 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -102,6 +102,30 @@ describe("availability-service", () => { expect(executeMock).not.toHaveBeenCalled(); }); + it("queryProviderAvailability 在 endTime 早于 startTime 时抛出明确错误且不访问数据库", async () => { + const selectMock = vi.fn(() => createThenableQuery([])); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + + await expect( + queryProviderAvailability({ + startTime: new Date("2026-04-13T09:00:00.000Z"), + endTime: new Date("2026-04-13T07:00:00.000Z"), + }) + ).rejects.toThrow("Invalid time range"); + + expect(selectMock).not.toHaveBeenCalled(); + expect(executeMock).not.toHaveBeenCalled(); + }); + it("queryProviderAvailability 改为数据库聚合后仍只统计终态请求", async () => { const selectMock = vi.fn(() => createThenableQuery([ From 1397fc3be069776502f6803a8e2906bbcc866d81 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 02:29:21 +0800 Subject: [PATCH 10/28] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E8=81=9A=E5=90=88=E6=A1=B6=E6=95=B0=E9=87=8F?= =?UTF-8?q?=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/availability/route.ts | 4 +-- src/lib/availability/availability-service.ts | 4 ++- src/lib/availability/types.ts | 2 +- tests/unit/lib/availability-service.test.ts | 38 ++++++++++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/app/api/availability/route.ts b/src/app/api/availability/route.ts index 925c4ec82..58363f393 100644 --- a/src/app/api/availability/route.ts +++ b/src/app/api/availability/route.ts @@ -8,7 +8,7 @@ * - providerIds: comma-separated provider IDs (default: all) * - bucketSizeMinutes: number, time bucket size (default: auto) * - includeDisabled: boolean, include disabled providers (default: false) - * - maxBuckets: number, max time buckets (default: 100) + * - maxBuckets: number, max time buckets (default: 100, hard cap: 100) */ import { type NextRequest, NextResponse } from "next/server"; @@ -51,7 +51,7 @@ function parsePositiveNumberQueryParam(value: string, fieldName: string): number function parseProviderIdsQueryParam(value: string): number[] { const tokens = value.split(",").map((token) => token.trim()); - if (tokens.length === 0 || tokens.some((token) => token.length === 0)) { + if (tokens.some((token) => token.length === 0)) { throw new AvailabilityQueryValidationError( "Invalid providerIds: expected comma-separated positive integers" ); diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 779132bd6..62a46f6f7 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -39,6 +39,8 @@ type AggregatedCurrentProviderStatusRow = { const MIN_BUCKET_SIZE_MINUTES = 0.25; const DEFAULT_MAX_BUCKETS = 100; +// Keep per-provider bucket result sets bounded even if callers pass a huge finite value. +const MAX_BUCKETS_HARD_LIMIT = DEFAULT_MAX_BUCKETS; export class AvailabilityQueryValidationError extends Error { constructor(message: string) { @@ -204,7 +206,7 @@ function sanitizeMaxBuckets(maxBuckets: number | undefined): number { return DEFAULT_MAX_BUCKETS; } - return Math.max(1, Math.floor(maxBuckets)); + return Math.min(MAX_BUCKETS_HARD_LIMIT, Math.max(1, Math.floor(maxBuckets))); } function validateAvailabilityTimeRange(startDate: Date, endDate: Date): void { diff --git a/src/lib/availability/types.ts b/src/lib/availability/types.ts index 529697f1b..97f20d932 100644 --- a/src/lib/availability/types.ts +++ b/src/lib/availability/types.ts @@ -113,7 +113,7 @@ export interface AvailabilityQueryOptions { bucketSizeMinutes?: number; /** Whether to include disabled providers */ includeDisabled?: boolean; - /** Maximum number of time buckets to return */ + /** Maximum number of time buckets to return (hard capped at 100) */ maxBuckets?: number; } diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index 5e9ec4ff7..9c0c62950 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -375,6 +375,44 @@ describe("availability-service", () => { expect(query.params.at(-1)).toBe(100); }); + it("queryProviderAvailability 在 maxBuckets 为超大有限值时也会收紧到硬上限", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 60, + maxBuckets: Number.MAX_SAFE_INTEGER, + }); + + const query = sqlToQuery(executeMock.mock.calls[0]?.[0]); + const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).toHaveBeenCalledTimes(1); + expect(queryText).toContain("row_number() over"); + expect(queryText).toContain("where rn <="); + expect(query.params.at(-1)).toBe(100); + }); + it("queryProviderAvailability 在无聚合数据时仍返回 unknown 提供商状态", async () => { const selectMock = vi.fn(() => createThenableQuery([ From 74e32c26262f54664ca82fd7040508b479bc5ae6 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 02:50:53 +0800 Subject: [PATCH 11/28] =?UTF-8?q?fix:=20=E5=AF=B9=E9=BD=90=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E9=BB=98=E8=AE=A4=E6=A1=B6=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E5=B9=B6=E6=BE=84=E6=B8=85=E5=85=BC=E5=AE=B9=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 8 +++++--- src/lib/availability/types.ts | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 62a46f6f7..e07bd67ab 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -230,7 +230,7 @@ export async function queryProviderAvailability( providerIds = [], bucketSizeMinutes: explicitBucketSize, includeDisabled = false, - maxBuckets = 100, + maxBuckets = DEFAULT_MAX_BUCKETS, } = options; const startDate = parseAvailabilityDate(startTime, "startTime"); @@ -403,6 +403,7 @@ export async function queryProviderAvailability( } const totalRequests = totalGreen + totalRed; + const queriedWindowAvailability = calculateAvailabilityScore(totalGreen, totalRed); // Determine current status based on last few buckets // IMPORTANT: No data = 'unknown', NOT 'green'! Must be honest. @@ -423,9 +424,10 @@ export async function queryProviderAvailability( providerType: provider.providerType ?? "claude", isEnabled: provider.enabled ?? true, currentStatus, - currentAvailability: calculateAvailabilityScore(totalGreen, totalRed), + currentAvailability: queriedWindowAvailability, totalRequests, - successRate: totalRequests > 0 ? totalGreen / totalRequests : 0, + // Keep `successRate` as a compatibility alias of the queried-window availability ratio. + successRate: queriedWindowAvailability, avgLatencyMs: totalLatencyCount > 0 ? totalLatencySumMs / totalLatencyCount : 0, lastRequestAt: lastRequestAtTime > 0 ? new Date(lastRequestAtTime).toISOString() : null, timeBuckets, diff --git a/src/lib/availability/types.ts b/src/lib/availability/types.ts index 97f20d932..973b4e24b 100644 --- a/src/lib/availability/types.ts +++ b/src/lib/availability/types.ts @@ -85,11 +85,11 @@ export interface ProviderAvailabilitySummary { isEnabled: boolean; /** Current status based on recent requests */ currentStatus: AvailabilityStatus; - /** Current weighted availability (0.0-1.0) */ + /** Queried-window availability ratio (currently kept equal to successRate for compatibility) */ currentAvailability: number; /** Total request count in period */ totalRequests: number; - /** Success rate (green requests / total) */ + /** Compatibility alias of currentAvailability (green requests / total) */ successRate: number; /** Average latency in ms */ avgLatencyMs: number; From 841fa88f1dff076e0ddd283ef9f9601c0f4ad894 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 03:14:22 +0800 Subject: [PATCH 12/28] =?UTF-8?q?docs:=20=E6=BE=84=E6=B8=85=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E8=BF=94=E5=9B=9E=E6=A1=B6=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/availability/route.ts | 2 +- src/lib/availability/availability-service.ts | 8 ++++---- src/lib/availability/types.ts | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/api/availability/route.ts b/src/app/api/availability/route.ts index 58363f393..3ed6b991d 100644 --- a/src/app/api/availability/route.ts +++ b/src/app/api/availability/route.ts @@ -8,7 +8,7 @@ * - providerIds: comma-separated provider IDs (default: all) * - bucketSizeMinutes: number, time bucket size (default: auto) * - includeDisabled: boolean, include disabled providers (default: false) - * - maxBuckets: number, max time buckets (default: 100, hard cap: 100) + * - maxBuckets: number, max non-empty time buckets per provider (default: 100, hard cap: 100) */ import { type NextRequest, NextResponse } from "next/server"; diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index e07bd67ab..031bb59c1 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -403,7 +403,7 @@ export async function queryProviderAvailability( } const totalRequests = totalGreen + totalRed; - const queriedWindowAvailability = calculateAvailabilityScore(totalGreen, totalRed); + const returnedBucketAvailability = calculateAvailabilityScore(totalGreen, totalRed); // Determine current status based on last few buckets // IMPORTANT: No data = 'unknown', NOT 'green'! Must be honest. @@ -424,10 +424,10 @@ export async function queryProviderAvailability( providerType: provider.providerType ?? "claude", isEnabled: provider.enabled ?? true, currentStatus, - currentAvailability: queriedWindowAvailability, + currentAvailability: returnedBucketAvailability, totalRequests, - // Keep `successRate` as a compatibility alias of the queried-window availability ratio. - successRate: queriedWindowAvailability, + // Keep `successRate` as a compatibility alias of the returned-bucket availability ratio. + successRate: returnedBucketAvailability, avgLatencyMs: totalLatencyCount > 0 ? totalLatencySumMs / totalLatencyCount : 0, lastRequestAt: lastRequestAtTime > 0 ? new Date(lastRequestAtTime).toISOString() : null, timeBuckets, diff --git a/src/lib/availability/types.ts b/src/lib/availability/types.ts index 973b4e24b..062cb9e07 100644 --- a/src/lib/availability/types.ts +++ b/src/lib/availability/types.ts @@ -85,11 +85,11 @@ export interface ProviderAvailabilitySummary { isEnabled: boolean; /** Current status based on recent requests */ currentStatus: AvailabilityStatus; - /** Queried-window availability ratio (currently kept equal to successRate for compatibility) */ + /** Availability ratio over the returned time buckets (currently kept equal to successRate for compatibility) */ currentAvailability: number; /** Total request count in period */ totalRequests: number; - /** Compatibility alias of currentAvailability (green requests / total) */ + /** Compatibility alias of currentAvailability over the returned time buckets (green requests / total) */ successRate: number; /** Average latency in ms */ avgLatencyMs: number; @@ -113,7 +113,7 @@ export interface AvailabilityQueryOptions { bucketSizeMinutes?: number; /** Whether to include disabled providers */ includeDisabled?: boolean; - /** Maximum number of time buckets to return (hard capped at 100) */ + /** Maximum number of non-empty time buckets to return per provider (hard capped at 100) */ maxBuckets?: number; } From a28517ab5c337410af6182ba09459a15dd13c145 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 03:25:58 +0800 Subject: [PATCH 13/28] =?UTF-8?q?test:=20=E8=A1=A5=E9=BD=90=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E6=94=B6=E5=B0=BE=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 4 +-- tests/unit/lib/availability-service.test.ts | 35 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 031bb59c1..e9354197f 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -39,8 +39,8 @@ type AggregatedCurrentProviderStatusRow = { const MIN_BUCKET_SIZE_MINUTES = 0.25; const DEFAULT_MAX_BUCKETS = 100; -// Keep per-provider bucket result sets bounded even if callers pass a huge finite value. -const MAX_BUCKETS_HARD_LIMIT = DEFAULT_MAX_BUCKETS; +// Keep the hard cap independent from the UI/API default so future default tuning does not silently relax/tighten the guardrail. +const MAX_BUCKETS_HARD_LIMIT = 100; export class AvailabilityQueryValidationError extends Error { constructor(message: string) { diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index 9c0c62950..b8045f0e5 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -503,4 +503,39 @@ describe("availability-service", () => { expect(queryText).toContain("count(*) filter"); expect(queryText).toContain("max("); }); + + it("getCurrentProviderStatus 在提供商无聚合数据时返回 unknown", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { getCurrentProviderStatus } = await import("@/lib/availability/availability-service"); + const result = await getCurrentProviderStatus(); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).toHaveBeenCalledTimes(1); + expect(result).toEqual([ + { + providerId: 1, + providerName: "Provider A", + status: "unknown", + availability: 0, + requestCount: 0, + lastRequestAt: null, + }, + ]); + }); }); From 0ad81e02bdab0d2f851a607130e4798d8de75e4f Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 03:46:42 +0800 Subject: [PATCH 14/28] =?UTF-8?q?docs:=20=E6=98=8E=E7=A1=AE=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E8=BF=94=E5=9B=9E=E7=AA=97=E5=8F=A3=E8=AF=AD?= =?UTF-8?q?=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 2 +- src/lib/availability/types.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index e9354197f..df969e37b 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -434,7 +434,7 @@ export async function queryProviderAvailability( }); } - // Calculate system-wide availability + // Calculate system-wide availability from the buckets returned after per-provider trimming. const totalSystemRequests = providerSummaries.reduce( (sum, provider) => sum + provider.totalRequests, 0 diff --git a/src/lib/availability/types.ts b/src/lib/availability/types.ts index 062cb9e07..7e6a6423f 100644 --- a/src/lib/availability/types.ts +++ b/src/lib/availability/types.ts @@ -113,7 +113,10 @@ export interface AvailabilityQueryOptions { bucketSizeMinutes?: number; /** Whether to include disabled providers */ includeDisabled?: boolean; - /** Maximum number of non-empty time buckets to return per provider (hard capped at 100) */ + /** + * Maximum number of non-empty time buckets to return per provider (hard capped at 100). + * Summary metrics in the response only reflect the returned buckets after this trimming. + */ maxBuckets?: number; } @@ -131,7 +134,10 @@ export interface AvailabilityQueryResult { bucketSizeMinutes: number; /** Provider summaries */ providers: ProviderAvailabilitySummary[]; - /** Overall system availability (weighted average) */ + /** + * Overall system availability weighted over the returned provider buckets. + * When maxBuckets trims older non-empty buckets, this may reflect a truncated sub-window. + */ systemAvailability: number; } From fba8e7d9713f2a241457744c210b2b5fea08304e Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 04:06:36 +0800 Subject: [PATCH 15/28] =?UTF-8?q?fix:=20=E9=99=90=E5=88=B6=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E5=88=86=E6=A1=B6=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/availability/route.ts | 18 ++++++++-- src/lib/availability/availability-service.ts | 9 +++-- src/lib/availability/index.ts | 1 + src/lib/availability/types.ts | 2 +- tests/unit/api/availability-route.test.ts | 11 ++++++ tests/unit/lib/availability-service.test.ts | 36 ++++++++++++++++++++ 6 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/app/api/availability/route.ts b/src/app/api/availability/route.ts index 3ed6b991d..0781b1f20 100644 --- a/src/app/api/availability/route.ts +++ b/src/app/api/availability/route.ts @@ -6,7 +6,7 @@ * - startTime: ISO string, start of query range (default: 24h ago) * - endTime: ISO string, end of query range (default: now) * - providerIds: comma-separated provider IDs (default: all) - * - bucketSizeMinutes: number, time bucket size (default: auto) + * - bucketSizeMinutes: number, time bucket size (default: auto, hard cap: 1440) * - includeDisabled: boolean, include disabled providers (default: false) * - maxBuckets: number, max non-empty time buckets per provider (default: 100, hard cap: 100) */ @@ -16,6 +16,7 @@ import { getSession } from "@/lib/auth"; import { type AvailabilityQueryOptions, AvailabilityQueryValidationError, + MAX_BUCKET_SIZE_MINUTES, queryProviderAvailability, } from "@/lib/availability"; @@ -40,12 +41,22 @@ function parsePositiveIntegerQueryParam(value: string, fieldName: string): numbe return parsed; } -function parsePositiveNumberQueryParam(value: string, fieldName: string): number { +function parsePositiveNumberQueryParam( + value: string, + fieldName: string, + maxValue?: number +): number { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { throw new AvailabilityQueryValidationError(`Invalid ${fieldName}: expected a positive number`); } + if (typeof maxValue === "number" && parsed > maxValue) { + throw new AvailabilityQueryValidationError( + `Invalid ${fieldName}: expected a positive number not greater than ${maxValue}` + ); + } + return parsed; } @@ -107,7 +118,8 @@ export async function GET(request: NextRequest) { if (bucketSizeMinutes !== null) { options.bucketSizeMinutes = parsePositiveNumberQueryParam( bucketSizeMinutes, - "bucketSizeMinutes" + "bucketSizeMinutes", + MAX_BUCKET_SIZE_MINUTES ); } diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index df969e37b..771c4bcdf 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -38,8 +38,10 @@ type AggregatedCurrentProviderStatusRow = { }; const MIN_BUCKET_SIZE_MINUTES = 0.25; +export const MAX_BUCKET_SIZE_MINUTES = 1440; const DEFAULT_MAX_BUCKETS = 100; // Keep the hard cap independent from the UI/API default so future default tuning does not silently relax/tighten the guardrail. +// It intentionally equals the default today; the separation preserves distinct semantic roles for future tuning. const MAX_BUCKETS_HARD_LIMIT = 100; export class AvailabilityQueryValidationError extends Error { @@ -195,10 +197,13 @@ function sanitizeBucketSizeMinutes( !Number.isFinite(explicitBucketSize) || explicitBucketSize <= 0 ) { - return Math.max(MIN_BUCKET_SIZE_MINUTES, safeFallbackBucketSize); + return Math.min( + MAX_BUCKET_SIZE_MINUTES, + Math.max(MIN_BUCKET_SIZE_MINUTES, safeFallbackBucketSize) + ); } - return Math.max(MIN_BUCKET_SIZE_MINUTES, explicitBucketSize); + return Math.min(MAX_BUCKET_SIZE_MINUTES, Math.max(MIN_BUCKET_SIZE_MINUTES, explicitBucketSize)); } function sanitizeMaxBuckets(maxBuckets: number | undefined): number { diff --git a/src/lib/availability/index.ts b/src/lib/availability/index.ts index 5400387aa..248e58eb5 100644 --- a/src/lib/availability/index.ts +++ b/src/lib/availability/index.ts @@ -19,6 +19,7 @@ export { classifyRequestStatus, determineOptimalBucketSize, getCurrentProviderStatus, + MAX_BUCKET_SIZE_MINUTES, queryProviderAvailability, } from "./availability-service"; export * from "./types"; diff --git a/src/lib/availability/types.ts b/src/lib/availability/types.ts index 7e6a6423f..9ea9623b3 100644 --- a/src/lib/availability/types.ts +++ b/src/lib/availability/types.ts @@ -109,7 +109,7 @@ export interface AvailabilityQueryOptions { endTime?: string | Date; /** Provider IDs to filter (empty = all providers) */ providerIds?: number[]; - /** Time bucket size in minutes */ + /** Time bucket size in minutes (hard capped at 1440) */ bucketSizeMinutes?: number; /** Whether to include disabled providers */ includeDisabled?: boolean; diff --git a/tests/unit/api/availability-route.test.ts b/tests/unit/api/availability-route.test.ts index 2bfeb1aba..5b9a3b73c 100644 --- a/tests/unit/api/availability-route.test.ts +++ b/tests/unit/api/availability-route.test.ts @@ -19,6 +19,7 @@ vi.mock("@/lib/auth", () => ({ vi.mock("@/lib/availability", () => ({ AvailabilityQueryValidationError: MockAvailabilityQueryValidationError, + MAX_BUCKET_SIZE_MINUTES: 1440, queryProviderAvailability: mockQueryProviderAvailability, })); @@ -128,6 +129,16 @@ describe("GET /api/availability", () => { expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); }); + it("bucketSizeMinutes 超过硬上限时返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("bucketSizeMinutes=1441")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid bucketSizeMinutes: expected a positive number not greater than 1440", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + it("空的 startTime 参数返回 400 且不访问 service", async () => { const res = await GET(makeRequest("startTime=")); diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index b8045f0e5..c4b819ed4 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -235,6 +235,42 @@ describe("availability-service", () => { expect(query.params).not.toContain(Number.POSITIVE_INFINITY); }); + it("queryProviderAvailability 在 bucketSizeMinutes 为超大有限值时钳制到 1440 分钟", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + const result = await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: Number.MAX_SAFE_INTEGER, + }); + + const query = sqlToQuery(executeMock.mock.calls[0]?.[0]); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).toHaveBeenCalledTimes(1); + expect(result.bucketSizeMinutes).toBe(1440); + expect(query.params).toContain(86400); + expect(query.params).not.toContain(Number.MAX_SAFE_INTEGER * 60); + }); + it("queryProviderAvailability 会排除进行中请求(statusCode=null 且 durationMs=null)", async () => { const selectMock = vi.fn(() => createThenableQuery([ From d7c7ab58eb772a102baf46a8a3c1844999177742 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 04:19:10 +0800 Subject: [PATCH 16/28] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=20maxBuckets=20=E8=B6=8A=E7=95=8C=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/availability/route.ts | 19 +++++++++++++++++-- src/lib/availability/availability-service.ts | 2 +- src/lib/availability/index.ts | 1 + src/lib/availability/types.ts | 4 ++-- tests/unit/api/availability-route.test.ts | 11 +++++++++++ 5 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/app/api/availability/route.ts b/src/app/api/availability/route.ts index 0781b1f20..14c5ef7af 100644 --- a/src/app/api/availability/route.ts +++ b/src/app/api/availability/route.ts @@ -17,6 +17,7 @@ import { type AvailabilityQueryOptions, AvailabilityQueryValidationError, MAX_BUCKET_SIZE_MINUTES, + MAX_BUCKETS_HARD_LIMIT, queryProviderAvailability, } from "@/lib/availability"; @@ -27,7 +28,11 @@ function parseBooleanQueryParam(value: string, fieldName: string): boolean { throw new AvailabilityQueryValidationError(`Invalid ${fieldName}: expected true or false`); } -function parsePositiveIntegerQueryParam(value: string, fieldName: string): number { +function parsePositiveIntegerQueryParam( + value: string, + fieldName: string, + maxValue?: number +): number { const normalizedValue = value.trim(); if (!/^\d+$/.test(normalizedValue)) { throw new AvailabilityQueryValidationError(`Invalid ${fieldName}: expected a positive integer`); @@ -38,6 +43,12 @@ function parsePositiveIntegerQueryParam(value: string, fieldName: string): numbe throw new AvailabilityQueryValidationError(`Invalid ${fieldName}: expected a positive integer`); } + if (typeof maxValue === "number" && parsed > maxValue) { + throw new AvailabilityQueryValidationError( + `Invalid ${fieldName}: expected a positive integer not greater than ${maxValue}` + ); + } + return parsed; } @@ -130,7 +141,11 @@ export async function GET(request: NextRequest) { const maxBuckets = searchParams.get("maxBuckets"); if (maxBuckets !== null) { - options.maxBuckets = parsePositiveIntegerQueryParam(maxBuckets, "maxBuckets"); + options.maxBuckets = parsePositiveIntegerQueryParam( + maxBuckets, + "maxBuckets", + MAX_BUCKETS_HARD_LIMIT + ); } const result = await queryProviderAvailability(options); diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 771c4bcdf..044271c9c 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -42,7 +42,7 @@ export const MAX_BUCKET_SIZE_MINUTES = 1440; const DEFAULT_MAX_BUCKETS = 100; // Keep the hard cap independent from the UI/API default so future default tuning does not silently relax/tighten the guardrail. // It intentionally equals the default today; the separation preserves distinct semantic roles for future tuning. -const MAX_BUCKETS_HARD_LIMIT = 100; +export const MAX_BUCKETS_HARD_LIMIT = 100; export class AvailabilityQueryValidationError extends Error { constructor(message: string) { diff --git a/src/lib/availability/index.ts b/src/lib/availability/index.ts index 248e58eb5..b8bfeb21f 100644 --- a/src/lib/availability/index.ts +++ b/src/lib/availability/index.ts @@ -20,6 +20,7 @@ export { determineOptimalBucketSize, getCurrentProviderStatus, MAX_BUCKET_SIZE_MINUTES, + MAX_BUCKETS_HARD_LIMIT, queryProviderAvailability, } from "./availability-service"; export * from "./types"; diff --git a/src/lib/availability/types.ts b/src/lib/availability/types.ts index 9ea9623b3..828f65d48 100644 --- a/src/lib/availability/types.ts +++ b/src/lib/availability/types.ts @@ -87,11 +87,11 @@ export interface ProviderAvailabilitySummary { currentStatus: AvailabilityStatus; /** Availability ratio over the returned time buckets (currently kept equal to successRate for compatibility) */ currentAvailability: number; - /** Total request count in period */ + /** Total finalized request count represented by the returned time buckets */ totalRequests: number; /** Compatibility alias of currentAvailability over the returned time buckets (green requests / total) */ successRate: number; - /** Average latency in ms */ + /** Average latency in ms over the returned time buckets */ avgLatencyMs: number; /** Last request timestamp */ lastRequestAt: string | null; diff --git a/tests/unit/api/availability-route.test.ts b/tests/unit/api/availability-route.test.ts index 5b9a3b73c..78b9e80be 100644 --- a/tests/unit/api/availability-route.test.ts +++ b/tests/unit/api/availability-route.test.ts @@ -19,6 +19,7 @@ vi.mock("@/lib/auth", () => ({ vi.mock("@/lib/availability", () => ({ AvailabilityQueryValidationError: MockAvailabilityQueryValidationError, + MAX_BUCKETS_HARD_LIMIT: 100, MAX_BUCKET_SIZE_MINUTES: 1440, queryProviderAvailability: mockQueryProviderAvailability, })); @@ -139,6 +140,16 @@ describe("GET /api/availability", () => { expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); }); + it("maxBuckets 超过硬上限时返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("maxBuckets=101")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid maxBuckets: expected a positive integer not greater than 100", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + it("空的 startTime 参数返回 400 且不访问 service", async () => { const res = await GET(makeRequest("startTime=")); From 7c529f3639c7b1bb4487a19f516b225642b1f2a9 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 04:44:18 +0800 Subject: [PATCH 17/28] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E5=88=86=E6=A1=B6=E8=BE=B9=E7=95=8C=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/availability/route.ts | 23 +++++++++++++++----- src/lib/availability/availability-service.ts | 2 +- src/lib/availability/index.ts | 1 + src/lib/availability/types.ts | 2 +- tests/unit/api/availability-route.test.ts | 11 ++++++++++ tests/unit/lib/availability-service.test.ts | 6 +++-- 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/app/api/availability/route.ts b/src/app/api/availability/route.ts index 14c5ef7af..f5ca803fe 100644 --- a/src/app/api/availability/route.ts +++ b/src/app/api/availability/route.ts @@ -6,7 +6,7 @@ * - startTime: ISO string, start of query range (default: 24h ago) * - endTime: ISO string, end of query range (default: now) * - providerIds: comma-separated provider IDs (default: all) - * - bucketSizeMinutes: number, time bucket size (default: auto, hard cap: 1440) + * - bucketSizeMinutes: number, time bucket size (default: auto, min: 0.25, hard cap: 1440) * - includeDisabled: boolean, include disabled providers (default: false) * - maxBuckets: number, max non-empty time buckets per provider (default: 100, hard cap: 100) */ @@ -18,6 +18,7 @@ import { AvailabilityQueryValidationError, MAX_BUCKET_SIZE_MINUTES, MAX_BUCKETS_HARD_LIMIT, + MIN_BUCKET_SIZE_MINUTES, queryProviderAvailability, } from "@/lib/availability"; @@ -55,16 +56,25 @@ function parsePositiveIntegerQueryParam( function parsePositiveNumberQueryParam( value: string, fieldName: string, - maxValue?: number + bounds?: { + minValue?: number; + maxValue?: number; + } ): number { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { throw new AvailabilityQueryValidationError(`Invalid ${fieldName}: expected a positive number`); } - if (typeof maxValue === "number" && parsed > maxValue) { + if (typeof bounds?.minValue === "number" && parsed < bounds.minValue) { + throw new AvailabilityQueryValidationError( + `Invalid ${fieldName}: expected a positive number not less than ${bounds.minValue}` + ); + } + + if (typeof bounds?.maxValue === "number" && parsed > bounds.maxValue) { throw new AvailabilityQueryValidationError( - `Invalid ${fieldName}: expected a positive number not greater than ${maxValue}` + `Invalid ${fieldName}: expected a positive number not greater than ${bounds.maxValue}` ); } @@ -130,7 +140,10 @@ export async function GET(request: NextRequest) { options.bucketSizeMinutes = parsePositiveNumberQueryParam( bucketSizeMinutes, "bucketSizeMinutes", - MAX_BUCKET_SIZE_MINUTES + { + minValue: MIN_BUCKET_SIZE_MINUTES, + maxValue: MAX_BUCKET_SIZE_MINUTES, + } ); } diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 044271c9c..18b11b33c 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -37,7 +37,7 @@ type AggregatedCurrentProviderStatusRow = { lastRequestAt: Date | null; }; -const MIN_BUCKET_SIZE_MINUTES = 0.25; +export const MIN_BUCKET_SIZE_MINUTES = 0.25; export const MAX_BUCKET_SIZE_MINUTES = 1440; const DEFAULT_MAX_BUCKETS = 100; // Keep the hard cap independent from the UI/API default so future default tuning does not silently relax/tighten the guardrail. diff --git a/src/lib/availability/index.ts b/src/lib/availability/index.ts index b8bfeb21f..3f5178aa6 100644 --- a/src/lib/availability/index.ts +++ b/src/lib/availability/index.ts @@ -21,6 +21,7 @@ export { getCurrentProviderStatus, MAX_BUCKET_SIZE_MINUTES, MAX_BUCKETS_HARD_LIMIT, + MIN_BUCKET_SIZE_MINUTES, queryProviderAvailability, } from "./availability-service"; export * from "./types"; diff --git a/src/lib/availability/types.ts b/src/lib/availability/types.ts index 828f65d48..c231f99eb 100644 --- a/src/lib/availability/types.ts +++ b/src/lib/availability/types.ts @@ -109,7 +109,7 @@ export interface AvailabilityQueryOptions { endTime?: string | Date; /** Provider IDs to filter (empty = all providers) */ providerIds?: number[]; - /** Time bucket size in minutes (hard capped at 1440) */ + /** Time bucket size in minutes (minimum 0.25, hard capped at 1440) */ bucketSizeMinutes?: number; /** Whether to include disabled providers */ includeDisabled?: boolean; diff --git a/tests/unit/api/availability-route.test.ts b/tests/unit/api/availability-route.test.ts index 78b9e80be..c346bfe19 100644 --- a/tests/unit/api/availability-route.test.ts +++ b/tests/unit/api/availability-route.test.ts @@ -19,6 +19,7 @@ vi.mock("@/lib/auth", () => ({ vi.mock("@/lib/availability", () => ({ AvailabilityQueryValidationError: MockAvailabilityQueryValidationError, + MIN_BUCKET_SIZE_MINUTES: 0.25, MAX_BUCKETS_HARD_LIMIT: 100, MAX_BUCKET_SIZE_MINUTES: 1440, queryProviderAvailability: mockQueryProviderAvailability, @@ -130,6 +131,16 @@ describe("GET /api/availability", () => { expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); }); + it("bucketSizeMinutes 低于最小值时返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("bucketSizeMinutes=0.001")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid bucketSizeMinutes: expected a positive number not less than 0.25", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + it("bucketSizeMinutes 超过硬上限时返回 400 且不访问 service", async () => { const res = await GET(makeRequest("bucketSizeMinutes=1441")); diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index c4b819ed4..89077a79b 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -408,7 +408,8 @@ describe("availability-service", () => { expect(executeMock).toHaveBeenCalledTimes(1); expect(queryText).toContain("row_number() over"); expect(queryText).toContain("where rn <="); - expect(query.params.at(-1)).toBe(100); + expect(query.params).toContain(100); + expect(query.params).not.toContain(Number.POSITIVE_INFINITY); }); it("queryProviderAvailability 在 maxBuckets 为超大有限值时也会收紧到硬上限", async () => { @@ -446,7 +447,8 @@ describe("availability-service", () => { expect(executeMock).toHaveBeenCalledTimes(1); expect(queryText).toContain("row_number() over"); expect(queryText).toContain("where rn <="); - expect(query.params.at(-1)).toBe(100); + expect(query.params).toContain(100); + expect(query.params).not.toContain(Number.MAX_SAFE_INTEGER); }); it("queryProviderAvailability 在无聚合数据时仍返回 unknown 提供商状态", async () => { From 93a4e55cc735e2d090764bf5da7e2e0cbd0bb95d Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 05:07:27 +0800 Subject: [PATCH 18/28] =?UTF-8?q?perf:=20=E9=99=90=E5=88=B6=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E6=9F=A5=E8=AF=A2=E6=97=B6=E9=97=B4=E8=B7=A8?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/availability/route.ts | 4 ++-- src/lib/availability/availability-service.ts | 14 +++++++++++- src/lib/availability/index.ts | 1 + src/lib/availability/types.ts | 4 ++-- tests/unit/lib/availability-service.test.ts | 24 ++++++++++++++++++++ 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/app/api/availability/route.ts b/src/app/api/availability/route.ts index f5ca803fe..ecedb5284 100644 --- a/src/app/api/availability/route.ts +++ b/src/app/api/availability/route.ts @@ -3,8 +3,8 @@ * * GET /api/availability * Query parameters: - * - startTime: ISO string, start of query range (default: 24h ago) - * - endTime: ISO string, end of query range (default: now) + * - startTime: ISO string, start of query range (default: 24h ago, maximum span with endTime: 100 days) + * - endTime: ISO string, end of query range (default: now, maximum span with startTime: 100 days) * - providerIds: comma-separated provider IDs (default: all) * - bucketSizeMinutes: number, time bucket size (default: auto, min: 0.25, hard cap: 1440) * - includeDisabled: boolean, include disabled providers (default: false) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 18b11b33c..d16b4bd93 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -43,6 +43,10 @@ const DEFAULT_MAX_BUCKETS = 100; // Keep the hard cap independent from the UI/API default so future default tuning does not silently relax/tighten the guardrail. // It intentionally equals the default today; the separation preserves distinct semantic roles for future tuning. export const MAX_BUCKETS_HARD_LIMIT = 100; +export const MAX_AVAILABILITY_QUERY_RANGE_DAYS = + (MAX_BUCKETS_HARD_LIMIT * MAX_BUCKET_SIZE_MINUTES) / (24 * 60); +const MAX_AVAILABILITY_QUERY_RANGE_MS = + MAX_BUCKETS_HARD_LIMIT * MAX_BUCKET_SIZE_MINUTES * 60 * 1000; export class AvailabilityQueryValidationError extends Error { constructor(message: string) { @@ -215,11 +219,19 @@ function sanitizeMaxBuckets(maxBuckets: number | undefined): number { } function validateAvailabilityTimeRange(startDate: Date, endDate: Date): void { - if (endDate.getTime() < startDate.getTime()) { + const rangeMs = endDate.getTime() - startDate.getTime(); + + if (rangeMs < 0) { throw new AvailabilityQueryValidationError( "Invalid time range: endTime must be greater than or equal to startTime" ); } + + if (rangeMs > MAX_AVAILABILITY_QUERY_RANGE_MS) { + throw new AvailabilityQueryValidationError( + `Invalid time range: requested range must not exceed ${MAX_AVAILABILITY_QUERY_RANGE_DAYS} days` + ); + } } /** diff --git a/src/lib/availability/index.ts b/src/lib/availability/index.ts index 3f5178aa6..b795312ee 100644 --- a/src/lib/availability/index.ts +++ b/src/lib/availability/index.ts @@ -19,6 +19,7 @@ export { classifyRequestStatus, determineOptimalBucketSize, getCurrentProviderStatus, + MAX_AVAILABILITY_QUERY_RANGE_DAYS, MAX_BUCKET_SIZE_MINUTES, MAX_BUCKETS_HARD_LIMIT, MIN_BUCKET_SIZE_MINUTES, diff --git a/src/lib/availability/types.ts b/src/lib/availability/types.ts index c231f99eb..068411e29 100644 --- a/src/lib/availability/types.ts +++ b/src/lib/availability/types.ts @@ -103,9 +103,9 @@ export interface ProviderAvailabilitySummary { * Availability query options */ export interface AvailabilityQueryOptions { - /** Start time for query (ISO string or Date) */ + /** Start time for query (ISO string or Date, maximum span with endTime is 100 days) */ startTime?: string | Date; - /** End time for query (ISO string or Date) */ + /** End time for query (ISO string or Date, maximum span with startTime is 100 days) */ endTime?: string | Date; /** Provider IDs to filter (empty = all providers) */ providerIds?: number[]; diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index 89077a79b..1100d0b7b 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -126,6 +126,30 @@ describe("availability-service", () => { expect(executeMock).not.toHaveBeenCalled(); }); + it("queryProviderAvailability 在时间跨度超过 100 天时抛出明确错误且不访问数据库", async () => { + const selectMock = vi.fn(() => createThenableQuery([])); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + + await expect( + queryProviderAvailability({ + startTime: new Date("2025-12-01T00:00:00.000Z"), + endTime: new Date("2026-04-13T00:00:00.000Z"), + }) + ).rejects.toThrow("requested range must not exceed 100 days"); + + expect(selectMock).not.toHaveBeenCalled(); + expect(executeMock).not.toHaveBeenCalled(); + }); + it("queryProviderAvailability 改为数据库聚合后仍只统计终态请求", async () => { const selectMock = vi.fn(() => createThenableQuery([ From 0fdcc3a804478bddfd1d16614d9cfc0676824ae6 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 05:29:13 +0800 Subject: [PATCH 19/28] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E7=8A=B6=E6=80=81=E5=88=86=E7=B1=BB=E9=98=88?= =?UTF-8?q?=E5=80=BC=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 38 ++++++++++++++++---- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index d16b4bd93..efd594364 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -4,7 +4,7 @@ * Simple two-tier status: success (green) or failure (red) */ -import { and, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, isNull, type SQLWrapper, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { messageRequest, providers } from "@/drizzle/schema"; import type { @@ -40,6 +40,15 @@ type AggregatedCurrentProviderStatusRow = { export const MIN_BUCKET_SIZE_MINUTES = 0.25; export const MAX_BUCKET_SIZE_MINUTES = 1440; const DEFAULT_MAX_BUCKETS = 100; +const AVAILABILITY_SUCCESS_STATUS_CODE_MIN = 200; +const AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE = 400; +const AVAILABILITY_SUCCESS_STATUS_CODE_MIN_SQL = sql.raw( + String(AVAILABILITY_SUCCESS_STATUS_CODE_MIN) +); +const AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE_SQL = sql.raw( + String(AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE) +); +const FINALIZED_REQUEST_STATUS_CODE_SQL = sql.raw('"statusCode"'); // Keep the hard cap independent from the UI/API default so future default tuning does not silently relax/tighten the guardrail. // It intentionally equals the default today; the separation preserves distinct semantic roles for future tuning. export const MAX_BUCKETS_HARD_LIMIT = 100; @@ -134,13 +143,30 @@ function getTimeValue(value: Date | string | null | undefined): number { return Number.isFinite(timestamp) ? timestamp : 0; } +function isAvailabilitySuccessStatusCode(statusCode: number): boolean { + return ( + statusCode >= AVAILABILITY_SUCCESS_STATUS_CODE_MIN && + statusCode < AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE + ); +} + +function buildAvailabilitySuccessStatusCondition(statusCodeExpression: SQLWrapper) { + return sql`${statusCodeExpression} >= ${AVAILABILITY_SUCCESS_STATUS_CODE_MIN_SQL} + AND ${statusCodeExpression} < ${AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE_SQL}`; +} + +function buildAvailabilityFailureStatusCondition(statusCodeExpression: SQLWrapper) { + return sql`${statusCodeExpression} < ${AVAILABILITY_SUCCESS_STATUS_CODE_MIN_SQL} + OR ${statusCodeExpression} >= ${AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE_SQL}`; +} + /** * Classify a single finalized request's status * Simple: success (2xx/3xx) = green, failure = red */ export function classifyRequestStatus(statusCode: number): RequestStatusClassification { // 仅把 2xx/3xx 视为成功;1xx 不应在可用性里被计为绿色。 - if (statusCode >= 200 && statusCode < 400) { + if (isAvailabilitySuccessStatusCode(statusCode)) { return { status: "green", isSuccess: true, @@ -313,8 +339,8 @@ export async function queryProviderAvailability( SELECT "providerId", "bucketStart", - COUNT(*) FILTER (WHERE "statusCode" >= 200 AND "statusCode" < 400)::int AS "greenCount", - COUNT(*) FILTER (WHERE "statusCode" < 200 OR "statusCode" >= 400)::int AS "redCount", + COUNT(*) FILTER (WHERE ${buildAvailabilitySuccessStatusCondition(FINALIZED_REQUEST_STATUS_CODE_SQL)})::int AS "greenCount", + COUNT(*) FILTER (WHERE ${buildAvailabilityFailureStatusCondition(FINALIZED_REQUEST_STATUS_CODE_SQL)})::int AS "redCount", COUNT("durationMs")::int AS "latencyCount", COALESCE(SUM("durationMs")::double precision, 0) AS "latencySumMs", COALESCE(AVG("durationMs")::double precision, 0) AS "avgLatencyMs", @@ -513,8 +539,8 @@ export async function getCurrentProviderStatus(): Promise< const aggregateQuery = sql` SELECT ${messageRequest.providerId} AS "providerId", - COUNT(*) FILTER (WHERE ${messageRequest.statusCode} >= 200 AND ${messageRequest.statusCode} < 400)::int AS "greenCount", - COUNT(*) FILTER (WHERE ${messageRequest.statusCode} < 200 OR ${messageRequest.statusCode} >= 400)::int AS "redCount", + COUNT(*) FILTER (WHERE ${buildAvailabilitySuccessStatusCondition(messageRequest.statusCode)})::int AS "greenCount", + COUNT(*) FILTER (WHERE ${buildAvailabilityFailureStatusCondition(messageRequest.statusCode)})::int AS "redCount", MAX(${messageRequest.createdAt}) AS "lastRequestAt" FROM ${messageRequest} WHERE ${requestConditions} From 5b026ba788270f6f97f26ecf3eb786c3930c8ab9 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 05:43:53 +0800 Subject: [PATCH 20/28] =?UTF-8?q?refactor:=20=E6=94=B6=E6=95=9B=20availabi?= =?UTF-8?q?lity=20CTE=20=E7=8A=B6=E6=80=81=E7=A0=81=E5=88=AB=E5=90=8D?= =?UTF-8?q?=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index efd594364..4514a0076 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -42,13 +42,14 @@ export const MAX_BUCKET_SIZE_MINUTES = 1440; const DEFAULT_MAX_BUCKETS = 100; const AVAILABILITY_SUCCESS_STATUS_CODE_MIN = 200; const AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE = 400; +const FINALIZED_REQUEST_STATUS_CODE_ALIAS = "statusCode" as const; const AVAILABILITY_SUCCESS_STATUS_CODE_MIN_SQL = sql.raw( String(AVAILABILITY_SUCCESS_STATUS_CODE_MIN) ); const AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE_SQL = sql.raw( String(AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE) ); -const FINALIZED_REQUEST_STATUS_CODE_SQL = sql.raw('"statusCode"'); +const FINALIZED_REQUEST_STATUS_CODE_SQL = sql.raw(`"${FINALIZED_REQUEST_STATUS_CODE_ALIAS}"`); // Keep the hard cap independent from the UI/API default so future default tuning does not silently relax/tighten the guardrail. // It intentionally equals the default today; the separation preserves distinct semantic roles for future tuning. export const MAX_BUCKETS_HARD_LIMIT = 100; @@ -327,7 +328,7 @@ export async function queryProviderAvailability( SELECT ${messageRequest.providerId} AS "providerId", ${messageRequest.createdAt} AS "createdAt", - ${messageRequest.statusCode} AS "statusCode", + ${messageRequest.statusCode} AS ${FINALIZED_REQUEST_STATUS_CODE_SQL}, ${messageRequest.durationMs} AS "durationMs", to_timestamp( floor(extract(epoch from ${messageRequest.createdAt}) / ${bucketSizeSeconds}) * ${bucketSizeSeconds} From a2d1e8e9a66cb63d2e96c5e7789303742b029ccf Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 06:00:14 +0800 Subject: [PATCH 21/28] =?UTF-8?q?docs:=20=E6=BE=84=E6=B8=85=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E7=BB=88=E6=80=81=E7=AD=9B=E9=80=89=E7=9A=84?= =?UTF-8?q?=E5=B7=B2=E7=9F=A5=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 4514a0076..b4b3bfadc 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -68,8 +68,9 @@ export class AvailabilityQueryValidationError extends Error { /** * 当前版本把“已终态”收敛为 `statusCode` 已落库。 * - * 已知限制:如果未来出现 `durationMs` / `errorMessage` 已落库、但 `statusCode` 仍为空且已稳定结束的写路径, - * 这些记录会被当前可用性统计排除。届时应引入独立的 finalized 谓词,而不是直接放宽为 `durationMs IS NOT NULL`。 + * 已知限制:在当前异步写入/丢 patch 的极端场景,或未来新增了 `durationMs` / `errorMessage` + * 已落库、但 `statusCode` 仍为空且已稳定结束的写路径时,这些记录会被当前可用性统计排除。 + * 届时应引入独立的 finalized 谓词,而不是直接放宽为 `durationMs IS NOT NULL`。 */ function buildAvailabilityFinalizedCondition() { return isNotNull(messageRequest.statusCode); From 7ce7e484cb2bea22b1895aec6e9b2770b5c93c4e Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 06:09:43 +0800 Subject: [PATCH 22/28] =?UTF-8?q?refactor:=20=E4=B8=BA=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E6=80=A7=E5=A4=B1=E8=B4=A5=E6=9D=A1=E4=BB=B6=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E6=8B=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index b4b3bfadc..6357803e1 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -158,8 +158,8 @@ function buildAvailabilitySuccessStatusCondition(statusCodeExpression: SQLWrappe } function buildAvailabilityFailureStatusCondition(statusCodeExpression: SQLWrapper) { - return sql`${statusCodeExpression} < ${AVAILABILITY_SUCCESS_STATUS_CODE_MIN_SQL} - OR ${statusCodeExpression} >= ${AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE_SQL}`; + return sql`(${statusCodeExpression} < ${AVAILABILITY_SUCCESS_STATUS_CODE_MIN_SQL} + OR ${statusCodeExpression} >= ${AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE_SQL})`; } /** From 8003cabaee5274255d624eee3122f42bb1491b3b Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 06:18:49 +0800 Subject: [PATCH 23/28] =?UTF-8?q?test:=20=E6=94=B6=E7=B4=A7=20finalized=5F?= =?UTF-8?q?requests=20CTE=20=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/lib/availability-service.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index 1100d0b7b..591d5f6cb 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -44,7 +44,7 @@ function extractFinalizedRequestsSql(queryText: string): string { const end = queryText.indexOf("provider_bucket_stats as"); if (start === -1 || end === -1 || end <= start) { - return queryText; + throw new Error("Could not locate finalized_requests CTE in query text"); } return queryText.slice(start, end); From cccffd27fc83f186f8d7b1b03201be5b3bfc2a59 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 06:30:42 +0800 Subject: [PATCH 24/28] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E5=8F=AF=E7=94=A8=E6=80=A7=E7=AA=97=E5=8F=A3=E4=B8=8A?= =?UTF-8?q?=E7=95=8C=E4=B8=8E=E8=BE=B9=E7=95=8C=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 2 ++ tests/unit/lib/availability-service.test.ts | 35 ++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 6357803e1..8a3be60a3 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -278,6 +278,7 @@ export async function queryProviderAvailability( maxBuckets = DEFAULT_MAX_BUCKETS, } = options; + // Apply defaults first so both implicit defaults and user-supplied values share the same parse/validation path. const startDate = parseAvailabilityDate(startTime, "startTime"); const endDate = parseAvailabilityDate(endTime, "endTime"); validateAvailabilityTimeRange(startDate, endDate); @@ -536,6 +537,7 @@ export async function getCurrentProviderStatus(): Promise< const requestConditions = buildAvailabilityRequestConditions({ providerIds: providerIdList, startDate: fifteenMinutesAgo, + endDate: now, }); const aggregateQuery = sql` diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index 591d5f6cb..48f1a8ace 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -150,6 +150,40 @@ describe("availability-service", () => { expect(executeMock).not.toHaveBeenCalled(); }); + it("queryProviderAvailability 在时间跨度恰好等于 100 天时允许继续执行", async () => { + const selectMock = vi.fn(() => createThenableQuery([])); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + + const startTime = new Date("2026-01-03T00:00:00.000Z"); + const endTime = new Date(startTime.getTime() + 100 * 24 * 60 * 60 * 1000); + + await expect( + queryProviderAvailability({ + startTime, + endTime, + }) + ).resolves.toEqual({ + queriedAt: expect.any(String), + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + bucketSizeMinutes: 1440, + providers: [], + systemAvailability: 0, + }); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).not.toHaveBeenCalled(); + }); + it("queryProviderAvailability 改为数据库聚合后仍只统计终态请求", async () => { const selectMock = vi.fn(() => createThenableQuery([ @@ -562,6 +596,7 @@ describe("availability-service", () => { const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); expect(queryText).toMatch(/where .*status_?code.*is not null/); + expect(queryText).toMatch(/where .*created_?at.*>= .*and .*created_?at.*<=/); expect(queryText).toContain("count(*) filter"); expect(queryText).toContain("max("); }); From 62fe47b8c80043470bda586a5f15f7aee958c09f Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 07:03:13 +0800 Subject: [PATCH 25/28] =?UTF-8?q?perf:=20=E6=94=B6=E7=B4=A7=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E6=9F=A5=E8=AF=A2=E6=A1=B6=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E4=B8=8E=E7=8A=B6=E6=80=81=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 70 ++++++++++++++------ src/lib/availability/types.ts | 27 +------- tests/unit/lib/availability-service.test.ts | 61 ++++++++++++++++- 3 files changed, 110 insertions(+), 48 deletions(-) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 8a3be60a3..8c555c316 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -53,6 +53,7 @@ const FINALIZED_REQUEST_STATUS_CODE_SQL = sql.raw(`"${FINALIZED_REQUEST_STATUS_C // Keep the hard cap independent from the UI/API default so future default tuning does not silently relax/tighten the guardrail. // It intentionally equals the default today; the separation preserves distinct semantic roles for future tuning. export const MAX_BUCKETS_HARD_LIMIT = 100; +const CURRENT_PROVIDER_STATUS_WINDOW_MINUTES = 15; export const MAX_AVAILABILITY_QUERY_RANGE_DAYS = (MAX_BUCKETS_HARD_LIMIT * MAX_BUCKET_SIZE_MINUTES) / (24 * 60); const MAX_AVAILABILITY_QUERY_RANGE_MS = @@ -106,6 +107,14 @@ function buildTimestampUpperBound( return sql`${column} <= CAST(${assertValidDate(date, fieldName).toISOString()} AS timestamptz)`; } +function buildRelativeNowLowerBound(column: typeof messageRequest.createdAt, minutes: number) { + return sql`${column} >= NOW() - (${sql.raw(String(minutes))} * INTERVAL '1 minute')`; +} + +function buildNowUpperBound(column: typeof messageRequest.createdAt) { + return sql`${column} <= NOW()`; +} + function buildAvailabilityRequestConditions(input: { providerIds: number[]; startDate: Date; @@ -194,12 +203,9 @@ export function calculateAvailabilityScore(greenCount: number, redCount: number) } /** - * Determine optimal time bucket size based on data density + * Determine optimal time bucket size based on time range */ -export function determineOptimalBucketSize( - _totalRequests: number, - timeRangeMinutes: number -): number { +export function determineOptimalBucketSize(timeRangeMinutes: number): number { // Target: 20-100 data points per time series for good visualization const targetBuckets = 50; const idealBucketMinutes = timeRangeMinutes / targetBuckets; @@ -218,11 +224,18 @@ export function determineOptimalBucketSize( function sanitizeBucketSizeMinutes( explicitBucketSize: number | undefined, - timeRangeMinutes: number + timeRangeMinutes: number, + maxBuckets: number ): number { - const fallbackBucketSize = determineOptimalBucketSize(0, timeRangeMinutes); + const fallbackBucketSize = determineOptimalBucketSize(timeRangeMinutes); const safeFallbackBucketSize = Number.isFinite(fallbackBucketSize) && fallbackBucketSize > 0 ? fallbackBucketSize : 60; + const minimumBudgetBucketSize = + timeRangeMinutes > 0 ? timeRangeMinutes / Math.max(1, maxBuckets) : MIN_BUCKET_SIZE_MINUTES; + const clampedMinimumBudgetBucketSize = Math.min( + MAX_BUCKET_SIZE_MINUTES, + Math.max(MIN_BUCKET_SIZE_MINUTES, minimumBudgetBucketSize) + ); if ( typeof explicitBucketSize !== "number" || @@ -231,11 +244,22 @@ function sanitizeBucketSizeMinutes( ) { return Math.min( MAX_BUCKET_SIZE_MINUTES, - Math.max(MIN_BUCKET_SIZE_MINUTES, safeFallbackBucketSize) + Math.max(MIN_BUCKET_SIZE_MINUTES, safeFallbackBucketSize, clampedMinimumBudgetBucketSize) + ); + } + + const normalizedExplicitBucketSize = Math.min( + MAX_BUCKET_SIZE_MINUTES, + Math.max(MIN_BUCKET_SIZE_MINUTES, explicitBucketSize) + ); + + if (timeRangeMinutes > normalizedExplicitBucketSize * maxBuckets) { + throw new AvailabilityQueryValidationError( + "Invalid bucket configuration: requested range exceeds the bucket budget implied by bucketSizeMinutes and maxBuckets" ); } - return Math.min(MAX_BUCKET_SIZE_MINUTES, Math.max(MIN_BUCKET_SIZE_MINUTES, explicitBucketSize)); + return normalizedExplicitBucketSize; } function sanitizeMaxBuckets(maxBuckets: number | undefined): number { @@ -283,10 +307,14 @@ export async function queryProviderAvailability( const endDate = parseAvailabilityDate(endTime, "endTime"); validateAvailabilityTimeRange(startDate, endDate); const timeRangeMinutes = (endDate.getTime() - startDate.getTime()) / (1000 * 60); - const bucketSizeMinutes = sanitizeBucketSizeMinutes(explicitBucketSize, timeRangeMinutes); + const sanitizedMaxBuckets = sanitizeMaxBuckets(maxBuckets); + const bucketSizeMinutes = sanitizeBucketSizeMinutes( + explicitBucketSize, + timeRangeMinutes, + sanitizedMaxBuckets + ); const bucketSizeMs = bucketSizeMinutes * 60 * 1000; const bucketSizeSeconds = bucketSizeMinutes * 60; - const sanitizedMaxBuckets = sanitizeMaxBuckets(maxBuckets); // Get provider list const providerConditions = [isNull(providers.deletedAt)]; @@ -451,7 +479,9 @@ export async function queryProviderAvailability( const totalRequests = totalGreen + totalRed; const returnedBucketAvailability = calculateAvailabilityScore(totalGreen, totalRed); - // Determine current status based on last few buckets + // Determine current status from the most recent returned buckets. + // Because older non-empty buckets may already be trimmed by maxBuckets, + // this intentionally reflects the truncated tail window rather than the full query range. // IMPORTANT: No data = 'unknown', NOT 'green'! Must be honest. let currentStatus: AvailabilityStatus = "unknown"; if (timeBuckets.length > 0) { @@ -516,10 +546,6 @@ export async function getCurrentProviderStatus(): Promise< lastRequestAt: string | null; }> > { - // Query last 15 minutes of data for current status - const now = new Date(); - const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000); - // Get enabled providers const providerList = await db .select({ @@ -534,11 +560,13 @@ export async function getCurrentProviderStatus(): Promise< } const providerIdList = providerList.map((provider) => provider.id); - const requestConditions = buildAvailabilityRequestConditions({ - providerIds: providerIdList, - startDate: fifteenMinutesAgo, - endDate: now, - }); + const requestConditions = and( + inArray(messageRequest.providerId, providerIdList), + buildRelativeNowLowerBound(messageRequest.createdAt, CURRENT_PROVIDER_STATUS_WINDOW_MINUTES), + buildNowUpperBound(messageRequest.createdAt), + isNull(messageRequest.deletedAt), + buildAvailabilityFinalizedCondition() + ); const aggregateQuery = sql` SELECT diff --git a/src/lib/availability/types.ts b/src/lib/availability/types.ts index 068411e29..281aa9e36 100644 --- a/src/lib/availability/types.ts +++ b/src/lib/availability/types.ts @@ -83,7 +83,7 @@ export interface ProviderAvailabilitySummary { providerType: string; /** Whether provider is enabled */ isEnabled: boolean; - /** Current status based on recent requests */ + /** Current status based on the most recent returned buckets */ currentStatus: AvailabilityStatus; /** Availability ratio over the returned time buckets (currently kept equal to successRate for compatibility) */ currentAvailability: number; @@ -140,28 +140,3 @@ export interface AvailabilityQueryResult { */ systemAvailability: number; } - -/** - * Raw request data from database - */ -export interface RawRequestData { - id: number; - providerId: number; - statusCode: number | null; - durationMs: number | null; - errorMessage: string | null; - createdAt: Date | null; -} - -/** - * Aggregated bucket data from database - */ -export interface AggregatedBucketData { - providerId: number; - bucketStart: Date; - totalRequests: number; - greenCount: number; - redCount: number; - avgLatencyMs: number; - latencies: number[]; -} diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index 48f1a8ace..bcc494961 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -184,6 +184,64 @@ describe("availability-service", () => { expect(executeMock).not.toHaveBeenCalled(); }); + it("queryProviderAvailability 在显式 bucket 配置超出 maxBuckets 预算时直接报错且不访问数据库", async () => { + const selectMock = vi.fn(() => createThenableQuery([])); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + + await expect( + queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 1, + maxBuckets: 100, + }) + ).rejects.toThrow("Invalid bucket configuration"); + + expect(selectMock).not.toHaveBeenCalled(); + expect(executeMock).not.toHaveBeenCalled(); + }); + + it("queryProviderAvailability 在自动分桶且 maxBuckets 较小时会上调 bucket 以匹配预算", async () => { + const selectMock = vi.fn(() => createThenableQuery([])); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + + await expect( + queryProviderAvailability({ + startTime: new Date("2026-04-13T00:00:00.000Z"), + endTime: new Date("2026-04-14T00:00:00.000Z"), + maxBuckets: 10, + }) + ).resolves.toEqual({ + queriedAt: expect.any(String), + startTime: "2026-04-13T00:00:00.000Z", + endTime: "2026-04-14T00:00:00.000Z", + bucketSizeMinutes: 144, + providers: [], + systemAvailability: 0, + }); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).not.toHaveBeenCalled(); + }); + it("queryProviderAvailability 改为数据库聚合后仍只统计终态请求", async () => { const selectMock = vi.fn(() => createThenableQuery([ @@ -596,7 +654,8 @@ describe("availability-service", () => { const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); expect(queryText).toMatch(/where .*status_?code.*is not null/); - expect(queryText).toMatch(/where .*created_?at.*>= .*and .*created_?at.*<=/); + expect(queryText).toContain(">= now() - (15 * interval '1 minute')"); + expect(queryText).toContain("<= now()"); expect(queryText).toContain("count(*) filter"); expect(queryText).toContain("max("); }); From 34cfd2e4cb9e176d26718ea5307e0ef592d5aa91 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 07:21:08 +0800 Subject: [PATCH 26/28] =?UTF-8?q?fix:=20=E6=8C=89=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E9=87=8F=E5=8A=A0=E6=9D=83=E6=9C=80=E8=BF=91=E6=A1=B6=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 6 +- tests/unit/lib/availability-service.test.ts | 63 ++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 8c555c316..a123d005e 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -486,9 +486,9 @@ export async function queryProviderAvailability( let currentStatus: AvailabilityStatus = "unknown"; if (timeBuckets.length > 0) { const recentBuckets = timeBuckets.slice(-3); // Last 3 buckets - const recentScore = - recentBuckets.reduce((sum, bucket) => sum + bucket.availabilityScore, 0) / - recentBuckets.length; + const recentGreen = recentBuckets.reduce((sum, bucket) => sum + bucket.greenCount, 0); + const recentRed = recentBuckets.reduce((sum, bucket) => sum + bucket.redCount, 0); + const recentScore = calculateAvailabilityScore(recentGreen, recentRed); // Simple: >= 50% success = green, otherwise red currentStatus = recentScore >= 0.5 ? "green" : "red"; diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index bcc494961..25093a724 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -315,6 +315,69 @@ describe("availability-service", () => { expect(queryText).toContain("row_number() over"); }); + it("queryProviderAvailability 计算 currentStatus 时会按最近 buckets 的请求量加权", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => [ + { + providerId: 1, + bucketStart: new Date("2026-04-13T08:00:00.000Z"), + greenCount: 1, + redCount: 0, + latencyCount: 1, + latencySumMs: 100, + avgLatencyMs: 100, + p50LatencyMs: 100, + p95LatencyMs: 100, + p99LatencyMs: 100, + lastRequestAt: new Date("2026-04-13T08:00:30.000Z"), + }, + { + providerId: 1, + bucketStart: new Date("2026-04-13T09:00:00.000Z"), + greenCount: 0, + redCount: 100, + latencyCount: 100, + latencySumMs: 20000, + avgLatencyMs: 200, + p50LatencyMs: 200, + p95LatencyMs: 250, + p99LatencyMs: 300, + lastRequestAt: new Date("2026-04-13T09:59:59.000Z"), + }, + ]); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + const result = await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T10:00:00.000Z"), + bucketSizeMinutes: 60, + }); + + expect(result.providers[0]).toMatchObject({ + providerId: 1, + totalRequests: 101, + currentAvailability: 1 / 101, + currentStatus: "red", + lastRequestAt: "2026-04-13T09:59:59.000Z", + }); + }); + it("queryProviderAvailability 在 bucketSizeMinutes 为 Infinity 时回退到自动分桶", async () => { const selectMock = vi.fn(() => createThenableQuery([ From 1f0d9697bd8865667115fc456883ec4e8c8a5d5e Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 07:52:43 +0800 Subject: [PATCH 27/28] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E9=AB=98?= =?UTF-8?q?=E5=86=99=E8=A1=A8=E7=B4=A2=E5=BC=95=E8=BF=81=E7=A7=BB=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- drizzle/0088_amazing_energizer.sql | 3 +++ 1 file changed, 3 insertions(+) diff --git a/drizzle/0088_amazing_energizer.sql b/drizzle/0088_amazing_energizer.sql index 9f290d9e2..4d0f8e6ae 100644 --- a/drizzle/0088_amazing_energizer.sql +++ b/drizzle/0088_amazing_energizer.sql @@ -1 +1,4 @@ +-- Note: message_request is a high-write table. Standard CREATE INDEX may block writes during index creation. +-- Drizzle migrator does not support CREATE INDEX CONCURRENTLY. If write blocking is a concern, +-- manually pre-create this index with CONCURRENTLY before running this migration (IF NOT EXISTS prevents conflicts). CREATE INDEX IF NOT EXISTS "idx_message_request_provider_created_at_finalized_active" ON "message_request" USING btree ("provider_id","created_at" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL AND "message_request"."status_code" IS NOT NULL; From 310df8a5587823590aa08ff42f89ae68766ed849 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 14 Apr 2026 08:17:45 +0800 Subject: [PATCH 28/28] =?UTF-8?q?fix:=20=E4=BC=98=E5=85=88=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E7=BB=88=E6=80=81=E8=AF=B7=E6=B1=82=E5=86=99=E5=85=A5?= =?UTF-8?q?=E8=A1=A5=E4=B8=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repository/message-write-buffer.ts | 24 ++++++++++++++++--- .../repository/message-write-buffer.test.ts | 9 +++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index d2f690189..dfa52709e 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -150,6 +150,18 @@ function buildBatchUpdateSql(updates: MessageRequestUpdateRecord[]): SQL | null `; } +function getPatchRetentionPriority(patch: MessageRequestUpdatePatch): number { + if (patch.statusCode !== undefined) { + return 3; + } + + if (patch.durationMs !== undefined) { + return 2; + } + + return 1; +} + class MessageRequestWriteBuffer { private readonly config: WriterConfig; private readonly pending = new Map(); @@ -176,15 +188,19 @@ class MessageRequestWriteBuffer { // 队列上限保护:DB 异常时避免无限增长导致 OOM if (this.pending.size > this.config.maxPending) { - // 优先丢弃非“终态”更新(没有 durationMs 的条目),尽量保留请求完成信息 + // 优先保留更接近终态的 patch: + // statusCode > durationMs > metadata-only + // 这样 Gemini passthrough 等 statusCode-only 终态更新不会比 duration-only 更容易被丢弃。 let droppedId: number | undefined; let droppedPatch: MessageRequestUpdatePatch | undefined; + let lowestPriority = Number.POSITIVE_INFINITY; for (const [candidateId, candidatePatch] of this.pending) { - if (candidatePatch.durationMs === undefined) { + const priority = getPatchRetentionPriority(candidatePatch); + if (priority < lowestPriority) { + lowestPriority = priority; droppedId = candidateId; droppedPatch = candidatePatch; - break; } } @@ -203,7 +219,9 @@ class MessageRequestWriteBuffer { logger.warn("[MessageRequestWriteBuffer] Pending queue overflow, dropping update", { maxPending: this.config.maxPending, droppedId, + droppedPriority: lowestPriority, droppedHasDurationMs: droppedPatch?.durationMs !== undefined, + droppedHasStatusCode: droppedPatch?.statusCode !== undefined, currentPending: this.pending.size, }); } diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index 17f5ab192..a3e3adf12 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -238,7 +238,7 @@ describe("message_request 异步批量写入", () => { expect(built.sql).toContain("status_code"); }); - it("队列溢出时应优先丢弃非终态更新(尽量保留 durationMs)", async () => { + it("队列溢出时应优先保留带 statusCode 的终态 patch", async () => { process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; process.env.MESSAGE_REQUEST_ASYNC_MAX_PENDING = "100"; @@ -246,7 +246,7 @@ describe("message_request 异步批量写入", () => { "@/repository/message-write-buffer" ); - enqueueMessageRequestUpdate(1001, { statusCode: 200 }); // 非终态(无 durationMs) + enqueueMessageRequestUpdate(1001, { statusCode: 200 }); // Gemini passthrough 等 statusCode-only 终态 for (let i = 0; i < 100; i++) { enqueueMessageRequestUpdate(2000 + i, { durationMs: i }); } @@ -258,8 +258,9 @@ describe("message_request 异步批量写入", () => { const query = executeMock.mock.calls[0]?.[0]; const built = toSqlText(query); - expect(built.params).toContain(2000); + expect(built.params).toContain(1001); + expect(built.sql).toContain("status_code"); + expect(built.params).not.toContain(2000); expect(built.params).toContain(2099); - expect(built.params).not.toContain(1001); }); });