Commit b9caf5ce by luoqi

merge: 热修矩阵 500 —— 补回 JOIN patients + 三层防线加固

parents 3aa69952 a0a86b94
Pipeline #3576 failed in 0 seconds
...@@ -26,8 +26,19 @@ function inline(q: { sql: string; values: unknown[] }): string { ...@@ -26,8 +26,19 @@ function inline(q: { sql: string; values: unknown[] }): string {
}); });
} }
function matrixSql(hostId: string, tenantId: string, clinicId: string): string { /**
const scope = { hostId, tenantId, clinicIds: [], sourceUnits: [] } as never; * ⚠️ **sourceUnits 必须按真实值传** —— 它非空时 `poolBaseSql` 会拼 `AND p.source_unit IN (...)`,
* 走的是另一条 SQL 分支。2026-08-17 这里硬编 `[]` 导致本地永远测不到那一支,
* 把删掉 `JOIN patients p` 的改动一路放行到线上,矩阵直接 500
* (`missing FROM-clause entry for table "p"`)。⛔ 别再退回硬编。
*/
function matrixSql(
hostId: string,
tenantId: string,
clinicId: string,
sourceUnits: string[],
): string {
const scope = { hostId, tenantId, clinicIds: [], sourceUnits } as never;
const inner = inline(planLabelAnchorsSql(poolBaseSql(scope, clinicId))); const inner = inline(planLabelAnchorsSql(poolBaseSql(scope, clinicId)));
// 外层与 `CohortAttributesService.matrix` 同构(温度 CASE 简化成三档,量的是同一条 join 路径) // 外层与 `CohortAttributesService.matrix` 同构(温度 CASE 简化成三档,量的是同一条 join 路径)
return `EXPLAIN (ANALYZE, BUFFERS) return `EXPLAIN (ANALYZE, BUFFERS)
...@@ -73,13 +84,21 @@ async function snapshot(sqlWithExplain: string) { ...@@ -73,13 +84,21 @@ async function snapshot(sqlWithExplain: string) {
FROM followup_plans WHERE status='active' AND assignee_user_id IS NULL FROM followup_plans WHERE status='active' AND assignee_user_id IS NULL
GROUP BY 1,2,3 HAVING count(*) >= 10 ORDER BY 4 DESC LIMIT 5`; GROUP BY 1,2,3 HAVING count(*) >= 10 ORDER BY 4 DESC LIMIT 5`;
// 真实存在的品牌命名空间 —— 拿来跑「sourceUnits 非空」那一支
const units = await prisma.$queryRaw<Array<{ source_unit: string }>>`
SELECT DISTINCT source_unit FROM patients WHERE source_unit IS NOT NULL LIMIT 3`;
const unitList = units.map((u) => u.source_unit);
console.log(` (另跑一遍 sourceUnits=${JSON.stringify(unitList)} 那一支)\n`);
console.log(`初选矩阵基准 · 每档取 ${ROUNDS} 轮最快值\n`); console.log(`初选矩阵基准 · 每档取 ${ROUNDS} 轮最快值\n`);
console.log(' plan 数 最快耗时 磁盘读 JIT(首段)'); console.log(' plan 数 最快耗时 磁盘读 JIT(首段)');
console.log(' ' + '─'.repeat(46)); console.log(' ' + '─'.repeat(46));
const out: Array<Record<string, number>> = []; const out: Array<Record<string, number>> = [];
const snaps: string[] = []; const snaps: string[] = [];
for (const c of clinics) { for (const c of clinics) {
const sql = matrixSql(c.host_id, c.tenant_id, c.target_clinic_id); const sql = matrixSql(c.host_id, c.tenant_id, c.target_clinic_id, []);
// 🔴 同一诊所再跑一遍**带品牌过滤**的那一支 —— 只测一支等于没测
await run(matrixSql(c.host_id, c.tenant_id, c.target_clinic_id, unitList));
let best = { ms: Infinity, reads: 0, jit: 0 }; let best = { ms: Infinity, reads: 0, jit: 0 };
for (let r = 0; r < ROUNDS; r++) { for (let r = 0; r < ROUNDS; r++) {
const x = await run(sql); const x = await run(sql);
......
...@@ -155,6 +155,12 @@ export function planLabelAnchorsSql(planFilter: Prisma.Sql): Prisma.Sql { ...@@ -155,6 +155,12 @@ export function planLabelAnchorsSql(planFilter: Prisma.Sql): Prisma.Sql {
${BOUNDS.warm} AS warm_until, ${BOUNDS.warm} AS warm_until,
${BOUNDS.anchor} AS anchor_at ${BOUNDS.anchor} AS anchor_at
FROM followup_plans fp FROM followup_plans fp
-- 🔴 p 必须留着 —— poolBaseSql 在 scope.sourceUnits 非空时会拼
-- AND p.source_unit IN (...)(多品牌隔离)。2026-08-17 线上 500 就是删了它:
-- missing FROM-clause entry for table "p"。
-- ⚠️ 本地基准脚本当时硬编了 sourceUnits=[],永远走不到那个分支 —— 测了个假场景。
-- ⛔ 改这里之前先确认 poolBaseSql 还引用了哪些别名。
JOIN patients p ON p.id = fp.patient_id
JOIN plan_reasons pr ON pr.plan_id = fp.id JOIN plan_reasons pr ON pr.plan_id = fp.id
CROSS JOIN LATERAL unnest(pr.potential_labels) AS lab(lbl) CROSS JOIN LATERAL unnest(pr.potential_labels) AS lab(lbl)
${LAST_VISIT_JOIN} ${LAST_VISIT_JOIN}
......
import { refreshLabelsSql, countMissingLabelsSql, LABEL_REFRESH_BATCH } from '../src/modules/plan/plan-label.sql'; import { refreshLabelsSql, countMissingLabelsSql, LABEL_REFRESH_BATCH } from '../src/modules/plan/plan-label.sql';
import { planLabelAnchorsSql } from '../src/modules/plan/reason-temperature.sql'; import { planLabelAnchorsSql } from '../src/modules/plan/reason-temperature.sql';
import { poolBaseSql } from '../src/modules/plan/cohort-filter';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
/** /**
...@@ -22,8 +23,16 @@ describe('矩阵查询不再碰 patient_facts', () => { ...@@ -22,8 +23,16 @@ describe('矩阵查询不再碰 patient_facts', () => {
expect(sql).not.toMatch(/jsonb_array_elements_text/); expect(sql).not.toMatch(/jsonb_array_elements_text/);
}); });
it('⛔ 不再 JOIN patients —— 年龄已烘进标签,那张表原本要被全表扫', () => { /**
expect(sql).not.toMatch(/JOIN patients/); * ⚠️ 这里原来断言的是「⛔ 不再 JOIN patients」—— **那条断言本身是错的**,
* 它把 2026-08-17 那个线上 500 固化成了"期望行为":`poolBaseSql` 在
* sourceUnits 非空时要引用 `p.source_unit`,删了 join 就 `missing FROM-clause`。
* ⇒ 现在改成断言**真正变了的那件事**:查询里不再出现按年龄推标签的 CASE
* (年龄已烘进 potential_labels),而 patients 这张表因为品牌隔离仍要在场。
*/
it('不再在查询里按年龄推标签 —— 年龄已烘进 potential_labels', () => {
expect(sql).not.toMatch(/date_part\('year'/);
expect(sql).not.toMatch(/birth_date/);
}); });
it('改用预存的 potential_labels', () => { it('改用预存的 potential_labels', () => {
...@@ -36,6 +45,50 @@ describe('矩阵查询不再碰 patient_facts', () => { ...@@ -36,6 +45,50 @@ describe('矩阵查询不再碰 patient_facts', () => {
}); });
}); });
/**
* 🔴 2026-08-17 线上 500 的回归守卫。
*
* 当时把 `JOIN patients p` 从矩阵查询里删掉了(标签预计算后"用不到年龄"),
* 但 `poolBaseSql` 在 **scope.sourceUnits 非空**时会拼 `AND p.source_unit IN (...)`
* ⇒ PG 报 `missing FROM-clause entry for table "p"`,矩阵整页 500。
*
* ⚠️ 本地基准脚本当时硬编 `sourceUnits: []`,永远走不到那一支 —— **测了个假场景**。
* ⇒ 这里不连库也能守住:把两种 scope 拼出来的 SQL 做**别名闭合检查**。
*/
describe('poolBaseSql 用到的别名,查询里必须都在 FROM 里', () => {
const scope = (sourceUnits: string[]) =>
({ hostId: 'h', tenantId: 't', clinicIds: [], sourceUnits }) as never;
it('🔴 sourceUnits 非空时会引用 p. —— 那么查询必须 JOIN patients p', () => {
const filter = poolBaseSql(scope(['瑞尔']), 'c1');
expect(filter.sql).toMatch(/p\.source_unit/); // 前提成立:它确实引用了 p
const q = planLabelAnchorsSql(filter).sql;
expect(q).toMatch(/JOIN patients p\b/);
});
it('sourceUnits 为空时也不能少 —— ⛔ 别按"这一支用不到"就删', () => {
const q = planLabelAnchorsSql(poolBaseSql(scope([]), 'c1')).sql;
expect(q).toMatch(/JOIN patients p\b/);
});
it('⭐ 别名闭合:过滤片段里出现的每个表别名,都要能在 FROM/JOIN 里找到', () => {
for (const units of [[], ['瑞尔', '瑞泰']]) {
const q = planLabelAnchorsSql(poolBaseSql(scope(units), 'c1')).sql;
const used = new Set([...q.matchAll(/\b([a-z]{1,4})\.[a-z_]+/g)].map((m) => m[1]));
const declared = new Set([
...[...q.matchAll(/\b(?:FROM|JOIN)\s+[a-z_]+\s+([a-z]{1,4})\b/g)].map((m) => m[1]),
...[...q.matchAll(/AS\s+([a-z]{1,4})\(/g)].map((m) => m[1]),
]);
for (const a of used) {
if (a === 'max' || a === 'now') continue; // 函数名不是别名
expect({ units, alias: a, declared: [...declared] }).toEqual(
expect.objectContaining({ alias: expect.stringMatching(new RegExp(`^(${[...declared].join('|')})$`)) }),
);
}
}
});
});
describe('刷新 SQL 的几条结构前提', () => { describe('刷新 SQL 的几条结构前提', () => {
it('🔴 带 id 游标 —— 否则全量重算每次取同一批,原地打转且不报错', () => { it('🔴 带 id 游标 —— 否则全量重算每次取同一批,原地打转且不报错', () => {
const first = text(refreshLabelsSql({ afterId: null, onlyMissing: false })); const first = text(refreshLabelsSql({ afterId: null, onlyMissing: false }));
......
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