1. 10 Oct, 2026 3 commits
  2. 09 Oct, 2026 8 commits
  3. 08 Oct, 2026 4 commits
  4. 06 Oct, 2026 2 commits
  5. 21 Sep, 2026 2 commits
    • fix(plan): 确认分配 500 —— 全 null 数组被 Prisma 推成 integer[],撞上 ::timestamptz[] · 5fcd4afe
      FRIDAY 宿主接入测试机后,点「确认分配」必 500:
        POST /pac/v1/plans/assignments → 42846
        cannot cast type integer[] to timestamp with time zone[]
        at plan-assignment.service.ts:392 ($transaction 内)
      
      机理:Prisma 从**值**推断数组元素类型。`aDedicatedCsLastVisit`(该患者专属客服的
      最近到诊时间)整个数组非空且全是 null 时推不出来,退化成 integer[],撞上显式
      ::timestamptz[]。⚠️ 空数组 [] 反而安全(走不到转换),**只有「非空且全 null」才炸**。
      
      为什么瑞尔一直没事:那边有回访历史,多数患者有专属客服 → 数组里总有非 null。
      FRIDAY 是新宿主、无回访历史 → 整批全 null。
      ⇒ **这不是 FRIDAY 特有的**,瑞尔只要碰上「这批人恰好都没有专属客服」一样炸。
      
      修法:参数以 ISO 文本传,再整体 ::text[]::timestamptz[]。三种形态(全 null /
      混合 / 全有值)都在测试机实测通过。不动 t(...) 的列名与顺序 —— 九个数组的位置
      对应关系很脆,那里原本就有一条警告。
      
      实测哪些目标类型会塌(2026-09-21 测试机):
        ::timestamptz[] ❌  ::uuid[] ❌
        ::text[] ✅  ::double precision[] ✅  ::int[] ✅
      ⇒ 同段的 aDedicatedCs(::text[])、aSourceConfidence(::double precision[])
        虽然也可空但**没病**,没有顺手改 —— 测试里专门锁了这一点。
      
      测试:新增仓库级守卫 —— 凡 ${x}::timestamptz[] / ::uuid[] 且 x 声明含 `| null`
      即判失败,覆盖全 src 而不只是这一处。5 项变异全部被咬红,其中一项
      (toContain('toISOString()'))第一版没牙、被变异测试当场抓到后已收紧为只锁那一行。
      全量 1979 passed。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  6. 05 Sep, 2026 21 commits
    • docs(ops): 更正余量口径 —— 我之前报的 36%/2.8倍 是错的,cgroup 实测 58%/1.7倍 · af0db174
      量错了指标。mem_limit 管的是 cgroup 的 memory.current,而我拿 `docker stats`
      和进程 rss 当依据,三者差 1.6 倍:
      
        cgroup memory.peak = 4760 MiB   ← mem_limit 真正管的数(58% of 8192)
        docker stats 采样  = 3.53 GiB   ← 采样,峰值漏在间隔里(我 10 分钟一采)
        进程 rss 峰        = 2936 MB    ← 不含 page cache
      
      差额是 page cache + 堆外分配。8g 仍然站得住(且 4760 里相当部分是可回收的
      page cache —— 跑完后 file 段只剩 2 MiB),但余量是 1.7 倍不是 2.8 倍。
      正确量法写进注释:`cat /sys/fs/cgroup/memory.peak`,并注明高水位随容器重建清零。
      
      同时这个数推翻了「堆上限 6144 ⇒ V8 一定先抛」的说法:cgroup 高水位比堆峰值
      多出约 2.2G,堆若真涨到 6144,总量会逼近 8192,谁先触发取决于当时可回收的
      page cache 有多少。改成如实写"大概率而非硬保证",并给出想要硬保证时该取的
      值(5120)和它的代价。6144 保持不变。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • perf(ops): 堆上限 8192→6144 —— 让撞顶时 V8 先抛(有堆栈)而不是被 cgroup 静默杀 · cfb6e16b
      加了 mem_limit(8g)后,堆上限还留在 8192 会形成一个坏顺序:RSS 恒大于堆
      (实测 rss=2873MB 时 heapTotal=2309MB,堆外常驻约 560MB),两者相等时
      **一定是先撞 cgroup 被 SIGKILL —— exit 137,零日志零堆栈**,而不是 V8 抛
      `Reached heap limit`(带 JS 堆栈,能直接定位是哪段吃的)。
      等于把唯一一次能拿到现场的机会浪费掉,而 09-04 整件事就是败在没有现场。
      
      6144 的依据是生产实测,不是拍脑袋 —— 09-05 10:15 那轮(分批改造上线后第一轮
      干净数据,550,481 召回池):峰值 heapUsed=2437MB(批 15),之后单调降到 402MB;
      峰值 rss=2873MB。6144 相对峰值留 2.5 倍;最坏 RSS ≈ 6144+600 ≈ 6.7G < 8G ⇒ V8 先抛。
      
      不影响 CLI:命令行 --max-old-space-size 覆盖 NODE_OPTIONS,已实测
      (NODE_OPTIONS=6144 + 命令行 8192 → heap_size_limit 8240MB)。
      
      顺带改准两处已经和事实对不上的注释:
      · 「观测峰值 6.03G,留 33% 余量」是分批改造**之前**的旧数,实测只用到 36%(2.8 倍余量);
        写明以后调这个数必须重新量,别再拿 6.03G 当依据。
      · 「与 8192 相等是刻意的过渡态」—— 过渡态已结束,改成说明为什么堆上限必须低于 mem_limit。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: main → test(反向合) · aeb93ad6
      luoqi committed
    • docs(ops): 写明 mem_limit 引入的新风险 —— exec 进服务的 CLI 共享这 8G · c9d15eda
      补摄 / 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
    • merge: main → test(反向合) · 72962a33
      luoqi committed
    • feat(ops): 探针加每日心跳(dead-man's switch)—— 没收到本身就是告警 · 4e55a207
      外部探针解决了「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
    • fix(sync): 增量链路补三条告警 —— 此前整条链一条都不推 · 7cea75b2
      现状: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
    • merge: main → test(反向合) · e8a8f0c5
      luoqi committed
    • 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
    • fix(plan): 阶段耗时分段口径 —— 「写入=」原本把「预取=」又算了一遍 · aed2da93
      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 上限,且不等价于改造前 · f419d115
      上一提交给 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