# 持久排空与维护屏障：停止接单，不等于可以停机

> 把停止接单、消化存量与暂停执行拆成持久状态，用 SQLite 事务、控制版本和真实进程退出实验，验证排空时的重复请求、在途任务、隔离积压及过期恢复指令。

- 作者：芝士AI吃鱼
- 发布日期：2026-10-22
- 主题：Agent、AI工程、状态机、故障恢复、SQLite
- HTML 正文：[https://ai-knowledgepoints.cn/blog/agent-reliability-14-durable-drain-barrier](https://ai-knowledgepoints.cn/blog/agent-reliability-14-durable-drain-barrier)
- Markdown 永久链接：[https://ai-knowledgepoints.cn/blog/agent-reliability-14-durable-drain-barrier/index.html.md](https://ai-knowledgepoints.cn/blog/agent-reliability-14-durable-drain-barrier/index.html.md)

引用本文时，请注明作者与 HTML 正文链接。Markdown 版本用于机器阅读，与 HTML 正文共享同一内容来源。

## 正文

营业厅准备关门。门口已经翻出“暂停取号”，显示屏上的待叫号码也归了零。值班的人拔掉电源，才发现窗口里还有一位顾客，后台还有两笔等着核实的业务。

这是一个假设场景，不是真实事故。它对应 Agent 执行系统里一个很常见的推理跳跃：**入口关闭了，队列空了，所以工作已经结束了。** 前两句话即使都是真的，也推不出第三句。任务可能已经被 worker 领走，也可能因为结果不明被移到隔离区；它们只是离开了待领取列表，没有离开我们的承诺。

[第十三篇](https://ai-knowledgepoints.cn/blog/agent-reliability-13-durable-admission-backpressure)控制“还能接多少”，本篇作为 Agent 可靠执行实战第十四篇，处理另一个完整问题：准备维护时，怎样停止制造新承诺，同时让旧承诺继续完成，并把“已经排空”的判定保存下来，而不是寄托在某个进程的内存开关上。

我们实现一个独立的 Python 标准库与 SQLite 模型，提供代码、真实子进程退出、并发争用和冻结结果。业务载荷与本地效果都是合成样例，没有调用模型、外部工具或真实服务。它不是前十三篇全部功能的集成版，更不是生产滚动发布方案。

## 三个状态，分别回答三件事

控制表只有一行：`mode` 表示状态，`version` 表示控制版本。状态只能是 `open`、`draining` 或 `paused`。

`open` 接受新身份的任务，也允许领取存量。`draining` 不再接受新身份，但继续允许领取已经承诺的任务。`paused` 则要求所有任务均已终结，并停止领取。这里的终结只采用本例能够自己证明的 `done`：本地效果与任务终态在同一个数据库事务里提交。

从 open 进入 draining，叫“开始排空”；从 draining 进入 paused，叫“确认排空”；从 draining 或 paused 回到 open，叫“恢复准入”。恢复 draining 意味着主动放弃这一轮排空，不是完成维护。每一次成功转换都使版本增加一，失败操作不增加。

为什么不一按开关就同时停止领取？因为停止领取会把剩余的 queued 永远留在原地。排空需要的是“停止接新活，继续做旧活”，不是把入口和出口一起堵住。反过来，如果仍允许新身份进入，那么刚观察到零未结任务，下一毫秒就可能又进来一条，所谓空场只是一张过时照片。

![open 接受并执行，draining 拒绝新任务但继续执行存量，只有未结为零才进入 paused；恢复必须匹配控制版本](https://ai-knowledgepoints.cn/images/articles/agent-reliability-14/drain-barrier.svg)

原创逻辑状态图；暂停是本地任务屏障，不是独占数据库或进程已经退出的证明。

[查看竖版大图](https://ai-knowledgepoints.cn/images/articles/agent-reliability-14/drain-barrier-mobile.svg)

这个“维护屏障”必须限定名字的含义：它记录的是本协议内没有未结任务、暂不创建新任务的状态，**不是一张可以立即改表、删除数据库文件或停止所有机器的许可证**。仍然可能有读连接、重放请求和存活的旧进程；另一个有权限的控制者也可能恢复准入。本文没有锁住整段维护操作，更没有实现迁移所有权。

## 关闭入口，要与受理共用同一把事务锁

最危险的实现往往长这样：先读一个布尔值，发现允许受理；然后开始写任务。与此同时，管理员把布尔值改成不允许受理，又观察到任务数为零，宣布排空完成。第一个进程随后提交，维护窗口里突然多出一份新工作。

本例让 `admit` 和控制转换都使用 `BEGIN IMMEDIATE`。准入检查与插入任务在同一个写事务里，关闭入口也是同一个数据库内的写事务。因此并发受理与开始排空存在一个确定的先后：受理先提交，就成为排空必须处理的存量；排空先提交，新身份就被拒绝。不会出现“已经接受但排空判定没算进去”的夹层。

[SQLite 事务文档](https://www.sqlite.org/lang_transaction.html)说明，同一数据库同时只能有一个写事务；IMMEDIATE 尝试立即取得写事务，也可能因为其他写者而得到 SQLITE_BUSY。示例设置十秒连接等待时间只是让小规模竞争实验容易运行，不是延迟承诺。锁等待最终失败时异常会暴露给调用者，不会把基础设施错误伪装成业务拒绝。

还要注意检查顺序：先查身份，再查入口。原身份和原载荷完全相同，返回 `replay`；同身份却改了载荷，返回 `conflict`；只有未见过的新身份才根据 mode 返回 `accepted` 或 `closed`。这让已经接受的请求在关闭入口以后仍能查询其存在，不至于把一张旧收据变成“从没受理”。

`replay` 在本文只表示记录存在且载荷一致，**不表示任务已经完成**。调用者需要继续读取状态。若第一次受理在提交前崩溃，之后入口关闭，重试得到 closed 是正确结果，因为系统没有留下那次承诺；若在提交后崩溃，重试得到 replay，也不会创建第二条任务。

本例没有持久配额，不能直接替换第十三篇的容量限制。组合时，应把“入口是否开放”和“是否还有容量”放进同一条准入事务，而不是把两个各自正确的检查串成两次独立提交。

## 排空不是数 queued，而是数所有未结

确认排空的核心条件只有一行：

```sql
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 与对账边界](https://ai-knowledgepoints.cn/blog/agent-reliability-03-unknown-reconciliation)及[第二篇的代次检查](https://ai-knowledgepoints.cn/blog/agent-reliability-02-leases-fencing)设计真实接收端协议，而不是为了让维护面板变绿就清空记录。

在这个封闭模型里，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：持久控制状态、准入与本地效果](https://ai-knowledgepoints.cn/examples/agent-reliability-14/drain.py)
- [test_drain.py：16 项测试与九个退出边界](https://ai-knowledgepoints.cn/examples/agent-reliability-14/test_drain.py)
- [run_experiment.py：九组完整场景重放](https://ai-knowledgepoints.cn/examples/agent-reliability-14/run_experiment.py)
- [results.json：实际运行的冻结结果](https://ai-knowledgepoints.cn/examples/agent-reliability-14/results.json)
- [README.md：接口语义与限制](https://ai-knowledgepoints.cn/examples/agent-reliability-14/README.md)

```bash
python3 test_drain.py
python3 run_experiment.py --check
```

预期分别看到 `16 tests` / `OK` 和 `PASS: 9 scenario groups match results.json`。不要把脚本路径改成真实数据库；不要为了让失败消失，先运行不带 --check 的命令覆盖基线。

可以从这个最短生命周期读懂接口，然后回到测试里检查崩溃与过期控制：

```python
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 生命周期文档](https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/)描述了终止宽限期与容器停止流程；这类运行时终止机制和本文的业务排空条件不是同一层。本文没有运行 Kubernetes，也没有验证 readiness、preStop 或强制终止时序。

其次，paused 不是数据库独占锁。本例仍允许读取、身份回放和合法控制转换，甚至一个旧进程还能调用初始化接口。要做真实 schema migration、备份一致性或文件替换，需要另外设计连接排空、维护所有权和恢复流程。我们只建立可检查的业务前置条件，没有执行任何这类维护动作。

最后，排空没有必然完成的保证。隔离项无人处理、inflight 永远不回来，或者操作方反复恢复准入，都会让排空迟迟不结束。生产流程需要明确等待期限、报警和人工决策，而不是超过一分钟就把 busy 改写成 paused。示例保留全部任务与效果历史，没有解决存储增长；也不提供公开 API 的输入防护、租户认证或跨节点一致性。

可靠维护的第一步，不是让控制台更快显示绿色，而是把绿色到底代表什么说清楚。门口不发新号，窗口继续办旧号，未完的业务仍在账上；只有这三件事同时成立，才能谈下一步。即使大厅终于空了，钥匙也不该因为一张截图就自动交给下一位施工者。

系列讨论可查看[知识星球](https://ai-knowledgepoints.cn/planet)，或通过[公众号入口](https://ai-knowledgepoints.cn/about#wechat)关注更新。

本文由 AI 辅助制作；代码、测试与冻结结果经过实际执行，业务载荷与效果为合成样例。配图为原创 SVG 机制示意，不含生产数值。官方文档核对日期：2026 年 10 月 22 日。
