1. 05 Sep, 2026 6 commits
    • fix(ops): 按实测更正两处会误导排查的说法(exit 137 / 堆上限) · 0ead4e0b
      09-05 在测试机用同一个 pac-service 镜像、256MB 上限的探针跑通了整条链,
      两处原写法与实测不符:
      
      1. 「越界 → SIGKILL(exit 137)」误导排查方向。
         restart:always 下 docker inspect 的 State.ExitCode / OOMKilled 显示的是
         **重启后那条命** —— 实测内核明确 CONSTRAINT_MEMCG 杀了 2 次,inspect 同时
         报 ExitCode=0 OOMKilled=false。照原文案去查会得出"没被杀"的反结论。
         判据改为 RestartCount + dmesg CONSTRAINT_MEMCG,告警正文里也一并改。
      
      2. 补上实测数:rss=241MB 时 heapUsed 仅 4MB。
         这是 mem_limit 不可替代的硬证据 —— 堆上限对堆外(Buffer/engine)零约束力,
         也是"别把 max-old-space-size 和 mem_limit 划等号"的依据。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(ops): 补上 09-04 事故缺的两道兜底 —— 容器内存上限 + 外部存活探针 · 6cb7b2f6
      09-04 生产宕 11.8 小时,根因不是"内存涨了",是三道兜底同时不在:
        ① V8 abort   —— 堆天花板 08-26 抬到 8G,还没顶到就先把机器吃穷
        ② cgroup OOM —— 容器 HostConfig.Memory=0,这条压根不存在
        ③ 内核 OOM   —— 机器无 swap,file page 永远"可回收",内核宁可反复
                         丢/重读 page cache 也不判 OOM → refault 活锁,不留任何日志
      外加告警 8 类全部由 pac-service 进程内推,进程一卡告警一起哑,
      连云监控 agent 都在 20:35 被饿死 → 12 小时无人知晓。
      
      本次两条都只买"炸得小、有人知道",不减内存:
      
      1. docker-compose.prod.yml 给 pac-service 加 mem_limit: 8g
         把爆炸半径从整机缩回单容器:越界 SIGKILL(137)→ restart:always 拉起,
         回到 08-29 那次内核 OOM 的分钟级自愈。8g 由生产实测倒推:
         15.36G 全机 − 2.5G(OS+page cache,无 swap 挤不得) − 2.7G(dockerd 压力峰值)
         − 0.2G(web+docs) ≈ 10G 可用,观测峰值 6.03G,取 8G 留 33% 余量。
         ️ 暂不动 NODE_OPTIONS 的 8192:等分批改造的 heapUsed= 日志给出生产实测峰值,
         再把堆上限压到 mem_limit 之下,让 V8 先自己抛(有堆栈)而不是被 SIGKILL(无日志)。
      
      2. deploy/health-watch.sh —— 装在**被监控机之外**的 cron 探针
         连续 N 次 /health 不通才告警,恢复也推一条;带对照探针防探针机断网误报;
         纯 bash+curl 无额外依赖;永远 exit 0 不给 cron 制造邮件。
      
      3. 顺带修 deploy-prod.sh 的 health 验证一直在空转
         真实路由是 /health(GLOBAL_PREFIX_EXCLUDE,不带 /pac/v1),
         而全局响应拦截器把 404 也包成 HTTP 200:
           /pac/v1/health → 200 {"code":10004,"msg":"Cannot GET /pac/v1/health"}
         于是"只看 %{http_code}"的探活恒真 —— 三项硬验证里的 health 那项
         只证明了 Nest 的 HTTP 层还在应答。改为探 /health 且判据落在 body 上。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(plan): 更正事故注释 —— 昨晚那条 OOM 日志是 6 天前的,叙事全错 · 3e62c5f0
      09-05 用 journalctl -b -1 复盘,推翻了 09-04 当晚写进代码的三条事实:
      
      ① **当晚内核一条 OOM 都没打。** VNC 上那条
         `Killed process 945388 (node) anon-rss:6321668kB` 是 **2026-08-29 07:26:17** 的 ——
         pid/anon-rss/pgtables 与内核日志逐字段相同,内核 cmdline 带 console=tty0,
         它在 framebuffer 上留了 6 天没被刷掉。/var/log/messages 独立日志槽交叉验证同样只此 1 条。
      ② **这台机没有 swap**(Swap: 0B / vm.swappiness=0 / /proc/swaps 空),「swap 抖死」不成立。
      ③ **没有崩溃循环**:内核 `eth0: renamed from` 按天计数 Sep 03=0 / Sep 04=0,
         当晚一次容器启动都没有;journalctl -u docker 事故时段无条目。
      
      真正形态是**无 swap 下的 page-cache refault 活锁**:匿名页吃满后只能回收文件页,
      程序正文被刷掉、下一条指令又缺页读回(监控上 286MB/s 满速读、写≈0、CPU~15%)。
      因为始终有文件页可回收,out_of_memory() 从未被调用 —— 没人被杀,也就没有任何自愈,
      一路挂到人工硬重启,共约 11.8 小时(不是原注释写的 2.5 小时)。
      对照 08-29:同机同水位同样吃到 6GB,那次走 OOM kill、10 分钟量级自愈。
      **失败形态比内存本身更决定后果。**
      
      死点(生产库写入痕迹重建,应用日志已随部署 --force-recreate 丢失):
        场景+预取 4,110s → 写入段仅 43s(550,011 行落库)→ 第 3 步 stale-close 最后一次提交
        20:05:49(全库最后一次写)→ 收尾 backfillMissing 一行没写、阶段耗时从未打出。
      
      代码行为不改(分批本身经测试机实测有效:45 批 heapUsed 单调下降 462→107MB、零累积;
      属性测试证明结果与批大小无关)。改的是**它的定位**:
      从「本次事故的直接对策」改成「降低峰值 → 降低再次触发活锁的概率」,
      并明确写出**不治**的三项:hits 交接的双份拷贝(≈2.1GB,现存最大未处理项)、
      第 3 步整池 findMany、以及真正的兜底缺口(容器 MemLimit=0 + 无 swap ⇒
      cgroup OOM 与内核 OOM 两道兜底全被绕过)。
      
      顺带删掉 treatment-initiation-recall.scenario.ts 里一句自 2026-08-30 起就失真的注释:
      「场景查询是串行的,同一时刻只有一条在跑,峰值可控」—— 那天起生产设了 conc=4,
      RDS 上同时有 4 个 SET LOCAL work_mem='256MB' 的事务,DB 侧峰值不再可控。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(plan): 阶段耗时分段口径 —— 「写入=」原本把「预取=」又算了一遍 · ce2d605a
      2026-09-04 测试机实跑发现:改造后 `写入=` 取的是整个批循环的墙钟(tWrite 设在循环外),
      而批内预取也在这个循环里 ⇒ 两段重叠、`场景+预取+写入` 远大于 `总计`。
      实测那轮 `写入=1,243,665ms` 里有 **983,448ms 其实是预取**,真正写库只有 260,217ms。
      
      误导性的度量比没有度量更糟 —— 而这几个数正是判断本次改造成败的依据:
      照原样读会得出"写入段劣化 886%"的结论,实际是 105%(而且那一轮的对照本身也不干净,
      未被修改的场景段同时涨了 49%,说明外部条件不同,当轮数据不足以判劣化)。
      
      改:批内单独累计 writeMs(只含 runPool + createMany),与 prefetchMs 不重叠。
      顺带修掉一个死变量:初版声明过 writeMs 但从未使用(tsc 没报是因为没开 noUnusedLocals)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(plan): 删掉「0 = single-shot」那个假回退档 —— 它绕过 bind 上限,且不等价于改造前 · f4987677
      上一提交给 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>
      luoqi committed
  2. 04 Sep, 2026 3 commits
    • perf(plan): 写入段改端到端分批 —— 取数早就分块了,产物却全程不释放 · 31ed4846
      2026-09-04 生产 OOM 宕机 2.5 小时。内核日志:
        Killed process (node) anon-rss:6,321,668kB(6.03GB) total-vm:16.7GB
      机器 14GB。进程被杀后整机 swap 抖死,sshd 与 Web 全部无响应,靠人工重启才能恢复。
      docker-compose.prod.yml 那段注释 2026-08-26 就写过「这是止血不是根治:池子还在涨,
      8G 迟早也会到顶」—— 那天到了。
      
      ## 病根不是"没分块",是"不释放"
      
      取数早就是 CHUNK=2000 一块(prefetchForBatch 内),但**产物累积**在跨全量存活的 Map 里;
      runPool 的闭包又把 latest/persona/visit/logRows 全部 context-allocate,
      V8 要到 runAllForHost 整个 frame 结束才可能回收 → 第 3 步扫全池时它们全还在,那一下就是峰值。
      
      堆账(550,049 命中患者 / 约 96 万条 hit,按 V8 对象布局建模):
        latestByPatient 1.93GB + hitsByPatient 1.06GB + persona/visit 0.14GB
        + activePlans 0.09GB + logRows 0.07GB ≈ 活跃保留 3.3GB,实测 RSS 6.03GB(≈1.8×)
      
      ## 改动
      
      · prefetchForBatch 拆成两半:
        - prefetchSnoozeAnchors(scope, now) —— **整轮一次**。它的代价跟"终态且冷静期未到期的
          计划数"走、与患者数无关(生产实测全库 106 行)。拆出来是让这条纪律在类型上也成立:
          它拿不到 patientIds,想塞进批循环也塞不了。
        - prefetchOneBatch(scope, ids) —— 每批一次,删掉内部的 CHUNK 循环,切批权交给调用方。
      · 写入段改成端到端批循环:每批 预取 → runPool 写库 → createMany 日志 → 释放。
        hit 的所有权交给本批局部数组后立刻 hitsByPatient.delete(pid),
        批末连同 latest/persona/visit/logRows 一起出作用域回收。
      · 新增 resolvePlanBatchSize():默认 2000,夹在 [1, 20000](上限守 PG 32767 bind ——
        latest/persona 走 patientId in ids),**显式传 0 = single-shot 回退开关**
        (照 cold-import 的 resolveCohortBatchSize 同一形状)。
      · 每 5 批 + 末批打一行 rss= / heapUsed= / heapTotal=(rss 沿用 cold-import 字样便于并排 grep;
        判 8G 堆余量只能看 heapUsed,rss 含 Prisma Rust 引擎与碎片)。
      
      ## 唯一的硬全局依赖,以及它的坑
      
      第 3 步 stale-close 拿本轮命中集对整个召回池取补集(`!hitsByPatient.has(pid)` → supersede)。
      分批后 hitsByPatient 被逐批清空 ⇒ 改用跨批累加的 `hitPatientIds` Set(只存 id,约 54MB)。
       第 3 步**必须等所有批跑完再跑一次**。搬进循环里 = 每批把其他批的患者判成"信号消失"
         → 整池 supersede + 每条认领单一条 auto_release,**而且不报错**,
         那行「本轮关闭 N 条无信号 plan」的日志反而会解释成「这不是 bug」。
       循环体里**不加 try/catch**:今天的语义是"selectHits 抛错发生在任何写之前 → 整轮中止、
         第 3 步不跑";分批后前 N 批已落库,吞错"把剩下的批跑完"会让第 3 步拿着残缺命中集去关池子。
      
      ## 测试
      
      新增 describe.each 覆盖 PAC_PLAN_BATCH_SIZE ∈ {0,1,2,3,2000},同一 fixture 各跑一遍,
      断言引擎返回八个字段 + plan 终态 + 日志行**逐字段一致**,直接编码「结果与批大小无关」。
      **为什么必须新加**:既有 20+ 条用例每个只有 1~4 个患者、默认批 2000,永远只有一批 ——
      分批写错它们全绿。batch=1 是最凶的一档,同时压测跨批不误关 / touchedPlanIds 按批等价 /
      snooze 外提后仍被每批查到。fixture 里特意放了一个本轮 0 命中的 p9 压 stale-close。
      
      有牙验证:把第 3 步判据改回 hitsByPatient.has(分批后已清空)→ plansClosed 0→3、
      active 与 assigned 双双变 superseded,多条用例立刻红。
      
      ## 预计效果与未做的部分
      
      峰值从"latest+hits+logRows 同时在堆"的堆叠拆成单批驻留,预计 3.3GB → 约 2.1GB。
      ️ 这一级**不碰任何 SQL、不碰 selectHits**,08-30 那根 ×2.31 的并发杠杆与 work_mem 收益
         一个字节不动。但 hitsByPatient 仍在第 1 步满载(1.06GB),峰值仍与命中总数线性相关。
         要真正解耦需要二级(患者域分区 selectHits),那要改 SQL 且必须先在生产标定
         11×P 次子场景查询的墙钟 —— 生产当前宕机,标不了,另开。
      ️ 第 3 步的 activePlans findMany 仍是无分页全池扫(约 94MB,占比 3%)。改键集分页有真实风险:
         plan-engine-stale-close.spec 的 findMany mock 忽略 take/orderBy/id.gt,分页写错在 CI 里全绿。
         单独一件事做,别和本次混。
      ️ PAC_PLAN_BATCH_CONCURRENCY 从来没被 withCohortDerivedPool 算进连接池(2026-09-02 已因
         漏算一次导致健康日报连续三天超时)。本次不改,记在这。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): 僵尸锁回收加「第二次机会」—— 启动态对 deploy 孤儿锁 100% 失效 · ecfbcf74
      2026-09-04 测试机事故:增量停摆 24.7 小时,DW 滞后告警(告警文案说「DW 可能没刷新」,
      是误导 —— DW 没问题,是我们自己的锁把自己挡在门外)。
      
        09-03 14:15:00  一轮增量起跑,写下 running 锁
        09-03 14:30:24  deploy 拆容器,那轮还差 ~2.5 分钟跑完,被硬杀
        09-03 14:30:31  新进程跑启动态回收 → 查到 0 行静默 return
                        (cutoff=min(now-3h, 进程启动)=11:30;锁 14:15 不满足 < 11:30)
        之后每轮 cron 全被 partial-unique 锁拦下,直到人工重启
      
      **这不是概率性漏网,是结构性必然:**
        deploy 孤儿锁的年龄 = T_重启 − T_起跑,而那轮在重启时尚未跑完 ⇒ 年龄恒 < 一轮耗时;
        而 REAP_MIN_AGE_MS 又被刻意取成 > 一轮耗时(其注释自陈「比一轮耗时留足余量」)。
        两式相与 ⇒ 任何 deploy 期间在飞的 sync 留下的锁,必然逃过启动那一次回收。
        而 reapStaleRunningLocks 是全仓唯一的 syncLog.updateMany,只在 onModuleInit 跑一次
        —— 逃过就永久驻留。调阈值没用(调多小都被「一轮耗时」从上方封住),得加**时机**。
      
      改动:runHostSafe 每轮开跑前再回收一次(周期态)。
        · 判据只用年龄闸,**不混 processStartedAt** —— 长驻进程带上它 min() 恒取它,
          对「本进程启动之后才产生的孤儿锁」就是 no-op,等于没加。
        · 只碰 `sync:<host>:` 前缀。 不能扩到全部:`full:` 是手工补摄(建国门实测 import 段
          2h25m,离 3h 阈值只剩 35 分钟),`patient_refresh:` 是客服点「刷新」的前台请求。
          周期回收每轮都跑,把它们纳入 = 把 2026-08-30 那个「误杀正在跑的活儿」换个入口放回来。
        · 走到该行说明 runningHosts 里没有本 host(本进程没在跑它),那么一把躺满 3 小时的
          `sync:<host>:` 锁必定是死进程留下的。
        · 自愈时延上限 = REAP_MIN_AGE_MS + 一个 cron 间隔,不再需要人工重启。
      
       顺带修掉实现里一个自己踩的坑:`reapStaleRunningLocks(processStartedAt = PROCESS_STARTED_AT)`
         的**默认参数**对显式传入的 `undefined` 同样生效 → 周期态调用被悄悄补成启动态,
         判据变回 min(...),对长驻进程恒 no-op、还不报错。改成无默认值,启动态由调用方显式传。
      
      测试:sync-lock-reap.spec 补 6 条。**原有 5 条为什么没拦住:**
        基准 `PROCESS_STARTED_AT = new Date('2026-07-23...')` 是个**过去**的固定日期,
        而 ageCutoff = 真实now − 3h 永远晚于它 ⇒ min() 恒取 processStartedAt,
        **REAP_MIN_AGE_MS 一行都没被执行到**;而「where 用 lt」那条还直接断言
        `lt === PROCESS_STARTED_AT`,把加年龄闸**之前**的行为钉成契约 —— 生产已坏、5 条全绿。
        新用例用 fake timers:复现态钉 2026 时刻(锁太年轻收不掉,原样钉住事故行为),
        周期态钉 **2099**(必须远期 —— 用 2026 基准时真实模块加载时刻更晚,min() 两边选出
        同一个值,「有没有混进 processStartedAt」在判据上不可观测;实测那样写 11 条全绿)。
        有牙验证:把周期态改回 min(...) → 3 条立刻失败。上面那个默认参数的坑就是它咬出来的。
      
      两处既有 spec 跟着改(都是真实行为变化,不是测试写错):
        · scheduler-kill-switch:调用点带参数了,写死字面量会假失败;改成锁「闸在第一次回收之前」。
          周期态那处是传递性受闸的(闸命中则 cron 不注册,runHostSafe 无从被调用)。
        · scheduler-reentrancy-guard:runHostSafe 进 runOne 前多了一次 await,
          断言前要把微任务队列放干。
      
      ️ 未纳入本次(另开):
        · importPatient(cold-import.service.ts:1346)建同一把 pull 锁却**没有 try/finally**,
          且没有 P2002 分支 —— 客服在患者详情页点「刷新」一次异常就永久卡死,比本次更易触发。
          代码里「daily cron 完全不受单患者刷新影响 ✓」那句注释是错的:cursor 隔离了,锁没有。
        · sync-incremental.cli 是唯一不带 disableSchedulersForCli 的入口(今天兼作人工解卡逃生口)。
        · 备份失败只写 backup.log 无告警通道(测试机已静默失败 4 天)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  3. 03 Sep, 2026 9 commits
    • fix(deploy): 自更新后用新版重跑 —— 光包 main() 不够 · 2d7883d3
      实测踩到:服务器上脚本是旧版,运行时 git pull 拉到了新版,但**本次进程跑的仍是内存里
      已载入的旧版函数** —— 文件已经是 curl -sL 了,报错文案却还是旧版的,排查了一轮才明白。
      
      包在 main() 里解决的是「bash 边读边执行、pull 后按新文件字节偏移读后半段」的错位问题
      (deploy-prod.sh 头部注释记的那个);但它防不了「这一轮跑的是旧逻辑」——
      函数在调用前就已整体载入内存,pull 换掉磁盘文件不影响已载入的定义。
      
      修法:pull 前后比对本文件的 blob hash,变了就 exec bash "$0" --no-pull 用新版重跑,
      --no-pull 同时防无限递归。
      luoqi committed
    • fix(deploy): 文档站健康检查跟随重定向 —— 根路径是 307 → /docs · 2b38aa08
      首次实跑 deploy-docs.sh 就踩到:servers 校验通过了,但第二项判据把健康的站报成故障。
      fumadocs 的根路径 / 是 307 重定向到 /docs(正常行为),直接判 200 必然失败。
      改用 curl -sL 跟随重定向后判最终状态(实测跟随后是 200)。
      
      判据写窄比写宽更危险:这次是误报(能立刻发现),反过来若判据恒真则会放过真故障。
      luoqi committed
    • feat(deploy): 文档站单独部署脚本 —— 固化 env-file 并硬校验 OpenAPI Server URL · dc207003
      【解决的问题】docs 镜像里 OpenAPI 的「Server URL」来自 build arg DOCS_API_URL:
        ${DOCS_API_URL:-${NEXT_PUBLIC_API_BASE_URL:-http://localhost:3101}}
      而 NEXT_PUBLIC_API_BASE_URL 在 apps/pac-web/.env 里,不在 pac-service/.env。
      deploy-prod.sh 本来就传两个 --env-file,但它**不含 pac-docs**,所以改文档得手工重建;
      手敲时少传一个 env-file 就回落成 localhost:3101 —— 而且**构建照样成功、不报任何错**,
      只有点开 API 参考页才看得出来,对接方照着填会打不通。
      
      2026-09-03 实际踩到:之前几次手工重建 docs 都只传了 pac-service/.env,
      线上 API 参考页的 Server URL 一直是 localhost:3101,挂了几天没人发现,
      直到对接方看文档时提出来。
      
      【为什么不并进 deploy-prod.sh(先前的想法,量化后否掉)】
      pac-docs 的 Dockerfile 是 `COPY . .`(整个仓库),任何代码改动都会让它的 build 缓存失效、
      Next.js SSG 全量重跑。测试服实测:
        什么都没改       →   3 秒(17/17 层缓存命中)
        改了后端代码一行 → 111 秒(COPY 之后全失效)
      而绝大多数部署都是改后端、文档没动 —— 并进主流程等于每次白花约 2 分钟买"不会忘"。
      真正的问题不是"要不要自动跑",是"手敲参数容易写错",所以治后者。
      
      【脚本做什么】
        - compose 组装与 deploy-prod.sh 完全一致(含 COMPOSE_MANAGED override + 两个 env-file)
        - 构建**前**先解析并打印将注入的地址,解析不到 / 是 localhost 直接 die(带修复提示)
        - 显式 build → force-recreate(同 deploy-prod.sh 不信 compose 重建判定的理由)
        - 构建**后**硬校验:读容器内 openapi/pac.json 的 servers[0].url,与期望值不等即失败
          (而不是只看构建日志 —— 日志对了不代表镜像里对)
        - 再验文档站 200
      
      deploy/README.md 登记脚本 + 说明为何分开、为何不要手敲 compose。
      luoqi committed
    • fix(transforms): strip_html 处理双重编码 —— 剥一轮不够 · 5431fc3c
      真实数据打脸:首轮导入 2552 条回访后,有 1 条落库仍是 "<p>阿斯蒂芬撒的发生</p>"。
      查源库(customer_return_visit id=266151)发现是**双重编码**:
      
        <p><span style="color:#000000">…<p>阿斯蒂芬撒的发生</p>…</span></p>
        外层是真标签,内层是被转义的标签。
      
      原实现「剥标签 → 还原实体」跑一轮:外层剥掉后,<p> 被还原成字面的 <p> 留在结果里。
      算子逻辑本身没错(单次剥离),但真实数据证明必须处理这种形态 —— 宿主富文本编辑器
      把粘贴进来的 HTML 又转义了一层。
      
      改成**有界两轮**:还原实体后若又出现标签则再剥一轮,最多 2 轮。
      不做无界循环 —— 构造输入(每轮都能再生标签,如 &amp;lt;p&amp;gt;)会把摄入卡死,
      两轮后仍有残留是可接受的,关键是必然终止。第 2 轮只在确实又露出标签时才跑,
      绝大多数行一轮即净,无谓开销为零。
      
      回归测试 2 项:线上那条原文按原样断言;畸形嵌套输入断言必然返回(不卡死)。
      luoqi committed
    • feat(friday): 摄入客户回访 —— customer_return_visit → PatientReturnVisit · dc591fec
      FRIDAY 此前没接回访(patient_return_visit 一直空)。全库扫 58 张相关表,
      只有 customer.customer_return_visit 有实际数据(2552 行 / 1122 患者 / 27 诊所 / 77 客服),
      其余是空表(arrail-medical-server.return_visit 0 行、tenant_apply.return_visit* 全 0)、
      配置字典、或「召回」另一套(customer_recall_*,量极小)。
      
      【源表是「统一任务表 + 分类型记录表」结构】
        customer_task(task_type: 1咨询 / 2回访 / 4预约备注 / 10…)
          ├─ 1 → customer_consult       (咨询,PAC 早已单独摄入)
          └─ 2 → customer_return_visit  (回访,本次)
      实测零交叉:consult 的 64 条 task_id 全指向 type=1,回访 2552 条全指向 type=2 ——
      所以这不是重复摄入咨询。关联 rv.task_id = t.id 实测 2552/2552 全中、零空值
      (反向的 t.return_visit_id 全空,是电子病历迁移遗留列,不可用)。
      
      任务头不单独摄入,task_date/task_status 由宿主 inline 进回访行 ——
      与 customer_treat_plan_item inline 计划头 organization_id/plan_name 同形态。
      
      【task_date 含未来排程,不能丢】界面「设回访」就是设未来日期(截图实证)。
      PAC 靠它区分「已发生」与「排了没做」:详情页倒序展示、召回话术只把已发生的算作
      "联系过";而客服名册判「在岗」必须用 sourceCreatedAt 而非 task_date,正因后者含未来
      (jvs-dw 实测最远 2033)。测试库当前无未来样本,但字段语义按含未来实现。
      
      【枚举翻中文,与 jvs-dw 逐字对齐】jvs-dw 的 DW 侧本就是 *_name 中文列,FRIDAY 是数字码。
      同一张 PatientReturnVisit 表里两个宿主的值必须一致,否则前端展示与按类型筛选会分叉。
      注意 return_visit_type 官方注释只写 1-3,实测存在 4/5(311 条,占 12%),不能落 _default 丢掉:
        4 → 100% 带治疗项,取值含「取消预约回访」「自定义」→ 自定义/事件驱动回访
        5 → 100% 带治疗项且全是诊断名(残根/龋齿/根尖周炎),task_director 全为 System
             → 系统按诊断自动生成的召回
      
      【新增 strip_html 算子】宿主回访内容用富文本编辑器录入,实测 794/2552(31%)带
      <p>/<ol>/<li>。PAC 侧该字段是纯文本语义(详情页直接展示 + 喂召回话术 LLM),
      带标签会原样显示成 "<p>xxx</p>" 并污染 LLM 上下文,故在摄入层剥掉而非留给每个消费方。
      口径:块级标签转换行保住段落边界、实体还原(& 放最后避免二次解码)、全空 → null。
      
      【canonical-fact-layer 闸 4 加特例】该闸按字段名校验 enum_mapping 目标 ∈ canonical-codes,
      但 closedSets.status 装的是 PACTreatmentStatuses(治疗状态),与回访的「已回访/未回访」
      同名不同义。回访三列是展示用自由文本(不进 fact、不参与召回),口径是与 jvs-dw 中文对齐,
      故整个 patient_return_visit 跳过该闸(同 appointment.status 已有的特例)。
      
      宿主多给的列(suggested_return_person / actual_return_person / actual_return_time /
      customer_status / is_first)原样带出存进 raw_payload,PAC 当前不映射 —— 将来要用不必再找宿主改。
      
      测试 29 项;契约文档新增第 7 节(source 数 9 → 10);export.sh 补导出语句。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(reparse): 分批粒度夹住 PG bind 上限 —— dryRun 修好了,实跑那侧的墙还在 · b0d45e60
      2026-08-28 那次修的是 dryRun 分支(整份 patientIds 塞进 `IN` → 3.2 万清单直接炸
      `too many bind variables ... expected maximum of 32767, received 62899`)。
      但只修了一半:**实跑分支的旋钮没有上限**
      
        const BATCH = Math.max(1, Number(process.env.PAC_REPARSE_BATCH) || 3000);
                      ↑ 只夹下限
      
      `PAC_REPARSE_BATCH=50000`(有人想"跑快点")就会让同一堵墙从实跑那侧长回来 ——
      每个 patientId 占一个 bind,墙在 32,767。
      
      改动:抽出 `reparseBatchSize()`,夹在 `PG_MAX_BIND_VARS - 100`(留 where 里
      hostId/subjectType 等其余 bind 的余量),**dryRun 与实跑共用同一个值** ——
      两侧各写一份是这个 bug 反复长回来的原因。
      
      测试(tests/reparse-dry-run-bind-limit.spec.ts,3 条):
        · 假 prisma **照 PG 的规矩发脾气**:单条 count 的 bind 超限就抛同样的错。
          不这么做的话"不分批"也能测过,是假绿。
        · 62,899 患者(事故量级)dry-run 不抛;分片不重不漏(累加 = 62,899);
          并断言 count 真发出去过(>1 次)—— 防"被判为非 transform 产出静默跳过"的假绿。
        · 不给清单时走全量 count(where 里没有 IN,不占 bind)。
        ·  PAC_REPARSE_BATCH=100000 也不许把墙放回来 ← 本次新增的那半。
      
      有牙验证:把夹子退回 `Math.max(1, raw)`,第 3 条立刻以生产原话失败
      (`received 50002`),不是写完就绿的摆设。
      
      出处:这份改动originally 躺在 worktree claude/peaceful-nightingale-940534 里未提交
      (fixtures + spec 都建好了但没落),清理分支时捞出来落到 main 当前的代码形态上。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(clinic-names): 取现名而不是 any() 随手抓的旧名 —— 80% 的诊所改过名 · 3a0b7c63
      `refresh-clinic-names` 用 `any(organization_name)` 从源表派生诊所名。ClickHouse 的
      `any()` 是**任取一个**,不是"取唯一的那个" —— 而诊所会改名,源表里同一个 id 存着历次名字。
      
      2026-09-03 生产 DW 实测(fact_emr_treatment_out,66 家):
      
        273393edd04e4aa4afe9347bb8a2da21
          「正大诊所」               91,053 行   2016-07 ~ 2022-08   ← any() 抓到的
          「花旗医院」                3,096 行   2023-06 ~ 2023-09
          「瑞尔齿科上海花旗诊所」   42,646 行   2023-09 ~ 至今      ← 现名(宿主花名册也是这个)
      
        · **66 家里 53 家(80%)有多个名字**,不是个案
        · 旧名用了六七年,**行数往往碾压现名** → 换成"取最高频"一样错
        · c59bfc52…(华贸)有 6 个名字,其中两个带前导 `\t`
      
      按最高频/任取都会把停用多年的旧名当成现名下发给前端。改为按 time_field 取最新:
      
        argMax(trimBoth(name), ifNull(toString(time), ''))
      
      `trimBoth` 同时用在取值和 notEmpty 判定上 —— 只用在取值上的话,纯 `\t` 的行仍会被
      当作有效名参与比较。
      
      改动:
        · 新增 clinic-directory.ts:buildClinicNameQuery / pickLatestNames 两个纯函数
          (CLI 底部是 `void main()`,逻辑留在里面没法单测,故抽出)
        · 文件源分支同口径 —— 原来是"遍历行、后者覆盖前者",行序即文件顺序,等于随机取名
        · manifest schema 加 time_field;jvs-dw 配 updated_date,friday 配 updated_gmt_at
        · 缺 time_field 时退化成字典序最大(只保证确定性,不保证是现名)并告警,不再 any()
      
      生产 DW 实跑新 SQL 验证(只读,未写库):66 家全部派生成功,
        273393ed… → 瑞尔齿科上海花旗诊所 
        c18cadf2… → 江苏瑞泰通善口腔学前街医院 
        仍带空白/空名的:0
      
      ️ 生产 host.clinic_names 目前是空的({}),本次**不含**任何生产写入 ——
         要不要跑这个 CLI 单独决策。另注:前端真实显示的诊所名以宿主换票传的
         dictionary.clinics 为准(auth.controller 里它覆盖服务端派生值),
         服务端这份只是打底,不能据此断言前端现在显示的是 GUID。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  4. 02 Sep, 2026 3 commits
    • fix(report): 回访对账的表名写错(单数)+ 更正"测试机有逃生口"这个错误论断 · 8d12f2b9
      查上一条修复时,拿测试机做对照发现两件事:
      
      ① **`patient_return_visit` 少了个 s**(daily-health-report.service.ts:256)。
         真实表名是 `patient_return_visits`(schema.prisma:455 的 @@map)。
         报错被 pacPatientCount 的 try/catch 吞成 WARN + 返回 null → 日报照发,
         只是「回访」那行长期没有 PAC 侧计数,静默缺了一项对账。
         生产上看不到这个 WARN,因为它的日报根本没跑到这一步就先被连接池超时打死了 ——
         一个 bug 把另一个 bug 遮住了。
      
      ② **「测试服 URL 里显式 connection_limit=30,所以不受影响」是错的**,而这句话被
         prisma.service.ts 的注释、测试文件的注释一路传下来,也是我判断"测试机不用验"的依据。
         实测(`docker inspect`)两台容器里的 DATABASE_URL 都是
         `postgresql://…@postgres:5432/pac?schema=public` —— **没有 connection_limit**:
         compose 的 environment: 段整条覆盖了 .env 里的值(.env.example 早就写了这条,只是没人联想到)。
         佐证:测试机 09-01 的健康日报也报了同一句 `connection limit: 5`。
         → 测试机跟生产是同一个池、同一个 bug,**这个修复在测试机验得出来**。
      
      判断逃生口生不生效只能看容器实际环境变量,不能看 .env 文件;三处注释都已改口径。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(db): 连接池把常驻服务自己的并发旋钮也算进来 —— 每日健康日报连续三天超时的根因 · db15fe0e
      现象:企微收不到每日健康日报。09-01 / 09-02 两天都在 **09:00:10** 失败
      (= cron 09:00:00 + 10s pool_timeout),报错是拿不到连接而不是推送失败:
      
        Invalid `prisma.followupPlan.count()` invocation:
        Timed out fetching a new connection from the connection pool
        (Current connection pool timeout: 10, connection limit: 5)
      
      根因两条叠加:
      
      ① **常驻服务的并发旋钮没被算进池。** withCohortDerivedPool 只认 PAC_DB_CONCURRENCY /
         PAC_COHORT_CONCURRENCY(都是 CLI 的旋钮),而 PAC_RECALL_SUBSCENARIO_CONCURRENCY=4
         (2026-08-30 在生产开启,场景段 ×2.31)是**常驻进程**的旋钮 —— 批次因此长期占住 4 条连接,
         池却还是 Prisma 默认。函数开头那句「一调并发就得手动调池,这里把两者联动」说的就是这个病,
         只是当时只联动了 CLI 那半边。
      
      ② **生产的默认池只有 5,不是注释里写的 9。** Prisma 默认池按**物理核**×2+1 算:
         生产机 nproc=4 但 `Core(s) per socket`=2 / `Thread(s) per core`=2 → 物理核 2 → 池 = 5。
         原注释按逻辑核算成 9,低估了一倍,实现和测试文件里都跟着错。
      
      5 条连接里批次占 4 条,日报要并发发 6 个 count → 排队 → 10 秒超时。
      同一根因还打掉过一条 plan upsert(`Unable to start a transaction in the given time`,
      09-01 21:47,549,106 个命中患者里 1 个)。
      
      改动:把 PAC_RECALL_SUBSCENARIO_CONCURRENCY 并入取最大 → 生产池 4×6+5 = 29。
      生产 RDS max_connections=820(实测,当时全库仅 23 条在用),29 条毫无压力。
      
      ️ 09:00 撞在 08:15 那轮的召回场景段(08:44~09:35)中间是**天天必撞**,不是偶发;
         这里选择扩池而不是挪 cron —— 挪 cron 只躲开这一个碰撞,扩池同时修掉 API/plan 侧的抢连接。
      
      顺带把三处写错或缺失的口径补上:实现注释、.env.example(DATABASE_URL 与并发旋钮的耦合、
      显式 connection_limit 会让自动放大整个失效)、以及 conc 读取处的反向指引。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  5. 31 Aug, 2026 6 commits
    • feat(sync): 预约事实补上医生姓名 —— DW 一直在给,只是没映射 · b9564716
      预约事实历来只有 doctor_id 没有姓名(前端/话术拿到裸 ID),而 DW 的
      fact_appointment_out 每一行都带 resource_name。列名叫「排班资源名」有误导性,
      先把它到底是不是医生姓名核实清楚:
      
        · 椅位另有列 appo_chair(仅 3 个取值),资源另有 resource_id —— 不是它们
        · resource_name 与 appo_doc_id 的绑定比与 resource_id 更紧
          (本地 (doc_id,name) 140 组 < (resource_id,name) 151 组)
        · 已到诊预约按 (患者,日期) 对当天病历 doctor_name:生产近 90 天
          99,170/101,192 = 98.0% 命中;144,148 条明细里「id 对上而名字不对」与
          「名字对上而 id 不对」各 0 条 → 就是 appo_doc_id 这个人的姓名
        · create_name / appo_handler 是呼叫中心约号人(值形如 400-叶玉娇),
          director_name 是另一个角色 —— 都排除
      
      口径:doctor_name = **约号时约的那位医生**,不是实际接诊医生(改派时不同,
      实际接诊仍以病历为准)。两个口径别混。
      
      少数排班资源不是人而是房间/服务/台席("预约"/"学前街手术室"/"正畸咨询"),
      生产近 180 天 9,838/1,047,901 = 0.94%,不拦的话时间轴每 106 条就出现一次
      「预约医生」,详情页「主治医生」的最高频兜底还可能解析成「学前街手术室」。
      故加 AppointmentParser.isPersonResource:
      
        · 九词词表从那份实测清单反推,覆盖全部 19 个取值
        · 误伤核验:生产 1,314 个真实医生名过规则,命中 1 个(「公共诊室」,
          本身就是漏进病历的房间名)—— 真人零误伤
        · 独立判据复核(不用词表):过闸的 428 个资源里 426 个的 id 在临床记录里
          真的当过医生,漏网 105 行 = 0.010%
        · 被拦下的原值不丢,另存 content.resource_name;
          `WHERE resource_name IS NOT NULL AND doctor_name IS NULL` 即可审计
      
      本地全量 reparse 验证(241,228 个版本 / 15 分钟):最新版 240,517 条,
      resource_name 100%、doctor_name 99.83%,拦下 413 条全是非人资源;
      逐行对源 transaction,(doctor_id, resource_name) 240,517/240,517 成对回溯。
      
      前端零改动 —— facts-timeline 的预约分支本来就把医生拼进 note,只是取不到值。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(sync): 补 DW 迟到的证据链、分表超时率、丢失边界 · d6a6621b
      新增三节:
      · 4b 证据链 —— 那条 47.4h 退费,中间 17 轮增量 + **2 次全表重读**(491 万/426 万行)
        都没拉到,第 20 次才出现。19 次查询看不见 = DW 当时确实没有,不是我们没去问。
        且最后那轮窗口只剩 58 分钟余量,险过。
        另记游标真实语义:cursor_after = run_start(墙钟),不是 max(updated_date);
        原注释「run_start 之后 DW 任何写入下次都能捞回」有漏洞——过滤的是 updated_date。
      · 4c 分表超时率 —— refund 18.7%>12h、recommendation 18.9%>6h、appointment 仅 2.3%。
        中位数 2.39h 守住了承诺,尾巴没守住;分表差异极大 → DW 内部不同表走不同 ETL 链路。
        recommendation 是召回第二信号源,18.9% 超 6h,直接影响时效。
      · 4d 时效与丢失边界是同一个数(48h)——比 T+2 更晚的根本进不来,永远不可知。
        且 full: 全量补摄是**人工临时跑**没有定时任务(最近一次 08-26),漏掉的不会自动修复。
        记录两个未采纳兜底:定期全量补摄 / 跟 DW 对账。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(sync): 增量回看窗分析 —— 不能缩,根因是游标跟的量与到达顺序无关 · e503e8ae
      生产只读实测(7 天 / 281,760 条增量记录):
        滞后 p50 2.39h / p99.9 15.39h / max 47.43h;>24h 31 条、>48h 0 条
        按表拆:refund max 47.4h(>24h 有 27 条)、临床表 max 15.5h(>24h 全 0)
      
      根因:游标跟 updated_date(源 HIS 改动时刻,≈事件时间),而数据到达取决于 DW 自己的
      ETL —— 两者无关。所以必然有"updated_date 比游标旧、此刻才到达"的行,游标追不上。
      退费那几条是铁证:源系统 13:05 建、13:13 改完,PAC 47 小时后才看到。
      
       不能缩:缩 24h 丢 31 条、36h 丢 19 条,且游标推过去是**永久**漏拉。
         且 max=47.43h 恰好顶在 48h 边界、>48h 为 0 —— 这是**截断的指纹**,
         真实尾巴可能更长。本测量方向单边:能证"够用"不能证"不够"。
      
      根治不在我们这边:DW 十张表一个入仓时间字段都没有(rq 是业务日期已排除)。
      若 DW 加一列 ETL 写入的时间戳,游标即可在到达顺序上单调 → 漏拉结构上不可能,
      fetched 从 24.8 万降到约 1.6 万,摄入 31m → 预计 5~10m。
      
       另记一个取样陷阱:第一版按 received_at 时间窗取样,混进 5 次 full: 全量补摄的
      历史数据,得出"滞后 5 年"的荒谬结果。既有增量又有补摄的系统,取样必须按事件来源限定。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  6. 30 Aug, 2026 13 commits
    • merge: 预取消 O(患者数) 项 + 三查询并行 → main · 48ca36cb
      测试机验证(87,885 命中患者 / 44 chunk):
        预取 288,003ms / 247,250ms(旧基线) → 183,810ms(新)  −26~31%
        命中患者 87,885,在 87,849~87,853 漂移带内 —— 口径没变
      本地(19,476 患者 / 10 chunk):预取 8,383 → 4,899ms;plan_reasons 行级 diff=0
      
      推算生产:预取 20m09s → 省 5~6 分钟,整轮 2h02m → 约 1h56m,回到 2 小时线内。
      luoqi committed
    • perf(plan): 预取消掉一个 O(患者数) 项 + 三条查询并行 · 66c54b46
      ① 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>
      luoqi committed
    • docs(gap): 生产实测集合式全量更慢,已回退 —— 子集掩盖了性能的规模拐点 · dc760962
      生产 10 万患者子集对拍 11/11 零差异 → 开启 → 20:15 轮第 1 批判据失败 → 回退。
        missing_tooth 8m13s → 30m42s(慢 3.7×)
        endo_no_rct   8m12s → 18m21s(慢 2.2×)
        第 1 批墙钟   17m03s → 30m42s(+80%)
      命中数仍在漂移带内 —— **正确性没问题,是性能不达标**。
      临时文件零增长、swap 为 0 —— 也不是内存问题(这次没再编错误归因)。
      
      归因:集合式代价跟**患者域**走(gap_scope 要预聚合全域),legacy 跟**候选行数**走。
      规模一变结论就翻转:30K/585K/10万子集 都快 1.5×,113 万全量慢 3.7×。
      
       教训:方案里写过「子集零差异 ≠ 全量零差异」,但只想着正确性覆盖。
         真正被掩盖的是**性能**,而且方向都反了。子集测正确性有效,测性能无效 ——
         除非能先证明代价与规模线性,而集合式恰恰不是。
      
      生产维持 legacy + 并发4(场景段 50m25s / 整轮 1h17m)。代码留 main,默认关闭。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • test(gap): 对拍差分支持 --subset=N —— 让生产真实数据上的零差异证据变得便宜 · 9a042f45
      生产 113 万患者全量差分要几小时,还会跟 2 小时批次抢 RDS;收窄到几十万后
      几分钟就能在**生产真实数据**上拿到证据。抽样偏向"有 active 诊断/建议信号"的患者,
      否则大多数抽中的人两版都返回空,验了个寂寞。
      
      ️ 收窄降低的是**覆盖**不是可信度:差异一旦出现仍是真差异;但「子集零差异」
         ≠「全量零差异」,所以报告行里带上患者域,逼自己写清跑的是多少人。
      
      本地 3000 位子集实测:11/11 零差异。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • perf(gap): resolvedTeeth 集合式形态 + 对拍工具(默认 legacy,生产不启用) · 1bde4764
      把 buildGapCore 的 resolvedTeethSql 从**逐 (患者×信号) 行相关子查询**改成
      **集合式预聚合 + 反连接**。核心恒等式 ∃x∈G: t(x) ⋛ a ⟺ max{t(x)} ⋛ a,
      分组 G=(患者,牙位)。13 个分支对 sig 的相关性只有三类:
      时间门(10)/ 病历号等值(1)/ 无相关(2),外加「建议优先」一条的 sig.type 标量谓词。
      
      ️ 集合式是**独立重写一份**,刻意不与 legacy 共用片段 —— 共用则重构写错的地方两边
         一起错、对拍互相抵消。等价性靠 verify-gap-equivalence 逐行差分来证。
      全口码(K05/K07)不进集合式:其 lateral PG 本来就会摘掉(零收益),硬套还慢 2~3 倍。
      
      **默认 legacy**,靠 PAC_GAP_VARIANT=setbased 显式开启。生产不设该变量 → 行为零变化。
      
      验证(测试机 585K 患者 / 本地 30K):
        · SQL 层 11/11 子场景逐 (患者×信号×牙位) **零差异**,行数逐个相同
        · 画像消费方 2000 位患者零差异,单患者 p95 47→27ms 不劣化
        · 端到端 plan_reasons 行级 diff=0;测试机 32 万条差异仅时间漂移、**零删除**
        · 交互路径(详情页刷新)200 位 × 11 子场景零差异,一次刷新 89→75ms
        · 13 条分支源行全部非空 —— 零差异不是空转
      收益(测试机相邻两轮,并发4):场景段 794s → 545s(×1.46)。**生产未验**。
      
      新增 src/cli/verify-gap-equivalence.cli.ts:两版同一 REPEATABLE READ 快照双向
      EXCEPT ALL 差分;--self 自对拍先证工具可信;--persona/--single/--conc 覆盖
      画像、交互、并发标定四条路径。
      新增 tests/gap-setbased-parity.spec.ts:结构对拍,守「两种形态的分支集合不许走散」
      (加分支只改一边 = 静默错召,tsc 和现有 spec 都发现不了)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(gap): 精简误召个案表 —— 只留结论和证据,删推理过程 · 61ba24de
      删掉四类冗余:
      · 「该计划共 N 条理由,另 X 条判得对」的旁注(#8/#10/#14/#15/#17)
      · 算法机制的重复解释(#9「算法逻辑没错,是事实没进结构化数据」等)
      · 「该修复上线晚于本单建单,故当时未生效」这类时序补注(#19/#22)
      · 举证细节的收尾展开(#8 总院回访那段收成一句)
      
      个案表是给人看结论的,不是复述推理的地方。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • perf(recall): 启用子场景并发 —— 生产全量 ×2.31,2 小时窗口进得去 · 0c391bd1
      生产全量实测(113 万患者 / 54.8 万命中,同一台 RDS,相邻两轮):
        串行(=1): 场景段 7,200,096ms(2h00m)  整轮 8,654,105ms(2h24m)
        并发(=4): 场景段 3,115,928ms(51m56s) 整轮 4,806,869ms(1h20m)
        → 场景段 ×2.31,整轮 ×1.80,省 64 分钟
      先在生产一个 39,148 患者的诊所交替标定(×2.18),再由全量复现(×2.31)。
      
      🔴 推翻原有的「 别指望靠这个旋钮提速」结论 —— 它让生产一年多没开这根杠杆。
      原依据是「并发3=24.1分 vs 串行23.3分」,两处都错:
      ① 3% 差距落在 ±25% 的环境噪音里(用「两边跑同一条 SQL」的对照组量过),那次比较
         什么也没证明;
      ② 机制归因也错:真瓶颈是**延迟**(逐次索引探查等 page,CPU 与磁盘都闲着)不是吞吐,
         所以并发 2 就超线性(生产 ×1.50 / 测试机 ×2.88)。若真是吞吐受限,墙钟应约等于
         各查询耗时之和;实测墙钟只有求和的 43%。
      
      同时把「集合式重写是唯一出路」那段改成现状:并发已解决窗口问题,集合式转为可选。
      .env.example 补文档,含「判据只能看阶段墙钟、不能看单条 sql=」的读数陷阱。
      
      本提交只改注释与 .env.example,不改任何运行逻辑(生产开关已于 2026-08-30 09:16 生效)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: CLI 启动误杀正在跑的同步锁 → main · 6f0915f7
      生产事故修复(2026-08-30):在 pac-service 容器里 docker exec 跑任何 CLI,
      都会 createApplicationContext(AppModule) → 跑一遍 SyncIncrementalScheduler.onModuleInit
      → reapStaleRunningLocks 把 service 里**正在跑**的那轮同步当僵尸锁清掉。
      实测 08:17:11 起 recompute-plans → 08:17:13 正常跑着的 08:15 那轮被标 failed
      (前 19 轮全 success)。数据未丢(cursor_after=null,下轮同水位 catchup),但白丢一轮。
      
      两道防线:年龄阈值 REAP_MIN_AGE_MS=3h + 16 个 CLI 建上下文前设 PAC_SCHEDULER_DISABLED=1。
      不带任何开关,上线即生效。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): CLI 启动会清掉正在跑的同步锁 —— 生产实测夭折一轮增量 · 824554a9
      🔴 事故(2026-08-30 生产):在 pac-service 容器里 docker exec 跑 recompute-plans,
         08:17:11 起进程 → 08:17:13 正在跑的 08:15 那轮同步被标 failed。前 19 轮全 success,
         只死了撞上的这一轮。数据未丢(cursor_after=null,下轮同水位 catchup),但白丢一轮。
      
      根因:每个 CLI 都 createApplicationContext(AppModule) → 跑一遍
      SyncIncrementalScheduler.onModuleInit → reapStaleRunningLocks。
      原判据是「startedAt < 本进程启动 = 僵尸锁」,注释里写着"两者的进程都不可能比本进程
      启动得更早还活着"—— 但**长驻的 pac-service 恰恰就是那个更早启动还活着的进程**。
      理由写反了方向,而且只在"回收逻辑跑在长驻服务里"时才成立。
      
      两道防线:
      ① scheduler 加年龄阈值 REAP_MIN_AGE_MS=3h —— 真僵尸锁必然躺很久,正在跑的不会。
         用年龄区分,不靠猜进程身份。(生产单轮摄入实测 28~52 分钟)
      ② 新增 src/cli/bootstrap-flags.ts,16 个 CLI 在建上下文**之前**设
         PAC_SCHEDULER_DISABLED=1(该总闸本就会跳过回收,只是没人用)。
         豁免 sync-incremental.cli(它就是要触发同步),由 ① 兜底。
      
      回归测试 tests/cli-scheduler-guard.spec.ts:遍历所有会建上下文的 CLI,
      断言调用存在**且位置早于** createApplicationContext;并锁住年龄阈值 ≥2h。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed