Commit 38e59329 by luoqi

feat(排查): 召回场景 SQL 的 dump 开关 —— pg_stat_activity 会截断,拿不到全文

PAC_RECALL_DUMP_SQL=1 时把每个子场景的完整 SQL + 绑定值打到日志。默认关,零开销。

为什么需要:2026-08-29 排查 plan 段耗时,采样抓到某条子场景查询单次跑 6.7 分钟,
但 pg_stat_activity.query 被 track_activity_query_size(默认 1024 字节)截断 ——
拿不到全文就没法 EXPLAIN,排查卡死在这一步。调 track_activity_query_size 要重启 PG,
生产上不划算;做成开关更可控。

实现上主查询从「$queryRaw 标签模板」改为「先建 Prisma.sql 对象再 $queryRaw(obj)」,
两者等价,但对象有 .sql / .values 可检视。
tests/recall-future-return-visit-gate.spec.ts 的 SQL 捕获桩同步兼容两种调用形态。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent d46a6cc5
...@@ -374,7 +374,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -374,7 +374,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ║ 输出:每个命中 (patient × sig) 一行,后段 byPatient Map 去重 ║ // ║ 输出:每个命中 (patient × sig) 一行,后段 byPatient Map 去重 ║
// ║ 只留 daysSince 最大那条(最早诊断 = 最有召回价值) ║ // ║ 只留 daysSince 最大那条(最早诊断 = 最有召回价值) ║
// ╚═════════════════════════════════════════════════════════════════════╝ // ╚═════════════════════════════════════════════════════════════════════╝
const rows: HitRow[] = await this.prisma.$queryRaw` const scenarioSql = Prisma.sql`
SELECT SELECT
p.id AS patient_id, p.id AS patient_id,
p.external_id AS patient_external_id, p.external_id AS patient_external_id,
...@@ -466,6 +466,21 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -466,6 +466,21 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
) )
`; `;
// ⚡ 调试开关:PAC_RECALL_DUMP_SQL=1 时把本子场景的完整 SQL 打出来。
// 为什么需要:pg_stat_activity.query 被 track_activity_query_size(默认 1024 字节)截断,
// 而本查询远超这个长度 —— 线上抓到"某条子场景查询跑了 6.7 分钟"却拿不到全文做 EXPLAIN,
// 2026-08-29 排查 plan 段耗时就卡在这一步。开关默认关,零开销。
// 用法:PAC_RECALL_DUMP_SQL=1 pnpm recompute-plans:prod -- --host=jvs-dw
// 然后把打出来的 SQL 喂给 EXPLAIN (ANALYZE, BUFFERS)。
if (process.env.PAC_RECALL_DUMP_SQL === '1') {
this.logger.log(
`[dump-sql] subKey=${subKey} primaryCode=${cfg.primaryCode} len=${scenarioSql.sql.length}\n` +
`${scenarioSql.sql}\n` +
`[dump-sql-params] ${JSON.stringify(scenarioSql.values)}`,
);
}
const rows: HitRow[] = await this.prisma.$queryRaw(scenarioSql);
// ⭐ 同 patient 同 sub_scenario 的多 sig 按 tooth-overlap 合并(union-find) // ⭐ 同 patient 同 sub_scenario 的多 sig 按 tooth-overlap 合并(union-find)
// 跟 chain-composer 的 bucket 合并口径一致 → reason 与 chain 1:1 对齐 // 跟 chain-composer 的 bucket 合并口径一致 → reason 与 chain 1:1 对齐
// 场景:王燕桦 K01 3 个 sig(38 / 48;38 / 18;28;38)牙位重叠 → 1 个临床 episode // 场景:王燕桦 K01 3 个 sig(38 / 48;38 / 18;28;38)牙位重叠 → 1 个临床 episode
......
...@@ -47,8 +47,18 @@ function flatten(frag: Prisma.Sql | SqlLike | unknown): string { ...@@ -47,8 +47,18 @@ function flatten(frag: Prisma.Sql | SqlLike | unknown): string {
/// 返回 [] 让调用方走空结果短路,不需要再 mock 后续查询。 /// 返回 [] 让调用方走空结果短路,不需要再 mock 后续查询。
function makeSqlCapturingPrisma(): { prisma: PrismaService; sql: () => string } { function makeSqlCapturingPrisma(): { prisma: PrismaService; sql: () => string } {
const captured: string[] = []; const captured: string[] = [];
const queryRaw = (strings: TemplateStringsArray, ...values: unknown[]) => { const queryRaw = (strings: TemplateStringsArray | { sql?: string }, ...values: unknown[]) => {
const parts = Array.isArray(strings?.raw) ? strings.raw : [String(strings)]; // 两种调用形态都要认:
// ① 标签模板 `$queryRaw`...`` → strings 是 TemplateStringsArray
// ② 传 Prisma.sql 对象 `$queryRaw(sql)` → strings 是 Sql,已摊平好的 .sql 直接用
// (2026-08-29 起主查询走 ② —— 先建 Prisma.sql 对象才能在 PAC_RECALL_DUMP_SQL=1 时
// 打出完整 SQL 做 EXPLAIN;pg_stat_activity 会把它截断在 1024 字节。)
if (typeof (strings as { sql?: string })?.sql === 'string') {
captured.push((strings as { sql: string }).sql);
return Promise.resolve([]);
}
const t = strings as TemplateStringsArray;
const parts = Array.isArray(t?.raw) ? t.raw : [String(strings)];
captured.push(parts.map((s, i) => s + (i < values.length ? flatten(values[i]) : '')).join('')); captured.push(parts.map((s, i) => s + (i < values.length ? flatten(values[i]) : '')).join(''));
return Promise.resolve([]); return Promise.resolve([]);
}; };
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment