Commit f4987677 by luoqi

fix(plan): 删掉「0 = single-shot」那个假回退档 —— 它绕过 bind 上限,且不等价于改造前

上一提交给 resolvePlanBatchSize 留了个「显式传 0 = 不分批」当回滚开关,照抄 cold-import 的
resolveCohortBatchSize 形状。2026-09-04 在测试机实跑 A/B 时当场炸:

  PAC_PLAN_BATCH_SIZE=0 + 88,728 命中患者
  → Invalid `prisma.followupPlan.findMany()`:
    too many bind variables in prepared statement, expected maximum of 32767, received 32769

两个错都在这一档上:
① **它绕过了同一函数里那条 20000 的硬上限。** latest/persona 走 `patientId: { in: ids }`,
   每个 id 一个 bind。昨天(b0d45e60)刚给 reparse 夹住同一堵墙、还在注释里写了
   「别让墙从另一侧长回来」,今天在这儿亲手开了个后门。同一类错,隔一天,换个入口。
② **它压根不等价于改造前。** 改造前 prefetchForBatch 内部有 CHUNK=2000 循环,
   取数从来没有一次超过 2000 个 id;把内循环删掉后,「一批 = 全部患者」是旧代码
   **从未有过**的行为。拿它当 A/B 对照组,量出来的东西没有意义 —— 这也是它最误导人的地方:
   我本来打算用它做"改造前"的基准。

⇒ 删掉该档,任何取值一律夹进 [1, 20000]。真正的回退手段是 revert,不是拧旋钮;
  旋钮只调批大小,不调"分不分批"。注释里把这段经过写下来,防止有人觉得
  「加个 single-shot 更灵活」再加回来。

