From 7ea33820933e82685fe7e1bb3755812f9f69804a Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 14 May 2026 23:03:27 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E6=80=A7=E7=9B=91=E6=8E=A7=E7=BB=88=E6=80=81=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/availability/availability-service.ts | 17 +++++------------ tests/unit/lib/availability-service.test.ts | 9 +++++---- 2 files changed, 10 insertions(+), 16 deletions(-) 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..d1830e68c 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -309,7 +309,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 +482,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 +515,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 +549,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 +718,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"); From fa534887951c00ca13a43101cba7a04b561fcdf9 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 14 May 2026 23:25:54 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test:=20=E5=8A=A0=E5=9B=BA=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=20SQL=20=E6=96=AD=E8=A8=80=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/lib/availability-service.test.ts | 27 ++++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index d1830e68c..adfae4982 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -39,15 +39,34 @@ function normalizeSql(sqlObject: unknown): string { return sqlToString(sqlObject).replace(/\s+/g, " ").trim().toLowerCase(); } +function findCteBoundary( + queryText: string, + cteName: string +): { cteStart: number; boundaryStart: number } | null { + const escapedName = cteName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = new RegExp(`(?:^|\\bwith\\s+|,\\s*)("?${escapedName}"?)\\s+as\\s*\\(`).exec( + queryText + ); + + if (!match?.[1]) { + return null; + } + + return { + cteStart: match.index + match[0].indexOf(match[1]), + 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) { + if (!start || !end || end.boundaryStart <= start.cteStart) { throw new Error("Could not locate finalized_requests CTE in query text"); } - return queryText.slice(start, end); + return queryText.slice(start.cteStart, end.boundaryStart); } describe("availability-service", () => { From 039918d6d35ffe33ee652b27a7e8dee19d6be762 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 14 May 2026 23:33:17 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test:=20=E6=94=B6=E7=B4=A7=20CTE=20?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E5=8C=B9=E9=85=8D=E9=98=B2=E5=BE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/lib/availability-service.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index adfae4982..500936130 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -43,17 +43,21 @@ function findCteBoundary( queryText: string, cteName: string ): { cteStart: number; boundaryStart: number } | null { + 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( + const match = new RegExp(`(^|\\bwith\\s+|,\\s*)("?${escapedName}"?)\\s+as\\s*\\(`).exec( queryText ); - if (!match?.[1]) { + if (!match?.[2]) { return null; } return { - cteStart: match.index + match[0].indexOf(match[1]), + cteStart: match.index + match[1].length, boundaryStart: match.index, }; } From 9cad7c58c74ae7b5846cb87d688f35bc80e8d980 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 14 May 2026 23:43:05 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=E4=BC=98=E5=8C=96=20CTE=20?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E6=B5=8B=E8=AF=95=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/lib/availability-service.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index 500936130..582223f56 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -43,6 +43,7 @@ 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; } @@ -66,8 +67,14 @@ function extractFinalizedRequestsSql(queryText: string): string { const start = findCteBoundary(queryText, "finalized_requests"); const end = findCteBoundary(queryText, "provider_bucket_stats"); - if (!start || !end || end.boundaryStart <= start.cteStart) { - 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.cteStart, end.boundaryStart);