feat: add durable safe retries with uncertainty tracking and SDK examples

This commit is contained in:
2026-09-17 15:43:44 +08:00
parent 9e3d4be5cc
commit d65cf96ea7
44 changed files with 1300 additions and 77 deletions

View File

@@ -8,9 +8,9 @@ Orbit 是面向多个业务项目的通用任务调度与流程编排平台,
## 当前进度 ## 当前进度
已完成 P0–P2 阶段验收:32 个数据模型、版本化迁移、凭据及范围校验、队列与任务 API、WSS 执行、独立 SDK、自动 Worker 和 [执行尝试/证据查询](docs/api/execution-history.md)。[Worker Schema](docs/api/worker-schema.md) 与真实进程强杀恢复回归已通过;安全重试、调度器和流程引擎按 [持续开发计划](docs/08-development-plan.md) 继续实现。 已完成 P0–P2 阶段验收:32 个数据模型、版本化迁移、凭据及范围校验、队列与任务 API、WSS 执行、独立 SDK、自动 Worker 和 [执行尝试/证据查询](docs/api/execution-history.md)。[Worker Schema](docs/api/worker-schema.md) 与真实进程强杀恢复回归已通过;[安全重试](docs/api/retries.md) 已接入服务端和 SDK,调度器和流程引擎按 [持续开发计划](docs/08-development-plan.md) 继续实现。
readiness 只表示数据库与迁移记录可用,不表示完整平台已具备生产服务能力。验证证据见 [P0](docs/reports/phase-0.md)、[P1](docs/reports/phase-1.md) 与 [P2 阶段验收](docs/reports/phase-2.md),后者汇总服务端、SDK、执行历史及故障验证的详细记录。 readiness 只表示数据库与迁移记录可用,不表示完整平台已具备生产服务能力。验证证据见 [P0](docs/reports/phase-0.md)、[P1](docs/reports/phase-1.md) 与 [P2 阶段验收](docs/reports/phase-2.md),后者汇总服务端、SDK、执行历史及故障验证的详细记录。P3 的当前进展见 [安全重试报告](docs/reports/phase-3-safe-retry.md)。
## 目录 ## 目录
@@ -24,6 +24,7 @@ platform/auth/ 凭据格式与动作/队列授权范围
pulse/model/ 任务、尝试、队列、计划和执行许可 pulse/model/ 任务、尝试、队列、计划和执行许可
pulse/service/ 任务与队列应用契约、输入校验 pulse/service/ 任务与队列应用契约、输入校验
pulse/worker/ 会话、开始、续租、结果和恢复契约 pulse/worker/ 会话、开始、续租、结果和恢复契约
pulse/retry/ 冻结策略、有限退避与未知效果决策
weave/model/ 定义、实例、步骤、信号和查询轮次 weave/model/ 定义、实例、步骤、信号和查询轮次
internal/config/ 配置校验 internal/config/ 配置校验
internal/storage/mysql/ 连接、事务、迁移及集成测试 internal/storage/mysql/ 连接、事务、迁移及集成测试

View File

@@ -83,6 +83,7 @@
- 查询轮次与任务分别唯一;轮次、单轮尝试预算和截止时间独立保存。 - 查询轮次与任务分别唯一;轮次、单轮尝试预算和截止时间独立保存。
- 内部事件与外部订阅交付有去重键,租用状态要求有效租用字段。 - 内部事件与外部订阅交付有去重键,租用状态要求有效租用字段。
- 终态必须有结束时间,待执行任务不能伪造有效执行租约。 - 终态必须有结束时间,待执行任务不能伪造有效执行租约。
- effect_uncertain=true 不能和成功、失败、取消终态共存;尝试/证据 retryable=true 只允许明确失败。
## 5. 仍需服务层事务保证的条件 ## 5. 仍需服务层事务保证的条件
@@ -110,4 +111,6 @@ P2 服务端已新增会话隔离、两步派发、租约、并发许可、结
000004_execution_history 为已有证据表增加 `(namespace_id, attempt_id, created_at, id)` 分页索引,不新增实体或字段,不回改已有迁移。尝试列表复用执行代次唯一索引。查询 DTO 与持久化模型分离,只在详情读取业务正文,不返回执行秘密或内部摘要;接口见 [执行历史](api/execution-history.md)。 000004_execution_history 为已有证据表增加 `(namespace_id, attempt_id, created_at, id)` 分页索引,不新增实体或字段,不回改已有迁移。尝试列表复用执行代次唯一索引。查询 DTO 与持久化模型分离,只在详情读取业务正文,不返回执行秘密或内部摘要;接口见 [执行历史](api/execution-history.md)。
000005_safe_retry 增加任务 EffectUncertain、尝试与证据 Retryable 字段,Go 与 SQL 均附中文说明;实体数仍为 32。跨尝试的不确定效果必须保留,历史 NEEDS_REVIEW 保守回填 true,旧状态、版本和冻结策略不变。回执增加 `(namespace_id, attempt_id, operation, request_digest)` 索引,换消息身份重报结果也返回原已应用回执。字段语义、升级策略启用与限制见 [安全重试](api/retries.md),升级回归见 [阶段报告](reports/phase-3-safe-retry.md)。
命令与验证记录见 [开发计划](08-development-plan.md)、[基础阶段验证报告](reports/phase-0.md) 与 [执行历史回归](reports/phase-2-execution-history.md)。 命令与验证记录见 [开发计划](08-development-plan.md)、[基础阶段验证报告](reports/phase-0.md) 与 [执行历史回归](reports/phase-2-execution-history.md)。

View File

@@ -27,7 +27,7 @@
| P0 基础与数据模型 | Go workspace、独立 SDK module、公共/Pulse/Weave 模型、MySQL 迁移、配置、健康检查 | 编译/竞态/静态检查;真实 MySQL 建表、隔离、唯一性、预算、不可变版本、迁移失败防护;SDK 健康调用 | 已完成并通过阶段回归(见 P0 报告) | | P0 基础与数据模型 | Go workspace、独立 SDK module、公共/Pulse/Weave 模型、MySQL 迁移、配置、健康检查 | 编译/竞态/静态检查;真实 MySQL 建表、隔离、唯一性、预算、不可变版本、迁移失败防护;SDK 健康调用 | 已完成并通过阶段回归(见 P0 报告) |
| P1 任务受理闭环 | 应用凭据与作用域权限、队列、幂等提交、查询、待执行取消、受理事件;SDK 客户端 | API/SDK 创建查询闭环;重复键与内容冲突、响应未确认重报、跨命名空间拒绝、参数边界 | 已完成并通过阶段回归(见 P1 报告;真实断路/提交 ACK 丢失注入留后续故障阶段) | | P1 任务受理闭环 | 应用凭据与作用域权限、队列、幂等提交、查询、待执行取消、受理事件;SDK 客户端 | API/SDK 创建查询闭环;重复键与内容冲突、响应未确认重报、跨命名空间拒绝、参数边界 | 已完成并通过阶段回归(见 P1 报告;真实断路/提交 ACK 丢失注入留后续故障阶段) |
| P2 Worker 执行闭环 | WSS 接入、能力/容量、领取与开始、租约、续租、结果确认、排空与恢复;Worker SDK | 真 Worker 执行;失联/重连/重复确认/旧尝试拒绝;业务效果未知转核查;节点故障接管 | 已完成并通过阶段回归:服务端、自动 SDK、历史查询、完整 Schema 与真实进程强杀恢复;测试拓扑和剩余生产故障矩阵见 P2 验收报告 | | P2 Worker 执行闭环 | WSS 接入、能力/容量、领取与开始、租约、续租、结果确认、排空与恢复;Worker SDK | 真 Worker 执行;失联/重连/重复确认/旧尝试拒绝;业务效果未知转核查;节点故障接管 | 已完成并通过阶段回归:服务端、自动 SDK、历史查询、完整 Schema 与真实进程强杀恢复;测试拓扑和剩余生产故障矩阵见 P2 验收报告 |
| P3 Pulse 完整能力 | 安全重试、延时、Cron/固定间隔、漏跑策略、计划版本、并发许可、配额/背压、可靠结果订阅 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 待开发 | | P3 Pulse 完整能力 | 安全重试、延时、Cron/固定间隔、漏跑策略、计划版本、并发许可、配额/背压、可靠结果订阅 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 进行中:安全重试、退避、未知效果保留、升级回填及 SDK 场景已通过回归;Cron/固定间隔、计划管理、运行中取消、配额/背压和订阅待开发 |
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 待开发 | | P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 待开发 |
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 待开发 | | P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 待开发 |
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 | | P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 |
@@ -41,6 +41,7 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最
| --- | --- | --- | | --- | --- | --- |
| 健康检查 | 创建客户端 → 存活/就绪查询 | P0 | | 健康检查 | 创建客户端 → 存活/就绪查询 | P0 |
| 文件转换任务 | 提交幂等任务 → Worker 处理 → 查询结果 | P1–P2 | | 文件转换任务 | 提交幂等任务 → Worker 处理 → 查询结果 | P1–P2 |
| 有限安全重试 | 明确失败 → 持久化退避 → 新尝试 → 查询完整历史 | P3,已实现完整示例 |
| 定期数据同步 | 发布计划 → 触发任务 → 暂停/恢复 → 查看触发记录 | P3 | | 定期数据同步 | 发布计划 → 触发任务 → 暂停/恢复 → 查看触发记录 | P3 |
| 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4 | | 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4 |
| 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4 | | 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4 |
@@ -75,6 +76,7 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最
- [P0 验证记录](reports/phase-0.md) - [P0 验证记录](reports/phase-0.md)
- [P1 验证记录](reports/phase-1.md) - [P1 验证记录](reports/phase-1.md)
- [P2 阶段验收与进程故障记录](reports/phase-2.md) - [P2 阶段验收与进程故障记录](reports/phase-2.md)
- [P3 安全重试阶段记录](reports/phase-3-safe-retry.md)
- [P2 服务端阶段记录](reports/phase-2-server.md) - [P2 服务端阶段记录](reports/phase-2-server.md)
- [P2 SDK 协议阶段记录](reports/phase-2-sdk-protocol.md) - [P2 SDK 协议阶段记录](reports/phase-2-sdk-protocol.md)
- [P2 自动 Worker 运行器记录](reports/phase-2-worker-runtime.md) - [P2 自动 Worker 运行器记录](reports/phase-2-worker-runtime.md)

View File

@@ -2,7 +2,7 @@
- 文档版本:0.2 - 文档版本:0.2
- 整理日期:2026-09-17 - 整理日期:2026-09-17
- 阶段:P0–P2 已通过阶段验收,包括协议 Schema 与真实进程故障恢复;P3–P7 继续推进,尚未完成全平台生产验收 - 阶段:P0–P2 已通过阶段验收,包括协议 Schema 与真实进程故障恢复;P3 安全重试已通过回归,P3–P7 继续推进,尚未完成全平台生产验收
- 适用范围:Orbit 统一平台,包含 Pulse 任务调度模块、Weave 流程编排模块与公共能力。平台面向多个业务系统复用。 - 适用范围:Orbit 统一平台,包含 Pulse 任务调度模块、Weave 流程编排模块与公共能力。平台面向多个业务系统复用。
## 阅读顺序 ## 阅读顺序
@@ -79,4 +79,4 @@ Orbit 不内置特定行业规则;Pulse 管理执行,Weave 管理步骤依
2026-09-17,文档更新至 0.2:用户确认研发定义发布流程及 Weave 首版功能,明确纳入有界重复查询和暂停/继续等生产管理能力。技术细则与参数继续按建议或待定标注,未将功能确认等同于实现或验收通过。 2026-09-17,文档更新至 0.2:用户确认研发定义发布流程及 Weave 首版功能,明确纳入有界重复查询和暂停/继续等生产管理能力。技术细则与参数继续按建议或待定标注,未将功能确认等同于实现或验收通过。
持续开发目标已建立。当前实现和验收进度以 [开发计划](08-development-plan.md)、[P0 报告](reports/phase-0.md)、[P1 报告](reports/phase-1.md) 与 [P2 阶段验收](reports/phase-2.md) 为准;早期设计稿中的“未实现”描述保留其编写阶段语境。 持续开发目标已建立。当前实现和验收进度以 [开发计划](08-development-plan.md)、[P0 报告](reports/phase-0.md)、[P1 报告](reports/phase-1.md) 与 [P2 阶段验收](reports/phase-2.md) 和 [P3 安全重试报告](reports/phase-3-safe-retry.md) 为准;早期设计稿中的“未实现”描述保留其编写阶段语境。

View File

@@ -14,6 +14,7 @@
| `/v1/queues` 创建与查询 | P1 已实现 | [队列接口](queues.md) | | `/v1/queues` 创建与查询 | P1 已实现 | [队列接口](queues.md) |
| 凭据校验与初始化 CLI | P1 已实现 | [鉴权](authentication.md) | | 凭据校验与初始化 CLI | P1 已实现 | [鉴权](authentication.md) |
| `/v1/worker/connect` | P2 已实现并通过阶段验收 | [Worker 契约](worker.md)、[Schema](worker-schema.md) | | `/v1/worker/connect` | P2 已实现并通过阶段验收 | [Worker 契约](worker.md)、[Schema](worker-schema.md) |
| 任务冻结策略与 Worker `retryable` | P3 安全重试已实现 | [安全重试](retries.md) |
| `/v1/schedules` 与计划管理 | 计划 P3,尚未实现 | [协议草案](../03-http-websocket-protocol.md) | | `/v1/schedules` 与计划管理 | 计划 P3,尚未实现 | [协议草案](../03-http-websocket-protocol.md) |
| `/v1/events/connect` | 计划 P3,尚未实现 | [协议草案](../03-http-websocket-protocol.md) | | `/v1/events/connect` | 计划 P3,尚未实现 | [协议草案](../03-http-websocket-protocol.md) |
| `/v1/workflows` 与流程管理 | 计划 P4–P5,尚未实现 | [流程接口规划](workflows.md) | | `/v1/workflows` 与流程管理 | 计划 P4–P5,尚未实现 | [流程接口规划](workflows.md) |

View File

@@ -6,7 +6,7 @@
任务代表一次受理的工作,尝试代表其中一次执行代次。领取后未获开始授权就过期的尝试也会保留,并消耗尝试预算。重连恢复同一次尝试,不创建新的尝试记录。 任务代表一次受理的工作,尝试代表其中一次执行代次。领取后未获开始授权就过期的尝试也会保留,并消耗尝试预算。重连恢复同一次尝试,不创建新的尝试记录。
证据保存 Worker 的迟到、未开始或冲突报告。`outcome=SUCCEEDED` 只是原报告的声明;`disposition=EVIDENCE_ONLY` 表示采集时仅作为证据保存,不能解释为任务成功。权威任务状态与结果使用 [GetTask](tasks.md) 查询。当前接口不执行人工裁定、恢复或重试。 证据保存 Worker 的迟到、未开始或冲突报告。`outcome=SUCCEEDED` 只是原报告的声明;`disposition=EVIDENCE_ONLY` 表示采集时仅作为证据保存,不能解释为任务成功。权威任务状态与结果使用 [GetTask](tasks.md) 查询。当前只读接口不执行人工裁定、恢复或重试。平台自动重试保留每次失败结果;其规则及任务 effect_uncertain 见 [安全重试](retries.md)。
四个接口均要求 `Authorization: Bearer <token>`、`tasks:read` 动作及任务所属队列范围。凭据确定命名空间,每次调用都重新校验有效性。ID 是 32 位小写十六进制字符串,全部父子关系都校验;知道尝试 ID 或证据 ID 不代表有权读取。 四个接口均要求 `Authorization: Bearer <token>`、`tasks:read` 动作及任务所属队列范围。凭据确定命名空间,每次调用都重新校验有效性。ID 是 32 位小写十六进制字符串,全部父子关系都校验;知道尝试 ID 或证据 ID 不代表有权读取。
@@ -60,6 +60,7 @@ curl "$ORBIT_URL/v1/tasks/$TASK_ID/attempts/$ATTEMPT_ID/evidence/$EVIDENCE_ID" \
| authorized_at | UTC RFC 3339 / 否 | 获批开始时间;省略表示尚未获批,不证明外部效果是否发生 | | authorized_at | UTC RFC 3339 / 否 | 获批开始时间;省略表示尚未获批,不证明外部效果是否发生 |
| ended_at | UTC RFC 3339 / 否 | 尝试结束时间;尚未结束时省略 | | ended_at | UTC RFC 3339 / 否 | 尝试结束时间;尚未结束时省略 |
| error_code | string / 否 | 稳定错误分类,无错误时省略 | | error_code | string / 否 | 稳定错误分类,无错误时省略 |
| retryable | bool / 否 | Worker 对明确失败的可安全重试声明,false 时省略;不证明平台实际安排了重试 |
| created_at、updated_at | UTC RFC 3339 / 是 | 创建和最近更新时间,最多微秒精度 | | created_at、updated_at | UTC RFC 3339 / 是 | 创建和最近更新时间,最多微秒精度 |
| session_id、worker_instance_id | string / 仅详情 | 当前或最后归属会话及进程身份;会话可能因恢复改变,不是完整归属变更日志 | | session_id、worker_instance_id | string / 仅详情 | 当前或最后归属会话及进程身份;会话可能因恢复改变,不是完整归属变更日志 |
| result | JSON / 仅详情,可省略 | 此尝试已保存的结果;无结果时省略,与追加证据分开 | | result | JSON / 仅详情,可省略 | 此尝试已保存的结果;无结果时省略,与追加证据分开 |
@@ -108,6 +109,7 @@ curl "$ORBIT_URL/v1/tasks/$TASK_ID/attempts/$ATTEMPT_ID/evidence/$EVIDENCE_ID" \
| id、task_id、attempt_id | string / 是 | 证据及已校验的父资源标识 | | id、task_id、attempt_id | string / 是 | 证据及已校验的父资源标识 |
| outcome | string / 是 | 原报告的 SUCCEEDED、FAILED 或 UNKNOWN | | outcome | string / 是 | 原报告的 SUCCEEDED、FAILED 或 UNKNOWN |
| error_code | string / 否 | 原报告的错误分类,空时省略 | | error_code | string / 否 | 原报告的错误分类,空时省略 |
| retryable | bool / 否 | 原失败报告的重试声明,false 时省略;证据不触发执行 |
| reason | string / 是 | LEASE_LOST(授权失效)、NOT_STARTED(未获批开始)、RESULT_CONFLICT(与已保存结果冲突) | | reason | string / 是 | LEASE_LOST(授权失效)、NOT_STARTED(未获批开始)、RESULT_CONFLICT(与已保存结果冲突) |
| disposition | string / 是 | 固定 EVIDENCE_ONLY,描述采集处理方式 | | disposition | string / 是 | 固定 EVIDENCE_ONLY,描述采集处理方式 |
| created_at | UTC RFC 3339 / 是 | 首次保存时间,相同身份的重复报告不刷新 | | created_at | UTC RFC 3339 / 是 | 首次保存时间,相同身份的重复报告不刷新 |

