# 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]` 说明。