第 2 / 23 篇

第 1 章:先定义任务怎样结束,再讨论怎样继续

用户让助手处理一笔售后工单,通常只看到一个“执行中”的状态。后台却可能已经经过模型规划、业务检查、人工审批、排队、工具调用和结果通知。任何阶段都可能跨越进程的生命周期。要讨论可靠执行,先把这些阶段分别负责的事实写清楚。

1.1 请求、执行尝试和业务操作有不同身份

HTTP 请求适合表达一次通信。worker 尝试适合表达某个执行者在一段时间内承担的工作。业务操作则代表用户希望完成的一件事情。这三种身份不能都被一个每次重试都会重新生成的请求编号替代。

以给账户发放教学积分为例,业务操作可以是“租户 A 对工单 42 的补偿”。它经历两次请求、三次 worker 尝试,仍应指向同一项已批准意图。否则每次网络超时都可能获得一个新的操作键,下游就无法判断它是不是重复执行。

相反,同一个请求也可能创建多个不同业务操作,例如同时处理两张工单。只凭请求编号去重,又可能把合法的第二项工作误认为重复。身份的粒度取决于承诺的业务事实,不能只取决于接入框架已经提供了哪些字段。

1.2 从一张执行合同开始

本书的示例只执行一个副作用节点:向教学账户发放一定数量的积分。计划包含动作、账户、以最小单位表示的数量和币种,进入系统后固定下来。发放积分是教学账本变化,不涉及真实资金。

阶段 已知事实 仍然不能推导的结论
提案已保存 格式与业务范围通过准入 操作者已经同意执行
审批已保存 某主体确认了这份具体意图 以后任何时间都可以调用
已领取 当前执行者持有本地租约 旧执行者一定已经停止
工具响应成功 收到与原意图匹配的回执 本地终态一定已经提交
工具结果未知 当前证据不足以裁决 工具没有产生效果
完成已记录 本地依据匹配回执保存结果 通知一定已经被用户看到

合同帮助我们识别缺失的证据。例如一条“worker 已开始”日志,只能说明执行者走到了某个位置。若日志写在调用工具之前,它不能证明效果已经发生;若日志没有可靠刷新,缺少这条日志也不能证明工具没有被调用。

1.3 完成是可查询的业务事实

示例中的成功要求两个条件:工具保存了与原意图相符的回执,执行引擎也把这个结果记录为终态。第二个条件迟于第一个并不罕见。进程可能恰好在它们之间退出,之后必须通过查询和核查补齐本地记录。

工具事务提交
    ↓
工具返回回执
    ↓
worker 校验回执
    ↓
工作流终态与事件共同提交

箭头表示顺序,不表示这四步共享一个数据库事务。本书故意保留这个缺口,使恢复责任可以被观察。即使实验把两套表放在同一 PostgreSQL 实例,不同连接、不同事务仍然不能自动组成一个原子业务操作。

失败也需要具体定义。可信工具在副作用之前明确拒绝,和工具已接收请求但连接断开,是不同证据。把两者都写成 failed,会诱导调度器采用同一种重试方式。结果未知需要独立状态,以便查询、延迟核查或人工介入。

1.4 四项贯穿全书的不变量

第一,同一租户中的同一业务操作,不能在重试时悄悄更换已批准参数。第二,过期或被替代的领取者,不能再提交本地执行终态。第三,自动重放必须有受信任的工具合同和预算支持。第四,缺少工具结果证据时,系统必须允许保留未知状态。

这些不变量不能单靠数据库字段名提供。唯一约束保护操作身份,条件更新保护拥有权,回执校验连接意图与效果,审批规则保护执行许可。每项规则都需要能违反它的竞争或故障实验,否则“已经设计了这个字段”仍然只是实现意图。

同样,不应从一次成功运行推导所有失败都可恢复。实验范围只有单个副作用节点;一项流程若先发积分再发短信,就有两个独立效果。短信失败时怎样表达整体状态、是否补偿积分,需要增加新的业务合同,不能随手把两个节点当作同一个重试单位。

1.5 用故障窗口组织学习

每增加一段持久化或一次网络调用,都问三个位置:动作之前退出、动作之后但确认之前退出、确认之后但本地记录之前退出。不同窗口可能产生相同的客户端错误,却留下不同事实。

读者可以先画一条时序线,标出用户、执行数据库、worker 和工具四个参与者。把每项“成功”放回它所描述的参与者,再思考另一位 worker 能从哪里查询。这样的图能够直接导出测试点,也能让评审者发现没有承担者的核查责任。

练习。 一项任务的最后一步是发送邮件。SMTP 调用超时,本地没有保存发送成功。请分别写出“邮件肯定没发出”“邮件可能已发出”“邮件已被确认发出”需要的证据,并说明哪一种状态允许直接从头执行整个任务。