# M06 服务化、事件流与数据一致性

> 本章产出：稳定的任务API、可重连事件流、数据库对象关系和Outbox设计。网页展示与任务执行生命周期分离。

## 1. 聊天连接不是任务所有者

用户关闭标签页，不一定想取消已经批准的任务。反过来，连接还活着也不能证明任务仍应运行。服务必须把“连接”“会话”“任务”“一次执行尝试”分成不同对象。

课程约定：`conversation_id`关联对话；`run_id`是一项可验收任务；`attempt_id`是某次Worker执行；`operation_id`是一项业务副作用意图。一个run可以经历多次attempt，但同一退款意图的operation_id不能随恢复变化。

## 2. 一个足够完整、但不臃肿的API

`POST /v1/runs`接收任务，客户端提供请求幂等键，认证主体来自会话。服务端创建run并返回202与run_id。相同主体、相同键、相同有效请求返回原run；相同键却不同请求返回冲突。

`GET /v1/runs/{id}`读取授权范围内的状态。`GET /v1/runs/{id}/events?after_seq=n`获取事件。`POST /v1/approvals/{id}/decision`处理批准或拒绝；`POST /v1/runs/{id}/cancel`提出取消请求。详细字段与错误码在`specs/API_CONTRACT.md`。

API的幂等键与退款操作的幂等键属于不同层次。用户刷新提交按钮需要去重创建run；同一个run恢复时需要去重退款；不能用一个未经定义的UUID解决所有去重问题。

权限检查必须覆盖读取、事件订阅、取消、审批和artifact下载。只给创建接口加鉴权，会让run_id成为越权读取入口。可猜性低的ID是辅助，不是访问控制。

## 3. 三个视图，不要共用一张随意JSON表

业务表保存订单、审批和操作事实；执行表保存run状态、版本、租约和预算；事件表保存面向UI或恢复的可排序事件。小项目可以在同一个关系数据库里实现，不必立刻拆微服务。

一个run状态行包含`status`、`version`、`owner`、`lease_until`、`fence`、`cancel_requested`。事件行包含`run_id`、`seq`、`type`、`payload`和`created_at`，以`(run_id,seq)`唯一约束避免重复序号。

状态更新与对应事件插入应该在同一事务中。否则可能出现任务已经成功但UI永远没有成功事件，或者UI显示成功但事务回滚。数据库事务的边界是明确的本地边界，不延伸到模型API。[S30](../../references/SOURCES.md#s30)

## 4. SSE是通知机制，不是执行引擎

浏览器可以通过Server-Sent Events查看进度。服务端为每个事件提供稳定ID，客户端重连时带上最后已处理序号。重放可能包含重复事件，客户端按ID去重；事件缺口则重新获取状态或历史。

关键不变量是：重连不重新创建run、不重新调用工具、不把已经收到的成功事件执行第二次。UI应该以服务端持久状态为事实源，而不是只靠一条内存中的流。

模型token流与业务事件流不同。模型的增量文本只表示生成进度；`refund.committed`这样的业务事件必须来自确定的业务结果。供应商也可能采用SSE输出模型响应，但你自己的任务事件协议应独立定义。[S36](../../references/SOURCES.md#s36)

<!-- interaction:I12 -->

## 5. 慢客户端不应该拖住退款任务

服务端不能为了一个断网浏览器无限缓存token。为连接设置有限缓冲；超限时停止推送并要求客户端按游标恢复。持久化的任务状态仍继续推进。

不要把大日志、完整文件和每个token都塞进高频事件表。事件保存摘要和artifact引用；原始输出放到有配额、有权限检查的对象存储或本地教学目录。审计所需内容则按明确保留策略保存。

跨实例推送可以用消息通道唤醒订阅者，但持久事件表是恢复来源。消息通知丢失后，客户端或服务端轮询仍能发现新事件。临时Pub/Sub不应承担唯一事实源的角色。

## 6. 创建任务成功，入队失败怎么办

一种常见代码是：先插入run并提交，再发送队列消息。进程恰好在两步之间崩溃，数据库里有任务，队列里却没有。反过来先发送消息再提交，Worker可能处理一个最终没有创建成功的任务。

Outbox的做法是把run状态和“需要投递的消息”写在同一数据库事务。独立投递器读取Outbox，把消息发到队列，然后标记已发送。这样解决的是**本地写入与投递意图的一致性**。

投递器可能在发送成功后、标记前崩溃，所以消息仍可能重复。消费者必须幂等，必要时用Inbox或run状态版本去重。Outbox不是端到端exactly-once开关。

<!-- interaction:I13 -->

## 7. 数据库队列与Redis什么时候用

课程基线可以直接用PostgreSQL任务表配合短事务领任务；`FOR UPDATE SKIP LOCKED`常用于避免多个消费者等待同一已锁行，但它不提供公平调度，也不自动形成长期租约。[S31](../../references/SOURCES.md#s31)

领任务时选一行，写入owner、lease和递增fence，然后尽快提交。不要在持有行锁的事务里等待模型几十秒。工作期间通过短事务续租，完成时用版本和fence条件提交。

Redis可以承担速率限制、短期缓存或队列，但每引入一个存储，就增加一类一致性问题。先明确哪一个存储拥有事实、消息是否可重复、持久化配置与恢复流程，再决定是否需要它。不是Backend项目就必须出现Redis。

## 8. 并发下的状态更新

设run版本为7。Worker A和B都读到7。A把它改为等待审批并写入版本8；B迟到地把它改为失败。若B直接覆盖，用户刚看到的审批消失；若B使用`WHERE version=7`，更新失败后重新读取即可发现状态已变。

这类乐观并发控制保护数据库状态，但不能自动阻止B已经发出的外部退款。外部副作用需要M07的幂等与fencing边界。不要把本地CAS的保证扩大到整个系统。

## 9. 服务化测试与交付

L05第二部分要求验证：重复创建run只产生一项任务；跨用户读取事件被拒绝；断线后事件可恢复；重复审批不重复提交操作；API重启不清空任务；Outbox重复投递不造成重复业务效果。

网页Agent不需要在课程网站里直接部署这个业务后端。网站展示协议、状态机和模拟器；学习者在本地完成服务实验。这样既减少实现负担，也避免让学习网站意外成为一个允许任意代码执行的公共服务。
