feat: add durable business task outbox example and fault recovery

This commit is contained in:
2026-09-17 23:13:48 +08:00
parent 2b8213a4f7
commit 522fe2ef3e
27 changed files with 2195 additions and 7 deletions

View File

@@ -2,6 +2,14 @@
示例只使用通用的作业、对象、文件、资源与审核信号,不包含接入方项目名称或行业规则。
## 已可运行:业务事务与任务提交Outbox
- 独立模块:[task-outbox](../../examples/task-outbox/README.md),[完整前置、命令、Go方法及故障处理](task-outbox.md)。
- 操作:业务记录和待发请求同事务 → 持久化领取 → 原幂等键HTTP提交 → 原回执与业务关联同事务确认。
- 预期:local_duplicate=true、state=ACCEPTED、sends=1;ACCEPTED仅表示平台受理,演示随后取消任务并保留历史。
- 覆盖:8个并发调用/20项领取、业务回滚、旧代次拒绝、HTTP回执丢失、三处MySQL COMMIT回执丢失及发送进程SIGKILL恢复。
- 边界:业务侧两张表均无外键,22个中文注释字段;固定发送预算,REVIEW保留原记录供业务管理核查,核心SDK不引入数据库依赖。
## 已可运行:健康检查
- 源码:[SDK 健康检查](../../sdk/go/examples/health/main.go)。

View File

@@ -0,0 +1,174 @@
# 业务事务与任务提交 Outbox
完整项目:[task-outbox](../../examples/task-outbox/README.md)。它演示接入方保存对象处理请求的业务记录,同时保存向Orbit提交任务的待发命令。平台受理后,再在业务库里同时确认Outbox与任务关联。核心SDK继续独立,不依赖MySQL或ORM。
## 1. 数据与状态
[中文模型](../../examples/task-outbox/internal/outbox/model.go)和[SQL](../../examples/task-outbox/internal/outbox/schema.sql)包含2张业务表、22个字段,全部有中文说明,外键为零,不进入Orbit平台迁移。
| 表 | 职责与关键约束 |
| --- | --- |
| sender_jobs | 业务操作、对象引用及已确认任务ID;主键(namespace_id,operation_id) |
| sender_outbox | 固定命名空间/队列、原提交键、原请求、SHA256、发送状态、次数/代次、截止及回执;主键与业务操作相同 |
operation_id由调用方先持久化,再作为参数传入,为32位小写十六进制ID。source_ref要求有效UTF-8、非全空白、最多512个Unicode字符且不含控制字符。提交键固定为`outbox-task-<operation_id>`,同命名空间内唯一。业务源引用、目标队列或正文不同的重复操作返回ErrConflict;相同对象可以有不同业务操作,幂等范围由业务操作语义决定。
```mermaid
flowchart LR
B[业务记录与待发命令同事务] --> P[PENDING]
P -->|领取并提交租约| S[SENDING]
S -->|平台受理回执| A[ACCEPTED与业务关联同事务]
S -->|未知响应或背压| P
S -->|租约过期后原身份接管| S
S -->|内容、权限或预算需核查| R[REVIEW]
P -->|发送截止或次数耗尽| R
```
| 本地状态 | 含义 |
| --- | --- |
| PENDING | 待下一次有限发送;可能曾有未确认的平台受理 |
| SENDING | 已领取30秒租约;HTTP在事务外,进程退出后可按数据库时间接管 |
| ACCEPTED | 原平台回执和业务任务关联均已提交;任务执行结果另行GetTask或订阅获取 |
| REVIEW | 自动发送停止,保留原身份、请求及稳定错误分类供核查 |
TaskID为空只说明本地尚未确认。业务提交前不发HTTP,发送失败不回滚已提交业务记录。真正的对象处理仍由业务Worker完成;本例演示提交后取消,不调用外部对象服务。
## 2. 初始化与完整演示
需要Go1.27.1、MySQL8.4+及当前Orbit迁移/服务。分别配置平台库`ORBIT_MYSQL_DSN`和独立业务库`ORBIT_SENDER_DSN`,通过环境注入凭据,不把真实密码写入文档、命令历史或Git。
业务库由业务方先创建,示意SQL在自己的开发MySQL执行:
```sql
CREATE DATABASE orbit_sender_demo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
```
从仓库根目录初始化Orbit,输出凭据文件必须不存在:
```sh
go run ./cmd/orbit-migrate up
mkdir -p .local
chmod 700 .local
go run ./cmd/orbit-admin bootstrap \
--application outbox-demo --namespace default --environment development \
--queue outbox --output .local/outbox.credentials.json
go run ./cmd/orbit
```
另开终端,设置指向空业务库的ORBIT_SENDER_DSN后,从仓库根目录执行:
```sh
cd examples/task-outbox
export ORBIT_URL=http://127.0.0.1:8080
export ORBIT_CREDENTIALS_FILE=../../.local/outbox.credentials.json
GOWORK=off go run ./cmd/sender init
GOWORK=off go run ./cmd/sender demo
```
init只创建本模块两张表,已有表会报错。生产已有业务库应使用自身迁移,不直接运行此初始化。demo使用独立操作ID,先输出ID,再创建一条业务记录、重复核对、发送并取消受理任务。运行上限20秒,任务从平台受理起有60秒总时限;原历史和审计保留。
```text
sender_schema_initialized=true foreign_keys=0
demo_operation=<本次操作ID>
operation=<本次操作ID> state=ACCEPTED sends=1 code=ACCEPTED task=<任务ID>
local_duplicate=true accepted_once=true execution_success_not_implied=true
```
每次demo新建操作,失败后应保留打印的ID并查询已有记录,不能再次运行demo当成原操作恢复。演示队列保持专用且不启动object.prepare处理器,避免取消示例与真实业务执行竞争。
## 3. 分开提交、发送和恢复
以下命令沿用上面的环境。`SENDER_OPERATION_ID`应替换为业务系统已持久化的32位操作ID,重报继续使用同一个值。
```sh
export SENDER_OPERATION_ID=11111111111111111111111111111111
GOWORK=off go run ./cmd/sender enqueue "$SENDER_OPERATION_ID" objects/input.txt
GOWORK=off go run ./cmd/sender status "$SENDER_OPERATION_ID"
GOWORK=off go run ./cmd/sender send
```
enqueue只提交本地事务,不等待网络。首次输出local_committed=true、duplicate=false;同内容再次调用返回duplicate=true,即使Outbox后来已受理也不再次创建业务记录。输入固定handler=object.prepare、版本v1、business_key=操作ID,payload包含对象引用和精确整数9007199254740993。业务引用与完整请求在首次事务中冻结。
send持续处理当前命名空间与队列的待发项,无任务时每500毫秒等待;存储等错误后等待2秒。可运行多个发送进程;Ctrl-C/SIGTERM停止后,用同一环境重新启动send即可继续。数据库租约使用索引范围查询及SKIP LOCKED领取,不依赖进程内队列。
另开相同环境的终端查看:
```sh
GOWORK=off go run ./cmd/sender status "$SENDER_OPERATION_ID"
```
预期由PENDING/SENDING变成ACCEPTED,包含任务ID。运行演示失败、COMMIT未知、服务中断或进程重启后都先查看同一个ID。status只读,不触发发送或变更预算。REVIEW不自动恢复;本例没有重置预算、换键或强制确认命令。
凭据文件提供token、namespace_id、queue_id;ORBIT_TOKEN、ORBIT_NAMESPACE_ID、ORBIT_QUEUE_ID可覆盖对应字段。发送器需要queues:read、tasks:submit及固定队列范围;demo还需tasks:read和tasks:cancel。换凭据后发送前再次GetQueue核对命名空间与队列;配置的目标地址必须仍指向原Orbit部署,不能将已有Outbox切换到独立的新部署。
## 4. Go接入接口
以下方法属于示例的内部业务适配包,完整源码见[store.go](../../examples/task-outbox/internal/outbox/store.go)及[sender.go](../../examples/task-outbox/internal/outbox/sender.go)。接入自己的业务模块时保留事务顺序与协议,替换sender_jobs为实际业务表;核心Orbit客户端仍使用独立SDK。
```go
func Open(ctx context.Context, dsn string) (*sql.DB, error)
func Init(ctx context.Context, db *sql.DB) error
func New(db *sql.DB, namespaceID, queueID string) (*Store, error)
func (s *Store) Enqueue(ctx context.Context, operationID, sourceRef string,
request orbit.SubmitTaskRequest) (duplicate bool, err error)
func (s *Store) SendOnce(ctx context.Context, api *orbit.Client) (Record, error)
func (s *Store) Status(ctx context.Context, operationID string) (Record, error)
```
已有db、命名空间、队列与客户端时,调用片段如下;完整imports/main/上下文和错误处理见[CLI源码](../../examples/task-outbox/cmd/sender/main.go)。
```go
store, err := outbox.New(db, namespaceID, queueID)
if err != nil { return err }
duplicate, err := store.Enqueue(ctx, operationID, "objects/input.txt",
orbit.SubmitTaskRequest{
QueueID: queueID, Handler: "object.prepare",
HandlerVersion: "v1", PayloadVersion: "v1",
Payload: json.RawMessage(`{"source_ref":"objects/input.txt"}`),
BusinessKey: operationID, TaskTimeoutMS: 60000,
})
if err != nil { return err }
fmt.Println("本地已提交", duplicate)
record, err := store.SendOnce(ctx, client)
if errors.Is(err, outbox.ErrIdle) { return nil }
if err != nil { return err }
fmt.Println(record.OperationID, record.State)
```
Enqueue先拒绝非法UTF-8字符串,避免JSON编码静默替换业务标识,再冻结当前v1默认时限、恢复等级、尝试策略和UTC时间;SHA256校验冻结文档的字节,不采用平台内部摘要算法。不对业务JSON对象键或数值表达做语义等价推断,因此同操作重报应保留原输入;完整平台字段规则仍由[任务API](../api/tasks.md)校验。升级业务适配层或SDK前应对已有待发文档做兼容回归,不能静默更新请求。
业务事务固定先锁Outbox操作身份,再写业务行;平台确认事务也先锁Outbox再写业务关联。适配已有业务事务时需统一全局锁顺序。所有网络交换均在这些事务之外,禁止先向平台提交再保存本地业务记录。
## 5. 预算、错误和不确定结果
| 规则 | 当前示例值与处理 |
| --- | --- |
| 单次本地事务 | 最多5秒;失败不盲目重放整个事务 |
| 领取租约 | 30秒,数据库UTC时间;每次领取send_count/lease_epoch同时递增 |
| 网络工作期限 | 从领取前开始最多15秒;提交HTTP再限制10秒及数据库剩余租约/截止 |
| 发送次数 | 最多100次领取,包含领取后未发HTTP便退出的情况 |
| 总发送期限 | 首次本地入库后24小时,短于平台90天幂等保留窗口;期满停止新发送 |
| 退避 | 指数1、2、4…最多60秒,加0–999毫秒抖动;429遵守有效Retry-After,整体不超过1小时 |
| 确认 | 只接受最近租约代次;尚未被替代的过期代次仍可保存真实受理回执 |
| 错误或分类 | 行为 |
| --- | --- |
| ErrConflict | 同业务操作内容不同;核查原记录,不换ID掩盖冲突 |
| ErrCommitUnconfirmed | 本地提交结果未知;查询或用原操作重报Enqueue,发送器按已提交状态继续 |
| ErrStorage | 本地存储失败;保留原身份并恢复连接,不能推断远端未受理 |
| ErrLeaseLost | 已有新代次或新状态;旧发送器停止写回 |
| UNCONFIRMED / BACKPRESSURE | 网络、超时、5xx或429后原键有限重报,次数/截止耗尽后转REVIEW |
| AUTHORIZATION / HTTP_REJECTED | 权限或明确HTTP拒绝,转REVIEW;可能已有历史未知受理,不能直接当作未创建 |
| IDEMPOTENCY_CONFLICT | 原键已对应其他内容,停止并核查 |
| INVALID_DOCUMENT / INVALID_RECEIPT / TARGET_MISMATCH | 请求摘要、回执归属或目标不可信,转REVIEW,保留证据 |
| SEND_BUDGET_EXHAUSTED / SEND_DEADLINE | 不再自动发送,保留原请求供人工核查 |
send_count统计本地发送领取,与Pulse的执行尝试次数分别计数;原键重复提交不会新建任务或执行尝试。自动发送期限是恢复安全边界,不代表任务执行期限;任务总时限从平台受理时开始。带绝对available_at/start_deadline_at的命令在积压后可能不再合法,不能为了通过校验修改原键对应的时间字段。迟到的合法成功回执仍可确认,已被新代次替换的发送器只能查询新状态。
## 6. 故障验证与生产边界
`make integration`验证本地并发重复、业务回滚、确认原子性、多发送者领取、旧代次拒绝、背压/权限/预算、精确数字、真实HTTP受理回执丢失、三个阶段真实MySQL COMMIT成功包丢失,以及平台已提交后发送器SIGKILL/新进程自然租约接管。CLI的初始化、enqueue重复、status、send、正常停止和demo也进入回归。详细证据见[验证报告](../reports/phase-3-task-outbox.md)。
本例的业务效果仅为本地SQL记录及平台受理,不提供外部服务恰好一次执行。业务Worker继续承担业务幂等;执行结果需要[可靠结果订阅与Inbox](result-inbox.md)等衔接。敏感请求和回执留在业务数据库,应纳入业务访问控制、审计、备份及保留策略;日志只输出操作ID和稳定状态,不输出token或请求正文。
REVIEW后的人工核查、证据审批及受控恢复由接入方的业务管理流程承担,示例不提供无证据清空错误/重置预算接口。不要直接修改SQL状态制造成功,也不要在无法确认的情况下换业务操作ID。生产MySQL故障切换、长期归档、跨部署迁移和容量仍按P6验收;本例不启动流程或发送Weave信号,相应Outbox命令类型随流程API演进。