117
docs/api/retries.md Normal file
View 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 前应升级服务端。

View File

@@ -1,8 +1,8 @@
# 任务接口 v1 # 任务接口 v1
状态:P1 已实现并通过真实 MySQL 与 SDK 回归。当前提供受理、查询、分页和待执行取消;P2 已提供 Worker 执行与历史查询,策略重试在 P3 实现。 状态:P1 已实现并通过真实 MySQL 与 SDK 回归。当前提供受理、查询、分页和待执行取消;P2 已提供 Worker 执行与历史查询,P3 已接入 [安全重试](retries.md)。
P2 进展:服务端执行内核、WSS 与独立自动 Worker SDK 已可产生 LEASED、RUNNING、SUCCEEDED、FAILED、NEEDS_REVIEW 等状态,见 [Worker 契约](worker.md);[尝试与证据查询](execution-history.md) 可核对每次执行,自动策略重试尚未完成。 P2 进展:服务端执行内核、WSS 与独立自动 Worker SDK 已可产生 LEASED、RUNNING、SUCCEEDED、FAILED、NEEDS_REVIEW 等状态,见 [Worker 契约](worker.md);[尝试与证据查询](execution-history.md) 可核对每次执行;重试以冻结策略、明确失败分类及未知效果安全声明为依据。
## 公共约定 ## 公共约定
@@ -28,7 +28,7 @@ P2 进展:服务端执行内核、WSS 与独立自动 Worker SDK 已可产生
| start_deadline_at | time / null | 无独立限制 | 必须晚于当前时间,不早于 available_at,不晚于总截止时间 | | start_deadline_at | time / null | 无独立限制 | 必须晚于当前时间,不早于 available_at,不晚于总截止时间 |
| task_timeout_ms | uint64 | 0 或省略为 86400000 | 从受理时计算的总时限,最大 30 天(2592000000 毫秒) | | task_timeout_ms | uint64 | 0 或省略为 86400000 | 从受理时计算的总时限,最大 30 天(2592000000 毫秒) |
| recovery_safety | string | RECONCILE_ONLY | 也可为 IDEMPOTENT;必须基于处理器真实的幂等能力声明 | | recovery_safety | string | RECONCILE_ONLY | 也可为 IDEMPOTENT;必须基于处理器真实的幂等能力声明 |
| retry | object | 见下表 | 受理时冻结策略;P2 已执行尝试预算及时限,策略退避重试在 P3 实现 | | retry | object | 见下表 | 受理时冻结策略;包含首次领取的有限尝试、退避及每次时限,见 [安全重试](retries.md) |
处理器及版本名称以 ASCII 字母或数字开头,后续只能包含字母、数字、`_ . : -`。时间年份为 2000–9998。 处理器及版本名称以 ASCII 字母或数字开头,后续只能包含字母、数字、`_ . : -`。时间年份为 2000–9998。
@@ -90,7 +90,7 @@ curl -i "$ORBIT_URL/v1/tasks" \
} }
``` ```
可选响应字段:start_deadline_at、result、cancel_requested_at、completed_at、business_key,未设置时省略。终态有 completed_at。状态集合为 WAITING / LEASED / RUNNING / NEEDS_REVIEW / SUCCEEDED / FAILED / CANCELLED;P1 仅主动产生 WAITING 和 CANCELLED。 可选响应字段:start_deadline_at、result、cancel_requested_at、completed_at、business_key,未设置时省略。effect_uncertain=true 表示存在尚未核实的授权执行效果,false 时省略;详情、摘要和 Worker 派发均可返回。等待重试时 result/completed_at 省略,历史结果在尝试中保留。终态有 completed_at。状态集合为 WAITING / LEASED / RUNNING / NEEDS_REVIEW / SUCCEEDED / FAILED / CANCELLED;P1 仅主动产生 WAITING 和 CANCELLED。
### 幂等与失败处理 ### 幂等与失败处理
@@ -164,7 +164,7 @@ curl "$ORBIT_URL/v1/tasks/$TASK_ID:cancel" \
expected_revision 必填,为非零 uint64 的十进制字符串;reason 必填且不能全为空白,最多 1024 个 Unicode 字符。服务端以版本和状态条件更新,只有一个竞争请求能成功。 expected_revision 必填,为非零 uint64 的十进制字符串;reason 必填且不能全为空白,最多 1024 个 Unicode 字符。服务端以版本和状态条件更新,只有一个竞争请求能成功。
200 的 data 为 `{"task":<完整最新任务>,"deduplicated":false}`。示例任务会变为 state=CANCELLED、revision="2",写入 cancel_requested_at/completed_at,并同事务追加取消事件及审计。首次取消之后使用相同键、原因和**原 expected_revision**重报,返回原取消回执且 deduplicated=true。换键重报已取消任务返回 409。 200 的 data 为 `{"task":<完整最新任务>,"deduplicated":false}`。示例任务会变为 state=CANCELLED、revision="2",写入 cancel_requested_at/completed_at,并同事务追加取消事件及审计。首次取消之后使用相同键、原因和**原 expected_revision**重报,返回原取消回执且 deduplicated=true。换键重报已取消任务返回 409。若 WAITING 来自未知效果后的自动重试,取消会保存 cancel_requested_at 并转 NEEDS_REVIEW,保留 effect_uncertain=true、completed_at 为空,停止后续尝试但不宣称此前效果已经撤销。
SDK:`Client.CancelTask(ctx, taskID, key, request)`。此操作不承诺回滚任何已发生的外部业务效果。 SDK:`Client.CancelTask(ctx, taskID, key, request)`。此操作不承诺回滚任何已发生的外部业务效果。
@@ -186,4 +186,4 @@ SDK:`Client.CancelTask(ctx, taskID, key, request)`。此操作不承诺回滚
{"request_id":"44444444444444444444444444444444","error":{"code":"IDEMPOTENCY_CONFLICT","message":"idempotency key was used with different content"}} {"request_id":"44444444444444444444444444444444","error":{"code":"IDEMPOTENCY_CONFLICT","message":"idempotency key was used with different content"}}
``` ```
批量受理、人工重试和运行中取消尚未实现。[执行历史](execution-history.md) 已提供尝试与证据的列表和详情。完整可运行程序见 [SDK 任务示例](../../sdk/go/examples/tasks/main.go),已验证异常场景见 [P1 回归报告](../reports/phase-1.md)。SDK 忽略未知响应字段;更改已有字段语义需升级契约和回归。 有限自动重试已实现,具体分类、退避、回执和升级边界见 [安全重试](retries.md)。批量受理、人工重试和运行中取消尚未实现。[执行历史](execution-history.md) 已提供尝试与证据的列表和详情。完整可运行程序见 [SDK 任务示例](../../sdk/go/examples/tasks/main.go),已验证异常场景见 [P1 回归报告](../reports/phase-1.md)。SDK 忽略未知响应字段;更改已有字段语义需升级契约和回归。

View File

@@ -30,9 +30,10 @@ ACK 的 body 根据原请求选择:worker.capacity → welcome,worker.resume
- epoch、sequence、revision 为规范十进制字符串:范围 1–18446744073709551615,无前导零和正号。SDK 使用 uint64,线上使用字符串。 - epoch、sequence、revision 为规范十进制字符串:范围 1–18446744073709551615,无前导零和正号。SDK 使用 uint64,线上使用字符串。
- instance_secret/lease_secret 必须是 32 字节秘密的严格无填充 base64url,长度 43;知道资源 ID 无法替代秘密。 - instance_secret/lease_secret 必须是 32 字节秘密的严格无填充 base64url,长度 43;知道资源 ID 无法替代秘密。
- 结果为非 null JSON;FAILED/UNKNOWN 必须携带稳定 error_code;SUCCEEDED 省略或使用空 error_code。 - 结果为非 null JSON;FAILED/UNKNOWN 必须携带稳定 error_code;SUCCEEDED 省略或使用空 error_code。
- finish.retryable 为可选布尔值,默认 false;true 仅允许 FAILED,Schema 与服务端都校验。派发任务可带 effect_uncertain=true,表示此前授权执行的效果尚未核实。业务重试条件见 [安全重试](retries.md)。
- 返回时间使用 UTC RFC 3339,最多微秒精度。验证器应开启 format 断言以校验实际日历日期。 - 返回时间使用 UTC RFC 3339,最多微秒精度。验证器应开启 format 断言以校验实际日历日期。
本轮收紧了开发期此前允许的省略控制字段、非规范计数字符串和数组内字段别名。当前 SDK 一直发送完整规范字段,不需迁移;其他自研客户端应按本文件更新。SDK 尚未发布首个版本。将来扩展消息类型或改变字段语义必须同时更新 Schema、服务端、SDK 与兼容回归。 P2 收紧了开发期此前允许的省略控制字段、非规范计数字符串和数组内字段别名。当前 SDK 一直发送完整规范字段,不需迁移;其他自研客户端应按本文件更新。SDK 尚未发布首个版本。将来扩展消息类型或改变字段语义必须同时更新 Schema、服务端、SDK 与兼容回归。
## Schema 与运行时的边界 ## Schema 与运行时的边界

View File

@@ -88,9 +88,9 @@ capacity 使用单调递增序号和覆盖值。同序号同内容可重复,
| --- | --- | --- | | --- | --- | --- |
| task.start | AttemptRef | 领取后再次检查当前会话、尝试、租约、队列暂停及流程门控,保存 RUNNING 后确认 | | task.start | AttemptRef | 领取后再次检查当前会话、尝试、租约、队列暂停及流程门控,保存 RUNNING 后确认 |
| task.renew | AttemptRef | 只续期当前 RUNNING;新消息可续租,重复原消息只取回原回执 | | task.renew | AttemptRef | 只续期当前 RUNNING;新消息可续租,重复原消息只取回原回执 |
| task.finish | ref: AttemptRef、outcome、result、error_code | 保存明确结果或未知效果;返回操作回执和当前授权 | | task.finish | ref: AttemptRef、outcome、result、error_code、可选 retryable | 保存明确结果或未知效果;返回操作回执和当前授权 |
outcome 为 SUCCEEDED / FAILED / UNKNOWN。result 为非 null JSON 或证据引用。FAILED/UNKNOWN 必须带 1–64 字符的稳定 error_code;SUCCEEDED 不带错误分类。当前 FAILED 成为任务失败终态,UNKNOWN 转 NEEDS_REVIEW;策略化自动重试在 P3 实现。 outcome 为 SUCCEEDED / FAILED / UNKNOWN。result 为非 null JSON 或证据引用。FAILED/UNKNOWN 必须带 1–64 字符的稳定 error_code;SUCCEEDED 不带错误分类。retryable 省略为 false,只有 FAILED 可设 true,表示此次明确失败可安全重试。FAILED/true 受次数与时限限制进入退避;UNKNOWN 仅在冻结策略为 IDEMPOTENT 且预算允许时自动恢复,其他情况转 NEEDS_REVIEW。历史不确定效果不会被后续失败覆盖;完整规则见 [安全重试](retries.md)。
开始、续租和结束返回 CommandResult: 开始、续租和结束返回 CommandResult:
@@ -120,7 +120,7 @@ receipt 是原操作的不可变确认;lease 是本次重新核验的执行权
- 原 start 回执可能仍表示 RUNNING,但当前 lease.active=false;旧 ACK 不能让已过期、已结束或已被替换的尝试复活。 - 原 start 回执可能仍表示 RUNNING,但当前 lease.active=false;旧 ACK 不能让已过期、已结束或已被替换的尝试复活。
- 有效期不超过租约窗口、会话期限、任务总截止和从 authorized_at 起计算的单次尝试时限。 - 有效期不超过租约窗口、会话期限、任务总截止和从 authorized_at 起计算的单次尝试时限。
- 客户端需要扣除请求总耗时及安全余量,用本地单调时钟停止执行,不能假定本地墙上时钟与数据库一致。 - 客户端需要扣除请求总耗时及安全余量,用本地单调时钟停止执行,不能假定本地墙上时钟与数据库一致。
- 同结果重报不重复释放许可或产生成功事件。同消息不同内容返回 IDEMPOTENCY_CONFLICT。 - 同结果重报不重复释放许可或产生成功事件;即使换 message_id,也返回该结果第一次已应用的回执,不借用后续尝试的状态。同消息改变内容或 retryable 返回 IDEMPOTENCY_CONFLICT。
- 迟到、未开始或与已保存结果冲突的内容,经过身份和秘密验证后保存为 EVIDENCE_ONLY;不改变任务权威结果,不当作新的执行授权。 - 迟到、未开始或与已保存结果冲突的内容,经过身份和秘密验证后保存为 EVIDENCE_ONLY;不改变任务权威结果,不当作新的执行授权。
- 断线后原结果仍保留稳定消息 ID,可在新会话恢复/核对后重报。不会因重连创建第二次业务执行。 - 断线后原结果仍保留稳定消息 ID,可在新会话恢复/核对后重报。不会因重连创建第二次业务执行。
@@ -132,12 +132,12 @@ receipt 是原操作的不可变确认;lease 是本次重新核验的执行权
每个节点每 2 秒发起一批恢复扫描,每批最多 100 项: 每个节点每 2 秒发起一批恢复扫描,每批最多 100 项:
- 未批准开始即到期:有剩余次数和截止时间时,延后 1 秒重派;否则 FAILED。 - 未批准开始即到期:有剩余次数和截止时间时按冻结策略退避;否则无历史不确定效果为 FAILED,有未核实效果为 NEEDS_REVIEW。
- 已批准开始后失联:NEEDS_REVIEW,尝试 UNKNOWN,不能因网络超时断定业务未生效。 - 已批准开始后失联:尝试 UNKNOWN;RECONCILE_ONLY 转 NEEDS_REVIEW,IDEMPOTENT 仅在次数及时限允许时退避重试,并保留 effect_uncertain。
- WAITING 超过总截止或最晚开始时间:FAILED。 - WAITING 超过总截止或最晚开始时间:无历史不确定效果为 FAILED,否则 NEEDS_REVIEW。
- 回收再次锁定队列、任务、尝试并读取数据库时间,与续租/完成串行化;多个节点不能重复释放许可或重复终结事件。 - 回收再次锁定队列、任务、尝试并读取数据库时间,与续租/完成串行化;多个节点不能重复释放许可或重复终结事件。
此处的安全回收不是完整重试引擎;IDEMPOTENT 的自动重试和策略退避仍是 P3 工作。 安全重试已使用同一决策逻辑处理有效结果与租约回收;下一执行时间持久化在任务中,重启不丢失等待。当前不提供人工重试或核查解除接口。
## 6. 连接保护与当前边界 ## 6. 连接保护与当前边界

View File

