# 安全重试 v1 状态:P3 的安全重试已实现。使用现有任务提交与 Worker 结果接口,无独立的自动重试路由;[Cron/固定间隔计划](schedules.md) 和 [运行中取消](cancellation.md) 已接入;人工恢复与可靠结果订阅继续按 [计划](../08-development-plan.md) 开发。验证证据见 [本阶段报告](../reports/phase-3-safe-retry.md)。 ## 1. 三种不同的重报 | 行为 | 身份与效果 | | --- | --- | | HTTP 受理结果不明,重报提交 | 保留原 Idempotency-Key 和内容,返回原任务,不增加尝试 | | WS 控制结果不明,重报 start/finish | 保留尝试和 message_id,返回原回执,不再次调用处理器 | | 执行失败或租约失效,平台安排重试 | 保留 Task.ID;到期后领取新 AttemptID/Epoch,重新消耗一次预算 | HTTP 调用方自行用原身份核对受理结果,自动 Worker 管理第二类控制确认,平台根据持久化事实决定第三类。业务效果是否可重复执行,需要处理器自身保证,不能只靠连接重试。 ## 2. 冻结策略与结果分类 提交时设置 `retry.max_attempts / initial_delay_ms / max_delay_ms / attempt_timeout_ms`、`recovery_safety`、任务总时限及可选最晚开始时间。默认 max_attempts=1、recovery_safety=RECONCILE_ONLY,不自动增加业务尝试。字段范围见 [任务接口](tasks.md)。 Worker 的 `task.finish` 新增可选布尔字段 `retryable`,省略为 false;只有 outcome=FAILED 才能为 true。SUCCEEDED/UNKNOWN 携带 true 返回 INVALID_ARGUMENT。该字段表示明确失败可安全再试,**不能用于把未知效果伪装成明确失败**。 以下均要求报告仍有当前有效授权;无授权的报告只保存证据: | 当前事实 | 有剩余预算、截止允许时 | 不能重试时 | | --- | --- | --- | | SUCCEEDED | 完成任务,清除 effect_uncertain | 同左 | | FAILED,retryable=false | 不安排重试 | 无历史不确定效果则 FAILED,否则 NEEDS_REVIEW | | FAILED,retryable=true | 退避后 WAITING | 无历史不确定效果则 FAILED,否则 NEEDS_REVIEW | | UNKNOWN 或已获开始授权后租约到期;RECONCILE_ONLY | NEEDS_REVIEW | NEEDS_REVIEW | | UNKNOWN 或已获开始授权后租约到期;IDEMPOTENT | effect_uncertain=true,退避后 WAITING | NEEDS_REVIEW,保留不确定性 | | 领取后未获开始授权即到期 | 退避后 WAITING,仍消耗已领取次数 | 无历史不确定效果则 FAILED,否则 NEEDS_REVIEW | 存在历史不确定效果时,只有 IDEMPOTENT 允许继续自动尝试。此声明要求跨进程、跨尝试、并发及迟到执行都具有业务幂等保障,例如持久化操作键、唯一约束、返回原结果及资源侧执行代次校验。Task.ID 在自动重试中稳定;AttemptID 会变化,不能作为跨尝试的业务幂等键。平台不会因为新尝试开始而保证旧进程已经停止。 `effect_uncertain` 是任务详情、摘要和派发任务中的可选布尔字段,false 时省略。只要曾发生尚未核实的授权执行,后来的明确失败、未开始即到期、总时限耗尽或取消都不会把该事实抹掉。当前有效尝试成功可自动清除;迟到成功证据不能清除。人工核查解除的管理接口尚未实现。 ## 3. 退避、预算与持久化 消费第 n 次领取预算后,退避上限为: ```text base = min(initial_delay_ms × 2^(n-1), max_delay_ms) delay_ms ∈ [ceil(base/2), base] available_at = 本次结束事务中的数据库时间 + delay_ms ``` 抖动使用 AttemptID 生成确定值,同一尝试重复计算不改变时间;指数倍增先检查上限,避免溢出。返回 WAITING 表示下一次最早可领取时间,实际派发还受容量、队列暂停、流程门控、凭据、扫描与服务可用性影响,不承诺准点开始。 - 领取就消耗次数,包含未获开始授权的尝试。重连、ACK 重报和取消都不重置次数。 - 下一次 available_at 必须严格早于总截止和最晚开始时间;等于截止也不能安排重试。领取/开始仍重新核验数据库时间。 - attempt_timeout_ms 从获批开始计算;每次尝试重新计时,任务总截止保持不变。 - 尝试结果、retryable、任务状态、不确定标记、下一时间、许可释放、事件及结果操作审计/回执在同一事务提交。后台租约回收保存包含节点身份与决策的持久化事件。 - 等待重试期间不占执行许可,任务不带 result/completed_at;上一尝试的错误和结果保留在 [执行历史](execution-history.md)。NEEDS_REVIEW 也没有 completed_at。 - 重启后以数据库 available_at 继续领取,不依赖某一节点内存中的定时器。 `task.requeued` 等持久化事件记录 decision、delay_ms、next_available_at、attempt_count、max_attempts、effect_uncertain。decision 包括 RETRYABLE_FAILURE、IDEMPOTENT_UNKNOWN、UNSTARTED_LEASE_EXPIRED、ATTEMPT_LIMIT、DEADLINE_EXCEEDED、RECONCILIATION_REQUIRED 等。当前可靠事件订阅尚未开放。 取消接口支持独立任务和计划任务的 WAITING、LEASED、RUNNING 与 NEEDS_REVIEW。取消意图阻止后续尝试;运行中继续收集结果,普通取消错误或失联保持 UNKNOWN,不因 IDEMPOTENT 声明再次执行。此前未核实的效果不会被本次确认取消清除,详见 [取消状态与结果表](cancellation.md)。 ## 4. SDK 接入 生产者在提交请求中设置有限策略,以下为函数片段: ```go accepted, err := client.SubmitTask(ctx, operationKey, orbit.SubmitTaskRequest{ QueueID: queueID, Handler: "text.normalize", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"text":" safe retry "}`), RecoverySafety: orbit.RecoveryReconcileOnly, TaskTimeoutMS: 60000, Retry: orbit.RetryPolicy{ MaxAttempts: 3, InitialDelayMS: 500, MaxDelayMS: 2000, AttemptTimeoutMS: 10000, }, }) if err != nil { return err } fmt.Println(accepted.Task.ID) ``` 处理器在已确认可安全重试的失败分支返回: ```go return nil, &orbit.WorkerFailure{ Code: "TEMPORARY_REJECTION", Retryable: true, Details: json.RawMessage(`{"reason_ref":"diagnostics/temporary"}`), } ``` WorkerFailure 默认 Retryable=false;普通 error、panic、runtime.Goexit 和非法结果仍上报 UNKNOWN,由冻结的 recovery_safety 决定是否允许新尝试。不要把任意网络超时转成 Retryable=true。底层 SDK 对应 `WorkerFinish.Retryable`;其消息结构见 [Worker 契约](worker.md)。 ## 5. 运行完整示例 源码:[examples/retry/main.go](../../sdk/go/examples/retry/main.go)。先运行当前迁移、服务,并按 [Worker 初始化步骤](../sdk/worker.md#1-可运行的文本规范化示例) 准备具有提交、读取、消费权限的专用凭据。从仓库根目录执行: ```sh cd sdk/go ORBIT_URL=http://127.0.0.1:8080 \ ORBIT_CREDENTIALS_FILE=../../.local/worker.credentials.json \ go run ./examples/retry ``` 输入为 `{"text":" safe retry "}`。第一次处理在产生效果前模拟明确拒绝,第二次执行真正的文本规范化;重试等待由平台持久化安排。 ```text task=<任务ID> state=SUCCEEDED attempts=2 first_retryable=true result={"text":"SAFE RETRY"} worker_drained=true started=2 confirmed=2 running=0 ``` confirmed 统计已确认的尝试报告,包含第一次失败,不是成功任务数。示例通过尝试查询核对第一次 FAILED/retryable=true 与第二次 SUCCEEDED。每次运行生成独立操作和处理器身份,不连接外部业务系统;正常结束排空,历史保留。内存计数只用于注入一次故障,不能用作生产幂等存储。错误时非零退出并尝试有界收尾。 ## 6. 回执与升级兼容 同 message_id 改变 retryable 或其他内容返回 IDEMPOTENCY_CONFLICT。保持同一结果而换 message_id 时,返回第一次已应用的结果回执;即使任务已在后续尝试成功,旧失败回执仍可能写着 WAITING。当前状态用 GetTask 查询;CommandResult.lease 仍只描述旧尝试当前是否有权执行。 迁移 000005 增加 effect_uncertain、尝试/证据 retryable 以及结果回执索引。已有 NEEDS_REVIEW 保守回填 effect_uncertain=true,保留原状态、版本和策略。已有结果摘要在 retryable 省略或 false 时保持兼容;true 属于不同语义。 本开发版本首次启用已冻结的多次尝试策略:旧的未结束任务若声明 max_attempts>1 / IDEMPOTENT,升级后也会按本页规则执行;不重写策略、不重置预算、不自动恢复已有 NEEDS_REVIEW。旧服务不识别新 Schema 版本,应停止旧节点后迁移并启动统一版本;尚未验证混合版本滚动升级或向旧二进制回退。SDK 尚未发布版本,自研 Worker 使用 retryable 前应升级服务端。