返回文章列表

持久取消与安全停靠:点了取消,为什么还得等一张回执?

芝士AI吃鱼2026年10月26日17 分钟阅读阅读统计加载中

用户按了取消,页面立刻显示“已停止”。几秒后,一封邮件还是发了出去。

如果后台只是把按钮变灰、停止等结果,这个结局并不神秘。真正麻烦的是,后来的人相信了那三个字,把“用户不想继续”理解成“刚才什么都没发生”。于是又执行了一次。

这是本文构造的故障场景,不是真实用户事故,也没有在实验中发送邮件。第四篇已经区分取消等待和撤销效果;第十四篇讨论整个队列的维护排空。本篇作为可靠执行系列第十八篇,处理另一项具体协议:一个任务的取消指令如何持久保存,怎样挡住迟到的提交,以及什么时候才能确认“不再继续”。

随附项目使用 Python 标准库和 SQLite。请求文本、步骤结果都是合成数据;真实运行的是事务、进程退出、锁竞争及重启读取。它不接入模型、邮件服务或真实 RPC,也不声称能够强行终止任意工具。先把这个边界说清,后面的绿色状态才不会比取消按钮更冒进。

取消请求和业务结果,别挤在同一个格子里#

只用一个 status 字段,常常会逼实现撒谎。

任务刚完成最后一步,取消指令也到了。如果写 cancelled,就容易把实际成功藏起来;如果只写 succeeded,又丢失了用户已经提出停止的控制事实。两者的先后还会影响取消回执,不该靠刷新页面碰运气。

本例分成两条状态轴。control 保存 run、requested、acknowledged,分别表示可以申请下一步、已请求取消、协议已经确认停靠。result 保存 open、succeeded、failed,描述固定步骤清单的完成情况。open 只是清单尚未全部成功、也没有明确失败,不能单独解释为任务仍在运行。

因此 acknowledged/open 是合法组合:已经确认不再继续,但后面的步骤没有完成。acknowledged/succeeded 也可能成立:取消请求到达时最后一步已经在途,随后拿到了成功回执,才确认停靠。确认没有下一步,并不会把最后一步的成功撤销。

反过来,如果最后一步的成功先提交,之后才首次请求取消,接口返回 too_late,control 保持 run,result 仍为 succeeded。这里 run 是“未受理取消控制”的枚举值,不表示已完成的任务仍可执行;领取入口同时检查 result。生产界面最好显示“已完成,取消未生效”,而不是把内部枚举直接翻译成“运行中”。

这些不是文字游戏。恢复进程、客服查询和补偿流程都可能读取这份记录。把停止原因与已知结果分开,才能解释同一时刻看似矛盾的事实。

取消比提交先到,也必须留下东西#

一次提交请求在路上延迟,用户已经点了取消。取消接口查不到 job,返回“任务不存在”;随后提交抵达,任务照常开始。

如果产品契约承诺撤回这次精确意图,仅仅返回不存在就不够。本例的 request_cancel 接收 job_id 和完整有序 steps 清单。若任务尚未登记,就在同一事务中写入 admitted=0、control=acknowledged 的取消墓碑,并保存该清单及取消回执。

之后同 ID、同清单的 submit 得到 blocked_cancelled,不能复活;同 ID 换清单得到 conflict,也不能借换内容绕过旧记录。墓碑不是“忽略这个名字以后出现的任何请求”,而是和一份不可变意图绑定的阻挡记录。

已登记任务同样检查精确清单。取消携带的请求内容或顺序不一致,得到 spec_conflict,原任务不被改变。这里只使用有序字符串列表,序号区分步骤,顺序变化就是意图变化。真实业务还需要把工具版本、目标、参数及租户作用域纳入身份,不能以本文这份短列表替代完整生产契约。

同样重要的是权限。代码中的取消入口接受可信本地输入,没有验证“谁有权取消谁”。一套允许任意调用者随手创建取消墓碑的公网接口,完全可能被拿来抢占别人的任务 ID。本例验证身份绑定和持久化,不提供身份认证;接入时必须先解决这层授权。

