- 05 Sep, 2026 20 commits
-
-
luoqi committed
-
luoqi committed
-
补摄 / recompute-plans 一直是 `docker exec pac-pac-service-1 node …`, 同一容器 = 同一 cgroup。加限制前服务 4G + CLI 4.9G 在 15G 宿主上撑得住; 加限制后两者之和过 8G,内核会在 cgroup 内部挑一个杀 —— 可能杀的是服务。 规则本来就写着"别并发跑全量 CLI",但违反的代价从「也许没事」变成「必然被杀」, 必须写在限制旁边而不是别处。 给出的替代配方(单开带自己上限的容器)已在测试机实跑验证: 新容器 memory.max 是自己的值、连库正常、服务那 8G 分母不受影响。 同时改掉两处会让人照着做不通的细节 —— CLI 是 dist/cli/<名字>.cli.js, 以及 DATABASE_URL 在托管/自带 DB 两种形态下的差别。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
luoqi committed -
luoqi committed
-
luoqi committed
-
外部探针解决了「pac-service 死了没人知道」,但它自己也会死:测试机宕、cron 被删、 脚本改坏、/etc/pac-health-watch.env 丢失 —— 这些情况下探针**静静地停掉**, 而所有人以为它还看着。这是 09-04 的同款失败换个位置又出现一次。 每天固定时刻(默认 9 点,WATCH_HEARTBEAT_HOUR=-1 关)推一条「我还活着 + 过去一个周期探了几次/失败几次」。刻意排在生产每日健康报告(09:07)旁边: 两条都到 = 两侧都活;只到一条 = 立刻知道断的是哪一侧。 两个实现细节: · 心跳判据是「今天还没推过 且 已到点」,不是「分钟正好相等」—— cron 可能因负载/重启错过某一格,按分钟判会整天不推,而漏推 = 误报"探针死了"。 · 状态文件的每个数值字段都做正则校验。它是被 source 的,写坏一次(磁盘满/并发) 会让后面的算术直接语法错退出 → 探针静默死亡,正是本脚本要防的那类失败。 已用"塞垃圾进状态文件"的用例验证能自愈。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
luoqi committed -
现状:sync-incremental.scheduler 不注入 AlertService。轮次失败只 logger.error 就吞掉(`:38` 注释「跑失败:log ERROR 不抛」),唯一出口是次日 09:07 的每日健康 报告 —— 最坏晚 24 小时。09-04 生产宕 11.8 小时全程零告警,有这一份原因。 而刚加的 compose mem_limit(8g)会把下面这条路径变成常见路径: 容器被 SIGKILL → 轮次失败 → HTTP 30 秒内恢复 → 外部探针一路绿 即**系统在稳定地失败,而所有指示灯是绿的**。不补这三条,新加的兜底反而 制造了一种更隐蔽的故障。 三条信号: 1. 回收到僵尸锁 → warning。这是「上一轮没正常终结」的**唯一确定性信号**, 比"轮次失败"更早更准:失败可能只是网络抖动,留下僵尸锁则意味着进程被 外力打断、没走到 finally。正文直接给 CONSTRAINT_MEMCG 的查法,并写明 别看 docker inspect 的 ExitCode(restart:always 下那是重启后那条命)。 2. 轮次抛异常 → critical,带错误原文。 3. 套圈跳过 → warning。这是 08-29 雪崩的前兆、也是 09-04 的中间态 —— 当时 11.8 小时里每轮都在这里 return,防套圈闸救了系统却**吞掉了症状**。 告警统一走 alertSafe:推送失败绝不能反过来打断同步。 测试:5 个新用例,并做了变异测试 —— 逐条拆掉告警 / 拆掉 try/catch, 四次变异全部被咬红,不是走过场的绿。全量 1975 passed。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
luoqi committed -
luoqi committed
-
luoqi committed
-
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 -
luoqi committed
-
luoqi committed
-
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 -
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 -
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 -
上一提交给 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 -
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 -
上一提交给 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 -
luoqi committed
-
luoqi committed
-
- 04 Sep, 2026 4 commits
-
-
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 -
luoqi committed
-
luoqi committed
-
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
-
- 03 Sep, 2026 16 commits
-
-
实测踩到:服务器上脚本是旧版,运行时 git pull 拉到了新版,但**本次进程跑的仍是内存里 已载入的旧版函数** —— 文件已经是 curl -sL 了,报错文案却还是旧版的,排查了一轮才明白。 包在 main() 里解决的是「bash 边读边执行、pull 后按新文件字节偏移读后半段」的错位问题 (deploy-prod.sh 头部注释记的那个);但它防不了「这一轮跑的是旧逻辑」—— 函数在调用前就已整体载入内存,pull 换掉磁盘文件不影响已载入的定义。 修法:pull 前后比对本文件的 blob hash,变了就 exec bash "$0" --no-pull 用新版重跑, --no-pull 同时防无限递归。
luoqi committed -
首次实跑 deploy-docs.sh 就踩到:servers 校验通过了,但第二项判据把健康的站报成故障。 fumadocs 的根路径 / 是 307 重定向到 /docs(正常行为),直接判 200 必然失败。 改用 curl -sL 跟随重定向后判最终状态(实测跟随后是 200)。 判据写窄比写宽更危险:这次是误报(能立刻发现),反过来若判据恒真则会放过真故障。
luoqi committed -
【解决的问题】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 -
真实数据打脸:首轮导入 2552 条回访后,有 1 条落库仍是 "<p>阿斯蒂芬撒的发生</p>"。 查源库(customer_return_visit id=266151)发现是**双重编码**: <p><span style="color:#000000">…<p>阿斯蒂芬撒的发生</p>…</span></p> 外层是真标签,内层是被转义的标签。 原实现「剥标签 → 还原实体」跑一轮:外层剥掉后,<p> 被还原成字面的 <p> 留在结果里。 算子逻辑本身没错(单次剥离),但真实数据证明必须处理这种形态 —— 宿主富文本编辑器 把粘贴进来的 HTML 又转义了一层。 改成**有界两轮**:还原实体后若又出现标签则再剥一轮,最多 2 轮。 不做无界循环 —— 构造输入(每轮都能再生标签,如 &lt;p&gt;)会把摄入卡死, 两轮后仍有残留是可接受的,关键是必然终止。第 2 轮只在确实又露出标签时才跑, 绝大多数行一轮即净,无谓开销为零。 回归测试 2 项:线上那条原文按原样断言;畸形嵌套输入断言必然返回(不卡死)。
luoqi committed -
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 -
luoqi committed
-
实测踩到:服务器上脚本是旧版,运行时 git pull 拉到了新版,但**本次进程跑的仍是内存里 已载入的旧版函数** —— 文件已经是 curl -sL 了,报错文案却还是旧版的,排查了一轮才明白。 包在 main() 里解决的是「bash 边读边执行、pull 后按新文件字节偏移读后半段」的错位问题 (deploy-prod.sh 头部注释记的那个);但它防不了「这一轮跑的是旧逻辑」—— 函数在调用前就已整体载入内存,pull 换掉磁盘文件不影响已载入的定义。 修法:pull 前后比对本文件的 blob hash,变了就 exec bash "$0" --no-pull 用新版重跑, --no-pull 同时防无限递归。
luoqi committed -
luoqi committed
-
首次实跑 deploy-docs.sh 就踩到:servers 校验通过了,但第二项判据把健康的站报成故障。 fumadocs 的根路径 / 是 307 重定向到 /docs(正常行为),直接判 200 必然失败。 改用 curl -sL 跟随重定向后判最终状态(实测跟随后是 200)。 判据写窄比写宽更危险:这次是误报(能立刻发现),反过来若判据恒真则会放过真故障。
luoqi committed -
luoqi committed
-
【解决的问题】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 -
luoqi committed
-
真实数据打脸:首轮导入 2552 条回访后,有 1 条落库仍是 "<p>阿斯蒂芬撒的发生</p>"。 查源库(customer_return_visit id=266151)发现是**双重编码**: <p><span style="color:#000000">…<p>阿斯蒂芬撒的发生</p>…</span></p> 外层是真标签,内层是被转义的标签。 原实现「剥标签 → 还原实体」跑一轮:外层剥掉后,<p> 被还原成字面的 <p> 留在结果里。 算子逻辑本身没错(单次剥离),但真实数据证明必须处理这种形态 —— 宿主富文本编辑器 把粘贴进来的 HTML 又转义了一层。 改成**有界两轮**:还原实体后若又出现标签则再剥一轮,最多 2 轮。 不做无界循环 —— 构造输入(每轮都能再生标签,如 &lt;p&gt;)会把摄入卡死, 两轮后仍有残留是可接受的,关键是必然终止。第 2 轮只在确实又露出标签时才跑, 绝大多数行一轮即净,无谓开销为零。 回归测试 2 项:线上那条原文按原样断言;畸形嵌套输入断言必然返回(不卡死)。
luoqi committed -
customer_return_visit → PatientReturnVisit,任务头 customer_task 的 task_date/task_status 由宿主 inline(同计划行 inline 计划头)。枚举翻中文与 jvs-dw 逐字对齐; 新增 strip_html 算子剥富文本(实测 31% 回访内容带 HTML)。 契约文档新增第 7 节,source 数 9 → 10。
luoqi committed -
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 -
luoqi committed
-