Files
FinDataHub/Scripts
FinDataHub ecae6767f9 fix(health): 修「节能模式下 stuck 误报 → 触发自动重启」——历史上百次误判的机制
缺陷与危害
----------
`get_status()` 用「数据库最后写入距今 > 300s」判定 `stuck`。
但非交易时段采集器进入节能模式、采集间隔由 30s 退避到 **3600s**
(日志 `[eco] 非交易时段,采集间隔 30s → 3600s`),**期间刻意不写数据** ——
于是每个非交易小时里约 55 分钟被误判为卡死。

而 `AutoRecoveryManager.swift:166` 把 `stuck == true` 当作 `collectorStuck` 故障并
**自动重启采集器** → 这正是历史上「一天内被判定异常停止 193 次」的机制。
(今天的 debug.log 时间线也印证:20:27 / 20:46 / 21:05 / 21:24 / 21:49 / 22:11
呈规律性停止,间隔与健康检查周期一致。)

修法
----
`stuck` 改由**心跳**判定:心跳文件由独立线程每 60s 刷新,长休眠时也保持活跃
(代码注释本就写明"即使采集循环处于长休眠(深夜 3600s),心跳也保持活跃"),
Swift 侧的免 spawn 探测一直是读心跳的 —— 本次把 CLI 路径与之统一。
- 心跳新鲜(≤3×60s)→ 不卡死
- 心跳过期/缺失 → 回退判定,且阈值取 **4200s**(>节能上限 3600s + 余量)
- **无写入时间可比时(空库 / DB 读取异常)保守判为不卡死** ——
  stuck 会触发自动重启,误判代价高于漏判
- `last_write_time` 语义不变,仍如实反映数据写入

顺带修一处解析脆弱性:DB 返回 `datetime` 对象时 `str()` 带微秒,
原 `strptime("%Y-%m-%d %H:%M:%S")` 抛 `unconverted data remains: .219352`;
新增 `_parse_ts()` 容错(支持带/不带微秒与 ISO 格式)。

验证
----
新增 6 条用例:
- **核心回归**:节能空闲 1 小时但心跳鲜活 → 不得判 stuck(旧逻辑必判 true)
- `last_write_time` 仍如实上报
- 心跳过期 + 写入 <4200s → 不判卡死;>4200s → 判卡死
- 无心跳文件时回退阈值须 >3600s(否则长休眠仍误判)
- 无写入时间时保守不判卡死
已有 5 条契约用例同步更新(它们断言的是旧的"按写入判 stuck"),
并新增 autouse fixture 让它们走回退路径。
部署位实测:`{"stuck": false}`,而 `last_write_time` 仍为 26 分钟前 —— 语义正确。
Scripts/bin 214 → **220 passed**。

附带:修 kuaixun E2E 用例与采集器撞锁
--------------------------------------
`test_unified_writer_e2e` 会真实写 `news_unified`,与正在运行的采集器撞锁:
    pymysql.err.OperationalError: (1205, 'Lock wait timeout exceeded')
(它此前"通过"只因采集器当时停摆 13 小时 —— 修好采集后反而暴露。)
已加守卫:采集器心跳活跃时 `pytest.skip` 并说明原因与停止方法,
而不是让它随机失败。该用例在 CI 里本就属非阻塞组(CI 无 MySQL)。
kuaixun 410 passed / 1 skipped,耗时 54s → **3.9s**。

回归:Scripts/bin 220、core 122、client 38、tushare 174、akshare 183、kuaixun 410
—— 合计 **1147 passed**。
2026-09-19 23:10:21 +08:00
..