1. 30 Aug, 2026 25 commits
    • 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
    • fix(sync): CLI 启动会清掉正在跑的同步锁 —— 生产实测夭折一轮增量 · 8d6664c8
      🔴 事故(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
    • test(gap): 对拍工具补批量并发标定(--conc=N --subset=M) · 0c76093a
      只读:每条子场景 SQL 外套 count(*),不写库。判据用**墙钟**,不看各查询耗时之和
      (并发下单条会被争抢拉长,和不可比 —— 这正是 2026-08-29 那次误判的成因之一)。
      
      背景:代码注释里「 别指望靠并发提速」的依据是「并发3=24.1分 vs 串行23.3分」,
      3% 的差距落在 ±25% 的环境噪音里,什么也没证明,却成了生产不开并发的理由。
      今晚测试机 C 轮(legacy,并发4)各子场景耗时之和 1,736s 而场景段墙钟仅 794s
      —— 墙钟远小于求和 = 并发确实在重叠,与「瓶颈是共享I/O、并行无用」的旧归因矛盾。
      
      本地(30K,8000 患者子集,两对交替):
        conc=4  4.8s / 5.2s
        conc=1 10.5s / 10.0s     → 稳定 ×2.0,可复现
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • test(gap): 对拍工具补交互路径(--single=N)—— 详情页「刷新」直接面向用户 · 8a58bb72
      plan.controller recomputeForPatient 是 HTTP 端点,走的就是这套召回 SQL 的
      单患者路径(scope.patientId)。批量慢是运维问题,这条慢是用户当场感受得到的问题,
      之前只测了批量和画像,漏了它。
      
      按「一位患者跑完 11 个子场景」= 一次刷新的真实代价来计时,同时逐 signal×tooth 比结果。
      本地(30K):零差异;单查询 p95 26→17ms;一次刷新 103→71ms。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(gap): 纠正 B 轮误判 + 落测试机最终结论 · 41472220
       我上一版据 B 轮单点写下「集合式内存打穿、溢盘、拖垮全口查询」——全错。
         D 轮复现不出来(545s,四轮最快);实测每轮临时文件 C=4 个 / D=4 个、增量 62MB,
         根本没溢盘;187GB 是统计累计值不是今晚产生的。B 轮整段压在 02:00 的
         stale-scan cron 上,是外部负载。那套「内存打穿」的因果链是照着一个数字编的。
      
      真实结论(C vs D,相邻两轮、两个同SQL全口对照组给出 ±25% 噪音基准):
        场景段 794s → 545s(−31%)   整轮 1,181s → 923s(−22%)
        超噪音的收益集中在 impacted ×2.25 / caries ×2.09 / hard ×1.59 / endo ×1.43
      正确性三道门全过;端到端差异全是新增(时间漂移),零删除。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • test(gap): 对拍工具补画像消费方(--persona=N) · d091ed21
      画像是**逐患者**调用(全量 54.7 万次),SQL 形态与召回不同(scope 恒 1 行),
      单次开销会被放大 54.7 万倍 —— 必须单独验,而且要比耗时分布不只比结果。
      只读不写库:直接调 selectForPatient 两次比 gap 列表。
      
      selectForPatient 加可选 variant 入参(只给对拍用;生产路径不传,走环境开关)。
      
      本地实测(2000 位有 active 信号的患者):
        零差异(其中 1441 位有 gap)
        耗时/患者 legacy p50=7ms p95=18ms 合计=16.1s
                  setbased p50=6ms p95=16ms 合计=13.9s  ← 不劣化
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • perf(gap): resolvedTeeth 集合式形态 + 对拍工具(默认仍走 legacy) · 8930c3b6
      形态:cand → scope → resolved 预聚合 → 牙位反连接。核心恒等式
        ∃x∈G: t(x) ⋛ a  ⟺  max{t(x)} ⋛ a,分组 G=(患者,牙位)。
      13 个分支对 sig 的相关性只有三类:时间门(10)/ 病历号等值(1)/ 无相关(2),
      另有「建议优先」一条多个 sig.type 标量谓词(ndx 标志)。
      
      ️ 集合式是**独立重写一份**,刻意不与 legacy 共用片段 —— 共用则重构写错的地方
         两边一起错、对拍互相抵消。等价性靠 verify-gap-equivalence 逐行差分来证。
      
      全口码(K05/K07)不进集合式:它们的 lateral PG 本来就会摘掉(零收益),
      实测硬套进 gap_cand 还慢 2~3 倍(1309→4439ms / 1388→3065ms)。
      
      新增:
        · src/cli/verify-gap-equivalence.cli.ts —— 两版同一 REPEATABLE READ 快照,
          双向 EXCEPT ALL 差分到 (患者,信号,牙位);--self 自对拍先证工具可信
        · tests/gap-setbased-parity.spec.ts —— 结构对拍,守「分支集合不许走散」
          (加分支只改一边 = 静默错召,tsc 和现有 spec 都发现不了)
        · scenario.buildScenarioSql() 抽成独立方法,让对拍拿到线上跑的那条 SQL 本身
        · PAC_GAP_VARIANT=setbased 切换;默认 legacy
      
      本地实测(30K 库):11 个子场景全部零差异,行数逐个相同;
      牙位级 ×1.05~1.73(库小全热,不作为收益判据,以测试机 585K 为准)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(gap): 集合式重写方案 —— 含两条改变边界的实测 · d2a208a3
      ① 全口场景(K05/K07)的 lateral PG 已自动删除(useless-left-join removal),
         本方案对它们零收益;测试机 K05 9m13s 的钱在患者级 NOT EXISTS,不在 resolvedTeeth。
         → 全轮预期改善下修到 35~40%,不足以单独装进 2 小时窗口。
      ② 本地 30K 库单分支对拍只快 1.26×,库太小全热不能外推 → 先建对拍与基准,
         单子场景试点达标(≥3×)再铺开;不达标就停,只留对拍工具。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  2. 29 Aug, 2026 15 commits
    • perf(召回): 场景查询事务级 work_mem=256MB —— 生产实测降 30~34% · 7d20ca2b
      2026-08-29 生产六轮实测(endo_no_rct,72,769 行,全部在生产 RDS 上跑):
        配置                      耗时     相对基线
        无索引 + 4MB   (冷)       18:43
        无索引 + 4MB   (暖)       17:33    ← 基线;冷暖只差 6%,缓存不是主因
        无索引 + 256MB (暖)       11:37    −34%
        无索引 + 256MB (暖,复现)  12:12    −30%
        有索引 + 4MB   (暖)       16:42    −5%   ← 噪声内,索引已从生产撤除
        有索引 + 128MB (暖)       14:47    −16%  ← 128MB 只吃到一半收益
      
       取 256MB:收益对取值很敏感,128→256 还差一倍。原本按「溢出只有 4 批、
        内存用量 8MB」推断 128MB 够用,实测否定了这个推断。
      
       事务级 SET LOCAL,不能全局调:work_mem 是每个排序/哈希节点的上限,不是每连接。
        生产 RDS 约 7GB 内存,全局设大值遇上并发排序会吃穿。SET LOCAL 出事务自动还原,
        Web API 连接不受影响;场景查询串行,同一时刻只有一条,峰值可控。
      
       别再加信号码部分索引:测试机与生产都测过,生产 −5% 落在噪声里
        (同配置两次测量本身差 6%),不值得引入一个 Prisma 管不到的索引。
      
      tests/recall-future-return-visit-gate.spec.ts 的 SQL 捕获桩补 $transaction/$executeRaw。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: plan 三层耗时日志 + 义齿按颌预过滤 → main · d6b0fa9d
      补的是常态可观测性:2026-08-29 生产 plan 段从 12 分钟涨到 159 分钟仍未跑完,
      而引擎中间什么都不打,只能临时起 pg_stat_activity 采样器反推。
      现在每个子场景一行(SQL 与 Node 后处理分开计时)+ 每轮阶段耗时一行。
      
      同时带上义齿按颌分支的预过滤(逻辑恒等,单患者路径实测 16.7×)——
      它针对的正是本次新增的两条 exam_findings 分支,是生产回归的头号嫌疑。
      luoqi committed
    • feat(可观测): plan 引擎补三层耗时日志 —— 一轮跑两小时不该是黑盒 · bac4c234
      2026-08-29 生产 plan 段从 12 分钟涨到 159 分钟仍未跑完,而引擎从「 Running engine」
      到最后的统计块之间**什么都不打**,整轮是个黑盒 —— 只能临时起 pg_stat_activity 采样器
      反推,才勉强定位到子场景粒度。这组日志就是为了不再重复那个过程。
      
      ① 每个子场景一行(常态开启,每轮 11 行):
         [recall] sub=missing_tooth code=K08 sql=1234ms rows=567 post=89ms hits=42
          SQL 与 Node 后处理**分开计时** —— 今天最大的困难就是知道慢、却分不清
           慢在 SQL 还是慢在 Node。
      
      ② runAllForHost 三段(常态开启,每轮 1 行):
         [plan] 阶段耗时 场景=Xms 预取=Yms 写入=Zms 命中患者=N 总计=Wms
         今天一度误以为瓶颈在预取,有这行就不会跑偏。
      
      ③ PAC_RECALL_DUMP_SQL=1(默认关)保留,需要拿 SQL 原文做 EXPLAIN 时才开。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(召回): 全量重算耗时归因 —— 68% 在逐行 gap 子查询,两个方案已实测否定 · d4b5264f
      2026-08-29 用 dump 开关 + pg_stat_activity 采样 + EXPLAIN ANALYZE 测出来的,
      结论与直觉相反,写进代码免得后来人重走弯路:
      
      ① 11 个子场景只有 2 个慢(K05 牙周 9m13s / K01 阻生牙 13m+),其余 9 个秒回;
         且与结果集大小无关 —— K02 理由数最多(49,382)却秒回。
      ② 11 条 SQL 逐字节相同,只有绑定参数不同 → 是数据分布 + 执行计划,不是 SQL 写法。
      ③ 真实热点:Append(resolvedTeeth 的 UNION) loops=117,256 × 1.71ms ≈ 201 秒,
         占 K01 单条 301 秒的 68% —— 每候选行跑一次的 gap 相关子查询。
      
       已实测否定,别再试:
       · 子场景并发=3:24.1 分钟 vs 串行 23.3。瓶颈是共享磁盘 I/O,并行只抢同一批 page。
       · 信号码部分索引:EXPLAIN 估算成本降 13×(122 万行 → 5,593 行),
         墙钟 24.5 vs 23.3 —— 无效。教训:别拿估算成本当依据,要看 EXPLAIN ANALYZE 的实际时间。
         (该索引的迁移已撤回,不上生产。)
      
       真正的方向(独立项目,未做):resolvedTeethSql 从逐行相关子查询改集合式。
         它是召回与画像共用的单一真理源,重写必须配等价性验证,否则是拿静默少召赌运气。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(排查): 召回场景 SQL 的 dump 开关 —— pg_stat_activity 会截断,拿不到全文 · 38e59329
      PAC_RECALL_DUMP_SQL=1 时把每个子场景的完整 SQL + 绑定值打到日志。默认关,零开销。
      
      为什么需要:2026-08-29 排查 plan 段耗时,采样抓到某条子场景查询单次跑 6.7 分钟,
      但 pg_stat_activity.query 被 track_activity_query_size(默认 1024 字节)截断 ——
      拿不到全文就没法 EXPLAIN,排查卡死在这一步。调 track_activity_query_size 要重启 PG,
      生产上不划算;做成开关更可控。
      
      实现上主查询从「$queryRaw 标签模板」改为「先建 Prisma.sql 对象再 $queryRaw(obj)」,
      两者等价,但对象有 .sql / .values 可检视。
      tests/recall-future-return-visit-gate.spec.ts 的 SQL 捕获桩同步兼容两种调用形态。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: cron 防重入 + reparse bind 溢出修复 + 并发否定结论 → main · d46a6cc5
      修的是 2026-08-29 生产的两个真实故障:
        ① plan 段被拖过 2 小时 cron 间隔后套圈,雪崩自持(源头消失也不自愈)
        ② 18 万患者 reparse 跑满 61/61 批后倒在收尾统计的 bind 变量溢出
      luoqi committed
    • docs(召回): 记录「子场景并发提速」的否定实测 —— I/O 受限,并行无效 · c4c1e3bd
      2026-08-29 测试服实测(585K 患者 / 87,661 命中,空闲机):
        串行(默认 1):24.6 / 21.7 / 23.5 分钟(三次)
        并发 3:      24.1 分钟 —— 不但没快,还略慢
      
      采样显示全程 DataFileRead:本阶段是共享磁盘 I/O 受限,不是查询延迟受限,
      并行只让几条查询抢同一批 page,总读取量不变。要提速得减少读取量(索引/收窄扫描),
      不是提高并行度。把这个负结果写进注释,免得后来人再拧一次这个旋钮。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed