长任务跑到一半,它现在到底是什么状态?
把状态写成一张显式的转换表:8 个状态、7 种事件,表里没有的组合一律拒绝。再把状态和事件日志放进同一个事务持久化,用版本号和租约挡住并发与僵尸 worker。
测试 Agent 跑一轮 BugHunt 要十几分钟,中间会被取消、会失败重试、worker 会被杀掉。用几个布尔字段拼出来的"状态",迟早会出现"已取消但还在跑"的任务。
1为什么不用几个布尔字段
场景:BugHunt-Bench 里,一个"让测试 Agent 探索 Conduit 并提交 Bug 报告"的任务要跑十几分钟。用户可能中途取消;模型 API 偶尔 429,需要重试;跑任务的 worker 进程可能被 OOM 杀掉。
第一版代码常常是 is_running、is_done、is_cancelled 三个布尔字段,各处代码自己改自己关心的那个。三个布尔有 23 = 8 种组合,is_running=True, is_cancelled=True 算什么?是"已请求取消、worker 还没停",还是"取消失败"?没有人写下来,每个调用方各自理解。
28 个状态、7 种事件、12 条合法转换
| 当前状态 | 事件 | 目标状态 | 说明 |
|---|---|---|---|
submitted | enqueue | queued | 进队列,等 worker 领取 |
submitted / queued / retry_wait | cancel | cancelled | 还没在跑,直接取消 |
queued | start | running | worker 领取,attempts + 1,拿到租约 |
running | succeed | succeeded | 终态 |
running | fail | retry_wait 或 failed | 可重试且 attempts < max_attempts 才进 retry_wait,否则终态失败 |
running | cancel | cancelling | 正在跑的任务不能瞬间停下,先标记"已请求取消" |
retry_wait | retry_due | queued | 退避时间到,回到队列(退避怎么算是下一章的内容) |
cancelling | cancel_ack / fail | cancelled | worker 确认停下;停下前失败了也不再重试 |
cancelling | succeed | succeeded | 取消信号到达前已经跑完:副作用已经发生,如实记成功 |
按单元格数:8 × 7 = 56 个(状态, 事件)组合,其中 12 个合法转换(running + fail 算一格,目标取决于守卫条件),2 个幂等 no-op(对 cancelling 和 cancelled 再发一次 cancel:不报错、不写库、不记事件),剩下 42 个非法。
1)取消是请求不是命令。
running → cancelling → cancelled,中间要等 worker 自己停下来。2)
cancelling 时收到 succeed 记成功而不是取消:副作用(比如已经提交的 Bug 报告)真实发生了,状态要和现实一致。3)重复取消做成幂等 no-op,但对
succeeded / failed 发 cancel 是非法:客户端重试取消请求不该报错,但"取消一个已经完成的任务"应该让调用方知道。3非法转换怎么测
- 56 格全覆盖:用
pytest.mark.parametrize把每个(状态, 事件)组合都跑一遍。合法的断言目标状态和 version + 1;no-op 断言返回的是同一个对象;非法的断言抛IllegalTransition。 - 期望表要独立写:测试里的
LEGAL字典是照着规格手写的,不从实现里 importTRANSITIONS。从实现里抄,测试和实现就会错得一模一样,永远是绿的。 - 守卫条件单独测边界:
max_attempts = 3时,attempts 为 1、2 的失败进 retry_wait,第 3 次失败进 failed;不可重试的错误第 1 次就进 failed。 - 随机游走查不变量:固定种子生成 2,000 条长度 40 的随机事件序列,断言三条不变量:终态不会再变;attempts 不超过 max_attempts;version 等于真实发生的状态变化次数。
- 持久化层断言"拒绝即不落库":非法转换之后,tasks 表、租约字段、事件日志三样都要和之前完全一样(逐字段比较查询出的值)。
配套代码(week05_长任务可靠性/code/task_state_machine/)共 77 个测试,本地 python3.13 -m pytest -q 全部通过(约 2 秒)。为了确认这些测试真能抓 bug,mutation_check.py 往实现里故意埋了 5 个 bug,每个都至少让一个测试失败:
| 埋进去的 bug | 抓到它的测试 |
|---|---|
终态不再吸收:cancelled 还能 enqueue | 56 格全覆盖、随机游走、终态吸收、持久化层非法转换 |
重试上限差一:attempts <= max_attempts | 守卫边界、随机游走、重试计数、租约回收 |
| 去掉版本号检查 | 两个 worker 抢同一任务(确定性交错和 8 线程各一个) |
| 去掉租约持有者检查 | 僵尸 worker |
| 去掉所有显式事务(BEGIN IMMEDIATE / COMMIT / ROLLBACK) | 写日志时注入异常后的回滚测试 |
4状态持久化:同一个事务、版本号、租约
状态只放内存里,进程一重启就全丢了。这里用 SQLite(Python 标准库 sqlite3,本机 SQLite 3.50.4),两张表:tasks 存当前状态,task_events 存每一次转换。计划里提过 Redis 队列,本章先用 SQLite:单文件、有事务、不用起服务,测试里每个用例一个临时库。
- 状态和事件日志同一个事务写。连接用
isolation_level=None(Python 文档:设为 None 时 sqlite3 不会隐式开事务),自己写BEGIN IMMEDIATE/COMMIT/ROLLBACK。测试在写事件日志时注入异常,断言 tasks 表的 UPDATE 也一起回滚了。 - 检查和写入必须是原子的。"先 SELECT 出 queued,在代码里判断,再 UPDATE"是两步,中间别人可以插进来。SQLite 文档说明
BEGIN IMMEDIATE在 BEGIN 时就启动写事务(另一个连接已经在写时返回SQLITE_BUSY,Python 的timeout参数默认等 5 秒),所以在事务里做"读 → 判断 → 写"是安全的。 - 版本号(乐观锁)。worker 轮询时读到的 version 会过期,领取时带上
expected_version,对不上就抛VersionConflict;UPDATE 也带WHERE version = ?,看rowcount是不是 1。在单个 SQLite 文件上,BEGIN IMMEDIATE已经把写串行化,这个 WHERE 条件不会被触发(把它改成恒真,77 个测试照样全过)。但只要读和写之间没有一把写锁,它就是唯一的防线:比如不开显式事务、PostgreSQL / MySQL 在默认隔离级别下先读后写,或者 Redis(用 WATCH 实现同样的检查再写入)。 - 租约。
start时记下lease_owner和lease_until,worker 定期心跳续约。只有租约持有者能上报succeed/fail/cancel_ack。本实现的过期是惰性的:只核对持有者、不比较lease_until,巡检回收之前,原持有者的心跳和上报仍然有效(设计选择)。 - 重启后回收。worker 被
kill -9后,库里它的任务还是running,没有人会再改它。巡检reclaim_expired()把租约过期的任务按一次可重试失败处理:attempts 还够就进 retry_wait,用完了就 failed,原来在 cancelling 的直接 cancelled。 - 事件日志可以重放。从
submit开始把日志里的事件逐条 apply,结果必须和 tasks 表一致。测试里对 200 个随机任务做了这个检查。
5对照:Codex 和 Celery 的状态
| 系统 | 状态 | 和本章的对应 |
|---|---|---|
| Codex app-server(turn) | Completed / Interrupted / Failed / InProgress | 只有 4 个,枚举里没有排队、重试等待这类中间状态。Interrupted 大致对应我们的 cancelled |
| Codex app-server(命令执行 item) | InProgress / Completed / Failed / Declined | 多一个 Declined:主要是审批被拒(源码注释承认部分运行前的拒绝也会报成 Declined),共同点是命令根本没跑。"没跑"和"跑了失败"是两种状态 |
| Celery | PENDING / STARTED / RETRY / FAILURE / SUCCESS / REVOKED | 文档说明 PENDING 并不是记录下来的状态,而是任何未知 task id 的默认状态;STARTED 默认不记录,要开 task_track_started,或给单个任务设 track_started=True;REVOKED 表示任务被撤销 |
Codex 引用基于 openai/codex commit 7993248:v2/turn.rs:33-38、v2/item.rs:1077-1082;Declined 的范围见 core/src/tools/events.rs:453-456 的注释。Celery 见 Next Steps、celery.result 和 Tasks 用户指南的 States 一节。
KeyError,"不存在"和"在排队"是两回事。▶互动演示 1:单步触发事件
点下面的事件按钮,对当前任务触发一次转换。"状态机"实现按转换表检查,非法的拒绝且不写库;"朴素写法"不做任何检查,每个事件直接把状态改成固定目标(fail 按次数)。下面是数据库里那一行的关键字段和事件日志。"worker 被 kill -9"模拟进程被杀:状态机实现等租约(30 秒)过期后由巡检回收,朴素写法没有租约,任务永远停在 running。
▶互动演示 2:56 格转换矩阵
行是当前状态,列是事件。绿色 = 合法转换(格子里写目标状态),浅黄 = 幂等 no-op,✕ = 非法。点任意一格,演示 1 的任务会被放到该行的状态(attempts = 1)再触发该列的事件。按钮会把测试里的 56 个用例分别跑在两种实现上。
▶互动演示 3:并发交错,一步一步看
三个确定性的交错场景,和配套代码 race_demo.py 的输出一致。每一步是某个参与者对数据库做的一次操作;表格右边几列是这一步之后数据库里那一行。切换实现可以对比朴素写法在哪一步出错;"不查租约"只在僵尸 worker 场景里和完整实现不同。
✎练习
running。应该怎么处理?cancelling,worker 在收到取消信号前已经把 Bug 报告提交出去了,随后上报 succeed。本章的实现记成什么?为什么?6面试要点
一句话讲清楚
长任务用显式状态机管理:8 个状态、7 种事件、12 条合法转换写成一张表,表外的组合一律拒绝且不落库;状态和事件日志在同一个事务里写,领取任务带版本号,运行中靠租约证明"我还活着",进程崩溃后由巡检把过期租约按可重试失败回收。测试按 56 格逐格对照独立写的规格,再用随机游走查不变量、用变异测试证明测试真能抓 bug。
追问准备
- 取消一个正在跑的任务,为什么不直接改成 cancelled?worker 还在跑,改了状态它也不知道,可能继续产生副作用。先进 cancelling,worker 在检查点看到后停下并上报 cancel_ack。
- 为什么要事件日志,tasks 表不够吗?tasks 表只有"现在",排查"它是怎么变成 failed 的"要靠日志。日志还能重放,重放结果和 tasks 表一致是一条很好的不变量。
- 租约设多长?要比心跳间隔长几倍,留出网络抖动和 GC 停顿的余量;太短会把慢 worker 误判成死了(回收后它的迟到上报会被拒绝:任务还在 retry_wait / queued 时是非法转换,已被别的 worker 领走时是 StaleLease),太长则崩溃后要等很久才回收。具体倍数没有通用答案,要看心跳实测延迟。
- 换成 Redis 怎么做?同样要把"检查再写入"做成原子的:Redis 文档说明 WATCH 给事务提供检查再写入(CAS)语义,被 WATCH 的 key 在 EXEC 前被改过,整个事务就放弃;也可以用脚本。注意 Redis 事务不支持回滚。
常见错误说法
❌ "非法转换测几个典型的就够了":8 × 7 的表只有 56 格,全测成本很低,漏的往往是没想到的那几格。
❌ "取消就是把状态改成 cancelled":正在跑的 worker 不知道,要有 cancelling 和 worker 确认。
❌ "进程重启后把 running 全部改回 queued":分不清死和慢,会双跑,也不计重试次数。
❌ "有了状态校验就不需要事务":校验和写入不在一个原子操作里,并发时校验读到的就是旧数据。
配套代码:machine.py(纯函数状态机)、store.py(SQLite 持久化)、test_machine.py / test_store.py(77 个测试)、race_demo.py(三个交错场景)、mutation_check.py(5 个变异)。下一章:重试、指数退避和幂等键。