# 业务事务与任务提交 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-`,同命名空间内唯一。业务源引用、目标队列或正文不同的重复操作返回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演进。 相对延迟任务可在SubmitTaskRequest设置DelayMS;Outbox冻结并重发原时长,由Orbit首次受理事务计算执行时间。它不从本地业务创建时间起算;固定业务时刻应使用冻结AvailableAt,详见[延迟接入](delayed-task.md)。