Commit 66c54b46 by luoqi

perf(plan): 预取消掉一个 O(患者数) 项 + 三条查询并行

① snooze 抑制集提到 chunk 循环外一次查完。
   它的代价跟「终态且冷静期未到期的计划数」走,跟患者数无关 ——
   2026-08-30 生产实测全库符合条件只有 **106 行**,而按 chunk 查要跑 274 次
   (54.8 万患者 / 2000),**273 次在查空**。
   🔴 这不是省常数,是消掉一个 O(患者数) 项:到 200 万患者原写法是 1000 次往返,
      新写法仍是 1 次。生产百万级且在涨,这类项要按规模判断而不是按当下耗时。
   索引 (status,…) 前导 status,completed/abandoned 是稀有态 → Bitmap 扫 42 buffers/0.6ms。

② 余下三条(latest plan / persona / 末次到诊诊所)彼此独立,改 Promise.all 并行。
   每 chunk 墙钟从「三条之和」降到「最慢那条」。并发度恒为 3,不随患者数涨,
   不会挤爆 Prisma 池(默认 核数×2+1)。

口径零变化(三条都是只读、无共享状态;snooze map 只会被本批患者查到)。
本地实测:预取 8,383ms → 4,899ms;**plan_reasons 逐行 diff = 0**(39,225 行)。
️ 预取是纯性能路径,单测覆盖不到,所以靠真实数据端到端行级对拍来验。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 58e850cb
...@@ -930,24 +930,20 @@ export class PlanEngineService { ...@@ -930,24 +930,20 @@ export class PlanEngineService {
const snoozedByPatient = new Map<string, Map<string, Date>>(); const snoozedByPatient = new Map<string, Map<string, Date>>();
const personaByPatient = new Map<string, string>(); const personaByPatient = new Map<string, string>();
const lastVisitClinicByPatient = new Map<string, string>(); const lastVisitClinicByPatient = new Map<string, string>();
const CHUNK = 2000;
for (let i = 0; i < patientIds.length; i += CHUNK) { // ⭐ snooze 抑制集**提到循环外一次查完** —— 它的代价跟「终态且冷静期未到期的计划数」走,
const ids = patientIds.slice(i, i + CHUNK); // 跟患者数无关。2026-08-30 生产实测:全库符合条件的只有 **106 行**,
// latest plan(含 reasons)— 每患者最高 version 那条(对齐 upsertPlan 的 orderBy version desc) // 而原来按 chunk 查会跑 274 次(54.8 万患者 / 2000),**273 次是在查空**。
const plans = await this.prisma.followupPlan.findMany({ // 索引 (status, …) 前导 status,completed/abandoned 是稀有态 → Bitmap 扫 42 buffers / 0.6ms。
where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId: { in: ids } }, //
include: { reasons: true }, // 🔴 这不是省常数,是**消掉一个 O(患者数) 项** —— 原写法到 200 万患者会变成 1000 次往返,
orderBy: [{ patientId: 'asc' }, { version: 'desc' }], // 新写法仍是 1 次。生产是百万级且在涨,这类项必须按规模而不是按当下耗时来判断。
}); // 口径不变:map 只会被本批患者查到,多取的那些患者条目不影响任何判定。
for (const p of plans) { {
if (p.patientId && !latestByPatient.has(p.patientId)) latestByPatient.set(p.patientId, p); const terminalAll = await this.prisma.followupPlan.findMany({
}
// snooze 抑制集(对齐 fetchSnoozedSignalKeys:终态 + 冷静期未到期 plan 的 reason → 结案锚点)
const terminal = await this.prisma.followupPlan.findMany({
where: { where: {
hostId: scope.hostId, hostId: scope.hostId,
tenantId: scope.tenantId, tenantId: scope.tenantId,
patientId: { in: ids },
status: { in: ['completed', 'abandoned'] }, status: { in: ['completed', 'abandoned'] },
snoozedUntil: { gt: now }, snoozedUntil: { gt: now },
}, },
...@@ -955,49 +951,63 @@ export class PlanEngineService { ...@@ -955,49 +951,63 @@ export class PlanEngineService {
patientId: true, patientId: true,
updatedAt: true, updatedAt: true,
reasons: { select: { scenario: true, subKey: true } }, reasons: { select: { scenario: true, subKey: true } },
// 结案锚点 = 结案 execution.createdAt(不可变;updatedAt 会被召回反馈等后续写顶后,仅兜底)
executions: { orderBy: { createdAt: 'desc' }, take: 1, select: { createdAt: true } }, executions: { orderBy: { createdAt: 'desc' }, take: 1, select: { createdAt: true } },
}, },
}); });
{ const plansByPatient = new Map<string, typeof terminalAll>();
const plansByPatient = new Map<string, typeof terminal>(); for (const t of terminalAll) {
for (const t of terminal) { if (!t.patientId) continue;
if (!t.patientId) continue; const arr = plansByPatient.get(t.patientId) ?? [];
const arr = plansByPatient.get(t.patientId) ?? []; arr.push(t);
arr.push(t); plansByPatient.set(t.patientId, arr);
plansByPatient.set(t.patientId, arr); }
} for (const [pid, plans] of plansByPatient) {
for (const [pid, plans] of plansByPatient) { snoozedByPatient.set(pid, buildSnoozeAnchors(plans));
snoozedByPatient.set(pid, buildSnoozeAnchors(plans)); }
} }
const CHUNK = 2000;
for (let i = 0; i < patientIds.length; i += CHUNK) {
const ids = patientIds.slice(i, i + CHUNK);
// ⭐ 三条彼此独立,**并行发** —— 原来是串行,每 chunk 的墙钟 = 三条之和;
// 并行后 = 最慢那条。三条都只读、无共享状态,并行不改任何口径。
// 并发度就是 3(不随患者数涨),不会挤爆连接池(Prisma 默认池 = 核数×2+1)。
const [plans, personas, visits] = await Promise.all([
// latest plan(含 reasons)— 每患者最高 version 那条(对齐 upsertPlan 的 orderBy version desc)
this.prisma.followupPlan.findMany({
where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId: { in: ids } },
include: { reasons: true },
orderBy: [{ patientId: 'asc' }, { version: 'desc' }],
}),
// active persona id(每患最新 active 版本)
this.prisma.persona.findMany({
where: { patientId: { in: ids }, supersededAt: null },
orderBy: [{ patientId: 'asc' }, { version: 'desc' }],
select: { id: true, patientId: true },
}),
// 每患最后一次到诊(encounter/emr)所在诊所 → 跟进归属(见 upsertPlan 说明)。
// DISTINCT ON 取 occurred_at 最新那条;跟"就诊冷静期"用同一 type 口径,语义一致。
this.prisma.$queryRaw<Array<{ patient_id: string; clinic_id: string }>>`
SELECT DISTINCT ON (patient_id) patient_id, clinic_id
FROM patient_facts
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND patient_id = ANY(${ids}::uuid[])
AND type IN ('encounter_record', 'emr_record')
AND occurred_at IS NOT NULL
AND clinic_id IS NOT NULL AND clinic_id <> ''
AND superseded_at IS NULL
ORDER BY patient_id, occurred_at DESC
`,
]);
for (const p of plans) {
if (p.patientId && !latestByPatient.has(p.patientId)) latestByPatient.set(p.patientId, p);
} }
// active persona id(每患最新 active 版本)
const personas = await this.prisma.persona.findMany({
where: { patientId: { in: ids }, supersededAt: null },
orderBy: [{ patientId: 'asc' }, { version: 'desc' }],
select: { id: true, patientId: true },
});
for (const pe of personas) { for (const pe of personas) {
if (pe.patientId && !personaByPatient.has(pe.patientId)) { if (pe.patientId && !personaByPatient.has(pe.patientId)) {
personaByPatient.set(pe.patientId, pe.id); personaByPatient.set(pe.patientId, pe.id);
} }
} }
// 每患最后一次到诊(encounter/emr)所在诊所 → 跟进归属(见 upsertPlan 说明)。
// DISTINCT ON 取 occurred_at 最新那条;跟"就诊冷静期"用同一 type 口径,语义一致。
const visits = await this.prisma.$queryRaw<
Array<{ patient_id: string; clinic_id: string }>
>`
SELECT DISTINCT ON (patient_id) patient_id, clinic_id
FROM patient_facts
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND patient_id = ANY(${ids}::uuid[])
AND type IN ('encounter_record', 'emr_record')
AND occurred_at IS NOT NULL
AND clinic_id IS NOT NULL AND clinic_id <> ''
AND superseded_at IS NULL
ORDER BY patient_id, occurred_at DESC
`;
for (const v of visits) lastVisitClinicByPatient.set(v.patient_id, v.clinic_id); for (const v of visits) lastVisitClinicByPatient.set(v.patient_id, v.clinic_id);
} }
// ⭐ 一次查完「客服碰过哪些单」—— 只问带 assignment_id 的那些(通常是很小的子集), // ⭐ 一次查完「客服碰过哪些单」—— 只问带 assignment_id 的那些(通常是很小的子集),
......
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