墓碑也没有自动过期。如果随手删除它,旧提交就可能再次出现。第六篇讨论过保留和代次封口的代价;取消记录面临同类问题。永久保留会增长存储,删除则必须有迟到请求不再有效的证据。代码没有偷偷用一个 TTL 把这个难题省略。

挡住下一次预留,不代表上一项已经停下#

固定清单保存后,reserve 每次只预留下一项步骤。每项步骤可能是 pending、inflight、unknown、ok 或 rejected。同一任务最多一项处于 inflight 或 unknown,前面的步骤必须已经 ok,才能预留下一项。

预留记录是持久的。它表示某一步已经越过“准备执行”的协议边界,不证明请求已到达外部服务。重复 reserve 如果看到已有在途项,只返回 inflight 和原序号;这是一条恢复线索,不是授权再次调用不可幂等工具。

取消请求受理后,control 变成 requested,新的 reserve 被拒绝。但一项在取消之前已经预留的动作,仍可能随后才发送。本例没有把数据库里的控制位想象成跨网络刹车。预留进程可能刚读完数据,也可能已交给工具执行;协调者不知道它具体走到了哪里。

所以 checkpoint 看见 inflight 或 unknown 时,返回 inflight,不能确认 acknowledged。取消按钮按得再急,也不能替这项工作编造一个“确定没发生”的结果。

gRPC 的取消说明指出,库通常无法直接打断应用处理器,长时间运行的处理器需要主动检查取消并配合停止。本文没有运行 gRPC;引用它是为了对照“取消需要执行方合作”的边界,不是把这个本地示例当成 RPC 取消实现。

还有一个很不讨喜却必须保留的情况:刚预留就崩溃,实际上根本没发送。对本例的持久记录来说,它仍然未决,不会自动归还成 pending。要证明“没发送且以后也不会发”,需要额外的派发日志、接收端封口或可信对账协议。本篇不具备这些能力,宁可停在待核验,也不把一次进程重启变成再次发送的许可。

取消请求阻止新预留,已有在途或unknown仍需结清;没有未结预留后确认停靠,保留已完成结果和未执行步骤
原创协议图:可信合成回执与本地事务;确认停靠不代表撤销效果、杀死进程或远端绝对停止。 查看大图 ↗

回执可以迟到,结论不能随手改写#

report 接受三个结论:unknown、ok、rejected。unknown 保留未结预留,value 必须为空;ok 和 rejected 结清该项,并保存有界结果文本。rejected 必须来自可信的明确拒绝证据,不能用“暂时查不到”或超时推测替代。

取消请求已经持久化之后,合法的迟到回执仍然需要接收。比如第一步实际完成,第二步尚未开始,此时收到第一步的 ok,项目保存这份成功,把第一步变为终态。它不会因为用户点过取消,就把结果当垃圾扔掉。

接着 checkpoint 可以把 control 变成 acknowledged:没有未结预留,也不会再申请第二步。第一步的成功值和第二步的 pending 都保留,result 仍然 open。这比一个没有解释的 cancelled 更诚实,也为后续有授权的人工处置提供了事实基础。

如果在途的正好是最后一步,迟到 ok 会把 result 变成 succeeded;随后确认取消,得到 acknowledged/succeeded。取消成功的是“阻止继续申请工作、到达协议安全点”这项控制,不是把已经完成的业务变回未发生。若产品不接受这种表达,应当改产品状态展示,而不是删掉成功记录。

如果迟到的是 rejected,result 会变成 failed。已有取消请求仍可在没有在途项后确认;失败也保留下来。示例没有自动执行补偿,更没有授权根据“取消”重新发起一项撤回操作。第十篇的补偿是另一个有对象、有证据、有授权边界的流程。

消息身份同样不能糊弄。取消和结果回执共用一个全库 event_id 空间;事件 ID 绑定命令类型、任务、清单或结果内容。相同 ID、相同内容重放原解释,同 ID 改内容返回 event_conflict。

这意味着第一次 unknown 后,必须用新的事件报告后续核验的 ok;原 unknown 事件重试仍返回 unresolved。第一次取消回执是 requested,即使后来 checkpoint 已确认,重放那条旧事件仍然得到 requested。原回执回答“那一次指令怎样被处理”,inspect 才回答“现在状态是什么”。让旧回执跟着现状改口,会破坏重试的可解释性。

未登记任务的回执、尚未预留步骤的回执,也会被明确拒收并记住。同一个拒收事件后来重试,不能悄悄被接受。换 ID 是否允许重新提交,是上层的明确决定,不是基础库自动修正业务事实的捷径。

让取消、最后完成和重启共用一个提交边界#

核心操作在一个 SQLite 文件里用 BEGIN IMMEDIATE 事务完成。取消控制位和取消回执一起提交;步骤终态、整体结果和报告回执一起提交;停靠确认则在检查没有在途项后,同事务修改控制位。

SQLite 官方事务文档说明,一个数据库同时只能有一个写事务,IMMEDIATE 会立即尝试开始写事务,也可能遇到 SQLITE_BUSY。这里用的是单库写入的串行边界,不是跨服务事务;连接等待 30 秒只是小规模演示的锁等待配置,不承诺任意负载下都能完成。

取消与最后完成之间因此会形成明确次序。如果成功事务先提交,取消得到 too_late;如果取消先提交,最后的合法回执仍能结清在途项,之后再确认停靠。两条路径都保留成功,不靠“谁最后写 status 就覆盖谁”来解决竞争。

崩溃也不能切开这些关系。步骤更新后、回执保存前退出,整个事务回滚;提交后但响应返回前退出,状态和回执一起留下,原事件重试重放既有解释。早取消墓碑的任务行、全部步骤和取消回执也同成同败,不留下半份清单。

不过,数据库串行化只约束经过这些入口的调用。管理员直接改表、旁路工具、多个数据库里重复使用同一业务意图、旧执行尝试伪造结果,都不在本例的保证内。项目没有 lease、fencing 或执行代次,不能靠“第一个到达的回执”判断生产中哪个 worker 有资格代表这项工作。

同样,acknowledged 不是进程表。它只确认本协议内没有未结预留,并且该任务不再产生新预留;没有证明操作系统进程已经退出,也没有独立验证外部工具以后不会产生迟到副作用。能否相信终态报告,仍取决于接收端契约和上层证据。

故障实验:不只把状态改成红色#

36 项单元测试覆盖早取消墓碑、清单冲突、跨命令事件冲突、重复回执、未预留拒收、unknown 保留、成功和拒绝的迟到结果、顺序预留、取消确认与 UTF-8 字节边界。每个测试结束都检查跨表不变量。

run_experiment.py 真正运行 12 组场景,其中包含 24 个进程退出边界。实验分别在提交、早取消墓碑、已提交任务的取消、预留、结果报告及停靠确认中调用 os._exit(73),父进程核对实际退出码。提交前必须完整回滚,提交后必须完整保留;随后重新连接并重试,检查状态与无故障路径一致。

四组并发实验各启动八个进程,所有进程先报告 READY,测试驱动再统一发 GO。它们分别竞争提交与取消、预留与取消、最后回执与取消,以及同一任务的停靠确认。实验不冻结哪一方赢锁:有时取消先到,没有在途项;有时预留先到,必须结清后才能确认。冻结的是两种合法次序都必须遵守的约束。

最能说明边界的一组实验使用两个临时 SQLite 文件。协调库先预留第一步,另一个真实子进程在接收端库写入一条合成 effect,提交后立即退出,尚未向协调库回报。此时请求取消,checkpoint 必须继续返回 inflight。

测试驱动重新读取接收端效果,拿到可信的合成核验结果后才报告 ok,再确认停靠。取消前后那条 effect 都仍然存在,下一步没有被预留。这里的查询是实验仪器的特权观测,不代表真实网络里任何一次“查不到”都能证明未执行,更不提供跨库原子性。

