You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

221 lines
6.3 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { config } from "dotenv";
config();
import { Prisma } from "@prisma/client";
import { writeAlert } from "@/modules/alert/service";
import {
QUOTE_TIMEOUT_MS,
SWEEPER_INTERVAL_MS,
} from "@/lib/constants/quote";
import { prisma } from "@/lib/prisma";
import { getL3 } from "@/modules/cache/redis-cache";
import { getConfidenceScore } from "@/modules/quote/confidence";
import {
applyMarkupToQuotes,
getMarkupRule,
type RawQuoteTier,
} from "@/modules/pricing/engine";
import { prepareMotherShipStorageQuotes } from "@/modules/quote/quote-completeness";
import { recordPostDone, safeRecord } from "@/modules/metrics/collector";
import { formatQuoteErrorMessage } from "@/modules/quote/quote-error-messages";
import { recordQuoteQueryOutcome } from "@/modules/quote/query-log";
import type { L2CachePayload } from "@/modules/quote/types";
import { getQuoteRpaQueue } from "@/workers/rpa/queue";
const TIMEOUT_ERROR = `processing 超过 ${Math.round(QUOTE_TIMEOUT_MS / 1_000)} 秒未完成`;
async function writeTimeoutAlert(
quoteId: string,
cargoHash: string,
alertType: "STALE_FALLBACK" | "RPA_FAILED",
detail: Record<string, unknown>,
): Promise<void> {
await writeAlert(alertType, { quoteId, cargoHash, detail });
}
/** 单条 processing 超时记录处理:优先 L3 降级,否则 QUOTE_TIMEOUT */
export async function sweepTimedOutQuote(quoteId: string): Promise<void> {
const record = await prisma.quoteRecord.findFirst({
where: { quoteId, status: "processing", isDeleted: false },
});
if (!record) {
return;
}
const elapsed = Date.now() - record.createdAt.getTime();
if (elapsed < QUOTE_TIMEOUT_MS) {
return;
}
const l3 = await getL3<L2CachePayload>(record.cargoHash);
if (l3?.quotes?.length) {
let uiQuotes: RawQuoteTier[];
try {
uiQuotes = prepareMotherShipStorageQuotes(l3.quotes as RawQuoteTier[]);
} catch {
await markQuoteTimeout(record.quoteId, record.cargoHash);
return;
}
const markupRule = await getMarkupRule(record.customerId);
const markedQuotes = applyMarkupToQuotes(uiQuotes, markupRule);
const validUntil = new Date(Date.now() + 3 * 60 * 1_000);
await prisma.quoteRecord.update({
where: { quoteId: record.quoteId },
data: {
status: "done",
sourceType: "stale",
isRealtime: false,
confidenceScore: getConfidenceScore("stale"),
markupPercent:
markupRule.type === "percent" ? markupRule.percent : 0,
quotesJson: markedQuotes as unknown as Prisma.InputJsonValue,
validUntil,
errorCode: null,
},
});
await writeTimeoutAlert(record.quoteId, record.cargoHash, "STALE_FALLBACK", {
reason: TIMEOUT_ERROR,
customer_id: record.customerId,
});
await recordQuoteQueryOutcome(record.quoteId, "stale", {
failureReason: TIMEOUT_ERROR,
sourceType: "stale",
tierCount: markedQuotes.length,
});
safeRecord(() =>
recordPostDone({
quoteId: record.quoteId,
sourceType: "stale",
isRealtime: false,
}),
);
return;
}
await markQuoteTimeout(record.quoteId, record.cargoHash);
}
async function cancelQuoteRpaJob(quoteId: string): Promise<void> {
try {
const job = await getQuoteRpaQueue().getJob(quoteId);
if (!job) {
return;
}
const state = await job.getState();
if (state === "active") {
await job.moveToFailed(new Error("QUOTE_TIMEOUT sweeper"), "0", true);
} else if (state === "waiting" || state === "delayed") {
await job.remove();
}
} catch (error) {
console.warn(
`[timeout-sweeper] 清理队列 job 失败 quote_id=${quoteId}:`,
error instanceof Error ? error.message : error,
);
}
}
async function markQuoteTimeout(
quoteId: string,
cargoHash: string,
): Promise<void> {
await cancelQuoteRpaJob(quoteId);
await prisma.quoteRecord.update({
where: { quoteId },
data: {
status: "failed",
errorCode: "QUOTE_TIMEOUT",
errorMessage: formatQuoteErrorMessage("QUOTE_TIMEOUT"),
isRealtime: false,
},
});
await writeTimeoutAlert(quoteId, cargoHash, "RPA_FAILED", {
reason: TIMEOUT_ERROR,
});
await recordQuoteQueryOutcome(quoteId, "failed", {
failureReason: formatQuoteErrorMessage("QUOTE_TIMEOUT"),
errorCode: "QUOTE_TIMEOUT",
});
}
/** 扫描所有超时 processing 记录 */
export async function sweepTimedOutQuotes(): Promise<number> {
const cutoff = new Date(Date.now() - QUOTE_TIMEOUT_MS);
const staleRecords = await prisma.quoteRecord.findMany({
where: {
status: "processing",
isDeleted: false,
createdAt: { lt: cutoff },
},
select: { quoteId: true },
take: 50,
});
if (staleRecords.length === 0) {
console.log(
`[timeout-sweeper] 扫描完成,无超时记录(阈值 ${QUOTE_TIMEOUT_MS / 1000}s`,
);
return 0;
}
console.log(
`[timeout-sweeper] 发现 ${staleRecords.length} 条超时 processing开始处理...`,
);
for (const { quoteId } of staleRecords) {
await sweepTimedOutQuote(quoteId);
console.log(`[timeout-sweeper] 已处理 quote_id=${quoteId}`);
}
return staleRecords.length;
}
/** 启动定时扫描(每 10s启动时立即执行首轮 */
export function startTimeoutSweeper(): NodeJS.Timeout {
sweepTimedOutQuotes().catch((error) => {
console.error("[timeout-sweeper] 首轮扫描异常:", error);
});
const timer = setInterval(() => {
sweepTimedOutQuotes().catch((error) => {
console.error("[timeout-sweeper] 扫描异常:", error);
});
}, SWEEPER_INTERVAL_MS);
return timer;
}
async function runOnce(): Promise<void> {
console.log("[timeout-sweeper] 单次扫描模式");
const count = await sweepTimedOutQuotes();
console.log(`[timeout-sweeper] 单次扫描结束,处理 ${count}`);
await prisma.$disconnect();
}
if (require.main === module) {
const once = process.argv.includes("--once");
if (!process.env.DATABASE_URL) {
console.error("[timeout-sweeper] DATABASE_URL 未配置,请检查 .env");
process.exit(1);
}
console.log("[timeout-sweeper] 启动,间隔", SWEEPER_INTERVAL_MS, "ms");
if (once) {
runOnce()
.catch((error) => {
console.error("[timeout-sweeper] 异常:", error);
process.exit(1);
})
.finally(() => process.exit(0));
} else {
startTimeoutSweeper();
}
}