From 1f60d23c574f530c61bdcbc982edc2ac51fb7d56 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 15:06:50 +0800 Subject: [PATCH 01/45] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E8=AE=B0=E5=BD=95=E5=8D=A1=E5=9C=A8=E2=80=9C=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E4=B8=AD=E2=80=9D=E5=AF=BC=E8=87=B4=E5=B4=A9=E6=BA=83?= =?UTF-8?q?=20(#854)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - message_request 异步批量写入:补充校验与显式类型转换,数据类错误时降级写入并加队列上限保护 - 进程退出时尽力 flush 写入队列;启动定时 sweeper 封闭历史孤儿记录 - proxy-status 改为基于数据库聚合并限制活跃请求返回量,避免异常累积撑爆内存 - 增加单元测试覆盖 write buffer 与 orphan sealing --- src/instrumentation.ts | 70 +++ src/lib/proxy-status-tracker.ts | 8 +- src/repository/message-write-buffer.ts | 428 +++++++++++++++++- src/repository/message.ts | 67 ++- .../message-orphaned-requests.test.ts | 111 +++++ .../repository/message-write-buffer.test.ts | 34 ++ 6 files changed, 686 insertions(+), 32 deletions(-) create mode 100644 tests/unit/repository/message-orphaned-requests.test.ts diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 74afd79d1..a2fc047f0 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -17,6 +17,8 @@ const instrumentationState = globalThis as unknown as { __CCH_SHUTDOWN_IN_PROGRESS__?: boolean; __CCH_CLOUD_PRICE_SYNC_STARTED__?: boolean; __CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__?: ReturnType; + __CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_STARTED__?: boolean; + __CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_INTERVAL_ID__?: ReturnType; __CCH_API_KEY_VF_SYNC_STARTED__?: boolean; __CCH_API_KEY_VF_SYNC_CLEANUP__?: (() => void) | null; }; @@ -137,6 +139,54 @@ function warmupApiKeyVacuumFilter(): void { void startApiKeyVacuumFilterSync(); } +/** + * 封闭历史遗留的“孤儿请求”记录(duration/status 长期为空)。 + * + * 这些记录通常来自:进程被非优雅方式终止(OOM/SIGKILL),导致 async 批量写入的尾部更新丢失。 + * 若不处理,会被 Dashboard/统计视为“进行中”并持续累积,引发页面异常与资源风险。 + */ +async function startOrphanedMessageRequestSweeper(): Promise { + if (instrumentationState.__CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_STARTED__) { + return; + } + + try { + const { sealOrphanedMessageRequests } = await import("@/repository/message"); + const intervalMs = 60 * 1000; + + const runOnce = async (reason: "startup" | "scheduled") => { + try { + const { sealedCount } = await sealOrphanedMessageRequests(); + if (sealedCount > 0) { + logger.warn("[Instrumentation] Orphaned message_request records sealed", { + sealedCount, + reason, + }); + } + } catch (error) { + logger.warn("[Instrumentation] Failed to seal orphaned message_request records", { + reason, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + await runOnce("startup"); + instrumentationState.__CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_INTERVAL_ID__ = setInterval(() => { + void runOnce("scheduled"); + }, intervalMs); + + instrumentationState.__CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_STARTED__ = true; + logger.info("[Instrumentation] Orphaned message_request sweeper started", { + intervalSeconds: intervalMs / 1000, + }); + } catch (error) { + logger.warn("[Instrumentation] Orphaned message_request sweeper init failed", { + error: error instanceof Error ? error.message : String(error), + }); + } +} + export async function register() { // 仅在服务器端执行 if (process.env.NEXT_RUNTIME === "nodejs") { @@ -258,6 +308,24 @@ export async function register() { error: error instanceof Error ? error.message : String(error), }); } + + try { + if (instrumentationState.__CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_INTERVAL_ID__) { + clearInterval( + instrumentationState.__CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_INTERVAL_ID__ + ); + instrumentationState.__CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_INTERVAL_ID__ = undefined; + instrumentationState.__CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_STARTED__ = false; + } + } catch (error) { + logger.warn("[Instrumentation] Failed to stop orphaned message request sweeper", { + error: error instanceof Error ? error.message : String(error), + }); + } + + // 重要:注册 SIGTERM/SIGINT handler 会覆盖默认退出行为。 + // 若不显式退出,进程会在“半关闭”状态继续运行(例如异步写入队列已停止),导致后续日志长期异常。 + process.exit(0); }; process.once("SIGTERM", () => { @@ -314,6 +382,7 @@ export async function register() { }); warmupApiKeyVacuumFilter(); + await startOrphanedMessageRequestSweeper(); // 回填 provider_vendors/provider_endpoints(幂等) // 多实例启动时仅允许一个实例执行,避免重复扫描/写入导致的启动抖动(#779/#781)。 @@ -454,6 +523,7 @@ export async function register() { }); warmupApiKeyVacuumFilter(); + await startOrphanedMessageRequestSweeper(); // 回填 provider_vendors(按域名自动聚合旧 providers) try { diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index f449ccf1c..62c7605b1 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -1,4 +1,4 @@ -import { and, eq, isNull, sql } from "drizzle-orm"; +import { and, desc, eq, isNull, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys, messageRequest, providers, users } from "@/drizzle/schema"; import { maskKey } from "@/lib/utils/validation"; @@ -166,7 +166,11 @@ export class ProxyStatusTracker { isNull(messageRequest.durationMs), isNull(providers.deletedAt) ) - ); + ) + // 防御:异常情况下 durationMs 长期为空会导致“活跃请求”无限累积,进而撑爆查询与响应体。 + // 这里对返回明细做上限保护(监控用途不需要无穷列表)。 + .orderBy(desc(messageRequest.createdAt)) + .limit(1000); return rows as ActiveRequestRow[]; } diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index d2f690189..37c3b222a 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -64,6 +64,238 @@ const COLUMN_MAP: Record = { specialSettings: "special_settings", }; +const INT32_MAX = 2147483647; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function sanitizeInt32( + value: unknown, + options?: { min?: number; max?: number } +): number | undefined { + if (!isFiniteNumber(value)) { + return undefined; + } + + const truncated = Math.trunc(value); + const min = options?.min ?? -INT32_MAX - 1; + const max = options?.max ?? INT32_MAX; + + if (truncated < min) { + return min; + } + if (truncated > max) { + return max; + } + return truncated; +} + +function sanitizeNullableInt32( + value: unknown, + options?: { min?: number; max?: number } +): number | null | undefined { + if (value === null) { + return null; + } + return sanitizeInt32(value, options); +} + +function sanitizeNumericString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + if (trimmed.length === 0) { + return undefined; + } + + // 允许常见十进制与科学计数法,拒绝 NaN/Infinity/空白/十六进制等异常输入 + const numericLike = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(trimmed); + if (!numericLike) { + return undefined; + } + + return trimmed; +} + +function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePatch { + const sanitized: MessageRequestUpdatePatch = {}; + + const durationMs = sanitizeInt32(patch.durationMs, { min: 0, max: INT32_MAX }); + if (durationMs !== undefined) { + sanitized.durationMs = durationMs; + } + + const statusCode = sanitizeInt32(patch.statusCode, { min: 0, max: 999 }); + if (statusCode !== undefined) { + sanitized.statusCode = statusCode; + } + + const inputTokens = sanitizeInt32(patch.inputTokens, { min: 0, max: INT32_MAX }); + if (inputTokens !== undefined) { + sanitized.inputTokens = inputTokens; + } + + const outputTokens = sanitizeInt32(patch.outputTokens, { min: 0, max: INT32_MAX }); + if (outputTokens !== undefined) { + sanitized.outputTokens = outputTokens; + } + + const ttfbMs = sanitizeNullableInt32(patch.ttfbMs, { min: 0, max: INT32_MAX }); + if (ttfbMs !== undefined) { + sanitized.ttfbMs = ttfbMs; + } + + const cacheCreationInputTokens = sanitizeInt32(patch.cacheCreationInputTokens, { + min: 0, + max: INT32_MAX, + }); + if (cacheCreationInputTokens !== undefined) { + sanitized.cacheCreationInputTokens = cacheCreationInputTokens; + } + + const cacheReadInputTokens = sanitizeInt32(patch.cacheReadInputTokens, { + min: 0, + max: INT32_MAX, + }); + if (cacheReadInputTokens !== undefined) { + sanitized.cacheReadInputTokens = cacheReadInputTokens; + } + + const cacheCreation5mInputTokens = sanitizeInt32(patch.cacheCreation5mInputTokens, { + min: 0, + max: INT32_MAX, + }); + if (cacheCreation5mInputTokens !== undefined) { + sanitized.cacheCreation5mInputTokens = cacheCreation5mInputTokens; + } + + const cacheCreation1hInputTokens = sanitizeInt32(patch.cacheCreation1hInputTokens, { + min: 0, + max: INT32_MAX, + }); + if (cacheCreation1hInputTokens !== undefined) { + sanitized.cacheCreation1hInputTokens = cacheCreation1hInputTokens; + } + + if (patch.cacheTtlApplied === null) { + sanitized.cacheTtlApplied = null; + } else if (typeof patch.cacheTtlApplied === "string") { + sanitized.cacheTtlApplied = patch.cacheTtlApplied; + } + + const costUsd = sanitizeNumericString(patch.costUsd); + if (costUsd !== undefined) { + sanitized.costUsd = costUsd; + } + + if (patch.providerChain !== undefined) { + if (!Array.isArray(patch.providerChain)) { + logger.warn("[MessageRequestWriteBuffer] Invalid providerChain type, skipping", { + providerChainType: typeof patch.providerChain, + }); + } else { + try { + JSON.stringify(patch.providerChain); + sanitized.providerChain = patch.providerChain; + } catch (error) { + logger.warn("[MessageRequestWriteBuffer] Invalid providerChain, skipping", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + + if (typeof patch.errorMessage === "string") { + sanitized.errorMessage = patch.errorMessage; + } + if (typeof patch.errorStack === "string") { + sanitized.errorStack = patch.errorStack; + } + if (typeof patch.errorCause === "string") { + sanitized.errorCause = patch.errorCause; + } + if (typeof patch.model === "string") { + sanitized.model = patch.model; + } + + const providerId = sanitizeInt32(patch.providerId, { min: 0, max: INT32_MAX }); + if (providerId !== undefined) { + sanitized.providerId = providerId; + } + + if (typeof patch.context1mApplied === "boolean") { + sanitized.context1mApplied = patch.context1mApplied; + } + if (typeof patch.swapCacheTtlApplied === "boolean") { + sanitized.swapCacheTtlApplied = patch.swapCacheTtlApplied; + } + + if (patch.specialSettings === null) { + sanitized.specialSettings = null; + } else if (patch.specialSettings !== undefined) { + try { + JSON.stringify(patch.specialSettings); + sanitized.specialSettings = patch.specialSettings; + } catch (error) { + logger.warn("[MessageRequestWriteBuffer] Invalid specialSettings, skipping", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return sanitized; +} + +function isTerminalPatch(patch: MessageRequestUpdatePatch): boolean { + return patch.durationMs !== undefined || patch.statusCode !== undefined; +} + +function getErrorCode(error: unknown): string | null { + if (!error || typeof error !== "object") { + return null; + } + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : null; +} + +function isDataRelatedDbError(error: unknown): boolean { + const code = getErrorCode(error); + if (!code) { + return false; + } + + // 仅对“数据/约束类”错误做隔离处理,避免对连接/暂态问题造成额外压力 + return code.startsWith("22") || code.startsWith("23"); +} + +function getSafePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePatch { + // 刻意排除 costUsd/providerChain/specialSettings:这些字段更容易引发类型/JSON 异常 + const { + costUsd: _costUsd, + providerChain: _providerChain, + specialSettings: _specialSettings, + ...rest + } = patch; + return rest; +} + +function getMinimalPatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePatch { + const minimal: MessageRequestUpdatePatch = {}; + if (patch.durationMs !== undefined) { + minimal.durationMs = patch.durationMs; + } + if (patch.statusCode !== undefined) { + minimal.statusCode = patch.statusCode; + } + if (patch.errorMessage !== undefined) { + minimal.errorMessage = patch.errorMessage; + } + return minimal; +} + function loadWriterConfig(): WriterConfig { const env = getEnvConfig(); return { @@ -108,8 +340,19 @@ function buildBatchUpdateSql(updates: MessageRequestUpdateRecord[]): SQL | null cases.push(sql`WHEN ${update.id} THEN NULL`); continue; } - const json = JSON.stringify(value); - cases.push(sql`WHEN ${update.id} THEN ${json}::jsonb`); + try { + const json = JSON.stringify(value); + cases.push(sql`WHEN ${update.id} THEN ${json}::jsonb`); + } catch (error) { + logger.warn( + "[MessageRequestWriteBuffer] Failed to stringify JSON patch field, skipping", + { + requestId: update.id, + field: key, + error: error instanceof Error ? error.message : String(error), + } + ); + } continue; } @@ -163,16 +406,13 @@ class MessageRequestWriteBuffer { } enqueue(id: number, patch: MessageRequestUpdatePatch): void { - const existing = this.pending.get(id) ?? {}; - const merged: MessageRequestUpdatePatch = { ...existing }; - for (const [k, v] of Object.entries(patch) as Array< - [keyof MessageRequestUpdatePatch, MessageRequestUpdatePatch[keyof MessageRequestUpdatePatch]] - >) { - if (v !== undefined) { - merged[k] = v as never; - } + const sanitized = sanitizePatch(patch); + if (Object.keys(sanitized).length === 0) { + return; } - this.pending.set(id, merged); + + const existing = this.pending.get(id) ?? {}; + this.pending.set(id, { ...existing, ...sanitized }); // 队列上限保护:DB 异常时避免无限增长导致 OOM if (this.pending.size > this.config.maxPending) { @@ -217,7 +457,8 @@ class MessageRequestWriteBuffer { // 停止阶段不再调度 timer,避免阻止进程退出 if (!this.stopping) { - this.ensureFlushTimer(); + // 终态 patch 尽快落库,减少 duration/status 为空的“悬挂窗口” + this.ensureFlushTimer(isTerminalPatch(sanitized) ? 0 : undefined); } // 达到批量阈值时尽快 flush,降低 durationMs 为空的“悬挂时间” @@ -226,15 +467,25 @@ class MessageRequestWriteBuffer { } } - private ensureFlushTimer(): void { - if (this.stopping || this.flushTimer) { + private ensureFlushTimer(delayMs?: number): void { + if (this.stopping) { return; } + const delay = Math.max(0, delayMs ?? this.config.flushIntervalMs); + + if (this.flushTimer) { + if (delay === 0) { + this.clearFlushTimer(); + } else { + return; + } + } + this.flushTimer = setTimeout(() => { this.flushTimer = null; void this.flush(); - }, this.config.flushIntervalMs); + }, delay); } private clearFlushTimer(): void { @@ -244,6 +495,14 @@ class MessageRequestWriteBuffer { } } + private requeueBatchForRetry(batch: MessageRequestUpdateRecord[]): void { + // 合并策略:保留“更新更晚”的字段(existing 优先),避免覆盖新数据 + for (const item of batch) { + const existing = this.pending.get(item.id) ?? {}; + this.pending.set(item.id, { ...item.patch, ...existing }); + } + } + async flush(): Promise { if (this.flushInFlight) { this.flushAgainAfterCurrent = true; @@ -259,7 +518,21 @@ class MessageRequestWriteBuffer { while (this.pending.size > 0) { const batch = takeBatch(this.pending, this.config.batchSize); - const query = buildBatchUpdateSql(batch); + let query: SQL | null = null; + + try { + query = buildBatchUpdateSql(batch); + } catch (error) { + // 极端场景:构建 SQL 失败(例如非预期类型)。回队列稍后重试,避免直接抛错影响请求链路。 + this.requeueBatchForRetry(batch); + logger.error("[MessageRequestWriteBuffer] Build batch SQL failed, will retry later", { + error: error instanceof Error ? error.message : String(error), + pending: this.pending.size, + batchSize: batch.length, + }); + break; + } + if (!query) { continue; } @@ -267,15 +540,121 @@ class MessageRequestWriteBuffer { try { await db.execute(query); } catch (error) { - // 失败重试:将 batch 放回队列 - // 合并策略:保留“更新更晚”的字段(existing 优先),避免覆盖新数据 - for (const item of batch) { - const existing = this.pending.get(item.id) ?? {}; - this.pending.set(item.id, { ...item.patch, ...existing }); + if (isDataRelatedDbError(error)) { + logger.error( + "[MessageRequestWriteBuffer] Flush failed with data error, falling back to per-item writes", + { + error: error instanceof Error ? error.message : String(error), + errorCode: getErrorCode(error), + pending: this.pending.size, + batchSize: batch.length, + } + ); + + let shouldRetryLater = false; + + for (let index = 0; index < batch.length; index++) { + const item = batch[index]; + if (!item) { + continue; + } + + const tryExecute = async (patch: MessageRequestUpdatePatch) => { + const singleQuery = buildBatchUpdateSql([{ id: item.id, patch }]); + if (!singleQuery) { + return; + } + await db.execute(singleQuery); + }; + + try { + await tryExecute(item.patch); + } catch (singleError) { + if (!isDataRelatedDbError(singleError)) { + // 连接/暂态问题:把当前及剩余条目回队列,留待下次 flush + this.requeueBatchForRetry(batch.slice(index)); + logger.error( + "[MessageRequestWriteBuffer] Per-item flush hit transient error, will retry", + { + error: + singleError instanceof Error ? singleError.message : String(singleError), + errorCode: getErrorCode(singleError), + pending: this.pending.size, + } + ); + shouldRetryLater = true; + break; + } + + const safePatch = getSafePatch(item.patch); + try { + await tryExecute(safePatch); + } catch (safeError) { + if (!isDataRelatedDbError(safeError)) { + this.requeueBatchForRetry(batch.slice(index)); + logger.error( + "[MessageRequestWriteBuffer] Per-item safe flush hit transient error, will retry", + { + error: safeError instanceof Error ? safeError.message : String(safeError), + errorCode: getErrorCode(safeError), + pending: this.pending.size, + } + ); + shouldRetryLater = true; + break; + } + + const minimalPatch = getMinimalPatch(item.patch); + try { + await tryExecute(minimalPatch); + } catch (minimalError) { + if (!isDataRelatedDbError(minimalError)) { + this.requeueBatchForRetry(batch.slice(index)); + logger.error( + "[MessageRequestWriteBuffer] Per-item minimal flush hit transient error, will retry", + { + error: + minimalError instanceof Error + ? minimalError.message + : String(minimalError), + errorCode: getErrorCode(minimalError), + pending: this.pending.size, + } + ); + shouldRetryLater = true; + break; + } + + // 数据持续异常:丢弃该条更新,避免拖死整个队列(后续由 sweeper 兜底封闭) + logger.error( + "[MessageRequestWriteBuffer] Dropping invalid update to unblock queue", + { + requestId: item.id, + error: + minimalError instanceof Error + ? minimalError.message + : String(minimalError), + errorCode: getErrorCode(minimalError), + } + ); + } + } + } + } + + if (shouldRetryLater) { + break; + } + + continue; } + // 失败重试:将 batch 放回队列 + this.requeueBatchForRetry(batch); + logger.error("[MessageRequestWriteBuffer] Flush failed, will retry later", { error: error instanceof Error ? error.message : String(error), + errorCode: getErrorCode(error), pending: this.pending.size, batchSize: batch.length, }); @@ -320,16 +699,17 @@ function getBuffer(): MessageRequestWriteBuffer | null { return _buffer; } -export function enqueueMessageRequestUpdate(id: number, patch: MessageRequestUpdatePatch): void { +export function enqueueMessageRequestUpdate(id: number, patch: MessageRequestUpdatePatch): boolean { // 只在 async 模式下启用队列,避免额外内存/定时器开销 if (getEnvConfig().MESSAGE_REQUEST_WRITE_MODE !== "async") { - return; + return false; } const buffer = getBuffer(); if (!buffer) { - return; + return false; } buffer.enqueue(id, patch); + return true; } export async function flushMessageRequestWriteBuffer(): Promise { diff --git a/src/repository/message.ts b/src/repository/message.ts index 32a849e6d..559a0994f 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -75,8 +75,7 @@ export async function createMessageRequest( * 更新消息请求的耗时 */ export async function updateMessageRequestDuration(id: number, durationMs: number): Promise { - if (getEnvConfig().MESSAGE_REQUEST_WRITE_MODE === "async") { - enqueueMessageRequestUpdate(id, { durationMs }); + if (enqueueMessageRequestUpdate(id, { durationMs })) { return; } @@ -101,8 +100,7 @@ export async function updateMessageRequestCost( return; } - if (getEnvConfig().MESSAGE_REQUEST_WRITE_MODE === "async") { - enqueueMessageRequestUpdate(id, { costUsd: formattedCost }); + if (enqueueMessageRequestUpdate(id, { costUsd: formattedCost })) { return; } @@ -141,8 +139,7 @@ export async function updateMessageRequestDetails( specialSettings?: CreateMessageRequestData["special_settings"]; // 特殊设置(审计/展示) } ): Promise { - if (getEnvConfig().MESSAGE_REQUEST_WRITE_MODE === "async") { - enqueueMessageRequestUpdate(id, details); + if (enqueueMessageRequestUpdate(id, details)) { return; } @@ -208,6 +205,64 @@ export async function updateMessageRequestDetails( await db.update(messageRequest).set(updateData).where(eq(messageRequest.id, id)); } +/** + * 封闭“孤儿请求”记录(防御性修复) + * + * 在 MESSAGE_REQUEST_WRITE_MODE=async 时,请求终态信息(duration/status/tokens/cost 等)会先进入内存队列, + * 再异步批量刷入数据库。若进程被 OOM Killer/SIGKILL 等非优雅方式终止,尾部更新会丢失, + * 导致 message_request 记录长期保持“请求中”(duration_ms 长期为空;status_code 也可能为空或缺失)。 + * + * 影响: + * - Dashboard/统计把这些记录当作“进行中”,导致异常展示与聚合膨胀; + * - 某些页面会高频轮询“活跃请求”,在孤儿记录持续累积时可能引发内存与性能风险。 + * + * 本函数会把超过阈值仍未落下终态的记录标记为已结束(未知失败),避免无限累积。 + */ +export async function sealOrphanedMessageRequests(options?: { + staleAfterMs?: number; + limit?: number; +}): Promise<{ sealedCount: number }> { + const env = getEnvConfig(); + + const staleAfterMs = Math.max(60_000, options?.staleAfterMs ?? env.FETCH_BODY_TIMEOUT + 60_000); + const limit = Math.max(1, options?.limit ?? 1000); + const threshold = new Date(Date.now() - staleAfterMs); + + const ORPHANED_STATUS_CODE = 520; + const ORPHANED_ERROR_MESSAGE = "ORPHANED_REQUEST"; + + const query = sql<{ id: number }>` + WITH candidates AS ( + SELECT id + FROM message_request + WHERE deleted_at IS NULL + AND duration_ms IS NULL + AND created_at < ${threshold} + AND ${EXCLUDE_WARMUP_CONDITION} + ORDER BY created_at ASC + LIMIT ${limit} + ) + UPDATE message_request + SET + duration_ms = ( + LEAST( + 2147483647, + GREATEST(0, (EXTRACT(EPOCH FROM (NOW() - created_at)) * 1000)) + )::int + ), + status_code = COALESCE(status_code, ${ORPHANED_STATUS_CODE}), + error_message = COALESCE(error_message, ${ORPHANED_ERROR_MESSAGE}), + updated_at = NOW() + WHERE id IN (SELECT id FROM candidates) + RETURNING id + `; + + const result = await db.execute(query); + const sealed = Array.from(result); + + return { sealedCount: sealed.length }; +} + /** * 根据用户ID查询消息请求记录(分页) */ diff --git a/tests/unit/repository/message-orphaned-requests.test.ts b/tests/unit/repository/message-orphaned-requests.test.ts new file mode 100644 index 000000000..e7da1ce3f --- /dev/null +++ b/tests/unit/repository/message-orphaned-requests.test.ts @@ -0,0 +1,111 @@ +import { CasingCache } from "drizzle-orm/casing"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type EnvSnapshot = Partial>; + +function snapshotEnv(keys: string[]): EnvSnapshot { + const snapshot: EnvSnapshot = {}; + for (const key of keys) { + snapshot[key] = process.env[key]; + } + return snapshot; +} + +function restoreEnv(snapshot: EnvSnapshot) { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +function toSqlText(query: { toQuery: (config: any) => { sql: string; params: unknown[] } }) { + return query.toQuery({ + casing: new CasingCache(), + escapeName: (name: string) => `"${name}"`, + escapeParam: (index: number) => `$${index}`, + escapeString: (value: string) => `'${value}'`, + paramStartIndex: { value: 1 }, + }); +} + +describe("sealOrphanedMessageRequests", () => { + const envKeys = ["NODE_ENV", "DSN", "FETCH_BODY_TIMEOUT"]; + const originalEnv = snapshotEnv(envKeys); + + const executeMock = vi.fn(async () => [{ id: 1 }, { id: 2 }]); + + beforeEach(() => { + vi.resetModules(); + executeMock.mockClear(); + + process.env.NODE_ENV = "test"; + process.env.DSN = "postgres://postgres:postgres@localhost:5432/claude_code_hub_test"; + process.env.FETCH_BODY_TIMEOUT = "1000"; + + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + vi.doMock("@/drizzle/db", () => ({ + db: { + execute: executeMock, + // 避免 tests/setup.ts 的 afterAll 清理逻辑因 mock 缺失 select 而报错 + select: () => ({ + from: () => ({ + where: async () => [], + }), + }), + }, + })); + }); + + afterEach(() => { + vi.useRealTimers(); + restoreEnv(originalEnv); + }); + + it("应批量封闭超时仍未落终态的 message_request 并返回 sealedCount", async () => { + const { sealOrphanedMessageRequests } = await import("@/repository/message"); + + const result = await sealOrphanedMessageRequests({ staleAfterMs: 10, limit: 5 }); + + expect(result.sealedCount).toBe(2); + expect(executeMock).toHaveBeenCalledTimes(1); + + const query = executeMock.mock.calls[0]?.[0]; + const built = toSqlText(query); + + expect(built.sql).toContain("UPDATE message_request"); + expect(built.sql).toContain("duration_ms IS NULL"); + expect(built.sql).toContain("created_at <"); + expect(built.sql).toContain("status_code ="); + expect(built.sql).toContain("error_message ="); + expect(built.sql).toContain("LIMIT"); + + expect(built.params).toContain(520); + expect(built.params).toContain("ORPHANED_REQUEST"); + expect(built.params).toContain(5); + + const threshold = built.params.find((p) => p instanceof Date) as Date | undefined; + expect(threshold).toBeInstanceOf(Date); + expect(threshold?.toISOString()).toBe("2025-12-31T23:59:00.000Z"); + }); + + it("默认 staleAfterMs 应基于 FETCH_BODY_TIMEOUT + 60s 且不低于 60s", async () => { + const { sealOrphanedMessageRequests } = await import("@/repository/message"); + + await sealOrphanedMessageRequests(); + + expect(executeMock).toHaveBeenCalledTimes(1); + + const query = executeMock.mock.calls[0]?.[0]; + const built = toSqlText(query); + + const threshold = built.params.find((p) => p instanceof Date) as Date | undefined; + expect(threshold).toBeInstanceOf(Date); + // FETCH_BODY_TIMEOUT=1000ms -> staleAfterMs=61000ms + expect(threshold?.toISOString()).toBe("2025-12-31T23:58:59.000Z"); + }); +}); diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index 17f5ab192..def16f06d 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -143,6 +143,40 @@ describe("message_request 异步批量写入", () => { expect(built.sql).toContain("::jsonb"); }); + it("遇到数据类 DB 错误时应降级写入并避免卡死队列", async () => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + + executeMock.mockImplementation(async (query) => { + const built = toSqlText(query); + if (built.sql.includes("::numeric")) { + const error: { code?: string } = new Error("invalid input syntax for type numeric"); + error.code = "22P02"; + throw error; + } + return []; + }); + + const { enqueueMessageRequestUpdate, stopMessageRequestWriteBuffer } = await import( + "@/repository/message-write-buffer" + ); + + enqueueMessageRequestUpdate(1, { durationMs: 123, statusCode: 200, costUsd: "0.000123" }); + + await stopMessageRequestWriteBuffer(); + + expect(executeMock).toHaveBeenCalledTimes(3); + + const firstQuery = executeMock.mock.calls[0]?.[0]; + const thirdQuery = executeMock.mock.calls[2]?.[0]; + const firstBuilt = toSqlText(firstQuery); + const thirdBuilt = toSqlText(thirdQuery); + + expect(firstBuilt.sql).toContain("::numeric"); + expect(thirdBuilt.sql).not.toContain("::numeric"); + expect(thirdBuilt.sql).toContain("duration_ms"); + expect(thirdBuilt.sql).toContain("status_code"); + }); + it("stop 应等待 in-flight flush 完成", async () => { process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; From eb6916231fe424017d54798edbd06ef499281627 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 15:27:10 +0800 Subject: [PATCH 02/45] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E5=86=99=E5=85=A5=E5=85=A5=E9=98=9F=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E5=B9=B6=E5=A2=9E=E5=BC=BA=E5=B0=81=E9=97=AD=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - enqueueMessageRequestUpdate 返回值严格反映 patch 是否被接受,避免 sanitize 为空时误判导致丢失更新 - sealOrphanedMessageRequests UPDATE 增加并发保护条件,避免覆盖已完成记录 - proxy-status 命中活跃请求 limit 时记录告警,便于运维发现截断 - orphan sweeper 启动改为后台执行并加入 inFlight 防重入 - 增加单元测试覆盖入队返回值语义 --- src/instrumentation.ts | 9 ++++++++- src/lib/proxy-status-tracker.ts | 15 ++++++++++++++- src/repository/message-write-buffer.ts | 15 ++++++++++----- src/repository/message.ts | 2 ++ .../unit/repository/message-write-buffer.test.ts | 15 +++++++++++++++ 5 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/instrumentation.ts b/src/instrumentation.ts index a2fc047f0..3cf22a9f7 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -154,7 +154,12 @@ async function startOrphanedMessageRequestSweeper(): Promise { const { sealOrphanedMessageRequests } = await import("@/repository/message"); const intervalMs = 60 * 1000; + let inFlight = false; const runOnce = async (reason: "startup" | "scheduled") => { + if (inFlight) { + return; + } + inFlight = true; try { const { sealedCount } = await sealOrphanedMessageRequests(); if (sealedCount > 0) { @@ -168,10 +173,12 @@ async function startOrphanedMessageRequestSweeper(): Promise { reason, error: error instanceof Error ? error.message : String(error), }); + } finally { + inFlight = false; } }; - await runOnce("startup"); + void runOnce("startup"); instrumentationState.__CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_INTERVAL_ID__ = setInterval(() => { void runOnce("scheduled"); }, intervalMs); diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index 62c7605b1..6998c621b 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -1,6 +1,7 @@ import { and, desc, eq, isNull, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys, messageRequest, providers, users } from "@/drizzle/schema"; +import { logger } from "@/lib/logger"; import { maskKey } from "@/lib/utils/validation"; import type { ProxyStatusResponse } from "@/types/proxy-status"; @@ -146,6 +147,8 @@ export class ProxyStatusTracker { } private async loadActiveRequests(): Promise { + const limit = 1000; + const rows = await db .select({ requestId: messageRequest.id, @@ -170,7 +173,17 @@ export class ProxyStatusTracker { // 防御:异常情况下 durationMs 长期为空会导致“活跃请求”无限累积,进而撑爆查询与响应体。 // 这里对返回明细做上限保护(监控用途不需要无穷列表)。 .orderBy(desc(messageRequest.createdAt)) - .limit(1000); + .limit(limit); + + if (rows.length >= limit) { + logger.warn( + "[ProxyStatusTracker] Active requests query hit limit, results may be incomplete", + { + limit, + rowCount: rows.length, + } + ); + } return rows as ActiveRequestRow[]; } diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index 37c3b222a..08321fd05 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -405,14 +405,15 @@ class MessageRequestWriteBuffer { this.config = config; } - enqueue(id: number, patch: MessageRequestUpdatePatch): void { + enqueue(id: number, patch: MessageRequestUpdatePatch): boolean { const sanitized = sanitizePatch(patch); if (Object.keys(sanitized).length === 0) { - return; + return false; } const existing = this.pending.get(id) ?? {}; this.pending.set(id, { ...existing, ...sanitized }); + let accepted = true; // 队列上限保护:DB 异常时避免无限增长导致 OOM if (this.pending.size > this.config.maxPending) { @@ -440,6 +441,9 @@ class MessageRequestWriteBuffer { if (droppedId !== undefined) { this.pending.delete(droppedId); + if (droppedId === id) { + accepted = false; + } logger.warn("[MessageRequestWriteBuffer] Pending queue overflow, dropping update", { maxPending: this.config.maxPending, droppedId, @@ -452,7 +456,7 @@ class MessageRequestWriteBuffer { // flush 过程中有新任务:标记需要再跑一轮(避免刚好 flush 完成时遗漏) if (this.flushInFlight) { this.flushAgainAfterCurrent = true; - return; + return accepted; } // 停止阶段不再调度 timer,避免阻止进程退出 @@ -465,6 +469,8 @@ class MessageRequestWriteBuffer { if (this.pending.size >= this.config.batchSize) { void this.flush(); } + + return accepted; } private ensureFlushTimer(delayMs?: number): void { @@ -708,8 +714,7 @@ export function enqueueMessageRequestUpdate(id: number, patch: MessageRequestUpd if (!buffer) { return false; } - buffer.enqueue(id, patch); - return true; + return buffer.enqueue(id, patch); } export async function flushMessageRequestWriteBuffer(): Promise { diff --git a/src/repository/message.ts b/src/repository/message.ts index 559a0994f..122ce1ee0 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -254,6 +254,8 @@ export async function sealOrphanedMessageRequests(options?: { error_message = COALESCE(error_message, ${ORPHANED_ERROR_MESSAGE}), updated_at = NOW() WHERE id IN (SELECT id FROM candidates) + AND duration_ms IS NULL + AND deleted_at IS NULL RETURNING id `; diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index def16f06d..589ea8722 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -272,6 +272,21 @@ describe("message_request 异步批量写入", () => { expect(built.sql).toContain("status_code"); }); + it("enqueueMessageRequestUpdate 的返回值应反映 patch 是否被接受(sanitize 为空时返回 false)", async () => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + + const { enqueueMessageRequestUpdate, stopMessageRequestWriteBuffer } = await import( + "@/repository/message-write-buffer" + ); + + const accepted = enqueueMessageRequestUpdate(1, { durationMs: Number.NaN }); + + await stopMessageRequestWriteBuffer(); + + expect(accepted).toBe(false); + expect(executeMock).not.toHaveBeenCalled(); + }); + it("队列溢出时应优先丢弃非终态更新(尽量保留 durationMs)", async () => { process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; process.env.MESSAGE_REQUEST_ASYNC_MAX_PENDING = "100"; From b8be549adbae91e09f743fd11c6b67110ddf7e76 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 15:46:55 +0800 Subject: [PATCH 03/45] =?UTF-8?q?fix:=20=E5=8C=BA=E5=88=86=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E5=85=A5=E9=98=9F=E7=BB=93=E6=9E=9C=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E6=97=A0=E6=95=88=20patch=20=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - enqueueMessageRequestUpdate 返回区分结果(enqueued/rejected_invalid/buffer_unavailable/dropped_overflow) - message.ts 仅在 buffer_unavailable 时才走同步写入,避免 invalid/overflow 场景误触发 db.update - 更新单元测试覆盖 rejected_invalid 分支 --- src/repository/message-write-buffer.ts | 27 ++++++++++++------- src/repository/message.ts | 6 ++--- .../repository/message-write-buffer.test.ts | 4 +-- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index 08321fd05..539a87591 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -30,6 +30,12 @@ export type MessageRequestUpdatePatch = { specialSettings?: CreateMessageRequestData["special_settings"]; }; +export type MessageRequestUpdateEnqueueResult = + | "enqueued" + | "rejected_invalid" + | "buffer_unavailable" + | "dropped_overflow"; + type MessageRequestUpdateRecord = { id: number; patch: MessageRequestUpdatePatch; @@ -405,15 +411,15 @@ class MessageRequestWriteBuffer { this.config = config; } - enqueue(id: number, patch: MessageRequestUpdatePatch): boolean { + enqueue(id: number, patch: MessageRequestUpdatePatch): MessageRequestUpdateEnqueueResult { const sanitized = sanitizePatch(patch); if (Object.keys(sanitized).length === 0) { - return false; + return "rejected_invalid"; } const existing = this.pending.get(id) ?? {}; this.pending.set(id, { ...existing, ...sanitized }); - let accepted = true; + let result: MessageRequestUpdateEnqueueResult = "enqueued"; // 队列上限保护:DB 异常时避免无限增长导致 OOM if (this.pending.size > this.config.maxPending) { @@ -442,7 +448,7 @@ class MessageRequestWriteBuffer { if (droppedId !== undefined) { this.pending.delete(droppedId); if (droppedId === id) { - accepted = false; + result = "dropped_overflow"; } logger.warn("[MessageRequestWriteBuffer] Pending queue overflow, dropping update", { maxPending: this.config.maxPending, @@ -456,7 +462,7 @@ class MessageRequestWriteBuffer { // flush 过程中有新任务:标记需要再跑一轮(避免刚好 flush 完成时遗漏) if (this.flushInFlight) { this.flushAgainAfterCurrent = true; - return accepted; + return result; } // 停止阶段不再调度 timer,避免阻止进程退出 @@ -470,7 +476,7 @@ class MessageRequestWriteBuffer { void this.flush(); } - return accepted; + return result; } private ensureFlushTimer(delayMs?: number): void { @@ -705,14 +711,17 @@ function getBuffer(): MessageRequestWriteBuffer | null { return _buffer; } -export function enqueueMessageRequestUpdate(id: number, patch: MessageRequestUpdatePatch): boolean { +export function enqueueMessageRequestUpdate( + id: number, + patch: MessageRequestUpdatePatch +): MessageRequestUpdateEnqueueResult { // 只在 async 模式下启用队列,避免额外内存/定时器开销 if (getEnvConfig().MESSAGE_REQUEST_WRITE_MODE !== "async") { - return false; + return "buffer_unavailable"; } const buffer = getBuffer(); if (!buffer) { - return false; + return "buffer_unavailable"; } return buffer.enqueue(id, patch); } diff --git a/src/repository/message.ts b/src/repository/message.ts index 122ce1ee0..b7eb443df 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -75,7 +75,7 @@ export async function createMessageRequest( * 更新消息请求的耗时 */ export async function updateMessageRequestDuration(id: number, durationMs: number): Promise { - if (enqueueMessageRequestUpdate(id, { durationMs })) { + if (enqueueMessageRequestUpdate(id, { durationMs }) !== "buffer_unavailable") { return; } @@ -100,7 +100,7 @@ export async function updateMessageRequestCost( return; } - if (enqueueMessageRequestUpdate(id, { costUsd: formattedCost })) { + if (enqueueMessageRequestUpdate(id, { costUsd: formattedCost }) !== "buffer_unavailable") { return; } @@ -139,7 +139,7 @@ export async function updateMessageRequestDetails( specialSettings?: CreateMessageRequestData["special_settings"]; // 特殊设置(审计/展示) } ): Promise { - if (enqueueMessageRequestUpdate(id, details)) { + if (enqueueMessageRequestUpdate(id, details) !== "buffer_unavailable") { return; } diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index 589ea8722..8d0bd1e57 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -279,11 +279,11 @@ describe("message_request 异步批量写入", () => { "@/repository/message-write-buffer" ); - const accepted = enqueueMessageRequestUpdate(1, { durationMs: Number.NaN }); + const result = enqueueMessageRequestUpdate(1, { durationMs: Number.NaN }); await stopMessageRequestWriteBuffer(); - expect(accepted).toBe(false); + expect(result).toBe("rejected_invalid"); expect(executeMock).not.toHaveBeenCalled(); }); From 99d95a19b55c041e21dbb4f3d95bd2d1a1d1e39f Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 16:04:23 +0800 Subject: [PATCH 04/45] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=E9=98=9F?= =?UTF-8?q?=E5=88=97=E6=BA=A2=E5=87=BA=E7=BB=88=E6=80=81=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E5=B9=B6=E5=A2=9E=E5=BC=BA=E5=B0=81=E9=97=AD=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - overflow 淘汰逻辑改用 isTerminalPatch,避免误删仅含 statusCode 的终态 patch - rejected_invalid 增加节流告警日志,便于排查上游数据问题 - sealOrphanedMessageRequests 对 staleAfterMs/limit/FETCH_BODY_TIMEOUT 做 finite/int 归一化,避免 NaN 传播 - 调整单元测试覆盖非终态淘汰 --- src/repository/message-write-buffer.ts | 17 ++++++++++++++--- src/repository/message.ts | 15 +++++++++++++-- .../repository/message-write-buffer.test.ts | 4 ++-- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index 539a87591..252a4e0b1 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -72,6 +72,9 @@ const COLUMN_MAP: Record = { const INT32_MAX = 2147483647; +const REJECTED_INVALID_LOG_THROTTLE_MS = 60_000; +let _lastRejectedInvalidLogAt = 0; + function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } @@ -414,6 +417,14 @@ class MessageRequestWriteBuffer { enqueue(id: number, patch: MessageRequestUpdatePatch): MessageRequestUpdateEnqueueResult { const sanitized = sanitizePatch(patch); if (Object.keys(sanitized).length === 0) { + const now = Date.now(); + if (now - _lastRejectedInvalidLogAt > REJECTED_INVALID_LOG_THROTTLE_MS) { + _lastRejectedInvalidLogAt = now; + logger.warn("[MessageRequestWriteBuffer] Patch rejected: empty after sanitize", { + requestId: id, + originalKeys: Object.keys(patch), + }); + } return "rejected_invalid"; } @@ -423,12 +434,12 @@ class MessageRequestWriteBuffer { // 队列上限保护:DB 异常时避免无限增长导致 OOM if (this.pending.size > this.config.maxPending) { - // 优先丢弃非“终态”更新(没有 durationMs 的条目),尽量保留请求完成信息 + // 优先丢弃非“终态”更新(不含 durationMs/statusCode 的条目),尽量保留请求完成信息 let droppedId: number | undefined; let droppedPatch: MessageRequestUpdatePatch | undefined; for (const [candidateId, candidatePatch] of this.pending) { - if (candidatePatch.durationMs === undefined) { + if (!isTerminalPatch(candidatePatch)) { droppedId = candidateId; droppedPatch = candidatePatch; break; @@ -453,7 +464,7 @@ class MessageRequestWriteBuffer { logger.warn("[MessageRequestWriteBuffer] Pending queue overflow, dropping update", { maxPending: this.config.maxPending, droppedId, - droppedHasDurationMs: droppedPatch?.durationMs !== undefined, + droppedIsTerminal: droppedPatch ? isTerminalPatch(droppedPatch) : undefined, currentPending: this.pending.size, }); } diff --git a/src/repository/message.ts b/src/repository/message.ts index b7eb443df..d13a3ead6 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -224,8 +224,19 @@ export async function sealOrphanedMessageRequests(options?: { }): Promise<{ sealedCount: number }> { const env = getEnvConfig(); - const staleAfterMs = Math.max(60_000, options?.staleAfterMs ?? env.FETCH_BODY_TIMEOUT + 60_000); - const limit = Math.max(1, options?.limit ?? 1000); + const toFiniteInt = (value: unknown): number | null => { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + return Math.trunc(value); + }; + + const fetchBodyTimeout = toFiniteInt(env.FETCH_BODY_TIMEOUT) ?? 600_000; + const staleAfterMsCandidate = toFiniteInt(options?.staleAfterMs) ?? fetchBodyTimeout + 60_000; + const staleAfterMs = Math.max(60_000, staleAfterMsCandidate); + + const limitCandidate = toFiniteInt(options?.limit) ?? 1000; + const limit = Math.max(1, limitCandidate); const threshold = new Date(Date.now() - staleAfterMs); const ORPHANED_STATUS_CODE = 520; diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index 8d0bd1e57..f6b253a1f 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -272,7 +272,7 @@ describe("message_request 异步批量写入", () => { expect(built.sql).toContain("status_code"); }); - it("enqueueMessageRequestUpdate 的返回值应反映 patch 是否被接受(sanitize 为空时返回 false)", async () => { + it("enqueueMessageRequestUpdate 的返回值应反映 patch 是否被接受(sanitize 为空时返回 rejected_invalid)", async () => { process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; const { enqueueMessageRequestUpdate, stopMessageRequestWriteBuffer } = await import( @@ -295,7 +295,7 @@ describe("message_request 异步批量写入", () => { "@/repository/message-write-buffer" ); - enqueueMessageRequestUpdate(1001, { statusCode: 200 }); // 非终态(无 durationMs) + enqueueMessageRequestUpdate(1001, { ttfbMs: 200 }); // 非终态(无 durationMs/statusCode) for (let i = 0; i < 100; i++) { enqueueMessageRequestUpdate(2000 + i, { durationMs: i }); } From f8a9bf5a33db3998aa80ce68b70954b6e9243fd1 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 22:01:32 +0800 Subject: [PATCH 05/45] =?UTF-8?q?fix:=20=E9=98=9F=E5=88=97=E6=BA=A2?= =?UTF-8?q?=E5=87=BA=E6=97=B6=E4=BF=9D=E6=8A=A4=E7=BB=88=E6=80=81=E5=B9=B6?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repository/message-write-buffer.ts | 17 +-- src/repository/message.ts | 110 ++++++------------ .../repository/message-write-buffer.test.ts | 28 +++++ 3 files changed, 73 insertions(+), 82 deletions(-) diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index 252a4e0b1..eb15a78c7 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -258,6 +258,12 @@ function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePa return sanitized; } +export function sanitizeMessageRequestUpdatePatch( + patch: MessageRequestUpdatePatch +): MessageRequestUpdatePatch { + return sanitizePatch(patch); +} + function isTerminalPatch(patch: MessageRequestUpdatePatch): boolean { return patch.durationMs !== undefined || patch.statusCode !== undefined; } @@ -446,14 +452,11 @@ class MessageRequestWriteBuffer { } } + // 当 pending 全部为终态 patch 时,不应随机淘汰已有终态(会导致其他请求永久缺失完成信息)。 + // 此时优先丢弃“当前” patch,并让调用方按返回值决定是否走同步写入兜底。 if (droppedId === undefined) { - const first = this.pending.entries().next().value as - | [number, MessageRequestUpdatePatch] - | undefined; - if (first) { - droppedId = first[0]; - droppedPatch = first[1]; - } + droppedId = id; + droppedPatch = this.pending.get(id); } if (droppedId !== undefined) { diff --git a/src/repository/message.ts b/src/repository/message.ts index d13a3ead6..6bdb5d9e6 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -11,7 +11,11 @@ import type { SpecialSetting } from "@/types/special-settings"; import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; import { toMessageRequest } from "./_shared/transformers"; -import { enqueueMessageRequestUpdate } from "./message-write-buffer"; +import type { MessageRequestUpdatePatch } from "./message-write-buffer"; +import { + enqueueMessageRequestUpdate, + sanitizeMessageRequestUpdatePatch, +} from "./message-write-buffer"; /** * 创建消息请求记录 @@ -71,21 +75,34 @@ export async function createMessageRequest( return toMessageRequest(result); } -/** - * 更新消息请求的耗时 - */ -export async function updateMessageRequestDuration(id: number, durationMs: number): Promise { - if (enqueueMessageRequestUpdate(id, { durationMs }) !== "buffer_unavailable") { +async function writeMessageRequestUpdateToDb( + id: number, + patch: MessageRequestUpdatePatch +): Promise { + const sanitized = sanitizeMessageRequestUpdatePatch(patch); + if (Object.keys(sanitized).length === 0) { return; } await db .update(messageRequest) .set({ - durationMs: durationMs, + ...sanitized, updatedAt: new Date(), }) - .where(eq(messageRequest.id, id)); + .where(and(eq(messageRequest.id, id), isNull(messageRequest.deletedAt))); +} + +/** + * 更新消息请求的耗时 + */ +export async function updateMessageRequestDuration(id: number, durationMs: number): Promise { + const enqueueResult = enqueueMessageRequestUpdate(id, { durationMs }); + if (enqueueResult === "enqueued" || enqueueResult === "rejected_invalid") { + return; + } + + await writeMessageRequestUpdateToDb(id, { durationMs }); } /** @@ -100,17 +117,12 @@ export async function updateMessageRequestCost( return; } - if (enqueueMessageRequestUpdate(id, { costUsd: formattedCost }) !== "buffer_unavailable") { + const enqueueResult = enqueueMessageRequestUpdate(id, { costUsd: formattedCost }); + if (enqueueResult === "enqueued" || enqueueResult === "rejected_invalid") { return; } - await db - .update(messageRequest) - .set({ - costUsd: formattedCost, - updatedAt: new Date(), - }) - .where(eq(messageRequest.id, id)); + await writeMessageRequestUpdateToDb(id, { costUsd: formattedCost }); } /** @@ -139,70 +151,18 @@ export async function updateMessageRequestDetails( specialSettings?: CreateMessageRequestData["special_settings"]; // 特殊设置(审计/展示) } ): Promise { - if (enqueueMessageRequestUpdate(id, details) !== "buffer_unavailable") { + const enqueueResult = enqueueMessageRequestUpdate(id, details); + if (enqueueResult === "enqueued" || enqueueResult === "rejected_invalid") { return; } - const updateData: Record = { - updatedAt: new Date(), - }; - - if (details.statusCode !== undefined) { - updateData.statusCode = details.statusCode; - } - if (details.inputTokens !== undefined) { - updateData.inputTokens = details.inputTokens; - } - if (details.outputTokens !== undefined) { - updateData.outputTokens = details.outputTokens; - } - if (details.ttfbMs !== undefined) { - updateData.ttfbMs = details.ttfbMs; - } - if (details.cacheCreationInputTokens !== undefined) { - updateData.cacheCreationInputTokens = details.cacheCreationInputTokens; - } - if (details.cacheReadInputTokens !== undefined) { - updateData.cacheReadInputTokens = details.cacheReadInputTokens; - } - if (details.cacheCreation5mInputTokens !== undefined) { - updateData.cacheCreation5mInputTokens = details.cacheCreation5mInputTokens; - } - if (details.cacheCreation1hInputTokens !== undefined) { - updateData.cacheCreation1hInputTokens = details.cacheCreation1hInputTokens; - } - if (details.cacheTtlApplied !== undefined) { - updateData.cacheTtlApplied = details.cacheTtlApplied; - } - if (details.providerChain !== undefined) { - updateData.providerChain = details.providerChain; - } - if (details.errorMessage !== undefined) { - updateData.errorMessage = details.errorMessage; - } - if (details.errorStack !== undefined) { - updateData.errorStack = details.errorStack; - } - if (details.errorCause !== undefined) { - updateData.errorCause = details.errorCause; - } - if (details.model !== undefined) { - updateData.model = details.model; - } - if (details.providerId !== undefined) { - updateData.providerId = details.providerId; - } - if (details.context1mApplied !== undefined) { - updateData.context1mApplied = details.context1mApplied; - } - if (details.swapCacheTtlApplied !== undefined) { - updateData.swapCacheTtlApplied = details.swapCacheTtlApplied; - } - if (details.specialSettings !== undefined) { - updateData.specialSettings = details.specialSettings; + // 非终态 patch 在 overflow 场景下丢弃即可,避免在压力峰值时反向放大 DB 写入。 + // 终态(包含 statusCode)则尽量走同步写入,避免请求长期卡在“请求中”。 + if (enqueueResult === "dropped_overflow" && details.statusCode === undefined) { + return; } - await db.update(messageRequest).set(updateData).where(eq(messageRequest.id, id)); + await writeMessageRequestUpdateToDb(id, details); } /** diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index f6b253a1f..611c218ba 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -311,4 +311,32 @@ describe("message_request 异步批量写入", () => { expect(built.params).toContain(2099); expect(built.params).not.toContain(1001); }); + + it("队列溢出且全部为终态更新时应丢弃当前 patch(避免误删已有终态)", async () => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + process.env.MESSAGE_REQUEST_ASYNC_MAX_PENDING = "100"; + + const { enqueueMessageRequestUpdate, stopMessageRequestWriteBuffer } = await import( + "@/repository/message-write-buffer" + ); + + for (let i = 0; i < 100; i++) { + enqueueMessageRequestUpdate(2000 + i, { durationMs: i }); + } + + const overflowId = 9999; + const result = enqueueMessageRequestUpdate(overflowId, { durationMs: 123 }); + + await stopMessageRequestWriteBuffer(); + + expect(result).toBe("dropped_overflow"); + expect(executeMock).toHaveBeenCalledTimes(1); + + const query = executeMock.mock.calls[0]?.[0]; + const built = toSqlText(query); + + expect(built.params).toContain(2000); + expect(built.params).toContain(2099); + expect(built.params).not.toContain(overflowId); + }); }); From db55c412162228e8705d6714267ca37b72cf62ca Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 22:11:39 +0800 Subject: [PATCH 06/45] =?UTF-8?q?chore:=20=E7=BB=9F=E4=B8=80=20message-wri?= =?UTF-8?q?te-buffer=20=E5=AF=BC=E5=85=A5=E5=B9=B6=E6=98=8E=E7=A1=AE?= =?UTF-8?q?=E5=AD=A4=E5=84=BF=E9=94=99=E8=AF=AF=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repository/message.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/repository/message.ts b/src/repository/message.ts index 6bdb5d9e6..31bf61b95 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -6,16 +6,16 @@ import { keys as keysTable, messageRequest, providers, usageLedger, users } from import { getEnvConfig } from "@/lib/config/env.schema"; import { isLedgerOnlyMode } from "@/lib/ledger-fallback"; import { formatCostForStorage } from "@/lib/utils/currency"; +import type { MessageRequestUpdatePatch } from "@/repository/message-write-buffer"; +import { + enqueueMessageRequestUpdate, + sanitizeMessageRequestUpdatePatch, +} from "@/repository/message-write-buffer"; import type { CreateMessageRequestData, MessageRequest, ProviderChainItem } from "@/types/message"; import type { SpecialSetting } from "@/types/special-settings"; import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; import { toMessageRequest } from "./_shared/transformers"; -import type { MessageRequestUpdatePatch } from "./message-write-buffer"; -import { - enqueueMessageRequestUpdate, - sanitizeMessageRequestUpdatePatch, -} from "./message-write-buffer"; /** * 创建消息请求记录 @@ -200,7 +200,8 @@ export async function sealOrphanedMessageRequests(options?: { const threshold = new Date(Date.now() - staleAfterMs); const ORPHANED_STATUS_CODE = 520; - const ORPHANED_ERROR_MESSAGE = "ORPHANED_REQUEST"; + // 注意:这里写入的是稳定的“错误码”,展示层若直接展示 error_message,应做 i18n 映射。 + const ORPHANED_ERROR_CODE = "ORPHANED_REQUEST"; const query = sql<{ id: number }>` WITH candidates AS ( @@ -222,7 +223,7 @@ export async function sealOrphanedMessageRequests(options?: { )::int ), status_code = COALESCE(status_code, ${ORPHANED_STATUS_CODE}), - error_message = COALESCE(error_message, ${ORPHANED_ERROR_MESSAGE}), + error_message = COALESCE(error_message, ${ORPHANED_ERROR_CODE}), updated_at = NOW() WHERE id IN (SELECT id FROM candidates) AND duration_ms IS NULL From 9ea03026a7f08daeeb2bf20f9d683c4af039e4a7 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 22:24:55 +0800 Subject: [PATCH 07/45] =?UTF-8?q?fix:=20cost=20=E6=9B=B4=E6=96=B0=E5=9C=A8?= =?UTF-8?q?=20overflow=20=E6=97=B6=E4=B8=8D=E8=B5=B0=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repository/message.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/repository/message.ts b/src/repository/message.ts index 31bf61b95..0b8f57e50 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -122,6 +122,11 @@ export async function updateMessageRequestCost( return; } + // costUsd 非终态信息:overflow 时丢弃即可,避免压力峰值下放大同步 DB 写入。 + if (enqueueResult === "dropped_overflow") { + return; + } + await writeMessageRequestUpdateToDb(id, { costUsd: formattedCost }); } From ab07c59d98296158b536e448c12ccf183083c083 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 22:37:55 +0800 Subject: [PATCH 08/45] =?UTF-8?q?ci:=20=E7=A1=AE=E4=BF=9D=E5=AE=89?= =?UTF-8?q?=E8=A3=85=20Biome=20CLI=20=E4=BB=A5=E7=A8=B3=E5=AE=9A=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-check.yml | 5 +++++ .github/workflows/test.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index e90516235..9c124ade9 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -33,6 +33,11 @@ jobs: - name: 📦 Install dependencies run: bun install + - name: 📦 Ensure Biome CLI + run: | + BIOME_VERSION=$(bun -p "require('@biomejs/biome/package.json').version") + bun install --no-save @biomejs/cli-linux-x64@${BIOME_VERSION} + - name: 🔍 Type check run: bun run typecheck diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 59694b704..fe76827ed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,11 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Ensure Biome CLI + run: | + BIOME_VERSION=$(bun -p "require('@biomejs/biome/package.json').version") + bun install --no-save @biomejs/cli-linux-x64@${BIOME_VERSION} + - name: Run linting run: bun run lint From 3d285d71fb472040a1227e4e36ee93d928291562 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Mar 2026 14:43:07 +0000 Subject: [PATCH 09/45] chore: format code (fix-issue-854-message-request-stuck-ab07c59) --- tests/integration/usage-ledger.test.ts | 80 +++++++++++++------------- 1 file changed, 39 insertions(+), 41 deletions(-) diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index 5b7204e8a..d64b36ee1 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,47 +278,45 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test( - "backfill copies non-warmup message_request rows when ledger rows are missing", - { timeout: 60_000 }, - async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - } - ); + test("backfill copies non-warmup message_request rows when ledger rows are missing", { + timeout: 60_000, + }, async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + }); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ From 5e5ccf5d988effee6c303a81d060124613018e2e Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 23:17:17 +0800 Subject: [PATCH 10/45] =?UTF-8?q?fix:=20=E5=BC=BA=E5=8C=96=20message=5Freq?= =?UTF-8?q?uest=20=E5=86=99=E7=BC=93=E5=86=B2=E5=AE=B9=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sanitize 支持数字字符串/number costUsd,降低 rejected_invalid 与数据类写入失败概率\n- DB 错误码识别沿 cause 链查找,确保 22/23* 能触发降级写入避免队列卡死\n- 补充单元测试覆盖 --- src/repository/message-write-buffer.ts | 88 ++++++++++++++++--- .../repository/message-write-buffer.test.ts | 47 +++++++++- 2 files changed, 121 insertions(+), 14 deletions(-) diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index eb15a78c7..3ee5c7e37 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -71,23 +71,57 @@ const COLUMN_MAP: Record = { }; const INT32_MAX = 2147483647; +const NUMERIC_LIKE_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; const REJECTED_INVALID_LOG_THROTTLE_MS = 60_000; let _lastRejectedInvalidLogAt = 0; -function isFiniteNumber(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value); +function toFiniteNumber(value: unknown): number | null { + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + if (trimmed.length === 0) { + return null; + } + + if (!NUMERIC_LIKE_RE.test(trimmed)) { + return null; + } + + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; +} + +function summarizePatchTypes(patch: MessageRequestUpdatePatch): Record { + const summary: Record = {}; + for (const [key, value] of Object.entries(patch)) { + if (value === null) { + summary[key] = "null"; + } else if (Array.isArray(value)) { + summary[key] = "array"; + } else { + summary[key] = typeof value; + } + } + return summary; } function sanitizeInt32( value: unknown, options?: { min?: number; max?: number } ): number | undefined { - if (!isFiniteNumber(value)) { + const numeric = toFiniteNumber(value); + if (numeric === null) { return undefined; } - const truncated = Math.trunc(value); + const truncated = Math.trunc(numeric); const min = options?.min ?? -INT32_MAX - 1; const max = options?.max ?? INT32_MAX; @@ -111,18 +145,31 @@ function sanitizeNullableInt32( } function sanitizeNumericString(value: unknown): string | undefined { - if (typeof value !== "string") { + let raw: string | undefined; + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return undefined; + } + raw = String(value); + } else if (typeof value === "string") { + raw = value; + } else { return undefined; } - const trimmed = value.trim(); + const trimmed = raw.trim(); if (trimmed.length === 0) { return undefined; } // 允许常见十进制与科学计数法,拒绝 NaN/Infinity/空白/十六进制等异常输入 - const numericLike = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(trimmed); - if (!numericLike) { + if (!NUMERIC_LIKE_RE.test(trimmed)) { + return undefined; + } + + // 数值过大(例如 1e309)会变成 Infinity;这种输入对 numeric 列也大概率不可用,直接拒绝。 + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { return undefined; } @@ -269,11 +316,27 @@ function isTerminalPatch(patch: MessageRequestUpdatePatch): boolean { } function getErrorCode(error: unknown): string | null { - if (!error || typeof error !== "object") { - return null; + const visited = new Set(); + let current: unknown = error; + + for (let depth = 0; depth < 5; depth++) { + if (!current || typeof current !== "object") { + return null; + } + if (visited.has(current)) { + return null; + } + visited.add(current); + + const code = (current as { code?: unknown }).code; + if (typeof code === "string") { + return code; + } + + current = (current as { cause?: unknown }).cause; } - const code = (error as { code?: unknown }).code; - return typeof code === "string" ? code : null; + + return null; } function isDataRelatedDbError(error: unknown): boolean { @@ -429,6 +492,7 @@ class MessageRequestWriteBuffer { logger.warn("[MessageRequestWriteBuffer] Patch rejected: empty after sanitize", { requestId: id, originalKeys: Object.keys(patch), + originalTypes: summarizePatchTypes(patch), }); } return "rejected_invalid"; diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index 611c218ba..360af1969 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -149,8 +149,9 @@ describe("message_request 异步批量写入", () => { executeMock.mockImplementation(async (query) => { const built = toSqlText(query); if (built.sql.includes("::numeric")) { - const error: { code?: string } = new Error("invalid input syntax for type numeric"); - error.code = "22P02"; + const error = new Error("invalid input syntax for type numeric", { + cause: { code: "22P02" }, + }); throw error; } return []; @@ -339,4 +340,46 @@ describe("message_request 异步批量写入", () => { expect(built.params).toContain(2099); expect(built.params).not.toContain(overflowId); }); + + it("应接受常见数字字符串输入(避免 patch 被误判为空)", async () => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + + const { enqueueMessageRequestUpdate, stopMessageRequestWriteBuffer } = await import( + "@/repository/message-write-buffer" + ); + + const result = enqueueMessageRequestUpdate(1, { + durationMs: "123" as unknown as number, + statusCode: "200" as unknown as number, + }); + + await stopMessageRequestWriteBuffer(); + + expect(result).toBe("enqueued"); + expect(executeMock).toHaveBeenCalledTimes(1); + + const query = executeMock.mock.calls[0]?.[0]; + const built = toSqlText(query); + expect(built.sql).toContain("duration_ms"); + expect(built.sql).toContain("status_code"); + }); + + it("应接受 costUsd number 输入并转换为 numeric", async () => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + + const { enqueueMessageRequestUpdate, stopMessageRequestWriteBuffer } = await import( + "@/repository/message-write-buffer" + ); + + const result = enqueueMessageRequestUpdate(7, { costUsd: 0.000123 as unknown as string }); + + await stopMessageRequestWriteBuffer(); + + expect(result).toBe("enqueued"); + expect(executeMock).toHaveBeenCalledTimes(1); + + const query = executeMock.mock.calls[0]?.[0]; + const built = toSqlText(query); + expect(built.sql).toContain("::numeric"); + }); }); From b378f1baeef875f72fd380c378f30b4794a65c28 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 23:17:30 +0800 Subject: [PATCH 11/45] =?UTF-8?q?chore:=20=E6=81=A2=E5=A4=8D=20usage-ledge?= =?UTF-8?q?r=20=E6=B5=8B=E8=AF=95=E6=A0=BC=E5=BC=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 回滚 CI 自动格式化引入的无关 diff\n- 使 Biome format 检查保持一致 --- tests/integration/usage-ledger.test.ts | 80 +++++++++++++------------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index d64b36ee1..5b7204e8a 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,45 +278,47 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test("backfill copies non-warmup message_request rows when ledger rows are missing", { - timeout: 60_000, - }, async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - }); + test( + "backfill copies non-warmup message_request rows when ledger rows are missing", + { timeout: 60_000 }, + async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + } + ); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ From d7b927ec94052108e3f8b9073fcd1be1dadd8dcc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Mar 2026 15:18:14 +0000 Subject: [PATCH 12/45] chore: format code (fix-issue-854-message-request-stuck-b378f1b) --- tests/integration/usage-ledger.test.ts | 80 +++++++++++++------------- 1 file changed, 39 insertions(+), 41 deletions(-) diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index 5b7204e8a..d64b36ee1 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,47 +278,45 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test( - "backfill copies non-warmup message_request rows when ledger rows are missing", - { timeout: 60_000 }, - async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - } - ); + test("backfill copies non-warmup message_request rows when ledger rows are missing", { + timeout: 60_000, + }, async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + }); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ From f636bd4be9a81b41d7f87ff4a5f71b50fd588442 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 23:30:29 +0800 Subject: [PATCH 13/45] =?UTF-8?q?chore:=20=E8=A7=A6=E5=8F=91=20CI=20?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 3d41876bcaa53d84fbab83ef2917d3f7526f4683 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 00:36:43 +0800 Subject: [PATCH 14/45] =?UTF-8?q?fix:=20=E6=89=A9=E5=B1=95=E5=AD=A4?= =?UTF-8?q?=E5=84=BF=E8=AF=B7=E6=B1=82=E5=B0=81=E9=97=AD=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=EF=BC=88=E8=A1=A5=20status=5Fcode=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repository/message.ts | 17 ++++++++++------- .../message-orphaned-requests.test.ts | 2 ++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/repository/message.ts b/src/repository/message.ts index 0b8f57e50..680970ef2 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -213,7 +213,7 @@ export async function sealOrphanedMessageRequests(options?: { SELECT id FROM message_request WHERE deleted_at IS NULL - AND duration_ms IS NULL + AND (duration_ms IS NULL OR status_code IS NULL) AND created_at < ${threshold} AND ${EXCLUDE_WARMUP_CONDITION} ORDER BY created_at ASC @@ -221,18 +221,21 @@ export async function sealOrphanedMessageRequests(options?: { ) UPDATE message_request SET - duration_ms = ( - LEAST( - 2147483647, - GREATEST(0, (EXTRACT(EPOCH FROM (NOW() - created_at)) * 1000)) - )::int + duration_ms = COALESCE( + duration_ms, + ( + LEAST( + 2147483647, + GREATEST(0, (EXTRACT(EPOCH FROM (NOW() - created_at)) * 1000)) + )::int + ) ), status_code = COALESCE(status_code, ${ORPHANED_STATUS_CODE}), error_message = COALESCE(error_message, ${ORPHANED_ERROR_CODE}), updated_at = NOW() WHERE id IN (SELECT id FROM candidates) - AND duration_ms IS NULL AND deleted_at IS NULL + AND (duration_ms IS NULL OR status_code IS NULL) RETURNING id `; diff --git a/tests/unit/repository/message-orphaned-requests.test.ts b/tests/unit/repository/message-orphaned-requests.test.ts index e7da1ce3f..e945f4033 100644 --- a/tests/unit/repository/message-orphaned-requests.test.ts +++ b/tests/unit/repository/message-orphaned-requests.test.ts @@ -79,7 +79,9 @@ describe("sealOrphanedMessageRequests", () => { expect(built.sql).toContain("UPDATE message_request"); expect(built.sql).toContain("duration_ms IS NULL"); + expect(built.sql).toContain("status_code IS NULL"); expect(built.sql).toContain("created_at <"); + expect(built.sql).toContain("duration_ms = COALESCE"); expect(built.sql).toContain("status_code ="); expect(built.sql).toContain("error_message ="); expect(built.sql).toContain("LIMIT"); From a2dd6f00a382f0c034194e699d137092181bdca1 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 00:36:55 +0800 Subject: [PATCH 15/45] chore: format usage-ledger integration test --- tests/integration/usage-ledger.test.ts | 82 ++++++++++++++------------ 1 file changed, 43 insertions(+), 39 deletions(-) diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index d64b36ee1..303d005cd 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,45 +278,49 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test("backfill copies non-warmup message_request rows when ledger rows are missing", { - timeout: 60_000, - }, async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - }); + test( + "backfill copies non-warmup message_request rows when ledger rows are missing", + { + timeout: 60_000, + }, + async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + } + ); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ From e55090ff51edb6a1cb36ed5152435f181a488134 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Mar 2026 16:37:52 +0000 Subject: [PATCH 16/45] chore: format code (fix-issue-854-message-request-stuck-a2dd6f0) --- tests/integration/usage-ledger.test.ts | 82 ++++++++++++-------------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index 303d005cd..d64b36ee1 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,49 +278,45 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test( - "backfill copies non-warmup message_request rows when ledger rows are missing", - { - timeout: 60_000, - }, - async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - } - ); + test("backfill copies non-warmup message_request rows when ledger rows are missing", { + timeout: 60_000, + }, async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + }); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ From 88a1459cb1f08623caf8513c8d2f136b7d8686f6 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 00:45:58 +0800 Subject: [PATCH 17/45] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=20proxy-status?= =?UTF-8?q?=20lastRequest=20=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/proxy-status-tracker.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index 6998c621b..b514f60f1 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -69,10 +69,12 @@ export class ProxyStatusTracker { providerName: string; model: string; }): void { + // no-op:当前实现基于数据库聚合(getAllUsersStatus),保留方法仅为兼容既有调用点 void params; } endRequest(userId: number, requestId: number): void { + // no-op:当前实现基于数据库聚合(getAllUsersStatus),保留方法仅为兼容既有调用点 void userId; void requestId; } @@ -204,7 +206,8 @@ export class ProxyStatusTracker { LEFT JOIN keys k ON k.key = mr.key AND k.deleted_at IS NULL WHERE mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - ORDER BY mr.user_id, mr.updated_at DESC + -- 优先按 created_at 取“最后一次请求”,避免 updated_at 去重排序在大表上产生额外 sort 压力 + ORDER BY mr.user_id, mr.created_at DESC, mr.id DESC `; const result = await db.execute(query); From aab8fffe882f3ff6126d6c445ee1d6851ae82cc6 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 00:46:11 +0800 Subject: [PATCH 18/45] =?UTF-8?q?refactor:=20=E6=8F=90=E5=8F=96=20per-item?= =?UTF-8?q?=20=E6=9A=82=E6=80=81=E9=94=99=E8=AF=AF=E5=9B=9E=E9=98=9F?= =?UTF-8?q?=E5=88=97=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repository/message-write-buffer.ts | 63 +++++++++++++------------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index 3ee5c7e37..e0656db79 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -593,6 +593,22 @@ class MessageRequestWriteBuffer { } } + private handleTransientPerItemError( + error: unknown, + batch: MessageRequestUpdateRecord[], + startIndex: number, + logMessage: string + ): true { + // 连接/暂态问题:把当前及剩余条目回队列,留待下次 flush + this.requeueBatchForRetry(batch.slice(startIndex)); + logger.error(logMessage, { + error: error instanceof Error ? error.message : String(error), + errorCode: getErrorCode(error), + pending: this.pending.size, + }); + return true; + } + async flush(): Promise { if (this.flushInFlight) { this.flushAgainAfterCurrent = true; @@ -661,18 +677,12 @@ class MessageRequestWriteBuffer { await tryExecute(item.patch); } catch (singleError) { if (!isDataRelatedDbError(singleError)) { - // 连接/暂态问题:把当前及剩余条目回队列,留待下次 flush - this.requeueBatchForRetry(batch.slice(index)); - logger.error( - "[MessageRequestWriteBuffer] Per-item flush hit transient error, will retry", - { - error: - singleError instanceof Error ? singleError.message : String(singleError), - errorCode: getErrorCode(singleError), - pending: this.pending.size, - } + shouldRetryLater = this.handleTransientPerItemError( + singleError, + batch, + index, + "[MessageRequestWriteBuffer] Per-item flush hit transient error, will retry" ); - shouldRetryLater = true; break; } @@ -681,16 +691,12 @@ class MessageRequestWriteBuffer { await tryExecute(safePatch); } catch (safeError) { if (!isDataRelatedDbError(safeError)) { - this.requeueBatchForRetry(batch.slice(index)); - logger.error( - "[MessageRequestWriteBuffer] Per-item safe flush hit transient error, will retry", - { - error: safeError instanceof Error ? safeError.message : String(safeError), - errorCode: getErrorCode(safeError), - pending: this.pending.size, - } + shouldRetryLater = this.handleTransientPerItemError( + safeError, + batch, + index, + "[MessageRequestWriteBuffer] Per-item safe flush hit transient error, will retry" ); - shouldRetryLater = true; break; } @@ -699,19 +705,12 @@ class MessageRequestWriteBuffer { await tryExecute(minimalPatch); } catch (minimalError) { if (!isDataRelatedDbError(minimalError)) { - this.requeueBatchForRetry(batch.slice(index)); - logger.error( - "[MessageRequestWriteBuffer] Per-item minimal flush hit transient error, will retry", - { - error: - minimalError instanceof Error - ? minimalError.message - : String(minimalError), - errorCode: getErrorCode(minimalError), - pending: this.pending.size, - } + shouldRetryLater = this.handleTransientPerItemError( + minimalError, + batch, + index, + "[MessageRequestWriteBuffer] Per-item minimal flush hit transient error, will retry" ); - shouldRetryLater = true; break; } From e048a8b4015f96e42ca84448580cebebf9d6456b Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 01:53:32 +0800 Subject: [PATCH 19/45] =?UTF-8?q?fix:=20=E5=8A=A0=E5=9B=BA=20#854=20?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E5=86=99=E7=BC=93=E5=86=B2=E8=87=AA=E6=84=88?= =?UTF-8?q?=E4=B8=8E=E5=AD=A4=E5=84=BF=E6=B8=85=E6=89=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/instrumentation.ts | 47 +++- src/lib/proxy-status-tracker.ts | 43 ++-- src/repository/message-orphaned-requests.ts | 3 + src/repository/message-write-buffer.ts | 226 ++++++++++-------- src/repository/message.ts | 16 +- tests/integration/usage-ledger.test.ts | 82 ++++--- .../message-orphaned-requests.test.ts | 8 +- 7 files changed, 264 insertions(+), 161 deletions(-) create mode 100644 src/repository/message-orphaned-requests.ts diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 3cf22a9f7..9494bd03f 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -153,6 +153,29 @@ async function startOrphanedMessageRequestSweeper(): Promise { try { const { sealOrphanedMessageRequests } = await import("@/repository/message"); const intervalMs = 60 * 1000; + const timeoutMs = 30 * 1000; + const slowLogMs = 5 * 1000; + + const withTimeout = async ( + promise: Promise, + ms: number + ): Promise<{ timedOut: true } | { timedOut: false; value: T }> => { + let timeoutId: NodeJS.Timeout | null = null; + const timeoutPromise = new Promise<{ timedOut: true }>((resolve) => { + timeoutId = setTimeout(() => resolve({ timedOut: true }), ms); + }); + + const result = await Promise.race([ + promise.then((value) => ({ timedOut: false as const, value })), + timeoutPromise, + ]); + + if (timeoutId) { + clearTimeout(timeoutId); + } + + return result; + }; let inFlight = false; const runOnce = async (reason: "startup" | "scheduled") => { @@ -160,12 +183,34 @@ async function startOrphanedMessageRequestSweeper(): Promise { return; } inFlight = true; + const startedAt = Date.now(); try { - const { sealedCount } = await sealOrphanedMessageRequests(); + const sealPromise = sealOrphanedMessageRequests(); + const result = await withTimeout(sealPromise, timeoutMs); + if (result.timedOut) { + // 避免出现 unhandled rejection(promise 仍可能在超时后继续 reject) + void sealPromise.catch(() => {}); + logger.warn("[Instrumentation] Orphaned message_request sweeper timed out", { + reason, + timeoutMs, + }); + return; + } + + const { sealedCount } = result.value; + const durationMs = Date.now() - startedAt; + if (durationMs > slowLogMs) { + logger.warn("[Instrumentation] Orphaned message_request sweeper slow run", { + reason, + durationMs, + timeoutMs, + }); + } if (sealedCount > 0) { logger.warn("[Instrumentation] Orphaned message_request records sealed", { sealedCount, reason, + durationMs, }); } } catch (error) { diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index b514f60f1..7be4fde50 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -192,22 +192,35 @@ export class ProxyStatusTracker { private async loadLastRequests(): Promise { const query = sql` - SELECT DISTINCT ON (mr.user_id) - mr.user_id AS "userId", - mr.id AS "requestId", - mr.key AS "keyString", + SELECT + u.id AS "userId", + last.request_id AS "requestId", + last.key_string AS "keyString", k.name AS "keyName", - mr.provider_id AS "providerId", - p.name AS "providerName", - mr.model AS "model", - mr.updated_at AS "endTime" - FROM message_request mr - JOIN providers p ON mr.provider_id = p.id AND p.deleted_at IS NULL - LEFT JOIN keys k ON k.key = mr.key AND k.deleted_at IS NULL - WHERE mr.deleted_at IS NULL - AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - -- 优先按 created_at 取“最后一次请求”,避免 updated_at 去重排序在大表上产生额外 sort 压力 - ORDER BY mr.user_id, mr.created_at DESC, mr.id DESC + last.provider_id AS "providerId", + last.provider_name AS "providerName", + last.model AS "model", + last.end_time AS "endTime" + FROM users u + -- 使用 LATERAL 为每个用户做一次“取最新请求”的索引扫描,避免在 message_request 大表上做 DISTINCT ON 全表排序去重。 + JOIN LATERAL ( + SELECT + mr.id AS request_id, + mr.key AS key_string, + mr.provider_id AS provider_id, + p.name AS provider_name, + mr.model AS model, + mr.updated_at AS end_time + FROM message_request mr + JOIN providers p ON mr.provider_id = p.id AND p.deleted_at IS NULL + WHERE mr.user_id = u.id + AND mr.deleted_at IS NULL + AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') + ORDER BY mr.created_at DESC, mr.id DESC + LIMIT 1 + ) last ON true + LEFT JOIN keys k ON k.key = last.key_string AND k.deleted_at IS NULL + WHERE u.deleted_at IS NULL `; const result = await db.execute(query); diff --git a/src/repository/message-orphaned-requests.ts b/src/repository/message-orphaned-requests.ts new file mode 100644 index 000000000..b66088557 --- /dev/null +++ b/src/repository/message-orphaned-requests.ts @@ -0,0 +1,3 @@ +export const ORPHANED_MESSAGE_REQUEST_STATUS_CODE = 520; +// 注意:这里写入的是稳定的“错误码”,展示层若直接展示 error_message,应做 i18n 映射。 +export const ORPHANED_MESSAGE_REQUEST_ERROR_CODE = "ORPHANED_REQUEST"; diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index e0656db79..f4d01d2ff 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -76,6 +76,9 @@ const NUMERIC_LIKE_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; const REJECTED_INVALID_LOG_THROTTLE_MS = 60_000; let _lastRejectedInvalidLogAt = 0; +// 终态 patch(duration/status)尽快刷库,但仍保留极短延迟以便 micro-batch,避免高并发下频繁 flush。 +const TERMINAL_FLUSH_DELAY_MS = 10; + function toFiniteNumber(value: unknown): number | null { if (typeof value === "number") { return Number.isFinite(value) ? value : null; @@ -173,6 +176,11 @@ function sanitizeNumericString(value: unknown): string | undefined { return undefined; } + // 目前仅用于 costUsd(schema: numeric(21, 15),整数部分最多 6 位:< 1,000,000) + if (parsed < 0 || parsed >= 1_000_000) { + return undefined; + } + return trimmed; } @@ -474,7 +482,9 @@ function buildBatchUpdateSql(updates: MessageRequestUpdateRecord[]): SQL | null class MessageRequestWriteBuffer { private readonly config: WriterConfig; private readonly pending = new Map(); + private readonly nonTerminalIds = new Set(); private flushTimer: NodeJS.Timeout | null = null; + private flushTimerDueAt: number | null = null; private flushAgainAfterCurrent = false; private flushInFlight: Promise | null = null; private stopping = false; @@ -499,7 +509,13 @@ class MessageRequestWriteBuffer { } const existing = this.pending.get(id) ?? {}; - this.pending.set(id, { ...existing, ...sanitized }); + const merged = { ...existing, ...sanitized }; + this.pending.set(id, merged); + if (isTerminalPatch(merged)) { + this.nonTerminalIds.delete(id); + } else { + this.nonTerminalIds.add(id); + } let result: MessageRequestUpdateEnqueueResult = "enqueued"; // 队列上限保护:DB 异常时避免无限增长导致 OOM @@ -508,12 +524,15 @@ class MessageRequestWriteBuffer { let droppedId: number | undefined; let droppedPatch: MessageRequestUpdatePatch | undefined; - for (const [candidateId, candidatePatch] of this.pending) { - if (!isTerminalPatch(candidatePatch)) { - droppedId = candidateId; - droppedPatch = candidatePatch; - break; + for (const candidateId of this.nonTerminalIds) { + const candidatePatch = this.pending.get(candidateId); + if (!candidatePatch) { + this.nonTerminalIds.delete(candidateId); + continue; } + droppedId = candidateId; + droppedPatch = candidatePatch; + break; } // 当 pending 全部为终态 patch 时,不应随机淘汰已有终态(会导致其他请求永久缺失完成信息)。 @@ -525,6 +544,7 @@ class MessageRequestWriteBuffer { if (droppedId !== undefined) { this.pending.delete(droppedId); + this.nonTerminalIds.delete(droppedId); if (droppedId === id) { result = "dropped_overflow"; } @@ -546,7 +566,7 @@ class MessageRequestWriteBuffer { // 停止阶段不再调度 timer,避免阻止进程退出 if (!this.stopping) { // 终态 patch 尽快落库,减少 duration/status 为空的“悬挂窗口” - this.ensureFlushTimer(isTerminalPatch(sanitized) ? 0 : undefined); + this.ensureFlushTimer(isTerminalPatch(merged) ? TERMINAL_FLUSH_DELAY_MS : undefined); } // 达到批量阈值时尽快 flush,降低 durationMs 为空的“悬挂时间” @@ -563,17 +583,19 @@ class MessageRequestWriteBuffer { } const delay = Math.max(0, delayMs ?? this.config.flushIntervalMs); + const dueAt = Date.now() + delay; if (this.flushTimer) { - if (delay === 0) { - this.clearFlushTimer(); - } else { + if (this.flushTimerDueAt !== null && this.flushTimerDueAt <= dueAt) { return; } + this.clearFlushTimer(); } + this.flushTimerDueAt = dueAt; this.flushTimer = setTimeout(() => { this.flushTimer = null; + this.flushTimerDueAt = null; void this.flush(); }, delay); } @@ -582,6 +604,7 @@ class MessageRequestWriteBuffer { if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; + this.flushTimerDueAt = null; } } @@ -589,7 +612,13 @@ class MessageRequestWriteBuffer { // 合并策略:保留“更新更晚”的字段(existing 优先),避免覆盖新数据 for (const item of batch) { const existing = this.pending.get(item.id) ?? {}; - this.pending.set(item.id, { ...item.patch, ...existing }); + const merged = { ...item.patch, ...existing }; + this.pending.set(item.id, merged); + if (isTerminalPatch(merged)) { + this.nonTerminalIds.delete(item.id); + } else { + this.nonTerminalIds.add(item.id); + } } } @@ -609,6 +638,74 @@ class MessageRequestWriteBuffer { return true; } + private async flushBatchPerItem(batch: MessageRequestUpdateRecord[]): Promise { + for (let index = 0; index < batch.length; index++) { + const item = batch[index]; + if (!item) { + continue; + } + + const patchStrategies = [ + { name: "full" as const, patch: item.patch }, + { name: "safe" as const, patch: getSafePatch(item.patch) }, + { name: "minimal" as const, patch: getMinimalPatch(item.patch) }, + ]; + + let lastFailure: { + kind: "build" | "execute"; + strategy: "full" | "safe" | "minimal"; + error: unknown; + } | null = null; + + for (const { name, patch } of patchStrategies) { + let singleQuery: SQL | null = null; + try { + singleQuery = buildBatchUpdateSql([{ id: item.id, patch }]); + } catch (error) { + lastFailure = { kind: "build", strategy: name, error }; + continue; + } + + if (!singleQuery) { + lastFailure = null; + break; + } + + try { + await db.execute(singleQuery); + lastFailure = null; + break; + } catch (error) { + lastFailure = { kind: "execute", strategy: name, error }; + if (!isDataRelatedDbError(error)) { + return this.handleTransientPerItemError( + error, + batch, + index, + "[MessageRequestWriteBuffer] Per-item flush hit transient error, will retry" + ); + } + } + } + + if (lastFailure) { + logger.error("[MessageRequestWriteBuffer] Dropping invalid update to unblock queue", { + requestId: item.id, + keys: Object.keys(item.patch), + failureKind: lastFailure.kind, + failureStrategy: lastFailure.strategy, + error: + lastFailure.error instanceof Error + ? lastFailure.error.message + : String(lastFailure.error), + errorCode: getErrorCode(lastFailure.error), + }); + } + } + + return false; + } + async flush(): Promise { if (this.flushInFlight) { this.flushAgainAfterCurrent = true; @@ -624,19 +721,30 @@ class MessageRequestWriteBuffer { while (this.pending.size > 0) { const batch = takeBatch(this.pending, this.config.batchSize); + for (const item of batch) { + this.nonTerminalIds.delete(item.id); + } let query: SQL | null = null; try { query = buildBatchUpdateSql(batch); } catch (error) { - // 极端场景:构建 SQL 失败(例如非预期类型)。回队列稍后重试,避免直接抛错影响请求链路。 - this.requeueBatchForRetry(batch); - logger.error("[MessageRequestWriteBuffer] Build batch SQL failed, will retry later", { - error: error instanceof Error ? error.message : String(error), - pending: this.pending.size, - batchSize: batch.length, - }); - break; + logger.error( + "[MessageRequestWriteBuffer] Build batch SQL failed, falling back to per-item writes", + { + error: error instanceof Error ? error.message : String(error), + errorCode: getErrorCode(error), + pending: this.pending.size, + batchSize: batch.length, + } + ); + + // 通过 per-item 写入确保队列可排空,避免 build 失败导致无限重试。 + const shouldRetryLater = await this.flushBatchPerItem(batch); + if (shouldRetryLater) { + break; + } + continue; } if (!query) { @@ -657,79 +765,7 @@ class MessageRequestWriteBuffer { } ); - let shouldRetryLater = false; - - for (let index = 0; index < batch.length; index++) { - const item = batch[index]; - if (!item) { - continue; - } - - const tryExecute = async (patch: MessageRequestUpdatePatch) => { - const singleQuery = buildBatchUpdateSql([{ id: item.id, patch }]); - if (!singleQuery) { - return; - } - await db.execute(singleQuery); - }; - - try { - await tryExecute(item.patch); - } catch (singleError) { - if (!isDataRelatedDbError(singleError)) { - shouldRetryLater = this.handleTransientPerItemError( - singleError, - batch, - index, - "[MessageRequestWriteBuffer] Per-item flush hit transient error, will retry" - ); - break; - } - - const safePatch = getSafePatch(item.patch); - try { - await tryExecute(safePatch); - } catch (safeError) { - if (!isDataRelatedDbError(safeError)) { - shouldRetryLater = this.handleTransientPerItemError( - safeError, - batch, - index, - "[MessageRequestWriteBuffer] Per-item safe flush hit transient error, will retry" - ); - break; - } - - const minimalPatch = getMinimalPatch(item.patch); - try { - await tryExecute(minimalPatch); - } catch (minimalError) { - if (!isDataRelatedDbError(minimalError)) { - shouldRetryLater = this.handleTransientPerItemError( - minimalError, - batch, - index, - "[MessageRequestWriteBuffer] Per-item minimal flush hit transient error, will retry" - ); - break; - } - - // 数据持续异常:丢弃该条更新,避免拖死整个队列(后续由 sweeper 兜底封闭) - logger.error( - "[MessageRequestWriteBuffer] Dropping invalid update to unblock queue", - { - requestId: item.id, - error: - minimalError instanceof Error - ? minimalError.message - : String(minimalError), - errorCode: getErrorCode(minimalError), - } - ); - } - } - } - } + const shouldRetryLater = await this.flushBatchPerItem(batch); if (shouldRetryLater) { break; @@ -779,10 +815,10 @@ let _buffer: MessageRequestWriteBuffer | null = null; let _bufferState: "running" | "stopping" | "stopped" = "running"; function getBuffer(): MessageRequestWriteBuffer | null { + if (_bufferState !== "running") { + return null; + } if (!_buffer) { - if (_bufferState !== "running") { - return null; - } _buffer = new MessageRequestWriteBuffer(loadWriterConfig()); } return _buffer; diff --git a/src/repository/message.ts b/src/repository/message.ts index 680970ef2..4a26ca04a 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -6,6 +6,10 @@ import { keys as keysTable, messageRequest, providers, usageLedger, users } from import { getEnvConfig } from "@/lib/config/env.schema"; import { isLedgerOnlyMode } from "@/lib/ledger-fallback"; import { formatCostForStorage } from "@/lib/utils/currency"; +import { + ORPHANED_MESSAGE_REQUEST_ERROR_CODE, + ORPHANED_MESSAGE_REQUEST_STATUS_CODE, +} from "@/repository/message-orphaned-requests"; import type { MessageRequestUpdatePatch } from "@/repository/message-write-buffer"; import { enqueueMessageRequestUpdate, @@ -204,19 +208,15 @@ export async function sealOrphanedMessageRequests(options?: { const limit = Math.max(1, limitCandidate); const threshold = new Date(Date.now() - staleAfterMs); - const ORPHANED_STATUS_CODE = 520; - // 注意:这里写入的是稳定的“错误码”,展示层若直接展示 error_message,应做 i18n 映射。 - const ORPHANED_ERROR_CODE = "ORPHANED_REQUEST"; - const query = sql<{ id: number }>` WITH candidates AS ( SELECT id FROM message_request WHERE deleted_at IS NULL AND (duration_ms IS NULL OR status_code IS NULL) - AND created_at < ${threshold} + AND updated_at < ${threshold} AND ${EXCLUDE_WARMUP_CONDITION} - ORDER BY created_at ASC + ORDER BY updated_at ASC LIMIT ${limit} ) UPDATE message_request @@ -230,8 +230,8 @@ export async function sealOrphanedMessageRequests(options?: { )::int ) ), - status_code = COALESCE(status_code, ${ORPHANED_STATUS_CODE}), - error_message = COALESCE(error_message, ${ORPHANED_ERROR_CODE}), + status_code = COALESCE(status_code, ${ORPHANED_MESSAGE_REQUEST_STATUS_CODE}), + error_message = COALESCE(error_message, ${ORPHANED_MESSAGE_REQUEST_ERROR_CODE}), updated_at = NOW() WHERE id IN (SELECT id FROM candidates) AND deleted_at IS NULL diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index d64b36ee1..303d005cd 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,45 +278,49 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test("backfill copies non-warmup message_request rows when ledger rows are missing", { - timeout: 60_000, - }, async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - }); + test( + "backfill copies non-warmup message_request rows when ledger rows are missing", + { + timeout: 60_000, + }, + async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + } + ); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ diff --git a/tests/unit/repository/message-orphaned-requests.test.ts b/tests/unit/repository/message-orphaned-requests.test.ts index e945f4033..154d506f8 100644 --- a/tests/unit/repository/message-orphaned-requests.test.ts +++ b/tests/unit/repository/message-orphaned-requests.test.ts @@ -68,6 +68,8 @@ describe("sealOrphanedMessageRequests", () => { it("应批量封闭超时仍未落终态的 message_request 并返回 sealedCount", async () => { const { sealOrphanedMessageRequests } = await import("@/repository/message"); + const { ORPHANED_MESSAGE_REQUEST_ERROR_CODE, ORPHANED_MESSAGE_REQUEST_STATUS_CODE } = + await import("@/repository/message-orphaned-requests"); const result = await sealOrphanedMessageRequests({ staleAfterMs: 10, limit: 5 }); @@ -80,14 +82,14 @@ describe("sealOrphanedMessageRequests", () => { expect(built.sql).toContain("UPDATE message_request"); expect(built.sql).toContain("duration_ms IS NULL"); expect(built.sql).toContain("status_code IS NULL"); - expect(built.sql).toContain("created_at <"); + expect(built.sql).toContain("updated_at <"); expect(built.sql).toContain("duration_ms = COALESCE"); expect(built.sql).toContain("status_code ="); expect(built.sql).toContain("error_message ="); expect(built.sql).toContain("LIMIT"); - expect(built.params).toContain(520); - expect(built.params).toContain("ORPHANED_REQUEST"); + expect(built.params).toContain(ORPHANED_MESSAGE_REQUEST_STATUS_CODE); + expect(built.params).toContain(ORPHANED_MESSAGE_REQUEST_ERROR_CODE); expect(built.params).toContain(5); const threshold = built.params.find((p) => p instanceof Date) as Date | undefined; From e342b0cc9b2e1bd3c67f2b2d77272012e975b083 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Mar 2026 17:54:18 +0000 Subject: [PATCH 20/45] chore: format code (fix-issue-854-message-request-stuck-e048a8b) --- tests/integration/usage-ledger.test.ts | 82 ++++++++++++-------------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index 303d005cd..d64b36ee1 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,49 +278,45 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test( - "backfill copies non-warmup message_request rows when ledger rows are missing", - { - timeout: 60_000, - }, - async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - } - ); + test("backfill copies non-warmup message_request rows when ledger rows are missing", { + timeout: 60_000, + }, async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + }); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ From 485275af252d8a99c599409a5659fde32cf5de46 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 02:09:03 +0800 Subject: [PATCH 21/45] =?UTF-8?q?ci:=20=E5=9B=BA=E5=AE=9A=20Biome=20?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E4=BB=A5=E5=8C=B9=E9=85=8D=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-check.yml | 5 +++-- .github/workflows/test.yml | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 9c124ade9..bd33b5ebb 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -31,11 +31,12 @@ jobs: node-version: '20' - name: 📦 Install dependencies - run: bun install + run: bun install --frozen-lockfile - name: 📦 Ensure Biome CLI run: | - BIOME_VERSION=$(bun -p "require('@biomejs/biome/package.json').version") + BIOME_VERSION=$(bun -p 'require("./biome.json")["$schema"].match(/schemas\/(\d+\.\d+\.\d+)\/schema\.json/)[1]') + bun install --no-save @biomejs/biome@${BIOME_VERSION} bun install --no-save @biomejs/cli-linux-x64@${BIOME_VERSION} - name: 🔍 Type check diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fe76827ed..cb63c3ff1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,8 @@ jobs: - name: Ensure Biome CLI run: | - BIOME_VERSION=$(bun -p "require('@biomejs/biome/package.json').version") + BIOME_VERSION=$(bun -p 'require("./biome.json")["$schema"].match(/schemas\/(\d+\.\d+\.\d+)\/schema\.json/)[1]') + bun install --no-save @biomejs/biome@${BIOME_VERSION} bun install --no-save @biomejs/cli-linux-x64@${BIOME_VERSION} - name: Run linting From 63e2584b5bf1ce4c2d0a449360e9eb8ea4741804 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 02:43:40 +0800 Subject: [PATCH 22/45] =?UTF-8?q?chore:=20=E5=A4=84=E7=90=86=20AI=20review?= =?UTF-8?q?=EF=BC=88=E7=A7=BB=E9=99=A4=20Biome=20CLI=20=E7=A1=AC=E7=BC=96?= =?UTF-8?q?=E7=A0=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI:移除 @biomejs/cli-linux-x64 硬编码安装,避免架构漂移\n- message_request:补充关键不变量说明,并强化孤儿封闭 SQL 测试断言\n- tests:按 Biome 期望收敛 usage-ledger 集成测试格式 --- .github/workflows/pr-check.yml | 6 -- .github/workflows/test.yml | 6 -- src/repository/message-write-buffer.ts | 1 + src/repository/message.ts | 1 + tests/integration/usage-ledger.test.ts | 82 ++++++++++--------- .../message-orphaned-requests.test.ts | 2 + 6 files changed, 47 insertions(+), 51 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index bd33b5ebb..3ba9700c9 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -33,12 +33,6 @@ jobs: - name: 📦 Install dependencies run: bun install --frozen-lockfile - - name: 📦 Ensure Biome CLI - run: | - BIOME_VERSION=$(bun -p 'require("./biome.json")["$schema"].match(/schemas\/(\d+\.\d+\.\d+)\/schema\.json/)[1]') - bun install --no-save @biomejs/biome@${BIOME_VERSION} - bun install --no-save @biomejs/cli-linux-x64@${BIOME_VERSION} - - name: 🔍 Type check run: bun run typecheck diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb63c3ff1..59694b704 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,12 +29,6 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Ensure Biome CLI - run: | - BIOME_VERSION=$(bun -p 'require("./biome.json")["$schema"].match(/schemas\/(\d+\.\d+\.\d+)\/schema\.json/)[1]') - bun install --no-save @biomejs/biome@${BIOME_VERSION} - bun install --no-save @biomejs/cli-linux-x64@${BIOME_VERSION} - - name: Run linting run: bun run lint diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index f4d01d2ff..0f79efb6a 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -482,6 +482,7 @@ function buildBatchUpdateSql(updates: MessageRequestUpdateRecord[]): SQL | null class MessageRequestWriteBuffer { private readonly config: WriterConfig; private readonly pending = new Map(); + // 不含终态字段(duration/status)的待写入条目集合;始终与 pending 内合并后的 patch 状态保持一致。 private readonly nonTerminalIds = new Set(); private flushTimer: NodeJS.Timeout | null = null; private flushTimerDueAt: number | null = null; diff --git a/src/repository/message.ts b/src/repository/message.ts index 4a26ca04a..d26868cef 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -208,6 +208,7 @@ export async function sealOrphanedMessageRequests(options?: { const limit = Math.max(1, limitCandidate); const threshold = new Date(Date.now() - staleAfterMs); + // 注意:EXCLUDE_WARMUP_CONDITION 使用 Drizzle 列引用(message_request.blocked_by),这里不要给 message_request 起别名。 const query = sql<{ id: number }>` WITH candidates AS ( SELECT id diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index d64b36ee1..303d005cd 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,45 +278,49 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test("backfill copies non-warmup message_request rows when ledger rows are missing", { - timeout: 60_000, - }, async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - }); + test( + "backfill copies non-warmup message_request rows when ledger rows are missing", + { + timeout: 60_000, + }, + async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + } + ); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ diff --git a/tests/unit/repository/message-orphaned-requests.test.ts b/tests/unit/repository/message-orphaned-requests.test.ts index 154d506f8..93f3d2e76 100644 --- a/tests/unit/repository/message-orphaned-requests.test.ts +++ b/tests/unit/repository/message-orphaned-requests.test.ts @@ -83,6 +83,8 @@ describe("sealOrphanedMessageRequests", () => { expect(built.sql).toContain("duration_ms IS NULL"); expect(built.sql).toContain("status_code IS NULL"); expect(built.sql).toContain("updated_at <"); + expect(built.sql).toContain("blocked_by"); + expect(built.sql).toContain("warmup"); expect(built.sql).toContain("duration_ms = COALESCE"); expect(built.sql).toContain("status_code ="); expect(built.sql).toContain("error_message ="); From cf8fa1cffb0df741dc5f7c0f0b02e6e50db0b455 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Mar 2026 18:44:20 +0000 Subject: [PATCH 23/45] chore: format code (fix-issue-854-message-request-stuck-63e2584) --- tests/integration/usage-ledger.test.ts | 82 ++++++++++++-------------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index 303d005cd..d64b36ee1 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,49 +278,45 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test( - "backfill copies non-warmup message_request rows when ledger rows are missing", - { - timeout: 60_000, - }, - async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - } - ); + test("backfill copies non-warmup message_request rows when ledger rows are missing", { + timeout: 60_000, + }, async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + }); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ From 023c49193252411283ec19b70501fc883fa6fa12 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 03:00:43 +0800 Subject: [PATCH 24/45] =?UTF-8?q?ci:=20=E5=9B=BA=E5=AE=9A=20Biome=20?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E9=81=BF=E5=85=8D=20schema=20=E6=BC=82?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 @biomejs/biome 固定到 2.4.4,与 biome.json schema 保持一致\n- 收敛 usage-ledger 集成测试格式以通过 format:check --- package.json | 2 +- tests/integration/usage-ledger.test.ts | 82 ++++++++++++++------------ 2 files changed, 44 insertions(+), 40 deletions(-) diff --git a/package.json b/package.json index 519e1c55e..050646136 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "zod": "^4" }, "devDependencies": { - "@biomejs/biome": "^2", + "@biomejs/biome": "2.4.4", "@tailwindcss/postcss": "^4", "@types/ioredis": "^5", "@types/node": "^24", diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index d64b36ee1..303d005cd 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,45 +278,49 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test("backfill copies non-warmup message_request rows when ledger rows are missing", { - timeout: 60_000, - }, async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - }); + test( + "backfill copies non-warmup message_request rows when ledger rows are missing", + { + timeout: 60_000, + }, + async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + } + ); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ From 1bd6fffa0c49347469274f6b495e0d4b3f4ceeab Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 03:28:13 +0800 Subject: [PATCH 25/45] =?UTF-8?q?fix:=20=E6=9A=82=E6=80=81=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=97=B6=E9=81=BF=E5=85=8D=20flush=20=E5=BF=99?= =?UTF-8?q?=E7=AD=89=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flush 遇到暂态 DB 错误后立即让出给 timer 退避,避免高并发下 flushAgainAfterCurrent 触发忙循环\n- 新增单元测试覆盖该行为,防止回归 --- src/repository/message-write-buffer.ts | 10 +++++++ .../repository/message-write-buffer.test.ts | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index 0f79efb6a..ada391e41 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -719,6 +719,7 @@ class MessageRequestWriteBuffer { this.flushInFlight = (async () => { do { this.flushAgainAfterCurrent = false; + let shouldYieldToTimer = false; while (this.pending.size > 0) { const batch = takeBatch(this.pending, this.config.batchSize); @@ -743,6 +744,8 @@ class MessageRequestWriteBuffer { // 通过 per-item 写入确保队列可排空,避免 build 失败导致无限重试。 const shouldRetryLater = await this.flushBatchPerItem(batch); if (shouldRetryLater) { + // 暂态错误:避免 flushAgainAfterCurrent 在高并发下形成忙等/重试风暴,交由 timer 退避重试。 + shouldYieldToTimer = true; break; } continue; @@ -769,6 +772,8 @@ class MessageRequestWriteBuffer { const shouldRetryLater = await this.flushBatchPerItem(batch); if (shouldRetryLater) { + // 暂态错误:避免 flushAgainAfterCurrent 在高并发下形成忙等/重试风暴,交由 timer 退避重试。 + shouldYieldToTimer = true; break; } @@ -786,9 +791,14 @@ class MessageRequestWriteBuffer { }); // DB 异常时不在当前循环内死磕,留待下一次 timer/手动 flush + // 同时避免 flushAgainAfterCurrent 在高并发下形成忙等/重试风暴。 + shouldYieldToTimer = true; break; } } + if (shouldYieldToTimer) { + break; + } } while (this.flushAgainAfterCurrent); })().finally(() => { this.flushInFlight = null; diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index 360af1969..cbe715684 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -273,6 +273,35 @@ describe("message_request 异步批量写入", () => { expect(built.sql).toContain("status_code"); }); + it("遇到暂态错误时,本次 flush 不应因 flushAgainAfterCurrent 忙等重试(交由 timer 退避)", async () => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + + const deferred = createDeferred(); + executeMock.mockImplementationOnce(async () => deferred.promise); + + const { + enqueueMessageRequestUpdate, + flushMessageRequestWriteBuffer, + stopMessageRequestWriteBuffer, + } = await import("@/repository/message-write-buffer"); + + enqueueMessageRequestUpdate(1, { durationMs: 123 }); + + const flushPromise = flushMessageRequestWriteBuffer(); + expect(executeMock).toHaveBeenCalledTimes(1); + + // flush in-flight 期间 enqueue:会把 flushAgainAfterCurrent 置为 true + enqueueMessageRequestUpdate(1, { statusCode: 200 }); + + // 触发暂态错误:flush 应结束并把重试交给 timer,而不是在同一次 flush 内立即重试 + deferred.reject(new Error("db down")); + await flushPromise; + + expect(executeMock).toHaveBeenCalledTimes(1); + + await stopMessageRequestWriteBuffer(); + }); + it("enqueueMessageRequestUpdate 的返回值应反映 patch 是否被接受(sanitize 为空时返回 rejected_invalid)", async () => { process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; From d92c4eae9129782f79c40c00d338e004546bda3f Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 04:10:30 +0800 Subject: [PATCH 26/45] =?UTF-8?q?fix:=20overflow=20=E4=B8=A2=E5=BC=83?= =?UTF-8?q?=E6=97=B6=E8=BF=94=E5=9B=9E=E8=A1=A5=E4=B8=81=E5=B9=B6=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=90=8C=E6=AD=A5=E5=86=99=E5=85=A5=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/instrumentation.ts | 6 +++ src/lib/proxy-status-tracker.ts | 6 +++ src/repository/message-write-buffer.ts | 42 +++++++++++------- src/repository/message.ts | 44 ++++++++++++++----- .../repository/message-write-buffer.test.ts | 8 ++-- 5 files changed, 74 insertions(+), 32 deletions(-) diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 9494bd03f..6709828fc 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -206,6 +206,12 @@ async function startOrphanedMessageRequestSweeper(): Promise { timeoutMs, }); } + if (reason === "startup") { + logger.info("[Instrumentation] Orphaned message_request sweeper startup run completed", { + sealedCount, + durationMs, + }); + } if (sealedCount > 0) { logger.warn("[Instrumentation] Orphaned message_request records sealed", { sealedCount, diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index 7be4fde50..a70023fd2 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -60,6 +60,9 @@ export class ProxyStatusTracker { return ProxyStatusTracker.instance; } + /** + * @deprecated 已迁移为基于数据库聚合的实现(getAllUsersStatus)。保留仅为兼容既有调用点。 + */ startRequest(params: { userId: number; userName: string; @@ -73,6 +76,9 @@ export class ProxyStatusTracker { void params; } + /** + * @deprecated 已迁移为基于数据库聚合的实现(getAllUsersStatus)。保留仅为兼容既有调用点。 + */ endRequest(userId: number, requestId: number): void { // no-op:当前实现基于数据库聚合(getAllUsersStatus),保留方法仅为兼容既有调用点 void userId; diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index ada391e41..810269317 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -31,10 +31,10 @@ export type MessageRequestUpdatePatch = { }; export type MessageRequestUpdateEnqueueResult = - | "enqueued" - | "rejected_invalid" - | "buffer_unavailable" - | "dropped_overflow"; + | { kind: "enqueued" } + | { kind: "rejected_invalid" } + | { kind: "buffer_unavailable" } + | { kind: "dropped_overflow"; patch: MessageRequestUpdatePatch }; type MessageRequestUpdateRecord = { id: number; @@ -262,8 +262,8 @@ function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePa }); } else { try { - JSON.stringify(patch.providerChain); - sanitized.providerChain = patch.providerChain; + const json = JSON.stringify(patch.providerChain); + sanitized.providerChain = JSON.parse(json) as CreateMessageRequestData["provider_chain"]; } catch (error) { logger.warn("[MessageRequestWriteBuffer] Invalid providerChain, skipping", { error: error instanceof Error ? error.message : String(error), @@ -300,13 +300,21 @@ function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePa if (patch.specialSettings === null) { sanitized.specialSettings = null; } else if (patch.specialSettings !== undefined) { - try { - JSON.stringify(patch.specialSettings); - sanitized.specialSettings = patch.specialSettings; - } catch (error) { - logger.warn("[MessageRequestWriteBuffer] Invalid specialSettings, skipping", { - error: error instanceof Error ? error.message : String(error), + if (!Array.isArray(patch.specialSettings)) { + logger.warn("[MessageRequestWriteBuffer] Invalid specialSettings type, skipping", { + specialSettingsType: typeof patch.specialSettings, }); + } else { + try { + const json = JSON.stringify(patch.specialSettings); + sanitized.specialSettings = JSON.parse( + json + ) as CreateMessageRequestData["special_settings"]; + } catch (error) { + logger.warn("[MessageRequestWriteBuffer] Invalid specialSettings, skipping", { + error: error instanceof Error ? error.message : String(error), + }); + } } } @@ -506,7 +514,7 @@ class MessageRequestWriteBuffer { originalTypes: summarizePatchTypes(patch), }); } - return "rejected_invalid"; + return { kind: "rejected_invalid" }; } const existing = this.pending.get(id) ?? {}; @@ -517,7 +525,7 @@ class MessageRequestWriteBuffer { } else { this.nonTerminalIds.add(id); } - let result: MessageRequestUpdateEnqueueResult = "enqueued"; + let result: MessageRequestUpdateEnqueueResult = { kind: "enqueued" }; // 队列上限保护:DB 异常时避免无限增长导致 OOM if (this.pending.size > this.config.maxPending) { @@ -547,7 +555,7 @@ class MessageRequestWriteBuffer { this.pending.delete(droppedId); this.nonTerminalIds.delete(droppedId); if (droppedId === id) { - result = "dropped_overflow"; + result = { kind: "dropped_overflow", patch: droppedPatch ?? sanitized }; } logger.warn("[MessageRequestWriteBuffer] Pending queue overflow, dropping update", { maxPending: this.config.maxPending, @@ -841,11 +849,11 @@ export function enqueueMessageRequestUpdate( ): MessageRequestUpdateEnqueueResult { // 只在 async 模式下启用队列,避免额外内存/定时器开销 if (getEnvConfig().MESSAGE_REQUEST_WRITE_MODE !== "async") { - return "buffer_unavailable"; + return { kind: "buffer_unavailable" }; } const buffer = getBuffer(); if (!buffer) { - return "buffer_unavailable"; + return { kind: "buffer_unavailable" }; } return buffer.enqueue(id, patch); } diff --git a/src/repository/message.ts b/src/repository/message.ts index d26868cef..591a9517c 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -5,6 +5,7 @@ import { db } from "@/drizzle/db"; import { keys as keysTable, messageRequest, providers, usageLedger, users } from "@/drizzle/schema"; import { getEnvConfig } from "@/lib/config/env.schema"; import { isLedgerOnlyMode } from "@/lib/ledger-fallback"; +import { logger } from "@/lib/logger"; import { formatCostForStorage } from "@/lib/utils/currency"; import { ORPHANED_MESSAGE_REQUEST_ERROR_CODE, @@ -88,13 +89,21 @@ async function writeMessageRequestUpdateToDb( return; } - await db + const updated = await db .update(messageRequest) .set({ ...sanitized, updatedAt: new Date(), }) - .where(and(eq(messageRequest.id, id), isNull(messageRequest.deletedAt))); + .where(and(eq(messageRequest.id, id), isNull(messageRequest.deletedAt))) + .returning({ id: messageRequest.id }); + + if (updated.length === 0) { + logger.warn("[MessageRepository] Message request update affected 0 rows", { + requestId: id, + keys: Object.keys(sanitized), + }); + } } /** @@ -102,11 +111,13 @@ async function writeMessageRequestUpdateToDb( */ export async function updateMessageRequestDuration(id: number, durationMs: number): Promise { const enqueueResult = enqueueMessageRequestUpdate(id, { durationMs }); - if (enqueueResult === "enqueued" || enqueueResult === "rejected_invalid") { + if (enqueueResult.kind === "enqueued" || enqueueResult.kind === "rejected_invalid") { return; } - await writeMessageRequestUpdateToDb(id, { durationMs }); + const patchToWrite = + enqueueResult.kind === "dropped_overflow" ? enqueueResult.patch : { durationMs }; + await writeMessageRequestUpdateToDb(id, patchToWrite); } /** @@ -122,12 +133,18 @@ export async function updateMessageRequestCost( } const enqueueResult = enqueueMessageRequestUpdate(id, { costUsd: formattedCost }); - if (enqueueResult === "enqueued" || enqueueResult === "rejected_invalid") { + if (enqueueResult.kind === "enqueued" || enqueueResult.kind === "rejected_invalid") { return; } - // costUsd 非终态信息:overflow 时丢弃即可,避免压力峰值下放大同步 DB 写入。 - if (enqueueResult === "dropped_overflow") { + // costUsd 非终态信息:overflow 时尽量丢弃即可,避免压力峰值下放大同步 DB 写入。 + // 但若 overflow 过程中丢失了终态信息(duration/status),则需要同步写入兜底以避免“请求中”悬挂。 + if (enqueueResult.kind === "dropped_overflow") { + const patch = enqueueResult.patch; + if (patch.durationMs === undefined && patch.statusCode === undefined) { + return; + } + await writeMessageRequestUpdateToDb(id, patch); return; } @@ -161,13 +178,18 @@ export async function updateMessageRequestDetails( } ): Promise { const enqueueResult = enqueueMessageRequestUpdate(id, details); - if (enqueueResult === "enqueued" || enqueueResult === "rejected_invalid") { + if (enqueueResult.kind === "enqueued" || enqueueResult.kind === "rejected_invalid") { return; } - // 非终态 patch 在 overflow 场景下丢弃即可,避免在压力峰值时反向放大 DB 写入。 - // 终态(包含 statusCode)则尽量走同步写入,避免请求长期卡在“请求中”。 - if (enqueueResult === "dropped_overflow" && details.statusCode === undefined) { + // overflow 场景下:尽量丢弃非终态 patch,避免在压力峰值时反向放大 DB 写入。 + // 但如果 overflow 丢失了终态信息(duration/status),则必须同步写入兜底以避免请求长期卡在“请求中”。 + if (enqueueResult.kind === "dropped_overflow") { + const patch = enqueueResult.patch; + if (patch.durationMs === undefined && patch.statusCode === undefined) { + return; + } + await writeMessageRequestUpdateToDb(id, patch); return; } diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index cbe715684..dba41ff53 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -313,7 +313,7 @@ describe("message_request 异步批量写入", () => { await stopMessageRequestWriteBuffer(); - expect(result).toBe("rejected_invalid"); + expect(result.kind).toBe("rejected_invalid"); expect(executeMock).not.toHaveBeenCalled(); }); @@ -359,7 +359,7 @@ describe("message_request 异步批量写入", () => { await stopMessageRequestWriteBuffer(); - expect(result).toBe("dropped_overflow"); + expect(result.kind).toBe("dropped_overflow"); expect(executeMock).toHaveBeenCalledTimes(1); const query = executeMock.mock.calls[0]?.[0]; @@ -384,7 +384,7 @@ describe("message_request 异步批量写入", () => { await stopMessageRequestWriteBuffer(); - expect(result).toBe("enqueued"); + expect(result.kind).toBe("enqueued"); expect(executeMock).toHaveBeenCalledTimes(1); const query = executeMock.mock.calls[0]?.[0]; @@ -404,7 +404,7 @@ describe("message_request 异步批量写入", () => { await stopMessageRequestWriteBuffer(); - expect(result).toBe("enqueued"); + expect(result.kind).toBe("enqueued"); expect(executeMock).toHaveBeenCalledTimes(1); const query = executeMock.mock.calls[0]?.[0]; From 2f1e9fe4b300a3fac5c49e637f4ee52e299021b9 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 04:33:46 +0800 Subject: [PATCH 27/45] =?UTF-8?q?fix:=20proxy-status=20lastRequest=20?= =?UTF-8?q?=E6=8E=92=E9=99=A4=E8=BF=9B=E8=A1=8C=E4=B8=AD=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/proxy-status-tracker.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index a70023fd2..886cd1176 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -221,7 +221,10 @@ export class ProxyStatusTracker { JOIN providers p ON mr.provider_id = p.id AND p.deleted_at IS NULL WHERE mr.user_id = u.id AND mr.deleted_at IS NULL + -- lastRequest 仅统计已结束请求:activeRequests 已覆盖进行中请求,避免这里误选“请求中”的记录。 + AND mr.duration_ms IS NOT NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') + -- 这里使用 created_at 排序以更好利用既有索引(user_id, created_at),减少全表排序压力。 ORDER BY mr.created_at DESC, mr.id DESC LIMIT 1 ) last ON true From d30651c6d9137156c790d95c6f4d1dc62fbaaacc Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 04:57:52 +0800 Subject: [PATCH 28/45] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=20sanitize=20?= =?UTF-8?q?=E5=91=8A=E8=AD=A6=E5=B9=B6=E6=BE=84=E6=B8=85=20overflow/lastRe?= =?UTF-8?q?quest=20=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/proxy-status-tracker.ts | 2 ++ src/repository/message-write-buffer.ts | 12 ++++++++++++ src/repository/message.ts | 15 +++++++++++---- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index 886cd1176..af0951ac6 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -116,6 +116,8 @@ export class ProxyStatusTracker { activeMap.set(row.userId, list); } + // lastRequestRows 仅包含已结束请求(duration_ms IS NOT NULL): + // 若用户仅有进行中请求,则 lastRequest 会保持为 null,由 activeRequests 展示进行中状态。 const lastMap = new Map(); for (const row of lastRequestRows) { if (!lastMap.has(row.userId)) { diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index 810269317..e1a664ea1 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -248,6 +248,10 @@ function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePa sanitized.cacheTtlApplied = null; } else if (typeof patch.cacheTtlApplied === "string") { sanitized.cacheTtlApplied = patch.cacheTtlApplied; + } else if (patch.cacheTtlApplied !== undefined) { + logger.warn("[MessageRequestWriteBuffer] Invalid cacheTtlApplied type, skipping", { + cacheTtlAppliedType: typeof patch.cacheTtlApplied, + }); } const costUsd = sanitizeNumericString(patch.costUsd); @@ -292,9 +296,17 @@ function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePa if (typeof patch.context1mApplied === "boolean") { sanitized.context1mApplied = patch.context1mApplied; + } else if (patch.context1mApplied !== undefined) { + logger.warn("[MessageRequestWriteBuffer] Invalid context1mApplied type, skipping", { + context1mAppliedType: typeof patch.context1mApplied, + }); } if (typeof patch.swapCacheTtlApplied === "boolean") { sanitized.swapCacheTtlApplied = patch.swapCacheTtlApplied; + } else if (patch.swapCacheTtlApplied !== undefined) { + logger.warn("[MessageRequestWriteBuffer] Invalid swapCacheTtlApplied type, skipping", { + swapCacheTtlAppliedType: typeof patch.swapCacheTtlApplied, + }); } if (patch.specialSettings === null) { diff --git a/src/repository/message.ts b/src/repository/message.ts index 591a9517c..ddb09c5e1 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -99,10 +99,13 @@ async function writeMessageRequestUpdateToDb( .returning({ id: messageRequest.id }); if (updated.length === 0) { - logger.warn("[MessageRepository] Message request update affected 0 rows", { - requestId: id, - keys: Object.keys(sanitized), - }); + logger.warn( + "[MessageRepository] Message request update affected 0 rows (missing or soft-deleted)", + { + requestId: id, + keys: Object.keys(sanitized), + } + ); } } @@ -141,6 +144,8 @@ export async function updateMessageRequestCost( // 但若 overflow 过程中丢失了终态信息(duration/status),则需要同步写入兜底以避免“请求中”悬挂。 if (enqueueResult.kind === "dropped_overflow") { const patch = enqueueResult.patch; + // 注意:patch 可能携带已合并的非终态字段;当其不含终态字段时这里仍选择丢弃, + // 避免压力峰值下放大同步 DB 写入(非终态信息允许在极端情况下丢失)。 if (patch.durationMs === undefined && patch.statusCode === undefined) { return; } @@ -186,6 +191,8 @@ export async function updateMessageRequestDetails( // 但如果 overflow 丢失了终态信息(duration/status),则必须同步写入兜底以避免请求长期卡在“请求中”。 if (enqueueResult.kind === "dropped_overflow") { const patch = enqueueResult.patch; + // 注意:patch 可能携带已合并的非终态字段;当其不含终态字段时这里仍选择丢弃, + // 避免压力峰值下放大同步 DB 写入(非终态信息允许在极端情况下丢失)。 if (patch.durationMs === undefined && patch.statusCode === undefined) { return; } From 3b002598e5505f3f0b52e8399dd03cee80702959 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 05:43:52 +0800 Subject: [PATCH 29/45] =?UTF-8?q?fix:=20=E5=8A=A0=E5=9B=BA=E5=AD=A4?= =?UTF-8?q?=E5=84=BF=E5=B0=81=E9=97=AD=E5=B9=B6=E5=A2=9E=E5=BC=BA=20clamp?= =?UTF-8?q?=20=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repository/message-write-buffer.ts | 69 ++++++++++++++++--- src/repository/message.ts | 2 + .../message-orphaned-requests.test.ts | 2 +- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index e1a664ea1..3896e8456 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -76,6 +76,9 @@ const NUMERIC_LIKE_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; const REJECTED_INVALID_LOG_THROTTLE_MS = 60_000; let _lastRejectedInvalidLogAt = 0; +const INT32_CLAMP_LOG_THROTTLE_MS = 60_000; +const _lastInt32ClampLogAt = new Map(); + // 终态 patch(duration/status)尽快刷库,但仍保留极短延迟以便 micro-batch,避免高并发下频繁 flush。 const TERMINAL_FLUSH_DELAY_MS = 10; @@ -117,7 +120,7 @@ function summarizePatchTypes(patch: MessageRequestUpdatePatch): Record INT32_CLAMP_LOG_THROTTLE_MS) { + _lastInt32ClampLogAt.set(field, now); + logger.warn("[MessageRequestWriteBuffer] Int32 value out of range, clamping", { + field, + value: typeof value === "string" ? value.slice(0, 64) : value, + clampedTo: min, + min, + max, + }); + } + } return min; } if (truncated > max) { + const field = options?.field; + if (field) { + const now = Date.now(); + const lastLogAt = _lastInt32ClampLogAt.get(field) ?? 0; + if (now - lastLogAt > INT32_CLAMP_LOG_THROTTLE_MS) { + _lastInt32ClampLogAt.set(field, now); + logger.warn("[MessageRequestWriteBuffer] Int32 value out of range, clamping", { + field, + value: typeof value === "string" ? value.slice(0, 64) : value, + clampedTo: max, + min, + max, + }); + } + } return max; } return truncated; @@ -139,7 +172,7 @@ function sanitizeInt32( function sanitizeNullableInt32( value: unknown, - options?: { min?: number; max?: number } + options?: { min?: number; max?: number; field?: string } ): number | null | undefined { if (value === null) { return null; @@ -187,32 +220,45 @@ function sanitizeNumericString(value: unknown): string | undefined { function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePatch { const sanitized: MessageRequestUpdatePatch = {}; - const durationMs = sanitizeInt32(patch.durationMs, { min: 0, max: INT32_MAX }); + const durationMs = sanitizeInt32(patch.durationMs, { + field: "durationMs", + min: 0, + max: INT32_MAX, + }); if (durationMs !== undefined) { sanitized.durationMs = durationMs; } - const statusCode = sanitizeInt32(patch.statusCode, { min: 0, max: 999 }); + const statusCode = sanitizeInt32(patch.statusCode, { field: "statusCode", min: 0, max: 999 }); if (statusCode !== undefined) { sanitized.statusCode = statusCode; } - const inputTokens = sanitizeInt32(patch.inputTokens, { min: 0, max: INT32_MAX }); + const inputTokens = sanitizeInt32(patch.inputTokens, { + field: "inputTokens", + min: 0, + max: INT32_MAX, + }); if (inputTokens !== undefined) { sanitized.inputTokens = inputTokens; } - const outputTokens = sanitizeInt32(patch.outputTokens, { min: 0, max: INT32_MAX }); + const outputTokens = sanitizeInt32(patch.outputTokens, { + field: "outputTokens", + min: 0, + max: INT32_MAX, + }); if (outputTokens !== undefined) { sanitized.outputTokens = outputTokens; } - const ttfbMs = sanitizeNullableInt32(patch.ttfbMs, { min: 0, max: INT32_MAX }); + const ttfbMs = sanitizeNullableInt32(patch.ttfbMs, { field: "ttfbMs", min: 0, max: INT32_MAX }); if (ttfbMs !== undefined) { sanitized.ttfbMs = ttfbMs; } const cacheCreationInputTokens = sanitizeInt32(patch.cacheCreationInputTokens, { + field: "cacheCreationInputTokens", min: 0, max: INT32_MAX, }); @@ -221,6 +267,7 @@ function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePa } const cacheReadInputTokens = sanitizeInt32(patch.cacheReadInputTokens, { + field: "cacheReadInputTokens", min: 0, max: INT32_MAX, }); @@ -229,6 +276,7 @@ function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePa } const cacheCreation5mInputTokens = sanitizeInt32(patch.cacheCreation5mInputTokens, { + field: "cacheCreation5mInputTokens", min: 0, max: INT32_MAX, }); @@ -237,6 +285,7 @@ function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePa } const cacheCreation1hInputTokens = sanitizeInt32(patch.cacheCreation1hInputTokens, { + field: "cacheCreation1hInputTokens", min: 0, max: INT32_MAX, }); @@ -289,7 +338,11 @@ function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePa sanitized.model = patch.model; } - const providerId = sanitizeInt32(patch.providerId, { min: 0, max: INT32_MAX }); + const providerId = sanitizeInt32(patch.providerId, { + field: "providerId", + min: 0, + max: INT32_MAX, + }); if (providerId !== undefined) { sanitized.providerId = providerId; } diff --git a/src/repository/message.ts b/src/repository/message.ts index ddb09c5e1..3fc93da25 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -266,6 +266,8 @@ export async function sealOrphanedMessageRequests(options?: { WHERE id IN (SELECT id FROM candidates) AND deleted_at IS NULL AND (duration_ms IS NULL OR status_code IS NULL) + AND updated_at < ${threshold} + AND ${EXCLUDE_WARMUP_CONDITION} RETURNING id `; diff --git a/tests/unit/repository/message-orphaned-requests.test.ts b/tests/unit/repository/message-orphaned-requests.test.ts index 93f3d2e76..80640f6a0 100644 --- a/tests/unit/repository/message-orphaned-requests.test.ts +++ b/tests/unit/repository/message-orphaned-requests.test.ts @@ -82,7 +82,7 @@ describe("sealOrphanedMessageRequests", () => { expect(built.sql).toContain("UPDATE message_request"); expect(built.sql).toContain("duration_ms IS NULL"); expect(built.sql).toContain("status_code IS NULL"); - expect(built.sql).toContain("updated_at <"); + expect((built.sql.match(/updated_at Date: Tue, 3 Mar 2026 06:19:56 +0800 Subject: [PATCH 30/45] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=20requeue=20?= =?UTF-8?q?=E6=BA=A2=E5=87=BA=E5=B9=B6=E4=BF=AE=E6=AD=A3=20endTime=20?= =?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/proxy-status-tracker.ts | 21 +-- src/repository/message-write-buffer.ts | 152 ++++++++++++++---- .../repository/message-write-buffer.test.ts | 38 +++++ 3 files changed, 169 insertions(+), 42 deletions(-) diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index af0951ac6..b636fdd31 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -212,16 +212,17 @@ export class ProxyStatusTracker { FROM users u -- 使用 LATERAL 为每个用户做一次“取最新请求”的索引扫描,避免在 message_request 大表上做 DISTINCT ON 全表排序去重。 JOIN LATERAL ( - SELECT - mr.id AS request_id, - mr.key AS key_string, - mr.provider_id AS provider_id, - p.name AS provider_name, - mr.model AS model, - mr.updated_at AS end_time - FROM message_request mr - JOIN providers p ON mr.provider_id = p.id AND p.deleted_at IS NULL - WHERE mr.user_id = u.id + SELECT + mr.id AS request_id, + mr.key AS key_string, + mr.provider_id AS provider_id, + p.name AS provider_name, + mr.model AS model, + -- 使用 created_at + duration_ms 推导结束时间:避免 async 批量写入导致 updated_at 漂移而“看起来更近”。 + (mr.created_at + (mr.duration_ms * interval '1 millisecond')) AS end_time + FROM message_request mr + JOIN providers p ON mr.provider_id = p.id AND p.deleted_at IS NULL + WHERE mr.user_id = u.id AND mr.deleted_at IS NULL -- lastRequest 仅统计已结束请求:activeRequests 已覆盖进行中请求,避免这里误选“请求中”的记录。 AND mr.duration_ms IS NOT NULL diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index 3896e8456..097ebb81d 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -180,7 +180,7 @@ function sanitizeNullableInt32( return sanitizeInt32(value, options); } -function sanitizeNumericString(value: unknown): string | undefined { +function sanitizeCostUsdString(value: unknown): string | undefined { let raw: string | undefined; if (typeof value === "number") { if (!Number.isFinite(value)) { @@ -303,7 +303,7 @@ function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePa }); } - const costUsd = sanitizeNumericString(patch.costUsd); + const costUsd = sanitizeCostUsdString(patch.costUsd); if (costUsd !== undefined) { sanitized.costUsd = costUsd; } @@ -594,38 +594,20 @@ class MessageRequestWriteBuffer { // 队列上限保护:DB 异常时避免无限增长导致 OOM if (this.pending.size > this.config.maxPending) { - // 优先丢弃非“终态”更新(不含 durationMs/statusCode 的条目),尽量保留请求完成信息 - let droppedId: number | undefined; - let droppedPatch: MessageRequestUpdatePatch | undefined; - - for (const candidateId of this.nonTerminalIds) { - const candidatePatch = this.pending.get(candidateId); - if (!candidatePatch) { - this.nonTerminalIds.delete(candidateId); - continue; - } - droppedId = candidateId; - droppedPatch = candidatePatch; - break; - } - - // 当 pending 全部为终态 patch 时,不应随机淘汰已有终态(会导致其他请求永久缺失完成信息)。 - // 此时优先丢弃“当前” patch,并让调用方按返回值决定是否走同步写入兜底。 - if (droppedId === undefined) { - droppedId = id; - droppedPatch = this.pending.get(id); - } - - if (droppedId !== undefined) { - this.pending.delete(droppedId); - this.nonTerminalIds.delete(droppedId); - if (droppedId === id) { - result = { kind: "dropped_overflow", patch: droppedPatch ?? sanitized }; + const trimResult = this.trimPendingToMaxPending(id); + if (trimResult.droppedCount > 0) { + if (trimResult.droppedCurrent) { + result = { + kind: "dropped_overflow", + patch: trimResult.droppedCurrentPatch ?? sanitized, + }; } - logger.warn("[MessageRequestWriteBuffer] Pending queue overflow, dropping update", { + logger.warn("[MessageRequestWriteBuffer] Pending queue overflow, dropping updates", { maxPending: this.config.maxPending, - droppedId, - droppedIsTerminal: droppedPatch ? isTerminalPatch(droppedPatch) : undefined, + droppedCount: trimResult.droppedCount, + droppedTerminalCount: trimResult.droppedTerminalCount, + droppedIdsSample: trimResult.droppedIdsSample, + droppedCurrent: trimResult.droppedCurrent, currentPending: this.pending.size, }); } @@ -694,6 +676,112 @@ class MessageRequestWriteBuffer { this.nonTerminalIds.add(item.id); } } + + const trimResult = this.trimPendingToMaxPending(); + if (trimResult.droppedCount > 0) { + logger.warn( + "[MessageRequestWriteBuffer] Pending queue overflow after requeue, dropping updates", + { + maxPending: this.config.maxPending, + droppedCount: trimResult.droppedCount, + droppedTerminalCount: trimResult.droppedTerminalCount, + droppedIdsSample: trimResult.droppedIdsSample, + currentPending: this.pending.size, + } + ); + } + } + + private trimPendingToMaxPending(currentId?: number): { + droppedCount: number; + droppedTerminalCount: number; + droppedIdsSample: number[]; + droppedCurrent: boolean; + droppedCurrentPatch?: MessageRequestUpdatePatch; + } { + let droppedCount = 0; + let droppedTerminalCount = 0; + const droppedIdsSample: number[] = []; + let droppedCurrent = false; + let droppedCurrentPatch: MessageRequestUpdatePatch | undefined; + + while (this.pending.size > this.config.maxPending) { + let droppedId: number | undefined; + let droppedPatch: MessageRequestUpdatePatch | undefined; + + // 优先丢弃非终态更新(不含 durationMs/statusCode 的条目),尽量保留请求完成信息。 + // 若存在其他可丢弃条目,尽量不要丢弃 currentId(避免本次 enqueue 的 patch 被优先淘汰)。 + for (const candidateId of this.nonTerminalIds) { + if (candidateId === currentId) { + continue; + } + const candidatePatch = this.pending.get(candidateId); + if (!candidatePatch) { + this.nonTerminalIds.delete(candidateId); + continue; + } + droppedId = candidateId; + droppedPatch = candidatePatch; + break; + } + + if (droppedId === undefined) { + for (const candidateId of this.nonTerminalIds) { + const candidatePatch = this.pending.get(candidateId); + if (!candidatePatch) { + this.nonTerminalIds.delete(candidateId); + continue; + } + droppedId = candidateId; + droppedPatch = candidatePatch; + break; + } + } + + if (droppedId === undefined) { + // 当 pending 全部为终态 patch 时,不应随机淘汰已有终态(会导致其他请求永久缺失完成信息)。 + // enqueue 路径会优先丢弃“当前” patch 并由调用方决定是否同步兜底;其他场景(如 requeue)则退化为丢弃最早条目。 + if (currentId !== undefined && this.pending.has(currentId)) { + droppedId = currentId; + droppedPatch = this.pending.get(currentId); + } else { + const first = this.pending.keys().next(); + if (first.done) { + break; + } + droppedId = first.value; + droppedPatch = this.pending.get(droppedId); + } + } + + if (droppedId === undefined) { + break; + } + + this.pending.delete(droppedId); + this.nonTerminalIds.delete(droppedId); + + if (droppedPatch && isTerminalPatch(droppedPatch)) { + droppedTerminalCount++; + } + + droppedCount++; + if (droppedIdsSample.length < 5) { + droppedIdsSample.push(droppedId); + } + if (droppedId === currentId) { + droppedCurrent = true; + droppedCurrentPatch = droppedPatch; + } + } + + return { + droppedCount, + droppedTerminalCount, + droppedIdsSample, + droppedCurrent, + droppedCurrentPatch, + }; } private handleTransientPerItemError( diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index dba41ff53..4904408fc 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -302,6 +302,44 @@ describe("message_request 异步批量写入", () => { await stopMessageRequestWriteBuffer(); }); + it("requeue 后应收敛到 maxPending,避免后续更新误触发 overflow 丢弃", async () => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + process.env.MESSAGE_REQUEST_ASYNC_BATCH_SIZE = "100"; + process.env.MESSAGE_REQUEST_ASYNC_MAX_PENDING = "100"; + + const deferred = createDeferred(); + executeMock.mockImplementationOnce(async () => deferred.promise); + + const { + enqueueMessageRequestUpdate, + flushMessageRequestWriteBuffer, + stopMessageRequestWriteBuffer, + } = await import("@/repository/message-write-buffer"); + + // pending=100 后会触发一次自动 flush(batchSize=100) + for (let i = 1; i <= 100; i++) { + enqueueMessageRequestUpdate(i, { durationMs: i }); + } + + const flushPromise = flushMessageRequestWriteBuffer(); + expect(executeMock).toHaveBeenCalledTimes(1); + + // flush in-flight 期间继续写入(填满 pending) + for (let i = 101; i <= 200; i++) { + enqueueMessageRequestUpdate(i, { durationMs: i }); + } + + // 暂态错误:触发 requeue + deferred.reject(new Error("db down")); + await flushPromise; + + // 若 requeue 不收敛到 maxPending,这里更新“已存在”的 id 也会误触发 overflow 并丢弃当前 patch。 + const result = enqueueMessageRequestUpdate(100, { durationMs: 999 }); + expect(result.kind).toBe("enqueued"); + + await stopMessageRequestWriteBuffer(); + }); + it("enqueueMessageRequestUpdate 的返回值应反映 patch 是否被接受(sanitize 为空时返回 rejected_invalid)", async () => { process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; From ef969f72766ca63884e495c2973caaad7929ee78 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 07:18:40 +0800 Subject: [PATCH 31/45] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=20message=5Freq?= =?UTF-8?q?uest=20=E5=BC=82=E6=AD=A5=E5=86=99=E5=85=A5=E4=B8=8E=E5=AD=A4?= =?UTF-8?q?=E5=84=BF=E5=B0=81=E9=97=AD=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repository/message-write-buffer.ts | 40 ++++++++++++++++--- src/repository/message.ts | 19 +++++---- .../message-orphaned-requests.test.ts | 1 - .../repository/message-write-buffer.test.ts | 18 +++++++-- 4 files changed, 59 insertions(+), 19 deletions(-) diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index 097ebb81d..1422039dc 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -79,6 +79,9 @@ let _lastRejectedInvalidLogAt = 0; const INT32_CLAMP_LOG_THROTTLE_MS = 60_000; const _lastInt32ClampLogAt = new Map(); +const REQUEUE_OVERFLOW_LOG_THROTTLE_MS = 60_000; +let _lastRequeueOverflowLogAt = 0; + // 终态 patch(duration/status)尽快刷库,但仍保留极短延迟以便 micro-batch,避免高并发下频繁 flush。 const TERMINAL_FLUSH_DELAY_MS = 10; @@ -568,6 +571,10 @@ class MessageRequestWriteBuffer { } enqueue(id: number, patch: MessageRequestUpdatePatch): MessageRequestUpdateEnqueueResult { + if (this.stopping) { + return { kind: "buffer_unavailable" }; + } + const sanitized = sanitizePatch(patch); if (Object.keys(sanitized).length === 0) { const now = Date.now(); @@ -582,8 +589,9 @@ class MessageRequestWriteBuffer { return { kind: "rejected_invalid" }; } - const existing = this.pending.get(id) ?? {}; - const merged = { ...existing, ...sanitized }; + const existing = this.pending.get(id); + const isNewId = existing === undefined; + const merged = { ...(existing ?? {}), ...sanitized }; this.pending.set(id, merged); if (isTerminalPatch(merged)) { this.nonTerminalIds.delete(id); @@ -593,8 +601,8 @@ class MessageRequestWriteBuffer { let result: MessageRequestUpdateEnqueueResult = { kind: "enqueued" }; // 队列上限保护:DB 异常时避免无限增长导致 OOM - if (this.pending.size > this.config.maxPending) { - const trimResult = this.trimPendingToMaxPending(id); + if (isNewId && this.pending.size > this.config.maxPending) { + const trimResult = this.trimPendingToMaxPending({ currentId: id, allowDropTerminal: true }); if (trimResult.droppedCount > 0) { if (trimResult.droppedCurrent) { result = { @@ -677,7 +685,7 @@ class MessageRequestWriteBuffer { } } - const trimResult = this.trimPendingToMaxPending(); + const trimResult = this.trimPendingToMaxPending({ allowDropTerminal: false }); if (trimResult.droppedCount > 0) { logger.warn( "[MessageRequestWriteBuffer] Pending queue overflow after requeue, dropping updates", @@ -690,15 +698,32 @@ class MessageRequestWriteBuffer { } ); } + + // requeue 场景下尽量不丢弃终态 patch(duration/status),避免“请求中”悬挂;若仍超上限,允许暂时超过 maxPending。 + if (this.pending.size > this.config.maxPending) { + const now = Date.now(); + if (now - _lastRequeueOverflowLogAt > REQUEUE_OVERFLOW_LOG_THROTTLE_MS) { + _lastRequeueOverflowLogAt = now; + logger.warn( + "[MessageRequestWriteBuffer] Pending queue exceeds maxPending after requeue, keeping terminal patches", + { + maxPending: this.config.maxPending, + currentPending: this.pending.size, + } + ); + } + } } - private trimPendingToMaxPending(currentId?: number): { + private trimPendingToMaxPending(options?: { currentId?: number; allowDropTerminal?: boolean }): { droppedCount: number; droppedTerminalCount: number; droppedIdsSample: number[]; droppedCurrent: boolean; droppedCurrentPatch?: MessageRequestUpdatePatch; } { + const currentId = options?.currentId; + const allowDropTerminal = options?.allowDropTerminal ?? true; let droppedCount = 0; let droppedTerminalCount = 0; const droppedIdsSample: number[] = []; @@ -739,6 +764,9 @@ class MessageRequestWriteBuffer { } if (droppedId === undefined) { + if (!allowDropTerminal) { + break; + } // 当 pending 全部为终态 patch 时,不应随机淘汰已有终态(会导致其他请求永久缺失完成信息)。 // enqueue 路径会优先丢弃“当前” patch 并由调用方决定是否同步兜底;其他场景(如 requeue)则退化为丢弃最早条目。 if (currentId !== undefined && this.pending.has(currentId)) { diff --git a/src/repository/message.ts b/src/repository/message.ts index 3fc93da25..b855bc133 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -114,7 +114,7 @@ async function writeMessageRequestUpdateToDb( */ export async function updateMessageRequestDuration(id: number, durationMs: number): Promise { const enqueueResult = enqueueMessageRequestUpdate(id, { durationMs }); - if (enqueueResult.kind === "enqueued" || enqueueResult.kind === "rejected_invalid") { + if (enqueueResult.kind === "enqueued") { return; } @@ -136,7 +136,7 @@ export async function updateMessageRequestCost( } const enqueueResult = enqueueMessageRequestUpdate(id, { costUsd: formattedCost }); - if (enqueueResult.kind === "enqueued" || enqueueResult.kind === "rejected_invalid") { + if (enqueueResult.kind === "enqueued") { return; } @@ -175,15 +175,15 @@ export async function updateMessageRequestDetails( errorMessage?: string; errorStack?: string; // 完整堆栈信息 errorCause?: string; // 嵌套错误原因(JSON 格式) - model?: string; // ⭐ 新增:支持更新重定向后的模型名称 - providerId?: number; // ⭐ 新增:支持更新最终供应商ID(重试切换后) + model?: string; // 新增:支持更新重定向后的模型名称 + providerId?: number; // 新增:支持更新最终供应商ID(重试切换后) context1mApplied?: boolean; // 是否应用了1M上下文窗口 swapCacheTtlApplied?: boolean; // Swap Cache TTL Billing active at request time specialSettings?: CreateMessageRequestData["special_settings"]; // 特殊设置(审计/展示) } ): Promise { const enqueueResult = enqueueMessageRequestUpdate(id, details); - if (enqueueResult.kind === "enqueued" || enqueueResult.kind === "rejected_invalid") { + if (enqueueResult.kind === "enqueued") { return; } @@ -243,7 +243,7 @@ export async function sealOrphanedMessageRequests(options?: { SELECT id FROM message_request WHERE deleted_at IS NULL - AND (duration_ms IS NULL OR status_code IS NULL) + AND duration_ms IS NULL AND updated_at < ${threshold} AND ${EXCLUDE_WARMUP_CONDITION} ORDER BY updated_at ASC @@ -261,11 +261,14 @@ export async function sealOrphanedMessageRequests(options?: { ) ), status_code = COALESCE(status_code, ${ORPHANED_MESSAGE_REQUEST_STATUS_CODE}), - error_message = COALESCE(error_message, ${ORPHANED_MESSAGE_REQUEST_ERROR_CODE}), + error_message = CASE + WHEN status_code IS NULL THEN COALESCE(error_message, ${ORPHANED_MESSAGE_REQUEST_ERROR_CODE}) + ELSE error_message + END, updated_at = NOW() WHERE id IN (SELECT id FROM candidates) AND deleted_at IS NULL - AND (duration_ms IS NULL OR status_code IS NULL) + AND duration_ms IS NULL AND updated_at < ${threshold} AND ${EXCLUDE_WARMUP_CONDITION} RETURNING id diff --git a/tests/unit/repository/message-orphaned-requests.test.ts b/tests/unit/repository/message-orphaned-requests.test.ts index 80640f6a0..965eee61a 100644 --- a/tests/unit/repository/message-orphaned-requests.test.ts +++ b/tests/unit/repository/message-orphaned-requests.test.ts @@ -81,7 +81,6 @@ describe("sealOrphanedMessageRequests", () => { expect(built.sql).toContain("UPDATE message_request"); expect(built.sql).toContain("duration_ms IS NULL"); - expect(built.sql).toContain("status_code IS NULL"); expect((built.sql.match(/updated_at { await stopMessageRequestWriteBuffer(); }); - it("requeue 后应收敛到 maxPending,避免后续更新误触发 overflow 丢弃", async () => { + it("requeue 后更新已存在 id 不应误触发 overflow 丢弃", async () => { process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; process.env.MESSAGE_REQUEST_ASYNC_BATCH_SIZE = "100"; process.env.MESSAGE_REQUEST_ASYNC_MAX_PENDING = "100"; @@ -318,7 +318,7 @@ describe("message_request 异步批量写入", () => { // pending=100 后会触发一次自动 flush(batchSize=100) for (let i = 1; i <= 100; i++) { - enqueueMessageRequestUpdate(i, { durationMs: i }); + enqueueMessageRequestUpdate(i, { durationMs: 1 }); } const flushPromise = flushMessageRequestWriteBuffer(); @@ -326,18 +326,28 @@ describe("message_request 异步批量写入", () => { // flush in-flight 期间继续写入(填满 pending) for (let i = 101; i <= 200; i++) { - enqueueMessageRequestUpdate(i, { durationMs: i }); + enqueueMessageRequestUpdate(i, { durationMs: 1 }); } // 暂态错误:触发 requeue deferred.reject(new Error("db down")); await flushPromise; - // 若 requeue 不收敛到 maxPending,这里更新“已存在”的 id 也会误触发 overflow 并丢弃当前 patch。 + // 即使 requeue 后 pending 可能暂时超过 maxPending,这里更新“已存在”的 id 也不应误触发 overflow 并丢弃当前 patch。 const result = enqueueMessageRequestUpdate(100, { durationMs: 999 }); expect(result.kind).toBe("enqueued"); await stopMessageRequestWriteBuffer(); + + // 防回归:旧实现的 requeue trim 可能会静默丢弃终态 patch(duration/status),导致这些请求永久缺失完成信息。 + // 本用例在 requeue 后确保 id=101 的终态更新仍会被写入 DB。 + expect(executeMock).toHaveBeenCalledTimes(3); + const secondQuery = executeMock.mock.calls[1]?.[0]; + const thirdQuery = executeMock.mock.calls[2]?.[0]; + const secondBuilt = toSqlText(secondQuery); + const thirdBuilt = toSqlText(thirdQuery); + expect(secondBuilt.params).toContain(101); + expect(thirdBuilt.params).toContain(2); }); it("enqueueMessageRequestUpdate 的返回值应反映 patch 是否被接受(sanitize 为空时返回 rejected_invalid)", async () => { From c0b49893cb3bf774671b49ea29ef412f6c999ba4 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 07:49:16 +0800 Subject: [PATCH 32/45] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=20message=5Freq?= =?UTF-8?q?uest=20sanitize=20=E4=B8=8E=20proxy-status=20=E5=8F=AF=E8=A7=82?= =?UTF-8?q?=E6=B5=8B=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/proxy-status-tracker.ts | 10 +--- src/repository/message-write-buffer.ts | 53 +++++++++++++++++-- src/repository/message.ts | 12 ++++- .../repository/message-write-buffer.test.ts | 2 + 4 files changed, 62 insertions(+), 15 deletions(-) diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index b636fdd31..362fb2c34 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -173,13 +173,7 @@ export class ProxyStatusTracker { .from(messageRequest) .innerJoin(providers, eq(messageRequest.providerId, providers.id)) .leftJoin(keys, and(eq(keys.key, messageRequest.key), isNull(keys.deletedAt))) - .where( - and( - isNull(messageRequest.deletedAt), - isNull(messageRequest.durationMs), - isNull(providers.deletedAt) - ) - ) + .where(and(isNull(messageRequest.deletedAt), isNull(messageRequest.durationMs))) // 防御:异常情况下 durationMs 长期为空会导致“活跃请求”无限累积,进而撑爆查询与响应体。 // 这里对返回明细做上限保护(监控用途不需要无穷列表)。 .orderBy(desc(messageRequest.createdAt)) @@ -221,7 +215,7 @@ export class ProxyStatusTracker { -- 使用 created_at + duration_ms 推导结束时间:避免 async 批量写入导致 updated_at 漂移而“看起来更近”。 (mr.created_at + (mr.duration_ms * interval '1 millisecond')) AS end_time FROM message_request mr - JOIN providers p ON mr.provider_id = p.id AND p.deleted_at IS NULL + JOIN providers p ON mr.provider_id = p.id WHERE mr.user_id = u.id AND mr.deleted_at IS NULL -- lastRequest 仅统计已结束请求:activeRequests 已覆盖进行中请求,避免这里误选“请求中”的记录。 diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index 1422039dc..e4e67f48c 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -79,6 +79,15 @@ let _lastRejectedInvalidLogAt = 0; const INT32_CLAMP_LOG_THROTTLE_MS = 60_000; const _lastInt32ClampLogAt = new Map(); +const STRING_TRUNCATE_LOG_THROTTLE_MS = 60_000; +const _lastStringTruncateLogAt = new Map(); + +const MESSAGE_REQUEST_MODEL_MAX_LENGTH = 128; +const MESSAGE_REQUEST_CACHE_TTL_APPLIED_MAX_LENGTH = 10; +const MESSAGE_REQUEST_ERROR_MESSAGE_MAX_LENGTH = 8_192; +const MESSAGE_REQUEST_ERROR_STACK_MAX_LENGTH = 65_536; +const MESSAGE_REQUEST_ERROR_CAUSE_MAX_LENGTH = 8_192; + const REQUEUE_OVERFLOW_LOG_THROTTLE_MS = 60_000; let _lastRequeueOverflowLogAt = 0; @@ -121,6 +130,25 @@ function summarizePatchTypes(patch: MessageRequestUpdatePatch): Record STRING_TRUNCATE_LOG_THROTTLE_MS) { + _lastStringTruncateLogAt.set(options.field, now); + logger.warn("[MessageRequestWriteBuffer] String field too long, truncating", { + field: options.field, + maxLength: options.maxLength, + originalLength: value.length, + }); + } + + return value.slice(0, options.maxLength); +} + function sanitizeInt32( value: unknown, options?: { min?: number; max?: number; field?: string } @@ -299,7 +327,10 @@ function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePa if (patch.cacheTtlApplied === null) { sanitized.cacheTtlApplied = null; } else if (typeof patch.cacheTtlApplied === "string") { - sanitized.cacheTtlApplied = patch.cacheTtlApplied; + sanitized.cacheTtlApplied = truncateString(patch.cacheTtlApplied, { + field: "cacheTtlApplied", + maxLength: MESSAGE_REQUEST_CACHE_TTL_APPLIED_MAX_LENGTH, + }); } else if (patch.cacheTtlApplied !== undefined) { logger.warn("[MessageRequestWriteBuffer] Invalid cacheTtlApplied type, skipping", { cacheTtlAppliedType: typeof patch.cacheTtlApplied, @@ -329,16 +360,28 @@ function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePa } if (typeof patch.errorMessage === "string") { - sanitized.errorMessage = patch.errorMessage; + sanitized.errorMessage = truncateString(patch.errorMessage, { + field: "errorMessage", + maxLength: MESSAGE_REQUEST_ERROR_MESSAGE_MAX_LENGTH, + }); } if (typeof patch.errorStack === "string") { - sanitized.errorStack = patch.errorStack; + sanitized.errorStack = truncateString(patch.errorStack, { + field: "errorStack", + maxLength: MESSAGE_REQUEST_ERROR_STACK_MAX_LENGTH, + }); } if (typeof patch.errorCause === "string") { - sanitized.errorCause = patch.errorCause; + sanitized.errorCause = truncateString(patch.errorCause, { + field: "errorCause", + maxLength: MESSAGE_REQUEST_ERROR_CAUSE_MAX_LENGTH, + }); } if (typeof patch.model === "string") { - sanitized.model = patch.model; + sanitized.model = truncateString(patch.model, { + field: "model", + maxLength: MESSAGE_REQUEST_MODEL_MAX_LENGTH, + }); } const providerId = sanitizeInt32(patch.providerId, { diff --git a/src/repository/message.ts b/src/repository/message.ts index b855bc133..83f685bee 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -135,7 +135,15 @@ export async function updateMessageRequestCost( return; } - const enqueueResult = enqueueMessageRequestUpdate(id, { costUsd: formattedCost }); + const sanitizedCost = sanitizeMessageRequestUpdatePatch({ costUsd: formattedCost }).costUsd; + if (!sanitizedCost) { + logger.warn("[MessageRepository] costUsd rejected by sanitize, dropping cost update", { + requestId: id, + }); + return; + } + + const enqueueResult = enqueueMessageRequestUpdate(id, { costUsd: sanitizedCost }); if (enqueueResult.kind === "enqueued") { return; } @@ -153,7 +161,7 @@ export async function updateMessageRequestCost( return; } - await writeMessageRequestUpdateToDb(id, { costUsd: formattedCost }); + await writeMessageRequestUpdateToDb(id, { costUsd: sanitizedCost }); } /** diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index a4f69ead2..da1581763 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -1,3 +1,4 @@ +import { CasingCache } from "drizzle-orm/casing"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; type EnvSnapshot = Partial>; @@ -22,6 +23,7 @@ function restoreEnv(snapshot: EnvSnapshot) { function toSqlText(query: { toQuery: (config: any) => { sql: string; params: unknown[] } }) { return query.toQuery({ + casing: new CasingCache(), escapeName: (name: string) => `"${name}"`, escapeParam: (index: number) => `$${index}`, escapeString: (value: string) => `'${value}'`, From cc5ceb992f4877bd179a3dcda12c9ab8a9c08ffc Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 08:11:05 +0800 Subject: [PATCH 33/45] =?UTF-8?q?fix:=20proxy-status=20=E9=9A=90=E8=97=8F?= =?UTF-8?q?=E5=B7=B2=E8=BD=AF=E5=88=A0=E9=99=A4=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/proxy-status-tracker.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index 362fb2c34..40f5cb381 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -11,7 +11,7 @@ type ActiveRequestRow = { keyString: string; keyName: string | null; providerId: number; - providerName: string; + providerName: string | null; model: string | null; createdAt: Date | string | null; }; @@ -22,7 +22,7 @@ type LastRequestRow = { keyString: string; keyName: string | null; providerId: number; - providerName: string; + providerName: string | null; model: string | null; endTime: Date | string | null; }; @@ -108,7 +108,7 @@ export class ProxyStatusTracker { requestId: row.requestId, keyName: row.keyName ?? maskKey(row.keyString), providerId: row.providerId, - providerName: row.providerName, + providerName: row.providerName || "unknown", model: row.model || "unknown", startTime, duration: now - startTime, @@ -136,7 +136,7 @@ export class ProxyStatusTracker { requestId: lastRow.requestId, keyName: lastRow.keyName ?? maskKey(lastRow.keyString), providerId: lastRow.providerId, - providerName: lastRow.providerName, + providerName: lastRow.providerName || "unknown", model: lastRow.model || "unknown", endTime, elapsed: now - endTime, @@ -165,13 +165,16 @@ export class ProxyStatusTracker { userId: messageRequest.userId, keyString: messageRequest.key, keyName: keys.name, - providerId: providers.id, + providerId: messageRequest.providerId, providerName: providers.name, model: messageRequest.model, createdAt: messageRequest.createdAt, }) .from(messageRequest) - .innerJoin(providers, eq(messageRequest.providerId, providers.id)) + .leftJoin( + providers, + and(eq(messageRequest.providerId, providers.id), isNull(providers.deletedAt)) + ) .leftJoin(keys, and(eq(keys.key, messageRequest.key), isNull(keys.deletedAt))) .where(and(isNull(messageRequest.deletedAt), isNull(messageRequest.durationMs))) // 防御:异常情况下 durationMs 长期为空会导致“活跃请求”无限累积,进而撑爆查询与响应体。 @@ -215,7 +218,7 @@ export class ProxyStatusTracker { -- 使用 created_at + duration_ms 推导结束时间:避免 async 批量写入导致 updated_at 漂移而“看起来更近”。 (mr.created_at + (mr.duration_ms * interval '1 millisecond')) AS end_time FROM message_request mr - JOIN providers p ON mr.provider_id = p.id + LEFT JOIN providers p ON mr.provider_id = p.id AND p.deleted_at IS NULL WHERE mr.user_id = u.id AND mr.deleted_at IS NULL -- lastRequest 仅统计已结束请求:activeRequests 已覆盖进行中请求,避免这里误选“请求中”的记录。 From 46a1bda4707e9180f02b4798968c4308e85b615a Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 08:53:17 +0800 Subject: [PATCH 34/45] =?UTF-8?q?fix:=20=E9=87=87=E7=BA=B3=20AI=20review?= =?UTF-8?q?=EF=BC=8C=E5=AE=8C=E5=96=84=20per-item=20flush=20=E4=B8=8E=20co?= =?UTF-8?q?stUsd=20=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/proxy-status-tracker.ts | 3 +++ src/repository/message-write-buffer.ts | 17 ++++++++++++++-- src/repository/message.ts | 20 +++++++++++++------ tests/helpers/drizzle.ts | 15 ++++++++++++++ .../message-orphaned-requests.test.ts | 12 +---------- .../repository/message-write-buffer.test.ts | 12 +---------- 6 files changed, 49 insertions(+), 30 deletions(-) create mode 100644 tests/helpers/drizzle.ts diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index 40f5cb381..33b0cec74 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -196,6 +196,9 @@ export class ProxyStatusTracker { } private async loadLastRequests(): Promise { + // 注意:该接口需要返回所有用户状态,因此整体复杂度与 users 数量线性相关。 + // 这里使用 LATERAL + 索引扫描来避免在 message_request 大表上做全表排序去重(DISTINCT ON), + // 若未来用户规模显著增大(例如 1e4+),建议为该接口增加分页/按需查询,或引入专门的汇总表/物化视图。 const query = sql` SELECT u.id AS "userId", diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index e4e67f48c..672fb8990 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -900,8 +900,13 @@ class MessageRequestWriteBuffer { } if (!singleQuery) { - lastFailure = null; - break; + // 本策略未产生任何可更新列(例如字段被过滤/无法序列化):尝试下一个降级策略。 + lastFailure = { + kind: "build", + strategy: name, + error: new Error("No SQL columns produced for patch strategy"), + }; + continue; } try { @@ -925,6 +930,14 @@ class MessageRequestWriteBuffer { logger.error("[MessageRequestWriteBuffer] Dropping invalid update to unblock queue", { requestId: item.id, keys: Object.keys(item.patch), + types: summarizePatchTypes(item.patch), + sample: (() => { + try { + return JSON.stringify(item.patch).slice(0, 200); + } catch { + return undefined; + } + })(), failureKind: lastFailure.kind, failureStrategy: lastFailure.strategy, error: diff --git a/src/repository/message.ts b/src/repository/message.ts index 83f685bee..dc33fa265 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -86,6 +86,15 @@ async function writeMessageRequestUpdateToDb( ): Promise { const sanitized = sanitizeMessageRequestUpdatePatch(patch); if (Object.keys(sanitized).length === 0) { + const definedKeys = Object.entries(patch) + .filter(([, value]) => value !== undefined) + .map(([key]) => key); + if (definedKeys.length > 0) { + logger.warn("[MessageRepository] Message request patch rejected: empty after sanitize", { + requestId: id, + keys: definedKeys, + }); + } return; } @@ -135,15 +144,14 @@ export async function updateMessageRequestCost( return; } - const sanitizedCost = sanitizeMessageRequestUpdatePatch({ costUsd: formattedCost }).costUsd; - if (!sanitizedCost) { - logger.warn("[MessageRepository] costUsd rejected by sanitize, dropping cost update", { + const enqueueResult = enqueueMessageRequestUpdate(id, { costUsd: formattedCost }); + if (enqueueResult.kind === "rejected_invalid") { + logger.warn("[MessageRepository] costUsd update rejected as invalid, skipping", { requestId: id, + costUsd: formattedCost, }); return; } - - const enqueueResult = enqueueMessageRequestUpdate(id, { costUsd: sanitizedCost }); if (enqueueResult.kind === "enqueued") { return; } @@ -161,7 +169,7 @@ export async function updateMessageRequestCost( return; } - await writeMessageRequestUpdateToDb(id, { costUsd: sanitizedCost }); + await writeMessageRequestUpdateToDb(id, { costUsd: formattedCost }); } /** diff --git a/tests/helpers/drizzle.ts b/tests/helpers/drizzle.ts new file mode 100644 index 000000000..a70a74b83 --- /dev/null +++ b/tests/helpers/drizzle.ts @@ -0,0 +1,15 @@ +import { CasingCache } from "drizzle-orm/casing"; + +type QueryToSql = { + toQuery: (config: any) => { sql: string; params: unknown[] }; +}; + +export function toSqlText(query: QueryToSql) { + return query.toQuery({ + casing: new CasingCache(), + escapeName: (name: string) => `"${name}"`, + escapeParam: (index: number) => `$${index}`, + escapeString: (value: string) => `'${value}'`, + paramStartIndex: { value: 1 }, + }); +} diff --git a/tests/unit/repository/message-orphaned-requests.test.ts b/tests/unit/repository/message-orphaned-requests.test.ts index 965eee61a..b3286e68a 100644 --- a/tests/unit/repository/message-orphaned-requests.test.ts +++ b/tests/unit/repository/message-orphaned-requests.test.ts @@ -1,5 +1,5 @@ -import { CasingCache } from "drizzle-orm/casing"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { toSqlText } from "../../helpers/drizzle"; type EnvSnapshot = Partial>; @@ -21,16 +21,6 @@ function restoreEnv(snapshot: EnvSnapshot) { } } -function toSqlText(query: { toQuery: (config: any) => { sql: string; params: unknown[] } }) { - return query.toQuery({ - casing: new CasingCache(), - escapeName: (name: string) => `"${name}"`, - escapeParam: (index: number) => `$${index}`, - escapeString: (value: string) => `'${value}'`, - paramStartIndex: { value: 1 }, - }); -} - describe("sealOrphanedMessageRequests", () => { const envKeys = ["NODE_ENV", "DSN", "FETCH_BODY_TIMEOUT"]; const originalEnv = snapshotEnv(envKeys); diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index da1581763..abd40758e 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -1,5 +1,5 @@ -import { CasingCache } from "drizzle-orm/casing"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { toSqlText } from "../../helpers/drizzle"; type EnvSnapshot = Partial>; @@ -21,16 +21,6 @@ function restoreEnv(snapshot: EnvSnapshot) { } } -function toSqlText(query: { toQuery: (config: any) => { sql: string; params: unknown[] } }) { - return query.toQuery({ - casing: new CasingCache(), - escapeName: (name: string) => `"${name}"`, - escapeParam: (index: number) => `$${index}`, - escapeString: (value: string) => `'${value}'`, - paramStartIndex: { value: 1 }, - }); -} - function createDeferred() { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; From 1531b40bb623761aa06383a5fd348e06d2def941 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 09:20:14 +0800 Subject: [PATCH 35/45] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AD=A4?= =?UTF-8?q?=E5=84=BF=E6=B8=85=E6=89=AB=E5=99=A8=E5=B9=B6=E5=8F=91=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E4=B8=8E=E9=9D=9E=E6=B3=95=20statusCode=20?= =?UTF-8?q?=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/instrumentation.ts | 11 +++++++++- src/repository/message-write-buffer.ts | 29 +++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 6709828fc..a862c35e3 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -150,6 +150,10 @@ async function startOrphanedMessageRequestSweeper(): Promise { return; } + // 先标记 started,避免并发初始化(例如热重载/多入口 init)导致重复注册 interval, + // 从而出现“先注册的 intervalId 被后注册覆盖,导致无法停止”的问题。 + instrumentationState.__CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_STARTED__ = true; + try { const { sealOrphanedMessageRequests } = await import("@/repository/message"); const intervalMs = 60 * 1000; @@ -234,11 +238,16 @@ async function startOrphanedMessageRequestSweeper(): Promise { void runOnce("scheduled"); }, intervalMs); - instrumentationState.__CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_STARTED__ = true; logger.info("[Instrumentation] Orphaned message_request sweeper started", { intervalSeconds: intervalMs / 1000, }); } catch (error) { + // init 失败:回滚 started 标记,允许后续重试 + instrumentationState.__CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_STARTED__ = false; + if (instrumentationState.__CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_INTERVAL_ID__) { + clearInterval(instrumentationState.__CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_INTERVAL_ID__); + instrumentationState.__CCH_ORPHANED_MESSAGE_REQUEST_SWEEPER_INTERVAL_ID__ = undefined; + } logger.warn("[Instrumentation] Orphaned message_request sweeper init failed", { error: error instanceof Error ? error.message : String(error), }); diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index 672fb8990..c33045168 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -79,6 +79,9 @@ let _lastRejectedInvalidLogAt = 0; const INT32_CLAMP_LOG_THROTTLE_MS = 60_000; const _lastInt32ClampLogAt = new Map(); +const STATUS_CODE_REJECT_LOG_THROTTLE_MS = 60_000; +let _lastStatusCodeRejectLogAt = 0; + const STRING_TRUNCATE_LOG_THROTTLE_MS = 60_000; const _lastStringTruncateLogAt = new Map(); @@ -211,6 +214,30 @@ function sanitizeNullableInt32( return sanitizeInt32(value, options); } +function sanitizeStatusCode(value: unknown): number | undefined { + const numeric = toFiniteNumber(value); + if (numeric === null) { + return undefined; + } + + const truncated = Math.trunc(numeric); + // HTTP status code 正常范围为 100-999(本项目还会写入 520 作为“孤儿请求”哨兵) + if (truncated < 100 || truncated > 999) { + const now = Date.now(); + if (now - _lastStatusCodeRejectLogAt > STATUS_CODE_REJECT_LOG_THROTTLE_MS) { + _lastStatusCodeRejectLogAt = now; + logger.warn("[MessageRequestWriteBuffer] Status code out of expected range, skipping", { + value: typeof value === "string" ? value.slice(0, 64) : value, + min: 100, + max: 999, + }); + } + return undefined; + } + + return truncated; +} + function sanitizeCostUsdString(value: unknown): string | undefined { let raw: string | undefined; if (typeof value === "number") { @@ -260,7 +287,7 @@ function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePa sanitized.durationMs = durationMs; } - const statusCode = sanitizeInt32(patch.statusCode, { field: "statusCode", min: 0, max: 999 }); + const statusCode = sanitizeStatusCode(patch.statusCode); if (statusCode !== undefined) { sanitized.statusCode = statusCode; } From aaea3f6fc1a729704d6c94c49ed131b28b5888da Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 10:51:19 +0800 Subject: [PATCH 36/45] =?UTF-8?q?fix:=20#854=20=E8=A1=A5=E5=85=85=20comple?= =?UTF-8?q?ted=20=E7=B4=A2=E5=BC=95=E5=B9=B6=E4=BC=98=E5=8C=96=20orphan=20?= =?UTF-8?q?sealer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 proxy-status lastRequest LATERAL 查询增加部分索引 + 迁移\n- orphan 封闭阈值改用 created_at,避免 updated_at 漂移延迟\n- rejected_invalid 直接返回,避免重复 sanitize/告警\n- stop 阶段剩余 pending 时记录 error 便于排障 --- drizzle/0078_huge_weapon_omega.sql | 1 + drizzle/meta/0078_snapshot.json | 3936 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/drizzle/schema.ts | 6 + src/lib/proxy-status-tracker.ts | 2 +- src/repository/message-write-buffer.ts | 24 + src/repository/message.ts | 10 +- .../message-orphaned-requests.test.ts | 2 +- 8 files changed, 3983 insertions(+), 5 deletions(-) create mode 100644 drizzle/0078_huge_weapon_omega.sql create mode 100644 drizzle/meta/0078_snapshot.json diff --git a/drizzle/0078_huge_weapon_omega.sql b/drizzle/0078_huge_weapon_omega.sql new file mode 100644 index 000000000..998e2addc --- /dev/null +++ b/drizzle/0078_huge_weapon_omega.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS "idx_message_request_user_created_at_id_completed" ON "message_request" USING btree ("user_id","created_at" DESC NULLS LAST,"id" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL AND "message_request"."duration_ms" IS NOT NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup'); diff --git a/drizzle/meta/0078_snapshot.json b/drizzle/meta/0078_snapshot.json new file mode 100644 index 000000000..167b820e4 --- /dev/null +++ b/drizzle/meta/0078_snapshot.json @@ -0,0 +1,3936 @@ +{ + "id": "e0e0d836-cd6e-42ec-8531-7a65fa046cb4", + "prevId": "22eb3652-56d7-4a04-9845-5fa18210ef90", + "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 + }, + "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_user_created_at_id_completed": { + "name": "idx_message_request_user_created_at_id_completed", + "columns": [ + { + "expression": "user_id", + "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 AND \"message_request\".\"duration_ms\" IS NOT 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_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_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(50)", + "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 + }, + "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 + }, + "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 + }, + "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": {} + } + }, + "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'" + }, + "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 + }, + "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_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": {} + } + }, + "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 + }, + "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 fb7b5a646..bb8325759 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -547,6 +547,13 @@ "when": 1772219877045, "tag": "0077_nappy_giant_man", "breakpoints": true + }, + { + "idx": 78, + "version": "7", + "when": 1772505850994, + "tag": "0078_huge_weapon_omega", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 60a1f8e17..b0af79667 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -500,6 +500,12 @@ export const messageRequest = pgTable('message_request', { .where(sql`${table.deletedAt} IS NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')`), // 优化用户查询的复合索引(按创建时间倒序) messageRequestUserQueryIdx: index('idx_message_request_user_query').on(table.userId, table.createdAt).where(sql`${table.deletedAt} IS NULL`), + // #854:proxy-status LATERAL lastRequest 查询加速(仅扫描已结束请求,避免孤儿积累时回溯过深) + messageRequestUserCreatedAtIdCompletedIdx: index('idx_message_request_user_created_at_id_completed') + .on(table.userId, table.createdAt.desc(), table.id.desc()) + .where( + sql`${table.deletedAt} IS NULL AND ${table.durationMs} IS NOT NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')` + ), 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')`), diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index 33b0cec74..d6420e82f 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -227,7 +227,7 @@ export class ProxyStatusTracker { -- lastRequest 仅统计已结束请求:activeRequests 已覆盖进行中请求,避免这里误选“请求中”的记录。 AND mr.duration_ms IS NOT NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - -- 这里使用 created_at 排序以更好利用既有索引(user_id, created_at),减少全表排序压力。 + -- 这里使用 created_at + id 排序以命中 idx_message_request_user_created_at_id_completed,避免孤儿积累时回溯过深。 ORDER BY mr.created_at DESC, mr.id DESC LIMIT 1 ) last ON true diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index c33045168..df6b069d1 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -785,6 +785,20 @@ class MessageRequestWriteBuffer { } } + private pruneNonTerminalIds(): void { + // 防御:确保 nonTerminalIds 不含 stale/终态条目,避免 overflow 场景下反复扫描“幽灵 id” + const toDelete: number[] = []; + for (const id of this.nonTerminalIds) { + const patch = this.pending.get(id); + if (!patch || isTerminalPatch(patch)) { + toDelete.push(id); + } + } + for (const id of toDelete) { + this.nonTerminalIds.delete(id); + } + } + private trimPendingToMaxPending(options?: { currentId?: number; allowDropTerminal?: boolean }): { droppedCount: number; droppedTerminalCount: number; @@ -792,6 +806,8 @@ class MessageRequestWriteBuffer { droppedCurrent: boolean; droppedCurrentPatch?: MessageRequestUpdatePatch; } { + this.pruneNonTerminalIds(); + const currentId = options?.currentId; const allowDropTerminal = options?.allowDropTerminal ?? true; let droppedCount = 0; @@ -1086,11 +1102,19 @@ class MessageRequestWriteBuffer { async stop(): Promise { this.stopping = true; this.clearFlushTimer(); + const pendingBefore = this.pending.size; await this.flush(); // stop 期间尽量补刷一次,避免极小概率竞态导致的 tail 更新残留 if (this.pending.size > 0) { await this.flush(); } + if (this.pending.size > 0) { + logger.error("[MessageRequestWriteBuffer] Stop finished with pending updates remaining", { + pendingBefore, + pendingAfter: this.pending.size, + nonTerminalIds: this.nonTerminalIds.size, + }); + } } } diff --git a/src/repository/message.ts b/src/repository/message.ts index dc33fa265..bb6185131 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -202,6 +202,10 @@ export async function updateMessageRequestDetails( if (enqueueResult.kind === "enqueued") { return; } + if (enqueueResult.kind === "rejected_invalid") { + // patch 在 buffer 内已被 sanitize 并判定为空;避免重复 sanitize/重复告警 + return; + } // overflow 场景下:尽量丢弃非终态 patch,避免在压力峰值时反向放大 DB 写入。 // 但如果 overflow 丢失了终态信息(duration/status),则必须同步写入兜底以避免请求长期卡在“请求中”。 @@ -260,9 +264,9 @@ export async function sealOrphanedMessageRequests(options?: { FROM message_request WHERE deleted_at IS NULL AND duration_ms IS NULL - AND updated_at < ${threshold} + AND created_at < ${threshold} AND ${EXCLUDE_WARMUP_CONDITION} - ORDER BY updated_at ASC + ORDER BY created_at ASC LIMIT ${limit} ) UPDATE message_request @@ -285,7 +289,7 @@ export async function sealOrphanedMessageRequests(options?: { WHERE id IN (SELECT id FROM candidates) AND deleted_at IS NULL AND duration_ms IS NULL - AND updated_at < ${threshold} + AND created_at < ${threshold} AND ${EXCLUDE_WARMUP_CONDITION} RETURNING id `; diff --git a/tests/unit/repository/message-orphaned-requests.test.ts b/tests/unit/repository/message-orphaned-requests.test.ts index b3286e68a..a9a465416 100644 --- a/tests/unit/repository/message-orphaned-requests.test.ts +++ b/tests/unit/repository/message-orphaned-requests.test.ts @@ -71,7 +71,7 @@ describe("sealOrphanedMessageRequests", () => { expect(built.sql).toContain("UPDATE message_request"); expect(built.sql).toContain("duration_ms IS NULL"); - expect((built.sql.match(/updated_at Date: Tue, 3 Mar 2026 11:18:40 +0800 Subject: [PATCH 37/45] =?UTF-8?q?chore:=20=E9=87=87=E7=BA=B3=20Greptile=20?= =?UTF-8?q?=E5=BB=BA=E8=AE=AE=E6=95=B4=E7=90=86=E6=B5=8B=E8=AF=95=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=B9=B6=E5=A2=9E=E5=BC=BA=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽出 tests/helpers/env.ts 避免重复 env snapshot\n- drop invalid update 日志增加 isTerminal 且按终态提升严重级别\n- 补充注释说明 loadLastRequests 仅返回有完成记录的用户 --- src/lib/proxy-status-tracker.ts | 1 + src/repository/message-write-buffer.ts | 5 ++++- tests/helpers/env.ts | 19 +++++++++++++++++ .../message-orphaned-requests.test.ts | 21 +------------------ .../repository/message-write-buffer.test.ts | 21 +------------------ 5 files changed, 26 insertions(+), 41 deletions(-) create mode 100644 tests/helpers/env.ts diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index d6420e82f..af19bb292 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -199,6 +199,7 @@ export class ProxyStatusTracker { // 注意:该接口需要返回所有用户状态,因此整体复杂度与 users 数量线性相关。 // 这里使用 LATERAL + 索引扫描来避免在 message_request 大表上做全表排序去重(DISTINCT ON), // 若未来用户规模显著增大(例如 1e4+),建议为该接口增加分页/按需查询,或引入专门的汇总表/物化视图。 + // 本查询仅返回“存在已结束请求”的用户;其余用户由 getAllUsersStatus 补齐 lastRequest=null。 const query = sql` SELECT u.id AS "userId", diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index df6b069d1..c37ee6894 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -970,8 +970,11 @@ class MessageRequestWriteBuffer { } if (lastFailure) { - logger.error("[MessageRequestWriteBuffer] Dropping invalid update to unblock queue", { + const isTerminal = isTerminalPatch(item.patch); + const log = isTerminal ? logger.error : logger.warn; + log("[MessageRequestWriteBuffer] Dropping invalid update to unblock queue", { requestId: item.id, + isTerminal, keys: Object.keys(item.patch), types: summarizePatchTypes(item.patch), sample: (() => { diff --git a/tests/helpers/env.ts b/tests/helpers/env.ts new file mode 100644 index 000000000..ed851e3fb --- /dev/null +++ b/tests/helpers/env.ts @@ -0,0 +1,19 @@ +export type EnvSnapshot = Record; + +export function snapshotEnv(keys: string[]): EnvSnapshot { + const snapshot: EnvSnapshot = {}; + for (const key of keys) { + snapshot[key] = process.env[key]; + } + return snapshot; +} + +export function restoreEnv(snapshot: EnvSnapshot): void { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} diff --git a/tests/unit/repository/message-orphaned-requests.test.ts b/tests/unit/repository/message-orphaned-requests.test.ts index a9a465416..46aaab751 100644 --- a/tests/unit/repository/message-orphaned-requests.test.ts +++ b/tests/unit/repository/message-orphaned-requests.test.ts @@ -1,26 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { restoreEnv, snapshotEnv } from "../../helpers/env"; import { toSqlText } from "../../helpers/drizzle"; -type EnvSnapshot = Partial>; - -function snapshotEnv(keys: string[]): EnvSnapshot { - const snapshot: EnvSnapshot = {}; - for (const key of keys) { - snapshot[key] = process.env[key]; - } - return snapshot; -} - -function restoreEnv(snapshot: EnvSnapshot) { - for (const [key, value] of Object.entries(snapshot)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } -} - describe("sealOrphanedMessageRequests", () => { const envKeys = ["NODE_ENV", "DSN", "FETCH_BODY_TIMEOUT"]; const originalEnv = snapshotEnv(envKeys); diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index abd40758e..bbd99fbe9 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -1,26 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { restoreEnv, snapshotEnv } from "../../helpers/env"; import { toSqlText } from "../../helpers/drizzle"; -type EnvSnapshot = Partial>; - -function snapshotEnv(keys: string[]): EnvSnapshot { - const snapshot: EnvSnapshot = {}; - for (const key of keys) { - snapshot[key] = process.env[key]; - } - return snapshot; -} - -function restoreEnv(snapshot: EnvSnapshot) { - for (const [key, value] of Object.entries(snapshot)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } -} - function createDeferred() { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; From 8a0831617f9f80d14517eb72235904e10cdb5b18 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 11:42:15 +0800 Subject: [PATCH 38/45] =?UTF-8?q?chore:=20=E6=8C=89=20Greptile=20=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E5=86=85=E8=81=94=20warmup=20=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E5=85=85=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sealOrphanedMessageRequests 改用 raw SQL 内联 blocked_by 条件,避免列引用依赖表名\n- writeMessageRequestUpdateToDb 补充注释说明 sanitize 二次执行的防御性 --- src/repository/message.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/repository/message.ts b/src/repository/message.ts index bb6185131..913f1c56c 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -84,6 +84,8 @@ async function writeMessageRequestUpdateToDb( id: number, patch: MessageRequestUpdatePatch ): Promise { + // 防御:即使 patch 来源于 buffer 的已清洗数据(例如 dropped_overflow),这里也统一再 sanitize 一次(幂等)。 + // 这样可避免未来调用点误传未清洗 patch 时把脏数据直接写入数据库。 const sanitized = sanitizeMessageRequestUpdatePatch(patch); if (Object.keys(sanitized).length === 0) { const definedKeys = Object.entries(patch) @@ -257,7 +259,7 @@ export async function sealOrphanedMessageRequests(options?: { const limit = Math.max(1, limitCandidate); const threshold = new Date(Date.now() - staleAfterMs); - // 注意:EXCLUDE_WARMUP_CONDITION 使用 Drizzle 列引用(message_request.blocked_by),这里不要给 message_request 起别名。 + // 注意:这里使用 raw SQL,以避免 Drizzle 在大表上的构造开销;同时直接内联 warmup 过滤条件,避免表别名导致列引用失效。 const query = sql<{ id: number }>` WITH candidates AS ( SELECT id @@ -265,7 +267,7 @@ export async function sealOrphanedMessageRequests(options?: { WHERE deleted_at IS NULL AND duration_ms IS NULL AND created_at < ${threshold} - AND ${EXCLUDE_WARMUP_CONDITION} + AND (blocked_by IS NULL OR blocked_by <> 'warmup') ORDER BY created_at ASC LIMIT ${limit} ) @@ -290,7 +292,7 @@ export async function sealOrphanedMessageRequests(options?: { AND deleted_at IS NULL AND duration_ms IS NULL AND created_at < ${threshold} - AND ${EXCLUDE_WARMUP_CONDITION} + AND (blocked_by IS NULL OR blocked_by <> 'warmup') RETURNING id `; From 46b32de200f8a16ac74391c0d14010508398cc61 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 12:06:08 +0800 Subject: [PATCH 39/45] =?UTF-8?q?fix:=20token=20=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20safe-int=20sanitize=20=E4=BB=A5=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=20bigint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 sanitizeSafeInt,token 相关字段上限提升至 Number.MAX_SAFE_INTEGER\n- 补充单测:>INT32_MAX 不应被截断,超出 safe-int 时应 clamp --- src/repository/message-write-buffer.ts | 80 ++++++++++++++++--- .../repository/message-write-buffer.test.ts | 14 ++++ 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index c37ee6894..dfe29ce9c 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -71,6 +71,7 @@ const COLUMN_MAP: Record = { }; const INT32_MAX = 2147483647; +const BIGINT_JS_MAX = Number.MAX_SAFE_INTEGER; // 2^53 - 1(JS 可精确表示的最大整数) const NUMERIC_LIKE_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; const REJECTED_INVALID_LOG_THROTTLE_MS = 60_000; @@ -78,6 +79,8 @@ let _lastRejectedInvalidLogAt = 0; const INT32_CLAMP_LOG_THROTTLE_MS = 60_000; const _lastInt32ClampLogAt = new Map(); +const SAFE_INT_CLAMP_LOG_THROTTLE_MS = INT32_CLAMP_LOG_THROTTLE_MS; +const _lastSafeIntClampLogAt = new Map(); const STATUS_CODE_REJECT_LOG_THROTTLE_MS = 60_000; let _lastStatusCodeRejectLogAt = 0; @@ -214,6 +217,59 @@ function sanitizeNullableInt32( return sanitizeInt32(value, options); } +function sanitizeSafeInt( + value: unknown, + options?: { min?: number; max?: number; field?: string } +): number | undefined { + const numeric = toFiniteNumber(value); + if (numeric === null) { + return undefined; + } + + const truncated = Math.trunc(numeric); + const min = options?.min ?? -BIGINT_JS_MAX; + const max = options?.max ?? BIGINT_JS_MAX; + + if (truncated < min) { + const field = options?.field; + if (field) { + const now = Date.now(); + const lastLogAt = _lastSafeIntClampLogAt.get(field) ?? 0; + if (now - lastLogAt > SAFE_INT_CLAMP_LOG_THROTTLE_MS) { + _lastSafeIntClampLogAt.set(field, now); + logger.warn("[MessageRequestWriteBuffer] Safe integer value out of range, clamping", { + field, + value: typeof value === "string" ? value.slice(0, 64) : value, + clampedTo: min, + min, + max, + }); + } + } + return min; + } + if (truncated > max) { + const field = options?.field; + if (field) { + const now = Date.now(); + const lastLogAt = _lastSafeIntClampLogAt.get(field) ?? 0; + if (now - lastLogAt > SAFE_INT_CLAMP_LOG_THROTTLE_MS) { + _lastSafeIntClampLogAt.set(field, now); + logger.warn("[MessageRequestWriteBuffer] Safe integer value out of range, clamping", { + field, + value: typeof value === "string" ? value.slice(0, 64) : value, + clampedTo: max, + min, + max, + }); + } + } + return max; + } + + return truncated; +} + function sanitizeStatusCode(value: unknown): number | undefined { const numeric = toFiniteNumber(value); if (numeric === null) { @@ -292,19 +348,19 @@ function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePa sanitized.statusCode = statusCode; } - const inputTokens = sanitizeInt32(patch.inputTokens, { + const inputTokens = sanitizeSafeInt(patch.inputTokens, { field: "inputTokens", min: 0, - max: INT32_MAX, + max: BIGINT_JS_MAX, }); if (inputTokens !== undefined) { sanitized.inputTokens = inputTokens; } - const outputTokens = sanitizeInt32(patch.outputTokens, { + const outputTokens = sanitizeSafeInt(patch.outputTokens, { field: "outputTokens", min: 0, - max: INT32_MAX, + max: BIGINT_JS_MAX, }); if (outputTokens !== undefined) { sanitized.outputTokens = outputTokens; @@ -315,37 +371,37 @@ function sanitizePatch(patch: MessageRequestUpdatePatch): MessageRequestUpdatePa sanitized.ttfbMs = ttfbMs; } - const cacheCreationInputTokens = sanitizeInt32(patch.cacheCreationInputTokens, { + const cacheCreationInputTokens = sanitizeSafeInt(patch.cacheCreationInputTokens, { field: "cacheCreationInputTokens", min: 0, - max: INT32_MAX, + max: BIGINT_JS_MAX, }); if (cacheCreationInputTokens !== undefined) { sanitized.cacheCreationInputTokens = cacheCreationInputTokens; } - const cacheReadInputTokens = sanitizeInt32(patch.cacheReadInputTokens, { + const cacheReadInputTokens = sanitizeSafeInt(patch.cacheReadInputTokens, { field: "cacheReadInputTokens", min: 0, - max: INT32_MAX, + max: BIGINT_JS_MAX, }); if (cacheReadInputTokens !== undefined) { sanitized.cacheReadInputTokens = cacheReadInputTokens; } - const cacheCreation5mInputTokens = sanitizeInt32(patch.cacheCreation5mInputTokens, { + const cacheCreation5mInputTokens = sanitizeSafeInt(patch.cacheCreation5mInputTokens, { field: "cacheCreation5mInputTokens", min: 0, - max: INT32_MAX, + max: BIGINT_JS_MAX, }); if (cacheCreation5mInputTokens !== undefined) { sanitized.cacheCreation5mInputTokens = cacheCreation5mInputTokens; } - const cacheCreation1hInputTokens = sanitizeInt32(patch.cacheCreation1hInputTokens, { + const cacheCreation1hInputTokens = sanitizeSafeInt(patch.cacheCreation1hInputTokens, { field: "cacheCreation1hInputTokens", min: 0, - max: INT32_MAX, + max: BIGINT_JS_MAX, }); if (cacheCreation1hInputTokens !== undefined) { sanitized.cacheCreation1hInputTokens = cacheCreation1hInputTokens; diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index bbd99fbe9..22de17723 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -338,6 +338,20 @@ describe("message_request 异步批量写入", () => { expect(executeMock).not.toHaveBeenCalled(); }); + it("token 字段应按 bigint(JS safe int)范围 sanitize(不再强制 Int32)", async () => { + const { sanitizeMessageRequestUpdatePatch } = await import("@/repository/message-write-buffer"); + + const sanitized = sanitizeMessageRequestUpdatePatch({ + inputTokens: 2147483648, // INT32_MAX + 1 + outputTokens: Number.MAX_SAFE_INTEGER + 1, // 超出 JS safe int:应 clamp + cacheCreationInputTokens: -1, // 负数:应 clamp 到 0 + }); + + expect(sanitized.inputTokens).toBe(2147483648); + expect(sanitized.outputTokens).toBe(Number.MAX_SAFE_INTEGER); + expect(sanitized.cacheCreationInputTokens).toBe(0); + }); + it("队列溢出时应优先丢弃非终态更新(尽量保留 durationMs)", async () => { process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; process.env.MESSAGE_REQUEST_ASYNC_MAX_PENDING = "100"; From f30a51ea0efd04fce9687b56e1d595ef039b3584 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 12:30:43 +0800 Subject: [PATCH 40/45] =?UTF-8?q?fix:=20=E4=B8=BA=E6=B4=BB=E8=B7=83/?= =?UTF-8?q?=E5=AD=A4=E5=84=BF=E8=AF=B7=E6=B1=82=E5=8A=A0=E7=B4=A2=E5=BC=95?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E9=BD=90=E6=89=B9=E9=87=8F=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=20cast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 idx_message_request_active_created_at_id(duration_ms IS NULL)支持 proxy-status 与 orphan sweeper\n- buildBatchUpdateSql:NULL::jsonb + int/bigint/boolean 显式 cast,降低隐式推断风险\n- 生成并修正 drizzle 迁移为 IF NOT EXISTS(幂等) --- drizzle/0079_special_zarda.sql | 1 + drizzle/meta/0079_snapshot.json | 3958 ++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/drizzle/schema.ts | 5 + src/repository/message-write-buffer.ts | 36 +- 5 files changed, 4006 insertions(+), 1 deletion(-) create mode 100644 drizzle/0079_special_zarda.sql create mode 100644 drizzle/meta/0079_snapshot.json diff --git a/drizzle/0079_special_zarda.sql b/drizzle/0079_special_zarda.sql new file mode 100644 index 000000000..284277e84 --- /dev/null +++ b/drizzle/0079_special_zarda.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS "idx_message_request_active_created_at_id" ON "message_request" USING btree ("created_at","id") WHERE "message_request"."deleted_at" IS NULL AND "message_request"."duration_ms" IS NULL; diff --git a/drizzle/meta/0079_snapshot.json b/drizzle/meta/0079_snapshot.json new file mode 100644 index 000000000..5d05ebfb3 --- /dev/null +++ b/drizzle/meta/0079_snapshot.json @@ -0,0 +1,3958 @@ +{ + "id": "474b3b30-ed05-4fcf-8fd6-50f781438fd9", + "prevId": "e0e0d836-cd6e-42ec-8531-7a65fa046cb4", + "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 + }, + "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_user_created_at_id_completed": { + "name": "idx_message_request_user_created_at_id_completed", + "columns": [ + { + "expression": "user_id", + "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 AND \"message_request\".\"duration_ms\" IS NOT 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_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_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_active_created_at_id": { + "name": "idx_message_request_active_created_at_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"duration_ms\" 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(50)", + "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 + }, + "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 + }, + "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 + }, + "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": {} + } + }, + "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'" + }, + "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 + }, + "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_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": {} + } + }, + "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 + }, + "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 bb8325759..c286b3a60 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -554,6 +554,13 @@ "when": 1772505850994, "tag": "0078_huge_weapon_omega", "breakpoints": true + }, + { + "idx": 79, + "version": "7", + "when": 1772511987232, + "tag": "0079_special_zarda", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index b0af79667..efa0e3f10 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -547,6 +547,11 @@ export const messageRequest = pgTable('message_request', { table.createdAt.desc(), table.id.desc() ).where(sql`${table.deletedAt} IS NULL`), + // #854:进行中/孤儿请求 created_at 扫描加速(proxy-status activeRequests + orphan sweeper) + messageRequestActiveCreatedAtIdIdx: index('idx_message_request_active_created_at_id').on( + table.createdAt, + table.id + ).where(sql`${table.deletedAt} IS NULL AND ${table.durationMs} IS NULL`), // #779:筛选器 DISTINCT model / status_code 加速(admin usage logs) messageRequestModelActiveIdx: index('idx_message_request_model_active').on(table.model).where(sql`${table.deletedAt} IS NULL AND ${table.model} IS NOT NULL`), messageRequestStatusCodeActiveIdx: index('idx_message_request_status_code_active').on(table.statusCode).where(sql`${table.deletedAt} IS NULL AND ${table.statusCode} IS NOT NULL`), diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index dfe29ce9c..085680c81 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -70,6 +70,25 @@ const COLUMN_MAP: Record = { specialSettings: "special_settings", }; +const INT_CASE_KEYS = new Set([ + "durationMs", + "statusCode", + "ttfbMs", + "providerId", +]); +const BIGINT_CASE_KEYS = new Set([ + "inputTokens", + "outputTokens", + "cacheCreationInputTokens", + "cacheReadInputTokens", + "cacheCreation5mInputTokens", + "cacheCreation1hInputTokens", +]); +const BOOLEAN_CASE_KEYS = new Set([ + "context1mApplied", + "swapCacheTtlApplied", +]); + const INT32_MAX = 2147483647; const BIGINT_JS_MAX = Number.MAX_SAFE_INTEGER; // 2^53 - 1(JS 可精确表示的最大整数) const NUMERIC_LIKE_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; @@ -625,7 +644,7 @@ function buildBatchUpdateSql(updates: MessageRequestUpdateRecord[]): SQL | null if (key === "providerChain" || key === "specialSettings") { if (value === null) { - cases.push(sql`WHEN ${update.id} THEN NULL`); + cases.push(sql`WHEN ${update.id} THEN NULL::jsonb`); continue; } try { @@ -650,6 +669,21 @@ function buildBatchUpdateSql(updates: MessageRequestUpdateRecord[]): SQL | null continue; } + if (INT_CASE_KEYS.has(key)) { + cases.push(sql`WHEN ${update.id} THEN ${value}::int`); + continue; + } + + if (BIGINT_CASE_KEYS.has(key)) { + cases.push(sql`WHEN ${update.id} THEN ${value}::bigint`); + continue; + } + + if (BOOLEAN_CASE_KEYS.has(key)) { + cases.push(sql`WHEN ${update.id} THEN ${value}::boolean`); + continue; + } + cases.push(sql`WHEN ${update.id} THEN ${value}`); } From 4ab8bdc4b6ebb806b032ef089b6c80200ed68543 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 13:09:57 +0800 Subject: [PATCH 41/45] =?UTF-8?q?fix:=20=E6=8E=92=E9=99=A4=20warmup=20?= =?UTF-8?q?=E6=B4=BB=E8=B7=83=E8=AF=B7=E6=B1=82=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=86=99=E7=BC=93=E5=86=B2=E9=99=8D=E7=BA=A7=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/proxy-status-tracker.ts | 9 ++++++- src/repository/message-write-buffer.ts | 26 ++++++++++++++----- src/repository/message.ts | 13 +++------- .../message-orphaned-requests.test.ts | 2 +- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index af19bb292..ab225ab63 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -176,7 +176,14 @@ export class ProxyStatusTracker { and(eq(messageRequest.providerId, providers.id), isNull(providers.deletedAt)) ) .leftJoin(keys, and(eq(keys.key, messageRequest.key), isNull(keys.deletedAt))) - .where(and(isNull(messageRequest.deletedAt), isNull(messageRequest.durationMs))) + .where( + and( + isNull(messageRequest.deletedAt), + isNull(messageRequest.durationMs), + // warmup 请求仅用于探测/预热:不应污染活跃请求列表与统计 + sql`(${messageRequest.blockedBy} IS NULL OR ${messageRequest.blockedBy} <> 'warmup')` + ) + ) // 防御:异常情况下 durationMs 长期为空会导致“活跃请求”无限累积,进而撑爆查询与响应体。 // 这里对返回明细做上限保护(监控用途不需要无穷列表)。 .orderBy(desc(messageRequest.createdAt)) diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index 085680c81..b9638b2c9 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -795,6 +795,9 @@ class MessageRequestWriteBuffer { // 达到批量阈值时尽快 flush,降低 durationMs 为空的“悬挂时间” if (this.pending.size >= this.config.batchSize) { + // 若刚刚调度了 timer(尤其是终态 10ms timer),这里直接 flush 会让 timer 变成多余噪声; + // 提前清理可避免短时间内重复触发 flush 以及额外的事件循环负担。 + this.clearFlushTimer(); void this.flush(); } @@ -1016,6 +1019,7 @@ class MessageRequestWriteBuffer { { name: "safe" as const, patch: getSafePatch(item.patch) }, { name: "minimal" as const, patch: getMinimalPatch(item.patch) }, ]; + const noSqlColumnsStrategies: Array<(typeof patchStrategies)[number]["name"]> = []; let lastFailure: { kind: "build" | "execute"; @@ -1034,11 +1038,8 @@ class MessageRequestWriteBuffer { if (!singleQuery) { // 本策略未产生任何可更新列(例如字段被过滤/无法序列化):尝试下一个降级策略。 - lastFailure = { - kind: "build", - strategy: name, - error: new Error("No SQL columns produced for patch strategy"), - }; + // 注意:这不是“错误”,不要覆盖前面真实的 DB/data 错误上下文。 + noSqlColumnsStrategies.push(name); continue; } @@ -1059,14 +1060,27 @@ class MessageRequestWriteBuffer { } } + if (!lastFailure && noSqlColumnsStrategies.length === patchStrategies.length) { + // 所有策略都无法产生 SQL:通常是 patch 字段被过滤/无法序列化,且不含终态字段(duration/status)。 + // 这类更新对“请求中”问题无关键影响,直接跳过即可;仅用 debug 降噪。 + logger.debug("[MessageRequestWriteBuffer] Skipping update with no writable columns", { + requestId: item.id, + keys: Object.keys(item.patch), + types: summarizePatchTypes(item.patch), + noSqlColumnsStrategies, + }); + continue; + } + if (lastFailure) { const isTerminal = isTerminalPatch(item.patch); const log = isTerminal ? logger.error : logger.warn; - log("[MessageRequestWriteBuffer] Dropping invalid update to unblock queue", { + log("[MessageRequestWriteBuffer] Dropping update to unblock queue", { requestId: item.id, isTerminal, keys: Object.keys(item.patch), types: summarizePatchTypes(item.patch), + noSqlColumnsStrategies, sample: (() => { try { return JSON.stringify(item.patch).slice(0, 200); diff --git a/src/repository/message.ts b/src/repository/message.ts index 913f1c56c..c32afdd56 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -273,15 +273,10 @@ export async function sealOrphanedMessageRequests(options?: { ) UPDATE message_request SET - duration_ms = COALESCE( - duration_ms, - ( - LEAST( - 2147483647, - GREATEST(0, (EXTRACT(EPOCH FROM (NOW() - created_at)) * 1000)) - )::int - ) - ), + duration_ms = LEAST( + 2147483647, + GREATEST(0, (EXTRACT(EPOCH FROM (NOW() - created_at)) * 1000)) + )::int, status_code = COALESCE(status_code, ${ORPHANED_MESSAGE_REQUEST_STATUS_CODE}), error_message = CASE WHEN status_code IS NULL THEN COALESCE(error_message, ${ORPHANED_MESSAGE_REQUEST_ERROR_CODE}) diff --git a/tests/unit/repository/message-orphaned-requests.test.ts b/tests/unit/repository/message-orphaned-requests.test.ts index 46aaab751..4fd6c6097 100644 --- a/tests/unit/repository/message-orphaned-requests.test.ts +++ b/tests/unit/repository/message-orphaned-requests.test.ts @@ -55,7 +55,7 @@ describe("sealOrphanedMessageRequests", () => { expect((built.sql.match(/created_at Date: Tue, 3 Mar 2026 13:39:36 +0800 Subject: [PATCH 42/45] =?UTF-8?q?fix:=20orphan=20sealer=20=E6=8C=89=20stat?= =?UTF-8?q?us=5Fcode=20=E5=B0=81=E9=97=AD=E5=B9=B6=E7=BC=93=E5=AD=98=20pro?= =?UTF-8?q?xy-status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- drizzle/0078_huge_weapon_omega.sql | 3 + drizzle/0079_special_zarda.sql | 3 + src/lib/proxy-status-tracker.ts | 68 ++++++++++++++++--- src/repository/message.ts | 24 ++++--- .../message-orphaned-requests.test.ts | 6 +- 5 files changed, 79 insertions(+), 25 deletions(-) diff --git a/drizzle/0078_huge_weapon_omega.sql b/drizzle/0078_huge_weapon_omega.sql index 998e2addc..70134a0d9 100644 --- a/drizzle/0078_huge_weapon_omega.sql +++ b/drizzle/0078_huge_weapon_omega.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 indexes with CONCURRENTLY before running this migration (IF NOT EXISTS prevents conflicts). CREATE INDEX IF NOT EXISTS "idx_message_request_user_created_at_id_completed" ON "message_request" USING btree ("user_id","created_at" DESC NULLS LAST,"id" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL AND "message_request"."duration_ms" IS NOT NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup'); diff --git a/drizzle/0079_special_zarda.sql b/drizzle/0079_special_zarda.sql index 284277e84..f62d39886 100644 --- a/drizzle/0079_special_zarda.sql +++ b/drizzle/0079_special_zarda.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 indexes with CONCURRENTLY before running this migration (IF NOT EXISTS prevents conflicts). CREATE INDEX IF NOT EXISTS "idx_message_request_active_created_at_id" ON "message_request" USING btree ("created_at","id") WHERE "message_request"."deleted_at" IS NULL AND "message_request"."duration_ms" IS NULL; diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index ab225ab63..c01543a78 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -27,6 +27,20 @@ type LastRequestRow = { endTime: Date | string | null; }; +type DbUserRow = { + id: number; + name: string; +}; + +type StatusSnapshot = { + expiresAt: number; + dbUsers: DbUserRow[]; + activeRequestRows: ActiveRequestRow[]; + lastRequestRows: LastRequestRow[]; +}; + +const PROXY_STATUS_SNAPSHOT_TTL_MS = 5000; + function toTimestamp(value: Date | string | number | null | undefined): number | null { if (value == null) { return null; @@ -53,6 +67,9 @@ function toTimestamp(value: Date | string | number | null | undefined): number | export class ProxyStatusTracker { private static instance: ProxyStatusTracker | null = null; + private statusSnapshotCache: StatusSnapshot | null = null; + private statusSnapshotInFlight: Promise | null = null; + static getInstance(): ProxyStatusTracker { if (!ProxyStatusTracker.instance) { ProxyStatusTracker.instance = new ProxyStatusTracker(); @@ -85,20 +102,48 @@ export class ProxyStatusTracker { void requestId; } + private async getStatusSnapshot(now: number): Promise { + const cached = this.statusSnapshotCache; + if (cached && cached.expiresAt > now) { + return cached; + } + + if (this.statusSnapshotInFlight) { + return await this.statusSnapshotInFlight; + } + + this.statusSnapshotInFlight = (async () => { + const [dbUsers, activeRequestRows, lastRequestRows] = await Promise.all([ + db + .select({ + id: users.id, + name: users.name, + }) + .from(users) + .where(isNull(users.deletedAt)), + this.loadActiveRequests(), + this.loadLastRequests(), + ]); + + return { + expiresAt: Date.now() + PROXY_STATUS_SNAPSHOT_TTL_MS, + dbUsers: dbUsers as unknown as DbUserRow[], + activeRequestRows, + lastRequestRows, + }; + })().finally(() => { + this.statusSnapshotInFlight = null; + }); + + const snapshot = await this.statusSnapshotInFlight; + this.statusSnapshotCache = snapshot; + return snapshot; + } + async getAllUsersStatus(): Promise { const now = Date.now(); - const [dbUsers, activeRequestRows, lastRequestRows] = await Promise.all([ - db - .select({ - id: users.id, - name: users.name, - }) - .from(users) - .where(isNull(users.deletedAt)), - this.loadActiveRequests(), - this.loadLastRequests(), - ]); + const { dbUsers, activeRequestRows, lastRequestRows } = await this.getStatusSnapshot(now); const activeMap = new Map(); for (const row of activeRequestRows) { @@ -180,6 +225,7 @@ export class ProxyStatusTracker { and( isNull(messageRequest.deletedAt), isNull(messageRequest.durationMs), + isNull(messageRequest.statusCode), // warmup 请求仅用于探测/预热:不应污染活跃请求列表与统计 sql`(${messageRequest.blockedBy} IS NULL OR ${messageRequest.blockedBy} <> 'warmup')` ) diff --git a/src/repository/message.ts b/src/repository/message.ts index c32afdd56..6a8510a11 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -265,7 +265,7 @@ export async function sealOrphanedMessageRequests(options?: { SELECT id FROM message_request WHERE deleted_at IS NULL - AND duration_ms IS NULL + AND status_code IS NULL AND created_at < ${threshold} AND (blocked_by IS NULL OR blocked_by <> 'warmup') ORDER BY created_at ASC @@ -273,19 +273,21 @@ export async function sealOrphanedMessageRequests(options?: { ) UPDATE message_request SET - duration_ms = LEAST( - 2147483647, - GREATEST(0, (EXTRACT(EPOCH FROM (NOW() - created_at)) * 1000)) - )::int, - status_code = COALESCE(status_code, ${ORPHANED_MESSAGE_REQUEST_STATUS_CODE}), - error_message = CASE - WHEN status_code IS NULL THEN COALESCE(error_message, ${ORPHANED_MESSAGE_REQUEST_ERROR_CODE}) - ELSE error_message - END, + duration_ms = COALESCE( + duration_ms, + ( + LEAST( + 2147483647, + GREATEST(0, (EXTRACT(EPOCH FROM (NOW() - created_at)) * 1000)) + )::int + ) + ), + status_code = ${ORPHANED_MESSAGE_REQUEST_STATUS_CODE}, + error_message = COALESCE(error_message, ${ORPHANED_MESSAGE_REQUEST_ERROR_CODE}), updated_at = NOW() WHERE id IN (SELECT id FROM candidates) AND deleted_at IS NULL - AND duration_ms IS NULL + AND status_code IS NULL AND created_at < ${threshold} AND (blocked_by IS NULL OR blocked_by <> 'warmup') RETURNING id diff --git a/tests/unit/repository/message-orphaned-requests.test.ts b/tests/unit/repository/message-orphaned-requests.test.ts index 4fd6c6097..c985dc2db 100644 --- a/tests/unit/repository/message-orphaned-requests.test.ts +++ b/tests/unit/repository/message-orphaned-requests.test.ts @@ -51,11 +51,11 @@ describe("sealOrphanedMessageRequests", () => { const built = toSqlText(query); expect(built.sql).toContain("UPDATE message_request"); - expect(built.sql).toContain("duration_ms IS NULL"); - expect((built.sql.match(/created_at Date: Tue, 3 Mar 2026 14:04:38 +0800 Subject: [PATCH 43/45] =?UTF-8?q?fix:=20orphan=20sealer=20=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20active=20=E7=B4=A2=E5=BC=95=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E8=A1=A5=20proxy-status=20=E7=BC=93=E5=AD=98=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/proxy-status-tracker.ts | 11 +++++++---- src/repository/message.ts | 19 +++++++++---------- .../message-orphaned-requests.test.ts | 4 +++- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index c01543a78..a308712cb 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -125,19 +125,22 @@ export class ProxyStatusTracker { this.loadLastRequests(), ]); - return { + const snapshot: StatusSnapshot = { expiresAt: Date.now() + PROXY_STATUS_SNAPSHOT_TTL_MS, dbUsers: dbUsers as unknown as DbUserRow[], activeRequestRows, lastRequestRows, }; + + // cache:避免 dashboard 轮询频繁触发全量 users + LATERAL 扫描 + this.statusSnapshotCache = snapshot; + + return snapshot; })().finally(() => { this.statusSnapshotInFlight = null; }); - const snapshot = await this.statusSnapshotInFlight; - this.statusSnapshotCache = snapshot; - return snapshot; + return await this.statusSnapshotInFlight; } async getAllUsersStatus(): Promise { diff --git a/src/repository/message.ts b/src/repository/message.ts index 6a8510a11..7862da69f 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -265,6 +265,7 @@ export async function sealOrphanedMessageRequests(options?: { SELECT id FROM message_request WHERE deleted_at IS NULL + AND duration_ms IS NULL AND status_code IS NULL AND created_at < ${threshold} AND (blocked_by IS NULL OR blocked_by <> 'warmup') @@ -273,20 +274,18 @@ export async function sealOrphanedMessageRequests(options?: { ) UPDATE message_request SET - duration_ms = COALESCE( - duration_ms, - ( - LEAST( - 2147483647, - GREATEST(0, (EXTRACT(EPOCH FROM (NOW() - created_at)) * 1000)) - )::int - ) + duration_ms = ( + LEAST( + 2147483647, + GREATEST(0, (EXTRACT(EPOCH FROM (NOW() - created_at)) * 1000)) + )::int ), status_code = ${ORPHANED_MESSAGE_REQUEST_STATUS_CODE}, error_message = COALESCE(error_message, ${ORPHANED_MESSAGE_REQUEST_ERROR_CODE}), updated_at = NOW() WHERE id IN (SELECT id FROM candidates) AND deleted_at IS NULL + AND duration_ms IS NULL AND status_code IS NULL AND created_at < ${threshold} AND (blocked_by IS NULL OR blocked_by <> 'warmup') @@ -294,9 +293,9 @@ export async function sealOrphanedMessageRequests(options?: { `; const result = await db.execute(query); - const sealed = Array.from(result); + const sealedCount = Array.isArray(result) ? result.length : Array.from(result).length; - return { sealedCount: sealed.length }; + return { sealedCount }; } /** diff --git a/tests/unit/repository/message-orphaned-requests.test.ts b/tests/unit/repository/message-orphaned-requests.test.ts index c985dc2db..099af7382 100644 --- a/tests/unit/repository/message-orphaned-requests.test.ts +++ b/tests/unit/repository/message-orphaned-requests.test.ts @@ -51,11 +51,13 @@ describe("sealOrphanedMessageRequests", () => { const built = toSqlText(query); expect(built.sql).toContain("UPDATE message_request"); + expect(built.sql).toContain("duration_ms IS NULL"); expect(built.sql).toContain("status_code IS NULL"); expect((built.sql.match(/created_at Date: Tue, 3 Mar 2026 14:27:37 +0800 Subject: [PATCH 44/45] =?UTF-8?q?fix:=20=E5=86=99=E7=BC=93=E5=86=B2=20requ?= =?UTF-8?q?eue=20=E5=8A=A0=E7=A1=AC=E4=B8=8A=E9=99=90=E5=B9=B6=E9=81=BF?= =?UTF-8?q?=E5=85=8D=20sweeper=20=E8=B6=85=E6=97=B6=E5=B9=B6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/instrumentation.ts | 14 +++++++++++--- src/repository/message-write-buffer.ts | 17 +++++++++++++++++ src/repository/message.ts | 2 ++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/instrumentation.ts b/src/instrumentation.ts index a862c35e3..9cd0bc7b6 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -187,13 +187,19 @@ async function startOrphanedMessageRequestSweeper(): Promise { return; } inFlight = true; + let timedOut = false; const startedAt = Date.now(); + const sealPromise = sealOrphanedMessageRequests(); try { - const sealPromise = sealOrphanedMessageRequests(); const result = await withTimeout(sealPromise, timeoutMs); if (result.timedOut) { + timedOut = true; // 避免出现 unhandled rejection(promise 仍可能在超时后继续 reject) - void sealPromise.catch(() => {}); + void sealPromise + .catch(() => {}) + .finally(() => { + inFlight = false; + }); logger.warn("[Instrumentation] Orphaned message_request sweeper timed out", { reason, timeoutMs, @@ -229,7 +235,9 @@ async function startOrphanedMessageRequestSweeper(): Promise { error: error instanceof Error ? error.message : String(error), }); } finally { - inFlight = false; + if (!timedOut) { + inFlight = false; + } } }; diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index b9638b2c9..f084f4efb 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -876,6 +876,23 @@ class MessageRequestWriteBuffer { ); } } + + // 极端防御:若 DB 长时间不可用且流量持续涌入,requeue + enqueue 可能导致队列在短时间内暴涨。 + // 这里设置一个硬上限,避免进程被 OOM 杀死(OOM 会导致更多终态丢失/孤儿请求放大)。 + const hardCap = this.config.maxPending * 2; + if (hardCap > 0 && this.pending.size > hardCap) { + const hardTrim = this.trimPendingToMaxPending({ allowDropTerminal: true }); + if (hardTrim.droppedCount > 0) { + logger.error("[MessageRequestWriteBuffer] Pending queue exceeded hard cap after requeue", { + maxPending: this.config.maxPending, + hardCap, + droppedCount: hardTrim.droppedCount, + droppedTerminalCount: hardTrim.droppedTerminalCount, + droppedIdsSample: hardTrim.droppedIdsSample, + currentPending: this.pending.size, + }); + } + } } private pruneNonTerminalIds(): void { diff --git a/src/repository/message.ts b/src/repository/message.ts index 7862da69f..a365ea850 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -237,6 +237,8 @@ export async function updateMessageRequestDetails( * - 某些页面会高频轮询“活跃请求”,在孤儿记录持续累积时可能引发内存与性能风险。 * * 本函数会把超过阈值仍未落下终态的记录标记为已结束(未知失败),避免无限累积。 + * + * 约束:staleAfterMs 最小为 60s(小于会被 clamp),避免误封闭真正的长耗时请求。 */ export async function sealOrphanedMessageRequests(options?: { staleAfterMs?: number; From c37a68a128334c758c7485aff0f361a8b9d7c8c0 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Tue, 3 Mar 2026 15:05:53 +0800 Subject: [PATCH 45/45] =?UTF-8?q?fix:=20=E9=99=8D=E7=BA=A7=20proxy-status?= =?UTF-8?q?=20=E5=BF=AB=E7=85=A7=E5=B9=B6=E8=A1=A5=E5=BC=BA=E5=AD=A4?= =?UTF-8?q?=E5=84=BF=E5=B0=81=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProxyStatusTracker: 刷新失败时返回过期缓存并延长 TTL,避免 dashboard 轮询错误风暴 - sealOrphanedMessageRequests: 覆盖 status_code 已写入但 duration_ms 缺失的记录,并兼容 rowCount 返回 - message-write-buffer: costUsd 超范围节流告警 - 补充单测覆盖 --- src/lib/proxy-status-tracker.ts | 61 ++++++++++------ src/repository/message-write-buffer.ts | 12 ++++ src/repository/message.ts | 48 +++++++++---- tests/unit/lib/proxy-status-tracker.test.ts | 69 +++++++++++++++++++ .../message-orphaned-requests.test.ts | 17 ++++- .../repository/message-write-buffer.test.ts | 15 ++++ 6 files changed, 185 insertions(+), 37 deletions(-) create mode 100644 tests/unit/lib/proxy-status-tracker.test.ts diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index a308712cb..b4ed3ee79 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -113,29 +113,46 @@ export class ProxyStatusTracker { } this.statusSnapshotInFlight = (async () => { - const [dbUsers, activeRequestRows, lastRequestRows] = await Promise.all([ - db - .select({ - id: users.id, - name: users.name, - }) - .from(users) - .where(isNull(users.deletedAt)), - this.loadActiveRequests(), - this.loadLastRequests(), - ]); - - const snapshot: StatusSnapshot = { - expiresAt: Date.now() + PROXY_STATUS_SNAPSHOT_TTL_MS, - dbUsers: dbUsers as unknown as DbUserRow[], - activeRequestRows, - lastRequestRows, - }; - - // cache:避免 dashboard 轮询频繁触发全量 users + LATERAL 扫描 - this.statusSnapshotCache = snapshot; + try { + const [dbUsers, activeRequestRows, lastRequestRows] = await Promise.all([ + db + .select({ + id: users.id, + name: users.name, + }) + .from(users) + .where(isNull(users.deletedAt)), + this.loadActiveRequests(), + this.loadLastRequests(), + ]); + + const snapshot: StatusSnapshot = { + expiresAt: Date.now() + PROXY_STATUS_SNAPSHOT_TTL_MS, + dbUsers: dbUsers as unknown as DbUserRow[], + activeRequestRows, + lastRequestRows, + }; + + // cache:避免 dashboard 轮询频繁触发全量 users + LATERAL 扫描 + this.statusSnapshotCache = snapshot; + + return snapshot; + } catch (error) { + logger.warn("[ProxyStatusTracker] Failed to refresh status snapshot, serving stale cache", { + error: error instanceof Error ? error.message : String(error), + }); + + if (this.statusSnapshotCache) { + // 退化:延长过期时间,避免 DB 抖动时 dashboard 轮询形成错误风暴 + this.statusSnapshotCache = { + ...this.statusSnapshotCache, + expiresAt: Date.now() + PROXY_STATUS_SNAPSHOT_TTL_MS, + }; + return this.statusSnapshotCache; + } - return snapshot; + throw error; + } })().finally(() => { this.statusSnapshotInFlight = null; }); diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index f084f4efb..c6787f78e 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -104,6 +104,9 @@ const _lastSafeIntClampLogAt = new Map(); const STATUS_CODE_REJECT_LOG_THROTTLE_MS = 60_000; let _lastStatusCodeRejectLogAt = 0; +const COST_USD_RANGE_LOG_THROTTLE_MS = 60_000; +let _lastCostUsdRangeLogAt = 0; + const STRING_TRUNCATE_LOG_THROTTLE_MS = 60_000; const _lastStringTruncateLogAt = new Map(); @@ -344,6 +347,15 @@ function sanitizeCostUsdString(value: unknown): string | undefined { // 目前仅用于 costUsd(schema: numeric(21, 15),整数部分最多 6 位:< 1,000,000) if (parsed < 0 || parsed >= 1_000_000) { + const now = Date.now(); + if (now - _lastCostUsdRangeLogAt > COST_USD_RANGE_LOG_THROTTLE_MS) { + _lastCostUsdRangeLogAt = now; + logger.warn("[MessageRequestWriteBuffer] costUsd out of accepted range, skipping", { + value: trimmed.slice(0, 32), + min: 0, + max: 1_000_000, + }); + } return undefined; } diff --git a/src/repository/message.ts b/src/repository/message.ts index a365ea850..7a41bf770 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -230,7 +230,7 @@ export async function updateMessageRequestDetails( * * 在 MESSAGE_REQUEST_WRITE_MODE=async 时,请求终态信息(duration/status/tokens/cost 等)会先进入内存队列, * 再异步批量刷入数据库。若进程被 OOM Killer/SIGKILL 等非优雅方式终止,尾部更新会丢失, - * 导致 message_request 记录长期保持“请求中”(duration_ms 长期为空;status_code 也可能为空或缺失)。 + * 导致 message_request 记录长期保持“请求中”(duration_ms 长期为空;status_code 也可能为空,或已写入但 duration_ms 缺失)。 * * 影响: * - Dashboard/统计把这些记录当作“进行中”,导致异常展示与聚合膨胀; @@ -268,7 +268,6 @@ export async function sealOrphanedMessageRequests(options?: { FROM message_request WHERE deleted_at IS NULL AND duration_ms IS NULL - AND status_code IS NULL AND created_at < ${threshold} AND (blocked_by IS NULL OR blocked_by <> 'warmup') ORDER BY created_at ASC @@ -276,26 +275,51 @@ export async function sealOrphanedMessageRequests(options?: { ) UPDATE message_request SET - duration_ms = ( - LEAST( - 2147483647, - GREATEST(0, (EXTRACT(EPOCH FROM (NOW() - created_at)) * 1000)) - )::int - ), - status_code = ${ORPHANED_MESSAGE_REQUEST_STATUS_CODE}, - error_message = COALESCE(error_message, ${ORPHANED_MESSAGE_REQUEST_ERROR_CODE}), + duration_ms = CASE + WHEN status_code IS NULL THEN ( + LEAST( + 2147483647, + GREATEST(0, (EXTRACT(EPOCH FROM (NOW() - created_at)) * 1000)) + )::int + ) + ELSE ( + LEAST( + 2147483647, + GREATEST(0, COALESCE(ttfb_ms, 0)) + )::int + ) + END, + status_code = COALESCE(status_code, ${ORPHANED_MESSAGE_REQUEST_STATUS_CODE}), + error_message = CASE + WHEN status_code IS NULL THEN COALESCE(error_message, ${ORPHANED_MESSAGE_REQUEST_ERROR_CODE}) + ELSE error_message + END, updated_at = NOW() WHERE id IN (SELECT id FROM candidates) AND deleted_at IS NULL AND duration_ms IS NULL - AND status_code IS NULL AND created_at < ${threshold} AND (blocked_by IS NULL OR blocked_by <> 'warmup') RETURNING id `; const result = await db.execute(query); - const sealedCount = Array.isArray(result) ? result.length : Array.from(result).length; + const resultAny = result as unknown as { + rows?: unknown[]; + rowCount?: number; + [Symbol.iterator]?: unknown; + }; + + const sealedCount = Array.isArray(result) + ? result.length + : typeof resultAny.rowCount === "number" + ? resultAny.rowCount + : Array.isArray(resultAny.rows) + ? resultAny.rows.length + : typeof (resultAny as unknown as { [Symbol.iterator]?: unknown })[Symbol.iterator] === + "function" + ? Array.from(resultAny as unknown as Iterable).length + : 0; return { sealedCount }; } diff --git a/tests/unit/lib/proxy-status-tracker.test.ts b/tests/unit/lib/proxy-status-tracker.test.ts new file mode 100644 index 000000000..7f20d9c06 --- /dev/null +++ b/tests/unit/lib/proxy-status-tracker.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("ProxyStatusTracker", () => { + const selectMock = vi.fn(); + const warnMock = vi.fn(); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + vi.resetModules(); + + selectMock.mockReset(); + warnMock.mockReset(); + + let callCount = 0; + selectMock.mockImplementation(() => ({ + from: () => ({ + where: async () => { + callCount++; + if (callCount === 1) { + return [{ id: 1, name: "u1" }]; + } + throw new Error("db down"); + }, + }), + })); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(), + }, + })); + + vi.doMock("@/lib/logger", () => ({ + logger: { + warn: warnMock, + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + }, + })); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("刷新失败时应返回过期缓存(避免 dashboard 轮询错误风暴)", async () => { + const { ProxyStatusTracker } = await import("@/lib/proxy-status-tracker"); + + const tracker = new ProxyStatusTracker(); + vi.spyOn(tracker as any, "loadActiveRequests").mockResolvedValue([]); + vi.spyOn(tracker as any, "loadLastRequests").mockResolvedValue([]); + + const first = await tracker.getAllUsersStatus(); + expect(first.users.map((u) => u.userName)).toEqual(["u1"]); + + // 5000ms TTL 过期后,第二次刷新失败应降级为返回缓存 + vi.advanceTimersByTime(6000); + + const second = await tracker.getAllUsersStatus(); + expect(second.users.map((u) => u.userName)).toEqual(["u1"]); + + expect(selectMock).toHaveBeenCalledTimes(2); + expect(warnMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/repository/message-orphaned-requests.test.ts b/tests/unit/repository/message-orphaned-requests.test.ts index 099af7382..36557db97 100644 --- a/tests/unit/repository/message-orphaned-requests.test.ts +++ b/tests/unit/repository/message-orphaned-requests.test.ts @@ -52,14 +52,15 @@ describe("sealOrphanedMessageRequests", () => { expect(built.sql).toContain("UPDATE message_request"); expect(built.sql).toContain("duration_ms IS NULL"); - expect(built.sql).toContain("status_code IS NULL"); + expect(built.sql).toContain("WHEN status_code IS NULL"); + expect(built.sql).not.toContain("AND status_code IS NULL"); expect((built.sql.match(/created_at { expect(threshold?.toISOString()).toBe("2025-12-31T23:59:00.000Z"); }); + it("应兼容 db.execute 返回 rowCount 的驱动实现", async () => { + executeMock.mockImplementationOnce(async () => ({ rowCount: 3 }) as any); + + const { sealOrphanedMessageRequests } = await import("@/repository/message"); + + const result = await sealOrphanedMessageRequests({ staleAfterMs: 10, limit: 5 }); + + expect(result.sealedCount).toBe(3); + }); + it("默认 staleAfterMs 应基于 FETCH_BODY_TIMEOUT + 60s 且不低于 60s", async () => { const { sealOrphanedMessageRequests } = await import("@/repository/message"); diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index 22de17723..4e394e741 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -338,6 +338,21 @@ describe("message_request 异步批量写入", () => { expect(executeMock).not.toHaveBeenCalled(); }); + it("costUsd 超出范围时应被丢弃并导致 patch 为空(rejected_invalid)", async () => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + + const { enqueueMessageRequestUpdate, stopMessageRequestWriteBuffer } = await import( + "@/repository/message-write-buffer" + ); + + const result = enqueueMessageRequestUpdate(1, { costUsd: "1000000" }); + + await stopMessageRequestWriteBuffer(); + + expect(result.kind).toBe("rejected_invalid"); + expect(executeMock).not.toHaveBeenCalled(); + }); + it("token 字段应按 bigint(JS safe int)范围 sanitize(不再强制 Int32)", async () => { const { sanitizeMessageRequestUpdatePatch } = await import("@/repository/message-write-buffer");