fix: 免审验收容错与成本中心归一回填超时修复

- worker:等级升级计数移出验收事务,瞬时错误重试一次,最终失败转人工待办且不再返回 500
- worker:待验收阶段修改验收资料时保留 reviewMode/evidenceStatus/evidenceDueAt
- cost-center:迁移 107 新增人工待归一覆盖部分索引(生产实测候选聚合 19.6s → 391ms)
- cost-center:归一人工商品/按工单回填商品/模板预览前端超时放宽到 300s
- cost-center:批处理接口语句超时放宽到 60s(SET LOCAL,在线接口仍为 15s)
This commit is contained in:
yml2213
2026-10-29 14:33:35 +08:00
parent 814b357d46
commit 979a1bfa41
13 changed files with 617 additions and 68 deletions
+56
View File
@@ -1,3 +1,5 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { Pool } from 'pg'
import type { PoolClient, QueryResult, QueryResultRow } from 'pg'
@@ -7,6 +9,28 @@ import { assertSqlParamArity } from './sql-arity.js'
let poolInstance: Pool | null = null
/**
* 后台批处理(回填/归一)单条语句的时间上限。
*
* 在线接口沿用 DATABASE_STATEMENT_TIMEOUT_MS(默认 15s);这些批处理要扫
* 十几万行,单批给 60s,避免库稍微抖一下就整批 500。真正的解法是分批 + 覆盖索引,
* 这里只是兜底。
*/
export const BATCH_STATEMENT_TIMEOUT_MS = 60_000
/** 当前异步调用链上的批处理语句超时(不设置则走连接池默认值)。 */
const statementTimeoutOverride = new AsyncLocalStorage<number>()
/**
* 在这个异步调用链内把单条语句的 statement_timeout 放宽到 timeoutMs。
*
* 仅在批处理路由里使用;只影响本次请求,不会污染连接池里的其它连接。
*/
export function runWithStatementTimeout<T>(timeoutMs: number, fn: () => Promise<T>): Promise<T> {
const normalized = Math.max(1, Math.trunc(Number(timeoutMs) || 0))
return statementTimeoutOverride.run(normalized, fn)
}
export function getDb(): Pool {
if (!poolInstance) {
const pool = new Pool({
@@ -40,6 +64,9 @@ export async function query<T extends QueryResultRow>(
text: string,
params: unknown[] = [],
): Promise<QueryResult<T>> {
const batchTimeoutMs = statementTimeoutOverride.getStore()
if (batchTimeoutMs) return queryWithStatementTimeout<T>(text, params, batchTimeoutMs)
const pool = getDb()
const startedAt = process.hrtime.bigint()
try {
@@ -52,6 +79,35 @@ export async function query<T extends QueryResultRow>(
}
}
/**
* 批处理单条语句:独立事务 + SET LOCAL 放宽 statement_timeout。
*
* SET LOCAL 只在本事务内生效,COMMIT/ROLLBACK 后自动恢复,连接还给池子时
* 仍然是默认的 15s;同时补上慢查询/失败日志(事务内 client.query 不会记录)。
*/
async function queryWithStatementTimeout<T extends QueryResultRow>(
text: string,
params: unknown[],
timeoutMs: number,
): Promise<QueryResult<T>> {
const client = await getDb().connect()
const startedAt = process.hrtime.bigint()
try {
await client.query('BEGIN')
await client.query(`SET LOCAL statement_timeout = ${Math.trunc(timeoutMs)}`)
const result = await client.query<T>(text, params)
await client.query('COMMIT')
logSlowQuery(text, startedAt, result.rowCount ?? 0)
return result
} catch (error) {
logSlowQuery(text, startedAt, 0, error)
await client.query('ROLLBACK').catch(() => undefined)
throw error
} finally {
client.release()
}
}
function logSlowQuery(text: string, startedAt: bigint, rowCount: number, error?: unknown) {
const thresholdMs = Number(runtimeConfig.database?.slowQueryThresholdMs ?? 500)
if (thresholdMs <= 0) return
@@ -0,0 +1,26 @@
-- 107_order_items_manual_pending_cover_index.sql
--
-- 背景(2026-10-29 生产实测):
-- 「商品与成本中心 → 归一人工商品」的候选 SKU 聚合,以及「从接单模板初始化」里
-- 的归一数量回填,都在 15s statement_timeout 上直接 500:
-- · 候选聚合(30 天窗口)19.6s;去掉日期联表 25.1s;换 merge join 27.5s
-- · 执行计划是 Bitmap Heap Scan on order_items,命中 102,779 行 / 59,313 个堆块
-- (约 460MB 随机读)——现有 idx_order_items_manual_sku_key_stored 只带
-- manual_sku_key,sku_name / canonical_product_id / order_id 仍需回表。
--
-- 这个覆盖部分索引只存「待归一的人工订单项」,让候选聚合与按 id 分批回填都能走
-- index-only scan,预计 20 秒级降到 1–2 秒。
--
-- 迁移在 singleTransaction 里执行(见 migrate.ts),所以这里用普通 CREATE INDEX;
-- 生产按 086 的惯例先执行一次 CREATE INDEX CONCURRENTLY 预建,
-- IF NOT EXISTS 会直接跳过,部署时不锁表。
CREATE INDEX IF NOT EXISTS idx_order_items_manual_pending_cover
ON order_items (id)
INCLUDE (order_id, manual_sku_key, sku_name)
WHERE fulfillment_mode = 'manual'
AND canonical_product_id IS NULL
AND manual_sku_key IS NOT NULL;
COMMENT ON INDEX idx_order_items_manual_pending_cover IS
'人工商品归一/回填:待归一人工订单项的覆盖索引(2026-10-29 性能修复)';