另附独立 audit_invariants.py,不直接依赖实现的状态转换来计算预期。实际执行 48 个种子、每个 300 次操作,共 14,400 次参考模型比较,包含 2,881 次显式重复尝试;再运行 32 组各四个门控进程,共 128 个进程。每组把返回值和最终数据库与至多 24 种串行排列对照,核验这批实际并发历史能被合法次序解释。

这些是有限实验,不是形式化证明、生产压力测试或可靠率统计。结果文件不记录谁赢了多少次,也不把运行耗时包装成取消延迟。测试中的等待上限只防止子进程挂死,与业务 deadline 无关。

下载运行,先看两个状态,再谈接入真实工具#

把以下六个文件放在同一目录,使用 Python 3.10 或更新版本运行,无需第三方 Python 包、网络或密钥。不要使用 python -O,因为实验断言本身就是检查的一部分。

python3 test_cancellation.py
python3 run_experiment.py --check
python3 audit_invariants.py

第一条应报告 Ran 36 tests 和 OK;第二条应报告 PASS: 12 scenario groups match results.json。--check 会重新建立临时数据库、启动子进程执行,再与冻结文件对照;不是读取一份 JSON 后打印“通过”。不带 --check 会有意重建结果文件,不应用来掩盖没有解释的差异。

下面这段最小例子保留第一步的成功,但不执行第二步:

from pathlib import Path
from tempfile import TemporaryDirectory
import cancellation as c

with TemporaryDirectory() as folder:
    db = Path(folder) / 'cancel.db'
    steps = ['合成步骤A', '合成步骤B']
    c.init(db)
    c.submit(db, 'job-a', steps)
    c.reserve(db, 'job-a')
    c.request_cancel(db, 'cancel-1', 'job-a', steps)
    assert c.checkpoint(db, 'job-a') == 'inflight'
    c.report(db, 'result-1', 'job-a', 0, 'ok', '合成成功结果')
    assert c.checkpoint(db, 'job-a') == 'acknowledged'
    assert c.reserve(db, 'job-a')['status'] == 'cancelled'
    assert c.check_invariants(db)

真实接入还有明确缺口:谁持续发现取消请求并运行 checkpoint,谁核验在途结果,如何验证调用者身份和执行代次,如何限制未决任务及墓碑的存储增长。这个项目没有后台扫描器、自动超时、重试调度、生产背压或清理协议。一个在途结果永远无法结清,取消确认就可能永远停在 requested;没有自动恢复能力的地方,不应写成“最终一定会停止”。

回到开头的取消按钮。可以立刻告诉用户“取消请求已接收,正在核验已发出的步骤”。等证据足够,再显示已经停止继续处理,同时保留哪些步骤实际完成、哪些未被本协议预留。按钮没有变慢,只是终于没有替后台抢答。

可继续阅读第十七篇的持久并行汇合,讨论多个分支的未决结果如何影响整体完成。进一步交流见知识星球与公众号入口。

本文由 AI 辅助制作。代码、SQLite 事务及进程实验已经实际运行;业务请求、回执和效果为合成样例,未调用真实模型、RPC 或外部业务工具。配图按白底技术信息图文字要求原创绘制,原始参考图尚待补充核对。官方资料核对日期:2026 年 10 月 26 日。

AI 实践

想把本文的问题继续做深一点?

知识星球里会继续整理相关案例、工程约束和问题讨论;具体内容以当前社区页面为准。

AI 实践知识星球加入海报,包含可扫描二维码
芝士AI吃鱼公众号二维码

关注「芝士AI吃鱼」公众号持续更新

在这里,我用「人话」和「漫画」拆解 AI 技术。公众号更新会同步整理到本站,方便继续阅读、检索和订阅。

点击二维码可放大,使用微信扫码关注。

芝士AI吃鱼

持续记录 AI 原理和工程实践

更多文章