diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 4d5e6f0bd..386abd5d3 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, isNull, type SQLWrapper, 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 { @@ -62,19 +62,12 @@ export class AvailabilityQueryValidationError extends Error { } /** - * 当前版本把“已终态”收敛为 `statusCode` 已落库。 - * - * 已知限制:在当前异步写入/丢 patch 的极端场景,或未来新增了 `durationMs` / `errorMessage` - * 已落库、但 `statusCode` 仍为空且已稳定结束的写路径时,这些记录会被当前可用性统计排除。 - * 届时应引入独立的 finalized 谓词,而不是直接放宽为 `durationMs IS NOT NULL`。 + * 可用性监控用 `statusCode IS NOT NULL` 作为终态边界。 + * 请求过程中的中间状态可能已经有 providerChain/errorMessage 片段,但仍不能算入可用性, + * 否则会把 requesting 中的请求误判为失败。success-rate outcome 仅用于终态记录的分类。 */ function buildAvailabilityFinalizedCondition() { - return sql`fn_is_message_request_finalized( - ${messageRequest.blockedBy}, - ${messageRequest.statusCode}, - ${messageRequest.providerChain}, - ${messageRequest.errorMessage} - )`; + return isNotNull(messageRequest.statusCode); } function assertValidDate(date: Date, fieldName: string): Date { diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index ac14b0013..582223f56 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -39,15 +39,45 @@ function normalizeSql(sqlObject: unknown): string { return sqlToString(sqlObject).replace(/\s+/g, " ").trim().toLowerCase(); } +function findCteBoundary( + queryText: string, + cteName: string +): { cteStart: number; boundaryStart: number } | null { + // cteName 先经长度/格式校验,再生成 escapedName 参与受控测试正则构造。 + if (queryText.length === 0 || cteName.length > 100 || !/^[a-z_][a-z0-9_]*$/i.test(cteName)) { + return null; + } + + const escapedName = cteName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = new RegExp(`(^|\\bwith\\s+|,\\s*)("?${escapedName}"?)\\s+as\\s*\\(`).exec( + queryText + ); + + if (!match?.[2]) { + return null; + } + + return { + cteStart: match.index + match[1].length, + boundaryStart: match.index, + }; +} + function extractFinalizedRequestsSql(queryText: string): string { - const start = queryText.indexOf("finalized_requests as"); - const end = queryText.indexOf("provider_bucket_stats as"); + const start = findCteBoundary(queryText, "finalized_requests"); + const end = findCteBoundary(queryText, "provider_bucket_stats"); - if (start === -1 || end === -1 || end <= start) { - throw new Error("Could not locate finalized_requests CTE in query text"); + if (!start) { + throw new Error("finalized_requests CTE not found in query text"); + } + if (!end) { + throw new Error("provider_bucket_stats CTE not found in query text"); + } + if (end.boundaryStart <= start.cteStart) { + throw new Error("finalized_requests CTE appears after provider_bucket_stats CTE"); } - return queryText.slice(start, end); + return queryText.slice(start.cteStart, end.boundaryStart); } describe("availability-service", () => { @@ -309,7 +339,7 @@ describe("availability-service", () => { const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); const finalizedRequestsSql = extractFinalizedRequestsSql(queryText); - expect(finalizedRequestsSql).toContain("fn_is_message_request_finalized"); + expect(finalizedRequestsSql).toContain('"status_code" is not null'); expect(queryText).toContain("group by"); expect(queryText).toContain("percentile_cont(0.95)"); expect(queryText).toContain("row_number() over"); @@ -482,7 +512,7 @@ describe("availability-service", () => { const finalizedRequestsSql = extractFinalizedRequestsSql( normalizeSql(executeMock.mock.calls[0]?.[0]) ); - expect(finalizedRequestsSql).toContain("fn_is_message_request_finalized"); + expect(finalizedRequestsSql).toContain('"status_code" is not null'); }); it("queryProviderAvailability 会保留 Gemini passthrough 终态(statusCode!=null 且 durationMs=null)", async () => { @@ -515,6 +545,7 @@ describe("availability-service", () => { const finalizedRequestsSql = extractFinalizedRequestsSql( normalizeSql(executeMock.mock.calls[0]?.[0]) ); + expect(finalizedRequestsSql).toContain('"status_code" is not null'); expect(finalizedRequestsSql).not.toMatch(/where .*duration_?ms.*is not null/); }); @@ -548,7 +579,7 @@ describe("availability-service", () => { const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); const finalizedRequestsSql = extractFinalizedRequestsSql(queryText); - expect(finalizedRequestsSql).toContain("fn_is_message_request_finalized"); + expect(finalizedRequestsSql).toContain('"status_code" is not null'); expect(queryText).toContain("fn_compute_message_request_success_rate_outcome"); expect(queryText).toContain(`"successrateoutcome" = 'failure'`); }); @@ -717,7 +748,7 @@ describe("availability-service", () => { ]); const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); - expect(queryText).toContain("fn_is_message_request_finalized"); + expect(queryText).toContain('"status_code" is not null'); expect(queryText).toContain(">= now() - (15 * interval '1 minute')"); expect(queryText).toContain("<= now()"); expect(queryText).toContain("count(*) filter");