营业厅准备关门。门口已经翻出“暂停取号”,显示屏上的待叫号码也归了零。值班的人拔掉电源,才发现窗口里还有一位顾客,后台还有两笔等着核实的业务。
这是一个假设场景,不是真实事故。它对应 Agent 执行系统里一个很常见的推理跳跃:入口关闭了,队列空了,所以工作已经结束了。 前两句话即使都是真的,也推不出第三句。任务可能已经被 worker 领走,也可能因为结果不明被移到隔离区;它们只是离开了待领取列表,没有离开我们的承诺。
第十三篇控制“还能接多少”,本篇作为 Agent 可靠执行实战第十四篇,处理另一个完整问题:准备维护时,怎样停止制造新承诺,同时让旧承诺继续完成,并把“已经排空”的判定保存下来,而不是寄托在某个进程的内存开关上。
我们实现一个独立的 Python 标准库与 SQLite 模型,提供代码、真实子进程退出、并发争用和冻结结果。业务载荷与本地效果都是合成样例,没有调用模型、外部工具或真实服务。它不是前十三篇全部功能的集成版,更不是生产滚动发布方案。
三个状态,分别回答三件事#
控制表只有一行:mode 表示状态,version 表示控制版本。状态只能是 open、draining 或 paused。
open 接受新身份的任务,也允许领取存量。draining 不再接受新身份,但继续允许领取已经承诺的任务。paused 则要求所有任务均已终结,并停止领取。这里的终结只采用本例能够自己证明的 done:本地效果与任务终态在同一个数据库事务里提交。
从 open 进入 draining,叫“开始排空”;从 draining 进入 paused,叫“确认排空”;从 draining 或 paused 回到 open,叫“恢复准入”。恢复 draining 意味着主动放弃这一轮排空,不是完成维护。每一次成功转换都使版本增加一,失败操作不增加。
为什么不一按开关就同时停止领取?因为停止领取会把剩余的 queued 永远留在原地。排空需要的是“停止接新活,继续做旧活”,不是把入口和出口一起堵住。反过来,如果仍允许新身份进入,那么刚观察到零未结任务,下一毫秒就可能又进来一条,所谓空场只是一张过时照片。
这个“维护屏障”必须限定名字的含义:它记录的是本协议内没有未结任务、暂不创建新任务的状态,不是一张可以立即改表、删除数据库文件或停止所有机器的许可证。仍然可能有读连接、重放请求和存活的旧进程;另一个有权限的控制者也可能恢复准入。本文没有锁住整段维护操作,更没有实现迁移所有权。
关闭入口,要与受理共用同一把事务锁#
最危险的实现往往长这样:先读一个布尔值,发现允许受理;然后开始写任务。与此同时,管理员把布尔值改成不允许受理,又观察到任务数为零,宣布排空完成。第一个进程随后提交,维护窗口里突然多出一份新工作。
本例让 admit 和控制转换都使用 BEGIN IMMEDIATE。准入检查与插入任务在同一个写事务里,关闭入口也是同一个数据库内的写事务。因此并发受理与开始排空存在一个确定的先后:受理先提交,就成为排空必须处理的存量;排空先提交,新身份就被拒绝。不会出现“已经接受但排空判定没算进去”的夹层。
SQLite 事务文档说明,同一数据库同时只能有一个写事务;IMMEDIATE 尝试立即取得写事务,也可能因为其他写者而得到 SQLITE_BUSY。示例设置十秒连接等待时间只是让小规模竞争实验容易运行,不是延迟承诺。锁等待最终失败时异常会暴露给调用者,不会把基础设施错误伪装成业务拒绝。
还要注意检查顺序:先查身份,再查入口。原身份和原载荷完全相同,返回 replay;同身份却改了载荷,返回 conflict;只有未见过的新身份才根据 mode 返回 accepted 或 closed。这让已经接受的请求在关闭入口以后仍能查询其存在,不至于把一张旧收据变成“从没受理”。
replay 在本文只表示记录存在且载荷一致,不表示任务已经完成。调用者需要继续读取状态。若第一次受理在提交前崩溃,之后入口关闭,重试得到 closed 是正确结果,因为系统没有留下那次承诺;若在提交后崩溃,重试得到 replay,也不会创建第二条任务。
本例没有持久配额,不能直接替换第十三篇的容量限制。组合时,应把“入口是否开放”和“是否还有容量”放进同一条准入事务,而不是把两个各自正确的检查串成两次独立提交。
排空不是数 queued,而是数所有未结#
确认排空的核心条件只有一行:
SELECT 1 FROM task WHERE state != 'done' LIMIT 1;
但它必须位于检查控制版本、检查 mode 和写入 paused 的同一个写事务内。条件成立时返回 busy,仍保持 draining;条件不成立才写入 paused 并递增版本。
本例任务从 queued 被领取后进入 inflight。领取返回 (任务 ID, generation),完成必须匹配这个 token。finish 把合成效果插入 effect 表,再把任务改为 done,同事务提交。任何旧 token 或已完成任务上的重复回调,都返回 stale,不会多写一份效果。
隔离不是完成。任务可以从 inflight 进入 quarantined,表示本地执行需要人工处理,它仍然阻止排空。可信操作方可以显式把隔离项重新放回 queued;下一次领取增加 generation,之前的完成回调就过期了。这里没有实现真实身份认证、审批或远端查账,隔离重投不能被误解为“已经获准重新调用一个不可幂等服务”。
这个设计也有故意保留的不舒适:worker 领走任务后死亡,inflight 可能一直阻止排空。本文没有自动租约回收或超时删除,因为“等得太久”不是“事情没有发生”的证据。需要恢复时,应结合第三篇的 unknown 与对账边界及第二篇的代次检查设计真实接收端协议,而不是为了让维护面板变绿就清空记录。
在这个封闭模型里,paused 之后旧 worker 的 finish 会被拒绝:能够转入 paused 就意味着没有 inflight,完成路径又要求当前状态恰是 inflight 且代次匹配。这是本地写入边界的保护,不是远端进程已被杀死。 如果把发送邮件放到 SQLite 事务之外,一个旧 worker 完全可能先发邮件再被本地 stale 拒绝。本文没有给那封邮件建立屏障。
版本不是装饰,它拦住上一轮的“重新开门”#
考虑如下控制时序:版本 0 时开始排空,变成 draining / 1;随后放弃排空,恢复为 open / 2;再次开始,变成 draining / 3。此时上一轮延迟到达的 resume 如果只看 mode 是 draining,就会替新一轮打开门。
因此每个控制命令都带上调用方观察过的 expected_version。转换事务先比较版本,不匹配就返回 stale,即使当前模式恰好又回到 draining,也不能执行。这个版本约束保护的是控制状态的更新,不是业务任务的 generation;两者回答不同的问题,不能混为一个“万能 token”。
同样,网络回执丢失后重试 drain,可能得到 stale。不要把 stale 直接解释为失败,也不要直接解释为成功。正确处理是重新读取 mode 和 version,检查当前状态是否还符合本次控制意图;若出现其他操作方的新转换,应重新决策。示例没有保存控制命令 ID 的永久回执,所以不能给丢失响应的控制请求提供“恰好执行一次”的接口承诺。
读取 snapshot 也使用一个读事务,避免把不同瞬间的 mode、任务数和效果数拼成一张自相矛盾的照片。但读事务结束后照片仍会过期。任何真正要依赖屏障执行的维护动作,都还需要适合该动作的互斥、权限和版本检查;仅仅把 version 打印到日志里不会使维护安全。
让进程真的退出,而不是只在代码里假装失败#
随附 test_drain.py 实际运行 16 项 unittest。输入是合成的,实际执行的是 SQLite 事务、操作系统子进程与多进程竞争;没有吞吐、生产延迟或事故率数据。
九个故障边界覆盖:受理提交前后、开始排空提交前后、本地效果写入后但终态写入前、完成提交前后、确认排空提交前后。子进程到指定位置调用 os._exit(73),父进程检查退出码,再用新连接重读数据库。不是抛一个被同一进程捕获的异常。
开始排空提交前退出,重开数据库仍是 open / 0;提交后退出,则保持 draining / 1。重新运行初始化不会把持久控制状态重置成开放。完成提交前退出,本地效果和 done 一起回滚,任务继续阻止排空;提交后退出,两者都在,确认排空可以成功。这些结果只覆盖设置的进程退出边界,不是断电、存储损坏或所有指令间隙的证明。
并发部分分两组。所有子进程先报告 READY 并等待;父进程收齐后再发出 GO,避免先启动的进程在其他进程尚未就绪时就把实验做完。十二个控制进程同时尝试从版本 0 排空,恰好一个返回 draining,其余十一个返回 stale。另一组让十二个新身份受理与一个排空进程竞争。这里不能承诺固定有几个请求先赢,取决于调度;实验不把某次赢家数量冻结成“可重复性能”。它检查五件可重复的性质:十二次请求全部落在 accepted 或 closed,恰好一次排空成功,accepted 数等于数据库未结数,之后的新请求一定 closed,以及只有未结为零时才可 paused。
冻结的 results.json 包含九组场景,其中竞争组保存上述不变量的布尔值,而不是隐藏变化后宣称并发顺序固定。--check 会重新启动子进程和争用,再逐字段比对,不是单纯读取一份现成答案。
下载并复现完整协议#
把下面五个文件放在同一空目录,使用 Python 3.10 或更新版本。脚本只创建和清理临时数据库,不需要第三方包、密钥、网络或模型。
- drain.py:持久控制状态、准入与本地效果
- test_drain.py:16 项测试与九个退出边界
- run_experiment.py:九组完整场景重放
- results.json:实际运行的冻结结果
- README.md:接口语义与限制
python3 test_drain.py
python3 run_experiment.py --check
预期分别看到 16 tests / OK 和 PASS: 9 scenario groups match results.json。不要把脚本路径改成真实数据库;不要为了让失败消失,先运行不带 --check 的命令覆盖基线。
可以从这个最短生命周期读懂接口,然后回到测试里检查崩溃与过期控制:
from pathlib import Path
from tempfile import TemporaryDirectory
import drain as d
with TemporaryDirectory() as folder:
p = Path(folder) / "demo.db"
d.init(p)
assert d.admit(p, "a", "合成任务") == "accepted"
assert d.transition(p, 0, "drain") == "draining"
assert d.transition(p, 1, "pause") == "busy"
assert d.finish(p, d.claim(p)) == "done"
assert d.transition(p, 1, "pause") == "paused"
assert d.admit(p, "a", "合成任务") == "replay"
assert d.admit(p, "b", "新任务") == "closed"
关门之后,还欠哪些证据#
首先,任务归零不等于 worker 退出。操作系统进程、数据库连接和网络请求都有自己的生命周期。Kubernetes 的 Pod 生命周期文档描述了终止宽限期与容器停止流程;这类运行时终止机制和本文的业务排空条件不是同一层。本文没有运行 Kubernetes,也没有验证 readiness、preStop 或强制终止时序。
其次,paused 不是数据库独占锁。本例仍允许读取、身份回放和合法控制转换,甚至一个旧进程还能调用初始化接口。要做真实 schema migration、备份一致性或文件替换,需要另外设计连接排空、维护所有权和恢复流程。我们只建立可检查的业务前置条件,没有执行任何这类维护动作。
最后,排空没有必然完成的保证。隔离项无人处理、inflight 永远不回来,或者操作方反复恢复准入,都会让排空迟迟不结束。生产流程需要明确等待期限、报警和人工决策,而不是超过一分钟就把 busy 改写成 paused。示例保留全部任务与效果历史,没有解决存储增长;也不提供公开 API 的输入防护、租户认证或跨节点一致性。
可靠维护的第一步,不是让控制台更快显示绿色,而是把绿色到底代表什么说清楚。门口不发新号,窗口继续办旧号,未完的业务仍在账上;只有这三件事同时成立,才能谈下一步。即使大厅终于空了,钥匙也不该因为一张截图就自动交给下一位施工者。
本文由 AI 辅助制作;代码、测试与冻结结果经过实际执行,业务载荷与效果为合成样例。配图为原创 SVG 机制示意,不含生产数值。官方文档核对日期:2026 年 10 月 22 日。

