第 1 章:先定义任务怎样结束,再讨论怎样继续
用户让助手处理一笔售后工单,通常只看到一个“执行中”的状态。后台却可能已经经过模型规划、业务检查、人工审批、排队、工具调用和结果通知。任何阶段都可能跨越进程的生命周期。要讨论可靠执行,先把这些阶段分别负责的事实写清楚。
1.1 请求、执行尝试和业务操作有不同身份
HTTP 请求适合表达一次通信。worker 尝试适合表达某个执行者在一段时间内承担的工作。业务操作则代表用户希望完成的一件事情。这三种身份不能都被一个每次重试都会重新生成的请求编号替代。
以给账户发放教学积分为例,业务操作可以是“租户 A 对工单 42 的补偿”。它经历两次请求、三次 worker 尝试,仍应指向同一项已批准意图。否则每次网络超时都可能获得一个新的操作键,下游就无法判断它是不是重复执行。
相反,同一个请求也可能创建多个不同业务操作,例如同时处理两张工单。只凭请求编号去重,又可能把合法的第二项工作误认为重复。身份的粒度取决于承诺的业务事实,不能只取决于接入框架已经提供了哪些字段。
1.2 从一张执行合同开始
本书的示例只执行一个副作用节点:向教学账户发放一定数量的积分。计划包含动作、账户、以最小单位表示的数量和币种,进入系统后固定下来。发放积分是教学账本变化,不涉及真实资金。
| 阶段 | 已知事实 | 仍然不能推导的结论 |
|---|---|---|
| 提案已保存 | 格式与业务范围通过准入 | 操作者已经同意执行 |
| 审批已保存 | 某主体确认了这份具体意图 | 以后任何时间都可以调用 |
| 已领取 | 当前执行者持有本地租约 | 旧执行者一定已经停止 |
| 工具响应成功 | 收到与原意图匹配的回执 | 本地终态一定已经提交 |
| 工具结果未知 | 当前证据不足以裁决 | 工具没有产生效果 |
| 完成已记录 | 本地依据匹配回执保存结果 | 通知一定已经被用户看到 |
合同帮助我们识别缺失的证据。例如一条“worker 已开始”日志,只能说明执行者走到了某个位置。若日志写在调用工具之前,它不能证明效果已经发生;若日志没有可靠刷新,缺少这条日志也不能证明工具没有被调用。
1.3 完成是可查询的业务事实
示例中的成功要求两个条件:工具保存了与原意图相符的回执,执行引擎也把这个结果记录为终态。第二个条件迟于第一个并不罕见。进程可能恰好在它们之间退出,之后必须通过查询和核查补齐本地记录。
工具事务提交
↓
工具返回回执
↓
worker 校验回执
↓
工作流终态与事件共同提交
箭头表示顺序,不表示这四步共享一个数据库事务。本书故意保留这个缺口,使恢复责任可以被观察。即使实验把两套表放在同一 PostgreSQL 实例,不同连接、不同事务仍然不能自动组成一个原子业务操作。
失败也需要具体定义。可信工具在副作用之前明确拒绝,和工具已接收请求但连接断开,是不同证据。把两者都写成 failed,会诱导调度器采用同一种重试方式。结果未知需要独立状态,以便查询、延迟核查或人工介入。
1.4 四项贯穿全书的不变量
第一,同一租户中的同一业务操作,不能在重试时悄悄更换已批准参数。第二,过期或被替代的领取者,不能再提交本地执行终态。第三,自动重放必须有受信任的工具合同和预算支持。第四,缺少工具结果证据时,系统必须允许保留未知状态。
这些不变量不能单靠数据库字段名提供。唯一约束保护操作身份,条件更新保护拥有权,回执校验连接意图与效果,审批规则保护执行许可。每项规则都需要能违反它的竞争或故障实验,否则“已经设计了这个字段”仍然只是实现意图。
同样,不应从一次成功运行推导所有失败都可恢复。实验范围只有单个副作用节点;一项流程若先发积分再发短信,就有两个独立效果。短信失败时怎样表达整体状态、是否补偿积分,需要增加新的业务合同,不能随手把两个节点当作同一个重试单位。
1.5 用故障窗口组织学习
每增加一段持久化或一次网络调用,都问三个位置:动作之前退出、动作之后但确认之前退出、确认之后但本地记录之前退出。不同窗口可能产生相同的客户端错误,却留下不同事实。
读者可以先画一条时序线,标出用户、执行数据库、worker 和工具四个参与者。把每项“成功”放回它所描述的参与者,再思考另一位 worker 能从哪里查询。这样的图能够直接导出测试点,也能让评审者发现没有承担者的核查责任。
练习。 一项任务的最后一步是发送邮件。SMTP 调用超时,本地没有保存发送成功。请分别写出“邮件肯定没发出”“邮件可能已发出”“邮件已被确认发出”需要的证据,并说明哪一种状态允许直接从头执行整个任务。