fix(sync): 僵尸锁回收加「第二次机会」—— 启动态对 deploy 孤儿锁 100% 失效
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>
Showing
Please
register
or
sign in
to comment