测试:原来那条断言 `0 → 0` 反转成 `0 → 2000`(必须被夹住),describe.each 去掉 0 档。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 76b0060f
...@@ -42,16 +42,26 @@ import { PlanLabelService } from '../plan-label.service'; ...@@ -42,16 +42,26 @@ import { PlanLabelService } from '../plan-label.service';
* V8 要到 runAllForHost 整个 frame 结束才可能回收 → 第 3 步扫全池时它们全还在, * V8 要到 runAllForHost 整个 frame 结束才可能回收 → 第 3 步扫全池时它们全还在,
* 那一下就是峰值。所以要把「取数分块」升格成「端到端分批」:每批取数→写库→释放。 * 那一下就是峰值。所以要把「取数分块」升格成「端到端分批」:每批取数→写库→释放。
* *
* ⚠️ 上限 20000:latest/persona 两条走 Prisma `patientId: { in: ids }`,受 PG 32767 bind 上限约束。 * ⚠️ 上限 20000 是硬约束不是调优:latest/persona 两条走 Prisma `patientId: { in: ids }`,
* ⚠️ 显式传 0 = single-shot(不分批),等价于改造前的行为 —— **这是回滚开关**, * 每个 id 占一个 bind,PG 单条 prepared statement 上限 32767。
* 照 cold-import 的 resolveCohortBatchSize 同一形状(传 0 回退 single-shot)。 *
* 🔴 **这里没有 single-shot 回退档,别再加回来。**
* 初版曾照 cold-import 的 resolveCohortBatchSize 做成「显式传 0 = 不分批」当回滚开关。
* 2026-09-04 在测试机实跑当场炸:`PAC_PLAN_BATCH_SIZE=0` + 88,728 命中患者 →
* `too many bind variables in prepared statement, expected maximum of 32767, received 32769`
* 两个错都在那一档上:
* ① **它绕过了上面那条 20000 的硬上限** —— 昨天刚给 reparse 夹住同一堵墙(reparseBatchSize),
* 今天在这儿开了个后门让它从另一侧长回来。
* ② **它压根不等价于改造前。** 改造前 prefetchForBatch 内部有 CHUNK=2000 循环,
* 取数从来没有一次超过 2000 个 id;把内循环删掉后,「一批 = 全部患者」是旧代码
* 从未有过的行为。拿它当对照组,量出来的东西没有意义。
* ⇒ 真正的回退手段是 revert 这个提交,不是拧旋钮。旋钮只调批大小,不调"分不分批"。
*
* ⛔ 别把 snooze 预取塞进批循环(见 prefetchSnoozeAnchors 的注释):那是已经被消掉的 * ⛔ 别把 snooze 预取塞进批循环(见 prefetchSnoozeAnchors 的注释):那是已经被消掉的
* O(患者数) 项,塞回去到 200 万患者就是 1 次 vs 1000 次。 * O(患者数) 项,塞回去到 200 万患者就是 1 次 vs 1000 次。
*/ */
export function resolvePlanBatchSize(env: NodeJS.ProcessEnv = process.env): number { export function resolvePlanBatchSize(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.PAC_PLAN_BATCH_SIZE; return Math.min(20_000, Math.max(1, Number(env.PAC_PLAN_BATCH_SIZE) || 2000));
if (raw !== undefined && raw.trim() !== '' && Number(raw) === 0) return 0; // 0 = single-shot 回退
return Math.min(20_000, Math.max(1, Number(raw) || 2000));
} }
@Injectable() @Injectable()
...@@ -331,7 +341,7 @@ export class PlanEngineService { ...@@ -331,7 +341,7 @@ export class PlanEngineService {
* 逐患者的 try/catch(下面 runPool 回调里那个)原样保留,那个是有意的。 * 逐患者的 try/catch(下面 runPool 回调里那个)原样保留,那个是有意的。
*/ */
const batchSize = resolvePlanBatchSize(); const batchSize = resolvePlanBatchSize();
const step = batchSize > 0 ? batchSize : Math.max(1, patientIds.length); const step = batchSize;
let batches = 0; let batches = 0;
for (let off = 0; off < patientIds.length; off += step) { for (let off = 0; off < patientIds.length; off += step) {
const ids = patientIds.slice(off, off + step); const ids = patientIds.slice(off, off + step);
...@@ -502,7 +512,7 @@ export class PlanEngineService { ...@@ -502,7 +512,7 @@ export class PlanEngineService {
// 加 `批=` 便于跟历史对照。判据仍只看整轮墙钟,别拿分段和历史逐项比。 // 加 `批=` 便于跟历史对照。判据仍只看整轮墙钟,别拿分段和历史逐项比。
this.logger.log( this.logger.log(
`[plan] 阶段耗时 场景=${selectMs}ms 预取=${prefetchMs}ms 写入=${Date.now() - tWrite}ms ` + `[plan] 阶段耗时 场景=${selectMs}ms 预取=${prefetchMs}ms 写入=${Date.now() - tWrite}ms ` +
`批=${batches}(size=${batchSize === 0 ? 'single-shot' : batchSize}) ` + `批=${batches}(size=${batchSize}) ` +
`命中患者=${hitPatientIds.size} 总计=${Date.now() - startedAt.getTime()}ms`, `命中患者=${hitPatientIds.size} 总计=${Date.now() - startedAt.getTime()}ms`,
); );
return { return {
......
...@@ -1232,7 +1232,7 @@ describe('端到端分批 — 结果必须与 PAC_PLAN_BATCH_SIZE 无关', () => ...@@ -1232,7 +1232,7 @@ describe('端到端分批 — 结果必须与 PAC_PLAN_BATCH_SIZE 无关', () =>
logs: r.logs.map((l) => `${l.patientId}|${l.status}`).sort(), logs: r.logs.map((l) => `${l.patientId}|${l.status}`).sort(),
}); });
const SIZES = [0, 1, 2, 3, 2000]; const SIZES = [1, 2, 3, 2000];
let baseline: ReturnType<typeof snapshot> | null = null; let baseline: ReturnType<typeof snapshot> | null = null;
test.each(SIZES)('批大小 %s → 结果与基线逐字段一致', async (size) => { test.each(SIZES)('批大小 %s → 结果与基线逐字段一致', async (size) => {
...@@ -1251,8 +1251,15 @@ describe('端到端分批 — 结果必须与 PAC_PLAN_BATCH_SIZE 无关', () => ...@@ -1251,8 +1251,15 @@ describe('端到端分批 — 结果必须与 PAC_PLAN_BATCH_SIZE 无关', () =>
else expect(snap).toEqual(baseline); else expect(snap).toEqual(baseline);
}); });
test('⛔ 0 = single-shot 回退开关(等价于改造前的不分批行为)', () => { /**
expect(resolvePlanBatchSize({ PAC_PLAN_BATCH_SIZE: '0' } as NodeJS.ProcessEnv)).toBe(0); * 🔴 2026-09-04 测试机实跑炸出来的:初版有个「0 = single-shot 不分批」的回退档,
* 它**绕过了 20000 上限** → 88,728 个患者一次塞进 `patientId: { in: ids }` →
* `too many bind variables ... expected maximum of 32767, received 32769`。
* 而且它压根不等价于改造前(改造前内部有 CHUNK=2000,取数从没超过 2000 个 id)。
* 已删除。这条用例钉住「任何取值都必须落在 [1,20000]」,防止有人再加回来。
*/
test('⛔ 0 也必须被夹进 [1,20000] —— 不许存在绕过 bind 上限的档位', () => {
expect(resolvePlanBatchSize({ PAC_PLAN_BATCH_SIZE: '0' } as NodeJS.ProcessEnv)).toBe(2000);
}); });
test('⛔ 批大小夹在 [1, 20000] —— 上限守 PG 32767 bind(latest/persona 走 patientId in ids)', () => { test('⛔ 批大小夹在 [1, 20000] —— 上限守 PG 32767 bind(latest/persona 走 patientId in ids)', () => {
......
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