- 30 Aug, 2026 22 commits
-
-
luoqi committed
-
生产全量实测(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 -
生产事故修复(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 -
🔴 事故(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 -
🔴 事故(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 -
只读:每条子场景 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 -
luoqi committed
-
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 -
luoqi committed
-
⛔ 我上一版据 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 -
luoqi committed
-
luoqi committed
-
luoqi committed
-
luoqi committed
-
luoqi committed
-
luoqi committed
-
luoqi committed
-
画像是**逐患者**调用(全量 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 -
luoqi committed
-
luoqi committed
-
形态: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 -
① 全口场景(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
-
- 29 Aug, 2026 18 commits
-
-
luoqi committed
-
luoqi committed
-
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 -
补的是常态可观测性:2026-08-29 生产 plan 段从 12 分钟涨到 159 分钟仍未跑完, 而引擎中间什么都不打,只能临时起 pg_stat_activity 采样器反推。 现在每个子场景一行(SQL 与 Node 后处理分开计时)+ 每轮阶段耗时一行。 同时带上义齿按颌分支的预过滤(逻辑恒等,单患者路径实测 16.7×)—— 它针对的正是本次新增的两条 exam_findings 分支,是生产回归的头号嫌疑。
luoqi committed -
luoqi committed
-
luoqi committed
-
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 -
luoqi committed
-
luoqi committed
-
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 -
luoqi committed
-
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 -
修的是 2026-08-29 生产的两个真实故障: ① plan 段被拖过 2 小时 cron 间隔后套圈,雪崩自持(源头消失也不自愈) ② 18 万患者 reparse 跑满 61/61 批后倒在收尾统计的 bind 变量溢出
luoqi committed -
luoqi committed
-
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 -
## 一、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 -
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 -
luoqi committed
-