Commit 5b5379af by luoqi

perf(plan): runAllForHost 批量化 — bulk 预取 + 分块并发 + 日志 createMany

夜间全 host plan 重算的 upsert 段原为逐患者串行(每人 2 读 latest/snoozed + persona + 2 日志写),13万患者 ~49min。改为:循环前一次性 bulk 预取 latest(含 reasons)/snooze 抑制集/active persona → 逐患复用同一 upsertPlan 判定(结果等价)→ 写操作分块并发(PAC_PLAN_BATCH_CONCURRENCY 默认 8)→ PlanGenerationLog 收集后 createMany(每患仍一行,口径不变)。selectHits 全表扫不在本次范围(时间规则可改,每轮须全量重选)。单刷 recomputeForPatient 不传 prefetched,行为不变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent a31be316
...@@ -183,46 +183,73 @@ export class PlanEngineService { ...@@ -183,46 +183,73 @@ export class PlanEngineService {
let plansSuppressed = 0; let plansSuppressed = 0;
let plansClosed = 0; let plansClosed = 0;
// 2. 逐 patient 写 plan(每个 patient 一条 PlanGenerationLog,跟 schema 设计一致) // 2. 批量写 plan。
for (const [patientId, hits] of hitsByPatient.entries()) { // 优化(2026-06):原"逐 patient 串行 + 每人 2 读(latest/snoozed)+ persona + 2 日志写",
const log = await this.prisma.planGenerationLog.create({ // 全 host(~13万)上 upsert 段 ~49min。改为:循环前一次性 **bulk 预取** latest/snoozed/persona
data: { // → 逐患复用**同一** upsert 判定(结果等价)→ 写操作**分块并发** → PlanGenerationLog 收集后
hostId: scope.hostId, // 一次性 createMany(每患仍一行,审计/监控口径不变,只是写法从 12.5万次 → 几次)。
tenantId: scope.tenantId, // 注:selectHits 的全表扫不在本优化内(时间规则随时可改,每轮必须全量重选才正确)。
patientId, const patientIds = [...hitsByPatient.keys()];
triggeredBy: 'cli:batch', const { latestByPatient, snoozedByPatient, personaByPatient } = await this.prefetchForBatch(
status: 'running', scope,
scenarioRunCount: this.scenarios.length, patientIds,
}, now,
}); );
try { const EMPTY_SNOOZE = new Set<string>();
const result = await this.upsertPlan({ scope, patientId, hits }); const logRows: Prisma.PlanGenerationLogCreateManyInput[] = [];
if (result === 'created') plansCreated++; const concurrency = Math.max(1, Number(process.env.PAC_PLAN_BATCH_CONCURRENCY) || 8);
else if (result === 'superseded') plansSuperseded++; const entries = [...hitsByPatient.entries()];
else if (result === 'unchanged') plansUnchanged++; for (let i = 0; i < entries.length; i += concurrency) {
else if (result === 'suppressed') plansSuppressed++; await Promise.all(
entries.slice(i, i + concurrency).map(async ([patientId, hits]) => {
await this.prisma.planGenerationLog.update({ try {
where: { id: log.id }, const result = await this.upsertPlan({
data: { scope,
status: 'success', patientId,
plansCreated: result === 'created' || result === 'superseded' ? 1 : 0, hits,
endedAt: new Date(), prefetched: {
}, latest: latestByPatient.get(patientId) ?? null,
}); snoozedKeys: snoozedByPatient.get(patientId) ?? EMPTY_SNOOZE,
} catch (err) { personaId: personaByPatient.get(patientId) ?? null,
await this.prisma.planGenerationLog.update({ },
where: { id: log.id }, });
data: { // 计数:JS 单线程,await 恢复后同步自增,chunk 内并发无交错
status: 'failed', if (result === 'created') plansCreated++;
errorMessage: err instanceof Error ? err.message : String(err), else if (result === 'superseded') plansSuperseded++;
endedAt: new Date(), else if (result === 'unchanged') plansUnchanged++;
}, else if (result === 'suppressed') plansSuppressed++;
}); logRows.push({
this.logger.error( hostId: scope.hostId,
`plan upsert failed patient=${patientId}: ${err instanceof Error ? err.message : err}`, tenantId: scope.tenantId,
); patientId,
} triggeredBy: 'cli:batch',
status: 'success',
scenarioRunCount: this.scenarios.length,
plansCreated: result === 'created' || result === 'superseded' ? 1 : 0,
startedAt,
endedAt: new Date(),
});
} catch (err) {
logRows.push({
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId,
triggeredBy: 'cli:batch',
status: 'failed',
scenarioRunCount: this.scenarios.length,
errorMessage: err instanceof Error ? err.message : String(err),
startedAt,
endedAt: new Date(),
});
this.logger.error(
`plan upsert failed patient=${patientId}: ${err instanceof Error ? err.message : err}`,
);
}
}),
);
}
for (let i = 0; i < logRows.length; i += 5000) {
await this.prisma.planGenerationLog.createMany({ data: logRows.slice(i, i + 5000) });
} }
// 3. ⭐ 缺口1 修复:关闭"有 active plan 但本轮 0 命中"的患者的遗留 plan。 // 3. ⭐ 缺口1 修复:关闭"有 active plan 但本轮 0 命中"的患者的遗留 plan。
...@@ -265,18 +292,28 @@ export class PlanEngineService { ...@@ -265,18 +292,28 @@ export class PlanEngineService {
} }
// ── plan upsert(单 patient,事务内 supersede + insert)── // ── plan upsert(单 patient,事务内 supersede + insert)──
// prefetched:批量路径(runAllForHost)循环前一次性 bulk 取好 latest/snoozed/persona,
// 逐患者复用**同一**判定逻辑但不再每人发 3 条读查询(结果等价,仅省往返)。
// 单刷路径(recomputeForPatient)不传 → 照旧逐查,行为不变。
private async upsertPlan(input: { private async upsertPlan(input: {
scope: ScenarioScope; scope: ScenarioScope;
patientId: string; patientId: string;
hits: ScenarioHitWithKey[]; hits: ScenarioHitWithKey[];
prefetched?: {
latest: PlanWithReasons | null;
snoozedKeys: Set<string>;
personaId: string | null;
};
}): Promise<'created' | 'superseded' | 'unchanged' | 'suppressed'> { }): Promise<'created' | 'superseded' | 'unchanged' | 'suppressed'> {
const { scope, patientId, hits } = input; const { scope, patientId, hits, prefetched } = input;
const latest = await this.prisma.followupPlan.findFirst({ const latest = prefetched
where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId }, ? prefetched.latest
orderBy: { version: 'desc' }, : await this.prisma.followupPlan.findFirst({
include: { reasons: true }, where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId },
}); orderBy: { version: 'desc' },
include: { reasons: true },
});
// ⭐ 设计决策(2026-06):assigned 不再整体 skip。 // ⭐ 设计决策(2026-06):assigned 不再整体 skip。
// 理由:客服"正在执行"恰好撞上"正在重算"概率极低,为这点概率冻结内容、让客服拿过时理由白打, // 理由:客服"正在执行"恰好撞上"正在重算"概率极低,为这点概率冻结内容、让客服拿过时理由白打,
...@@ -290,7 +327,9 @@ export class PlanEngineService { ...@@ -290,7 +327,9 @@ export class PlanEngineService {
// 并集作抑制集,逐 hit 过滤掉命中抑制集的信号;剩下的(全新诊断)继续走下面生成逻辑。 // 并集作抑制集,逐 hit 过滤掉命中抑制集的信号;剩下的(全新诊断)继续走下面生成逻辑。
// 修复的 bug:旧版"整患者抑制" → 成功转化 K08 后,snooze 期内新长的 K02 也被压住不召(误伤新缺口)。 // 修复的 bug:旧版"整患者抑制" → 成功转化 K08 后,snooze 期内新长的 K02 也被压住不召(误伤新缺口)。
// 多次结案也正确:查的是全部未到期终态 plan 的并集(不止 latest 一条)。 // 多次结案也正确:查的是全部未到期终态 plan 的并集(不止 latest 一条)。
const snoozedKeys = await this.fetchSnoozedSignalKeys(scope, patientId, scope.now); const snoozedKeys = prefetched
? prefetched.snoozedKeys
: await this.fetchSnoozedSignalKeys(scope, patientId, scope.now);
const usableHits = const usableHits =
snoozedKeys.size === 0 snoozedKeys.size === 0
? hits ? hits
...@@ -335,12 +374,16 @@ export class PlanEngineService { ...@@ -335,12 +374,16 @@ export class PlanEngineService {
// head = 优先级最高的 hit(用于 plan 顶层字段:goal / recommendedRole / recommendedAt / recommendedChannel) // head = 优先级最高的 hit(用于 plan 顶层字段:goal / recommendedRole / recommendedAt / recommendedChannel)
const head = [...usableHits].sort((a, b) => b.priorityScore - a.priorityScore)[0]!; const head = [...usableHits].sort((a, b) => b.priorityScore - a.priorityScore)[0]!;
// 拉对应 patient 的最新 active persona(可空) // 拉对应 patient 的最新 active persona(可空);批量路径用预取值,单刷照旧查
const persona = await this.prisma.persona.findFirst({ const personaId = prefetched
where: { patientId, supersededAt: null }, ? prefetched.personaId
orderBy: { version: 'desc' }, : (
select: { id: true }, await this.prisma.persona.findFirst({
}); where: { patientId, supersededAt: null },
orderBy: { version: 'desc' },
select: { id: true },
})
)?.id ?? null;
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
if (latest && (latest.status === 'active' || latest.status === 'assigned')) { if (latest && (latest.status === 'active' || latest.status === 'assigned')) {
...@@ -354,7 +397,7 @@ export class PlanEngineService { ...@@ -354,7 +397,7 @@ export class PlanEngineService {
hostId: scope.hostId, hostId: scope.hostId,
tenantId: scope.tenantId, tenantId: scope.tenantId,
patientId, patientId,
personaId: persona?.id ?? null, personaId: personaId ?? null,
targetClinicId: head.targetClinicId ?? null, targetClinicId: head.targetClinicId ?? null,
version: nextVersion, version: nextVersion,
priorityScore: newPriorityScore, priorityScore: newPriorityScore,
...@@ -437,6 +480,68 @@ export class PlanEngineService { ...@@ -437,6 +480,68 @@ export class PlanEngineService {
return keys; return keys;
} }
/**
* 批量预取 —— runAllForHost 循环前一次性把每患者的 latest plan(含 reasons)/ snooze 抑制集 /
* active persona id 取好,避免循环内每人发 3 条读查询(等价,仅省往返)。
* 取数口径与 upsertPlan(findFirst orderBy version desc)/ fetchSnoozedSignalKeys(终态+未到期)
* 严格对齐;IN 列表分块防参数超限。
*/
private async prefetchForBatch(
scope: ScenarioScope,
patientIds: string[],
now: Date,
): Promise<{
latestByPatient: Map<string, PlanWithReasons>;
snoozedByPatient: Map<string, Set<string>>;
personaByPatient: Map<string, string>;
}> {
const latestByPatient = new Map<string, PlanWithReasons>();
const snoozedByPatient = new Map<string, Set<string>>();
const personaByPatient = new Map<string, string>();
const CHUNK = 2000;
for (let i = 0; i < patientIds.length; i += CHUNK) {
const ids = patientIds.slice(i, i + CHUNK);
// latest plan(含 reasons)— 每患者最高 version 那条(对齐 upsertPlan 的 orderBy version desc)
const plans = await this.prisma.followupPlan.findMany({
where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId: { in: ids } },
include: { reasons: true },
orderBy: [{ patientId: 'asc' }, { version: 'desc' }],
});
for (const p of plans) {
if (p.patientId && !latestByPatient.has(p.patientId)) latestByPatient.set(p.patientId, p);
}
// snooze 抑制集(对齐 fetchSnoozedSignalKeys:终态 + 冷静期未到期 plan 的 reason 并集)
const terminal = await this.prisma.followupPlan.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId: { in: ids },
status: { in: ['completed', 'abandoned'] },
snoozedUntil: { gt: now },
},
select: { patientId: true, reasons: { select: { scenario: true, subKey: true } } },
});
for (const t of terminal) {
if (!t.patientId) continue;
const set = snoozedByPatient.get(t.patientId) ?? new Set<string>();
for (const r of t.reasons) set.add(`${r.scenario}|${r.subKey ?? ''}`);
snoozedByPatient.set(t.patientId, set);
}
// 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) {
if (pe.patientId && !personaByPatient.has(pe.patientId)) {
personaByPatient.set(pe.patientId, pe.id);
}
}
}
return { latestByPatient, snoozedByPatient, personaByPatient };
}
/// 子场景 → 生命周期类型(one_shot 短效 / recurring 长效) /// 子场景 → 生命周期类型(one_shot 短效 / recurring 长效)
private lifecycleFor(scenario: string, subKey?: string): string { private lifecycleFor(scenario: string, subKey?: string): string {
// 复查类长效(种植年度 / 牙周维护),其余短效 // 复查类长效(种植年度 / 牙周维护),其余短效
...@@ -446,6 +551,8 @@ export class PlanEngineService { ...@@ -446,6 +551,8 @@ export class PlanEngineService {
} }
type ScenarioHitWithKey = ScenarioHit & { scenarioKey: string }; type ScenarioHitWithKey = ScenarioHit & { scenarioKey: string };
/// 批量预取/upsert 用:plan 含 reasons(对齐 upsertPlan 的 include:{reasons:true})
type PlanWithReasons = Prisma.FollowupPlanGetPayload<{ include: { reasons: true } }>;
export interface EngineRunResult { export interface EngineRunResult {
scenariosRun: number; scenariosRun: number;
......
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