feat: add audited task review and linked manual retries
This commit is contained in:
@@ -14,6 +14,9 @@
|
|||||||
|
|
||||||
- 结果日志的序号在独立命名空间流行锁下分配,与任务状态及原事件同事务提交;禁止用最大事件ID或时间戳推断提交水位。生产者不得锁订阅行。
|
- 结果日志的序号在独立命名空间流行锁下分配,与任务状态及原事件同事务提交;禁止用最大事件ID或时间戳推断提交水位。生产者不得锁订阅行。
|
||||||
- 创建订阅先锁队列再锁流行;消费按订阅→交付持锁,不能再取得任务、队列、额度或流行锁。ACK只验证最近代次及证明,过期未被替代的证明仍可确认;重复确认不能再次扣减pending_count。
|
- 创建订阅先锁队列再锁流行;消费按订阅→交付持锁,不能再取得任务、队列、额度或流行锁。ACK只验证最近代次及证明,过期未被替代的证明仍可确认;重复确认不能再次扣减pending_count。
|
||||||
- 可靠订阅已完成核心、HTTP/WSS、管理及消费SDK、独立业务Inbox示例、节点及消费者进程故障回归。批量受理已提供逐项幂等与未知结果分类。P3仍需补齐独立任务核查/人工重试、任务查询筛选和事务Outbox接入;不能因订阅完成而跳过原功能范围。
|
- 可靠订阅已完成核心、HTTP/WSS、管理及消费SDK、独立业务Inbox示例、节点及消费者进程故障回归。批量受理已提供逐项幂等与未知结果分类。独立任务核查/人工重试已实现,P3仍需补齐任务查询筛选和事务Outbox接入;不能因订阅完成而跳过原功能范围。
|
||||||
|
|
||||||
- 批量受理最多32项、512 KiB;每项复用单项身份与事务,未知结果必须原键核对。任务提交按认证→应用额度→提交幂等身份→队列持锁,禁止恢复为先抢提交唯一键后等待额度锁;原回执仍须先于配额上限检查返回。
|
- 批量受理最多32项、512 KiB;每项复用单项身份与事务,未知结果必须原键核对。任务提交按认证→应用额度→提交幂等身份→队列持锁,禁止恢复为先抢提交唯一键后等待额度锁;原回执仍须先于配额上限检查返回。
|
||||||
|
|
||||||
|
- 独立任务核查必须显式声明所有历史执行已结束,并提交原因、证据和版本;NEEDS_REVIEW只转SUCCEEDED/FAILED。核查不改写旧尝试或迟到证据。人工重试仅无所有者且无未知效果的FAILED/CANCELLED任务,创建retry_of关联新任务,每个原任务最多一个直接后继;继续重试沿后继推进。
|
||||||
|
- tasks:review、tasks:retry、tasks:audit:read是显式管理权限,普通提交/Worker凭据不自动获得。核查结果流锁最后获取,审计/回执/额度与状态同事务;恢复不得绕过Weave/计划所有权、门控及现有额度限制。
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Orbit 是面向多个业务项目的通用任务调度与流程编排平台,
|
|||||||
|
|
||||||
## 当前进度
|
## 当前进度
|
||||||
|
|
||||||
已完成 P0–P2 阶段验收:35 个数据模型、版本化迁移、凭据及范围校验、队列与任务 API、WSS 执行、独立 SDK、自动 Worker 和 [执行尝试/证据查询](docs/api/execution-history.md)。[Worker Schema](docs/api/worker-schema.md) 与真实进程强杀恢复回归已通过;[安全重试](docs/api/retries.md)、[定时计划](docs/api/schedules.md)、[运行中取消](docs/api/cancellation.md) 、[队列资源控制](docs/api/queues.md) 、[应用共享配额](docs/api/application-resources.md) 、[资源键互斥](docs/api/keyed-concurrency.md) 和 [优先级/节点公平派发](docs/api/priority-and-fairness.md) 已接入服务端和 SDK,[可靠结果订阅](docs/api/subscriptions.md) 已实现持久化核心、HTTP/WSS、管理 SDK 与示例,[消费 SDK](docs/sdk/event-consumer.md) 和 [独立 MySQL Inbox 示例](docs/examples/result-inbox.md) 已实现;[批量任务受理](docs/api/batch-tasks.md) 已提供逐项幂等、失败分类与 SDK 示例;剩余 Pulse 能力与流程引擎按 [持续开发计划](docs/08-development-plan.md) 继续实现。
|
已完成 P0–P2 阶段验收:35 个数据模型、版本化迁移、凭据及范围校验、队列与任务 API、WSS 执行、独立 SDK、自动 Worker 和 [执行尝试/证据查询](docs/api/execution-history.md)。[Worker Schema](docs/api/worker-schema.md) 与真实进程强杀恢复回归已通过;[安全重试](docs/api/retries.md)、[定时计划](docs/api/schedules.md)、[运行中取消](docs/api/cancellation.md) 、[队列资源控制](docs/api/queues.md) 、[应用共享配额](docs/api/application-resources.md) 、[资源键互斥](docs/api/keyed-concurrency.md) 和 [优先级/节点公平派发](docs/api/priority-and-fairness.md) 已接入服务端和 SDK,[可靠结果订阅](docs/api/subscriptions.md) 已实现持久化核心、HTTP/WSS、管理 SDK 与示例,[消费 SDK](docs/sdk/event-consumer.md) 和 [独立 MySQL Inbox 示例](docs/examples/result-inbox.md) 已实现;[批量任务受理](docs/api/batch-tasks.md) 已提供逐项幂等、失败分类与 SDK 示例;[人工核查与重试](docs/api/task-recovery.md) 已提供带证据的管理接口、关联新任务、审计查询及运行示例;剩余 Pulse 能力与流程引擎按 [持续开发计划](docs/08-development-plan.md) 继续实现。
|
||||||
|
|
||||||
数据库禁止外键,关联一致性通过服务端事务校验,索引、唯一约束和 CHECK 保留;readiness 同时检查迁移及零外键要求。这不表示完整平台已具备生产服务能力。验证证据见 [P0](docs/reports/phase-0.md)、[P1](docs/reports/phase-1.md)、[P2 阶段验收](docs/reports/phase-2.md)、[安全重试](docs/reports/phase-3-safe-retry.md)、[计划调度](docs/reports/phase-3-schedules.md)、[取消与无外键回归](docs/reports/phase-3-cancellation.md) 及 [队列资源回归](docs/reports/phase-3-queue-resources.md)。
|
数据库禁止外键,关联一致性通过服务端事务校验,索引、唯一约束和 CHECK 保留;readiness 同时检查迁移及零外键要求。这不表示完整平台已具备生产服务能力。验证证据见 [P0](docs/reports/phase-0.md)、[P1](docs/reports/phase-1.md)、[P2 阶段验收](docs/reports/phase-2.md)、[安全重试](docs/reports/phase-3-safe-retry.md)、[计划调度](docs/reports/phase-3-schedules.md)、[取消与无外键回归](docs/reports/phase-3-cancellation.md) 及 [队列资源回归](docs/reports/phase-3-queue-resources.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func run() error {
|
|||||||
flags.BoolVar(&in.WorkerAccess, "worker-access", false, "also grant Worker connect and consume actions for development")
|
flags.BoolVar(&in.WorkerAccess, "worker-access", false, "also grant Worker connect and consume actions for development")
|
||||||
flags.BoolVar(&in.ScheduleAccess, "schedule-access", false, "also grant schedule read and manage actions for development")
|
flags.BoolVar(&in.ScheduleAccess, "schedule-access", false, "also grant schedule read and manage actions for development")
|
||||||
flags.BoolVar(&in.EventAccess, "event-access", false, "also grant result subscription read, manage and consume actions")
|
flags.BoolVar(&in.EventAccess, "event-access", false, "also grant result subscription read, manage and consume actions")
|
||||||
|
flags.BoolVar(&in.RecoveryAccess, "recovery-access", false, "explicitly grant task review, manual retry and operation audit read actions")
|
||||||
if err := flags.Parse(os.Args[2:]); err != nil {
|
if err := flags.Parse(os.Args[2:]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Orbit 功能说明:Pulse 与公共能力
|
# Orbit 功能说明:Pulse 与公共能力
|
||||||
|
|
||||||
> 版本 0.2 · 2026-09-17。Pulse 与 Weave 首版功能范围已确认;标注“建议”的细节待评审。Weave 具体范围另见流程设计。基础、任务执行、调度、资源控制和可靠订阅已实现;批量受理、独立任务核查/人工重试及查询筛选等继续开发,实现状态见持续开发计划。
|
> 版本 0.2 · 2026-09-17。Pulse 与 Weave 首版功能范围已确认;标注“建议”的细节待评审。Weave 具体范围另见流程设计。基础、任务执行、调度、资源控制和可靠订阅已实现;批量受理及独立任务核查/人工重试已实现,查询筛选等继续开发,实现状态见持续开发计划。
|
||||||
|
|
||||||
## 1. 目标与责任边界
|
## 1. 目标与责任边界
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ Weave 有界查询的一轮对应一个 Pulse 任务;查询得到“处理中
|
|||||||
- **立即执行**:受理后尽快进入可派发范围,实际开始时间受资源和排队影响。
|
- **立即执行**:受理后尽快进入可派发范围,实际开始时间受资源和排队影响。
|
||||||
- **延迟执行**:在指定等待时长之后才允许开始。
|
- **延迟执行**:在指定等待时长之后才允许开始。
|
||||||
- **指定时间执行**:在指定时间之后才允许开始,不承诺硬实时触发。
|
- **指定时间执行**:在指定时间之后才允许开始,不承诺硬实时触发。
|
||||||
- **批量提交**:多项普通任务的提交方式;建议逐项返回结果,每项独立幂等,失败项独立补交。
|
- **批量提交**:多项普通任务的提交方式;已实现最多32项逐项返回结果,每项独立幂等,失败项独立补交,整批请求不超过512 KiB。
|
||||||
|
|
||||||
任务至少说明:归属、队列、处理器与版本、参数版本、执行时间、时限、重试规则和提交幂等标识。已受理参数与生效策略保存快照;修改配置不静默改写历史任务。
|
任务至少说明:归属、队列、处理器与版本、参数版本、执行时间、时限、重试规则和提交幂等标识。已受理参数与生效策略保存快照;修改配置不静默改写历史任务。
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ Weave 有界查询的一轮对应一个 Pulse 任务;查询得到“处理中
|
|||||||
| 人工重试 | 校验权限、错误类别及业务确认条件,保留原历史,记录操作人和原因 |
|
| 人工重试 | 校验权限、错误类别及业务确认条件,保留原历史,记录操作人和原因 |
|
||||||
| 修改业务参数 | 新建关联任务,原任务参数不变 |
|
| 修改业务参数 | 新建关联任务,原任务参数不变 |
|
||||||
|
|
||||||
**设计建议:人工重试创建带 `retry_of` 的新任务。** 原任务保留终态及结果,避免外部订阅方已经消费终态后任务又悄悄恢复。业务幂等键应按原操作语义保留;平台新任务 ID 不代表允许重复产生业务副作用。此项在协议冻结前确认。
|
**当前契约:人工重试创建带 `retry_of` 的新任务。** 原任务保留终态及结果,避免外部订阅方已经消费终态后任务又悄悄恢复。业务幂等键应按原操作语义保留;平台新任务 ID 不代表允许重复产生业务副作用。每个原任务最多一个直接后继,继续重试沿后继任务推进;需要明确安全声明、版本、证据和审计,详见[人工恢复接口](api/task-recovery.md)。
|
||||||
|
|
||||||
流程关联任务的人工恢复统一经过 Weave,校验流程版本、步骤归属和当前恢复条件;不能直接从普通任务重试入口生成无人接续的流程任务。
|
流程关联任务的人工恢复统一经过 Weave,校验流程版本、步骤归属和当前恢复条件;不能直接从普通任务重试入口生成无人接续的流程任务。
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ stateDiagram-v2
|
|||||||
|
|
||||||
核查操作需要证据引用、权限和审计。核查前的迟到结果可以作为证据保存,但无效尝试不能直接改写任务终态。
|
核查操作需要证据引用、权限和审计。核查前的迟到结果可以作为证据保存,但无效尝试不能直接改写任务终态。
|
||||||
|
|
||||||
人工重试的建议是创建关联新任务,旧任务终态不回退;此项参见 [待确认决策](README.md#需要继续确认的事项)。
|
人工重试已冻结为创建retry_of关联新任务,旧任务终态不回退;每个原任务最多一个直接后继。核查和重试分别要求显式执行结束/安全重试声明,具体权限、证据、版本与预算见[人工恢复接口](api/task-recovery.md)。
|
||||||
|
|
||||||
管理审计随对应状态变更同事务追加,应用日常权限不允许改写历史审计。更正通过新的关联记录表达;归档、导出和到期清理另设权限与审计。数据库管理员级别的防篡改证据及长期留存方案在生产审计要求确定后另行设计,不能将普通日志等同于业务审计证据。
|
管理审计随对应状态变更同事务追加,应用日常权限不允许改写历史审计。更正通过新的关联记录表达;归档、导出和到期清理另设权限与审计。数据库管理员级别的防篡改证据及长期留存方案在生产审计要求确定后另行设计,不能将普通日志等同于业务审计证据。
|
||||||
|
|
||||||
|
|||||||
@@ -160,3 +160,11 @@ P2 服务端已新增会话隔离、两步派发、租约、并发许可、结
|
|||||||
## 15. 独立接收方示例模型
|
## 15. 独立接收方示例模型
|
||||||
|
|
||||||
[result-inbox示例](examples/result-inbox.md) 在业务方自己的数据库建立receiver_inbox和receiver_task_views,2个实体、17个Go/SQL中文注释字段,无外键。它们属于独立示例模块,不计入平台35个实体,也不加入平台迁移。Inbox去重记录与业务视图同事务提交;视图仅按更高任务版本推进,重复和迟到事件不能重复更新或回退业务结果。
|
[result-inbox示例](examples/result-inbox.md) 在业务方自己的数据库建立receiver_inbox和receiver_task_views,2个实体、17个Go/SQL中文注释字段,无外键。它们属于独立示例模块,不计入平台35个实体,也不加入平台迁移。Inbox去重记录与业务视图同事务提交;视图仅按更高任务版本推进,重复和迟到事件不能重复更新或回退业务结果。
|
||||||
|
|
||||||
|
## 000015 人工恢复及重试关联
|
||||||
|
|
||||||
|
复用现有Task.RetryOf与AuditEvent.EvidenceRef,仍为35个实体、289个直接声明的持久化字段。RetryOf中文注释/SQL COMMENT明确“同命名空间每个原任务最多一个直接后继”。迁移将历史普通索引fk_pulse_tasks_3替换为相同列(namespace_id,retry_of)的唯一索引uq_task_retry_parent,并增加禁止自身关联的CHECK;该历史名字表示留下的索引,不是重新增加外键。
|
||||||
|
|
||||||
|
核查和重试按认证→应用额度→操作幂等→队列→任务/尝试持锁,事务内校验状态、父任务/计划归属和无活动执行。核查的状态/result、额度释放、审计、回执及结果日志一起提交,结果流锁最后取得。人工重试创建新任务与受理事件,审计关联保存在原任务上;旧终态、旧尝试和迟到证据不覆盖。详见[接口契约](api/task-recovery.md)。
|
||||||
|
|
||||||
|
真实v7升级回归检查70个外键全部删除、原任务保留、323个未替换索引组件不变,并单独核验新的重试唯一索引覆盖原两列。已有非法重复重试关联或自关联不会被静默清理;迁移失败需核查实际数据。后续仍禁止外键及级联。
|
||||||
|
|||||||
@@ -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/固定间隔、漏跑策略、计划版本、运行中取消、并发许可、配额/背压、可靠结果订阅、批量受理、独立任务核查/人工重试、查询筛选与事务Outbox接入 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 进行中:安全重试、计划调度、运行中取消、队列控制/限速/积压背压、跨命名空间应用配额、显式资源键许可及 SDK 场景已实现并通过全量回归;优先级及节点公平派发已实现并通过全量回归;可靠订阅核心、HTTP/WSS、管理 SDK 和管理示例已实现并回归;消费 SDK、协议示例及两项节点强杀回归已完成;业务 Inbox 场景、COMMIT响应丢失及消费者进程故障回归已完成;批量受理、SDK、接口与部分补交示例已完成全量回归;独立任务核查/人工重试、业务键/时间筛选与事务Outbox接入待完成 |
|
| P3 Pulse 完整能力 | 安全重试、延时、Cron/固定间隔、漏跑策略、计划版本、运行中取消、并发许可、配额/背压、可靠结果订阅、批量受理、独立任务核查/人工重试、查询筛选与事务Outbox接入 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 进行中:安全重试、计划调度、运行中取消、队列控制/限速/积压背压、跨命名空间应用配额、显式资源键许可及 SDK 场景已实现并通过全量回归;优先级及节点公平派发已实现并通过全量回归;可靠订阅核心、HTTP/WSS、管理 SDK 和管理示例已实现并回归;消费 SDK、协议示例及两项节点强杀回归已完成;业务 Inbox 场景、COMMIT响应丢失及消费者进程故障回归已完成;批量受理、SDK、接口与部分补交示例已完成全量回归;独立任务核查/人工重试、审计查询、两次真实HTTP回执丢失及完整示例已通过全量回归;业务键/时间筛选与事务Outbox接入待完成 |
|
||||||
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 待开发 |
|
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 待开发 |
|
||||||
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 待开发 |
|
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 待开发 |
|
||||||
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 |
|
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 |
|
||||||
@@ -42,7 +42,7 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最
|
|||||||
| 核对项 | 当前证据与后续交付 |
|
| 核对项 | 当前证据与后续交付 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 批量任务受理 | 已实现[批量API](api/batch-tasks.md)、独立SDK及[补交示例](examples/batch.md);真实MySQL部分失败与HTTP响应丢失用例已通过,同键并发20轮及全量回归已通过,见[报告](reports/phase-3-batch-tasks.md) |
|
| 批量任务受理 | 已实现[批量API](api/batch-tasks.md)、独立SDK及[补交示例](examples/batch.md);真实MySQL部分失败与HTTP响应丢失用例已通过,同键并发20轮及全量回归已通过,见[报告](reports/phase-3-batch-tasks.md) |
|
||||||
| 独立任务人工核查与重试 | [重试API](api/retries.md) 仍未提供解除不确定性的核查接口;需证据、权限、版本、审计和历史保留,人工重试身份按关联新任务实现并冻结契约 |
|
| 独立任务人工核查与重试 | 已实现[管理API及SDK](api/task-recovery.md)、专门权限、显式安全声明、证据/版本/审计和关联新任务;[完整场景](examples/recovery.md)及[回归报告](reports/phase-3-task-recovery.md)同步交付 |
|
||||||
| 任务查询筛选 | [当前ListTasks](../sdk/go/types.go) 仅队列/状态/分页;需按业务关联键及创建时间范围查询,游标绑定所有筛选,保持授权与有界索引扫描 |
|
| 任务查询筛选 | [当前ListTasks](../sdk/go/types.go) 仅队列/状态/分页;需按业务关联键及创建时间范围查询,游标绑定所有筛选,保持授权与有界索引扫描 |
|
||||||
| 业务到平台的事务Outbox接入 | [事务Outbox设计](04-reliability-and-data.md#61-业务系统到平台) 尚缺完整接入示例;需业务变更与待发请求同事务、稳定提交身份、响应不明核对和发送恢复,保持核心SDK无ORM/数据库依赖 |
|
| 业务到平台的事务Outbox接入 | [事务Outbox设计](04-reliability-and-data.md#61-业务系统到平台) 尚缺完整接入示例;需业务变更与待发请求同事务、稳定提交身份、响应不明核对和发送恢复,保持核心SDK无ORM/数据库依赖 |
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最
|
|||||||
| 优先级与应用隔离 | 近同时到期按优先级 → 旧任务等待补偿 → 应用/队列轮转 | P3,已实现完整示例 |
|
| 优先级与应用隔离 | 近同时到期按优先级 → 旧任务等待补偿 → 应用/队列轮转 | P3,已实现完整示例 |
|
||||||
| 可靠结果订阅 | 冻结结果 → 持久化交付 → Inbox 去重 → 确认/补发 → 耗尽后人工重试 | P3,核心、HTTP/WSS、管理及消费 SDK、独立Inbox示例与真实进程故障回归已完成 |
|
| 可靠结果订阅 | 冻结结果 → 持久化交付 → Inbox 去重 → 确认/补发 → 耗尽后人工重试 | P3,核心、HTTP/WSS、管理及消费 SDK、独立Inbox示例与真实进程故障回归已完成 |
|
||||||
| 批量文件作业受理 | 逐项身份 → 部分结果 → 同键核对 → 释放额度 → 只补交失败项 | P3,已实现完整示例 |
|
| 批量文件作业受理 | 逐项身份 → 部分结果 → 同键核对 → 释放额度 → 只补交失败项 | P3,已实现完整示例 |
|
||||||
|
| 独立任务人工恢复 | 未知结果 → 证据核查 → 保留原任务 → 关联新任务 → 审计查询 | P3,已实现完整示例 |
|
||||||
| 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4 |
|
| 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4 |
|
||||||
| 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4 |
|
| 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4 |
|
||||||
| 外部作业轮询 | 提交一次 → 有界查询处理中状态 → 回执或最终结果 → 超限核查 | P5 |
|
| 外部作业轮询 | 提交一次 → 有界查询处理中状态 → 回执或最终结果 → 超限核查 | P5 |
|
||||||
@@ -96,6 +97,7 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最
|
|||||||
- [P3 消费 SDK 与节点故障回归](reports/phase-3-event-consumer-sdk.md)
|
- [P3 消费 SDK 与节点故障回归](reports/phase-3-event-consumer-sdk.md)
|
||||||
- [P3 业务 Inbox 与消费者故障回归](reports/phase-3-result-inbox.md)
|
- [P3 业务 Inbox 与消费者故障回归](reports/phase-3-result-inbox.md)
|
||||||
- [P3 批量任务受理回归](reports/phase-3-batch-tasks.md)
|
- [P3 批量任务受理回归](reports/phase-3-batch-tasks.md)
|
||||||
|
- [P3 人工核查与重试回归](reports/phase-3-task-recovery.md)
|
||||||
- [接口手册](api/README.md)
|
- [接口手册](api/README.md)
|
||||||
- [SDK 接入](sdk/README.md)
|
- [SDK 接入](sdk/README.md)
|
||||||
- [通用场景示例](examples/README.md)
|
- [通用场景示例](examples/README.md)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
- 文档版本:0.2
|
- 文档版本:0.2
|
||||||
- 整理日期:2026-09-17
|
- 整理日期:2026-09-17
|
||||||
- 阶段:P0–P2 已通过阶段验收;P3 安全重试、计划调度、运行中取消、队列资源控制和应用共享配额已通过回归,资源键许可及真实进程恢复已通过全量回归,优先级/节点公平派发及真实隔离场景已通过全量回归,批量任务受理已实现,数据库已改为无外键,P3–P7 继续推进,尚未完成全平台生产验收
|
- 阶段:P0–P2 已通过阶段验收;P3 安全重试、计划调度、运行中取消、队列资源控制和应用共享配额已通过回归,资源键许可及真实进程恢复已通过全量回归,优先级/节点公平派发及真实隔离场景已通过全量回归,批量任务受理和独立任务人工核查/重试已实现,数据库已改为无外键,P3–P7 继续推进,尚未完成全平台生产验收
|
||||||
- 适用范围:Orbit 统一平台,包含 Pulse 任务调度模块、Weave 流程编排模块与公共能力。平台面向多个业务系统复用。
|
- 适用范围:Orbit 统一平台,包含 Pulse 任务调度模块、Weave 流程编排模块与公共能力。平台面向多个业务系统复用。
|
||||||
|
|
||||||
## 阅读顺序
|
## 阅读顺序
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
| WS 职责 | Worker 接入、能力与容量上报、任务派发、续租、结果上报、取消通知 |
|
| WS 职责 | Worker 接入、能力与容量上报、任务派发、续租、结果上报、取消通知 |
|
||||||
| 执行位置 | 业务处理器位于业务项目自己的 Worker 中 |
|
| 执行位置 | 业务处理器位于业务项目自己的 Worker 中 |
|
||||||
| 外部依赖范围 | 首版以 MySQL 为必需的外部存储依赖;运行不要求 Redis、独立消息队列或独立协调服务 |
|
| 外部依赖范围 | 首版以 MySQL 为必需的外部存储依赖;运行不要求 Redis、独立消息队列或独立协调服务 |
|
||||||
|
| 人工重试的任务身份 | 创建带retry_of的关联新任务,原任务保留;自动重试保持原任务ID,见[接口](api/task-recovery.md) |
|
||||||
| Pulse 与公共功能范围 | 任务管理、定时计划、Worker 与恢复、资源控制、多项目权限、审计可观测、SDK 与运维 |
|
| Pulse 与公共功能范围 | 任务管理、定时计划、Worker 与恢复、资源控制、多项目权限、审计可观测、SDK 与运维 |
|
||||||
| Weave 定位与定义方式 | 同一平台内承担流程编排,研发通过 Go SDK 描述并发布版本,业务处理器仍在业务 Worker 中运行 |
|
| Weave 定位与定义方式 | 同一平台内承担流程编排,研发通过 Go SDK 描述并发布版本,业务处理器仍在业务 Worker 中运行 |
|
||||||
| Weave 首版流程能力 | 顺序、条件分支、有限并行与汇合、等待信号、定时等待、有界重复查询 |
|
| Weave 首版流程能力 | 顺序、条件分支、有限并行与汇合、等待信号、定时等待、有界重复查询 |
|
||||||
@@ -58,7 +59,6 @@ Orbit 不内置特定行业规则;Pulse 管理执行,Weave 管理步骤依
|
|||||||
| HTTP / WS 的最终字段、路径、错误码 | 以本稿作为协议草案,评审后冻结 v1 | [协议](03-http-websocket-protocol.md) |
|
| HTTP / WS 的最终字段、路径、错误码 | 以本稿作为协议草案,评审后冻结 v1 | [协议](03-http-websocket-protocol.md) |
|
||||||
| Weave 技术细则 | 功能范围已确认;SDK/定义 Schema、暂停门控与计时、查询预算、并行失败策略、人工恢复身份仍需评审 | [流程](06-workflow-spec.md) |
|
| Weave 技术细则 | 功能范围已确认;SDK/定义 Schema、暂停门控与计时、查询预算、并行失败策略、人工恢复身份仍需评审 | [流程](06-workflow-spec.md) |
|
||||||
| WS 库及后续依赖版本 | 当前基础使用 Go 1.27.1、MySQL 8.4+ 与显式 SQL;WS 已锁定 coder/websocket v1.8.15,后续依赖需单独验证 | [模型基线](07-data-model.md) |
|
| WS 库及后续依赖版本 | 当前基础使用 Go 1.27.1、MySQL 8.4+ 与显式 SQL;WS 已锁定 coder/websocket v1.8.15,后续依赖需单独验证 | [模型基线](07-data-model.md) |
|
||||||
| 人工重试的任务身份 | 建议创建关联的新任务,自动重试保持原任务 ID | [功能](01-functional-spec.md) |
|
|
||||||
| Cron、固定间隔与夏令时细节 | 已实现五段 Cron、fixed-rate;缺失时刻跳过、重复时刻按 UTC 分开;时区快照随版本冻结 | [计划接口](api/schedules.md) |
|
| Cron、固定间隔与夏令时细节 | 已实现五段 Cron、fixed-rate;缺失时刻跳过、重复时刻按 UTC 分开;时区快照随版本冻结 | [计划接口](api/schedules.md) |
|
||||||
| 批量大小、消息上限、心跳、租约、扫描周期 | 均需设置有限上限;数值通过原型与压测确定 | [验收](05-production-acceptance.md) |
|
| 批量大小、消息上限、心跳、租约、扫描周期 | 均需设置有限上限;数值通过原型与压测确定 | [验收](05-production-acceptance.md) |
|
||||||
| 幂等、事件、审计的保留周期 | 按恢复与业务追溯要求确定,不能仅随任务清理 | [数据](04-reliability-and-data.md) |
|
| 幂等、事件、审计的保留周期 | 按恢复与业务追溯要求确定,不能仅随任务清理 | [数据](04-reliability-and-data.md) |
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
| `GET /health/ready` | 已实现,验证数据库、迁移记录及零外键要求 | [健康检查](health.md) |
|
| `GET /health/ready` | 已实现,验证数据库、迁移记录及零外键要求 | [健康检查](health.md) |
|
||||||
| `/v1/tasks` 提交、查询、分页与待执行取消 | P1 已实现 | [任务接口](tasks.md) |
|
| `/v1/tasks` 提交、查询、分页与待执行取消 | P1 已实现 | [任务接口](tasks.md) |
|
||||||
| `POST /v1/tasks:batch` | P3 已实现逐项受理、独立幂等、部分结果与补交 | [批量任务](batch-tasks.md) |
|
| `POST /v1/tasks:batch` | P3 已实现逐项受理、独立幂等、部分结果与补交 | [批量任务](batch-tasks.md) |
|
||||||
|
| `/v1/tasks/{task_id}:review` / `:retry` 与 `/operations` | P3 已实现带证据核查、关联新任务与操作审计 | [人工恢复](task-recovery.md) |
|
||||||
| `/v1/tasks/{task_id}/attempts` 与尝试详情 | P2 已实现 | [执行历史](execution-history.md) |
|
| `/v1/tasks/{task_id}/attempts` 与尝试详情 | P2 已实现 | [执行历史](execution-history.md) |
|
||||||
| `/v1/tasks/{task_id}/attempts/{attempt_id}/evidence` 与证据详情 | P2 已实现 | [执行历史](execution-history.md) |
|
| `/v1/tasks/{task_id}/attempts/{attempt_id}/evidence` 与证据详情 | P2 已实现 | [执行历史](execution-history.md) |
|
||||||
| `/v1/queues` 创建、查询及 `:configure` 控制 | 已实现队列并发、开始速率、积压背压与暂停/恢复 | [队列接口与 SDK 场景](queues.md) |
|
| `/v1/queues` 创建、查询及 `:configure` 控制 | 已实现队列并发、开始速率、积压背压与暂停/恢复 | [队列接口与 SDK 场景](queues.md) |
|
||||||
|
|||||||
@@ -58,4 +58,6 @@ P2 增加 workers:connect 和 workers:consume。bootstrap 只有显式提供 --w
|
|||||||
|
|
||||||
P3 应用额度管理新增 `application:resources:read` 和 `application:resources:manage`。只有显式 `--resource-access` 才授予,作用域是所属应用的全部命名空间,普通队列管理不能替代。查询返回聚合计数,不开放其他命名空间的任务数据。`bootstrap --application-id <现有应用ID>` 可由数据库管理员增加命名空间,与 `--application` 二选一;验证应用启用及额度记录存在后,同事务创建新资源,所有秘密交付约定保持不变。应用默认未终结上限 1000000、并发 10000、开始速率 100000/秒;详见 [应用共享配额](application-resources.md)。
|
P3 应用额度管理新增 `application:resources:read` 和 `application:resources:manage`。只有显式 `--resource-access` 才授予,作用域是所属应用的全部命名空间,普通队列管理不能替代。查询返回聚合计数,不开放其他命名空间的任务数据。`bootstrap --application-id <现有应用ID>` 可由数据库管理员增加命名空间,与 `--application` 二选一;验证应用启用及额度记录存在后,同事务创建新资源,所有秘密交付约定保持不变。应用默认未终结上限 1000000、并发 10000、开始速率 100000/秒;详见 [应用共享配额](application-resources.md)。
|
||||||
|
|
||||||
|
P3 独立任务核查、人工重试和操作审计分别增加 `tasks:review`、`tasks:retry`、`tasks:audit:read`,bootstrap显式 `--recovery-access` 才授予三项;默认凭据和 `--worker-access` 都不自动获得。核查/重试记录真实认证凭据及证据,读取任务详情仍需tasks:read,见[人工恢复](task-recovery.md)。
|
||||||
|
|
||||||
P3 结果订阅核心新增 `events:read`、`events:manage` 和 `events:consume`,bootstrap 仅显式 `--event-access` 时授予。订阅绑定创建凭据与单个队列,不能用同命名空间的另一张凭据消费或管理。当前 HTTP/WSS、管理及消费 SDK 已接入;凭据重绑继续开发,详见 [订阅接口](subscriptions.md)。
|
P3 结果订阅核心新增 `events:read`、`events:manage` 和 `events:consume`,bootstrap 仅显式 `--event-access` 时授予。订阅绑定创建凭据与单个队列,不能用同命名空间的另一张凭据消费或管理。当前 HTTP/WSS、管理及消费 SDK 已接入;凭据重绑继续开发,详见 [订阅接口](subscriptions.md)。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 安全重试 v1
|
# 安全重试 v1
|
||||||
|
|
||||||
状态:P3 的安全重试已实现。使用现有任务提交与 Worker 结果接口,无独立的自动重试路由;[Cron/固定间隔计划](schedules.md) 和 [运行中取消](cancellation.md) 已接入;[可靠结果订阅](subscriptions.md) 与业务Inbox已实现;独立任务人工核查与重试继续按 [计划](../08-development-plan.md) 开发。验证证据见 [本阶段报告](../reports/phase-3-safe-retry.md)。
|
状态:P3 的安全重试已实现。使用现有任务提交与 Worker 结果接口,无独立的自动重试路由;[Cron/固定间隔计划](schedules.md) 和 [运行中取消](cancellation.md) 已接入;[可靠结果订阅](subscriptions.md) 与业务Inbox已实现;[独立任务人工核查与重试](task-recovery.md) 已实现,剩余能力继续按 [计划](../08-development-plan.md) 开发。验证证据见 [本阶段报告](../reports/phase-3-safe-retry.md)。
|
||||||
|
|
||||||
## 1. 三种不同的重报
|
## 1. 三种不同的重报
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ Worker 的 `task.finish` 新增可选布尔字段 `retryable`,省略为 false
|
|||||||
|
|
||||||
存在历史不确定效果时,只有 IDEMPOTENT 允许继续自动尝试。此声明要求跨进程、跨尝试、并发及迟到执行都具有业务幂等保障,例如持久化操作键、唯一约束、返回原结果及资源侧执行代次校验。Task.ID 在自动重试中稳定;AttemptID 会变化,不能作为跨尝试的业务幂等键。平台不会因为新尝试开始而保证旧进程已经停止。
|
存在历史不确定效果时,只有 IDEMPOTENT 允许继续自动尝试。此声明要求跨进程、跨尝试、并发及迟到执行都具有业务幂等保障,例如持久化操作键、唯一约束、返回原结果及资源侧执行代次校验。Task.ID 在自动重试中稳定;AttemptID 会变化,不能作为跨尝试的业务幂等键。平台不会因为新尝试开始而保证旧进程已经停止。
|
||||||
|
|
||||||
`effect_uncertain` 是任务详情、摘要和派发任务中的可选布尔字段,false 时省略。只要曾发生尚未核实的授权执行,后来的明确失败、未开始即到期、总时限耗尽或取消都不会把该事实抹掉。当前有效尝试成功可自动清除;迟到成功证据不能清除。人工核查解除的管理接口尚未实现。
|
`effect_uncertain` 是任务详情、摘要和派发任务中的可选布尔字段,false 时省略。只要曾发生尚未核实的授权执行,后来的明确失败、未开始即到期、总时限耗尽或取消都不会把该事实抹掉。当前有效尝试成功可自动清除;迟到成功证据不能清除。已实现[带证据核查](task-recovery.md):获授权管理主体确认历史执行结束及真实结果后,提交版本与结论;迟到证据本身不能代替核查。
|
||||||
|
|
||||||
## 3. 退避、预算与持久化
|
## 3. 退避、预算与持久化
|
||||||
|
|
||||||
|
|||||||
232
docs/api/task-recovery.md
Normal file
232
docs/api/task-recovery.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# 人工核查、人工重试与任务操作审计
|
||||||
|
|
||||||
|
状态:P3 已实现服务端、Go SDK 和[可运行场景](../examples/recovery.md)。与[自动安全重试](retries.md)不同,人工操作需要专门权限、原因、证据引用和当前版本。验证范围见[阶段报告](../reports/phase-3-task-recovery.md)。
|
||||||
|
|
||||||
|
## 1. 公共规则
|
||||||
|
|
||||||
|
- HTTPS `Authorization: Bearer <token>`,写入带 `Content-Type: application/json` 与稳定 `Idempotency-Key`;查询不带幂等键。
|
||||||
|
- 权限分别是 `tasks:review`、`tasks:retry`、`tasks:audit:read`,均校验任务当前命名空间和队列范围。默认提交及 Worker 凭据不包含它们;开发初始化显式 `--recovery-access` 才添加。真实生产应分离执行与管理身份。
|
||||||
|
- ID 为32位小写十六进制;`expected_revision` 是非零十进制字符串。正文最多512 KiB,拒绝未知字段、重复键、大小写别名及非允许的null。
|
||||||
|
- 写操作幂等作用域:命名空间 + `task.review` / `task.retry` + 原任务ID + key。同键不同内容409;当前鉴权通过后优先返回原回执,不因任务已终结或额度已满重新执行。幂等保留约定与[任务受理](tasks.md)相同。
|
||||||
|
- 网络/提交结果不明时持久保留**原键、原版本和完整正文**核对;不要换键。回执是操作当时快照,最新状态用 `GetTask`。SDK不自动生成身份或自动重试。
|
||||||
|
- `reason`:非空白UTF-8,1–1024个Unicode字符,无NUL。`evidence_ref`:1–512个Unicode字符,无空白/控制字符;使用受控记录引用,禁止放入凭据秘密。平台只保存引用,不请求地址或自动判定证据真实性。
|
||||||
|
|
||||||
|
## 2. POST /v1/tasks/{task_id}:review
|
||||||
|
|
||||||
|
权限 `tasks:review`。仅 `NEEDS_REVIEW` 且版本匹配、无当前租约、无活跃尝试和未释放许可时允许。普通独立任务可核查;合法计划任务需同事务核验计划与触发记录归属。流程所属任务或有门控的任务拒绝,恢复须经对应上层控制器。
|
||||||
|
|
||||||
|
| 字段 | 类型/要求 | 含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| expected_revision | string,必填 | 当前待核查任务版本 |
|
||||||
|
| reason / evidence_ref | string,必填 | 上述原因和证据边界 |
|
||||||
|
| execution_settled | bool,必须显式true | 声明已核实全部历史执行已结束、不会继续产生效果 |
|
||||||
|
| outcome | string,必填 | 仅SUCCEEDED或FAILED |
|
||||||
|
| result | JSON,必填非null | 核查结论或业务结果引用,最多256 KiB,保留精确数值 |
|
||||||
|
|
||||||
|
先收集执行历史、迟到证据及外部系统凭证,核实所有旧执行和实际效果,再提交结论。仅“数据库租约已过期”不足以声明执行已经物理停止;平台无法杀死远程goroutine,也无法证明外部系统没有副作用。
|
||||||
|
|
||||||
|
请求示例,原状态为 NEEDS_REVIEW/revision=4:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expected_revision":"4",
|
||||||
|
"reason":"已核实旧执行全部结束,操作未完成",
|
||||||
|
"evidence_ref":"evidence:reconciliation-001",
|
||||||
|
"execution_settled":true,
|
||||||
|
"outcome":"FAILED",
|
||||||
|
"result":{"confirmed":true,"sequence":9007199254740993}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
首次及去重均返回HTTP200;完整示意响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id": "44444444444444444444444444444444",
|
||||||
|
"data": {
|
||||||
|
"task": {
|
||||||
|
"id": "22222222222222222222222222222222",
|
||||||
|
"namespace_id": "33333333333333333333333333333333",
|
||||||
|
"queue_id": "11111111111111111111111111111111",
|
||||||
|
"state": "FAILED",
|
||||||
|
"revision": "5",
|
||||||
|
"handler": "object.prepare",
|
||||||
|
"handler_version": "v1",
|
||||||
|
"payload_version": "v1",
|
||||||
|
"payload": {
|
||||||
|
"object_ref": "objects/input",
|
||||||
|
"sequence": 9007199254740993
|
||||||
|
},
|
||||||
|
"business_key": "object:operation-001",
|
||||||
|
"available_at": "2026-09-17T00:00:00Z",
|
||||||
|
"deadline_at": "2026-09-17T00:01:00Z",
|
||||||
|
"recovery_safety": "RECONCILE_ONLY",
|
||||||
|
"retry": {
|
||||||
|
"max_attempts": 1,
|
||||||
|
"initial_delay_ms": 0,
|
||||||
|
"max_delay_ms": 0,
|
||||||
|
"attempt_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"attempt_count": 1,
|
||||||
|
"result": {
|
||||||
|
"confirmed": true,
|
||||||
|
"sequence": 9007199254740993
|
||||||
|
},
|
||||||
|
"completed_at": "2026-09-17T00:00:20Z",
|
||||||
|
"created_at": "2026-09-17T00:00:00Z",
|
||||||
|
"updated_at": "2026-09-17T00:00:20Z"
|
||||||
|
},
|
||||||
|
"audit_id": "55555555555555555555555555555555",
|
||||||
|
"deduplicated": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
事务内保存新终态/result、清除effect_uncertain、revision加一、设置completed_at,释放一次未终结额度;同时保存幂等回执、不可变审计及新的结果事件。失败整体回滚,不能只更新状态后补写审计。旧尝试及UNKNOWN结果不改写,迟到报告仍只追加证据;已有needs_review结果事件保持冻结,订阅会再收到更高版本的最终结果。串行计划可以在核查终结后推进后续触发。
|
||||||
|
|
||||||
|
SDK:`ReviewTask(ctx, taskID, key, orbit.ReviewTaskRequest)` 返回 `ReviewTaskResult`。
|
||||||
|
|
||||||
|
## 3. POST /v1/tasks/{task_id}:retry
|
||||||
|
|
||||||
|
权限 `tasks:retry`。只接受**没有上层所有者的独立任务**,原状态FAILED或CANCELLED,effect_uncertain=false,版本匹配,无租约/活跃尝试/未释放许可。NEEDS_REVIEW先核查,SUCCEEDED不能重试;计划任务和流程任务均拒绝直接生成后继。
|
||||||
|
|
||||||
|
| 字段 | 类型/要求 | 含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| expected_revision | string,必填 | 原终态任务的当前版本 |
|
||||||
|
| reason / evidence_ref | string,必填 | 重执行原因与安全依据 |
|
||||||
|
| safe_to_retry | bool,必须显式true | 声明已确认旧执行结束,按原业务身份重新执行安全 |
|
||||||
|
| task_timeout_ms | uint64,必填1–2592000000 | 新任务从受理起的总预算,最多30天;至少覆盖冻结的attempt_timeout_ms |
|
||||||
|
| available_at | time/null,可选 | 新任务最早可执行时间,默认当前数据库时间 |
|
||||||
|
| start_deadline_at | time/null,可选 | 新任务最晚开始时间,默认无独立限制,受新总截止时间约束 |
|
||||||
|
|
||||||
|
时间采用UTC RFC3339、微秒精度、年份2000–9998。available_at只允许最多1秒过去容差,必须早于新总截止;start_deadline_at必须晚于现在、不早于available_at、不晚于新总截止,详见[受理时间约束](tasks.md)。不继承原任务已经过期的绝对时间。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expected_revision":"5",
|
||||||
|
"reason":"已核实旧执行不会继续产生效果,允许重新执行",
|
||||||
|
"evidence_ref":"evidence:safe-retry-001",
|
||||||
|
"safe_to_retry":true,
|
||||||
|
"task_timeout_ms":60000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
首次HTTP201,原键重报HTTP200且deduplicated=true。完整示意响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id": "77777777777777777777777777777777",
|
||||||
|
"data": {
|
||||||
|
"source_task_id": "22222222222222222222222222222222",
|
||||||
|
"task": {
|
||||||
|
"id": "66666666666666666666666666666666",
|
||||||
|
"namespace_id": "33333333333333333333333333333333",
|
||||||
|
"queue_id": "11111111111111111111111111111111",
|
||||||
|
"state": "WAITING",
|
||||||
|
"revision": "1",
|
||||||
|
"handler": "object.prepare",
|
||||||
|
"handler_version": "v1",
|
||||||
|
"payload_version": "v1",
|
||||||
|
"payload": {
|
||||||
|
"object_ref": "objects/input",
|
||||||
|
"sequence": 9007199254740993
|
||||||
|
},
|
||||||
|
"business_key": "object:operation-001",
|
||||||
|
"available_at": "2026-09-17T00:00:30Z",
|
||||||
|
"deadline_at": "2026-09-17T00:01:30Z",
|
||||||
|
"recovery_safety": "RECONCILE_ONLY",
|
||||||
|
"retry": {
|
||||||
|
"max_attempts": 1,
|
||||||
|
"initial_delay_ms": 0,
|
||||||
|
"max_delay_ms": 0,
|
||||||
|
"attempt_timeout_ms": 10000
|
||||||
|
},
|
||||||
|
"attempt_count": 0,
|
||||||
|
"created_at": "2026-09-17T00:00:30Z",
|
||||||
|
"updated_at": "2026-09-17T00:00:30Z",
|
||||||
|
"retry_of": "22222222222222222222222222222222"
|
||||||
|
},
|
||||||
|
"audit_id": "88888888888888888888888888888888",
|
||||||
|
"deduplicated": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
新任务保留原队列、处理器与输入版本、payload、business_key、concurrency_key、priority、recovery_safety和冻结重试策略;新ID、revision=1、attempt_count=0和新时间预算,`retry_of`指回原任务。原任务终态、版本、结果和尝试次数均保持不变。
|
||||||
|
|
||||||
|
每个原任务最多一个直接重试后继;不同键竞争时只有一个成功,其余409,继续人工重试应沿失败/取消的后继任务操作。同键重报返回同一子任务和audit_id。唯一索引 `(namespace_id,retry_of)` 兜底,关联存在与归属由事务校验,**无数据库外键**。
|
||||||
|
|
||||||
|
新任务占用原队列与应用额度,仍受背压/暂停/并发/限速/资源键控制;满额不创建子任务、不遗留幂等身份。人工声明不能替代业务幂等:业务操作身份仍须保持稳定,新taskID不能直接用作外部业务的去重身份。
|
||||||
|
|
||||||
|
`task.retry`审计记在原任务上,after包含retry_task_id;子任务的task.accepted事件包含retry_of和audit_id。任务详情、单项回执和Worker分配中的retry_of为新增可选字段;任务摘要列表没有该字段。
|
||||||
|
|
||||||
|
SDK:`RetryTask(ctx, taskID, key, orbit.RetryTaskRequest)` 返回 `RetryTaskResult`。
|
||||||
|
|
||||||
|
## 4. 查询不可变操作审计
|
||||||
|
|
||||||
|
权限 `tasks:audit:read`,与普通tasks:read分开;当前凭据与队列范围逐次校验,不返回其他任务的审计或秘密。
|
||||||
|
|
||||||
|
`GET /v1/tasks/{task_id}/operations?limit=20&cursor=...`:limit默认20,1–100,cursor最长1024;参数只能出现一次且不能传未知筛选。按(created_at DESC,id DESC)分页;游标绑定命名空间、任务和查询种类。不是跨页快照,新的并发操作需从首页重新查询。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id":"44444444444444444444444444444444",
|
||||||
|
"data":{
|
||||||
|
"items":[{
|
||||||
|
"id":"88888888888888888888888888888888",
|
||||||
|
"task_id":"22222222222222222222222222222222",
|
||||||
|
"operation_id":"99999999999999999999999999999999",
|
||||||
|
"actor_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"action":"task.retry",
|
||||||
|
"created_at":"2026-09-17T00:00:30Z"
|
||||||
|
}],
|
||||||
|
"next_cursor":"<服务端返回的不透明游标>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /v1/tasks/{task_id}/operations/{audit_id}`:200详情在上述摘要基础上增加reason、可选evidence_ref、before和after;不存在或不属该任务返回404。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id":"44444444444444444444444444444444",
|
||||||
|
"data":{
|
||||||
|
"id":"88888888888888888888888888888888",
|
||||||
|
"task_id":"22222222222222222222222222222222",
|
||||||
|
"operation_id":"99999999999999999999999999999999",
|
||||||
|
"actor_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"action":"task.retry",
|
||||||
|
"created_at":"2026-09-17T00:00:30Z",
|
||||||
|
"reason":"已核实旧执行不会继续产生效果,允许重新执行",
|
||||||
|
"evidence_ref":"evidence:safe-retry-001",
|
||||||
|
"before":{"state":"FAILED","revision":"5"},
|
||||||
|
"after":{
|
||||||
|
"source_task_id":"22222222222222222222222222222222",
|
||||||
|
"retry_task_id":"66666666666666666666666666666666",
|
||||||
|
"safe_to_retry":true,
|
||||||
|
"task_timeout_ms":60000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
核查after包含state、revision、effect_uncertain=false、execution_settled=true及result_digest。摘要不复制完整业务输入;详情也不返回租约/凭据秘密。核查/重试的actor_id由实际认证凭据确定,不能在正文自报。审计可含受理、取消、执行结果等其他动作;查询操作记录不等于获得任务执行权。
|
||||||
|
|
||||||
|
SDK:`ListTaskOperations(ctx, taskID, HistoryPageRequest)`、`GetTaskOperation(ctx, taskID, auditID)`。
|
||||||
|
|
||||||
|
## 5. 错误与兼容
|
||||||
|
|
||||||
|
| HTTP/错误码 | 处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 400 INVALID_ARGUMENT | 修正字段、显式确认、预算、JSON或游标,不触及任务 |
|
||||||
|
| 401 UNAUTHENTICATED | 核对凭据到期/撤销与所属应用启用情况 |
|
||||||
|
| 403 PERMISSION_DENIED | 当前凭据未获该管理/审计权限 |
|
||||||
|
| 404 NOT_FOUND | 任务/操作不存在,或命名空间/队列/任务归属不匹配 |
|
||||||
|
| 409 STATE_CONFLICT | 状态/版本/所有者/门控不允许,或已有直接重试后继;先读当前状态和审计 |
|
||||||
|
| 409 IDEMPOTENCY_CONFLICT | 同操作身份正文变化,核对已保存原请求 |
|
||||||
|
| 429 QUEUE_FULL / APPLICATION_FULL | 新任务受理背压,释放额度后原请求重试;错误携带retry_after_ms提示 |
|
||||||
|
| 503 UNAVAILABLE 或网络错误 | 操作可能已提交,保持原身份核对 |
|
||||||
|
|
||||||
|
完整通用错误边界与请求大小限制见[任务接口](tasks.md)。新接口调用旧服务返回404,不静默改成重新SubmitTask。旧SDK忽略retry_of响应字段;使用additionalProperties=false的旧版严格Schema验证器需更新Schema。需要恢复接口与关联追踪时升级当前SDK。Worker协议仍为v1,retry_of是可选新增字段。
|
||||||
|
|
||||||
|
迁移000015不新增模型字段:明确中文retry_of语义,将相同列的历史非唯一索引替换为唯一索引,并增加禁止自关联CHECK。停止旧写入节点后迁移并统一升级;迁移遇到历史非法重复或自关联应失败,不能自动删除数据。跨版本混合负载和生产滚动发布尚未验收。
|
||||||
@@ -92,7 +92,7 @@ curl -i "$ORBIT_URL/v1/tasks" \
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
可选响应字段:start_deadline_at、result、cancel_requested_at、completed_at、business_key、concurrency_key,未设置时省略。effect_uncertain=true 表示存在尚未核实的授权执行效果,false 时省略;详情、摘要和 Worker 派发均可返回。等待重试时 result/completed_at 省略,历史结果在尝试中保留。终态有 completed_at。状态集合为 WAITING / LEASED / RUNNING / NEEDS_REVIEW / SUCCEEDED / FAILED / CANCELLED;P1 仅主动产生 WAITING 和 CANCELLED。
|
可选响应字段:start_deadline_at、result、cancel_requested_at、completed_at、business_key、concurrency_key、retry_of,未设置时省略。effect_uncertain=true 表示存在尚未核实的授权执行效果,false 时省略;详情、摘要和 Worker 派发均可返回。retry_of仅在任务详情、受理回执和Worker派发中标识人工重试的原任务,列表摘要不包含。等待重试时 result/completed_at 省略,历史结果在尝试中保留。终态有 completed_at。状态集合为 WAITING / LEASED / RUNNING / NEEDS_REVIEW / SUCCEEDED / FAILED / CANCELLED;P1 仅主动产生 WAITING 和 CANCELLED。
|
||||||
|
|
||||||
### 幂等与失败处理
|
### 幂等与失败处理
|
||||||
|
|
||||||
@@ -190,4 +190,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"}}
|
||||||
```
|
```
|
||||||
|
|
||||||
有限自动重试已实现,具体分类、退避、回执和升级边界见 [安全重试](retries.md)。[运行中取消](cancellation.md) 已实现;[批量受理](batch-tasks.md) 已实现逐项结果与原身份补交;独立任务人工重试仍待实现。[执行历史](execution-history.md) 已提供尝试与证据的列表和详情。完整可运行程序见 [SDK 任务示例](../../sdk/go/examples/tasks/main.go),已验证异常场景见 [P1 回归报告](../reports/phase-1.md) 及 [取消回归](../reports/phase-3-cancellation.md)。SDK 忽略未知响应字段;更改已有字段语义需升级契约和回归。
|
有限自动重试已实现,具体分类、退避、回执和升级边界见 [安全重试](retries.md)。[运行中取消](cancellation.md) 已实现;[批量受理](batch-tasks.md) 已实现逐项结果与原身份补交;[独立任务核查与关联新任务重试](task-recovery.md) 已实现。[执行历史](execution-history.md) 已提供尝试与证据的列表和详情。完整可运行程序见 [SDK 任务示例](../../sdk/go/examples/tasks/main.go),已验证异常场景见 [P1 回归报告](../reports/phase-1.md) 及 [取消回归](../reports/phase-3-cancellation.md)。SDK 忽略未知响应字段;更改已有字段语义需升级契约和回归。
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ ACK 的 body 根据原请求选择:worker.capacity → welcome,worker.resume
|
|||||||
- hello.concurrency_keys 可选且默认 false,未声明时不派发带资源键任务;task.assign.task.concurrency_key 与非零字符串 concurrency_token 必须同时出现。Schema 检查字段配对,SDK 另核验支持声明和键格式,事务维护许可;见 [资源键契约](keyed-concurrency.md)。
|
- hello.concurrency_keys 可选且默认 false,未声明时不派发带资源键任务;task.assign.task.concurrency_key 与非零字符串 concurrency_token 必须同时出现。Schema 检查字段配对,SDK 另核验支持声明和键格式,事务维护许可;见 [资源键契约](keyed-concurrency.md)。
|
||||||
- finish.retryable 为可选布尔值,默认 false;true 仅允许 FAILED,Schema 与服务端都校验。派发任务可带 effect_uncertain=true,表示此前授权执行的效果尚未核实。业务重试条件见 [安全重试](retries.md)。
|
- finish.retryable 为可选布尔值,默认 false;true 仅允许 FAILED,Schema 与服务端都校验。派发任务可带 effect_uncertain=true,表示此前授权执行的效果尚未核实。业务重试条件见 [安全重试](retries.md)。
|
||||||
- 返回时间使用 UTC RFC 3339,最多微秒精度。验证器应开启 format 断言以校验实际日历日期。
|
- 返回时间使用 UTC RFC 3339,最多微秒精度。验证器应开启 format 断言以校验实际日历日期。
|
||||||
|
- task.assign.task.retry_of为可选原任务ID,表示人工重试的直接来源;SDK校验合法ID及非自身关联。旧SDK忽略未知响应字段,使用旧版严格Schema的客户端需更新Schema;业务幂等身份不因新taskID变化,见[人工恢复](task-recovery.md)。
|
||||||
- task.assign.task.priority 为可选 0–9 整数,零值省略;平台在实际到期后结合等待时间排序,详见 [优先级与公平派发](priority-and-fairness.md)。
|
- task.assign.task.priority 为可选 0–9 整数,零值省略;平台在实际到期后结合等待时间排序,详见 [优先级与公平派发](priority-and-fairness.md)。
|
||||||
|
|
||||||
P2 收紧了开发期此前允许的省略控制字段、非规范计数字符串和数组内字段别名。当前 SDK 一直发送完整规范字段,不需迁移;其他自研客户端应按本文件更新。SDK 尚未发布首个版本。将来扩展消息类型或改变字段语义必须同时更新 Schema、服务端、SDK 与兼容回归。
|
P2 收紧了开发期此前允许的省略控制字段、非规范计数字符串和数组内字段别名。当前 SDK 一直发送完整规范字段,不需迁移;其他自研客户端应按本文件更新。SDK 尚未发布首个版本。将来扩展消息类型或改变字段语义必须同时更新 Schema、服务端、SDK 与兼容回归。
|
||||||
|
|||||||
@@ -138,3 +138,11 @@
|
|||||||
- 预期:accepted=2、replayed=2、retried_items=1、unfinished=0。
|
- 预期:accepted=2、replayed=2、retried_items=1、unfinished=0。
|
||||||
- 异常:逐项区分REJECTED/UNKNOWN/NOT_ATTEMPTED;真实HTTP响应丢失后恢复无重复,保留当前权限和额度控制。
|
- 异常:逐项区分REJECTED/UNKNOWN/NOT_ATTEMPTED;真实HTTP响应丢失后恢复无重复,保留当前权限和额度控制。
|
||||||
- 范围:示例只演示受理,不转换文件、不提供业务Outbox;失败非零退出,每项60秒总时限,保留队列及审计。
|
- 范围:示例只演示受理,不转换文件、不提供业务Outbox;失败非零退出,每项60秒总时限,保留队列及审计。
|
||||||
|
|
||||||
|
## 已可运行:独立任务人工核查与关联重试
|
||||||
|
|
||||||
|
- 源码:[recovery](../../sdk/go/examples/recovery/main.go),[完整运行与失败说明](recovery.md)。
|
||||||
|
- 步骤:模拟UNKNOWN → 人工声明历史执行结束 → 核查FAILED → 原键核对 → 创建retry_of关联新任务 → 执行成功 → 查询原任务及操作审计。
|
||||||
|
- 预期:原任务FAILED、新任务SUCCEEDED,原尝试和审计保留;管理凭据显式--recovery-access。
|
||||||
|
- 异常:真实HTTP在核查/重试提交后丢响应,原身份恢复同一审计和新任务;迟到成功只进入证据。
|
||||||
|
- 范围:本例不操作外部系统,不能替代真实证据核查、业务幂等或持久化管理操作Outbox。
|
||||||
|
|||||||
77
docs/examples/recovery.md
Normal file
77
docs/examples/recovery.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 未知结果的核查与人工重试示例
|
||||||
|
|
||||||
|
完整源码:[SDK recovery/main.go](../../sdk/go/examples/recovery/main.go)。只处理本地模拟对象,不调用任何真实外部业务系统。接口细节见[人工恢复手册](../api/task-recovery.md)。
|
||||||
|
|
||||||
|
## 准备与运行
|
||||||
|
|
||||||
|
从仓库根目录执行,ORBIT_MYSQL_DSN通过环境指向专用开发库;已有基础服务时使用当前迁移及二进制。初始化创建独立演示应用,输出文件必须不存在:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/orbit-migrate up
|
||||||
|
mkdir -p .local
|
||||||
|
chmod 700 .local
|
||||||
|
go run ./cmd/orbit-admin bootstrap \
|
||||||
|
--application recovery-demo --namespace default --environment development \
|
||||||
|
--queue recovery --worker-access --recovery-access \
|
||||||
|
--output .local/recovery.credentials.json
|
||||||
|
go run ./cmd/orbit
|
||||||
|
```
|
||||||
|
|
||||||
|
另开终端,从仓库根目录执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd sdk/go
|
||||||
|
ORBIT_URL=http://127.0.0.1:8080 \
|
||||||
|
ORBIT_CREDENTIALS_FILE=../../.local/recovery.credentials.json \
|
||||||
|
go run ./examples/recovery
|
||||||
|
```
|
||||||
|
|
||||||
|
演示凭据为了完整运行同时包含Worker与人工管理权限;生产将它们分开。凭据文件0600、token不输出;也接受ORBIT_TOKEN/ORBIT_QUEUE_ID,环境值优先。
|
||||||
|
|
||||||
|
## 实际调用链
|
||||||
|
|
||||||
|
1. 用独立处理器名提交一个对象引用,领取/开始后报告UNKNOWN,任务进入NEEDS_REVIEW。
|
||||||
|
2. 模拟人工核实全部旧执行结束且操作未完成,带证据引用和版本ReviewTask,核查为FAILED。
|
||||||
|
3. 原键重报核查,确认同audit_id和deduplicated=true。
|
||||||
|
4. RetryTask声明可安全重新执行并指定新总预算60秒,取得retry_of指向原任务的新任务。
|
||||||
|
5. Worker开始新任务、报告SUCCEEDED;查询原任务仍FAILED、新任务SUCCEEDED。
|
||||||
|
6. 查询原任务操作列表及重试审计详情,排空并关闭Worker连接。
|
||||||
|
|
||||||
|
预期输出,标识由实际执行生成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
source=<原任务ID> state=FAILED retry=<新任务ID> state=SUCCEEDED audit=<审计ID> operations=4
|
||||||
|
```
|
||||||
|
|
||||||
|
操作数包含本例受理、原结果、核查和人工重试;以本次实际查询为准。示例不删除任务、审计或尝试。重复运行创建独立演示任务,每次保留原历史。
|
||||||
|
|
||||||
|
## 接入片段
|
||||||
|
|
||||||
|
下方在已有客户端和已核实的current任务上下文中调用;完整imports/main及错误处理见源码。
|
||||||
|
|
||||||
|
```go
|
||||||
|
review, err := client.ReviewTask(ctx, current.ID, reviewKey, orbit.ReviewTaskRequest{
|
||||||
|
ExpectedRevision: current.Revision,
|
||||||
|
Reason: "已核实全部旧执行结束,操作未完成",
|
||||||
|
EvidenceRef: "evidence:reconciliation-001",
|
||||||
|
ExecutionSettled: true,
|
||||||
|
Outcome: orbit.TaskFailed,
|
||||||
|
Result: json.RawMessage(`{"confirmed":true}`),
|
||||||
|
})
|
||||||
|
if err != nil { return err }
|
||||||
|
retry, err := client.RetryTask(ctx, current.ID, retryKey, orbit.RetryTaskRequest{
|
||||||
|
ExpectedRevision: review.Task.Revision,
|
||||||
|
Reason: "同一业务操作可以安全重新执行",
|
||||||
|
EvidenceRef: "evidence:safe-retry-001",
|
||||||
|
SafeToRetry: true,
|
||||||
|
TaskTimeoutMS: 60000,
|
||||||
|
})
|
||||||
|
if err != nil { return err }
|
||||||
|
fmt.Println(retry.Task.ID, *retry.Task.RetryOf, retry.AuditID)
|
||||||
|
```
|
||||||
|
|
||||||
|
生产先持久化每次操作的key与完整请求,再发HTTP;响应丢失必须用原键、原版本、原结论重报。不能将同键回执中的新revision塞回原请求,不能看到UNKNOWN就直接RetryTask,也不能让审核按钮默认勾选“旧执行已结束”。
|
||||||
|
|
||||||
|
本程序为了清楚展示调用而使用单连接协议SDK及20秒总运行上限;错误非零退出,不包含持久化管理操作Outbox。失败后的任务须查询状态/历史再处理,60秒时限不意味着不确定效果会自动变成失败。持续Worker运行使用[自动Worker SDK](../sdk/worker-runtime.md)。
|
||||||
|
|
||||||
|
`make integration` 在隔离MySQL中运行本例及真实HTTP故障:核查和重试都在服务端提交后丢失响应,再原身份核对同一审计和子任务;旧Worker迟到成功只进入证据。外部证据的真实性与物理停止仍由管理流程核实。
|
||||||
85
docs/reports/phase-3-task-recovery.md
Normal file
85
docs/reports/phase-3-task-recovery.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# P3 人工核查、关联重试与操作审计回归
|
||||||
|
|
||||||
|
验证日期:2026-09-17。服务端、SDK、接口及示例已实现,完整回归已通过,脚本终态退出码0。P3剩余查询筛选与事务Outbox接入继续推进。
|
||||||
|
|
||||||
|
## 1. 实现范围
|
||||||
|
|
||||||
|
- [服务契约](../../pulse/service/recovery.go):显式历史执行结束/安全重试声明、原因、证据、版本及预算校验。
|
||||||
|
- [数据库事务](../../internal/storage/mysql/task_recovery.go):NEEDS_REVIEW核查为SUCCEEDED/FAILED,原尝试保留;独立FAILED/CANCELLED任务生成retry_of关联后继,原任务不改写。
|
||||||
|
- [审计查询](../../internal/storage/mysql/task_operations.go):专门权限与父资源范围,摘要游标分页、详情原因和证据,真实凭据身份。
|
||||||
|
- [Go SDK](../../sdk/go/recovery.go)、[接口手册](../api/task-recovery.md)、[完整运行场景](../examples/recovery.md)及[源码](../../sdk/go/examples/recovery/main.go)。
|
||||||
|
- [迁移000015](../../internal/storage/mysql/migrations/000015_task_manual_recovery.up.sql):现有RetryOf字段中文语义、同父后继唯一索引与禁止自身关联CHECK。35个模型、289个持久化字段,无新增字段,无外键/级联。
|
||||||
|
|
||||||
|
## 2. 已覆盖的数据库与协议场景
|
||||||
|
|
||||||
|
[真实MySQL测试](../../internal/storage/mysql/task_recovery_integration_test.go)包含八组场景:
|
||||||
|
|
||||||
|
1. 核查保存新权威结果、保留旧尝试,释放一次额度;同键重报原audit_id,旧needs_review快照不改写,新终态单独交付,迟到成功只记录证据。
|
||||||
|
2. 普通凭据不能核查或读取管理审计;显式授权后可用。跨命名空间/错误父任务拒绝,游标绑定任务/种类;撤销队列范围后原回执也不能绕过权限。
|
||||||
|
3. 审计INSERT触发器故障整体回滚核查、额度、幂等及新结果事件;两个不同键竞争同一版本仅一个成功。
|
||||||
|
4. 未核查任务不能重试,队列满额不残留身份/子任务;释放后原键成功,新任务新预算、原载荷/冻结策略,原任务不变;迟到报告不覆盖核查。
|
||||||
|
5. 人工重试审计故障回滚新任务及额度;八个不同键竞争同一父任务仅一个直接后继和一条task.retry审计。
|
||||||
|
6. 真实串行计划因未知结果停止推进;合法归属核查后可推进下一触发,但计划任务不允许独立人工重试。
|
||||||
|
7. 等待、运行、成功、带门控或无效计划关联的恢复均拒绝,不能借无外键设计绕过所有权。
|
||||||
|
8. 流程所属任务拒绝直接核查,归属恢复后普通任务可核查;计划任务拒绝独立派生。
|
||||||
|
|
||||||
|
[HTTP输入边界](../../internal/transport/httpserver/recovery_test.go)与[服务参数边界](../../pulse/service/recovery_test.go)覆盖缺失/null/false声明、未知actor_id/owner_id、重复JSON键、非规范revision、证据空白/长度、结论/结果/预算限制。
|
||||||
|
|
||||||
|
[SDK测试](../../sdk/go/recovery_test.go)核验原幂等头、字符串版本精度、审计游标编码、非法路径拒绝、矛盾核查回执及缺少重试来源时拒绝确认成功。
|
||||||
|
|
||||||
|
## 3. 无外键与迁移回归
|
||||||
|
|
||||||
|
真实v7→当前:70个历史外键全部删除,旧任务数据不变;323个未替换索引组件保留,单独核验(namespace_id,retry_of)新唯一索引。新装库外键为0。一次性库注入违规外键后启动结构检查拒绝就绪,移除后恢复。
|
||||||
|
|
||||||
|
本轮初次升级测试发现MySQL在新增同列唯一索引时会撤去原隐式非唯一索引fk_pulse_tasks_3。新迁移明确用唯一索引替换该冗余索引,测试分别核验替换对象和其他索引,避免将有意替换误报为数据损坏;没有增加数据库外键。
|
||||||
|
|
||||||
|
全量回归还发现旧v9升级夹具没有撤除新迁移15的约束,导致重放时重复CHECK名称;已修正一次性库夹具的还原步骤,保留真实生产迁移内容和校验和规则。
|
||||||
|
|
||||||
|
## 4. 环境与复现
|
||||||
|
|
||||||
|
macOS、Go1.27.1、Docker单实例MySQL8.4.10,隔离临时数据库与回环端口,结束自动清理。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make fmt test vet build
|
||||||
|
make integration
|
||||||
|
```
|
||||||
|
|
||||||
|
完整脚本为人工恢复单独初始化--worker-access与--recovery-access凭据,普通Worker测试凭据不扩权。集成SDK通过[真实HTTP代理](../../sdk/go/recovery_integration_test.go)在核查和重试提交后丢弃响应,再原身份核对;并在真实WSS派发中验证retry_of。
|
||||||
|
|
||||||
|
| 验证组 | 本轮已取得的结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| fmt / race / vet / build | PASS |
|
||||||
|
| 人工恢复八组数据库用例 | PASS,5.32秒 |
|
||||||
|
| v7无外键升级 / 全量MySQL集成 | PASS,4.85 / 104.575秒 |
|
||||||
|
| SDK Worker完整组 | PASS,87.918秒 |
|
||||||
|
| SDK计划 / 队列 / 批量 | PASS,5.659 / 4.536 / 1.366秒 |
|
||||||
|
| SDK订阅管理 / 事件消费 | PASS,33.379 / 51.798秒 |
|
||||||
|
| 新人工恢复SDK及两次HTTP丢回执 | PASS,3.371秒(含包开销) |
|
||||||
|
| Inbox六组真实数据库与消费者故障 | PASS,35.606秒 |
|
||||||
|
| SDK应用配额 | PASS,1.357秒 |
|
||||||
|
| 九项节点/Worker进程故障 | PASS,60.644秒;连同Inbox消费者为十项真实进程故障 |
|
||||||
|
| 全部运行示例及脚本终态 | 16个核心SDK示例 + 1个独立Inbox示例通过,完整脚本正常退出0 |
|
||||||
|
|
||||||
|
新人工恢复完整示例已通过;核心SDK依赖图未包含服务端包或MySQL驱动,文档575个本地链接、45个JSON块检查通过。最终结果取自已经结束的进程,未将中途日志当作全量通过。单次耗时仅为回归记录,不作为性能指标。
|
||||||
|
|
||||||
|
## 5. 实际HTTP响应丢失与运行结果
|
||||||
|
|
||||||
|
SDK集成代理先完整收到服务端成功响应,再主动关闭客户端连接。核查和人工重试各丢一次响应;测试关闭HTTP连接复用,确保不会因传输层隐式重发掩盖首次网络错误。原键重报后分别恢复同一audit_id和同一子任务,审计分页确认仅一次task.review和一次task.retry。
|
||||||
|
|
||||||
|
WSS接收的新任务带有正确retry_of,执行成功后原任务仍FAILED。旧Worker再报成功时返回EVIDENCE_ONLY,原任务版本/结果不变;原执行尝试也未被核查改写。
|
||||||
|
|
||||||
|
完整示例实际输出模式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
source=<原任务ID> state=FAILED retry=<新任务ID> state=SUCCEEDED audit=<审计ID> operations=4
|
||||||
|
```
|
||||||
|
|
||||||
|
本例四条审计对应受理、原结果、核查与人工重试,开始事件本身不额外增加一条管理审计。没有自动删除历史。
|
||||||
|
|
||||||
|
## 6. 生产边界
|
||||||
|
|
||||||
|
- 人工执行结束与安全重试是获授权主体基于外部证据的声明;平台不会自动证明外部物理停止或恰好一次。旧迟到报告保留证据,不重新打开已核查终态。
|
||||||
|
- 计划生成任务可核查但不能独立人工重试;流程所属任务/门控恢复留Weave控制器,不声称本阶段已实现流程恢复。
|
||||||
|
- 独立新任务仍受原应用/队列配额、冻结自动重试策略和执行权限约束;业务幂等身份必须沿原操作保持,不能用新taskID盲目调用外部接口。
|
||||||
|
- 任务详情和派发新增可选retry_of;旧SDK忽略未知响应字段,旧严格Schema需更新。迁移前停旧写入节点,不回改历史迁移,不自动清理非法历史关联。
|
||||||
|
- 本机正确性/进程故障不等于生产多故障域高可用、MySQL切换或百万租户容量验收;P4–P7仍按持续目标推进。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Go SDK 接入说明
|
# Go SDK 接入说明
|
||||||
|
|
||||||
已实现健康查询、任务单项与[批量提交](../api/batch-tasks.md)/查询/分页/待执行取消、队列创建/查询、[自动 Worker](worker-runtime.md)、[Worker 底层协议 SDK](worker.md) 和 [执行历史查询](../api/execution-history.md)。[安全重试](../api/retries.md) 已提供有限策略、WorkerFailure.Retryable、历史查询及完整例子。[计划调度](../api/schedules.md) 已支持创建、查询、版本发布、暂停/恢复、触发历史与完整示例。[订阅管理](../api/subscriptions.md) 已支持创建、查询、暂停恢复、交付历史与人工重试;[事件消费 SDK](event-consumer.md) 已支持连接、自动心跳、重连与确认。流程构建等后续接口按 [开发计划](../08-development-plan.md) 继续实现。
|
已实现健康查询、任务单项与[批量提交](../api/batch-tasks.md)/查询/分页/待执行取消、队列创建/查询、[自动 Worker](worker-runtime.md)、[Worker 底层协议 SDK](worker.md) 和 [执行历史查询](../api/execution-history.md)。[人工核查与重试](../api/task-recovery.md) 已提供 ReviewTask、RetryTask 与任务操作审计查询。[安全重试](../api/retries.md) 已提供有限策略、WorkerFailure.Retryable、历史查询及完整例子。[计划调度](../api/schedules.md) 已支持创建、查询、版本发布、暂停/恢复、触发历史与完整示例。[订阅管理](../api/subscriptions.md) 已支持创建、查询、暂停恢复、交付历史与人工重试;[事件消费 SDK](event-consumer.md) 已支持连接、自动心跳、重连与确认。流程构建等后续接口按 [开发计划](../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`,不引用服务端模型或数据库实现。
|
||||||
|
|
||||||
@@ -105,6 +105,8 @@ fmt.Println(cancelled.Task.State)
|
|||||||
|
|
||||||
| 方法 | 请求 / 返回 | 文档 |
|
| 方法 | 请求 / 返回 | 文档 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| ReviewTask / RetryTask | 原任务ID、稳定key、显式声明、证据与版本;返回任务及audit_id | [人工恢复](../api/task-recovery.md) / [完整场景](../examples/recovery.md) |
|
||||||
|
| ListTaskOperations / GetTaskOperation | 操作摘要分页、原因、证据与前后状态 | [任务审计](../api/task-recovery.md) |
|
||||||
| SubmitTasks | SubmitTasksRequest / SubmitTasksResult,32项独立幂等与结果 | [批量受理](../api/batch-tasks.md) / [运行场景](../examples/batch.md) |
|
| SubmitTasks | SubmitTasksRequest / SubmitTasksResult,32项独立幂等与结果 | [批量受理](../api/batch-tasks.md) / [运行场景](../examples/batch.md) |
|
||||||
| CreateSubscription / GetSubscription / ListSubscriptions | 订阅受理、当前配置与队列分页 | [订阅](../api/subscriptions.md) |
|
| CreateSubscription / GetSubscription / ListSubscriptions | 订阅受理、当前配置与队列分页 | [订阅](../api/subscriptions.md) |
|
||||||
| ConfigureSubscription | 显式暂停/继续与版本、原因 | [订阅](../api/subscriptions.md) |
|
| ConfigureSubscription | 显式暂停/继续与版本、原因 | [订阅](../api/subscriptions.md) |
|
||||||
@@ -185,7 +187,7 @@ 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 已实现 [安全重试](../api/retries.md)、[计划调度](../api/schedules.md)、[运行中取消](../api/cancellation.md) 、[队列资源控制及示例](../api/queues.md) 和 [应用共享配额及示例](../api/application-resources.md),[资源键互斥和单调序号示例](../api/keyed-concurrency.md) 已实现,[优先级场景和节点公平派发](../api/priority-and-fairness.md) 已实现,结果订阅继续开发;P4–P5 提供流程构建发布、信号、等待、轮询与人工恢复。每项随实现同步交付可运行示例及异常回归。
|
P2 已提供底层协议、[自动 Worker 运行器及示例](worker-runtime.md) 与历史查询,并通过 [Schema 及进程故障阶段验收](../reports/phase-2.md);P3 已实现 [安全重试](../api/retries.md)、[计划调度](../api/schedules.md)、[运行中取消](../api/cancellation.md) 、[队列资源控制及示例](../api/queues.md) 和 [应用共享配额及示例](../api/application-resources.md),[资源键互斥和单调序号示例](../api/keyed-concurrency.md) 已实现,[优先级场景和节点公平派发](../api/priority-and-fairness.md) 已实现,结果订阅及消费SDK、独立Inbox场景已实现,人工核查与重试也已提供完整示例;P4–P5 提供流程构建发布、信号、等待、轮询与人工恢复。每项随实现同步交付可运行示例及异常回归。
|
||||||
|
|
||||||
## 订阅管理示例
|
## 订阅管理示例
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
|
|||||||
retryFinish(t, g, finished, "SUCCEEDED", false)
|
retryFinish(t, g, finished, "SUCCEEDED", false)
|
||||||
assertApplicationCounts(t, f, 4, 2, 1)
|
assertApplicationCounts(t, f, 4, 2, 1)
|
||||||
// 还原后续新增表和字段,得到真实 v9 结构;现有任务内容保持不变。
|
// 还原后续新增表和字段,得到真实 v9 结构;现有任务内容保持不变。
|
||||||
|
f.exec(`ALTER TABLE pulse_tasks DROP CHECK ck_task_retry_not_self, DROP INDEX uq_task_retry_parent, ADD INDEX fk_pulse_tasks_3(namespace_id,retry_of)`)
|
||||||
f.exec(`DROP TABLE orbit_application_resources`)
|
f.exec(`DROP TABLE orbit_application_resources`)
|
||||||
f.exec(`DROP TABLE orbit_result_events,orbit_event_streams`)
|
f.exec(`DROP TABLE orbit_result_events,orbit_event_streams`)
|
||||||
f.exec(`ALTER TABLE orbit_subscriptions DROP CHECK ck_subscription_window, DROP COLUMN queue_id, DROP COLUMN cursor_sequence, DROP COLUMN pending_count, DROP COLUMN max_pending, DROP COLUMN max_attempts`)
|
f.exec(`ALTER TABLE orbit_subscriptions DROP CHECK ck_subscription_window, DROP COLUMN queue_id, DROP COLUMN cursor_sequence, DROP COLUMN pending_count, DROP COLUMN max_pending, DROP COLUMN max_attempts`)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
|
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
|
||||||
type BootstrapInput struct {
|
type BootstrapInput struct {
|
||||||
|
RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。
|
||||||
EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。
|
EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。
|
||||||
Application string // Application 新应用名称,与 ExistingApplicationID 二选一。
|
Application string // Application 新应用名称,与 ExistingApplicationID 二选一。
|
||||||
Namespace string // Namespace 命名空间名称。
|
Namespace string // Namespace 命名空间名称。
|
||||||
@@ -84,6 +85,9 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
|||||||
if in.EventAccess {
|
if in.EventAccess {
|
||||||
permissions.Actions = append(permissions.Actions, "events:read", "events:manage", "events:consume")
|
permissions.Actions = append(permissions.Actions, "events:read", "events:manage", "events:consume")
|
||||||
}
|
}
|
||||||
|
if in.RecoveryAccess {
|
||||||
|
permissions.Actions = append(permissions.Actions, "tasks:review", "tasks:retry", "tasks:audit:read")
|
||||||
|
}
|
||||||
raw, err := json.Marshal(permissions)
|
raw, err := json.Marshal(permissions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- 人工重试每个原任务只允许直接派生一个后继,后续重试沿新任务继续;不增加外键。
|
||||||
|
-- fk_pulse_tasks_3 是历史外键已删除后留下的普通非唯一索引;以下唯一索引覆盖相同列。
|
||||||
|
ALTER TABLE pulse_tasks
|
||||||
|
MODIFY COLUMN retry_of BINARY(16) NULL COMMENT '人工重试的原任务标识,同命名空间每个原任务最多一个直接后继;空表示非人工派生',
|
||||||
|
DROP INDEX fk_pulse_tasks_3,
|
||||||
|
ADD UNIQUE KEY uq_task_retry_parent(namespace_id,retry_of) COMMENT '防止不同操作身份并发重新发起同一原任务',
|
||||||
|
ADD CONSTRAINT ck_task_retry_not_self CHECK (retry_of IS NULL OR retry_of<>id);
|
||||||
@@ -88,7 +88,8 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
|
|||||||
}
|
}
|
||||||
indexes := func() []string {
|
indexes := func() []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
// v15 将原 retry_of 非唯一索引替换为相同列的唯一索引,下方单独验证其定义。
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -167,7 +168,28 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
|
|||||||
if got := foreignKeys(); got != 0 {
|
if got := foreignKeys(); got != 0 {
|
||||||
t.Fatalf("remaining foreign keys=%d", got)
|
t.Fatalf("remaining foreign keys=%d", got)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(beforeIndexes, indexes()) {
|
var retryIndex string
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT GROUP_CONCAT(CONCAT(COLUMN_NAME,':',NON_UNIQUE) ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pulse_tasks' AND INDEX_NAME='uq_task_retry_parent'`).Scan(&retryIndex); err != nil || retryIndex != "namespace_id:0,retry_of:0" {
|
||||||
|
t.Fatalf("retry uniqueness replacement=%q err=%v", retryIndex, err)
|
||||||
|
}
|
||||||
|
if afterIndexes := indexes(); !reflect.DeepEqual(beforeIndexes, afterIndexes) {
|
||||||
|
before, after := map[string]bool{}, map[string]bool{}
|
||||||
|
for _, index := range beforeIndexes {
|
||||||
|
before[index] = true
|
||||||
|
}
|
||||||
|
for _, index := range afterIndexes {
|
||||||
|
after[index] = true
|
||||||
|
}
|
||||||
|
for index := range before {
|
||||||
|
if !after[index] {
|
||||||
|
t.Errorf("upgrade removed index component: %s", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index := range after {
|
||||||
|
if !before[index] {
|
||||||
|
t.Errorf("upgrade added unexpected index component: %s", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
t.Fatal("upgrade changed index definitions")
|
t.Fatal("upgrade changed index definitions")
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(beforeTask, taskSnapshot()) {
|
if !reflect.DeepEqual(beforeTask, taskSnapshot()) {
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ type historyCursor struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func decodeHistoryCursor(value string, ns, task, attempt core.ID) (historyCursor, error) {
|
func decodeHistoryCursor(value string, ns, task, attempt core.ID) (historyCursor, error) {
|
||||||
|
kind := "attempts"
|
||||||
|
if attempt != "" {
|
||||||
|
kind = "evidence"
|
||||||
|
}
|
||||||
|
return decodeHistoryCursorKind(value, ns, task, attempt, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeHistoryCursorKind(value string, ns, task, attempt core.ID, kind string) (historyCursor, error) {
|
||||||
var c historyCursor
|
var c historyCursor
|
||||||
raw, err := base64.RawURLEncoding.Strict().DecodeString(value)
|
raw, err := base64.RawURLEncoding.Strict().DecodeString(value)
|
||||||
if err != nil || len(raw) > 768 {
|
if err != nil || len(raw) > 768 {
|
||||||
@@ -53,12 +61,12 @@ func decodeHistoryCursor(value string, ns, task, attempt core.ID) (historyCursor
|
|||||||
if d.Decode(&c) != nil || c.Version != 1 || c.Namespace != ns || c.Task != task || c.Attempt != attempt {
|
if d.Decode(&c) != nil || c.Version != 1 || c.Namespace != ns || c.Task != task || c.Attempt != attempt {
|
||||||
return c, fault.Invalid("cursor does not match this history query")
|
return c, fault.Invalid("cursor does not match this history query")
|
||||||
}
|
}
|
||||||
if attempt == "" {
|
if kind == "attempts" {
|
||||||
if c.Kind != "attempts" || c.Epoch == 0 || c.ID != "" || c.Created != nil {
|
if c.Kind != "attempts" || c.Epoch == 0 || c.ID != "" || c.Created != nil {
|
||||||
return c, fault.Invalid("invalid attempts cursor")
|
return c, fault.Invalid("invalid attempts cursor")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if c.Kind != "evidence" || c.Epoch != 0 || c.ID.Validate() != nil || c.Created == nil || c.Created.Year() < 2000 || c.Created.Year() > 9998 || c.Created.Nanosecond()%1000 != 0 {
|
if c.Kind != kind || c.Epoch != 0 || c.ID.Validate() != nil || c.Created == nil || c.Created.Year() < 2000 || c.Created.Year() > 9998 || c.Created.Nanosecond()%1000 != 0 {
|
||||||
return c, fault.Invalid("invalid evidence cursor")
|
return c, fault.Invalid("invalid evidence cursor")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
internal/storage/mysql/task_operations.go
Normal file
79
internal/storage/mysql/task_operations.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const taskOperationColumns = `id,resource_id,operation_id,actor_id,action,created_at`
|
||||||
|
|
||||||
|
func scanTaskOperation(row scanner) (service.TaskOperationSummary, error) {
|
||||||
|
var v service.TaskOperationSummary
|
||||||
|
err := row.Scan(&v.ID, &v.TaskID, &v.OperationID, &v.ActorID, &v.Action, &v.CreatedAt)
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
func (s *TaskStore) ListTaskOperations(ctx context.Context, t auth.Token, id core.ID, in service.HistoryPageInput) (service.TaskOperationPage, error) {
|
||||||
|
out := service.TaskOperationPage{Items: []service.TaskOperationSummary{}}
|
||||||
|
err := s.transaction(ctx, t, "tasks:audit:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||||
|
if err := authorizeHistoryTask(ctx, tx, t.NamespaceID, id, p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
query := `SELECT ` + taskOperationColumns + ` FROM orbit_audit_events WHERE namespace_id=? AND resource_kind='task' AND resource_id=?`
|
||||||
|
args := []any{t.NamespaceID, id}
|
||||||
|
if in.Cursor != "" {
|
||||||
|
c, err := decodeHistoryCursorKind(in.Cursor, t.NamespaceID, id, "", "operations")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
query += ` AND (created_at<? OR (created_at=? AND id<?))`
|
||||||
|
args = append(args, *c.Created, *c.Created, c.ID)
|
||||||
|
}
|
||||||
|
query += ` ORDER BY created_at DESC,id DESC LIMIT ?`
|
||||||
|
args = append(args, in.Limit+1)
|
||||||
|
rows, err := tx.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
v, err := scanTaskOperation(rows)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Items = append(out.Items, v)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(out.Items) > in.Limit {
|
||||||
|
out.Items = out.Items[:in.Limit]
|
||||||
|
last := out.Items[len(out.Items)-1]
|
||||||
|
out.NextCursor, err = encodeHistoryCursor(historyCursor{Version: 1, Kind: "operations", Namespace: t.NamespaceID, Task: id, ID: last.ID, Created: &last.CreatedAt})
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TaskStore) GetTaskOperation(ctx context.Context, t auth.Token, id, audit core.ID) (service.TaskOperationView, error) {
|
||||||
|
var out service.TaskOperationView
|
||||||
|
err := s.transaction(ctx, t, "tasks:audit:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||||
|
if err := authorizeHistoryTask(ctx, tx, t.NamespaceID, id, p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var before, after []byte
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT `+taskOperationColumns+",reason,COALESCE(evidence_ref,''),`before`,`after` FROM orbit_audit_events WHERE namespace_id=? AND resource_kind='task' AND resource_id=? AND id=?", t.NamespaceID, id, audit).Scan(&out.ID, &out.TaskID, &out.OperationID, &out.ActorID, &out.Action, &out.CreatedAt, &out.Reason, &out.EvidenceRef, &before, &after)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fault.NotFound
|
||||||
|
}
|
||||||
|
out.Before, out.After = before, after
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
185
internal/storage/mysql/task_recovery.go
Normal file
185
internal/storage/mysql/task_recovery.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recoveryTaskIdentity 认证后按应用额度→管理身份→队列→任务锁定。
|
||||||
|
// 回执核对不重新执行核查/重试,仍检查当前凭据与队列范围。
|
||||||
|
func recoveryTaskIdentity(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Permissions, id core.ID, operation, key string, in any) (executionTask, core.ID, []byte, error) {
|
||||||
|
v, err := readTask(ctx, tx, t.NamespaceID, id, false)
|
||||||
|
if err != nil {
|
||||||
|
return executionTask{}, "", nil, err
|
||||||
|
}
|
||||||
|
if !p.AllowsQueue(v.QueueID) {
|
||||||
|
return executionTask{}, "", nil, fault.NotFound
|
||||||
|
}
|
||||||
|
if _, err := lockApplicationResources(ctx, tx, t.NamespaceID, false); err != nil {
|
||||||
|
return executionTask{}, "", nil, err
|
||||||
|
}
|
||||||
|
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, operation, string(id), key, "task", id, in)
|
||||||
|
if err != nil || cached != nil {
|
||||||
|
return executionTask{}, identity, cached, err
|
||||||
|
}
|
||||||
|
if _, _, err := lockQueueAfterApplication(ctx, tx, t.NamespaceID, v.QueueID); err != nil {
|
||||||
|
return executionTask{}, "", nil, err
|
||||||
|
}
|
||||||
|
task, err := readExecutionTask(ctx, tx, t.NamespaceID, id)
|
||||||
|
return task, identity, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowTaskRecovery(ctx context.Context, tx *sql.Tx, ns, id core.ID, allowSchedule bool) error {
|
||||||
|
var owner sql.NullString
|
||||||
|
var ownerID, gate *core.ID
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT owner_kind,owner_id,gate_id FROM pulse_tasks WHERE namespace_id=? AND id=?`, ns, id).Scan(&owner, &ownerID, &gate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if gate != nil {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
if owner.Valid {
|
||||||
|
if !allowSchedule || owner.String != "schedule" || ownerID == nil {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
var matched int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM pulse_schedules s JOIN pulse_schedule_occurrences o ON o.namespace_id=s.namespace_id AND o.schedule_id=s.id WHERE s.namespace_id=? AND s.id=? AND o.task_id=?`, ns, *ownerID, id).Scan(&matched); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if matched != 1 {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
} else if ownerID != nil {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
var active int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM pulse_attempts WHERE namespace_id=? AND task_id=? AND state IN ('LEASED','RUNNING')`, ns, id).Scan(&active); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if active != 0 {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM pulse_permits p JOIN pulse_attempts a ON a.namespace_id=p.namespace_id AND a.id=p.attempt_id WHERE a.namespace_id=? AND a.task_id=? AND p.released_at IS NULL`, ns, id).Scan(&active); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if active != 0 {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TaskStore) ReviewTask(ctx context.Context, t auth.Token, id core.ID, key string, in service.ReviewInput) (service.ReviewResult, error) {
|
||||||
|
var out service.ReviewResult
|
||||||
|
err := s.transaction(ctx, t, "tasks:review", func(tx *sql.Tx, p auth.Permissions) error {
|
||||||
|
task, identity, cached, err := recoveryTaskIdentity(ctx, tx, t, p, id, "task.review", key, in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cached != nil {
|
||||||
|
if err := json.Unmarshal(cached, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Deduplicated = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := task.view
|
||||||
|
if v.State != pulse.TaskNeedsReview || v.Revision != in.ExpectedRevision || task.lease != nil {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
if err := allowTaskRecovery(ctx, tx, t.NamespaceID, id, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now, err := dbNow(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed, err := tx.ExecContext(ctx, `UPDATE pulse_tasks SET state=?,result=?,effect_uncertain=FALSE,completed_at=?,updated_at=?,revision=revision+1 WHERE namespace_id=? AND id=? AND revision=? AND state='NEEDS_REVIEW'`, in.Outcome, string(in.Result), now, now, t.NamespaceID, id, in.ExpectedRevision)
|
||||||
|
if err := exactlyOne(changed, err); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := releaseQueueTask(ctx, tx, t.NamespaceID, v.QueueID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Task, err = readTask(ctx, tx, t.NamespaceID, id, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
digest, err := requestDigest(in.Result)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
after := map[string]any{"state": in.Outcome, "revision": fmt.Sprint(out.Task.Revision), "effect_uncertain": false, "execution_settled": true, "result_digest": hex.EncodeToString(digest[:])}
|
||||||
|
out.AuditID, err = appendAuditWithEvidence(ctx, tx, t, identity, id, "task", "task.review", in.Reason, in.EvidenceRef, map[string]any{"state": v.State, "revision": fmt.Sprint(v.Revision), "effect_uncertain": v.EffectUncertain}, after)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := saveIdentity(ctx, tx, t.NamespaceID, identity, out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 结果流锁最后取得;冻结新的终态事件,历史NEEDS_REVIEW事件/尝试保持原样。
|
||||||
|
return emitEvent(ctx, tx, t.NamespaceID, id, out.Task.Revision, "task", resultEvent(in.Outcome), map[string]any{"task_id": id, "state": in.Outcome, "effect_uncertain": false, "decision": "MANUAL_REVIEW", "audit_id": out.AuditID})
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TaskStore) RetryTask(ctx context.Context, t auth.Token, id core.ID, key string, in service.ManualRetryInput) (service.ManualRetryResult, error) {
|
||||||
|
var out service.ManualRetryResult
|
||||||
|
err := s.transaction(ctx, t, "tasks:retry", func(tx *sql.Tx, p auth.Permissions) error {
|
||||||
|
task, identity, cached, err := recoveryTaskIdentity(ctx, tx, t, p, id, "task.retry", key, in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cached != nil {
|
||||||
|
if err := json.Unmarshal(cached, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Deduplicated = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := task.view
|
||||||
|
if (v.State != pulse.TaskFailed && v.State != pulse.TaskCancelled) || v.EffectUncertain || v.Revision != in.ExpectedRevision || task.lease != nil {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
if err := allowTaskRecovery(ctx, tx, t.NamespaceID, id, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var children int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM pulse_tasks WHERE namespace_id=? AND retry_of=?`, t.NamespaceID, id).Scan(&children); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if children != 0 {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
input, err := service.NormalizeSubmit(service.SubmitInput{QueueID: v.QueueID, Handler: v.Handler, HandlerVersion: v.HandlerVersion, PayloadVersion: v.PayloadVersion, Payload: v.Payload, BusinessKey: v.BusinessKey, ConcurrencyKey: v.ConcurrencyKey, Priority: v.Priority, RecoverySafety: v.RecoverySafety, Retry: v.Retry, AvailableAt: in.AvailableAt, StartDeadlineAt: in.StartDeadlineAt, TaskTimeoutMS: in.TaskTimeoutMS})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newID, err := core.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.SourceTaskID = id
|
||||||
|
out.Task, err = insertAcceptedTask(ctx, tx, t.NamespaceID, newID, input, &id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.AuditID, err = appendAuditWithEvidence(ctx, tx, t, identity, id, "task", "task.retry", in.Reason, in.EvidenceRef, map[string]any{"state": v.State, "revision": fmt.Sprint(v.Revision)}, map[string]any{"source_task_id": id, "retry_task_id": newID, "safe_to_retry": true, "task_timeout_ms": in.TaskTimeoutMS})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := emitEvent(ctx, tx, t.NamespaceID, newID, 1, "task", "task.accepted", map[string]any{"task_id": newID, "state": pulse.TaskWaiting, "retry_of": id, "audit_id": out.AuditID}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
345
internal/storage/mysql/task_recovery_integration_test.go
Normal file
345
internal/storage/mysql/task_recovery_integration_test.go
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||||
|
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
func grantTaskRecovery(f *executionFixture) {
|
||||||
|
f.exec(`UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.actions',JSON_ARRAY_APPEND(JSON_EXTRACT(permissions,'$.actions'),'$','tasks:review','$','tasks:retry','$','tasks:audit:read')) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID)
|
||||||
|
}
|
||||||
|
func unknownTask(t *testing.T, f *executionFixture) (service.TaskView, worker.AttemptRef) {
|
||||||
|
t.Helper()
|
||||||
|
v := f.submit(1)
|
||||||
|
ref := retryStart(t, f)
|
||||||
|
retryFinish(t, f, ref, "UNKNOWN", false)
|
||||||
|
v = f.get(v.ID)
|
||||||
|
if v.State != pulse.TaskNeedsReview || !v.EffectUncertain {
|
||||||
|
t.Fatal("fixture did not reach uncertainty")
|
||||||
|
}
|
||||||
|
return v, ref
|
||||||
|
}
|
||||||
|
func reviewInput(v service.TaskView, outcome pulse.TaskState) service.ReviewInput {
|
||||||
|
return service.ReviewInput{ExpectedRevision: v.Revision, Reason: "operator verified execution and external result", EvidenceRef: "evidence:reconciliation-001", ExecutionSettled: true, Outcome: outcome, Result: json.RawMessage(`{"verified":true,"n":9007199254740993}`)}
|
||||||
|
}
|
||||||
|
func manualRetryInput(v service.TaskView) service.ManualRetryInput {
|
||||||
|
return service.ManualRetryInput{ExpectedRevision: v.Revision, Reason: "operator confirmed safe same-operation retry", EvidenceRef: "evidence:safe-retry-001", SafeToRetry: true, TaskTimeoutMS: 600000}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationTaskRecovery(t *testing.T) {
|
||||||
|
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("requires disposable MySQL")
|
||||||
|
}
|
||||||
|
ctx, stop := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer stop()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
fixture := func(t *testing.T) *executionFixture {
|
||||||
|
f := newExecutionFixture(t, ctx, db)
|
||||||
|
grantTaskRecovery(f)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
t.Run("review_preserves_attempts_and_publishes_new_authoritative_result", func(t *testing.T) {
|
||||||
|
f := newEventFixture(t, ctx, db)
|
||||||
|
grantTaskRecovery(f.executionFixture)
|
||||||
|
sub := f.create(events.CreateInput{})
|
||||||
|
v, ref := unknownTask(t, f.executionFixture)
|
||||||
|
before, err := f.tasks.GetTaskAttempt(ctx, f.token, v.ID, ref.AttemptID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input := reviewInput(v, pulse.TaskSucceeded)
|
||||||
|
out, err := f.tasks.ReviewTask(ctx, f.token, v.ID, "review", input)
|
||||||
|
if err != nil || out.Task.State != pulse.TaskSucceeded || out.Task.EffectUncertain || out.Task.AttemptCount != v.AttemptCount || out.Task.Revision != v.Revision+1 || string(out.Task.Result) != string(input.Result) {
|
||||||
|
t.Fatal(out, err)
|
||||||
|
}
|
||||||
|
after, err := f.tasks.GetTaskAttempt(ctx, f.token, v.ID, ref.AttemptID)
|
||||||
|
if err != nil || !reflect.DeepEqual(before, after) {
|
||||||
|
t.Fatal("review rewrote worker history", err)
|
||||||
|
}
|
||||||
|
assertQueueCount(t, f.executionFixture, 0)
|
||||||
|
assertApplicationCounts(t, f.executionFixture, 0, 0, 0)
|
||||||
|
replay, err := f.tasks.ReviewTask(ctx, f.token, v.ID, "review", input)
|
||||||
|
if err != nil || !replay.Deduplicated || replay.AuditID != out.AuditID || replay.Task.Revision != out.Task.Revision {
|
||||||
|
t.Fatal("review identity changed", err)
|
||||||
|
}
|
||||||
|
input.Outcome = pulse.TaskFailed
|
||||||
|
_, err = f.tasks.ReviewTask(ctx, f.token, v.ID, "review", input)
|
||||||
|
expectWorkerCode(t, err, "IDEMPOTENCY_CONFLICT")
|
||||||
|
audit, err := f.tasks.GetTaskOperation(ctx, f.token, v.ID, out.AuditID)
|
||||||
|
if err != nil || audit.EvidenceRef != "evidence:reconciliation-001" || audit.ActorID != string(f.token.CredentialID) || audit.Action != "task.review" {
|
||||||
|
t.Fatal(audit, err)
|
||||||
|
}
|
||||||
|
// 旧UNKNOWN结果仍作为原快照交付;核查终态成为下一条独立不可变事件。
|
||||||
|
g := f.grant(sub.ID)
|
||||||
|
if g.Event.Type != "task.needs_review" || !g.Event.Snapshot.EffectUncertain {
|
||||||
|
t.Fatal("old snapshot was rewritten", g.Event)
|
||||||
|
}
|
||||||
|
f.ack(sub.ID, g)
|
||||||
|
g = f.grant(sub.ID)
|
||||||
|
if g.Event.Type != "task.succeeded" || g.Event.Snapshot.EffectUncertain || g.Event.Snapshot.Revision != out.Task.Revision {
|
||||||
|
t.Fatal("review result not journaled", g.Event)
|
||||||
|
}
|
||||||
|
f.ack(sub.ID, g)
|
||||||
|
late := retryFinish(t, f.executionFixture, ref, "SUCCEEDED", false)
|
||||||
|
if late.Receipt.Disposition != "EVIDENCE_ONLY" || f.get(v.ID).Revision != out.Task.Revision {
|
||||||
|
t.Fatal("late worker result overwrote review")
|
||||||
|
}
|
||||||
|
if f.count("orbit_audit_events", "resource_id=? AND action='task.review'", v.ID) != 1 {
|
||||||
|
t.Fatal("duplicate review audit")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("review_authority_is_explicit_and_history_is_scoped", func(t *testing.T) {
|
||||||
|
f := newExecutionFixture(t, ctx, db)
|
||||||
|
v, _ := unknownTask(t, f)
|
||||||
|
_, err := f.tasks.ReviewTask(ctx, f.token, v.ID, "review", reviewInput(v, pulse.TaskFailed))
|
||||||
|
expectWorkerCode(t, err, "PERMISSION_DENIED")
|
||||||
|
_, err = f.tasks.ListTaskOperations(ctx, f.token, v.ID, service.HistoryPageInput{})
|
||||||
|
expectWorkerCode(t, err, "PERMISSION_DENIED")
|
||||||
|
grantTaskRecovery(f)
|
||||||
|
out, err := f.tasks.ReviewTask(ctx, f.token, v.ID, "review", reviewInput(v, pulse.TaskFailed))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g := fixture(t)
|
||||||
|
_, err = g.tasks.GetTaskOperation(ctx, g.token, v.ID, out.AuditID)
|
||||||
|
expectWorkerCode(t, err, "NOT_FOUND")
|
||||||
|
other := f.submit(1)
|
||||||
|
_, err = f.tasks.GetTaskOperation(ctx, f.token, other.ID, out.AuditID)
|
||||||
|
expectWorkerCode(t, err, "NOT_FOUND")
|
||||||
|
page, err := f.tasks.ListTaskOperations(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 1})
|
||||||
|
if err != nil || len(page.Items) != 1 || page.NextCursor == "" {
|
||||||
|
t.Fatal(page, err)
|
||||||
|
}
|
||||||
|
next, err := f.tasks.ListTaskOperations(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 1, Cursor: page.NextCursor})
|
||||||
|
if err != nil || len(next.Items) != 1 || next.Items[0].ID == page.Items[0].ID {
|
||||||
|
t.Fatal(next, err)
|
||||||
|
}
|
||||||
|
_, err = f.tasks.ListTaskOperations(ctx, f.token, other.ID, service.HistoryPageInput{Cursor: page.NextCursor})
|
||||||
|
expectWorkerCode(t, err, "INVALID_ARGUMENT")
|
||||||
|
_, err = f.tasks.ListTaskAttempts(ctx, f.token, v.ID, service.HistoryPageInput{Cursor: page.NextCursor})
|
||||||
|
expectWorkerCode(t, err, "INVALID_ARGUMENT")
|
||||||
|
f.exec(`UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.queues',JSON_ARRAY(?)) WHERE namespace_id=? AND id=?`, string(g.resources.QueueID), f.resources.NamespaceID, f.resources.CredentialID)
|
||||||
|
_, err = f.tasks.ReviewTask(ctx, f.token, v.ID, "review", reviewInput(v, pulse.TaskFailed))
|
||||||
|
expectWorkerCode(t, err, "NOT_FOUND")
|
||||||
|
})
|
||||||
|
t.Run("review_cas_competition_and_atomic_audit_failure", func(t *testing.T) {
|
||||||
|
f := fixture(t)
|
||||||
|
v, _ := unknownTask(t, f)
|
||||||
|
input := reviewInput(v, pulse.TaskFailed)
|
||||||
|
trigger := "review_audit_" + string(executionID(t))
|
||||||
|
f.exec("CREATE TRIGGER " + trigger + " BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='controlled review audit failure'")
|
||||||
|
_, err := f.tasks.ReviewTask(ctx, f.token, v.ID, "review", input)
|
||||||
|
f.exec("DROP TRIGGER " + trigger)
|
||||||
|
if err == nil || !reflect.DeepEqual(v, f.get(v.ID)) {
|
||||||
|
t.Fatal("failed audit did not roll back review", err)
|
||||||
|
}
|
||||||
|
assertQueueCount(t, f, 1)
|
||||||
|
assertApplicationCounts(t, f, 1, 0, 0)
|
||||||
|
if f.count("orbit_idempotency_keys", "operation='task.review'") != 0 || f.count("orbit_result_events", "1=1") != 1 {
|
||||||
|
t.Fatal("review leaked identity or result event")
|
||||||
|
}
|
||||||
|
keys := []string{"review", "competing-review"}
|
||||||
|
ch := make(chan error, 2)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, key := range keys {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() { defer wg.Done(); _, err := f.tasks.ReviewTask(ctx, f.token, v.ID, key, input); ch <- err }()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(ch)
|
||||||
|
success, conflicts := 0, 0
|
||||||
|
for err := range ch {
|
||||||
|
if err == nil {
|
||||||
|
success++
|
||||||
|
} else if fault.Code(err) == "STATE_CONFLICT" {
|
||||||
|
conflicts++
|
||||||
|
} else {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if success != 1 || conflicts != 1 {
|
||||||
|
t.Fatal(success, conflicts)
|
||||||
|
}
|
||||||
|
assertQueueCount(t, f, 0)
|
||||||
|
})
|
||||||
|
t.Run("retry_new_identity_preserves_source_and_respects_backpressure", func(t *testing.T) {
|
||||||
|
f := fixture(t)
|
||||||
|
configureResources(t, f, 10, 4, 1, false)
|
||||||
|
v, oldRef := unknownTask(t, f)
|
||||||
|
_, err := f.tasks.RetryTask(ctx, f.token, v.ID, "retry", manualRetryInput(v))
|
||||||
|
expectWorkerCode(t, err, "STATE_CONFLICT")
|
||||||
|
review, err := f.tasks.ReviewTask(ctx, f.token, v.ID, "review", reviewInput(v, pulse.TaskFailed))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v = review.Task
|
||||||
|
occupant := f.submit(1)
|
||||||
|
in := manualRetryInput(v)
|
||||||
|
_, err = f.tasks.RetryTask(ctx, f.token, v.ID, "retry", in)
|
||||||
|
expectWorkerCode(t, err, "QUEUE_FULL")
|
||||||
|
if f.count("pulse_tasks", "retry_of=?", v.ID) != 0 || f.count("orbit_idempotency_keys", "operation='task.retry'") != 0 {
|
||||||
|
t.Fatal("quota rejection left a child")
|
||||||
|
}
|
||||||
|
if _, err := f.tasks.CancelTask(ctx, f.token, occupant.ID, "free", service.CancelInput{ExpectedRevision: occupant.Revision, Reason: "free retry quota"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := f.tasks.RetryTask(ctx, f.token, v.ID, "retry", in)
|
||||||
|
if err != nil || out.Task.ID == v.ID || out.Task.RetryOf == nil || *out.Task.RetryOf != v.ID || out.Task.AttemptCount != 0 || out.Task.Revision != 1 || out.Task.Retry != v.Retry || string(out.Task.Payload) != string(v.Payload) || !reflect.DeepEqual(v, f.get(v.ID)) {
|
||||||
|
t.Fatal(out, err)
|
||||||
|
}
|
||||||
|
if !out.Task.DeadlineAt.After(out.Task.CreatedAt) || out.Task.DeadlineAt.Sub(out.Task.CreatedAt) != 10*time.Minute {
|
||||||
|
t.Fatal("new budget incorrect")
|
||||||
|
}
|
||||||
|
replay, err := f.tasks.RetryTask(ctx, f.token, v.ID, "retry", in)
|
||||||
|
if err != nil || !replay.Deduplicated || replay.Task.ID != out.Task.ID || replay.AuditID != out.AuditID {
|
||||||
|
t.Fatal("retry replay changed identity", err)
|
||||||
|
}
|
||||||
|
_, err = f.tasks.RetryTask(ctx, f.token, v.ID, "another", in)
|
||||||
|
expectWorkerCode(t, err, "STATE_CONFLICT")
|
||||||
|
late := retryFinish(t, f, oldRef, "SUCCEEDED", false)
|
||||||
|
if late.Receipt.Disposition != "EVIDENCE_ONLY" || !reflect.DeepEqual(v, f.get(v.ID)) {
|
||||||
|
t.Fatal("old worker changed resolved task")
|
||||||
|
}
|
||||||
|
assignment, ref := f.claim()
|
||||||
|
if assignment.Task.ID != out.Task.ID || assignment.Task.RetryOf == nil || *assignment.Task.RetryOf != v.ID {
|
||||||
|
t.Fatal("worker lost retry lineage")
|
||||||
|
}
|
||||||
|
if _, err := f.a.Start(ctx, f.token, f.binding, executionID(t), ref); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
retryFinish(t, f, ref, "SUCCEEDED", false)
|
||||||
|
assertQueueCount(t, f, 0)
|
||||||
|
})
|
||||||
|
t.Run("retry_competing_keys_and_audit_rollback", func(t *testing.T) {
|
||||||
|
f := fixture(t)
|
||||||
|
v := f.submit(1)
|
||||||
|
cancelled, err := f.tasks.CancelTask(ctx, f.token, v.ID, "cancel", service.CancelInput{ExpectedRevision: v.Revision, Reason: "stop original"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v = cancelled.Task
|
||||||
|
trigger := "retry_audit_" + string(executionID(t))
|
||||||
|
f.exec("CREATE TRIGGER " + trigger + " BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='controlled retry audit failure'")
|
||||||
|
_, err = f.tasks.RetryTask(ctx, f.token, v.ID, "retry", manualRetryInput(v))
|
||||||
|
f.exec("DROP TRIGGER " + trigger)
|
||||||
|
if err == nil || f.count("pulse_tasks", "retry_of=?", v.ID) != 0 {
|
||||||
|
t.Fatal("retry audit failure leaked child", err)
|
||||||
|
}
|
||||||
|
assertQueueCount(t, f, 0)
|
||||||
|
ch := make(chan error, 8)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
key := string(executionID(t))
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, err := f.tasks.RetryTask(ctx, f.token, v.ID, key, manualRetryInput(v))
|
||||||
|
ch <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(ch)
|
||||||
|
success := 0
|
||||||
|
for err := range ch {
|
||||||
|
if err == nil {
|
||||||
|
success++
|
||||||
|
} else if fault.Code(err) != "STATE_CONFLICT" {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if success != 1 || f.count("pulse_tasks", "retry_of=?", v.ID) != 1 || f.count("orbit_audit_events", "action='task.retry'") != 1 {
|
||||||
|
t.Fatal("multiple manual children", success)
|
||||||
|
}
|
||||||
|
assertApplicationCounts(t, f, 1, 0, 0)
|
||||||
|
})
|
||||||
|
t.Run("review_unblocks_serial_schedule_without_manual_child", func(t *testing.T) {
|
||||||
|
f := newScheduleFixture(t, newExecutionFixture(t, ctx, db))
|
||||||
|
grantTaskRecovery(f.executionFixture)
|
||||||
|
in := f.definition()
|
||||||
|
in.Policy.Overlap = "SERIAL"
|
||||||
|
plan := f.create(in)
|
||||||
|
f.due(plan, 2*time.Second)
|
||||||
|
f.tick()
|
||||||
|
items := f.occurrences(plan.ID)
|
||||||
|
if len(items) != 1 || items[0].TaskID == nil {
|
||||||
|
t.Fatal("missing scheduled task")
|
||||||
|
}
|
||||||
|
ref := retryStart(t, f.executionFixture)
|
||||||
|
retryFinish(t, f.executionFixture, ref, "UNKNOWN", false)
|
||||||
|
v := f.get(*items[0].TaskID)
|
||||||
|
f.exec(`UPDATE pulse_schedules SET check_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, plan.ID)
|
||||||
|
f.tick()
|
||||||
|
if len(f.occurrences(plan.ID)) != 1 {
|
||||||
|
t.Fatal("unknown execution did not block serial schedule")
|
||||||
|
}
|
||||||
|
out, err := f.tasks.ReviewTask(ctx, f.token, v.ID, "review", reviewInput(v, pulse.TaskFailed))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = f.tasks.RetryTask(ctx, f.token, v.ID, "retry", manualRetryInput(out.Task))
|
||||||
|
expectWorkerCode(t, err, "STATE_CONFLICT")
|
||||||
|
f.exec(`UPDATE pulse_schedules SET check_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, plan.ID)
|
||||||
|
f.tick()
|
||||||
|
if len(f.occurrences(plan.ID)) != 2 {
|
||||||
|
t.Fatal("review did not release serial schedule")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("recovery_rejects_gates_bad_owner_active_and_success", func(t *testing.T) {
|
||||||
|
f := fixture(t)
|
||||||
|
waiting := f.submit(1)
|
||||||
|
_, err := f.tasks.ReviewTask(ctx, f.token, waiting.ID, "waiting", reviewInput(waiting, pulse.TaskFailed))
|
||||||
|
expectWorkerCode(t, err, "STATE_CONFLICT")
|
||||||
|
ref := retryStart(t, f)
|
||||||
|
active := f.get(waiting.ID)
|
||||||
|
_, err = f.tasks.ReviewTask(ctx, f.token, active.ID, "active", reviewInput(active, pulse.TaskFailed))
|
||||||
|
expectWorkerCode(t, err, "STATE_CONFLICT")
|
||||||
|
retryFinish(t, f, ref, "SUCCEEDED", false)
|
||||||
|
success := f.get(waiting.ID)
|
||||||
|
_, err = f.tasks.RetryTask(ctx, f.token, success.ID, "retry", manualRetryInput(success))
|
||||||
|
expectWorkerCode(t, err, "STATE_CONFLICT")
|
||||||
|
v, _ := unknownTask(t, f)
|
||||||
|
f.exec(`UPDATE pulse_tasks SET gate_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, v.ID)
|
||||||
|
_, err = f.tasks.ReviewTask(ctx, f.token, v.ID, "review", reviewInput(v, pulse.TaskFailed))
|
||||||
|
expectWorkerCode(t, err, "STATE_CONFLICT")
|
||||||
|
f.exec(`UPDATE pulse_tasks SET gate_id=NULL,owner_kind='schedule',owner_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, v.ID)
|
||||||
|
_, err = f.tasks.ReviewTask(ctx, f.token, v.ID, "review", reviewInput(v, pulse.TaskFailed))
|
||||||
|
expectWorkerCode(t, err, "STATE_CONFLICT")
|
||||||
|
if f.get(v.ID).Revision != v.Revision {
|
||||||
|
t.Fatal("rejected recovery changed task")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("managed_tasks_cannot_bypass_recovery_owner", func(t *testing.T) {
|
||||||
|
f := fixture(t)
|
||||||
|
v, _ := unknownTask(t, f)
|
||||||
|
f.exec(`UPDATE pulse_tasks SET owner_kind='workflow',owner_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, v.ID)
|
||||||
|
_, err := f.tasks.ReviewTask(ctx, f.token, v.ID, "review", reviewInput(v, pulse.TaskFailed))
|
||||||
|
expectWorkerCode(t, err, "STATE_CONFLICT")
|
||||||
|
f.exec(`UPDATE pulse_tasks SET owner_kind=NULL,owner_id=NULL WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, v.ID)
|
||||||
|
out, err := f.tasks.ReviewTask(ctx, f.token, v.ID, "review", reviewInput(v, pulse.TaskFailed))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.exec(`UPDATE pulse_tasks SET owner_kind='schedule',owner_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, v.ID)
|
||||||
|
_, err = f.tasks.RetryTask(ctx, f.token, v.ID, "retry", manualRetryInput(out.Task))
|
||||||
|
expectWorkerCode(t, err, "STATE_CONFLICT")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -137,20 +137,25 @@ func emitEvent(ctx context.Context, tx *sql.Tx, ns, resource core.ID, revision u
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
func appendAudit(ctx context.Context, tx *sql.Tx, token auth.Token, operation, resource core.ID, kind, action, reason string, before, after any) error {
|
func appendAudit(ctx context.Context, tx *sql.Tx, token auth.Token, operation, resource core.ID, kind, action, reason string, before, after any) error {
|
||||||
|
_, err := appendAuditWithEvidence(ctx, tx, token, operation, resource, kind, action, reason, "", before, after)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendAuditWithEvidence(ctx context.Context, tx *sql.Tx, token auth.Token, operation, resource core.ID, kind, action, reason, evidence string, before, after any) (core.ID, error) {
|
||||||
id, err := core.NewID()
|
id, err := core.NewID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
old, err := json.Marshal(before)
|
old, err := json.Marshal(before)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
current, err := json.Marshal(after)
|
current, err := json.Marshal(after)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
_, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,`before`,`after`) VALUES (?,?,?,?,?,?,?,?,?,?)", token.NamespaceID, id, operation, string(token.CredentialID), action, kind, resource, reason, string(old), string(current))
|
_, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,evidence_ref,`before`,`after`) VALUES (?,?,?,?,?,?,?,?,NULLIF(?,''),?,?)", token.NamespaceID, id, operation, string(token.CredentialID), action, kind, resource, reason, evidence, string(old), string(current))
|
||||||
return err
|
return id, err
|
||||||
}
|
}
|
||||||
func dbNow(ctx context.Context, tx *sql.Tx) (time.Time, error) {
|
func dbNow(ctx context.Context, tx *sql.Tx) (time.Time, error) {
|
||||||
var now time.Time
|
var now time.Time
|
||||||
@@ -188,34 +193,7 @@ func (s *TaskStore) Submit(ctx context.Context, token auth.Token, key string, in
|
|||||||
if _, _, err := lockQueueAfterApplication(ctx, tx, token.NamespaceID, in.QueueID); err != nil {
|
if _, _, err := lockQueueAfterApplication(ctx, tx, token.NamespaceID, in.QueueID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
now, err := dbNow(ctx, tx)
|
out.Task, err = insertAcceptedTask(ctx, tx, token.NamespaceID, taskID, in, nil)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
available := now
|
|
||||||
if in.AvailableAt != nil {
|
|
||||||
available = *in.AvailableAt
|
|
||||||
}
|
|
||||||
deadline := now.Add(time.Duration(in.TaskTimeoutMS) * time.Millisecond)
|
|
||||||
if available.Before(now.Add(-time.Second)) || !available.Before(deadline) {
|
|
||||||
return fault.Invalid("available_at must be current/future and before the task deadline")
|
|
||||||
}
|
|
||||||
if in.StartDeadlineAt != nil && (!in.StartDeadlineAt.After(now) || in.StartDeadlineAt.Before(available) || in.StartDeadlineAt.After(deadline)) {
|
|
||||||
return fault.Invalid("start_deadline_at must be after now, at or after available_at, and no later than the task deadline")
|
|
||||||
}
|
|
||||||
policy, err := json.Marshal(in.Retry)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := reserveQueueTask(ctx, tx, token.NamespaceID, in.QueueID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = tx.ExecContext(ctx, `INSERT INTO pulse_tasks(namespace_id,id,queue_id,handler,handler_version,payload_version,payload,policy_snapshot,recovery_safety,business_key,concurrency_key,concurrency_key_digest,priority,available_at,start_deadline_at,deadline_at,attempt_timeout_ms,max_attempts,created_at,updated_at)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,NULLIF(?,''),?,?,?,?,?,?,?,?,?,?)`, token.NamespaceID, taskID, in.QueueID, in.Handler, in.HandlerVersion, in.PayloadVersion, string(in.Payload), string(policy), in.RecoverySafety, in.BusinessKey, concurrencyKeyValue(in.ConcurrencyKey), concurrencyDigest(in.ConcurrencyKey), in.Priority, available, in.StartDeadlineAt, deadline, in.Retry.AttemptTimeoutMS, in.Retry.MaxAttempts, now, now)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
out.Task, err = readTask(ctx, tx, token.NamespaceID, taskID, false)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -230,14 +208,46 @@ 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,''),COALESCE(concurrency_key,''),priority,available_at,start_deadline_at,deadline_at,recovery_safety,policy_snapshot,attempt_count,cancel_requested_at,completed_at,created_at,updated_at,effect_uncertain`
|
// insertAcceptedTask 要求调用方已持有应用额度和队列锁,并将身份、事件及审计同事务提交。
|
||||||
|
func insertAcceptedTask(ctx context.Context, tx *sql.Tx, ns, taskID core.ID, in service.SubmitInput, retryOf *core.ID) (service.TaskView, error) {
|
||||||
|
now, err := dbNow(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return service.TaskView{}, err
|
||||||
|
}
|
||||||
|
available := now
|
||||||
|
if in.AvailableAt != nil {
|
||||||
|
available = *in.AvailableAt
|
||||||
|
}
|
||||||
|
deadline := now.Add(time.Duration(in.TaskTimeoutMS) * time.Millisecond)
|
||||||
|
if available.Before(now.Add(-time.Second)) || !available.Before(deadline) {
|
||||||
|
return service.TaskView{}, fault.Invalid("available_at must be current/future and before the task deadline")
|
||||||
|
}
|
||||||
|
if in.StartDeadlineAt != nil && (!in.StartDeadlineAt.After(now) || in.StartDeadlineAt.Before(available) || in.StartDeadlineAt.After(deadline)) {
|
||||||
|
return service.TaskView{}, fault.Invalid("start_deadline_at must be after now, at or after available_at, and no later than the task deadline")
|
||||||
|
}
|
||||||
|
policy, err := json.Marshal(in.Retry)
|
||||||
|
if err != nil {
|
||||||
|
return service.TaskView{}, err
|
||||||
|
}
|
||||||
|
if err := reserveQueueTask(ctx, tx, ns, in.QueueID); err != nil {
|
||||||
|
return service.TaskView{}, err
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `INSERT INTO pulse_tasks(namespace_id,id,queue_id,handler,handler_version,payload_version,payload,policy_snapshot,recovery_safety,business_key,concurrency_key,concurrency_key_digest,priority,available_at,start_deadline_at,deadline_at,attempt_timeout_ms,max_attempts,created_at,updated_at,retry_of)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,NULLIF(?,''),?,?,?,?,?,?,?,?,?,?,?)`, ns, taskID, in.QueueID, in.Handler, in.HandlerVersion, in.PayloadVersion, string(in.Payload), string(policy), in.RecoverySafety, in.BusinessKey, concurrencyKeyValue(in.ConcurrencyKey), concurrencyDigest(in.ConcurrencyKey), in.Priority, available, in.StartDeadlineAt, deadline, in.Retry.AttemptTimeoutMS, in.Retry.MaxAttempts, now, now, retryOf)
|
||||||
|
if err != nil {
|
||||||
|
return service.TaskView{}, err
|
||||||
|
}
|
||||||
|
return readTask(ctx, tx, ns, taskID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskColumns = `id,namespace_id,queue_id,state,revision,handler,handler_version,payload_version,payload,result,COALESCE(business_key,''),COALESCE(concurrency_key,''),priority,available_at,start_deadline_at,deadline_at,recovery_safety,policy_snapshot,attempt_count,cancel_requested_at,completed_at,created_at,updated_at,effect_uncertain,retry_of`
|
||||||
|
|
||||||
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.ConcurrencyKey, &v.Priority, &v.AvailableAt, &v.StartDeadlineAt, &v.DeadlineAt, &v.RecoverySafety, &policy, &v.AttemptCount, &v.CancelRequestedAt, &v.CompletedAt, &v.CreatedAt, &v.UpdatedAt, &v.EffectUncertain)
|
err := row.Scan(&v.ID, &v.NamespaceID, &v.QueueID, &v.State, &v.Revision, &v.Handler, &v.HandlerVersion, &v.PayloadVersion, &payload, &result, &v.BusinessKey, &v.ConcurrencyKey, &v.Priority, &v.AvailableAt, &v.StartDeadlineAt, &v.DeadlineAt, &v.RecoverySafety, &policy, &v.AttemptCount, &v.CancelRequestedAt, &v.CompletedAt, &v.CreatedAt, &v.UpdatedAt, &v.EffectUncertain, &v.RetryOf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return v, err
|
return v, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ func historyQuery(r *http.Request, paged bool) (service.HistoryPageInput, error)
|
|||||||
return in, nil
|
return in, nil
|
||||||
}
|
}
|
||||||
func registerHistory(mux *http.ServeMux, s *service.Service) {
|
func registerHistory(mux *http.ServeMux, s *service.Service) {
|
||||||
|
mux.HandleFunc("GET /v1/tasks/{task}/operations", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||||
|
in, err := historyQuery(r, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out, err := s.ListTaskOperations(r.Context(), t, core.ID(r.PathValue("task")), in)
|
||||||
|
return out, 200, err
|
||||||
|
}))
|
||||||
|
mux.HandleFunc("GET /v1/tasks/{task}/operations/{operation}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||||
|
if _, err := historyQuery(r, false); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out, err := s.GetTaskOperation(r.Context(), t, core.ID(r.PathValue("task")), core.ID(r.PathValue("operation")))
|
||||||
|
return out, 200, err
|
||||||
|
}))
|
||||||
mux.HandleFunc("GET /v1/tasks/{task}/attempts", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
mux.HandleFunc("GET /v1/tasks/{task}/attempts", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||||
in, err := historyQuery(r, true)
|
in, err := historyQuery(r, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
39
internal/transport/httpserver/recovery_test.go
Normal file
39
internal/transport/httpserver/recovery_test.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecoveryJSONRequiresExplicitConfirmation(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
body string
|
||||||
|
retry, valid bool
|
||||||
|
}{
|
||||||
|
{`{"execution_settled":true,"expected_revision":"9007199254740993"}`, false, true},
|
||||||
|
{`{"execution_settled":false}`, false, true},
|
||||||
|
{`{}`, false, false},
|
||||||
|
{`{"execution_settled":null}`, false, false},
|
||||||
|
{`{"ExecutionSettled":true}`, false, false},
|
||||||
|
{`{"execution_settled":true,"actor_id":"spoof"}`, false, false},
|
||||||
|
{`{"execution_settled":true,"execution_settled":false}`, false, false},
|
||||||
|
{`{"execution_settled":true,"expected_revision":9007199254740993}`, false, false},
|
||||||
|
{`{"safe_to_retry":true}`, true, true},
|
||||||
|
{`{"safe_to_retry":null}`, true, false},
|
||||||
|
{`{}`, true, false},
|
||||||
|
{`{"safe_to_retry":true,"owner_id":"spoof"}`, true, false},
|
||||||
|
} {
|
||||||
|
r := httptest.NewRequest("POST", "/v1/tasks/example:review", strings.NewReader(tc.body))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
var in any = &service.ReviewInput{}
|
||||||
|
if tc.retry {
|
||||||
|
in = &service.ManualRetryInput{}
|
||||||
|
}
|
||||||
|
err := decodeBody(r, in)
|
||||||
|
if (err == nil) != tc.valid {
|
||||||
|
t.Fatalf("%s: %v", tc.body, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,30 @@ func registerTasks(mux *http.ServeMux, s *service.Service) {
|
|||||||
}))
|
}))
|
||||||
mux.HandleFunc("POST /v1/tasks/{command}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
mux.HandleFunc("POST /v1/tasks/{command}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||||
command := r.PathValue("command")
|
command := r.PathValue("command")
|
||||||
|
if strings.HasSuffix(command, ":review") {
|
||||||
|
var in service.ReviewInput
|
||||||
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
key, err := idempotencyKey(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out, err := s.ReviewTask(r.Context(), t, core.ID(strings.TrimSuffix(command, ":review")), key, in)
|
||||||
|
return out, 200, err
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(command, ":retry") {
|
||||||
|
var in service.ManualRetryInput
|
||||||
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
key, err := idempotencyKey(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out, err := s.RetryTask(r.Context(), t, core.ID(strings.TrimSuffix(command, ":retry")), key, in)
|
||||||
|
return out, createdStatus(out.Deduplicated), err
|
||||||
|
}
|
||||||
if !strings.HasSuffix(command, ":cancel") {
|
if !strings.HasSuffix(command, ":cancel") {
|
||||||
return nil, 0, fault.NotFound
|
return nil, 0, fault.NotFound
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ type Permissions struct {
|
|||||||
Queues []string `json:"queues"`
|
Queues []string `json:"queues"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var supported = map[string]bool{"events:read": true, "events:manage": true, "events:consume": true, "application:resources:read": true, "application:resources:manage": true, "tasks:submit": true, "tasks:read": true, "tasks:cancel": true, "queues:read": true, "queues:manage": true, "workers:connect": true, "workers:consume": true, "schedules:read": true, "schedules:manage": true}
|
var supported = map[string]bool{"tasks:review": true, "tasks:retry": true, "tasks:audit:read": true, "events:read": true, "events:manage": true, "events:consume": true, "application:resources:read": true, "application:resources:manage": true, "tasks:submit": true, "tasks:read": true, "tasks:cancel": true, "queues:read": true, "queues:manage": true, "workers:connect": true, "workers:consume": true, "schedules:read": true, "schedules:manage": true}
|
||||||
|
|
||||||
func (p Permissions) Validate() error {
|
func (p Permissions) Validate() error {
|
||||||
if len(p.Actions) == 0 || len(p.Actions) > 32 || len(p.Queues) == 0 || len(p.Queues) > 128 {
|
if len(p.Actions) == 0 || len(p.Actions) > 32 || len(p.Queues) == 0 || len(p.Queues) > 128 {
|
||||||
|
|||||||
@@ -536,6 +536,9 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"maximum": 9
|
"maximum": 9
|
||||||
|
},
|
||||||
|
"retry_of": {
|
||||||
|
"$ref": "#/$defs/id"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ type Task struct {
|
|||||||
LeaseUntil *time.Time `db:"lease_until"`
|
LeaseUntil *time.Time `db:"lease_until"`
|
||||||
// CancelRequestedAt 取消意图的持久化时间;不等于取消已完成;可为空,表示尚未设置该关联或值。
|
// CancelRequestedAt 取消意图的持久化时间;不等于取消已完成;可为空,表示尚未设置该关联或值。
|
||||||
CancelRequestedAt *time.Time `db:"cancel_requested_at"`
|
CancelRequestedAt *time.Time `db:"cancel_requested_at"`
|
||||||
// RetryOf 来源记录标识,人工重新发起时用于保留历史关联;可为空,表示尚未设置该关联或值。
|
// RetryOf 人工重试的原任务标识,同命名空间每个原任务最多一个直接后继;为空表示非人工派生。
|
||||||
RetryOf *core.ID `db:"retry_of"`
|
RetryOf *core.ID `db:"retry_of"`
|
||||||
// CompletedAt 终结时间;非终态为空,进入终态时必须写入。
|
// CompletedAt 终结时间;非终态为空,进入终态时必须写入。
|
||||||
CompletedAt *time.Time `db:"completed_at"`
|
CompletedAt *time.Time `db:"completed_at"`
|
||||||
|
|||||||
178
pulse/service/recovery.go
Normal file
178
pulse/service/recovery.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReviewInput 人工核查未知效果;声明与证据由获授权的核查主体负责。
|
||||||
|
type ReviewInput struct {
|
||||||
|
// ExpectedRevision 当前待核查任务版本,非零;重报保留原版本。
|
||||||
|
ExpectedRevision uint64 `json:"expected_revision,string"`
|
||||||
|
// Reason 核查原因与结论说明,最多1024个Unicode字符。
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
// EvidenceRef 受控证据引用,1至512个无空白/控制字符的Unicode字符;平台不请求该地址。
|
||||||
|
EvidenceRef string `json:"evidence_ref"`
|
||||||
|
// ExecutionSettled 必须显式为true,声明已核实全部历史执行已结束且不会继续产生效果。
|
||||||
|
ExecutionSettled bool `json:"execution_settled" required:"true"`
|
||||||
|
// Outcome 核查结论,仅SUCCEEDED或FAILED;不能直接重开旧任务。
|
||||||
|
Outcome pulse.TaskState `json:"outcome"`
|
||||||
|
// Result 非空JSON结论或结果引用,最大256 KiB,保留精确数值。
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReviewResult struct {
|
||||||
|
// Task 核查提交时的任务快照;当前状态另行查询。
|
||||||
|
Task TaskView `json:"task"`
|
||||||
|
// AuditID 不可变核查审计记录标识,可通过任务操作历史读取。
|
||||||
|
AuditID core.ID `json:"audit_id"`
|
||||||
|
// Deduplicated 是否返回原核查操作回执。
|
||||||
|
Deduplicated bool `json:"deduplicated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManualRetryInput 为已确认失败/取消的独立任务建立新预算,不修改原任务历史。
|
||||||
|
type ManualRetryInput struct {
|
||||||
|
// ExpectedRevision 原任务的当前版本,重报保持原值。
|
||||||
|
ExpectedRevision uint64 `json:"expected_revision,string"`
|
||||||
|
// Reason 人工重新执行的原因,最多1024个Unicode字符。
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
// EvidenceRef 重新执行的安全依据引用,最多512个字符,不包含秘密。
|
||||||
|
EvidenceRef string `json:"evidence_ref"`
|
||||||
|
// SafeToRetry 必须显式为true,声明旧执行已结束且按原业务身份重新执行安全。
|
||||||
|
SafeToRetry bool `json:"safe_to_retry" required:"true"`
|
||||||
|
// TaskTimeoutMS 新任务从受理起的总时限,必须显式指定,最多30天。
|
||||||
|
TaskTimeoutMS uint64 `json:"task_timeout_ms"`
|
||||||
|
// AvailableAt 新任务最早开始时间,空表示立即;不复用旧任务的过期绝对时间。
|
||||||
|
AvailableAt *time.Time `json:"available_at,omitempty"`
|
||||||
|
// StartDeadlineAt 新任务最晚开始时间,空表示使用新的总截止时间。
|
||||||
|
StartDeadlineAt *time.Time `json:"start_deadline_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManualRetryResult struct {
|
||||||
|
// SourceTaskID 被人工重新发起的原任务标识,其终态与尝试历史保持不变。
|
||||||
|
SourceTaskID core.ID `json:"source_task_id"`
|
||||||
|
// Task 新任务的原始受理快照,RetryOf指回原任务。
|
||||||
|
Task TaskView `json:"task"`
|
||||||
|
// AuditID 原任务上的人工重试审计标识,包含新任务关联。
|
||||||
|
AuditID core.ID `json:"audit_id"`
|
||||||
|
// Deduplicated 是否返回原人工重试回执。
|
||||||
|
Deduplicated bool `json:"deduplicated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskOperationSummary struct {
|
||||||
|
// ID 不可改写的审计记录标识。
|
||||||
|
ID core.ID `json:"id"`
|
||||||
|
// TaskID 本页授权任务标识。
|
||||||
|
TaskID core.ID `json:"task_id"`
|
||||||
|
// OperationID 服务端管理操作身份,不是调用方原始幂等键。
|
||||||
|
OperationID core.ID `json:"operation_id"`
|
||||||
|
// ActorID 实际认证主体标识,不接受请求体自报。
|
||||||
|
ActorID string `json:"actor_id"`
|
||||||
|
// Action 管理动作,例如task.review、task.retry、task.cancel、task.result。
|
||||||
|
Action string `json:"action"`
|
||||||
|
// CreatedAt 不可变审计创建时间,UTC微秒精度。
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskOperationView struct {
|
||||||
|
TaskOperationSummary
|
||||||
|
// Reason 实际提交的操作原因。
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
// EvidenceRef 操作证据引用;非核查类操作可能为空。
|
||||||
|
EvidenceRef string `json:"evidence_ref,omitempty"`
|
||||||
|
// Before 操作前的受控状态摘要,不包含完整业务输入。
|
||||||
|
Before json.RawMessage `json:"before"`
|
||||||
|
// After 操作后摘要、核查声明或关联新任务,不包含凭据秘密。
|
||||||
|
After json.RawMessage `json:"after"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskOperationPage struct {
|
||||||
|
// Items 按创建时间及审计ID倒序排列,不返回原因与摘要正文。
|
||||||
|
Items []TaskOperationSummary `json:"items"`
|
||||||
|
// NextCursor 必须沿相同任务继续使用,空表示本次已无下一页。
|
||||||
|
NextCursor string `json:"next_cursor,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRecoveryControl(id core.ID, key string, revision uint64, reason, evidence string) error {
|
||||||
|
if id.Validate() != nil {
|
||||||
|
return fault.Invalid("invalid task id")
|
||||||
|
}
|
||||||
|
if err := ValidateKey(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if revision == 0 || !utf8.ValidString(reason) || strings.TrimSpace(reason) == "" || utf8.RuneCountInString(reason) > 1024 || strings.ContainsRune(reason, 0) {
|
||||||
|
return fault.Invalid("reason and nonzero expected_revision are required")
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(evidence) || len(evidence) == 0 || utf8.RuneCountInString(evidence) > 512 {
|
||||||
|
return fault.Invalid("evidence_ref must contain 1 to 512 characters")
|
||||||
|
}
|
||||||
|
for _, r := range evidence {
|
||||||
|
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||||
|
return fault.Invalid("evidence_ref must not contain whitespace or control characters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReviewTask(ctx context.Context, t auth.Token, id core.ID, key string, in ReviewInput) (ReviewResult, error) {
|
||||||
|
if err := validateRecoveryControl(id, key, in.ExpectedRevision, in.Reason, in.EvidenceRef); err != nil {
|
||||||
|
return ReviewResult{}, err
|
||||||
|
}
|
||||||
|
if !in.ExecutionSettled || (in.Outcome != pulse.TaskSucceeded && in.Outcome != pulse.TaskFailed) {
|
||||||
|
return ReviewResult{}, fault.Invalid("execution_settled=true and SUCCEEDED or FAILED outcome are required")
|
||||||
|
}
|
||||||
|
if len(in.Result) == 0 || len(in.Result) > 256<<10 || bytes.Equal(bytes.TrimSpace(in.Result), []byte("null")) {
|
||||||
|
return ReviewResult{}, fault.Invalid("result must be non-null JSON up to 256 KiB")
|
||||||
|
}
|
||||||
|
if _, err := jsonvalue.Canonical(in.Result); err != nil {
|
||||||
|
return ReviewResult{}, fault.Invalid("invalid review result")
|
||||||
|
}
|
||||||
|
return s.store.ReviewTask(ctx, t, id, key, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RetryTask(ctx context.Context, t auth.Token, id core.ID, key string, in ManualRetryInput) (ManualRetryResult, error) {
|
||||||
|
if err := validateRecoveryControl(id, key, in.ExpectedRevision, in.Reason, in.EvidenceRef); err != nil {
|
||||||
|
return ManualRetryResult{}, err
|
||||||
|
}
|
||||||
|
if !in.SafeToRetry || in.TaskTimeoutMS == 0 || in.TaskTimeoutMS > 30*24*60*60*1000 {
|
||||||
|
return ManualRetryResult{}, fault.Invalid("safe_to_retry=true and task_timeout_ms between 1 and 2592000000 are required")
|
||||||
|
}
|
||||||
|
for _, field := range []**time.Time{&in.AvailableAt, &in.StartDeadlineAt} {
|
||||||
|
if *field != nil {
|
||||||
|
v := (**field).UTC()
|
||||||
|
if v.Year() < 2000 || v.Year() > 9998 || v.Nanosecond()%1000 != 0 {
|
||||||
|
return ManualRetryResult{}, fault.Invalid("times must use supported years and microsecond precision")
|
||||||
|
}
|
||||||
|
*field = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.store.RetryTask(ctx, t, id, key, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListTaskOperations(ctx context.Context, t auth.Token, id core.ID, in HistoryPageInput) (TaskOperationPage, error) {
|
||||||
|
if err := validateHistoryIDs(id); err != nil {
|
||||||
|
return TaskOperationPage{}, err
|
||||||
|
}
|
||||||
|
in, err := normalizeHistoryPage(in)
|
||||||
|
if err != nil {
|
||||||
|
return TaskOperationPage{}, err
|
||||||
|
}
|
||||||
|
return s.store.ListTaskOperations(ctx, t, id, in)
|
||||||
|
}
|
||||||
|
func (s *Service) GetTaskOperation(ctx context.Context, t auth.Token, id, operation core.ID) (TaskOperationView, error) {
|
||||||
|
if err := validateHistoryIDs(id, operation); err != nil {
|
||||||
|
return TaskOperationView{}, err
|
||||||
|
}
|
||||||
|
return s.store.GetTaskOperation(ctx, t, id, operation)
|
||||||
|
}
|
||||||
76
pulse/service/recovery_test.go
Normal file
76
pulse/service/recovery_test.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recoveryValidationStore struct {
|
||||||
|
Store
|
||||||
|
reviews, retries int
|
||||||
|
retry ManualRetryInput
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *recoveryValidationStore) ReviewTask(context.Context, auth.Token, core.ID, string, ReviewInput) (ReviewResult, error) {
|
||||||
|
s.reviews++
|
||||||
|
return ReviewResult{}, nil
|
||||||
|
}
|
||||||
|
func (s *recoveryValidationStore) RetryTask(_ context.Context, _ auth.Token, _ core.ID, _ string, in ManualRetryInput) (ManualRetryResult, error) {
|
||||||
|
s.retries++
|
||||||
|
s.retry = in
|
||||||
|
return ManualRetryResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryRequiresExplicitEvidenceAndConfirmation(t *testing.T) {
|
||||||
|
id := core.ID(strings.Repeat("1", 32))
|
||||||
|
s := &recoveryValidationStore{}
|
||||||
|
api := New(s)
|
||||||
|
base := ReviewInput{ExpectedRevision: 7, Reason: "verified externally", EvidenceRef: "evidence:record-1", ExecutionSettled: true, Outcome: pulse.TaskFailed, Result: json.RawMessage(`{"n":9007199254740993}`)}
|
||||||
|
for name, change := range map[string]func(*ReviewInput){
|
||||||
|
"no revision": func(v *ReviewInput) { v.ExpectedRevision = 0 },
|
||||||
|
"blank reason": func(v *ReviewInput) { v.Reason = " " },
|
||||||
|
"large reason": func(v *ReviewInput) { v.Reason = strings.Repeat("x", 1025) },
|
||||||
|
"empty evidence": func(v *ReviewInput) { v.EvidenceRef = "" },
|
||||||
|
"secret whitespace": func(v *ReviewInput) { v.EvidenceRef = "receipt:\nvalue" },
|
||||||
|
"too long evidence": func(v *ReviewInput) { v.EvidenceRef = strings.Repeat("x", 513) },
|
||||||
|
"unsettled": func(v *ReviewInput) { v.ExecutionSettled = false },
|
||||||
|
"reopen": func(v *ReviewInput) { v.Outcome = pulse.TaskWaiting },
|
||||||
|
"cancel": func(v *ReviewInput) { v.Outcome = pulse.TaskCancelled },
|
||||||
|
"null": func(v *ReviewInput) { v.Result = json.RawMessage(`null`) },
|
||||||
|
"duplicate fields": func(v *ReviewInput) { v.Result = json.RawMessage(`{"n":1,"n":2}`) },
|
||||||
|
"large result": func(v *ReviewInput) { v.Result = json.RawMessage(`"` + strings.Repeat("a", 256<<10) + `"`) },
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
in := base
|
||||||
|
change(&in)
|
||||||
|
if _, err := api.ReviewTask(context.Background(), auth.Token{}, id, "review", in); fault.Code(err) != "INVALID_ARGUMENT" {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if s.reviews != 0 {
|
||||||
|
t.Fatal("invalid review reached persistence")
|
||||||
|
}
|
||||||
|
if _, err := api.ReviewTask(context.Background(), auth.Token{}, id, "review", base); err != nil || s.reviews != 1 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
in := ManualRetryInput{ExpectedRevision: 7, Reason: "verified retry", EvidenceRef: "evidence:retry", SafeToRetry: true, TaskTimeoutMS: 30000}
|
||||||
|
for _, bad := range []ManualRetryInput{{ExpectedRevision: 7, Reason: in.Reason, EvidenceRef: in.EvidenceRef, TaskTimeoutMS: 30000}, {ExpectedRevision: 7, Reason: in.Reason, EvidenceRef: in.EvidenceRef, SafeToRetry: true}, {ExpectedRevision: 7, Reason: in.Reason, EvidenceRef: in.EvidenceRef, SafeToRetry: true, TaskTimeoutMS: 30*86400000 + 1}} {
|
||||||
|
if _, err := api.RetryTask(context.Background(), auth.Token{}, id, "retry", bad); fault.Code(err) != "INVALID_ARGUMENT" {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tm := time.Date(2026, 9, 17, 12, 0, 0, 0, time.FixedZone("example", 8*60*60))
|
||||||
|
in.AvailableAt = &tm
|
||||||
|
if _, err := api.RetryTask(context.Background(), auth.Token{}, id, "retry", in); err != nil || s.retries != 1 || s.retry.AvailableAt.Location() != time.UTC {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ type SubmitInput struct {
|
|||||||
|
|
||||||
// TaskView 是对外任务快照,不包含 Worker 令牌或凭据摘要。
|
// TaskView 是对外任务快照,不包含 Worker 令牌或凭据摘要。
|
||||||
type TaskView struct {
|
type TaskView struct {
|
||||||
|
// RetryOf 人工重新发起时的原任务标识;为空表示不是人工派生任务。
|
||||||
|
RetryOf *core.ID `json:"retry_of,omitempty"`
|
||||||
// ID 资源标识,32 位小写十六进制字符串。
|
// ID 资源标识,32 位小写十六进制字符串。
|
||||||
ID core.ID `json:"id"`
|
ID core.ID `json:"id"`
|
||||||
// NamespaceID 命名空间标识,由凭据确定隔离范围。
|
// NamespaceID 命名空间标识,由凭据确定隔离范围。
|
||||||
@@ -216,6 +218,10 @@ type QueueResult struct {
|
|||||||
|
|
||||||
// Store 实现命名空间隔离、事务授权与持久化,不在事务外产生业务副作用。
|
// Store 实现命名空间隔离、事务授权与持久化,不在事务外产生业务副作用。
|
||||||
type Store interface {
|
type Store interface {
|
||||||
|
ReviewTask(context.Context, auth.Token, core.ID, string, ReviewInput) (ReviewResult, error)
|
||||||
|
RetryTask(context.Context, auth.Token, core.ID, string, ManualRetryInput) (ManualRetryResult, error)
|
||||||
|
ListTaskOperations(context.Context, auth.Token, core.ID, HistoryPageInput) (TaskOperationPage, error)
|
||||||
|
GetTaskOperation(context.Context, auth.Token, core.ID, core.ID) (TaskOperationView, error)
|
||||||
AuthorizeSubmit(context.Context, auth.Token) error
|
AuthorizeSubmit(context.Context, auth.Token) error
|
||||||
Submit(context.Context, auth.Token, string, SubmitInput) (SubmitResult, error)
|
Submit(context.Context, auth.Token, string, SubmitInput) (SubmitResult, error)
|
||||||
GetTask(context.Context, auth.Token, core.ID) (TaskView, error)
|
GetTask(context.Context, auth.Token, core.ID) (TaskView, error)
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ 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/subscriptions)
|
(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/subscriptions)
|
||||||
(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/event-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/event-protocol)
|
||||||
|
|
||||||
|
# 人工核查/重试权限单独签发,普通提交与Worker凭据默认不具备这些管理权限。
|
||||||
|
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap --application sdk-recovery --namespace default --environment test --queue recovery --worker-access --recovery-access --output "$TASK_TEST_DIR/recovery.credentials.json"
|
||||||
|
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/recovery.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationTaskRecoverySDK$' -v .)
|
||||||
|
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/recovery.credentials.json" "$TASK_GO" run ./examples/recovery)
|
||||||
|
|
||||||
# 接收方业务数据独立建库;数据库驱动仅属于此示例模块,不加入核心SDK。
|
# 接收方业务数据独立建库;数据库驱动仅属于此示例模块,不加入核心SDK。
|
||||||
"$TASK_DOCKER" exec "$TASK_TEST_NAME" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=tcp -e "CREATE DATABASE orbit_receiver_test CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"'
|
"$TASK_DOCKER" exec "$TASK_TEST_NAME" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=tcp -e "CREATE DATABASE orbit_receiver_test CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"'
|
||||||
export ORBIT_TEST_RECEIVER_DSN="root:$TASK_DB_PASSWORD@tcp(127.0.0.1:$TASK_DB_PORT)/orbit_receiver_test"
|
export ORBIT_TEST_RECEIVER_DSN="root:$TASK_DB_PASSWORD@tcp(127.0.0.1:$TASK_DB_PORT)/orbit_receiver_test"
|
||||||
|
|||||||
182
sdk/go/examples/recovery/main.go
Normal file
182
sdk/go/examples/recovery/main.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// recovery 演示未知结果、人工核查、关联新任务和不可变审计;只处理本地模拟输入。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"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 c struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
QueueID string `json:"queue_id"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &c) != nil {
|
||||||
|
return errors.New("invalid credentials file")
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
token = c.Token
|
||||||
|
}
|
||||||
|
if queue == "" {
|
||||||
|
queue = c.QueueID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
op, err := orbit.NewWorkerMessageID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
handler := "recovery.inspect." + op
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
accepted, err := client.SubmitTask(ctx, "recovery-"+op, orbit.SubmitTaskRequest{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"object_ref":"examples/input"}`), TaskTimeoutMS: 60000, Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 10000}})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
registration, err := orbit.NewWorkerRegistration(queue, 1, []orbit.WorkerCapability{{Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1"}})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w, err := client.DialWorker(ctx, registration)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer w.Close()
|
||||||
|
nextID := func() (string, error) { return orbit.NewWorkerMessageID() }
|
||||||
|
id, err := nextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Capacity(ctx, id, orbit.WorkerCapacity{Sequence: 1, Capacity: 1}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a, err := w.NextAssignment(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if a.Task.ID != accepted.Task.ID {
|
||||||
|
return errors.New("unexpected task")
|
||||||
|
}
|
||||||
|
id, err = nextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
started, err := w.Start(ctx, id, a.Attempt())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, active := started.Lease.ExecutionDeadline(); !active {
|
||||||
|
return errors.New("missing current execution authority")
|
||||||
|
}
|
||||||
|
// 本例不操作外部系统:模拟“旧执行已结束,但最初无法判断结果”的场景。
|
||||||
|
id, err = nextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Finish(ctx, id, orbit.WorkerFinish{Ref: a.Attempt(), Outcome: orbit.WorkerUnknown, Result: json.RawMessage(`{"status":"unknown"}`), ErrorCode: "EXAMPLE_UNCERTAIN"}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
current, err := client.GetTask(ctx, a.Task.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if current.State != orbit.TaskNeedsReview {
|
||||||
|
return errors.New("expected manual review")
|
||||||
|
}
|
||||||
|
// 生产中先核查旧执行和外部效果,保存真实证据,再由专门管理凭据提交此结论。
|
||||||
|
// 每次操作将幂等键与完整请求持久化;响应丢失时保持原键、版本、结论与证据重报。
|
||||||
|
reviewKey := "review-" + op
|
||||||
|
reviewInput := orbit.ReviewTaskRequest{ExpectedRevision: current.Revision, Reason: "模拟旧执行已结束,确认操作未完成", EvidenceRef: "evidence:example-reconciliation", ExecutionSettled: true, Outcome: orbit.TaskFailed, Result: json.RawMessage(`{"confirmed":true,"sequence":9007199254740993}`)}
|
||||||
|
reviewed, err := client.ReviewTask(ctx, current.ID, reviewKey, reviewInput)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
replay, err := client.ReviewTask(ctx, current.ID, reviewKey, reviewInput)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !replay.Deduplicated || replay.AuditID != reviewed.AuditID {
|
||||||
|
return errors.New("review identity changed")
|
||||||
|
}
|
||||||
|
// 人工重试保留原任务,另建同业务身份的新任务,并显式给出新的总时间预算。
|
||||||
|
retryKey := "retry-" + op
|
||||||
|
retryInput := orbit.RetryTaskRequest{ExpectedRevision: reviewed.Task.Revision, Reason: "已核实模拟任务可安全重新执行", EvidenceRef: "evidence:example-safe-retry", SafeToRetry: true, TaskTimeoutMS: 60000}
|
||||||
|
retried, err := client.RetryTask(ctx, current.ID, retryKey, retryInput)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a, err = w.NextAssignment(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if a.Task.ID != retried.Task.ID || a.Task.RetryOf == nil || *a.Task.RetryOf != current.ID {
|
||||||
|
return errors.New("missing retry lineage")
|
||||||
|
}
|
||||||
|
id, err = nextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
started, err = w.Start(ctx, id, a.Attempt())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, active := started.Lease.ExecutionDeadline(); !active {
|
||||||
|
return errors.New("missing retry execution authority")
|
||||||
|
}
|
||||||
|
id, err = nextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Finish(ctx, id, orbit.WorkerFinish{Ref: a.Attempt(), Outcome: orbit.WorkerSucceeded, Result: json.RawMessage(`{"object_ref":"examples/output"}`)}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
audit, err := client.GetTaskOperation(ctx, current.ID, retried.AuditID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
page, err := client.ListTaskOperations(ctx, current.ID, orbit.HistoryPageRequest{Limit: 20})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
original, err := client.GetTask(ctx, current.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
child, err := client.GetTask(ctx, retried.Task.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if original.State != orbit.TaskFailed || child.State != orbit.TaskSucceeded || audit.Action != "task.retry" {
|
||||||
|
return errors.New("unexpected recovery result")
|
||||||
|
}
|
||||||
|
id, err = nextID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Capacity(ctx, id, orbit.WorkerCapacity{Sequence: 2, Drain: true}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("source=%s state=%s retry=%s state=%s audit=%s operations=%d\n", original.ID, original.State, child.ID, child.State, audit.ID, len(page.Items))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
143
sdk/go/recovery.go
Normal file
143
sdk/go/recovery.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package orbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReviewTaskRequest 人工核查未知效果;声明与证据由获授权的核查主体负责。
|
||||||
|
type ReviewTaskRequest struct {
|
||||||
|
// ExpectedRevision 当前待核查任务版本,非零;重报保留原版本。
|
||||||
|
ExpectedRevision uint64 `json:"expected_revision,string"`
|
||||||
|
// Reason 核查原因与结论说明,最多1024个Unicode字符。
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
// EvidenceRef 受控证据引用,1至512个无空白/控制字符的Unicode字符;平台不请求该地址。
|
||||||
|
EvidenceRef string `json:"evidence_ref"`
|
||||||
|
// ExecutionSettled 必须显式为true,声明已核实全部历史执行已结束且不会继续产生效果。
|
||||||
|
ExecutionSettled bool `json:"execution_settled"`
|
||||||
|
// Outcome 核查结论,仅SUCCEEDED或FAILED;不能直接重开旧任务。
|
||||||
|
Outcome TaskState `json:"outcome"`
|
||||||
|
// Result 非空JSON结论或结果引用,最大256 KiB,保留精确数值。
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReviewTaskResult struct {
|
||||||
|
// Task 核查提交时的任务快照;当前状态另行查询。
|
||||||
|
Task Task `json:"task"`
|
||||||
|
// AuditID 不可变核查审计记录标识,可通过任务操作历史读取。
|
||||||
|
AuditID string `json:"audit_id"`
|
||||||
|
// Deduplicated 是否返回原核查操作回执。
|
||||||
|
Deduplicated bool `json:"deduplicated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryTaskRequest 为已确认失败/取消的独立任务建立新预算,不修改原任务历史。
|
||||||
|
type RetryTaskRequest struct {
|
||||||
|
// ExpectedRevision 原任务的当前版本,重报保持原值。
|
||||||
|
ExpectedRevision uint64 `json:"expected_revision,string"`
|
||||||
|
// Reason 人工重新执行的原因,最多1024个Unicode字符。
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
// EvidenceRef 重新执行的安全依据引用,最多512个字符,不包含秘密。
|
||||||
|
EvidenceRef string `json:"evidence_ref"`
|
||||||
|
// SafeToRetry 必须显式为true,声明旧执行已结束且按原业务身份重新执行安全。
|
||||||
|
SafeToRetry bool `json:"safe_to_retry"`
|
||||||
|
// TaskTimeoutMS 新任务从受理起的总时限,必须显式指定,最多30天。
|
||||||
|
TaskTimeoutMS uint64 `json:"task_timeout_ms"`
|
||||||
|
// AvailableAt 新任务最早开始时间,空表示立即;不复用旧任务的过期绝对时间。
|
||||||
|
AvailableAt *time.Time `json:"available_at,omitempty"`
|
||||||
|
// StartDeadlineAt 新任务最晚开始时间,空表示使用新的总截止时间。
|
||||||
|
StartDeadlineAt *time.Time `json:"start_deadline_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RetryTaskResult struct {
|
||||||
|
// SourceTaskID 被人工重新发起的原任务标识,其终态与尝试历史保持不变。
|
||||||
|
SourceTaskID string `json:"source_task_id"`
|
||||||
|
// Task 新任务的原始受理快照,RetryOf指回原任务。
|
||||||
|
Task Task `json:"task"`
|
||||||
|
// AuditID 原任务上的人工重试审计标识,包含新任务关联。
|
||||||
|
AuditID string `json:"audit_id"`
|
||||||
|
// Deduplicated 是否返回原人工重试回执。
|
||||||
|
Deduplicated bool `json:"deduplicated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskOperationSummary struct {
|
||||||
|
// ID 不可改写的审计记录标识。
|
||||||
|
ID string `json:"id"`
|
||||||
|
// TaskID 本页授权任务标识。
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
// OperationID 服务端管理操作身份,不是调用方原始幂等键。
|
||||||
|
OperationID string `json:"operation_id"`
|
||||||
|
// ActorID 实际认证主体标识,不接受请求体自报。
|
||||||
|
ActorID string `json:"actor_id"`
|
||||||
|
// Action 管理动作,例如task.review、task.retry、task.cancel、task.result。
|
||||||
|
Action string `json:"action"`
|
||||||
|
// CreatedAt 不可变审计创建时间,UTC微秒精度。
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskOperation struct {
|
||||||
|
TaskOperationSummary
|
||||||
|
// Reason 实际提交的操作原因。
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
// EvidenceRef 操作证据引用;非核查类操作可能为空。
|
||||||
|
EvidenceRef string `json:"evidence_ref,omitempty"`
|
||||||
|
// Before 操作前的受控状态摘要,不包含完整业务输入。
|
||||||
|
Before json.RawMessage `json:"before"`
|
||||||
|
// After 操作后摘要、核查声明或关联新任务,不包含凭据秘密。
|
||||||
|
After json.RawMessage `json:"after"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskOperationPage struct {
|
||||||
|
// Items 按创建时间及审计ID倒序排列,不返回原因与摘要正文。
|
||||||
|
Items []TaskOperationSummary `json:"items"`
|
||||||
|
// NextCursor 必须沿相同任务继续使用,空表示本次已无下一页。
|
||||||
|
NextCursor string `json:"next_cursor,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewTask 需要显式核查权限;超时重报必须保留原键、原版本、结论与证据。
|
||||||
|
func (c *Client) ReviewTask(ctx context.Context, id, key string, in ReviewTaskRequest) (ReviewTaskResult, error) {
|
||||||
|
var out ReviewTaskResult
|
||||||
|
if !validID(id) {
|
||||||
|
return out, errors.New("invalid task id")
|
||||||
|
}
|
||||||
|
err := c.call(ctx, "POST", "/v1/tasks/"+id+":review", key, in, &out)
|
||||||
|
if err == nil && (!validID(out.AuditID) || out.Task.ID != id || out.Task.State != in.Outcome || out.Task.EffectUncertain) {
|
||||||
|
return ReviewTaskResult{}, errors.New("invalid task review receipt; reconcile using original identity")
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryTask 创建与原任务关联的新任务;不会修改原任务的终态或重置原尝试预算。
|
||||||
|
func (c *Client) RetryTask(ctx context.Context, id, key string, in RetryTaskRequest) (RetryTaskResult, error) {
|
||||||
|
var out RetryTaskResult
|
||||||
|
if !validID(id) {
|
||||||
|
return out, errors.New("invalid task id")
|
||||||
|
}
|
||||||
|
err := c.call(ctx, "POST", "/v1/tasks/"+id+":retry", key, in, &out)
|
||||||
|
if err == nil && (!validID(out.AuditID) || out.SourceTaskID != id || !validID(out.Task.ID) || out.Task.ID == id || out.Task.RetryOf == nil || *out.Task.RetryOf != id) {
|
||||||
|
return RetryTaskResult{}, errors.New("invalid task retry receipt; reconcile using original identity")
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListTaskOperations(ctx context.Context, id string, in HistoryPageRequest) (TaskOperationPage, error) {
|
||||||
|
var out TaskOperationPage
|
||||||
|
if !validID(id) {
|
||||||
|
return out, errors.New("invalid task id")
|
||||||
|
}
|
||||||
|
query, err := historyPageQuery(in)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
err = c.call(ctx, "GET", "/v1/tasks/"+id+"/operations"+query, "", nil, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
func (c *Client) GetTaskOperation(ctx context.Context, id, operation string) (TaskOperation, error) {
|
||||||
|
var out TaskOperation
|
||||||
|
if !validID(id) || !validID(operation) {
|
||||||
|
return out, errors.New("invalid task operation id")
|
||||||
|
}
|
||||||
|
err := c.call(ctx, "GET", "/v1/tasks/"+id+"/operations/"+operation, "", nil, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
175
sdk/go/recovery_integration_test.go
Normal file
175
sdk/go/recovery_integration_test.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package orbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIntegrationTaskRecoverySDK(t *testing.T) {
|
||||||
|
api, queue, token := integrationWorkerClient(t)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
handler := "recovery." + workerTestID(t)
|
||||||
|
accepted, err := api.SubmitTask(ctx, workerTestID(t), SubmitTaskRequest{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", BusinessKey: "object:recovery-example", Payload: json.RawMessage(`{"sequence":9007199254740993}`), TaskTimeoutMS: 60000, Retry: RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 10000}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg, err := NewWorkerRegistration(queue, 1, []WorkerCapability{{Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w, err := api.DialWorker(ctx, reg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer w.Close()
|
||||||
|
if _, err = w.Capacity(ctx, workerTestID(t), WorkerCapacity{Sequence: 1, Capacity: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a, err := w.NextAssignment(ctx)
|
||||||
|
if err != nil || a.Task.ID != accepted.Task.ID {
|
||||||
|
t.Fatal(a, err)
|
||||||
|
}
|
||||||
|
if _, err = w.Start(ctx, workerTestID(t), a.Attempt()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = w.Finish(ctx, workerTestID(t), WorkerFinish{Ref: a.Attempt(), Outcome: WorkerUnknown, Result: json.RawMessage(`{"status":"unknown"}`), ErrorCode: "EXAMPLE_UNCERTAIN"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
unknown := waitRuntimeState(t, ctx, api, a.Task.ID, TaskNeedsReview)
|
||||||
|
oldAttempt, err := api.GetTaskAttempt(ctx, a.Task.ID, a.AttemptID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 后端完整提交后主动丢失HTTP响应。禁用连接复用,排除net/http对复用连接的隐式重发。
|
||||||
|
target, _ := url.Parse(api.baseURL)
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
var drop atomic.Bool
|
||||||
|
var calls atomic.Int32
|
||||||
|
var committed atomic.Value
|
||||||
|
proxy.ModifyResponse = func(r *http.Response) error {
|
||||||
|
calls.Add(1)
|
||||||
|
if !drop.CompareAndSwap(true, false) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw, e := io.ReadAll(r.Body)
|
||||||
|
r.Body.Close()
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if r.StatusCode != 200 && r.StatusCode != 201 {
|
||||||
|
t.Errorf("unexpected response before drop: %s", raw)
|
||||||
|
}
|
||||||
|
committed.Store(raw)
|
||||||
|
return io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
conn, _, e := w.(http.Hijacker).Hijack()
|
||||||
|
if e == nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(proxy)
|
||||||
|
defer server.Close()
|
||||||
|
transport := &http.Transport{DisableKeepAlives: true}
|
||||||
|
defer transport.CloseIdleConnections()
|
||||||
|
lost, err := NewClient(server.URL, Options{Token: token, HTTPClient: &http.Client{Transport: transport}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
review := ReviewTaskRequest{ExpectedRevision: unknown.Revision, Reason: "确认模拟执行已结束且未完成", EvidenceRef: "evidence:demo-reconciliation", ExecutionSettled: true, Outcome: TaskFailed, Result: json.RawMessage(`{"confirmed":true,"sequence":9007199254740993}`)}
|
||||||
|
reviewKey := workerTestID(t)
|
||||||
|
drop.Store(true)
|
||||||
|
if _, err = lost.ReviewTask(ctx, unknown.ID, reviewKey, review); err == nil || calls.Load() != 1 {
|
||||||
|
t.Fatal("review response loss was hidden", err)
|
||||||
|
}
|
||||||
|
var reviewEnvelope struct {
|
||||||
|
Data ReviewTaskResult `json:"data"`
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(committed.Load().([]byte), &reviewEnvelope); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reviewed, err := api.ReviewTask(ctx, unknown.ID, reviewKey, review)
|
||||||
|
if err != nil || !reviewed.Deduplicated || reviewed.AuditID != reviewEnvelope.Data.AuditID || reviewed.Task.State != TaskFailed || reviewed.Task.EffectUncertain {
|
||||||
|
t.Fatal(reviewed, err)
|
||||||
|
}
|
||||||
|
attempt, err := api.GetTaskAttempt(ctx, a.Task.ID, a.AttemptID)
|
||||||
|
if err != nil || !reflect.DeepEqual(attempt, oldAttempt) {
|
||||||
|
t.Fatal("review changed immutable attempt", err)
|
||||||
|
}
|
||||||
|
audit, err := api.GetTaskOperation(ctx, unknown.ID, reviewed.AuditID)
|
||||||
|
if err != nil || audit.Action != "task.review" || audit.EvidenceRef != review.EvidenceRef || audit.Reason != review.Reason {
|
||||||
|
t.Fatal(audit, err)
|
||||||
|
}
|
||||||
|
retry := RetryTaskRequest{ExpectedRevision: reviewed.Task.Revision, Reason: "已核实模拟旧执行不会再产生效果,允许重新执行", EvidenceRef: "evidence:demo-safe-retry", SafeToRetry: true, TaskTimeoutMS: 60000}
|
||||||
|
retryKey := workerTestID(t)
|
||||||
|
drop.Store(true)
|
||||||
|
if _, err = lost.RetryTask(ctx, unknown.ID, retryKey, retry); err == nil || calls.Load() != 2 {
|
||||||
|
t.Fatal("retry response loss was hidden", err)
|
||||||
|
}
|
||||||
|
var retryEnvelope struct {
|
||||||
|
Data RetryTaskResult `json:"data"`
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(committed.Load().([]byte), &retryEnvelope); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
child, err := api.RetryTask(ctx, unknown.ID, retryKey, retry)
|
||||||
|
if err != nil || !child.Deduplicated || child.Task.ID != retryEnvelope.Data.Task.ID || child.AuditID != retryEnvelope.Data.AuditID || child.Task.RetryOf == nil || *child.Task.RetryOf != unknown.ID {
|
||||||
|
t.Fatal(child, err)
|
||||||
|
}
|
||||||
|
assigned, err := w.NextAssignment(ctx)
|
||||||
|
if err != nil || assigned.Task.ID != child.Task.ID || assigned.Task.RetryOf == nil || *assigned.Task.RetryOf != unknown.ID {
|
||||||
|
t.Fatal("wire lost retry lineage", assigned, err)
|
||||||
|
}
|
||||||
|
if _, err = w.Start(ctx, workerTestID(t), assigned.Attempt()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = w.Finish(ctx, workerTestID(t), WorkerFinish{Ref: assigned.Attempt(), Outcome: WorkerSucceeded, Result: json.RawMessage(`{"object_ref":"examples/output"}`)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
waitRuntimeState(t, ctx, api, child.Task.ID, TaskSucceeded)
|
||||||
|
late, err := w.Finish(ctx, workerTestID(t), WorkerFinish{Ref: a.Attempt(), Outcome: WorkerSucceeded, Result: json.RawMessage(`{"late":true}`)})
|
||||||
|
if err != nil || late.Receipt.Disposition != "EVIDENCE_ONLY" {
|
||||||
|
t.Fatal(late, err)
|
||||||
|
}
|
||||||
|
source, err := api.GetTask(ctx, unknown.ID)
|
||||||
|
if err != nil || !reflect.DeepEqual(source, reviewed.Task) {
|
||||||
|
t.Fatal("late source rewrote review", err)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
actions := map[string]int{}
|
||||||
|
cursor := ""
|
||||||
|
for {
|
||||||
|
page, e := api.ListTaskOperations(ctx, unknown.ID, HistoryPageRequest{Limit: 1, Cursor: cursor})
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
for _, v := range page.Items {
|
||||||
|
if seen[v.ID] || v.TaskID != unknown.ID {
|
||||||
|
t.Fatal("duplicate/unscoped audit")
|
||||||
|
}
|
||||||
|
seen[v.ID] = true
|
||||||
|
actions[v.Action]++
|
||||||
|
}
|
||||||
|
if page.NextCursor == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cursor = page.NextCursor
|
||||||
|
}
|
||||||
|
if actions["task.review"] != 1 || actions["task.retry"] != 1 {
|
||||||
|
t.Fatal("response loss created duplicate operations", actions)
|
||||||
|
}
|
||||||
|
if _, err = w.Capacity(ctx, workerTestID(t), WorkerCapacity{Sequence: 2, Drain: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Log("real UNKNOWN -> reviewed FAILED -> linked new task SUCCEEDED; both committed HTTP receipts lost/recovered; old attempt and audit preserved; late report evidence only")
|
||||||
|
}
|
||||||
105
sdk/go/recovery_test.go
Normal file
105
sdk/go/recovery_test.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package orbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecoveryReceiptAndAuditBoundary(t *testing.T) {
|
||||||
|
const task = "11111111111111111111111111111111"
|
||||||
|
const child = "22222222222222222222222222222222"
|
||||||
|
const audit = "33333333333333333333333333333333"
|
||||||
|
var calls atomic.Int32
|
||||||
|
var malformed atomic.Bool
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
if r.Header.Get("Authorization") != "Bearer example-token" {
|
||||||
|
t.Error("missing token")
|
||||||
|
}
|
||||||
|
var data any
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/tasks/" + task + ":review":
|
||||||
|
if r.Method != "POST" || r.Header.Get("Idempotency-Key") != "stable-review" {
|
||||||
|
t.Error("review identity")
|
||||||
|
}
|
||||||
|
var in ReviewTaskRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.ExpectedRevision != 9007199254740993 || !in.ExecutionSettled {
|
||||||
|
t.Error("lost review fields", err)
|
||||||
|
}
|
||||||
|
out := ReviewTaskResult{AuditID: audit, Task: Task{ID: task, State: TaskFailed, Revision: 9007199254740994}}
|
||||||
|
if malformed.Load() {
|
||||||
|
out.Task.EffectUncertain = true
|
||||||
|
}
|
||||||
|
data = out
|
||||||
|
case "/v1/tasks/" + task + ":retry":
|
||||||
|
if r.Method != "POST" || r.Header.Get("Idempotency-Key") != "stable-retry" {
|
||||||
|
t.Error("retry identity")
|
||||||
|
}
|
||||||
|
parent := task
|
||||||
|
out := RetryTaskResult{AuditID: audit, SourceTaskID: task, Task: Task{ID: child, RetryOf: &parent}}
|
||||||
|
if malformed.Load() {
|
||||||
|
out.Task.RetryOf = nil
|
||||||
|
}
|
||||||
|
data = out
|
||||||
|
case "/v1/tasks/" + task + "/operations":
|
||||||
|
if r.Method != "GET" || r.Header.Get("Idempotency-Key") != "" || r.URL.Query().Get("cursor") != "opaque+cursor/=" {
|
||||||
|
t.Error("audit page request")
|
||||||
|
}
|
||||||
|
data = TaskOperationPage{Items: []TaskOperationSummary{{ID: audit, TaskID: task, Action: "task.review"}}}
|
||||||
|
case "/v1/tasks/" + task + "/operations/" + audit:
|
||||||
|
data = TaskOperation{TaskOperationSummary: TaskOperationSummary{ID: audit, TaskID: task}, After: json.RawMessage(`{"revision":"9007199254740994"}`)}
|
||||||
|
default:
|
||||||
|
t.Error("unexpected path", r.URL.Path)
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"request_id": audit, "data": data})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
c, _ := NewClient(server.URL, Options{Token: "example-token"})
|
||||||
|
ctx := context.Background()
|
||||||
|
in := ReviewTaskRequest{ExpectedRevision: 9007199254740993, ExecutionSettled: true, Outcome: TaskFailed}
|
||||||
|
review, err := c.ReviewTask(ctx, task, "stable-review", in)
|
||||||
|
if err != nil || review.Task.Revision != 9007199254740994 {
|
||||||
|
t.Fatal(review, err)
|
||||||
|
}
|
||||||
|
retry, err := c.RetryTask(ctx, task, "stable-retry", RetryTaskRequest{})
|
||||||
|
if err != nil || retry.Task.ID != child {
|
||||||
|
t.Fatal(retry, err)
|
||||||
|
}
|
||||||
|
page, err := c.ListTaskOperations(ctx, task, HistoryPageRequest{Cursor: "opaque+cursor/="})
|
||||||
|
if err != nil || len(page.Items) != 1 {
|
||||||
|
t.Fatal(page, err)
|
||||||
|
}
|
||||||
|
detail, err := c.GetTaskOperation(ctx, task, audit)
|
||||||
|
if err != nil || string(detail.After) != `{"revision":"9007199254740994"}` {
|
||||||
|
t.Fatal(detail, err)
|
||||||
|
}
|
||||||
|
malformed.Store(true)
|
||||||
|
if _, err = c.ReviewTask(ctx, task, "stable-review", in); err == nil {
|
||||||
|
t.Fatal("inconsistent review accepted")
|
||||||
|
}
|
||||||
|
if _, err = c.RetryTask(ctx, task, "stable-retry", RetryTaskRequest{}); err == nil {
|
||||||
|
t.Fatal("missing retry parent accepted")
|
||||||
|
}
|
||||||
|
for _, id := range []string{"../tasks", strings.Repeat("A", 32), "id?token=secret"} {
|
||||||
|
if _, err = c.ReviewTask(ctx, id, "key", in); err == nil {
|
||||||
|
t.Fatal("unsafe path")
|
||||||
|
}
|
||||||
|
if _, err = c.RetryTask(ctx, id, "key", RetryTaskRequest{}); err == nil {
|
||||||
|
t.Fatal("unsafe path")
|
||||||
|
}
|
||||||
|
if _, err = c.GetTaskOperation(ctx, task, id); err == nil {
|
||||||
|
t.Fatal("unsafe audit path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = c.ListTaskOperations(ctx, task, HistoryPageRequest{Limit: 101}); err == nil {
|
||||||
|
t.Fatal("unbounded page")
|
||||||
|
}
|
||||||
|
if calls.Load() != 6 {
|
||||||
|
t.Fatal("invalid path reached transport")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,8 @@ type SubmitTaskRequest struct {
|
|||||||
|
|
||||||
// Task 是对外任务快照,不包含 Worker 令牌或凭据摘要。
|
// Task 是对外任务快照,不包含 Worker 令牌或凭据摘要。
|
||||||
type Task struct {
|
type Task struct {
|
||||||
|
// RetryOf 人工重新发起时的原任务标识;为空表示不是人工派生任务。
|
||||||
|
RetryOf *string `json:"retry_of,omitempty"`
|
||||||
// ID 资源标识,32 位小写十六进制字符串。
|
// ID 资源标识,32 位小写十六进制字符串。
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
// NamespaceID 命名空间标识,由凭据确定隔离范围。
|
// NamespaceID 命名空间标识,由凭据确定隔离范围。
|
||||||
|
|||||||
@@ -399,6 +399,9 @@ func (w *WorkerConnection) command(ctx context.Context, op, id string, ref Worke
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *WorkerConnection) validAssignment(a WorkerAssignment) bool {
|
func (w *WorkerConnection) validAssignment(a WorkerAssignment) bool {
|
||||||
|
if a.Task.RetryOf != nil && (!validID(*a.Task.RetryOf) || *a.Task.RetryOf == a.Task.ID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if a.Task.Priority < 0 || a.Task.Priority > 9 {
|
if a.Task.Priority < 0 || a.Task.Priority > 9 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user