@@ -45,6 +45,15 @@
- 异常:迟到或矛盾内容不能覆盖权威结果;缺少动作权限返回 403,错配父资源、跨命名空间或队列越权返回 404,详见 [接口](../api/execution-history.md)。示例不调用外部业务系统。 - 异常:迟到或矛盾内容不能覆盖权威结果;缺少动作权限返回 403,错配父资源、跨命名空间或队列越权返回 404,详见 [接口](../api/execution-history.md)。示例不调用外部业务系统。
- 清理:正常结束确认排空并关闭连接;任务及证据保留用于追踪,不硬删除。重复运行创建独立任务。 - 清理:正常结束确认排空并关闭连接;任务及证据保留用于追踪,不硬删除。重复运行创建独立任务。
## 已可运行:有限安全重试
- 源码:[重试示例](../../sdk/go/examples/retry/main.go),[完整启动命令与结果](../api/retries.md#5-运行完整示例)。
- 前置:当前迁移及服务,具有提交、读取和 Worker 权限的专用凭据。
- 步骤:第一次在产生效果前模拟明确拒绝 → 平台持久化退避 → 第二次处理文本 → 查询两次尝试 → 排空。
- 预期:SUCCEEDED、attempts=2、first_retryable=true、结果 `{"text":"SAFE RETRY"}`;Worker started=2、confirmed=2、running=0。
- 异常:次数/时限耗尽停止重试;未知效果默认转核查。`Retryable=true` 需要明确的安全依据,不能用于普通网络超时。
- 清理:示例不调用外部业务系统,每次创建独立任务并保留历史;失败时非零退出并有界收尾。
## 计划场景与验收标准 ## 计划场景与验收标准
| 场景 | 输入与步骤 | 预期结果 | 必测异常 | 状态 | | 场景 | 输入与步骤 | 预期结果 | 必测异常 | 状态 |

View File

@@ -0,0 +1,74 @@
# P3 安全重试阶段记录
验证日期:2026-09-17。此报告只完成 P3 中的安全重试部分;P3 及总目标继续进行,不代表全平台生产验收通过。
## 1. 本轮实现
- 按受理时冻结的次数、退避及截止时间决定是否新建下一次执行;同一 Task.ID、不同 AttemptID/Epoch,领取时消耗预算。
- 明确失败通过 WorkerFailure.Retryable / task.finish.retryable 显式声明可安全再试,默认 false;未知效果只有 IDEMPOTENT 且预算允许时重试。
- 保存跨尝试的 effect_uncertain,预算耗尽、后续失败、未获开始授权即到期、等待取消或截止都不会将旧未知效果伪装成普通终态。
- 抖动退避写入数据库 available_at;与尝试结果、许可释放、事件、结果审计及操作回执原子提交。后台回收保留含节点身份与决策的事件。
- 结果换消息身份重报也取原已应用回执;过滤早到的证据回执,避免借用后续尝试状态。
- 前向迁移 000005 增加三个中文注释字段和结果回执索引;实体仍为 32 个,旧迁移未改动。
- 同步独立 SDK、Worker Schema、查询字段、[接口手册](../api/retries.md) 与 [完整示例](../../sdk/go/examples/retry/main.go)。
## 2. 环境与执行命令
本机 macOS、Go 1.27.1、Docker 内单个 MySQL 8.4.10。隔离数据库使用随机密码和回环随机端口,测试结束删除临时容器;不连接业务数据库。升级测试在同一临时实例内另建独立数据库,从迁移 1–4 重建旧结构再运行正式 Migrate。
```sh
make fmt
make test vet build
make integration
cd sdk/go
GOWORK=off go test -race ./...
GOWORK=off go vet ./...
```
全部通过。根模块、SDK 的 make test 使用竞态检测;不设置集成环境时数据库测试跳过,真实数据库证据以 make integration 的记录为准。
最终完整集成输出:存储包 13.464 秒(含 v4 升级 3.19 秒、安全重试事务用例 1.51 秒);SDK 连接/运行器包 62.580 秒(含安全重试 7.80 秒);真实进程故障包 11.263 秒。时间为一次测试运行耗时,**不是吞吐或延迟基准**。此前还完成过一轮主链路回归,本次因新增升级、约束、早到证据回执及示例,再运行完整集成。
## 3. 实际覆盖的安全行为
| 层次 / 用例 | 核对结果 |
| --- | --- |
| 纯决策 | 默认失败不重试、显式失败重试、未知效果安全分流、预算耗尽、旧不确定性保留、总截止/最晚开始、等于截止拒绝 |
| 退避 | 256 个尝试身份的有界分布、同身份确定值、指数上限、1 毫秒边界、Duration 溢出保护 |
| MySQL 事务 | 失败 → WAITING → 新尝试成功;等待期间不占许可、无任务结果;旧尝试保留分类与结果 |
| 原回执 | 同消息重报不改变下一时间;改 retryable 冲突;后续成功后重报旧失败仍返回原 WAITING 回执 |
| 早到证据 | 同一结果在开始前作为证据、开始后正式应用,新身份再次重报取 APPLIED 回执 |
| 未知效果 | IDEMPOTENT 可有限重试,RECONCILE_ONLY 直接转核查;后续失败/未知/预开始失效/取消/截止均不抹掉旧不确定性 |
| 恢复竞争 | 两个执行存储节点并发回收同一尝试,只安排一次重试、一次许可释放和一次 requeued 事件 |
| 暂停与迟到 | 队列暂停阻止重试领取;迟到成功只保存证据,不清除不确定性;恢复队列后新有效尝试可成功 |
| 原子回滚 | 用测试触发器使结果审计写入失败,任务、许可、事件与回执整体回滚;原消息重报后提交成功 |
| 数据库约束 | 运行中的尝试不能声明 retryable,UNKNOWN 证据不能声明 retryable,终态不能标为 effect_uncertain |
| 升级兼容 | v4 NEEDS_REVIEW 回填 true,其他状态默认 false;状态、revision、冻结策略不变;重复迁移无二次修改 |
| 摘要兼容 | 省略或 false 的 retryable 与旧 finish 摘要结构完全一致 |
| SDK 真服务 | 明确失败后成功、幂等未知后成功、默认未知转核查、可重试失败耗尽;查询次数、Epoch 和历史分类 |
| SDK ACK 故障 | 第一次失败已提交并进入退避后丢失 finish ACK,运行器重连核对原回执,未重置预算或重复调用同一尝试 |
| 协议 | Schema 与实际校验接受 FAILED/true,拒绝 UNKNOWN/true;既有 WSS 消息契约回归通过 |
部分服务层用例将受控测试任务的 available_at/lease_until 改为到期值,以触发竞争路径;它们不用于证明真实计时精度。SDK 用例没有此类时间改写,核对第二次领取不早于第一次结束后的最小退避。
原 P0–P2 存储、权限、精确 JSON、历史、大载荷、WSS、自动续租及实际进程强杀回归同步通过。真实进程测试仍是原 P2 用例,验证同次 ACK 恢复及 RECONCILE_ONLY 的未知转核查;本轮未额外宣称已完成“新重试等待期间强杀所有服务节点”测试。
## 4. 六个可运行示例
make integration 已运行 health、tasks、worker-protocol、worker、history、retry。新增 retry 的实际输出(任务 ID 略):
```text
task=<任务ID> state=SUCCEEDED attempts=2 first_retryable=true result={"text":"SAFE RETRY"}
worker_drained=true started=2 confirmed=2 running=0
```
第一次故障在产生业务效果前注入,第二次执行文本规范化。Worker 的 confirmed=2 统计尝试报告,不代表完成两个任务。例子每次使用独立操作和处理器身份;正常排空,记录与审计保留。没有外部业务系统副作用。
## 5. 当前边界与后续
- 自动重试不提供端到端 exactly-once;声明 IDEMPOTENT 的处理器需在真实资源侧验证幂等和迟到执行隔离。Go 不能终止忽略 context 的旧 goroutine。
- Cron/固定间隔、计划版本与漏跑、运行中取消、rate_per_second/配额背压、可靠事件订阅尚未完成。人工核查/恢复入口也尚未实现。
- 本次只验证停旧服务后升级结构;未验证混合版本滚动升级、向旧二进制回退、大表在线 DDL 或生产数据库故障切换。升级会为旧的未结束多次尝试任务启用已冻结策略,详见 [兼容说明](../api/retries.md#6-回执与升级兼容)。
- 本地单 MySQL 与回环多进程不能代替跨主机高可用、复制确认、备份恢复、RPO/RTO 或容量验收。并发与重试风暴负载将在 P6 固定拓扑和负载模型后测量。
下一步继续 P3 的调度与控制闭环,按 [持续计划](../08-development-plan.md) 同步实现、SDK、示例与实际回归。

View File

@@ -1,6 +1,6 @@
# Go SDK 接入说明 # Go SDK 接入说明
已实现健康查询、任务提交/查询/分页/待执行取消、队列创建/查询、[自动 Worker](worker-runtime.md)、[Worker 底层协议 SDK](worker.md) 和 [执行历史查询](../api/execution-history.md)。流程构建等后续接口按 [开发计划](../08-development-plan.md) 继续实现。 已实现健康查询、任务提交/查询/分页/待执行取消、队列创建/查询、[自动 Worker](worker-runtime.md)、[Worker 底层协议 SDK](worker.md) 和 [执行历史查询](../api/execution-history.md)。[安全重试](../api/retries.md) 已提供有限策略、WorkerFailure.Retryable、历史查询及完整例子。流程构建等后续接口按 [开发计划](../08-development-plan.md) 继续实现。
module:`git.yuntuo.hk/k2safe/orbit/sdk/go`。SDK 使用独立 go.mod,依赖 Go 标准库与 `github.com/coder/websocket v1.8.15`,不引用服务端模型或数据库实现。 module:`git.yuntuo.hk/k2safe/orbit/sdk/go`。SDK 使用独立 go.mod,依赖 Go 标准库与 `github.com/coder/websocket v1.8.15`,不引用服务端模型或数据库实现。
@@ -97,7 +97,7 @@ if err != nil {
fmt.Println(cancelled.Task.State) fmt.Println(cancelled.Task.State)
``` ```
取消重报必须保留第一次取消时的 expected_revision,不能将响应中的新版本塞回同一幂等请求。当前只取消 WAITING 且不归属流程的任务。 取消重报必须保留第一次取消时的 expected_revision,不能将响应中的新版本塞回同一幂等请求。当前只取消 WAITING 且不归属流程的任务。若此前存在未知效果,取消结果为 NEEDS_REVIEW,不能解释为已撤销外部效果。
业务错误返回 *orbit.APIError,支持 errors.As 转为 *orbit.HTTPError 查看状态。健康错误或无法识别的网关错误使用 *orbit.HTTPError,不带未知响应正文。网络/上下文错误按标准库错误返回;超时并不能证明服务端没有提交。 业务错误返回 *orbit.APIError,支持 errors.As 转为 *orbit.HTTPError 查看状态。健康错误或无法识别的网关错误使用 *orbit.HTTPError,不带未知响应正文。网络/上下文错误按标准库错误返回;超时并不能证明服务端没有提交。
@@ -176,4 +176,4 @@ task=<任务ID> state=SUCCEEDED attempts=1 evidence=1 reason=RESULT_CONFLICT dis
## 7. 后续交付 ## 7. 后续交付
P2 已提供底层协议、[自动 Worker 运行器及示例](worker-runtime.md) 与历史查询,并通过 [Schema 及进程故障阶段验收](../reports/phase-2.md);P3 提供安全重试、计划与结果订阅;P4–P5 提供流程构建发布、信号、等待、轮询与人工恢复。每项随实现同步交付可运行示例及异常回归。 P2 已提供底层协议、[自动 Worker 运行器及示例](worker-runtime.md) 与历史查询,并通过 [Schema 及进程故障阶段验收](../reports/phase-2.md);P3 已实现 [安全重试与示例](../api/retries.md),计划和结果订阅继续开发;P4–P5 提供流程构建发布、信号、等待、轮询与人工恢复。每项随实现同步交付可运行示例及异常回归。

View File

@@ -65,7 +65,7 @@ SDK 的 Run 每个实例只能调用一次,包括已经正常退出的实例
| ReconnectMinDelay | 200 毫秒;至少 10 毫秒 | 连接失败退避起点 | | ReconnectMinDelay | 200 毫秒;至少 10 毫秒 | 连接失败退避起点 |
| ReconnectMaxDelay | 5 秒;不小于起点,最多 30 秒 | 指数退避上限,区间抖动;不增加任务尝试次数 | | ReconnectMaxDelay | 5 秒;不小于起点,最多 30 秒 | 指数退避上限,区间抖动;不增加任务尝试次数 |
`Run(ctx)` 正常排空返回 nil;同实例再次运行返回 ErrWorkerAlreadyRun。`Stats()` 返回进程内 Running、Started、RenewalsConfirmed、ReportsConfirmed、EvidenceConfirmed、Reconnects、DroppedEvents 快照,不是持久化业务成功率。 `Run(ctx)` 正常排空返回 nil;同实例再次运行返回 ErrWorkerAlreadyRun。`Stats()` 返回进程内 Running、Started、RenewalsConfirmed、ReportsConfirmed、EvidenceConfirmed、Reconnects、DroppedEvents 快照,不是持久化业务成功率。ReportsConfirmed 包含已确认的失败/未知尝试报告,一条重试任务可能计多次。
`Events()` 是 128 条缓冲的只读本地通知流,不读取也不会阻塞运行;缓冲满时丢弃并计数,Run 返回时关闭。通知只含类型、任务/尝试 ID 与稳定分类,没有业务结果或秘密。它不能代替审计、可靠结果订阅或任务查询。 `Events()` 是 128 条缓冲的只读本地通知流,不读取也不会阻塞运行;缓冲满时丢弃并计数,Run 返回时关闭。通知只含类型、任务/尝试 ID 与稳定分类,没有业务结果或秘密。它不能代替审计、可靠结果订阅或任务查询。
@@ -78,15 +78,16 @@ SDK 的 Run 每个实例只能调用一次,包括已经正常退出的实例
- 重连复用实例和尝试身份,在开启容量之前 Resume 所有已知尝试;待确认 start/renew/finish 使用原消息 ID。认证、权限及协议错误终止 Run;临时服务/连接错误按退避核对。 - 重连复用实例和尝试身份,在开启容量之前 Resume 所有已知尝试;待确认 start/renew/finish 使用原消息 ID。认证、权限及协议错误终止 Run;临时服务/连接错误按退避核对。
- 结果未确认或处理器失去租约时,运行器降低对外声明的容量。仍未返回的函数占用真实并发槽;已在传输中的预占会有界接收,已知尝试最多为配置容量的两倍。 - 结果未确认或处理器失去租约时,运行器降低对外声明的容量。仍未返回的函数占用真实并发槽;已在传输中的预占会有界接收,已知尝试最多为配置容量的两倍。
Run 的重连是控制消息恢复,不是业务自动重试。当前明确失败仍成为 FAILED;策略化业务重试在 P3 实现。 Run 的重连恢复同一次控制操作。平台的 [安全重试](../api/retries.md) 使用同一 Task.ID 派发新的 AttemptID/Epoch,运行器按新的有效授权调用处理器,预算与退避由服务端持久化管理。
## 5. 结果与错误分类 ## 5. 结果与错误分类
| 处理器结果 | SDK 报告 / 服务端行为 | | 处理器结果 | SDK 报告 / 服务端行为 |
| --- | --- | | --- | --- |
| 合法非 null JSON + nil error | SUCCEEDED | | 合法非 null JSON + nil error | SUCCEEDED |
| `&orbit.WorkerFailure{Code:"OBJECT_REJECTED", Details:...}` | FAILED;仅用于能确认的业务失败,Details 为空时为 `{}` | | `&orbit.WorkerFailure{Code:"OBJECT_REJECTED", Details:...}` | FAILED;默认 Retryable=false,Details 为空时为 `{}` |
| 普通 error | UNKNOWN / HANDLER_ERROR,进入 NEEDS_REVIEW | | `&orbit.WorkerFailure{Code:"TEMPORARY_REJECTION", Retryable:true}` | 明确失败可安全重试,平台仍核验次数、总时限和历史不确定效果 |
| 普通 error | UNKNOWN / HANDLER_ERROR;默认转核查,仅 IDEMPOTENT 且预算允许时可自动重试 |
| panic / runtime.Goexit | UNKNOWN / HANDLER_PANIC 或 HANDLER_ABORTED | | panic / runtime.Goexit | UNKNOWN / HANDLER_PANIC 或 HANDLER_ABORTED |
| 非法 JSON、超限结果、不支持数值或嵌套 | UNKNOWN / INVALID_HANDLER_RESULT | | 非法 JSON、超限结果、不支持数值或嵌套 | UNKNOWN / INVALID_HANDLER_RESULT |
| 处理器因租约取消返回错误 | UNKNOWN / LEASE_LOST | | 处理器因租约取消返回错误 | UNKNOWN / LEASE_LOST |
@@ -94,7 +95,7 @@ Run 的重连是控制消息恢复,不是业务自动重试。当前明确失
原始异常和 panic 内容不写入结果或通知。Result/Details 最多 256 KiB,应优先使用对象引用。SDK 保留精确数字文本,并按服务端数字范围及完整帧的嵌套上限预检,避免产生永远无法接受的报告。 原始异常和 panic 内容不写入结果或通知。Result/Details 最多 256 KiB,应优先使用对象引用。SDK 保留精确数字文本,并按服务端数字范围及完整帧的嵌套上限预检,避免产生永远无法接受的报告。
普通网络错误不能证明副作用未发生,因此不会自动解释为明确失败。UNKNOWN 和迟到证据的后续核查由平台管理能力与业务对账共同处理。 普通网络错误不能证明副作用未发生,因此不会自动解释为明确失败。UNKNOWN 由服务端结合真实幂等声明决定是否重试;迟到证据不能清除历史不确定效果。完整规则及可运行示例见 [安全重试](../api/retries.md)。
## 6. 退出与故障边界 ## 6. 退出与故障边界
@@ -102,7 +103,7 @@ Run 的重连是控制消息恢复,不是业务自动重试。当前明确失
ShutdownTimeout 从取消信号发生时开始计时,包含网络等待和处理器收尾。到期后取消控制请求及处理器 context,返回 `*WorkerRunError`;可用 `errors.Is(err, orbit.ErrWorkerShutdownTimeout)` 判断,Unconfirmed 和 Running 给出终止收尾时的快照。其他致命错误也带这些计数,便于识别未核对的执行。 ShutdownTimeout 从取消信号发生时开始计时,包含网络等待和处理器收尾。到期后取消控制请求及处理器 context,返回 `*WorkerRunError`;可用 `errors.Is(err, orbit.ErrWorkerShutdownTimeout)` 判断,Unconfirmed 和 Running 给出终止收尾时的快照。其他致命错误也带这些计数,便于识别未核对的执行。
Go 无法杀死忽略 context 的 goroutine。Run 可以有界返回,但这类函数可能仍在进程中执行;不得把返回错误解释为副作用已停止,也不要在同一进程盲目重建 Worker 叠加执行。服务端对失联执行按租约回收并进入核查。 Go 无法杀死忽略 context 的 goroutine。Run 可以有界返回,但这类函数可能仍在进程中执行;不得把返回错误解释为副作用已停止,也不要在同一进程盲目重建 Worker 叠加执行。服务端对失联执行按租约回收,依冻结安全策略决定重试或进入核查。
身份、尚未确认的结果和消息 ID 当前保存在内存,支持同进程重连;进程被强杀会失去这些内容。持久化租约、操作回执、最终结果和审计在 MySQL 中,业务外部效果仍需要业务幂等或查询核对。这一边界不等于端到端 exactly-once。 身份、尚未确认的结果和消息 ID 当前保存在内存,支持同进程重连;进程被强杀会失去这些内容。持久化租约、操作回执、最终结果和审计在 MySQL 中,业务外部效果仍需要业务幂等或查询核对。这一边界不等于端到端 exactly-once。

View File

@@ -28,6 +28,8 @@ task=<32位任务ID> final_state=SUCCEEDED attempts=1 result={"text":"HELLO ORBI
[完整源码](../../sdk/go/examples/worker-protocol/main.go) 实际提交 `{"text":" Hello Orbit "}`,领取并取得开始授权,执行去首尾空格/转大写,报告结果、查询当前状态并排空。没有生成文件或调用外部服务。每次运行创建独立任务与临时处理器名,避免消费前次示例遗留任务;历史和审计保留,不硬删除。 [完整源码](../../sdk/go/examples/worker-protocol/main.go) 实际提交 `{"text":" Hello Orbit "}`,领取并取得开始授权,执行去首尾空格/转大写,报告结果、查询当前状态并排空。没有生成文件或调用外部服务。每次运行创建独立任务与临时处理器名,避免消费前次示例遗留任务;历史和审计保留,不硬删除。
安全重试可通过 `WorkerFinish.Retryable=true` 声明明确失败允许再试,仅适用于 WorkerFailed;省略为 false。新尝试由服务端决定,详见 [安全重试](../api/retries.md)。
示例最多对开始/结果操作重连重报两次,保持原进程身份、尝试证明、消息 ID 和内容;业务函数只在首次取得有效开始确认后调用一次。网络结果仍不明时非零退出,不能推断服务端回滚。身份与待确认结果只保留在当前进程内,进程被终止后的未知结果由服务端回收进入核查。本示例处理很短的纯计算,不演示长任务自动续租。 示例最多对开始/结果操作重连重报两次,保持原进程身份、尝试证明、消息 ID 和内容;业务函数只在首次取得有效开始确认后调用一次。网络结果仍不明时非零退出,不能推断服务端回滚。身份与待确认结果只保留在当前进程内,进程被终止后的未知结果由服务端回收进入核查。本示例处理很短的纯计算,不演示长任务自动续租。
## 2. 创建进程身份与连接 ## 2. 创建进程身份与连接
@@ -105,7 +107,7 @@ defer cancel()
| Finish 请求结果不明 | 保留原 finishID、Outcome、Result、ErrorCode,重连核对后原样重报 | | Finish 请求结果不明 | 保留原 finishID、Outcome、Result、ErrorCode,重连核对后原样重报 |
| 明确业务成功 | `WorkerSucceeded`,不带 ErrorCode | | 明确业务成功 | `WorkerSucceeded`,不带 ErrorCode |
| 可确认的业务失败 | `WorkerFailed`,带稳定 ErrorCode | | 可确认的业务失败 | `WorkerFailed`,带稳定 ErrorCode |
| 超时、失联或不能确认外部效果 | `WorkerUnknown`,带稳定 ErrorCode,进入核查 | | 超时、失联或不能确认外部效果 | `WorkerUnknown`,带稳定 ErrorCode;默认转核查,仅 IDEMPOTENT 且预算允许时自动重试 |
| `Disposition=EVIDENCE_ONLY` | 内容已留作证据;不能当作任务成功,另查 GetTask 的权威状态 | | `Disposition=EVIDENCE_ONLY` | 内容已留作证据;不能当作任务成功,另查 GetTask 的权威状态 |
Result 是不超过 256 KiB 的非 null JSON,可传对象引用;原始异常不应写入稳定错误分类。SDK 拒绝非法 Unicode、重复键和过深 JSON,保留数字文本;数值范围仍由服务端最终校验。 Result 是不超过 256 KiB 的非 null JSON,可传对象引用;原始异常不应写入稳定错误分类。SDK 拒绝非法 Unicode、重复键和过深 JSON,保留数字文本;数值范围仍由服务端最终校验。

View File

@@ -0,0 +1,13 @@
-- 安全重试保留跨尝试的不确定效果,旧迁移及旧操作摘要保持不变。
ALTER TABLE pulse_tasks
ADD COLUMN effect_uncertain BOOLEAN NOT NULL DEFAULT FALSE COMMENT '曾有已授权执行的效果尚未核实,重试与耗尽不清除,仅权威成功或核查可解除',
ADD CONSTRAINT ck_task_effect_uncertain CHECK (effect_uncertain IN (0,1) AND (effect_uncertain=FALSE OR state NOT IN ('SUCCEEDED','FAILED','CANCELLED')));
UPDATE pulse_tasks SET effect_uncertain=TRUE WHERE state='NEEDS_REVIEW';
ALTER TABLE pulse_attempts
ADD COLUMN retryable BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Worker 明确失败时声明可安全重试,不用于未知效果或证明实际已安排重试',
ADD CONSTRAINT ck_attempt_retryable CHECK (retryable IN (0,1) AND (retryable=FALSE OR state='FAILED'));
ALTER TABLE pulse_attempt_evidence
ADD COLUMN retryable BOOLEAN NOT NULL DEFAULT FALSE COMMENT '原失败报告的安全重试声明,证据本身不触发重试',
ADD CONSTRAINT ck_evidence_retryable CHECK (retryable IN (0,1) AND (retryable=FALSE OR outcome='FAILED'));
ALTER TABLE pulse_attempt_receipts
ADD KEY ix_result_receipt(namespace_id,attempt_id,operation,request_digest) COMMENT '同一结果换消息身份重报时读取原始已应用回执';

View File

@@ -0,0 +1,116 @@
package mysql
import (
"context"
"os"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
mysqldriver "github.com/go-sql-driver/mysql"
)
// 在独立库中重建真实 v4,再走正式迁移入口;不改变主测试库的结构或迁移记录。
func TestIntegrationSafeRetryUpgrade(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL with CREATE DATABASE permission")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
admin, err := Open(ctx, dsn, 2, 1)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
name := "orbit_retry_upgrade_" + string(executionID(t))
if _, err := admin.ExecContext(ctx, "CREATE DATABASE `"+name+"`"); err != nil {
t.Fatal(err)
}
defer func() {
cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := admin.ExecContext(cleanup, "DROP DATABASE `"+name+"`"); err != nil {
t.Error(err)
}
}()
cfg, err := mysqldriver.ParseDSN(dsn)
if err != nil {
t.Fatal("invalid test DSN")
}
cfg.DBName = name
db, err := Open(ctx, cfg.FormatDSN(), 4, 2)
if err != nil {
t.Fatal(err)
}
defer db.Close()
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.ExecContext(ctx, q, args...); err != nil {
t.Fatal(err)
}
}
exec(`CREATE TABLE orbit_schema_migrations (version BIGINT UNSIGNED PRIMARY KEY,name VARCHAR(191) NOT NULL,checksum CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,dirty BOOLEAN NOT NULL,applied_at DATETIME(6) NULL) ENGINE=InnoDB`)
migrations, err := Migrations()
if err != nil {
t.Fatal(err)
}
for _, m := range migrations {
if m.Version > 4 {
break
}
statements, err := splitSQL(m.SQL)
if err != nil {
t.Fatal(err)
}
for _, statement := range statements {
exec(statement)
}
exec(`INSERT INTO orbit_schema_migrations(version,name,checksum,dirty,applied_at) VALUES (?,?,?,FALSE,UTC_TIMESTAMP(6))`, m.Version, m.Name, m.Checksum)
}
app, ns, queue := executionID(t), executionID(t), executionID(t)
exec(`INSERT INTO orbit_applications(id,name) VALUES (?,?)`, app, string(app))
exec(`INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,'test')`, ns, app, string(ns))
exec(`INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')`, ns, queue, string(queue))
policy := `{"retry":{"max_attempts":3,"initial_delay_ms":1000,"max_delay_ms":30000,"attempt_timeout_ms":5000}}`
ids := map[string]core.ID{}
for _, state := range []string{"NEEDS_REVIEW", "WAITING", "SUCCEEDED", "FAILED", "CANCELLED"} {
id := executionID(t)
ids[state] = id
var completed *time.Time
if state == "SUCCEEDED" || state == "FAILED" || state == "CANCELLED" {
now := time.Now().UTC()
completed = &now
}
exec(`INSERT INTO pulse_tasks(namespace_id,id,queue_id,state,handler,handler_version,payload_version,payload,policy_snapshot,available_at,deadline_at,attempt_timeout_ms,max_attempts,completed_at) VALUES (?,?,?,?,'object.prepare','v1','v1','{}',?,UTC_TIMESTAMP(6),DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR),5000,3,?)`, ns, id, queue, state, policy, completed)
}
// MySQL 原生 JSON 会规范化文本,以迁移前实际存储值核对冻结策略。
if err := db.QueryRowContext(ctx, `SELECT policy_snapshot FROM pulse_tasks WHERE namespace_id=? AND id=?`, ns, ids["WAITING"]).Scan(&policy); err != nil {
t.Fatal(err)
}
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(ctx, db); err != nil {
t.Fatal(err)
}
for state, id := range ids {
var gotState, gotPolicy string
var uncertain bool
var revision uint64
if err := db.QueryRowContext(ctx, `SELECT state,effect_uncertain,policy_snapshot,revision FROM pulse_tasks WHERE namespace_id=? AND id=?`, ns, id).Scan(&gotState, &uncertain, &gotPolicy, &revision); err != nil {
t.Fatal(err)
}
if gotState != state || uncertain != (state == "NEEDS_REVIEW") || gotPolicy != policy || revision != 1 {
t.Fatalf("upgrade changed frozen data or lost uncertainty for %s", state)
}
}
// 再次运行不重复修改回填或策略。
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
var count int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM pulse_tasks WHERE effect_uncertain=TRUE`).Scan(&count); err != nil || count != 1 {
t.Fatalf("upgrade is not idempotent: %d %v", count, err)
}
}

View File

@@ -91,11 +91,11 @@ func historyAttemptExists(ctx context.Context, tx *sql.Tx, ns, task, attempt cor
return err return err
} }
const attemptHistoryColumns = `a.id,a.task_id,a.epoch,a.state,a.revision,a.lease_until,a.authorized_at,a.ended_at,COALESCE(a.error_code,''),a.created_at,a.updated_at` const attemptHistoryColumns = `a.id,a.task_id,a.epoch,a.state,a.revision,a.lease_until,a.authorized_at,a.ended_at,COALESCE(a.error_code,''),a.retryable,a.created_at,a.updated_at`
func scanAttemptSummary(row interface{ Scan(...any) error }) (service.AttemptSummary, error) { func scanAttemptSummary(row interface{ Scan(...any) error }) (service.AttemptSummary, error) {
var a service.AttemptSummary var a service.AttemptSummary
err := row.Scan(&a.ID, &a.TaskID, &a.Epoch, &a.State, &a.Revision, &a.LeaseUntil, &a.AuthorizedAt, &a.EndedAt, &a.ErrorCode, &a.CreatedAt, &a.UpdatedAt) err := row.Scan(&a.ID, &a.TaskID, &a.Epoch, &a.State, &a.Revision, &a.LeaseUntil, &a.AuthorizedAt, &a.EndedAt, &a.ErrorCode, &a.Retryable, &a.CreatedAt, &a.UpdatedAt)
return a, err return a, err
} }
func (s *TaskStore) ListTaskAttempts(ctx context.Context, t auth.Token, task core.ID, in service.HistoryPageInput) (service.AttemptPage, error) { func (s *TaskStore) ListTaskAttempts(ctx context.Context, t auth.Token, task core.ID, in service.HistoryPageInput) (service.AttemptPage, error) {
@@ -148,7 +148,7 @@ func (s *TaskStore) GetTaskAttempt(ctx context.Context, t auth.Token, task, atte
return err return err
} }
var result []byte var result []byte
err := tx.QueryRowContext(ctx, `SELECT `+attemptHistoryColumns+`,a.session_id,w.worker_instance_id,a.result FROM pulse_attempts a JOIN pulse_worker_sessions w ON w.namespace_id=a.namespace_id AND w.id=a.session_id WHERE a.namespace_id=? AND a.task_id=? AND a.id=?`, t.NamespaceID, task, attempt).Scan(&out.ID, &out.TaskID, &out.Epoch, &out.State, &out.Revision, &out.LeaseUntil, &out.AuthorizedAt, &out.EndedAt, &out.ErrorCode, &out.CreatedAt, &out.UpdatedAt, &out.SessionID, &out.WorkerInstanceID, &result) err := tx.QueryRowContext(ctx, `SELECT `+attemptHistoryColumns+`,a.session_id,w.worker_instance_id,a.result FROM pulse_attempts a JOIN pulse_worker_sessions w ON w.namespace_id=a.namespace_id AND w.id=a.session_id WHERE a.namespace_id=? AND a.task_id=? AND a.id=?`, t.NamespaceID, task, attempt).Scan(&out.ID, &out.TaskID, &out.Epoch, &out.State, &out.Revision, &out.LeaseUntil, &out.AuthorizedAt, &out.EndedAt, &out.ErrorCode, &out.Retryable, &out.CreatedAt, &out.UpdatedAt, &out.SessionID, &out.WorkerInstanceID, &result)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return fault.NotFound return fault.NotFound
} }
@@ -166,7 +166,7 @@ func (s *TaskStore) ListAttemptEvidence(ctx context.Context, t auth.Token, task,
if err := historyAttemptExists(ctx, tx, t.NamespaceID, task, attempt); err != nil { if err := historyAttemptExists(ctx, tx, t.NamespaceID, task, attempt); err != nil {
return err return err
} }
q := `SELECT id,attempt_id,outcome,COALESCE(error_code,''),reason,created_at FROM pulse_attempt_evidence WHERE namespace_id=? AND attempt_id=?` q := `SELECT id,attempt_id,outcome,COALESCE(error_code,''),retryable,reason,created_at FROM pulse_attempt_evidence WHERE namespace_id=? AND attempt_id=?`
args := []any{t.NamespaceID, attempt} args := []any{t.NamespaceID, attempt}
if in.Cursor != "" { if in.Cursor != "" {
c, err := decodeHistoryCursor(in.Cursor, t.NamespaceID, task, attempt) c, err := decodeHistoryCursor(in.Cursor, t.NamespaceID, task, attempt)
@@ -185,7 +185,7 @@ func (s *TaskStore) ListAttemptEvidence(ctx context.Context, t auth.Token, task,
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
e := service.EvidenceSummary{TaskID: task, Disposition: "EVIDENCE_ONLY"} e := service.EvidenceSummary{TaskID: task, Disposition: "EVIDENCE_ONLY"}
if err := rows.Scan(&e.ID, &e.AttemptID, &e.Outcome, &e.ErrorCode, &e.Reason, &e.CreatedAt); err != nil { if err := rows.Scan(&e.ID, &e.AttemptID, &e.Outcome, &e.ErrorCode, &e.Retryable, &e.Reason, &e.CreatedAt); err != nil {
return err return err
} }
out.Items = append(out.Items, e) out.Items = append(out.Items, e)
@@ -213,7 +213,7 @@ func (s *TaskStore) GetAttemptEvidence(ctx context.Context, t auth.Token, task,
return err return err
} }
var payload []byte var payload []byte
err := tx.QueryRowContext(ctx, `SELECT id,attempt_id,outcome,COALESCE(error_code,''),reason,created_at,message_id,payload FROM pulse_attempt_evidence WHERE namespace_id=? AND attempt_id=? AND id=?`, t.NamespaceID, attempt, evidence).Scan(&out.ID, &out.AttemptID, &out.Outcome, &out.ErrorCode, &out.Reason, &out.CreatedAt, &out.MessageID, &payload) err := tx.QueryRowContext(ctx, `SELECT id,attempt_id,outcome,COALESCE(error_code,''),retryable,reason,created_at,message_id,payload FROM pulse_attempt_evidence WHERE namespace_id=? AND attempt_id=? AND id=?`, t.NamespaceID, attempt, evidence).Scan(&out.ID, &out.AttemptID, &out.Outcome, &out.ErrorCode, &out.Retryable, &out.Reason, &out.CreatedAt, &out.MessageID, &payload)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return fault.NotFound return fault.NotFound
} }

View File

@@ -216,14 +216,14 @@ func (s *TaskStore) Submit(ctx context.Context, token auth.Token, key string, in
return out, err return out, err
} }
const taskColumns = `id,namespace_id,queue_id,state,revision,handler,handler_version,payload_version,payload,result,COALESCE(business_key,''),available_at,start_deadline_at,deadline_at,recovery_safety,policy_snapshot,attempt_count,cancel_requested_at,completed_at,created_at,updated_at` const taskColumns = `id,namespace_id,queue_id,state,revision,handler,handler_version,payload_version,payload,result,COALESCE(business_key,''),available_at,start_deadline_at,deadline_at,recovery_safety,policy_snapshot,attempt_count,cancel_requested_at,completed_at,created_at,updated_at,effect_uncertain`
type scanner interface{ Scan(...any) error } type scanner interface{ Scan(...any) error }
func scanTask(row scanner) (service.TaskView, error) { func scanTask(row scanner) (service.TaskView, error) {
var v service.TaskView var v service.TaskView
var policy, payload, result []byte var policy, payload, result []byte
err := row.Scan(&v.ID, &v.NamespaceID, &v.QueueID, &v.State, &v.Revision, &v.Handler, &v.HandlerVersion, &v.PayloadVersion, &payload, &result, &v.BusinessKey, &v.AvailableAt, &v.StartDeadlineAt, &v.DeadlineAt, &v.RecoverySafety, &policy, &v.AttemptCount, &v.CancelRequestedAt, &v.CompletedAt, &v.CreatedAt, &v.UpdatedAt) err := row.Scan(&v.ID, &v.NamespaceID, &v.QueueID, &v.State, &v.Revision, &v.Handler, &v.HandlerVersion, &v.PayloadVersion, &payload, &result, &v.BusinessKey, &v.AvailableAt, &v.StartDeadlineAt, &v.DeadlineAt, &v.RecoverySafety, &policy, &v.AttemptCount, &v.CancelRequestedAt, &v.CompletedAt, &v.CreatedAt, &v.UpdatedAt, &v.EffectUncertain)
if err != nil { if err != nil {
return v, err return v, err
} }
@@ -273,7 +273,7 @@ func (s *TaskStore) ListTasks(ctx context.Context, token auth.Token, in service.
if !p.AllowsQueue(in.QueueID) { if !p.AllowsQueue(in.QueueID) {
return fault.Forbidden return fault.Forbidden
} }
q := `SELECT id,queue_id,state,revision,handler,handler_version,COALESCE(business_key,''),available_at,deadline_at,attempt_count,created_at,updated_at FROM pulse_tasks WHERE namespace_id=? AND queue_id=?` q := `SELECT id,queue_id,state,revision,handler,handler_version,COALESCE(business_key,''),available_at,deadline_at,attempt_count,created_at,updated_at,effect_uncertain FROM pulse_tasks WHERE namespace_id=? AND queue_id=?`
args := []any{token.NamespaceID, in.QueueID} args := []any{token.NamespaceID, in.QueueID}
if in.State != "" { if in.State != "" {
q += " AND state=?" q += " AND state=?"
@@ -297,7 +297,7 @@ func (s *TaskStore) ListTasks(ctx context.Context, token auth.Token, in service.
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var v service.TaskSummary var v service.TaskSummary
if err := rows.Scan(&v.ID, &v.QueueID, &v.State, &v.Revision, &v.Handler, &v.HandlerVersion, &v.BusinessKey, &v.AvailableAt, &v.DeadlineAt, &v.AttemptCount, &v.CreatedAt, &v.UpdatedAt); err != nil { if err := rows.Scan(&v.ID, &v.QueueID, &v.State, &v.Revision, &v.Handler, &v.HandlerVersion, &v.BusinessKey, &v.AvailableAt, &v.DeadlineAt, &v.AttemptCount, &v.CreatedAt, &v.UpdatedAt, &v.EffectUncertain); err != nil {
return err return err
} }
out.Items = append(out.Items, v) out.Items = append(out.Items, v)
@@ -357,7 +357,17 @@ func (s *TaskStore) CancelTask(ctx context.Context, token auth.Token, id core.ID
if owner.Valid { if owner.Valid {
return fault.Conflict return fault.Conflict
} }
result, err := tx.ExecContext(ctx, `UPDATE pulse_tasks SET state='CANCELLED',cancel_requested_at=UTC_TIMESTAMP(6),completed_at=UTC_TIMESTAMP(6),updated_at=UTC_TIMESTAMP(6),revision=revision+1 WHERE namespace_id=? AND id=? AND revision=? AND state='WAITING'`, token.NamespaceID, id, in.ExpectedRevision) now, err := dbNow(ctx, tx)
if err != nil {
return err
}
next := pulse.TaskCancelled
var completed *time.Time = &now
if task.EffectUncertain {
next = pulse.TaskNeedsReview
completed = nil
}
result, err := tx.ExecContext(ctx, `UPDATE pulse_tasks SET state=?,cancel_requested_at=?,completed_at=?,updated_at=?,revision=revision+1 WHERE namespace_id=? AND id=? AND revision=? AND state='WAITING'`, next, now, completed, now, token.NamespaceID, id, in.ExpectedRevision)
if err != nil { if err != nil {
return err return err
} }
@@ -372,7 +382,7 @@ func (s *TaskStore) CancelTask(ctx context.Context, token auth.Token, id core.ID
if err != nil { if err != nil {
return err return err
} }
if err := emitEvent(ctx, tx, token.NamespaceID, id, out.Task.Revision, "task", "task.cancelled", map[string]any{"task_id": id, "state": out.Task.State}); err != nil { if err := emitEvent(ctx, tx, token.NamespaceID, id, out.Task.Revision, "task", resultEvent(out.Task.State), map[string]any{"task_id": id, "state": out.Task.State, "reason": "CANCEL_REQUESTED", "effect_uncertain": out.Task.EffectUncertain}); err != nil {
return err return err
} }
if err := appendAudit(ctx, tx, token, identity, id, "task", "task.cancel", in.Reason, map[string]any{"state": task.State, "revision": fmt.Sprint(task.Revision)}, map[string]any{"state": out.Task.State, "revision": fmt.Sprint(out.Task.Revision)}); err != nil { if err := appendAudit(ctx, tx, token, identity, id, "task", "task.cancel", in.Reason, map[string]any{"state": task.State, "revision": fmt.Sprint(task.Revision)}, map[string]any{"state": out.Task.State, "revision": fmt.Sprint(out.Task.Revision)}); err != nil {

View File

@@ -309,7 +309,7 @@ func (s *ExecutionStore) lockExecution(ctx context.Context, tx *sql.Tx, token au
func readAttempt(ctx context.Context, tx *sql.Tx, ns, id core.ID) (pulse.Attempt, error) { func readAttempt(ctx context.Context, tx *sql.Tx, ns, id core.ID) (pulse.Attempt, error) {
var a pulse.Attempt var a pulse.Attempt
a.NamespaceID = ns a.NamespaceID = ns
err := tx.QueryRowContext(ctx, `SELECT id,revision,task_id,epoch,session_id,token_digest,state,lease_until,authorized_at,ended_at,error_code,result,result_digest FROM pulse_attempts WHERE namespace_id=? AND id=? FOR UPDATE`, ns, id).Scan(&a.ID, &a.Revision, &a.TaskID, &a.Epoch, &a.SessionID, &a.TokenDigest, &a.State, &a.LeaseUntil, &a.AuthorizedAt, &a.EndedAt, &a.ErrorCode, &a.Result, &a.ResultDigest) err := tx.QueryRowContext(ctx, `SELECT id,revision,task_id,epoch,session_id,token_digest,state,lease_until,authorized_at,ended_at,error_code,retryable,result,result_digest FROM pulse_attempts WHERE namespace_id=? AND id=? FOR UPDATE`, ns, id).Scan(&a.ID, &a.Revision, &a.TaskID, &a.Epoch, &a.SessionID, &a.TokenDigest, &a.State, &a.LeaseUntil, &a.AuthorizedAt, &a.EndedAt, &a.ErrorCode, &a.Retryable, &a.Result, &a.ResultDigest)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
err = fault.NotFound err = fault.NotFound
} }

View File

@@ -10,11 +10,12 @@ import (
"git.yuntuo.hk/k2safe/orbit/internal/fault" "git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model" core "git.yuntuo.hk/k2safe/orbit/model"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model" pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
"git.yuntuo.hk/k2safe/orbit/pulse/retry"
"git.yuntuo.hk/k2safe/orbit/pulse/worker" "git.yuntuo.hk/k2safe/orbit/pulse/worker"
) )
// Recover 是受控后台扫描入口,每批最多 100 项、每项一个短事务,可由多个节点竞争。 // Recover 是受控后台扫描入口,每批最多 100 项、每项一个短事务,可由多个节点竞争。
// 当前已授权执行的失联一律转核查,自动安全重试在 P3 接入冻结策略后实现。 // 已授权执行的失联保留效果未知,只有冻结的幂等恢复声明与剩余预算允许自动重试。
func (s *ExecutionStore) Recover(ctx context.Context, limit int) (worker.RecoveryStats, error) { func (s *ExecutionStore) Recover(ctx context.Context, limit int) (worker.RecoveryStats, error) {
var out worker.RecoveryStats var out worker.RecoveryStats
if limit < 1 || limit > 100 { if limit < 1 || limit > 100 {
@@ -88,11 +89,16 @@ func (s *ExecutionStore) Recover(ctx context.Context, limit int) (worker.Recover
return nil return nil
} }
changed = pulse.TaskFailed changed = pulse.TaskFailed
result, err := tx.ExecContext(ctx, `UPDATE pulse_tasks SET state='FAILED',completed_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, now, c.ns, c.id, task.view.Revision) var completed *time.Time = &now
if task.view.EffectUncertain {
changed = pulse.TaskNeedsReview
completed = nil
}
result, err := tx.ExecContext(ctx, `UPDATE pulse_tasks SET state=?,completed_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, changed, completed, now, c.ns, c.id, task.view.Revision)
if err := exactlyOne(result, err); err != nil { if err := exactlyOne(result, err); err != nil {
return err return err
} }
return emitEvent(ctx, tx, c.ns, c.id, task.view.Revision+1, "task", "task.failed", map[string]any{"reason": "START_DEADLINE_EXCEEDED", "node_id": s.node}) return emitEvent(ctx, tx, c.ns, c.id, task.view.Revision+1, "task", resultEvent(changed), map[string]any{"reason": "START_DEADLINE_EXCEEDED", "node_id": s.node, "effect_uncertain": task.view.EffectUncertain})
} }
if task.view.State != pulse.TaskLeased && task.view.State != pulse.TaskRunning { if task.view.State != pulse.TaskLeased && task.view.State != pulse.TaskRunning {
return nil return nil
@@ -118,29 +124,31 @@ func (s *ExecutionStore) Recover(ctx context.Context, limit int) (worker.Recover
return errors.New("active task references ended attempt") return errors.New("active task references ended attempt")
} }
attemptState := pulse.AttemptExpired attemptState := pulse.AttemptExpired
next := pulse.TaskFailed outcome := "NOT_STARTED"
reason := "LEASE_EXPIRED_BEFORE_START" reason := "LEASE_EXPIRED_BEFORE_START"
var completed *time.Time = &now
available := task.view.AvailableAt
if attempt.AuthorizedAt != nil { if attempt.AuthorizedAt != nil {
attemptState = pulse.AttemptUnknown attemptState = pulse.AttemptUnknown
next = pulse.TaskNeedsReview outcome = "UNKNOWN"
reason = "EXECUTION_OUTCOME_UNKNOWN" reason = "EXECUTION_OUTCOME_UNKNOWN"
completed = nil }
} else if task.view.AttemptCount < task.maxAttempts { decision, err := retry.Decide(retryFacts(task, attempt.ID, now, outcome, false))
// 未批准开始的尝试可以有界重派,次数不重置;开始截止与总截止仍有效。 if err != nil {
nextAt := now.Add(time.Second) return err
if nextAt.Before(task.view.DeadlineAt) && (task.view.StartDeadlineAt == nil || nextAt.Before(*task.view.StartDeadlineAt)) { }
next = pulse.TaskWaiting next := decision.State
available = nextAt var completed *time.Time
completed = nil if next.Terminal() {
} completed = &now
}
available := task.view.AvailableAt
if decision.NextAt != nil {
available = *decision.NextAt
} }
result, err := tx.ExecContext(ctx, `UPDATE pulse_attempts SET state=?,ended_at=?,error_code=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, attemptState, now, reason, now, c.ns, attempt.ID, attempt.Revision) result, err := tx.ExecContext(ctx, `UPDATE pulse_attempts SET state=?,ended_at=?,error_code=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, attemptState, now, reason, now, c.ns, attempt.ID, attempt.Revision)
if err := exactlyOne(result, err); err != nil { if err := exactlyOne(result, err); err != nil {
return err return err
} }
result, err = tx.ExecContext(ctx, `UPDATE pulse_tasks SET state=?,lease_until=NULL,available_at=?,completed_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, next, available, completed, now, c.ns, c.id, task.view.Revision) result, err = tx.ExecContext(ctx, `UPDATE pulse_tasks SET state=?,effect_uncertain=?,lease_until=NULL,available_at=?,completed_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, next, decision.Uncertain, available, completed, now, c.ns, c.id, task.view.Revision)
if err := exactlyOne(result, err); err != nil { if err := exactlyOne(result, err); err != nil {
return err return err
} }
@@ -148,7 +156,7 @@ func (s *ExecutionStore) Recover(ctx context.Context, limit int) (worker.Recover
return err return err
} }
changed = next changed = next
return emitEvent(ctx, tx, c.ns, c.id, task.view.Revision+1, "task", resultEvent(next), map[string]any{"attempt_id": attempt.ID, "epoch": fmt.Sprint(attempt.Epoch), "reason": reason, "node_id": s.node}) return emitEvent(ctx, tx, c.ns, c.id, task.view.Revision+1, "task", resultEvent(next), map[string]any{"attempt_id": attempt.ID, "epoch": fmt.Sprint(attempt.Epoch), "reason": reason, "node_id": s.node, "effect_uncertain": decision.Uncertain, "decision": decision.Reason, "next_available_at": decision.NextAt, "delay_ms": decision.DelayMS, "attempt_count": task.view.AttemptCount, "max_attempts": task.maxAttempts})
}) })
if err != nil { if err != nil {
return out, err return out, err

View File

@@ -3,12 +3,14 @@ package mysql
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"time" "time"
core "git.yuntuo.hk/k2safe/orbit/model" core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth" "git.yuntuo.hk/k2safe/orbit/platform/auth"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model" pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
"git.yuntuo.hk/k2safe/orbit/pulse/retry"
"git.yuntuo.hk/k2safe/orbit/pulse/worker" "git.yuntuo.hk/k2safe/orbit/pulse/worker"
) )
@@ -19,7 +21,8 @@ func (s *ExecutionStore) Finish(ctx context.Context, token auth.Token, b worker.
Outcome string Outcome string
Result core.JSON Result core.JSON
ErrorCode string ErrorCode string
}{attemptIdentity(in.Ref), in.Outcome, core.JSON(in.Result), in.ErrorCode}) Retryable bool `json:"Retryable,omitempty"`
}{attemptIdentity(in.Ref), in.Outcome, core.JSON(in.Result), in.ErrorCode, in.Retryable})
if err != nil { if err != nil {
return out, err return out, err
} }
@@ -45,6 +48,14 @@ func (s *ExecutionStore) Finish(ctx context.Context, token auth.Token, b worker.
receipt := worker.Receipt{Operation: "finish", TaskState: task.view.State, AttemptState: a.State, TaskRevision: task.view.Revision, LeaseUntil: a.LeaseUntil, Disposition: "APPLIED"} receipt := worker.Receipt{Operation: "finish", TaskState: task.view.State, AttemptState: a.State, TaskRevision: task.view.Revision, LeaseUntil: a.LeaseUntil, Disposition: "APPLIED"}
if a.ResultDigest != nil && *a.ResultDigest == digest { if a.ResultDigest != nil && *a.ResultDigest == digest {
// 新消息 ID 重报已保存的同一结果,也不能重复事件、许可释放或状态推进。 // 新消息 ID 重报已保存的同一结果,也不能重复事件、许可释放或状态推进。
// 重试可能已推进其他尝试,必须返回原操作回执,不能套用当前任务状态。
var raw []byte
if err := tx.QueryRowContext(ctx, `SELECT response FROM pulse_attempt_receipts WHERE namespace_id=? AND attempt_id=? AND operation='finish' AND request_digest=? AND JSON_UNQUOTE(JSON_EXTRACT(response,'$.disposition'))='APPLIED' LIMIT 1`, token.NamespaceID, a.ID, digest).Scan(&raw); err != nil {
return err
}
if err := json.Unmarshal(raw, &receipt); err != nil {
return err
}
if err := saveReceipt(ctx, tx, token.NamespaceID, a.ID, message, "finish", digest, receipt); err != nil { if err := saveReceipt(ctx, tx, token.NamespaceID, a.ID, message, "finish", digest, receipt); err != nil {
return err return err
} }
@@ -70,24 +81,34 @@ func (s *ExecutionStore) Finish(ctx context.Context, token auth.Token, b worker.
out = worker.CommandResult{Receipt: receipt, Lease: status} out = worker.CommandResult{Receipt: receipt, Lease: status}
return nil return nil
} }
attemptState, taskState := pulse.AttemptState(in.Outcome), pulse.TaskState(in.Outcome) attemptState := pulse.AttemptState(in.Outcome)
var completed *time.Time = &now decision, err := retry.Decide(retryFacts(task, a.ID, now, in.Outcome, in.Retryable))
if in.Outcome == "UNKNOWN" { if err != nil {
taskState = pulse.TaskNeedsReview return err
completed = nil
} }
changed, err := tx.ExecContext(ctx, `UPDATE pulse_attempts SET state=?,ended_at=?,error_code=NULLIF(?,''),result=?,result_digest=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, attemptState, now, in.ErrorCode, string(in.Result), digest, now, token.NamespaceID, a.ID, a.Revision) taskState := decision.State
var completed *time.Time
if taskState.Terminal() {
completed = &now
}
available := task.view.AvailableAt
var result any = string(in.Result)
if decision.NextAt != nil {
available = *decision.NextAt
result = nil
}
changed, err := tx.ExecContext(ctx, `UPDATE pulse_attempts SET state=?,ended_at=?,error_code=NULLIF(?,''),retryable=?,result=?,result_digest=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, attemptState, now, in.ErrorCode, in.Retryable, string(in.Result), digest, now, token.NamespaceID, a.ID, a.Revision)
if err := exactlyOne(changed, err); err != nil { if err := exactlyOne(changed, err); err != nil {
return err return err
} }
changed, err = tx.ExecContext(ctx, `UPDATE pulse_tasks SET state=?,result=?,lease_until=NULL,completed_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, taskState, string(in.Result), completed, now, token.NamespaceID, in.Ref.TaskID, task.view.Revision) changed, err = tx.ExecContext(ctx, `UPDATE pulse_tasks SET state=?,result=?,effect_uncertain=?,available_at=?,lease_until=NULL,completed_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, taskState, result, decision.Uncertain, available, completed, now, token.NamespaceID, in.Ref.TaskID, task.view.Revision)
if err := exactlyOne(changed, err); err != nil { if err := exactlyOne(changed, err); err != nil {
return err return err
} }
if err := releasePermits(ctx, tx, token.NamespaceID, a.ID, now); err != nil { if err := releasePermits(ctx, tx, token.NamespaceID, a.ID, now); err != nil {
return err return err
} }
event := map[string]any{"attempt_id": a.ID, "epoch": fmt.Sprint(a.Epoch), "state": taskState, "outcome": in.Outcome, "error_code": in.ErrorCode} event := map[string]any{"attempt_id": a.ID, "epoch": fmt.Sprint(a.Epoch), "state": taskState, "outcome": in.Outcome, "error_code": in.ErrorCode, "retryable": in.Retryable, "effect_uncertain": decision.Uncertain, "decision": decision.Reason, "next_available_at": decision.NextAt, "delay_ms": decision.DelayMS, "attempt_count": task.view.AttemptCount, "max_attempts": task.maxAttempts}
if err := emitEvent(ctx, tx, token.NamespaceID, in.Ref.TaskID, task.view.Revision+1, "task", resultEvent(taskState), event); err != nil { if err := emitEvent(ctx, tx, token.NamespaceID, in.Ref.TaskID, task.view.Revision+1, "task", resultEvent(taskState), event); err != nil {
return err return err
} }
@@ -124,9 +145,16 @@ func saveEvidence(ctx context.Context, tx *sql.Tx, ns, message core.ID, in worke
if err != nil { if err != nil {
return err return err
} }
_, err = tx.ExecContext(ctx, `INSERT INTO pulse_attempt_evidence(namespace_id,id,attempt_id,message_id,outcome,error_code,payload,request_digest,reason) VALUES (?,?,?,?,?,NULLIF(?,''),?,?,?)`, ns, id, in.Ref.AttemptID, message, in.Outcome, in.ErrorCode, string(in.Result), digest, reason) _, err = tx.ExecContext(ctx, `INSERT INTO pulse_attempt_evidence(namespace_id,id,attempt_id,message_id,outcome,error_code,retryable,payload,request_digest,reason) VALUES (?,?,?,?,?,NULLIF(?,''),?,?,?,?)`, ns, id, in.Ref.AttemptID, message, in.Outcome, in.ErrorCode, in.Retryable, string(in.Result), digest, reason)
if err != nil { if err != nil {
return err return err
} }
return emitEvent(ctx, tx, ns, id, 1, "attempt_evidence", "attempt.evidence_received", map[string]any{"attempt_id": in.Ref.AttemptID, "task_id": in.Ref.TaskID, "reason": reason, "outcome": in.Outcome}) return emitEvent(ctx, tx, ns, id, 1, "attempt_evidence", "attempt.evidence_received", map[string]any{"attempt_id": in.Ref.AttemptID, "task_id": in.Ref.TaskID, "reason": reason, "outcome": in.Outcome})
} }
// retryFacts 以持久化预算为准,JSON 快照只提供冻结的等待策略。
func retryFacts(task executionTask, attempt core.ID, now time.Time, outcome string, retryable bool) retry.Input {
policy := task.view.Retry
policy.MaxAttempts = task.maxAttempts
return retry.Input{Policy: policy, Safety: task.view.RecoverySafety, Consumed: task.view.AttemptCount, Outcome: outcome, Retryable: retryable, Uncertain: task.view.EffectUncertain, CancelRequested: task.view.CancelRequestedAt != nil, Now: now, Deadline: task.view.DeadlineAt, StartDeadline: task.view.StartDeadlineAt, AttemptID: attempt}
}

View File

@@ -0,0 +1,307 @@
package mysql
import (
"context"
"encoding/json"
"errors"
"os"
"sync"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
mysqldriver "github.com/go-sql-driver/mysql"
)
func retryTask(t *testing.T, f *executionFixture, max uint32, safety pulse.RecoverySafety) service.TaskView {
t.Helper()
out, err := f.tasks.Submit(f.ctx, f.token, string(executionID(t)), service.SubmitInput{QueueID: f.resources.QueueID, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`), RecoverySafety: safety, Retry: pulse.RetryPolicy{MaxAttempts: max, InitialDelayMS: 1000, MaxDelayMS: 5000, AttemptTimeoutMS: 30000}})
if err != nil {
t.Fatal(err)
}
return out.Task
}
func retryStart(t *testing.T, f *executionFixture) worker.AttemptRef {
t.Helper()
_, ref := f.claim()
if _, err := f.a.Start(f.ctx, f.token, f.binding, executionID(t), ref); err != nil {
t.Fatal(err)
}
return ref
}
func retryFinish(t *testing.T, f *executionFixture, ref worker.AttemptRef, outcome string, retryable bool) worker.CommandResult {
t.Helper()
in := worker.FinishInput{Ref: ref, Outcome: outcome, Retryable: retryable, Result: json.RawMessage(`{"n":9007199254740993}`)}
if outcome != "SUCCEEDED" {
in.ErrorCode = "CONTROLLED_RESULT"
}
out, err := f.a.Finish(f.ctx, f.token, f.binding, executionID(t), in)
if err != nil {
t.Fatal(err)
}
return out
}
func makeRetryDue(f *executionFixture, id core.ID) {
f.exec(`UPDATE pulse_tasks SET available_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, id)
}
func TestIntegrationSafeRetry(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 32, 8)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
t.Run("original_applied_receipt_excludes_early_evidence", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
retryTask(t, f, 2, pulse.RecoveryReconcileOnly)
_, ref := f.claim()
in := worker.FinishInput{Ref: ref, Outcome: "FAILED", Retryable: true, ErrorCode: "TEMPORARY_REJECTION", Result: json.RawMessage(`{}`)}
early, err := f.a.Finish(ctx, f.token, f.binding, executionID(t), in)
if err != nil || early.Receipt.Disposition != "EVIDENCE_ONLY" {
t.Fatal("early report was applied")
}
if _, err := f.a.Start(ctx, f.token, f.binding, executionID(t), ref); err != nil {
t.Fatal(err)
}
applied, err := f.a.Finish(ctx, f.token, f.binding, executionID(t), in)
if err != nil || applied.Receipt.Disposition != "APPLIED" || applied.Receipt.TaskState != pulse.TaskWaiting {
t.Fatal("authorized result not applied")
}
duplicate, err := f.a.Finish(ctx, f.token, f.binding, executionID(t), in)
if err != nil || !duplicate.Deduplicated || duplicate.Receipt != applied.Receipt {
t.Fatal("replay selected an evidence receipt")
}
})
t.Run("legacy_digest_and_database_retry_constraints", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
task := retryTask(t, f, 3, pulse.RecoveryReconcileOnly)
ref := retryStart(t, f)
reject := func(q string, args ...any) {
t.Helper()
_, err := db.ExecContext(ctx, q, args...)
var failure *mysqldriver.MySQLError
if !errors.As(err, &failure) || failure.Number != 3819 {
t.Fatalf("wanted CHECK rejection, got %v", err)
}
}
reject(`UPDATE pulse_attempts SET retryable=TRUE WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, ref.AttemptID)
in := worker.FinishInput{Ref: ref, Outcome: "FAILED", ErrorCode: "REJECTED", Result: json.RawMessage(`{"value":9007199254740993}`)}
if _, err := f.a.Finish(ctx, f.token, f.binding, executionID(t), in); err != nil {
t.Fatal(err)
}
// v4 的摘要结构没有 Retryable;省略或 false 必须保持旧身份不变。
legacy, err := requestDigest(struct {
Identity any
Outcome string
Result core.JSON
ErrorCode string
}{attemptIdentity(ref), in.Outcome, core.JSON(in.Result), in.ErrorCode})
if err != nil {
t.Fatal(err)
}
var saved core.Digest
if err := db.QueryRowContext(ctx, `SELECT result_digest FROM pulse_attempts WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, ref.AttemptID).Scan(&saved); err != nil || saved != legacy {
t.Fatal("legacy finish digest changed")
}
reject(`UPDATE pulse_tasks SET effect_uncertain=TRUE WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, task.ID)
reject(`INSERT INTO pulse_attempt_evidence(namespace_id,id,attempt_id,message_id,outcome,payload,request_digest,reason,retryable) VALUES (?,?,?,?,'UNKNOWN','{}',?,'RESULT_CONFLICT',TRUE)`, f.resources.NamespaceID, executionID(t), ref.AttemptID, executionID(t), legacy)
})
t.Run("known_retry_duplicate_receipts_and_history", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
task := retryTask(t, f, 2, pulse.RecoveryReconcileOnly)
ref := retryStart(t, f)
in := worker.FinishInput{Ref: ref, Outcome: "FAILED", Retryable: true, ErrorCode: "TEMPORARY_REJECTION", Result: json.RawMessage(`{"n":9007199254740993}`)}
message := executionID(t)
first, err := f.a.Finish(ctx, f.token, f.binding, message, in)
if err != nil {
t.Fatal(err)
}
pending := f.get(task.ID)
if first.Receipt.TaskState != pulse.TaskWaiting || first.Lease.Active || pending.EffectUncertain || len(pending.Result) != 0 || pending.CompletedAt != nil || f.used() != 0 || pending.AttemptCount != 1 {
t.Fatal("retry did not preserve task/attempt separation")
}
if delay := pending.AvailableAt.Sub(first.Lease.ServerTime); delay < 500*time.Millisecond || delay > time.Second {
t.Fatal("retry time is not bounded")
}
if a, err := f.a.Claim(ctx, f.token, f.binding); err != nil || a != nil {
t.Fatal("retry dispatched before due")
}
duplicate, err := f.a.Finish(ctx, f.token, f.binding, message, in)
if err != nil || !duplicate.Deduplicated || duplicate.Receipt != first.Receipt || !f.get(task.ID).AvailableAt.Equal(pending.AvailableAt) {
t.Fatal("same-message replay changed retry timing")
}
in.Retryable = false
_, err = f.a.Finish(ctx, f.token, f.binding, message, in)
expectWorkerCode(t, err, "IDEMPOTENCY_CONFLICT")
in.Retryable = true
makeRetryDue(f, task.ID)
second := retryStart(t, f)
if second.Epoch != 2 || second.AttemptID == ref.AttemptID {
t.Fatal("retry reused execution identity")
}
retryFinish(t, f, second, "SUCCEEDED", false)
final := f.get(task.ID)
// 在新尝试完成后用新消息身份重报旧失败,仍返回第一次的 WAITING 回执。
duplicate, err = f.a.Finish(ctx, f.token, f.binding, executionID(t), in)
if err != nil || !duplicate.Deduplicated || duplicate.Receipt != first.Receipt || duplicate.Lease.Active || f.get(task.ID).Revision != final.Revision {
t.Fatal("old result replay borrowed newer task state")
}
in.Result = json.RawMessage(`{"n":2}`)
late, err := f.a.Finish(ctx, f.token, f.binding, executionID(t), in)
if err != nil || late.Receipt.Disposition != "EVIDENCE_ONLY" {
t.Fatal("conflicting retry report changed authority")
}
attempts, err := f.tasks.ListTaskAttempts(ctx, f.token, task.ID, service.HistoryPageInput{})
if err != nil || len(attempts.Items) != 2 || !attempts.Items[1].Retryable || attempts.Items[0].State != pulse.AttemptSucceeded {
t.Fatal("retry history lost classification")
}
evidence, err := f.tasks.ListAttemptEvidence(ctx, f.token, task.ID, ref.AttemptID, service.HistoryPageInput{})
if err != nil || len(evidence.Items) != 1 || !evidence.Items[0].Retryable {
t.Fatal("evidence lost original retry declaration")
}
if f.count("orbit_events", "aggregate_id=? AND type='task.requeued'", task.ID) != 1 || f.count("orbit_events", "aggregate_id=? AND type='task.succeeded'", task.ID) != 1 || f.used() != 0 {
t.Fatal("retry duplicated events or permits")
}
})
t.Run("failure_classification_and_budget", func(t *testing.T) {
for _, retryable := range []bool{false, true} {
f := newExecutionFixture(t, ctx, db)
task := retryTask(t, f, 2, pulse.RecoveryReconcileOnly)
ref := retryStart(t, f)
retryFinish(t, f, ref, "FAILED", retryable)
count := uint32(1)
if retryable {
if f.get(task.ID).State != pulse.TaskWaiting {
t.Fatal("retryable failure not retried")
}
makeRetryDue(f, task.ID)
retryFinish(t, f, retryStart(t, f), "FAILED", true)
count = 2
}
got := f.get(task.ID)
if got.State != pulse.TaskFailed || got.AttemptCount != count || got.EffectUncertain || got.CompletedAt == nil {
t.Fatal("failure budget or classification ignored")
}
}
})
t.Run("unknown_history_remains_uncertain_until_authoritative_success", func(t *testing.T) {
for _, last := range []string{"SUCCEEDED", "FAILED", "UNKNOWN", "NOT_STARTED", "CANCEL", "DEADLINE"} {
t.Run(last, func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
task := retryTask(t, f, 2, pulse.RecoveryIdempotent)
ref := retryStart(t, f)
retryFinish(t, f, ref, "UNKNOWN", false)
pending := f.get(task.ID)
if pending.State != pulse.TaskWaiting || !pending.EffectUncertain || len(pending.Result) != 0 {
t.Fatal("unknown retry lost uncertainty")
}
switch last {
case "CANCEL":
out, err := f.tasks.CancelTask(ctx, f.token, task.ID, string(executionID(t)), service.CancelInput{ExpectedRevision: pending.Revision, Reason: "stop remaining retries"})
if err != nil || out.Task.CancelRequestedAt == nil {
t.Fatal(err)
}
case "DEADLINE":
f.exec(`UPDATE pulse_tasks SET available_at=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 2 SECOND),deadline_at=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 SECOND) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, task.ID)
if _, err := f.storeB.Recover(ctx, 100); err != nil {
t.Fatal(err)
}
case "NOT_STARTED":
makeRetryDue(f, task.ID)
_, next := f.claim()
f.expire(next)
if _, err := f.storeB.Recover(ctx, 100); err != nil {
t.Fatal(err)
}
default:
makeRetryDue(f, task.ID)
retryFinish(t, f, retryStart(t, f), last, last == "FAILED")
}
got := f.get(task.ID)
if last == "SUCCEEDED" {
if got.State != pulse.TaskSucceeded || got.EffectUncertain {
t.Fatal("success did not resolve uncertainty")
}
} else if got.State != pulse.TaskNeedsReview || !got.EffectUncertain || got.CompletedAt != nil {
t.Fatal("unresolved effects became false terminal failure/cancellation")
}
if f.used() != 0 {
t.Fatal("retry leaked permit")
}
})
}
})
t.Run("unknown_requires_explicit_idempotent_contract", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
task := retryTask(t, f, 3, pulse.RecoveryReconcileOnly)
retryFinish(t, f, retryStart(t, f), "UNKNOWN", false)
got := f.get(task.ID)
if got.State != pulse.TaskNeedsReview || !got.EffectUncertain || got.AttemptCount != 1 {
t.Fatal("unknown replayed without idempotence")
}
})
t.Run("recovery_race_pause_and_late_success", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
task := retryTask(t, f, 2, pulse.RecoveryIdempotent)
ref := retryStart(t, f)
f.expire(ref)
var wg sync.WaitGroup
errs := make(chan error, 2)
for _, store := range []*ExecutionStore{f.storeA, f.storeB} {
wg.Add(1)
go func(s *ExecutionStore) { defer wg.Done(); _, err := s.Recover(ctx, 100); errs <- err }(store)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
pending := f.get(task.ID)
if pending.State != pulse.TaskWaiting || !pending.EffectUncertain || f.used() != 0 || f.count("orbit_events", "aggregate_id=? AND type='task.requeued'", task.ID) != 1 {
t.Fatal("competing recovery did not make one retry decision")
}
makeRetryDue(f, task.ID)
f.exec(`UPDATE pulse_queues SET paused=TRUE WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.QueueID)
if a, err := f.a.Claim(ctx, f.token, f.binding); err != nil || a != nil {
t.Fatal("retry bypassed pause")
}
late := retryFinish(t, f, ref, "SUCCEEDED", false)
if late.Receipt.Disposition != "EVIDENCE_ONLY" || !f.get(task.ID).EffectUncertain {
t.Fatal("late success cleared unresolved authority")
}
f.exec(`UPDATE pulse_queues SET paused=FALSE WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.QueueID)
retryFinish(t, f, retryStart(t, f), "SUCCEEDED", false)
if f.get(task.ID).State != pulse.TaskSucceeded {
t.Fatal("resumed retry failed")
}
})
t.Run("retry_transaction_rolls_back_with_audit", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
task := retryTask(t, f, 2, pulse.RecoveryReconcileOnly)
ref := retryStart(t, f)
f.exec(`CREATE TRIGGER orbit_test_retry_audit BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='retry audit failure'`)
in := worker.FinishInput{Ref: ref, Outcome: "FAILED", Retryable: true, ErrorCode: "TEMPORARY_REJECTION", Result: json.RawMessage(`{}`)}
message := executionID(t)
_, err := f.a.Finish(ctx, f.token, f.binding, message, in)
f.exec(`DROP TRIGGER orbit_test_retry_audit`)
if err == nil || f.get(task.ID).State != pulse.TaskRunning || f.used() != 1 || f.count("orbit_events", "aggregate_id=? AND type='task.requeued'", task.ID) != 0 {
t.Fatal("partial retry transition committed")
}
if _, err := f.a.Finish(ctx, f.token, f.binding, message, in); err != nil {
t.Fatal(err)
}
})
}

View File

@@ -107,6 +107,7 @@ func TestWorkerPublishedContract(t *testing.T) {
{"task.finish", worker.FinishInput{Ref: ref, Outcome: "SUCCEEDED", Result: json.RawMessage(`{"n":9007199254740993}`)}}, {"task.finish", worker.FinishInput{Ref: ref, Outcome: "SUCCEEDED", Result: json.RawMessage(`{"n":9007199254740993}`)}},
{"task.finish", worker.FinishInput{Ref: ref, Outcome: "FAILED", ErrorCode: "OBJECT_REJECTED", Result: json.RawMessage(`{}`)}}, {"task.finish", worker.FinishInput{Ref: ref, Outcome: "FAILED", ErrorCode: "OBJECT_REJECTED", Result: json.RawMessage(`{}`)}},
{"task.finish", worker.FinishInput{Ref: ref, Outcome: "UNKNOWN", ErrorCode: "LEASE_LOST", Result: json.RawMessage(`{}`)}}, {"task.finish", worker.FinishInput{Ref: ref, Outcome: "UNKNOWN", ErrorCode: "LEASE_LOST", Result: json.RawMessage(`{}`)}},
{"task.finish", worker.FinishInput{Ref: ref, Outcome: "FAILED", Retryable: true, ErrorCode: "TEMPORARY_REJECTION", Result: json.RawMessage(`{}`)}},
} }
var valid [][]byte var valid [][]byte
for _, tc := range cases { for _, tc := range cases {
@@ -145,6 +146,7 @@ func TestWorkerPublishedContract(t *testing.T) {
{"null_result", strings.Replace(string(valid[5]), `{"n":9007199254740993}`, `null`, 1)}, {"null_result", strings.Replace(string(valid[5]), `{"n":9007199254740993}`, `null`, 1)},
{"failure_missing_code", strings.Replace(string(valid[6]), `,"error_code":"OBJECT_REJECTED"`, ``, 1)}, {"failure_missing_code", strings.Replace(string(valid[6]), `,"error_code":"OBJECT_REJECTED"`, ``, 1)},
{"success_with_code", strings.Replace(string(valid[6]), `"FAILED"`, `"SUCCEEDED"`, 1)}, {"success_with_code", strings.Replace(string(valid[6]), `"FAILED"`, `"SUCCEEDED"`, 1)},
{"unknown_retry_flag", strings.Replace(string(valid[8]), `"FAILED"`, `"UNKNOWN"`, 1)},
} }
for _, tc := range invalid { for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {

View File

@@ -193,6 +193,11 @@
"error_code": { "error_code": {
"type": "string", "type": "string",
"maxLength": 64 "maxLength": 64
},
"retryable": {
"type": "boolean",
"default": false,
"description": "仅 FAILED 可以为真,表示明确失败可安全重试,不表示必定安排重试。"
} }
}, },
"required": [ "required": [
@@ -230,6 +235,25 @@
} }
} }
} }
},
{
"if": {
"properties": {
"outcome": {
"enum": [
"SUCCEEDED",
"UNKNOWN"
]
}
}
},
"then": {
"properties": {
"retryable": {
"const": false
}
}
}
} }
] ]
}, },
@@ -485,6 +509,10 @@
}, },
"updated_at": { "updated_at": {
"$ref": "#/$defs/time" "$ref": "#/$defs/time"
},
"effect_uncertain": {
"type": "boolean",
"description": "仍有历史执行效果未核实,重试不清除。"
} }
}, },
"required": [ "required": [

View File

@@ -29,6 +29,8 @@ type Attempt struct {
EndedAt *time.Time `db:"ended_at"` EndedAt *time.Time `db:"ended_at"`
// ErrorCode 稳定错误分类,供恢复策略判断;不得写入敏感原始错误;可为空,表示尚未设置该关联或值。 // ErrorCode 稳定错误分类,供恢复策略判断;不得写入敏感原始错误;可为空,表示尚未设置该关联或值。
ErrorCode *string `db:"error_code"` ErrorCode *string `db:"error_code"`
// Retryable Worker 对明确失败声明可安全重试;只用于 FAILED,不证明未知效果安全。
Retryable bool `db:"retryable"`
// Result 已保存的结果或结果引用 JSON;未取得结果时为空。 // Result 已保存的结果或结果引用 JSON;未取得结果时为空。
Result core.JSON `db:"result"` Result core.JSON `db:"result"`
// ResultDigest 结果语义摘要,用于识别确认重报与冲突结果;可为空,表示尚未设置该关联或值。 // ResultDigest 结果语义摘要,用于识别确认重报与冲突结果;可为空,表示尚未设置该关联或值。

View File

@@ -14,6 +14,8 @@ type AttemptEvidence struct {
Outcome string `db:"outcome"` Outcome string `db:"outcome"`
// ErrorCode 可选稳定错误分类;无错误分类时为空。 // ErrorCode 可选稳定错误分类;无错误分类时为空。
ErrorCode *string `db:"error_code"` ErrorCode *string `db:"error_code"`
// Retryable 原失败报告的安全重试声明;证据本身不触发重试。
Retryable bool `db:"retryable"`
// Payload 结果或证据引用 JSON 文本,保留数字精度。 // Payload 结果或证据引用 JSON 文本,保留数字精度。
Payload core.JSON `db:"payload"` Payload core.JSON `db:"payload"`
// RequestDigest 结果语义摘要,排除会话身份与原始秘密。 // RequestDigest 结果语义摘要,排除会话身份与原始秘密。

View File

@@ -22,9 +22,9 @@ func (s TaskState) Terminal() bool {
func (s TaskState) CanTransitionTo(next TaskState) bool { func (s TaskState) CanTransitionTo(next TaskState) bool {
switch s { switch s {
case TaskWaiting: case TaskWaiting:
return next == TaskLeased || next == TaskCancelled || next == TaskFailed return next == TaskLeased || next == TaskCancelled || next == TaskFailed || next == TaskNeedsReview
case TaskLeased: case TaskLeased:
return next == TaskRunning || next == TaskWaiting || next == TaskCancelled || next == TaskFailed return next == TaskRunning || next == TaskWaiting || next == TaskCancelled || next == TaskFailed || next == TaskNeedsReview
case TaskRunning: case TaskRunning:
return next == TaskSucceeded || next == TaskWaiting || next == TaskFailed || next == TaskNeedsReview || next == TaskCancelled return next == TaskSucceeded || next == TaskWaiting || next == TaskFailed || next == TaskNeedsReview || next == TaskCancelled
case TaskNeedsReview: case TaskNeedsReview:

View File

@@ -27,6 +27,8 @@ type Task struct {
Payload core.JSON `db:"payload"` Payload core.JSON `db:"payload"`
// Result 已保存的结果或结果引用 JSON;未取得结果时为空。 // Result 已保存的结果或结果引用 JSON;未取得结果时为空。
Result core.JSON `db:"result"` Result core.JSON `db:"result"`
// EffectUncertain 曾有已授权执行的效果尚未核实;重试/超时不能清除,仅权威成功或后续核查可解除。
EffectUncertain bool `db:"effect_uncertain"`
// PolicySnapshot 受理时冻结的实际生效策略 JSON。 // PolicySnapshot 受理时冻结的实际生效策略 JSON。
PolicySnapshot core.JSON `db:"policy_snapshot"` PolicySnapshot core.JSON `db:"policy_snapshot"`
// RecoverySafety 恢复安全等级;未知值按需要核查处理,不能默认自动重跑。 // RecoverySafety 恢复安全等级;未知值按需要核查处理,不能默认自动重跑。

115
pulse/retry/decision.go Normal file
View File

@@ -0,0 +1,115 @@
// Package retry 以冻结策略和持久化预算决定下一状态,不执行任务或维护内存定时器。
package retry
import (
"crypto/sha256"
"encoding/binary"
"errors"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
)
// Input 描述本次尝试结束时数据库已锁定的事实,不能从客户端覆盖预算和时间。
type Input struct {
Policy pulse.RetryPolicy // Policy 受理时冻结的有限策略。
Safety pulse.RecoverySafety // Safety 仅 IDEMPOTENT 允许未知效果自动恢复。
Consumed uint32 // Consumed 已领取次数,包含本次及未获批开始的尝试。
Outcome string // Outcome SUCCEEDED、FAILED、UNKNOWN 或 NOT_STARTED。
Retryable bool // Retryable 仅对明确失败有效,Worker 声明可安全重试。
Uncertain bool // Uncertain 之前仍未核实的外部效果,失败或超时不能抹掉。
CancelRequested bool // CancelRequested 已保存取消意图时禁止后续尝试。
Now time.Time // Now 完成资源锁定后读取的数据库时间。
Deadline time.Time // Deadline 任务总截止时间。
StartDeadline *time.Time // StartDeadline 所有新尝试的最晚开始时间,可为空。
AttemptID core.ID // AttemptID 持久化尝试身份,用作可重现抖动的种子。
}
// Decision 必须与尝试结果、许可释放、事件及操作回执在同一事务提交。
type Decision struct {
State pulse.TaskState // State 下一任务状态,WAITING 不是业务终态。
Uncertain bool // Uncertain 跨尝试保留的不确定性;权威成功才自动清除。
NextAt *time.Time // NextAt 仅 WAITING 有值,写入 available_at。
Reason string // Reason 稳定决策分类,用于事件和审计。
DelayMS uint64 // DelayMS 实际等待毫秒数,非重试时为零。
}
func Decide(in Input) (Decision, error) {
out := Decision{State: pulse.TaskFailed, Uncertain: in.Uncertain, Reason: "NON_RETRYABLE_FAILURE"}
if err := in.Policy.Validate(); err != nil {
return out, err
}
if in.Consumed == 0 || in.Consumed > in.Policy.MaxAttempts || in.AttemptID.Validate() != nil || in.Now.IsZero() || in.Deadline.IsZero() {
return out, errors.New("invalid retry facts")
}
canRetry := false
switch in.Outcome {
case "SUCCEEDED":
return Decision{State: pulse.TaskSucceeded, Reason: "SUCCEEDED"}, nil
case "FAILED":
canRetry = in.Retryable
if canRetry {
out.Reason = "RETRYABLE_FAILURE"
}
case "UNKNOWN":
out.Uncertain = true
canRetry = in.Safety.CanRecoverAutomatically()
out.Reason = "RECONCILIATION_REQUIRED"
if canRetry {
out.Reason = "IDEMPOTENT_UNKNOWN"
}
case "NOT_STARTED":
canRetry = true
out.Reason = "UNSTARTED_LEASE_EXPIRED"
default:
return out, errors.New("invalid retry outcome")
}
if out.Uncertain {
out.State = pulse.TaskNeedsReview
if !in.Safety.CanRecoverAutomatically() {
canRetry = false
out.Reason = "RECONCILIATION_REQUIRED"
}
}
if in.CancelRequested {
out.Reason = "CANCEL_REQUESTED"
return out, nil
}
if !canRetry {
return out, nil
}
if in.Consumed >= in.Policy.MaxAttempts {
out.Reason = "ATTEMPT_LIMIT"
return out, nil
}
delay := DelayMS(in.Policy, in.Consumed, in.AttemptID)
next := in.Now.Add(time.Duration(delay) * time.Millisecond)
if !next.Before(in.Deadline) || (in.StartDeadline != nil && !next.Before(*in.StartDeadline)) {
out.Reason = "DEADLINE_EXCEEDED"
return out, nil
}
out.State = pulse.TaskWaiting
out.DelayMS = delay
out.NextAt = &next
return out, nil
}
// DelayMS 使用有上限的指数退避和 [ceil(base/2),base] 确定性抖动;策略须先 Validate。
// 相同尝试重复计算不改变结果,倍增先截断,避免次数或 Duration 溢出。
func DelayMS(policy pulse.RetryPolicy, consumed uint32, attempt core.ID) uint64 {
base := policy.InitialDelayMS
for n := uint32(1); n < consumed && base < policy.MaxDelayMS; n++ {
if base > policy.MaxDelayMS/2 {
base = policy.MaxDelayMS
break
}
base *= 2
}
if base > policy.MaxDelayMS {
base = policy.MaxDelayMS
}
lower := (base + 1) / 2
sum := sha256.Sum256([]byte("orbit.retry.v1:" + string(attempt)))
return lower + binary.BigEndian.Uint64(sum[:8])%(base-lower+1)
}

View File

@@ -0,0 +1,102 @@
package retry
import (
"fmt"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
)
func TestDecisionPreservesSafetyBudgetAndDeadline(t *testing.T) {
now := time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC)
base := Input{Policy: pulse.RetryPolicy{MaxAttempts: 3, InitialDelayMS: 1000, MaxDelayMS: 5000, AttemptTimeoutMS: 10000}, Consumed: 1, Outcome: "FAILED", Safety: pulse.RecoveryReconcileOnly, Now: now, Deadline: now.Add(time.Hour), AttemptID: core.ID("11111111111111111111111111111111")}
for _, tc := range []struct {
name string
change func(*Input)
state pulse.TaskState
uncertain bool
reason string
}{
{"explicit_failure_default", func(*Input) {}, pulse.TaskFailed, false, "NON_RETRYABLE_FAILURE"},
{"safe_failure", func(v *Input) { v.Retryable = true }, pulse.TaskWaiting, false, "RETRYABLE_FAILURE"},
{"unknown_default", func(v *Input) { v.Outcome = "UNKNOWN" }, pulse.TaskNeedsReview, true, "RECONCILIATION_REQUIRED"},
{"idempotent_unknown", func(v *Input) { v.Outcome = "UNKNOWN"; v.Safety = pulse.RecoveryIdempotent }, pulse.TaskWaiting, true, "IDEMPOTENT_UNKNOWN"},
{"known_exhaustion", func(v *Input) { v.Retryable = true; v.Consumed = 3 }, pulse.TaskFailed, false, "ATTEMPT_LIMIT"},
{"unknown_exhaustion", func(v *Input) { v.Outcome = "UNKNOWN"; v.Safety = pulse.RecoveryIdempotent; v.Consumed = 3 }, pulse.TaskNeedsReview, true, "ATTEMPT_LIMIT"},
{"old_uncertainty_is_sticky", func(v *Input) { v.Uncertain = true; v.Safety = pulse.RecoveryIdempotent }, pulse.TaskNeedsReview, true, "NON_RETRYABLE_FAILURE"},
{"prior_uncertainty_and_prestart_exhaustion", func(v *Input) {
v.Uncertain = true
v.Safety = pulse.RecoveryIdempotent
v.Consumed = 3
v.Outcome = "NOT_STARTED"
}, pulse.TaskNeedsReview, true, "ATTEMPT_LIMIT"},
{"known_deadline", func(v *Input) { v.Retryable = true; v.Deadline = now.Add(time.Millisecond) }, pulse.TaskFailed, false, "DEADLINE_EXCEEDED"},
{"unknown_start_deadline", func(v *Input) {
v.Outcome = "UNKNOWN"
v.Safety = pulse.RecoveryIdempotent
until := now.Add(time.Millisecond)
v.StartDeadline = &until
}, pulse.TaskNeedsReview, true, "DEADLINE_EXCEEDED"},
{"cancel_prevents_retry", func(v *Input) { v.Retryable = true; v.CancelRequested = true }, pulse.TaskFailed, false, "CANCEL_REQUESTED"},
{"success_resolves_uncertainty", func(v *Input) { v.Uncertain = true; v.Outcome = "SUCCEEDED" }, pulse.TaskSucceeded, false, "SUCCEEDED"},
} {
t.Run(tc.name, func(t *testing.T) {
input := base
tc.change(&input)
got, err := Decide(input)
if err != nil || got.State != tc.state || got.Uncertain != tc.uncertain || got.Reason != tc.reason {
t.Fatalf("unexpected decision: %+v %v", got, err)
}
if (got.NextAt != nil) != (got.State == pulse.TaskWaiting) {
t.Fatal("retry time does not match state")
}
if got.NextAt != nil && (got.DelayMS < 500 || got.DelayMS > 1000 || !got.NextAt.Equal(now.Add(time.Duration(got.DelayMS)*time.Millisecond))) {
t.Fatal("first retry escaped bounded jitter")
}
})
}
// 截止恰好等于选定下一次时间也不能授予新尝试。
base.Retryable = true
out, err := Decide(base)
if err != nil {
t.Fatal(err)
}
base.Deadline = *out.NextAt
if out, err = Decide(base); err != nil || out.State != pulse.TaskFailed {
t.Fatal("inclusive deadline allowed retry")
}
}
func TestBackoffBoundedDeterministicAndNoOverflow(t *testing.T) {
policy := pulse.RetryPolicy{MaxAttempts: 100, InitialDelayMS: 1000, MaxDelayMS: 5000, AttemptTimeoutMS: 1}
distinct := map[uint64]bool{}
for n := 1; n <= 256; n++ {
id := core.ID(fmt.Sprintf("%032x", n))
first := DelayMS(policy, 1, id)
distinct[first] = true
if first != DelayMS(policy, 1, id) || first < 500 || first > 1000 {
t.Fatal("unstable first delay")
}
if next := DelayMS(policy, 2, id); next < 1000 || next > 2000 {
t.Fatal("exponential delay lost bound")
}
if last := DelayMS(policy, 100, id); last < 2500 || last > 5000 {
t.Fatal("delay did not saturate")
}
}
if len(distinct) < 100 {
t.Fatal("jitter failed to distribute identities")
}
policy.InitialDelayMS = 1
policy.MaxDelayMS = 1
if DelayMS(policy, 100, core.ID(fmt.Sprintf("%032x", 1))) != 1 {
t.Fatal("positive delay became zero")
}
policy.InitialDelayMS = 9223372036853
policy.MaxDelayMS = 9223372036854
n := DelayMS(policy, 100, core.ID(fmt.Sprintf("%032x", 1)))
if n < 4611686018427 || n > 9223372036854 || time.Duration(n)*time.Millisecond <= 0 {
t.Fatal("overflowing retry duration")
}
}

View File

@@ -27,6 +27,7 @@ type AttemptSummary struct {
LeaseUntil time.Time `json:"lease_until"` // LeaseUntil 数据库记录的租约期限;查询不能授权业务执行。 LeaseUntil time.Time `json:"lease_until"` // LeaseUntil 数据库记录的租约期限;查询不能授权业务执行。
AuthorizedAt *time.Time `json:"authorized_at,omitempty"` // AuthorizedAt 开始批准时间;空表示未批准,不证明副作用已经发生。 AuthorizedAt *time.Time `json:"authorized_at,omitempty"` // AuthorizedAt 开始批准时间;空表示未批准,不证明副作用已经发生。
EndedAt *time.Time `json:"ended_at,omitempty"` // EndedAt 尝试结束时间;仍在执行时为空。 EndedAt *time.Time `json:"ended_at,omitempty"` // EndedAt 尝试结束时间;仍在执行时为空。
Retryable bool `json:"retryable,omitempty"` // Retryable 该明确失败报告是否声明可安全重试;不代表平台必定安排重试。
ErrorCode string `json:"error_code,omitempty"` // ErrorCode 稳定错误分类;无错误时为空。 ErrorCode string `json:"error_code,omitempty"` // ErrorCode 稳定错误分类;无错误时为空。
CreatedAt time.Time `json:"created_at"` // CreatedAt 创建时间,UTC 微秒精度。 CreatedAt time.Time `json:"created_at"` // CreatedAt 创建时间,UTC 微秒精度。
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最新变更时间,不能代替版本校验。 UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最新变更时间,不能代替版本校验。
@@ -53,6 +54,7 @@ type EvidenceSummary struct {
AttemptID core.ID `json:"attempt_id"` // AttemptID 所属执行尝试。 AttemptID core.ID `json:"attempt_id"` // AttemptID 所属执行尝试。
Outcome string `json:"outcome"` // Outcome Worker 声明的 SUCCEEDED、FAILED 或 UNKNOWN,不代表平台认可。 Outcome string `json:"outcome"` // Outcome Worker 声明的 SUCCEEDED、FAILED 或 UNKNOWN,不代表平台认可。
ErrorCode string `json:"error_code,omitempty"` // ErrorCode 原报告的稳定分类,可为空。 ErrorCode string `json:"error_code,omitempty"` // ErrorCode 原报告的稳定分类,可为空。
Retryable bool `json:"retryable,omitempty"` // Retryable 原报告的重试声明;证据不触发重试。
Reason string `json:"reason"` // Reason LEASE_LOST、NOT_STARTED 或 RESULT_CONFLICT。 Reason string `json:"reason"` // Reason LEASE_LOST、NOT_STARTED 或 RESULT_CONFLICT。
Disposition string `json:"disposition"` // Disposition 固定为 EVIDENCE_ONLY,表示采集时仅保存证据。 Disposition string `json:"disposition"` // Disposition 固定为 EVIDENCE_ONLY,表示采集时仅保存证据。
CreatedAt time.Time `json:"created_at"` // CreatedAt 首次收到时间,重复报告不改变它。 CreatedAt time.Time `json:"created_at"` // CreatedAt 首次收到时间,重复报告不改变它。

View File

@@ -59,6 +59,8 @@ type TaskView struct {
Payload json.RawMessage `json:"payload"` Payload json.RawMessage `json:"payload"`
// Result 业务 JSON 结果;未取得结果时为空并省略。 // Result 业务 JSON 结果;未取得结果时为空并省略。
Result json.RawMessage `json:"result,omitempty"` Result json.RawMessage `json:"result,omitempty"`
// EffectUncertain 曾有执行效果尚未核实;重试与预算耗尽不会把未知伪装成明确失败。
EffectUncertain bool `json:"effect_uncertain,omitempty"`
// BusinessKey 可选业务关联键,不自动提供业务效果幂等。 // BusinessKey 可选业务关联键,不自动提供业务效果幂等。
BusinessKey string `json:"business_key,omitempty"` BusinessKey string `json:"business_key,omitempty"`
// AvailableAt 最早可执行时间,UTC。 // AvailableAt 最早可执行时间,UTC。
@@ -125,6 +127,8 @@ type TaskSummary struct {
Handler string `json:"handler"` Handler string `json:"handler"`
// HandlerVersion 受理时固定的处理器版本。 // HandlerVersion 受理时固定的处理器版本。
HandlerVersion string `json:"handler_version"` HandlerVersion string `json:"handler_version"`
// EffectUncertain 曾有执行效果尚未核实;重试与预算耗尽不会把未知伪装成明确失败。
EffectUncertain bool `json:"effect_uncertain,omitempty"`
// BusinessKey 可选业务关联键,不自动提供业务效果幂等。 // BusinessKey 可选业务关联键,不自动提供业务效果幂等。
BusinessKey string `json:"business_key,omitempty"` BusinessKey string `json:"business_key,omitempty"`
// AvailableAt 最早可执行时间,UTC。 // AvailableAt 最早可执行时间,UTC。

View File

@@ -120,6 +120,9 @@ func (s *Service) Finish(ctx context.Context, t auth.Token, b Binding, message c
if in.Outcome != "SUCCEEDED" && in.Outcome != "FAILED" && in.Outcome != "UNKNOWN" { if in.Outcome != "SUCCEEDED" && in.Outcome != "FAILED" && in.Outcome != "UNKNOWN" {
return CommandResult{}, fault.Invalid("invalid result outcome") return CommandResult{}, fault.Invalid("invalid result outcome")
} }
if in.Retryable && in.Outcome != "FAILED" {
return CommandResult{}, fault.Invalid("retryable is only valid for known failure")
}
if len(in.Result) == 0 || len(in.Result) > 256<<10 || bytes.Equal(bytes.TrimSpace(in.Result), []byte("null")) { if len(in.Result) == 0 || len(in.Result) > 256<<10 || bytes.Equal(bytes.TrimSpace(in.Result), []byte("null")) {
return CommandResult{}, fault.Invalid("result must be non-null JSON up to 256 KiB") return CommandResult{}, fault.Invalid("result must be non-null JSON up to 256 KiB")
} }

View File

@@ -77,6 +77,7 @@ type FinishInput struct {
Outcome string `json:"outcome"` // Outcome 为 SUCCEEDED、FAILED 或 UNKNOWN。 Outcome string `json:"outcome"` // Outcome 为 SUCCEEDED、FAILED 或 UNKNOWN。
Result json.RawMessage `json:"result"` // Result 非 null JSON 结果或证据引用,最多 256 KiB。 Result json.RawMessage `json:"result"` // Result 非 null JSON 结果或证据引用,最多 256 KiB。
ErrorCode string `json:"error_code,omitempty"` // ErrorCode FAILED/UNKNOWN 必填稳定分类,不包含原始异常正文。 ErrorCode string `json:"error_code,omitempty"` // ErrorCode FAILED/UNKNOWN 必填稳定分类,不包含原始异常正文。
Retryable bool `json:"retryable,omitempty"` // Retryable 仅 FAILED 可为真,声明该明确失败可安全重试;默认假。
} }
// Receipt 是不可变的操作回执,不代表当前仍有执行权。 // Receipt 是不可变的操作回执,不代表当前仍有执行权。

View File

@@ -53,6 +53,7 @@ ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap -
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/worker-protocol) (cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/worker-protocol)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/worker) (cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/worker)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/history) (cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/history)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/retry)
# 停止原服务后再做进程故障演练,避免其他恢复扫描器掩盖接管验证。 # 停止原服务后再做进程故障演练,避免其他恢复扫描器掩盖接管验证。
kill "$TASK_SERVER_PID" kill "$TASK_SERVER_PID"

View File

@@ -0,0 +1,149 @@
// retry 演示明确失败的安全重试:同一任务两次尝试,退避由平台持久化管理。
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"sync/atomic"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
token, queue := os.Getenv("ORBIT_TOKEN"), os.Getenv("ORBIT_QUEUE_ID")
if path := os.Getenv("ORBIT_CREDENTIALS_FILE"); path != "" {
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read private credentials: %w", err)
}
var credentials struct {
Token string `json:"token"`
QueueID string `json:"queue_id"`
}
if json.Unmarshal(raw, &credentials) != nil {
return errors.New("invalid credentials file")
}
if token == "" {
token = credentials.Token
}
if queue == "" {
queue = credentials.QueueID
}
}
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
if err != nil {
return err
}
operation, err := orbit.NewWorkerMessageID()
if err != nil {
return err
}
handler := "text.normalize.retry." + operation
var calls atomic.Uint32
process := func(ctx context.Context, e orbit.WorkerExecution) (json.RawMessage, error) {
// 第一次在产生业务效果前明确拒绝,可安全重试;计数仅用于制造示例故障。
if calls.Add(1) == 1 {
return nil, &orbit.WorkerFailure{Code: "TEMPORARY_REJECTION", Retryable: true}
}
return normalize(ctx, e)
}
w, err := orbit.NewWorker(client, orbit.WorkerOptions{QueueID: queue, Capacity: 1, ShutdownTimeout: 5 * time.Second, Handlers: []orbit.WorkerHandler{{Capability: orbit.WorkerCapability{Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: process}}})
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// 示例先受理,再启动消费者;生产通常是独立的长驻 Worker 进程。
var tasks []orbit.Task
for i, text := range []string{" safe retry "} {
payload, _ := json.Marshal(struct {
Text string `json:"text"`
}{text})
accepted, err := client.SubmitTask(ctx, fmt.Sprintf("retry-%s-%d", operation, i), orbit.SubmitTaskRequest{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", Payload: payload, TaskTimeoutMS: 60000, Retry: orbit.RetryPolicy{MaxAttempts: 3, AttemptTimeoutMS: 10000, InitialDelayMS: 500, MaxDelayMS: 2000}, RecoverySafety: orbit.RecoveryReconcileOnly})
if err != nil {
return err
}
tasks = append(tasks, accepted.Task)
}
workerCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- w.Run(workerCtx) }()
// 任一调用失败时也等待有界排空,不在 main 返回时静默丢掉执行中的 Worker。
waited := false
defer func() {
stop()
if !waited {
<-done
}
}()
for _, task := range tasks {
for {
current, err := client.GetTask(ctx, task.ID)
if err != nil {
return err
}
if current.State == orbit.TaskSucceeded {
history, err := client.ListTaskAttempts(ctx, current.ID, orbit.HistoryPageRequest{})
if err != nil {
return err
}
if current.AttemptCount != 2 || len(history.Items) != 2 || history.Items[1].State != orbit.AttemptFailed || !history.Items[1].Retryable || current.EffectUncertain {
return errors.New("retry history mismatch")
}
fmt.Printf("task=%s state=%s attempts=%d first_retryable=%t result=%s\n", current.ID, current.State, current.AttemptCount, history.Items[1].Retryable, current.Result)
break
}
if current.State == orbit.TaskFailed || current.State == orbit.TaskNeedsReview {
return fmt.Errorf("task %s requires attention: %s", current.ID, current.State)
}
select {
case err := <-done:
waited = true
return fmt.Errorf("Worker stopped before completion: %v", err)
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
}
stop()
err = <-done
waited = true
if err != nil {
return err
}
stats := w.Stats()
if stats.Started != 2 || stats.ReportsConfirmed != 2 || stats.Running != 0 {
return errors.New("unexpected Worker completion counts")
}
fmt.Printf("worker_drained=true started=%d confirmed=%d running=%d\n", stats.Started, stats.ReportsConfirmed, stats.Running)
return nil
}
func normalize(ctx context.Context, execution orbit.WorkerExecution) (json.RawMessage, error) {
var in struct {
Text string `json:"text"`
}
if err := json.Unmarshal(execution.Task.Payload, &in); err != nil {
return nil, &orbit.WorkerFailure{Code: "INVALID_TEXT"}
}
// 模拟可取消的短工作;真实 I/O 也必须使用传入的 ctx。
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(200 * time.Millisecond):
}
return json.Marshal(struct {
Text string `json:"text"`
}{Text: strings.ToUpper(strings.TrimSpace(in.Text))})
}

View File

@@ -21,6 +21,7 @@ type AttemptSummary struct {
LeaseUntil time.Time `json:"lease_until"` // LeaseUntil 数据库记录的租约期限;查询不能授权业务执行。 LeaseUntil time.Time `json:"lease_until"` // LeaseUntil 数据库记录的租约期限;查询不能授权业务执行。
AuthorizedAt *time.Time `json:"authorized_at,omitempty"` // AuthorizedAt 开始批准时间;空表示未批准,不证明副作用已经发生。 AuthorizedAt *time.Time `json:"authorized_at,omitempty"` // AuthorizedAt 开始批准时间;空表示未批准,不证明副作用已经发生。
EndedAt *time.Time `json:"ended_at,omitempty"` // EndedAt 尝试结束时间;仍在执行时为空。 EndedAt *time.Time `json:"ended_at,omitempty"` // EndedAt 尝试结束时间;仍在执行时为空。
Retryable bool `json:"retryable,omitempty"` // Retryable 该明确失败报告是否声明可安全重试;不代表平台必定安排重试。
ErrorCode string `json:"error_code,omitempty"` // ErrorCode 稳定错误分类;无错误时为空。 ErrorCode string `json:"error_code,omitempty"` // ErrorCode 稳定错误分类;无错误时为空。
CreatedAt time.Time `json:"created_at"` // CreatedAt 创建时间,UTC 微秒精度。 CreatedAt time.Time `json:"created_at"` // CreatedAt 创建时间,UTC 微秒精度。
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最新变更时间,不能代替版本校验。 UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最新变更时间,不能代替版本校验。
@@ -47,6 +48,7 @@ type AttemptEvidenceSummary struct {
AttemptID string `json:"attempt_id"` // AttemptID 所属执行尝试。 AttemptID string `json:"attempt_id"` // AttemptID 所属执行尝试。
Outcome string `json:"outcome"` // Outcome Worker 声明的 SUCCEEDED、FAILED 或 UNKNOWN,不代表平台认可。 Outcome string `json:"outcome"` // Outcome Worker 声明的 SUCCEEDED、FAILED 或 UNKNOWN,不代表平台认可。
ErrorCode string `json:"error_code,omitempty"` // ErrorCode 原报告的稳定分类,可为空。 ErrorCode string `json:"error_code,omitempty"` // ErrorCode 原报告的稳定分类,可为空。
Retryable bool `json:"retryable,omitempty"` // Retryable 原报告的重试声明;证据不触发重试。
Reason string `json:"reason"` // Reason LEASE_LOST、NOT_STARTED 或 RESULT_CONFLICT。 Reason string `json:"reason"` // Reason LEASE_LOST、NOT_STARTED 或 RESULT_CONFLICT。
Disposition string `json:"disposition"` // Disposition 固定为 EVIDENCE_ONLY,表示采集时仅保存证据。 Disposition string `json:"disposition"` // Disposition 固定为 EVIDENCE_ONLY,表示采集时仅保存证据。
CreatedAt time.Time `json:"created_at"` // CreatedAt 首次收到时间,重复报告不改变它。 CreatedAt time.Time `json:"created_at"` // CreatedAt 首次收到时间,重复报告不改变它。

View File

@@ -54,6 +54,8 @@ type Task struct {
Payload json.RawMessage `json:"payload"` Payload json.RawMessage `json:"payload"`
// Result 业务 JSON 结果;未取得结果时为空并省略。 // Result 业务 JSON 结果;未取得结果时为空并省略。
Result json.RawMessage `json:"result,omitempty"` Result json.RawMessage `json:"result,omitempty"`
// EffectUncertain 曾有执行效果尚未核实;重试与预算耗尽不会把未知伪装成明确失败。
EffectUncertain bool `json:"effect_uncertain,omitempty"`
// BusinessKey 可选业务关联键,不自动提供业务效果幂等。 // BusinessKey 可选业务关联键,不自动提供业务效果幂等。
BusinessKey string `json:"business_key,omitempty"` BusinessKey string `json:"business_key,omitempty"`
// AvailableAt 最早可执行时间,UTC。 // AvailableAt 最早可执行时间,UTC。
@@ -120,6 +122,8 @@ type TaskSummary struct {
Handler string `json:"handler"` Handler string `json:"handler"`
// HandlerVersion 受理时固定的处理器版本。 // HandlerVersion 受理时固定的处理器版本。
HandlerVersion string `json:"handler_version"` HandlerVersion string `json:"handler_version"`
// EffectUncertain 曾有执行效果尚未核实;重试与预算耗尽不会把未知伪装成明确失败。
EffectUncertain bool `json:"effect_uncertain,omitempty"`
// BusinessKey 可选业务关联键,不自动提供业务效果幂等。 // BusinessKey 可选业务关联键,不自动提供业务效果幂等。
BusinessKey string `json:"business_key,omitempty"` BusinessKey string `json:"business_key,omitempty"`
// AvailableAt 最早可执行时间,UTC。 // AvailableAt 最早可执行时间,UTC。

View File

@@ -332,6 +332,9 @@ func (w *WorkerConnection) Renew(ctx context.Context, id string, ref WorkerAttem
// Finish 保存业务结果/证据;迟到结果可返回 EVIDENCE_ONLY,不能视为任务成功终结。 // Finish 保存业务结果/证据;迟到结果可返回 EVIDENCE_ONLY,不能视为任务成功终结。
func (w *WorkerConnection) Finish(ctx context.Context, id string, in WorkerFinish) (WorkerCommandResult, error) { func (w *WorkerConnection) Finish(ctx context.Context, id string, in WorkerFinish) (WorkerCommandResult, error) {
var out WorkerCommandResult var out WorkerCommandResult
if in.Retryable && in.Outcome != WorkerFailed {
return out, errors.New("retryable is only valid for known failure")
}
if (in.Outcome != WorkerSucceeded && in.Outcome != WorkerFailed && in.Outcome != WorkerUnknown) || len(in.Result) > 256<<10 || bytes.Equal(bytes.TrimSpace(in.Result), []byte("null")) || validateWorkerJSON(in.Result) != nil { if (in.Outcome != WorkerSucceeded && in.Outcome != WorkerFailed && in.Outcome != WorkerUnknown) || len(in.Result) > 256<<10 || bytes.Equal(bytes.TrimSpace(in.Result), []byte("null")) || validateWorkerJSON(in.Result) != nil {
return out, errors.New("invalid Worker result outcome or JSON") return out, errors.New("invalid Worker result outcome or JSON")
} }

View File

@@ -52,7 +52,7 @@ func (w *Worker) invoke(ctx context.Context, handler WorkerHandler, execution Wo
details = json.RawMessage(`{}`) details = json.RawMessage(`{}`)
} }
if validHandlerResult(details) { if validHandlerResult(details) {
out = WorkerFinish{Ref: ref, Outcome: WorkerFailed, ErrorCode: known.Code, Result: details} out = WorkerFinish{Ref: ref, Outcome: WorkerFailed, ErrorCode: known.Code, Result: details, Retryable: known.Retryable}
} else { } else {
out = unknownHandlerResult(ref, "INVALID_HANDLER_RESULT") out = unknownHandlerResult(ref, "INVALID_HANDLER_RESULT")
} }

View File

@@ -0,0 +1,104 @@
package orbit
import (
"context"
"encoding/json"
"errors"
"sync/atomic"
"testing"
"time"
)
func TestIntegrationWorkerRuntimeSafeRetry(t *testing.T) {
api, queue, token := integrationWorkerClient(t)
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
defer cancel()
for _, tc := range []struct {
name string
safety RecoverySafety
final TaskState
calls int32
unknown bool
}{
{"known", RecoveryReconcileOnly, TaskSucceeded, 2, false},
{"unknown_idempotent", RecoveryIdempotent, TaskSucceeded, 2, true},
{"unknown_reconcile", RecoveryReconcileOnly, TaskNeedsReview, 1, true},
{"exhausted", RecoveryReconcileOnly, TaskFailed, 2, false},
} {
t.Run(tc.name, func(t *testing.T) {
g := newWorkerFaultGateway(t, api.baseURL)
// 首次失败已落库并安排重试后丢确认,运行器必须核对同一回执而不是重置尝试。
if tc.name == "known" {
g.drop.Store("task.finish")
}
client, err := NewClient(g.server.URL, Options{Token: token, HTTPClient: g.server.Client()})
if err != nil {
t.Fatal(err)
}
name := "retry." + tc.name + "." + workerTestID(t)
var calls atomic.Int32
var epochs [2]uint64
w, err := NewWorker(client, WorkerOptions{QueueID: queue, Capacity: 1, Handlers: []WorkerHandler{runtimeHandler(name, func(_ context.Context, e WorkerExecution) (json.RawMessage, error) {
n := calls.Add(1)
if n > 2 {
return nil, errors.New("retry exceeded test budget")
}
epochs[n-1] = e.Epoch
if n == 1 || tc.name == "exhausted" {
if tc.unknown {
return nil, errors.New("simulated unknown outcome")
}
return nil, &WorkerFailure{Code: "TEMPORARY_REJECTION", Retryable: true, Details: json.RawMessage(`{"reason_ref":"diagnostics/temporary"}`)}
}
return json.RawMessage(`{"n":9007199254740993}`), nil
})}})
if err != nil {
t.Fatal(err)
}
accepted, err := api.SubmitTask(ctx, "retry-"+workerTestID(t), SubmitTaskRequest{QueueID: queue, Handler: name, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`), RecoverySafety: tc.safety, TaskTimeoutMS: 30000, Retry: RetryPolicy{MaxAttempts: 2, AttemptTimeoutMS: 5000, InitialDelayMS: 200, MaxDelayMS: 1000}})
if err != nil {
t.Fatal(err)
}
run, stop := context.WithCancel(ctx)
defer stop()
done := make(chan error, 1)
go func() { done <- w.Run(run) }()
current := waitRuntimeState(t, ctx, api, accepted.Task.ID, tc.final)
stop()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-ctx.Done():
t.Fatal("retry Worker did not drain")
}
if calls.Load() != tc.calls || current.AttemptCount != uint32(tc.calls) || w.Stats().Started != uint64(tc.calls) || epochs[0] != 1 || (tc.calls == 2 && epochs[1] != 2) {
t.Fatal("retry bypassed attempt identity or budget")
}
if current.EffectUncertain != (tc.final == TaskNeedsReview) {
t.Fatal("uncertainty does not match authoritative result")
}
attempts, err := api.ListTaskAttempts(ctx, current.ID, HistoryPageRequest{})
if err != nil || len(attempts.Items) != int(tc.calls) {
t.Fatal("SDK lost retry history")
}
oldest := attempts.Items[len(attempts.Items)-1]
if oldest.Retryable == tc.unknown {
t.Fatal("SDK lost retry classification")
}
if tc.calls == 2 {
second := attempts.Items[0]
if oldest.EndedAt == nil || second.CreatedAt.Before(oldest.EndedAt.Add(100*time.Millisecond)) {
t.Fatal("dispatch ignored retry delay")
}
}
if tc.name == "known" {
g.expectDrop(t, "task.finish")
if w.Stats().Reconnects == 0 {
t.Fatal("retry receipt did not recover lost ACK")
}
}
})
}
}

