- 运行句柄按 run_id 归属,修复取消后重跑时闸门被旧运行删除的竞态 - 调度循环改用取消令牌退出,启动时对账遗留运行,校验 rows_affected - 三类任务统一参与调度,新增 /api/v1/stats 与 /api/v1/schedule - SQLite 启用 WAL 与 busy_timeout,日志清理跳过活跃文件,单行上限 256 KiB - 前端缓存管理令牌并在 403 时重试一次,仪表盘改用统计接口
3.1 KiB
3.1 KiB
ADR-0007:运行期可靠性优化
- 状态:已接受
- 日期:2026-10-25
背景
代码评审与端到端自检暴露出五类问题,都会让"看起来正常"的功能在边界条件下失真:
- 取消任务时执行器直接删除
running句柄,而旧运行的收尾逻辑又无条件删除同任务句柄。取消后立刻重跑时,旧运行会把新运行的闸门删掉,跳过策略失效,同一任务可能并发执行。 - 调度器用
JoinHandle::abort()取消循环。若恰好卡在executor.trigger()的数据库 await 中,running表项和queued运行会永久残留,该任务此后永远被判定为"运行中"并跳过。 - 进程被强杀后,数据库里的
queued/running运行没有对账,前端会一直显示"运行中"并持续轮询。 mark_running/complete不检查rows_affected,运行记录被级联删除后收尾静默失效。- 调度器只注册脚本任务,HTTP 与进程任务"启用"后返回 200 却永远不会被定时触发。
决策
- 句柄带运行 ID,收尾使用
remove_running_if(task_id, run_id):只有仍属于本次运行的句柄才会被移除;取消时依旧立即释放闸门,允许马上重跑。 - 调度循环只用
CancellationToken退出,不再abort;ScheduledTask的JoinHandle仅用于保活。 - 启动时执行
fail_interrupted_runs:把遗留的queued/running统一改为cancelled,并写入"进程重启,运行被中断"。 mark_running/complete返回是否更新成功,执行器对 0 行更新记警告并同步清理内存句柄。- 三类 Action 统一参与调度:
list_enabled_tasks不再按action_type过滤,Scheduler::refresh只判断enabled。 - SQLite 启用 WAL +
busy_timeout(5s)+synchronous=NORMAL,避免执行器写日志时前端查询撞上database is locked。 - 日志清理跳过正在写入的文件;单行输出上限 256 KiB,超出后截断并继续读行尾。
- 请求体上限 2 MiB;新增
/api/v1/stats(一次查询拿到全部计数)与/api/v1/schedule(下次触发时间)。 - 前端缓存会话令牌,写请求收到 403 时丢弃缓存并重试一次(覆盖后端重启轮换令牌)。
- Python 任务默认注入
PYTHONIOENCODING=utf-8(用户显式设置优先),PYTHONUNBUFFERED=1保持强制。
原因
- 句柄竞态与
abort泄漏都会造成"任务不再执行"且无任何报错,是排障成本最高的一类缺陷。 - 对账让崩溃恢复变成确定行为,前端轮询也能正常收敛。
- 统一调度消除了"启用但永不触发"的假能力。
- 统计与调度接口把仪表盘从 4 次请求降到 2 次,并让"下次执行时间"成为可见信息。
边界
- 取消后立即可重跑,意味着旧进程的终止与新进程的启动可能短暂重叠(取决于 Job Object 终止速度)。
- 没有全局并发上限:多个任务在同一时刻触发会各自启动一个进程。
- 对账只针对
queued/running,不会尝试恢复重试或补偿错过的调度。 - 单行截断后该行内容不完整,日志里会多一条
[system]说明。