feat: add durable safe retries with uncertainty tracking and SDK examples
This commit is contained in:
117
docs/api/retries.md
Normal file
117
docs/api/retries.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# 安全重试 v1
|
||||
|
||||
状态:P3 的安全重试已实现。使用现有任务提交与 Worker 结果接口,无独立的自动重试路由;Cron、运行中取消、人工恢复与可靠结果订阅继续按 [计划](../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 任务。若等待的是历史效果不明后的重试,取消会记录意图、停止后续尝试并进入 NEEDS_REVIEW,不能宣称此前业务已撤销;其他 WAITING 任务可进入 CANCELLED。运行中取消仍未开放。
|
||||
|
||||
## 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 前应升级服务端。
|
||||
Reference in New Issue
Block a user