Files
task_pilot/docs/decisions/0007-runtime-reliability.md
CNWei 815258c19b feat: 加固运行期可靠性并补齐调度与观测接口
- 运行句柄按 run_id 归属,修复取消后重跑时闸门被旧运行删除的竞态
- 调度循环改用取消令牌退出,启动时对账遗留运行,校验 rows_affected
- 三类任务统一参与调度,新增 /api/v1/stats 与 /api/v1/schedule
- SQLite 启用 WAL 与 busy_timeout,日志清理跳过活跃文件,单行上限 256 KiB
- 前端缓存管理令牌并在 403 时重试一次,仪表盘改用统计接口
2026-10-26 00:38:16 +08:00

3.1 KiB
Raw Permalink Blame History

ADR-0007:运行期可靠性优化

  • 状态:已接受
  • 日期:2026-10-25

背景

代码评审与端到端自检暴露出五类问题,都会让"看起来正常"的功能在边界条件下失真:

  1. 取消任务时执行器直接删除 running 句柄,而旧运行的收尾逻辑又无条件删除同任务句柄。取消后立刻重跑时,旧运行会把新运行的闸门删掉,跳过策略失效,同一任务可能并发执行。
  2. 调度器用 JoinHandle::abort() 取消循环。若恰好卡在 executor.trigger() 的数据库 await 中,running 表项和 queued 运行会永久残留,该任务此后永远被判定为"运行中"并跳过。
  3. 进程被强杀后,数据库里的 queued/running 运行没有对账,前端会一直显示"运行中"并持续轮询。
  4. mark_running/complete 不检查 rows_affected,运行记录被级联删除后收尾静默失效。
  5. 调度器只注册脚本任务,HTTP 与进程任务"启用"后返回 200 却永远不会被定时触发。

决策

  1. 句柄带运行 ID,收尾使用 remove_running_if(task_id, run_id):只有仍属于本次运行的句柄才会被移除;取消时依旧立即释放闸门,允许马上重跑。
  2. 调度循环只用 CancellationToken 退出,不再 abort;ScheduledTask 的 JoinHandle 仅用于保活。
  3. 启动时执行 fail_interrupted_runs:把遗留的 queued/running 统一改为 cancelled,并写入"进程重启,运行被中断"。
  4. mark_running/complete 返回是否更新成功,执行器对 0 行更新记警告并同步清理内存句柄。
  5. 三类 Action 统一参与调度:list_enabled_tasks 不再按 action_type 过滤,Scheduler::refresh 只判断 enabled。
  6. SQLite 启用 WAL + busy_timeout(5s) + synchronous=NORMAL,避免执行器写日志时前端查询撞上 database is locked。
  7. 日志清理跳过正在写入的文件;单行输出上限 256 KiB,超出后截断并继续读行尾。
  8. 请求体上限 2 MiB;新增 /api/v1/stats(一次查询拿到全部计数)与 /api/v1/schedule(下次触发时间)。
  9. 前端缓存会话令牌,写请求收到 403 时丢弃缓存并重试一次(覆盖后端重启轮换令牌)。
  10. Python 任务默认注入 PYTHONIOENCODING=utf-8(用户显式设置优先),PYTHONUNBUFFERED=1 保持强制。

原因

  • 句柄竞态与 abort 泄漏都会造成"任务不再执行"且无任何报错,是排障成本最高的一类缺陷。
  • 对账让崩溃恢复变成确定行为,前端轮询也能正常收敛。
  • 统一调度消除了"启用但永不触发"的假能力。
  • 统计与调度接口把仪表盘从 4 次请求降到 2 次,并让"下次执行时间"成为可见信息。

边界

  • 取消后立即可重跑,意味着旧进程的终止与新进程的启动可能短暂重叠(取决于 Job Object 终止速度)。
  • 没有全局并发上限:多个任务在同一时刻触发会各自启动一个进程。
  • 对账只针对 queued/running,不会尝试恢复重试或补偿错过的调度。
  • 单行截断后该行内容不完整,日志里会多一条 [system] 说明。