View File

@@ -34,8 +34,9 @@ type WorkerHandler struct {
// WorkerFailure 仅用于接入方能够确认的业务失败,不用于网络超时或未知外部效果。 // WorkerFailure 仅用于接入方能够确认的业务失败,不用于网络超时或未知外部效果。
type WorkerFailure struct { type WorkerFailure struct {
Code string // Code 1–64 位稳定 ASCII 分类,禁止放原始异常正文。 Code string // Code 1–64 位稳定 ASCII 分类,禁止放原始异常正文。
Details json.RawMessage // Details 可选非 null JSON,最多 256 KiB;空值使用空对象。 Details json.RawMessage // Details 可选非 null JSON,最多 256 KiB;空值使用空对象。
Retryable bool // Retryable 声明该明确失败可安全重试;默认假,不能用它掩盖网络超时或部分副作用未知。
} }
func (e *WorkerFailure) Error() string { return "Worker handler reported a known failure" } func (e *WorkerFailure) Error() string { return "Worker handler reported a known failure" }

View File

@@ -97,9 +97,10 @@ type WorkerFinish struct {
Outcome WorkerOutcome `json:"outcome"` // Outcome 明确成功、明确失败或效果未知。 Outcome WorkerOutcome `json:"outcome"` // Outcome 明确成功、明确失败或效果未知。
Result json.RawMessage `json:"result"` // Result 非 null JSON,最多 256 KiB;保留精确数字文本。 Result json.RawMessage `json:"result"` // Result 非 null JSON,最多 256 KiB;保留精确数字文本。
ErrorCode string `json:"error_code,omitempty"` // ErrorCode 失败/未知时必填稳定分类,成功时为空;禁止原始异常正文。 ErrorCode string `json:"error_code,omitempty"` // ErrorCode 失败/未知时必填稳定分类,成功时为空;禁止原始异常正文。
Retryable bool `json:"retryable,omitempty"` // Retryable 仅明确 FAILED 可声明安全重试;默认假,不适用于 UNKNOWN。
} }
// WorkerOutcome 描述业务效果;UNKNOWN 必须进入核查而不是盲目重跑。 // WorkerOutcome 描述业务效果;UNKNOWN 仅在任务声明幂等恢复且预算足够时可重试,否则转核查。
type WorkerOutcome string type WorkerOutcome string
const ( const (