1. 30 Aug, 2026 16 commits
  2. 29 Aug, 2026 19 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
    • fix(调度): cron 回调防重入 + reparse 两处 bind 变量溢出 · 834a0c3e
      ## 一、cron 防重入(sync-incremental.scheduler.ts)
      
      🔴 2026-08-29 生产事故:plan 段耗时涨过 2 小时后,cron(每 2 小时)照常触发下一轮,
      两轮 plan 段并发抢同一批 I/O → 都更慢 → 更易被再下一轮套圈 → 雪崩。
      实测 08-28 20:15 起连续多轮 plan 段一次都没跑完;到 08-29 11:19(即触发原因
      reparse 结束 1.4 小时后)仍有两轮在并行互拖 —— 雪崩是**自持**的,源头消失也不会自愈。
      
      现有的锁都挡不住,这是本修复存在的理由:
        ① NestJS CronJob **默认不防重入** —— 上一次回调还在 await,下一次照样进;
        ② sync_logs 的 partial UNIQUE(host_id) WHERE status='running' 只覆盖**摄入段**,
           摄入一结束锁就放了,而最慢的 persona / plan 段还在跑。
      所以必须在回调入口用进程内 Set 挡。跳过而非排队:摄入是游标增量,下轮自然 catchup;
      plan 是时间驱动全量,跳一轮只是晚 2 小时评估,远好过雪崩。
      释放放在 finally —— 抛异常时不释放会把该 host 锁死到进程重启。
      
      tests/scheduler-reentrancy-guard.spec.ts 锁四条:并发跳过 / 结束后放行 /
      按 host 而非全局 / 抛异常也释放。
      
      ## 二、reparse 两处 bind 变量溢出(cold-import.service.ts)
      
      `patientId: { in: [...] }` 直接塞完整患者清单会撞 PG 的 32767 上限。
      
      实跑路径(第 3 步统计受影响患者)—— 2026-08-29 生产实测:18 万患者的 reparse
      跑满 61/61 批、写完全部事实(superseded=9,062)之后**倒在最后一步**:
        Assertion violation: too many bind variables ... received 32769
      6.6 小时的活全干完,只因收尾统计炸掉而 exit 1。
      
      dry-run 路径同病:>3.2 万患者直接崩,而 --patients-file 的文档恰恰说
      「按受影响患者收窄是最有效的提速手段(可达 250 倍)」—— 最需要先 dry-run 探路的
      大清单场景,正好是它唯一不工作的场景。
      
      两处都按 3000 分块(与 PAC_REPARSE_BATCH 同款),每块 3001 个变量,离上限很远。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • perf(召回): 义齿按颌分支补预过滤 —— 单患者路径快 16.7 倍 · 611548a1
      archDentureBranch 在展开 exam_findings 的 JSON 数组之前没有任何文本级过滤,
      而兄弟分支 RESTORATION_IN_PLACE 那条本来就先在整段文本上过滤了(rsrc 子查询)。
      这是漏了,不是有意为之。
      
      ARCH_DENTURE_UPPER_RE / LOWER_RE 两条都要求句中出现「义齿|假牙」,所以整段
      exam_findings 里一个都没有时,任何条目都不可能命中 —— 预过滤逻辑上恒等,只剪枝。
      
      2026-08-29 测试库实测:
        exam_findings 是数组的 emr 事实 1,519,829 份,含「义齿|假牙」的仅 7,396(0.49%)
        按患者相关子查询(实际形态,300 患者样本):832.7ms → 49.7ms,快 16.7 倍,结果一致
      
       注释里写死了一条反向提醒:别用「全表扫」形态验证本优化 —— 那个形态两边都是
      81 秒(顺序扫描 + detoast 压倒一切),看不出差别,照着那个数会误以为没用而删掉。
      我自己 2026-08-29 就先测错了这一次。批量路径接近全表扫形态,收益不明显;
      吃到 16.7 倍的是单患者路径(详情页刷新 / recomputeForPatient / reparse 后定向重算)。
      
      tests/arch-denture-false-missing.spec.ts 新增一组用例锁「预过滤词必须始终是
      两条 RE 的必要条件」的蕴含关系 —— 一旦不成立,预过滤会静默丢真命中(少召不报错)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  3. 28 Aug, 2026 5 commits