Commit 76b0060f by luoqi

merge: plan 写入段端到端分批 → main

parents 3e86060c 31ed4846
Pipeline #3672 failed in 0 seconds
import { PlanEngineService } from '../src/modules/plan/engine/plan-engine.service';
import { PlanEngineService, resolvePlanBatchSize } from '../src/modules/plan/engine/plan-engine.service';
import type { ScenarioHit } from '../src/modules/plan/engine/scenario.interface';
/**
......@@ -1156,3 +1156,109 @@ describe('归因继承的边界 — 判据是「客服碰过没」', () => {
for (const [args] of called) expect(args.where.planId.in).not.toContain('p-g');
});
});
/**
* 🔴 2026-09-04 端到端分批改造的**核心判据**:结果与批大小无关。
*
* 事故背景:生产 pac-service 涨到 6.03GB 常驻被内核 OOM killer 打掉,整机 swap 抖死 2.5 小时。
* 堆账里 latestByPatient(1.93GB)+ hitsByPatient(1.06GB)是大头,而它们原先**全程不释放**
* —— 取数早就是 2000 一块,但产物累积在跨全量存活的 Map 里,runPool 的闭包又把它们
* context-allocate 到整个 runAllForHost frame 结束。改造把取数分块升格成端到端分批。
*
* 为什么这组用例是必须的:上面那 20+ 条既有用例**一条都跑不到分批路径的差异** ——
* 它们每个只有 1~4 个患者,默认批大小 2000,永远只有一批。分批写错了它们全绿。
* 这里用 describe.each 把同一份 fixture 在不同批大小下各跑一遍,直接编码
* 「结果与批大小无关」这条不变式。
*
* ⚠️ batchSize=1 是最凶的一档:每批一个患者,同时压测三件事 ——
* ① 跨批不误关(stale-close 拿的是跨批累加的 hitPatientIds,不是被逐批清空的 hitsByPatient)
* ② touchedPlanIds 按批算与全量算等价
* ③ snooze 提在循环外之后仍能被每一批查到
*/
describe('端到端分批 — 结果必须与 PAC_PLAN_BATCH_SIZE 无关', () => {
const prev = process.env.PAC_PLAN_BATCH_SIZE;
afterEach(() => {
if (prev === undefined) delete process.env.PAC_PLAN_BATCH_SIZE;
else process.env.PAC_PLAN_BATCH_SIZE = prev;
});
/// 与上面「混合一批」同一份 fixture:5 种结局各一,外加一个 0 命中的患者压 stale-close
const runMixed = async () => {
const { prisma, plans, logs } = makeStore({
plans: [
{ id: 'u-old', patientId: 'p2', status: 'active', priorityScore: 50, reasons: [{ scenario: SCEN, subKey: 'k@1' }] },
{ id: 's-old', patientId: 'p3', status: 'active', reasons: [{ scenario: SCEN, subKey: 'k@old' }] },
{
id: 't-term',
patientId: 'p4',
status: 'abandoned',
snoozedUntil: new Date('2026-12-01T00:00:00Z'),
reasons: [{ scenario: SCEN, subKey: 'k@4' }],
},
// ⭐ p9 本轮 0 命中且有 active plan → 必须被 stale-close 关掉。
// 分批写错(第 3 步进了循环 / 用了被清空的 hitsByPatient)时,
// 这里会连 p1~p4 一起关掉,plansClosed 从 1 变 5 —— 这条就是照妖镜。
{ id: 'stale-old', patientId: 'p9', status: 'active', reasons: [{ scenario: SCEN, subKey: 'k@gone' }] },
],
});
const res = await engine(
prisma,
makeScenario([
hit('p1', 'k@1'),
hit('p2', 'k@1', 50),
hit('p3', 'k@new'),
hit('p4', 'k@4'),
]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
return { res, plans, logs };
};
/// 把最终状态压成一个可比对的快照(引擎返回值 + 落库结果),批大小不该改变它任何一位
const snapshot = (r: Awaited<ReturnType<typeof runMixed>>) => ({
result: {
scenariosRun: r.res.scenariosRun,
patientsHit: r.res.patientsHit,
plansCreated: r.res.plansCreated,
plansSuperseded: r.res.plansSuperseded,
plansUnchanged: r.res.plansUnchanged,
plansSuppressed: r.res.plansSuppressed,
plansClosed: r.res.plansClosed,
plansSkippedAssigned: r.res.plansSkippedAssigned,
},
// plan 终态:按 (patientId, version) 排序,避免并发写入顺序带来的假差异
plans: r.plans
.map((p) => `${p.patientId}|v${p.version}|${p.status}|${p.priorityScore}`)
.sort(),
logs: r.logs.map((l) => `${l.patientId}|${l.status}`).sort(),
});
const SIZES = [0, 1, 2, 3, 2000];
let baseline: ReturnType<typeof snapshot> | null = null;
test.each(SIZES)('批大小 %s → 结果与基线逐字段一致', async (size) => {
process.env.PAC_PLAN_BATCH_SIZE = String(size);
const snap = snapshot(await runMixed());
// 先自证这份 fixture 真的走到了 5 种结局,否则"全相等"可能只是都没跑
expect(snap.result).toMatchObject({
patientsHit: 4,
plansCreated: 1,
plansUnchanged: 1,
plansSuperseded: 1,
plansSuppressed: 1,
plansClosed: 1, // ⭐ 只关 p9;若变成 5 说明分批把其他批的患者误判成"信号消失"
});
if (baseline === null) baseline = snap;
else expect(snap).toEqual(baseline);
});
test('⛔ 0 = single-shot 回退开关(等价于改造前的不分批行为)', () => {
expect(resolvePlanBatchSize({ PAC_PLAN_BATCH_SIZE: '0' } as NodeJS.ProcessEnv)).toBe(0);
});
test('⛔ 批大小夹在 [1, 20000] —— 上限守 PG 32767 bind(latest/persona 走 patientId in ids)', () => {
expect(resolvePlanBatchSize({ PAC_PLAN_BATCH_SIZE: '999999' } as NodeJS.ProcessEnv)).toBe(20_000);
expect(resolvePlanBatchSize({ PAC_PLAN_BATCH_SIZE: '-5' } as NodeJS.ProcessEnv)).toBe(1);
expect(resolvePlanBatchSize({} as NodeJS.ProcessEnv)).toBe(2000);
expect(resolvePlanBatchSize({ PAC_PLAN_BATCH_SIZE: 'abc' } as NodeJS.ProcessEnv)).toBe(2000);
});
});
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