feat: add scoped task source tracing and SDK examples
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
- 结果日志的序号在独立命名空间流行锁下分配,与任务状态及原事件同事务提交;禁止用最大事件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;每项复用单项身份与事务,未知结果必须原键核对。任务提交按认证→应用额度→提交幂等身份→队列持锁,禁止恢复为先抢提交唯一键后等待额度锁;原回执仍须先于配额上限检查返回。
|
||||||
|
|
||||||
@@ -22,3 +22,5 @@
|
|||||||
- tasks:review、tasks:retry、tasks:audit:read是显式管理权限,普通提交/Worker凭据不自动获得。核查结果流锁最后获取,审计/回执/额度与状态同事务;恢复不得绕过Weave/计划所有权、门控及现有额度限制。
|
- tasks:review、tasks:retry、tasks:audit:read是显式管理权限,普通提交/Worker凭据不自动获得。核查结果流锁最后获取,审计/回执/额度与状态同事务;恢复不得绕过Weave/计划所有权、门控及现有额度限制。
|
||||||
|
|
||||||
- 任务列表队列必填,business_key精确匹配(包含尾部空格),created_from包含/created_before不包含。v2游标绑定所有筛选,不能因网络或游标错误删筛选降级;旧游标仅兼容旧查询。四种固定索引覆盖状态/业务键组合,禁止全量取回后内存过滤。业务键仅Go空字符串映射NULL。
|
- 任务列表队列必填,business_key精确匹配(包含尾部空格),created_from包含/created_before不包含。v2游标绑定所有筛选,不能因网络或游标错误删筛选降级;旧游标仅兼容旧查询。四种固定索引覆盖状态/业务键组合,禁止全量取回后内存过滤。业务键仅Go空字符串映射NULL。
|
||||||
|
|
||||||
|
- 任务来源通过独立只读路由查询,tasks:read仅授予当前任务的有限来源引用;计划定义仍需schedules:read。显式核对命名空间、队列、计划、触发与固定版本,悬空/错误/未实现所有者不得默认为DIRECT;不依赖外键,也不向派发路径加入来源查询。
|
||||||
|
|||||||
@@ -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 示例;[人工核查与重试](docs/api/task-recovery.md) 已提供带证据的管理接口、关联新任务、审计查询及运行示例;[业务键与时间范围查询](docs/api/task-queries.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) 已提供带证据的管理接口、关联新任务、审计查询及运行示例;[业务键与时间范围查询](docs/api/task-queries.md) 已提供过滤、分页与SDK场景;[任务来源查询](docs/api/task-source.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)。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Orbit 功能说明:Pulse 与公共能力
|
# Orbit 功能说明:Pulse 与公共能力
|
||||||
|
|
||||||
> 版本 0.2 · 2026-09-17。Pulse 与 Weave 首版功能范围已确认;标注“建议”的细节待评审。Weave 具体范围另见流程设计。基础、任务执行、调度、资源控制和可靠订阅已实现;批量受理、独立任务核查/人工重试及业务键/创建时间查询已实现,事务Outbox接入等继续开发,实现状态见持续开发计划。
|
> 版本 0.2 · 2026-09-17。Pulse 与 Weave 首版功能范围已确认;标注“建议”的细节待评审。Weave 具体范围另见流程设计。基础、任务执行、调度、资源控制和可靠订阅已实现;批量受理、独立任务核查/人工重试、业务键/创建时间查询及直接来源追踪已实现,事务Outbox接入等继续开发,实现状态见持续开发计划。
|
||||||
|
|
||||||
## 1. 目标与责任边界
|
## 1. 目标与责任边界
|
||||||
|
|
||||||
|
|||||||
@@ -174,3 +174,7 @@ P2 服务端已新增会话隔离、两步派发、租约、并发许可、结
|
|||||||
BusinessKey沿用既有可空VARCHAR(191),Go/SQL中文注释明确精确匹配、区分大小写和尾部空格;排序规则显式utf8mb4_0900_bin,历史文本原样保留,不增加模型字段。新增(namespace_id,queue_id,business_key,created_at,id)及增加state的组合索引,配合原两种创建时间索引覆盖有无状态/业务键的四种查询。
|
BusinessKey沿用既有可空VARCHAR(191),Go/SQL中文注释明确精确匹配、区分大小写和尾部空格;排序规则显式utf8mb4_0900_bin,历史文本原样保留,不增加模型字段。新增(namespace_id,queue_id,business_key,created_at,id)及增加state的组合索引,配合原两种创建时间索引覆盖有无状态/业务键的四种查询。
|
||||||
|
|
||||||
HTTP/SDK创建时间范围使用包含下界、不包含上界;v2游标以固定长度摘要绑定全部规范化筛选,当前权限逐页校验。单项提交、计划生成和人工重试仅把Go空字符串映射为NULL,不用连接排序规则折叠空格。仍为35个实体、289个中文注释字段,无外键/级联;具体接口与升级边界见[查询手册](api/task-queries.md)。
|
HTTP/SDK创建时间范围使用包含下界、不包含上界;v2游标以固定长度摘要绑定全部规范化筛选,当前权限逐页校验。单项提交、计划生成和人工重试仅把Go空字符串映射为NULL,不用连接排序规则折叠空格。仍为35个实体、289个中文注释字段,无外键/级联;具体接口与升级边界见[查询手册](api/task-queries.md)。
|
||||||
|
|
||||||
|
### 任务直接来源视图
|
||||||
|
|
||||||
|
[来源查询](api/task-source.md)复用Task.OwnerKind/OwnerID/RetryOf、Schedule、ScheduleVersion与ScheduleOccurrence,沿已有(namespace_id,task_id)唯一索引及计划版本键定位,不增加表、持久化字段、索引或迁移。只读响应模型的每个字段也有中文注释。任务、计划、触发版本与队列归属在认证事务中显式验证,缺失关联不被默认为普通任务;流程来源随Weave实现。
|
||||||
|
|||||||
@@ -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、接口与部分补交示例已完成全量回归;独立任务核查/人工重试、审计查询、两次真实HTTP回执丢失及完整示例已通过全量回归;业务键/创建时间筛选、SDK与示例已通过全量回归;任务详情来源追踪与事务Outbox接入待完成 |
|
| P3 Pulse 完整能力 | 安全重试、延时、Cron/固定间隔、漏跑策略、计划版本、运行中取消、并发许可、配额/背压、可靠结果订阅、批量受理、独立任务核查/人工重试、查询筛选与事务Outbox接入 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 进行中:安全重试、计划调度、运行中取消、队列控制/限速/积压背压、跨命名空间应用配额、显式资源键许可及 SDK 场景已实现并通过全量回归;优先级及节点公平派发已实现并通过全量回归;可靠订阅核心、HTTP/WSS、管理 SDK 和管理示例已实现并回归;消费 SDK、协议示例及两项节点强杀回归已完成;业务 Inbox 场景、COMMIT响应丢失及消费者进程故障回归已完成;批量受理、SDK、接口与部分补交示例已完成全量回归;独立任务核查/人工重试、审计查询、两次真实HTTP回执丢失及完整示例已通过全量回归;业务键/创建时间筛选、SDK与示例已通过全量回归;任务直接来源(普通/人工重试/计划固定版本)及SDK示例已通过全量回归;事务Outbox接入待完成 |
|
||||||
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 待开发 |
|
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 待开发 |
|
||||||
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 待开发 |
|
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 待开发 |
|
||||||
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 |
|
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 |
|
||||||
@@ -44,11 +44,23 @@ 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及SDK](api/task-recovery.md)、专门权限、显式安全声明、证据/版本/审计和关联新任务;[完整场景](examples/recovery.md)及[回归报告](reports/phase-3-task-recovery.md)同步交付 |
|
| 独立任务人工核查与重试 | 已实现[管理API及SDK](api/task-recovery.md)、专门权限、显式安全声明、证据/版本/审计和关联新任务;[完整场景](examples/recovery.md)及[回归报告](reports/phase-3-task-recovery.md)同步交付 |
|
||||||
| 任务查询筛选 | 已实现[业务键及创建时间查询](api/task-queries.md),v2游标绑定全部筛选、旧游标受限兼容、四种索引路径与[完整场景](examples/task-query.md);[回归报告](reports/phase-3-task-queries.md)同步记录 |
|
| 任务查询筛选 | 已实现[业务键及创建时间查询](api/task-queries.md),v2游标绑定全部筛选、旧游标受限兼容、四种索引路径与[完整场景](examples/task-query.md);[回归报告](reports/phase-3-task-queries.md)同步记录 |
|
||||||
| 任务详情来源追踪 | 原功能要求能查看关联计划;当前TaskView未返回来源计划/触发记录。需补齐任务到计划及触发版本的查询,验证当前权限、同命名空间归属与来源缺失处理;流程来源随Weave接入 |
|
| 任务详情来源追踪 | 已实现[独立来源接口](api/task-source.md)、SDK和普通/计划/人工恢复场景,返回计划及触发固定版本;同命名空间归属、当前权限和来源缺失拒绝已通过[全量回归](reports/phase-3-task-source.md),流程来源随Weave接入 |
|
||||||
| 业务到平台的事务Outbox接入 | [事务Outbox设计](04-reliability-and-data.md#61-业务系统到平台) 尚缺完整接入示例;需业务变更与待发请求同事务、稳定提交身份、响应不明核对和发送恢复,保持核心SDK无ORM/数据库依赖 |
|
| 业务到平台的事务Outbox接入 | [事务Outbox设计](04-reliability-and-data.md#61-业务系统到平台) 尚缺完整接入示例;需业务变更与待发请求同事务、稳定提交身份、响应不明核对和发送恢复,保持核心SDK无ORM/数据库依赖 |
|
||||||
|
|
||||||
凭据安全重绑、历史清理、全量监控/容量与MySQL故障切换仍按P6执行;Weave的通用执行门控/内部事件接续随P4–P5落实,不将外部结果订阅当作内部流程引擎。
|
凭据安全重绑、历史清理、全量监控/容量与MySQL故障切换仍按P6执行;Weave的通用执行门控/内部事件接续随P4–P5落实,不将外部结果订阅当作内部流程引擎。
|
||||||
|
|
||||||
|
### P3 事务Outbox接入的剩余验收
|
||||||
|
|
||||||
|
以下为下一项实现与测试要求,目前尚未交付:
|
||||||
|
|
||||||
|
1. 独立业务示例module,以业务记录和待发请求同一个MySQL事务提交;核心SDK保持无ORM/数据库依赖,业务表示例也禁止外键,字段提供中文注释。
|
||||||
|
2. 同时冻结目标命名空间/队列、原幂等键、完整请求及内容摘要。相同业务操作重报不新建待发项,内容不同必须冲突;凭据原文不能进入Outbox或日志。
|
||||||
|
3. 提交、领取与确认分别使用短事务;HTTP在事务外执行。多发送进程通过数据库租约及递增代次协调,旧代次不能覆盖新结果;重启后继续读取持久化待发项。
|
||||||
|
4. 平台已受理但响应丢失、业务COMMIT回执未知、发送进程退出,都必须用原身份核对。只确认平台受理,不将受理标记当作业务执行成功。
|
||||||
|
5. 退避、发送次数及总发送期限均有上限,补发窗口不超过平台幂等保留期;权限、内容冲突、预算耗尽和不能可靠确认的响应需保留可核查记录,不生成新键绕过。
|
||||||
|
6. 真实数据库回归覆盖业务回滚、并发重复、发送竞争、过期租约、迟到确认和原始数字精度;真实HTTP丢响应、MySQL COMMIT响应丢失和发送进程SIGKILL后恢复一并验收。
|
||||||
|
7. 提供完整初始化、提交、运行/恢复、状态核对及失败处理命令;接入全量回归,记录实际测试证据后再完成P3范围核对。
|
||||||
|
|
||||||
|
|
||||||
## 4. SDK 与示例交付清单
|
## 4. SDK 与示例交付清单
|
||||||
|
|
||||||
@@ -101,6 +113,7 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最
|
|||||||
- [P3 批量任务受理回归](reports/phase-3-batch-tasks.md)
|
- [P3 批量任务受理回归](reports/phase-3-batch-tasks.md)
|
||||||
- [P3 人工核查与重试回归](reports/phase-3-task-recovery.md)
|
- [P3 人工核查与重试回归](reports/phase-3-task-recovery.md)
|
||||||
- [P3 业务键与时间查询回归](reports/phase-3-task-queries.md)
|
- [P3 业务键与时间查询回归](reports/phase-3-task-queries.md)
|
||||||
|
- [P3 任务直接来源回归](reports/phase-3-task-source.md)
|
||||||
- [接口手册](api/README.md)
|
- [接口手册](api/README.md)
|
||||||
- [SDK 接入](sdk/README.md)
|
- [SDK 接入](sdk/README.md)
|
||||||
- [通用场景示例](examples/README.md)
|
- [通用场景示例](examples/README.md)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
| `GET /health/live` | 已实现 | [健康检查](health.md) |
|
| `GET /health/live` | 已实现 | [健康检查](health.md) |
|
||||||
| `GET /health/ready` | 已实现,验证数据库、迁移记录及零外键要求 | [健康检查](health.md) |
|
| `GET /health/ready` | 已实现,验证数据库、迁移记录及零外键要求 | [健康检查](health.md) |
|
||||||
| `/v1/tasks` 提交、查询、分页与待执行取消 | 已实现,P3扩展业务键与创建时间筛选 | [任务接口](tasks.md) / [查询契约](task-queries.md) |
|
| `/v1/tasks` 提交、查询、分页与待执行取消 | 已实现,P3扩展业务键与创建时间筛选 | [任务接口](tasks.md) / [查询契约](task-queries.md) |
|
||||||
|
| `GET /v1/tasks/{task_id}/source` | P3已实现普通、人工重试与计划固定版本来源 | [来源查询](task-source.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}: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) |
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ ListSchedules 需要 queue_id;计划列表和 occurrences 均可用 limit(
|
|||||||
|
|
||||||
FIRED 表示已创建单点任务;COALESCED 表示范围已合并到 task_id;SKIPPED 没有 task_id。它们都不是业务执行结果,继续 GetTask 查询,再通过 [尝试与证据接口](execution-history.md) 核对执行。
|
FIRED 表示已创建单点任务;COALESCED 表示范围已合并到 task_id;SKIPPED 没有 task_id。它们都不是业务执行结果,继续 GetTask 查询,再通过 [尝试与证据接口](execution-history.md) 核对执行。
|
||||||
|
|
||||||
|
从任务反查计划使用[GetTaskSource](task-source.md),返回来源计划、触发记录、当时固定版本和合并边界。该只读引用需要tasks:read,完整计划/版本内容仍需schedules:read;发布新版本不改变老任务来源。
|
||||||
|
|
||||||
## 6. SDK 与完整示例
|
## 6. SDK 与完整示例
|
||||||
|
|
||||||
所有方法首参数为 context.Context,写操作需要稳定 key。ListSchedules 的参数为 `(ctx, queueID, SchedulePageRequest)`;ListScheduleOccurrences 为 `(ctx, scheduleID, SchedulePageRequest)`。
|
所有方法首参数为 context.Context,写操作需要稳定 key。ListSchedules 的参数为 `(ctx, queueID, SchedulePageRequest)`;ListScheduleOccurrences 为 `(ctx, scheduleID, SchedulePageRequest)`。
|
||||||
@@ -185,7 +187,9 @@ ORBIT_URL=http://127.0.0.1:8080 \
|
|||||||
前置数据库与目录步骤见 [SDK 接入](../sdk/README.md)。源码:[完整调度示例](../../sdk/go/examples/schedules/main.go)。示例创建暂停的每秒计划,恢复后让自动 Worker 返回对象输入快照,查询两次完成,再暂停并排空。计划本身有十秒结束边界,任务与历史保留;重复运行生成独立身份,不调用外部业务系统。
|
前置数据库与目录步骤见 [SDK 接入](../sdk/README.md)。源码:[完整调度示例](../../sdk/go/examples/schedules/main.go)。示例创建暂停的每秒计划,恢复后让自动 Worker 返回对象输入快照,查询两次完成,再暂停并排空。计划本身有十秒结束边界,任务与历史保留;重复运行生成独立身份,不调用外部业务系统。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
source_kind=SCHEDULE fixed_version=1
|
||||||
schedule=<计划ID> occurrence=<触发ID> task=<任务ID> version=1 state=SUCCEEDED
|
schedule=<计划ID> occurrence=<触发ID> task=<任务ID> version=1 state=SUCCEEDED
|
||||||
|
source_kind=SCHEDULE fixed_version=1
|
||||||
schedule=<计划ID> occurrence=<触发ID> task=<任务ID> version=1 state=SUCCEEDED
|
schedule=<计划ID> occurrence=<触发ID> task=<任务ID> version=1 state=SUCCEEDED
|
||||||
paused=true completed_tasks=2 current_version=1
|
paused=true completed_tasks=2 current_version=1
|
||||||
worker_drained=true running=0
|
worker_drained=true running=0
|
||||||
|
|||||||
131
docs/api/task-source.md
Normal file
131
docs/api/task-source.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# 任务直接来源查询
|
||||||
|
|
||||||
|
状态:P3已实现服务端、独立SDK及普通任务、计划、人工恢复示例。接口只读,使用已有模型和索引,不增加数据库外键或迁移。验证证据见[回归报告](../reports/phase-3-task-source.md)。
|
||||||
|
|
||||||
|
## 1. 请求与权限
|
||||||
|
|
||||||
|
`GET /v1/tasks/{task_id}/source`
|
||||||
|
|
||||||
|
请求头:`Authorization: Bearer <token>`。不需要请求体、Content-Type或Idempotency-Key,不接受任何查询参数。task_id为32位小写十六进制ID;认证决定命名空间,URL不能覆盖它。
|
||||||
|
|
||||||
|
权限为`tasks:read`及任务所属队列范围。每次请求验证当前凭据;不存在、跨命名空间或不在队列范围的任务均返回404。只获得来源引用,不因此获得计划定义、模板、管理或执行权限;读取完整计划及固定版本仍需`schedules:read`,见[计划手册](schedules.md)。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl "$ORBIT_URL/v1/tasks/$TASK_ID/source" \
|
||||||
|
-H "Authorization: Bearer $ORBIT_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 响应字段
|
||||||
|
|
||||||
|
HTTP 200,使用`request_id`和`data`信封;X-Request-ID与正文一致。下表字段均位于data。
|
||||||
|
|
||||||
|
| 字段 | 类型 / 必填 | 含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| task_id | string / 是 | 已授权查询的任务ID |
|
||||||
|
| kind | string / 是 | DIRECT、MANUAL_RETRY或SCHEDULE |
|
||||||
|
| retry_of | string / 仅MANUAL_RETRY | 同命名空间、同队列的直接原任务ID |
|
||||||
|
| schedule | object / 仅SCHEDULE | 触发该任务的固定关联,字段如下 |
|
||||||
|
| schedule.schedule_id | string / 是 | 来源计划ID |
|
||||||
|
| schedule.schedule_version | 十进制字符串 / 是 | 实际触发时的非零uint64版本,发布新版本不改写它 |
|
||||||
|
| schedule.occurrence_id | string / 是 | 唯一关联该任务的触发记录ID |
|
||||||
|
| schedule.scheduled_at | UTC RFC3339 / 是 | 原计划时间,最多微秒精度,与实际受理/执行时间分开 |
|
||||||
|
| schedule.decision | string / 是 | FIRED单点触发或COALESCED合并触发 |
|
||||||
|
| schedule.covered_until | UTC RFC3339 / 仅COALESCED | 合并范围的排除上界,严格晚于scheduled_at |
|
||||||
|
|
||||||
|
DIRECT表示普通任务受理,包括单项和批量受理;任务内部的自动安全重试仍属于同一个任务,不改变来源。MANUAL_RETRY表示[人工重试](task-recovery.md)创建的新任务,只返回直接父任务,不递归遍历整条重试链。父任务继续保留原历史。
|
||||||
|
|
||||||
|
SCHEDULE中的版本来自触发记录,不能用计划最新版本替换。COALESCED对应区间`[scheduled_at, covered_until)`;合并范围内由计划定义确定原始时间点,不承诺每个自然时间单位都有一次触发。SKIPPED未创建任务,因此不会成为任务来源。
|
||||||
|
|
||||||
|
普通受理的完整响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id":"44444444444444444444444444444444",
|
||||||
|
"data":{
|
||||||
|
"task_id":"11111111111111111111111111111111",
|
||||||
|
"kind":"DIRECT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
人工重试的完整响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id":"44444444444444444444444444444444",
|
||||||
|
"data":{
|
||||||
|
"task_id":"11111111111111111111111111111111",
|
||||||
|
"kind":"MANUAL_RETRY",
|
||||||
|
"retry_of":"22222222222222222222222222222222"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
计划合并触发的完整响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id":"44444444444444444444444444444444",
|
||||||
|
"data":{
|
||||||
|
"task_id":"11111111111111111111111111111111",
|
||||||
|
"kind":"SCHEDULE",
|
||||||
|
"schedule":{
|
||||||
|
"schedule_id":"22222222222222222222222222222222",
|
||||||
|
"schedule_version":"1",
|
||||||
|
"occurrence_id":"33333333333333333333333333333333",
|
||||||
|
"scheduled_at":"2026-09-17T00:00:00Z",
|
||||||
|
"decision":"COALESCED",
|
||||||
|
"covered_until":"2026-09-17T00:03:00.000001Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
单点触发返回decision=FIRED并省略covered_until。所有不适用的可选字段省略,不用null占位。来源不包含任务payload/result、计划模板、策略、凭据或执行租约秘密。任务当前状态另用GetTask读取。
|
||||||
|
|
||||||
|
## 3. 关联校验与错误
|
||||||
|
|
||||||
|
平台采用无外键设计,本接口在认证事务内显式核对命名空间、任务、队列、计划、不可变版本与触发记录。人工重试父任务须为同队列中已失败/取消的独立任务。无所有者但存在触发记录、悬空父任务、缺失版本、错误队列、未知所有者或当前未支持的门控均拒绝,不能把关联损坏降级成DIRECT。
|
||||||
|
|
||||||
|
| HTTP / code | 处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 400 INVALID_ARGUMENT | 检查task_id;删除不受支持、重复或格式错误的查询参数 |
|
||||||
|
| 401 UNAUTHENTICATED | 检查凭据、过期、撤销及应用/命名空间状态 |
|
||||||
|
| 403 PERMISSION_DENIED | 需要tasks:read动作权限 |
|
||||||
|
| 404 NOT_FOUND | 任务不存在或不在当前命名空间/队列范围;旧版本节点也可能没有本路由 |
|
||||||
|
| 503 UNAVAILABLE | 数据库或来源关联不能可靠确认;不返回部分来源,不推测DIRECT |
|
||||||
|
|
||||||
|
读取不改变任务状态、版本、额度、尝试或审计。网络错误可以在有限预算内重新读取;SDK不自动重试。持续503应排查数据关联和服务日志,不能通过删除来源字段“修复”,也不能根据来源查询失败重新提交任务。
|
||||||
|
|
||||||
|
## 4. SDK调用与场景
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *Client) GetTaskSource(ctx context.Context, taskID string) (TaskSource, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
下方为已有客户端与taskID的调用片段;完整初始化、错误处理和可运行程序见后面的示例。
|
||||||
|
|
||||||
|
```go
|
||||||
|
source, err := client.GetTaskSource(ctx, taskID)
|
||||||
|
if err != nil { return err }
|
||||||
|
switch source.Kind {
|
||||||
|
case "DIRECT":
|
||||||
|
fmt.Println("普通受理", source.TaskID)
|
||||||
|
case "MANUAL_RETRY":
|
||||||
|
fmt.Println("直接原任务", *source.RetryOf)
|
||||||
|
case "SCHEDULE":
|
||||||
|
fmt.Println("来源计划", source.Schedule.ScheduleID,
|
||||||
|
"固定版本", source.Schedule.ScheduleVersion,
|
||||||
|
"触发记录", source.Schedule.OccurrenceID)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SDK的ScheduleVersion为uint64,JSON仍为字符串;时间使用time.Time。SDK验证响应task_id一致性、来源种类与字段组合、ID、版本及时间范围;未知kind或矛盾数据返回错误,不返回可误用的部分来源。协议新增来源种类时,接入方需升级SDK再使用。
|
||||||
|
|
||||||
|
| 场景 | 完整源码 | 运行说明与新增输出 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 单项受理、取消后读取来源 | [tasks](../../sdk/go/examples/tasks/main.go) | [SDK初始化](../sdk/README.md),source_kind=DIRECT |
|
||||||
|
| 按计划找到触发任务,再反查来源 | [schedules](../../sdk/go/examples/schedules/main.go) | [计划场景](schedules.md#6-sdk-与完整示例),source_kind=SCHEDULE、fixed_version=1 |
|
||||||
|
| 人工核查后创建关联新任务 | [recovery](../../sdk/go/examples/recovery/main.go) | [恢复场景](../examples/recovery.md),source_kind=MANUAL_RETRY、parent=原任务ID |
|
||||||
|
|
||||||
|
上述示例均运行在隔离回归中;新发布计划版本后老任务仍指向旧版本,由真实数据库与SDK集成测试验证。Weave流程来源随P4–P5实现,目前没有WORKFLOW响应。此接口为新增只读路由,不更改Task/TaskSummary或Worker消息结构,不增加权限配置;新客户端调用旧节点的404不能理解为DIRECT。
|
||||||
@@ -116,6 +116,8 @@ curl "$ORBIT_URL/v1/tasks/$TASK_ID" -H "Authorization: Bearer $ORBIT_TOKEN"
|
|||||||
|
|
||||||
SDK:`Client.GetTask(ctx, taskID)`。
|
SDK:`Client.GetTask(ctx, taskID)`。
|
||||||
|
|
||||||
|
任务直接来源独立通过`GET /v1/tasks/{task_id}/source`读取,SDK为`GetTaskSource(ctx, taskID)`;可定位普通受理、人工重试父任务、来源计划/触发记录/固定版本。使用相同的tasks:read和队列范围,详情见[来源查询](task-source.md)。来源未嵌入Task或列表摘要,读取完整计划仍需计划权限。
|
||||||
|
|
||||||
## GET /v1/tasks
|
## GET /v1/tasks
|
||||||
|
|
||||||
权限:tasks:read。查询参数均只能出现一次:
|
权限:tasks:read。查询参数均只能出现一次:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
- 源码:[SDK 任务受理示例](../../sdk/go/examples/tasks/main.go)。
|
- 源码:[SDK 任务受理示例](../../sdk/go/examples/tasks/main.go)。
|
||||||
- 前置:完成迁移、初始化凭据、启动服务,在没有 Worker 消费的专用队列运行此受理/取消示例,详见 [SDK 接入](../sdk/README.md)。
|
- 前置:完成迁移、初始化凭据、启动服务,在没有 Worker 消费的专用队列运行此受理/取消示例,详见 [SDK 接入](../sdk/README.md)。
|
||||||
- 输入:对象引用、格式与精确整数;提交两次同一身份,再查询、分页并带版本取消。
|
- 输入:对象引用、格式与精确整数;提交两次同一身份,再查询、分页并带版本取消。
|
||||||
- 预期:重复请求返回同一任务,最终 CANCELLED/revision=2。当前不启动 Worker,不实际生成文件。
|
- 预期:重复请求返回同一任务,最终 CANCELLED/revision=2,source_kind=DIRECT。当前不启动 Worker,不实际生成文件。
|
||||||
- 异常:凭据失效、权限不足、内容冲突、版本竞争和未知网络结果,见 [任务接口](../api/tasks.md)。
|
- 异常:凭据失效、权限不足、内容冲突、版本竞争和未知网络结果,见 [任务接口](../api/tasks.md)。
|
||||||
- 清理:示例以取消结束;任务和审计历史保留。重复运行会生成独立示例任务。
|
- 清理:示例以取消结束;任务和审计历史保留。重复运行会生成独立示例任务。
|
||||||
|
|
||||||
@@ -153,3 +153,10 @@
|
|||||||
- 步骤:提交三个模拟任务 → 精确业务键及时间范围过滤 → 每页一条遍历 → 验证上界排除 → 取消收尾。
|
- 步骤:提交三个模拟任务 → 精确业务键及时间范围过滤 → 每页一条遍历 → 验证上界排除 → 取消收尾。
|
||||||
- 预期:matched=2、pages=2、before_excluded=true、cancelled=3。
|
- 预期:matched=2、pages=2、before_excluded=true、cancelled=3。
|
||||||
- 范围:普通提交/读取/取消权限;不调用外部系统、不启动Worker,所有历史保留,状态分页不是数据库快照。
|
- 范围:普通提交/读取/取消权限;不调用外部系统、不启动Worker,所有历史保留,状态分页不是数据库快照。
|
||||||
|
|
||||||
|
## 已可运行:从任务定位来源
|
||||||
|
|
||||||
|
- [来源接口与SDK](../api/task-source.md)随现有tasks、schedules、recovery三个示例运行,无额外服务或数据库表。
|
||||||
|
- 步骤:取得任务ID → GetTaskSource → 区分普通提交/人工重试/计划触发 → 按权限读取父任务或固定计划版本。
|
||||||
|
- 预期:source_kind分别为DIRECT、MANUAL_RETRY、SCHEDULE;人工重试关联原任务,计划任务保留实际触发版本。
|
||||||
|
- 异常:来源缺失或归属不匹配返回503,不能降级为DIRECT;新计划版本不覆盖历史版本,引用查询不授予计划定义读取权限。
|
||||||
|
|||||||
@@ -35,16 +35,19 @@ ORBIT_URL=http://127.0.0.1:8080 \
|
|||||||
3. 原键重报核查,确认同audit_id和deduplicated=true。
|
3. 原键重报核查,确认同audit_id和deduplicated=true。
|
||||||
4. RetryTask声明可安全重新执行并指定新总预算60秒,取得retry_of指向原任务的新任务。
|
4. RetryTask声明可安全重新执行并指定新总预算60秒,取得retry_of指向原任务的新任务。
|
||||||
5. Worker开始新任务、报告SUCCEEDED;查询原任务仍FAILED、新任务SUCCEEDED。
|
5. Worker开始新任务、报告SUCCEEDED;查询原任务仍FAILED、新任务SUCCEEDED。
|
||||||
6. 查询原任务操作列表及重试审计详情,排空并关闭Worker连接。
|
6. 查询原任务操作列表及重试审计详情,通过GetTaskSource核对新任务的直接原任务,排空并关闭Worker连接。
|
||||||
|
|
||||||
预期输出,标识由实际执行生成:
|
预期输出,标识由实际执行生成:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
source_kind=MANUAL_RETRY parent=<原任务ID>
|
||||||
source=<原任务ID> state=FAILED retry=<新任务ID> state=SUCCEEDED audit=<审计ID> operations=4
|
source=<原任务ID> state=FAILED retry=<新任务ID> state=SUCCEEDED audit=<审计ID> operations=4
|
||||||
```
|
```
|
||||||
|
|
||||||
操作数包含本例受理、原结果、核查和人工重试;以本次实际查询为准。示例不删除任务、审计或尝试。重复运行创建独立演示任务,每次保留原历史。
|
操作数包含本例受理、原结果、核查和人工重试;以本次实际查询为准。示例不删除任务、审计或尝试。重复运行创建独立演示任务,每次保留原历史。
|
||||||
|
|
||||||
|
[来源接口](../api/task-source.md)仅返回直接父任务,重复人工重试沿后继继续,不递归加载整条链;查询不产生新的审计或改写历史。
|
||||||
|
|
||||||
## 接入片段
|
## 接入片段
|
||||||
|
|
||||||
下方在已有客户端和已核实的current任务上下文中调用;完整imports/main及错误处理见源码。
|
下方在已有客户端和已核实的current任务上下文中调用;完整imports/main及错误处理见源码。
|
||||||
|
|||||||
76
docs/reports/phase-3-task-source.md
Normal file
76
docs/reports/phase-3-task-source.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# P3 任务直接来源回归
|
||||||
|
|
||||||
|
验证日期:2026-09-17。服务端、SDK和三类现有场景已接入直接来源查询;定向MySQL、基础检查及全量隔离回归均已通过,完整脚本正常退出,状态码0。业务事务Outbox仍是P3待交付项,Weave按P4–P5继续。
|
||||||
|
|
||||||
|
## 1. 实现范围
|
||||||
|
|
||||||
|
- [服务模型](../../pulse/service/source.go)与[存储查询](../../internal/storage/mysql/task_source.go):DIRECT、MANUAL_RETRY、SCHEDULE三类来源;计划ID、触发记录、冻结版本及合并范围;人工重试仅一跳父任务。
|
||||||
|
- [HTTP路由](../../internal/transport/httpserver/history.go)提供GET /v1/tasks/{task_id}/source,沿用tasks:read和队列范围,拒绝查询参数。
|
||||||
|
- [独立SDK](../../sdk/go/task_source.go)验证响应ID、类型组合、版本和时间,保留uint64精度;没有数据库依赖或自动重试。
|
||||||
|
- [接口手册](../api/task-source.md)、[普通提交](../../sdk/go/examples/tasks/main.go)、[计划](../../sdk/go/examples/schedules/main.go)及[人工恢复](../../sdk/go/examples/recovery/main.go)同步提供调用与输出。
|
||||||
|
|
||||||
|
来源独立读取,不改变Task/TaskSummary及Worker协议,不在派发路径增加关联查询。复用现有(namespace_id,task_id)触发唯一索引及计划版本键,不新增迁移、索引或持久化字段;平台仍为35个实体、289个中文注释字段。新只读结构的字段也均有中文说明。
|
||||||
|
|
||||||
|
## 2. 真实MySQL与HTTP
|
||||||
|
|
||||||
|
[TaskSource集成](../../internal/storage/mysql/task_source_integration_test.go)已验证:
|
||||||
|
|
||||||
|
1. 普通受理和人工派生返回正确类型及直接父任务,读取前后任务快照不变。
|
||||||
|
2. 跨命名空间、未授权队列返回NOT_FOUND,撤销凭据后拒绝继续查询;缺失或跨命名空间父任务返回UNAVAILABLE。
|
||||||
|
3. 计划发布v2后,已生成任务仍指向v1和原触发记录,任务内容不变。
|
||||||
|
4. 只有tasks:read的凭据能查询有限来源引用,但读取完整计划仍被PERMISSION_DENIED拒绝。
|
||||||
|
5. 实际HTTP拒绝非法ID、参数与编码,隐藏无权限任务;响应不包含模板、payload或租约秘密。
|
||||||
|
6. COALESCED来源保留原触发时间和排除上界。
|
||||||
|
7. 模拟无外键数据库中的悬空所有者、缺失版本、错误计划队列、反向关联残留、未知所有者及门控,均拒绝且返回零值而非部分来源;恢复正确关联后查询成功。
|
||||||
|
|
||||||
|
定向回归总计24.058秒(含包及升级开销),TaskSource四组9.46秒通过。v7旧库升级验证外键70→0、323个未替换索引组件保留、已有任务内容不变;应用资源升级夹具还原历史真实utf8mb4_0900_as_cs后重放后续迁移。该调整仅修正测试夹具,生产迁移000016的精确utf8mb4_0900_bin保持不变。
|
||||||
|
|
||||||
|
## 3. SDK与完整场景
|
||||||
|
|
||||||
|
[SDK单元](../../sdk/go/task_source_test.go)覆盖三类有效响应、FIRED/COALESCED、9007199254740993版本精度、错误task_id、未知种类、字段矛盾、非法版本/时间/ID以及本地拒绝不安全路径。
|
||||||
|
|
||||||
|
[计划真实HTTP集成](../../sdk/go/schedule_integration_test.go)在发布新版本后核对旧任务固定来源;[恢复真实HTTP集成](../../sdk/go/recovery_integration_test.go)在回执丢失后以原身份恢复,再查询直接来源与人工重试关联。
|
||||||
|
|
||||||
|
已有三个可运行程序新增输出,原输出继续保留:
|
||||||
|
|
||||||
|
```text
|
||||||
|
source_kind=DIRECT
|
||||||
|
source_kind=SCHEDULE fixed_version=1
|
||||||
|
source_kind=MANUAL_RETRY parent=<原任务ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
这些行来自不同程序,不是同一条任务同时具有三种来源。完整启动命令分别见[SDK入门](../sdk/README.md)、[计划场景](../api/schedules.md#6-sdk-与完整示例)及[恢复场景](../examples/recovery.md)。
|
||||||
|
|
||||||
|
## 4. 复现与结果
|
||||||
|
|
||||||
|
macOS、Go1.27.1、Docker单实例MySQL8.4.10,随机测试凭据、回环端口和一次性数据库。脚本完成后清理隔离资源。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make fmt test vet build
|
||||||
|
make integration
|
||||||
|
```
|
||||||
|
|
||||||
|
普通未配置数据库的go test会跳过Integration,不能替代真实数据库回归。本轮全量脚本完成后的实际结果:
|
||||||
|
|
||||||
|
| 验证组 | 本轮结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| fmt / race / vet / build | PASS |
|
||||||
|
| 全量MySQL / TaskSource四组 | PASS,108.438 / 9.98秒 |
|
||||||
|
| SDK Worker完整组 | PASS,87.120秒 |
|
||||||
|
| SDK计划 / 队列 / 批量 / 查询 | PASS,5.815 / 4.419 / 1.391 / 1.461秒 |
|
||||||
|
| SDK订阅管理 / 事件消费 | PASS,33.366 / 52.813秒 |
|
||||||
|
| SDK人工恢复与来源 | PASS,3.398秒 |
|
||||||
|
| Inbox数据库与消费者故障 | PASS,34.591秒 |
|
||||||
|
| SDK应用额度 | PASS,1.370秒 |
|
||||||
|
| 九项节点/Worker进程故障 | PASS,60.842秒;加上Inbox消费者,共十项进程故障 |
|
||||||
|
| 可运行程序 | 17个核心SDK示例 + 1个独立Inbox示例通过,三个新增来源输出符合预期 |
|
||||||
|
|
||||||
|
零外键和历史迁移校验继续通过;文档链接、JSON块及核心SDK无服务端/数据库依赖检查通过。上述耗时为这次正确性回归记录,不是吞吐或容量结论。没有使用中途PASS日志推断脚本完成。
|
||||||
|
|
||||||
|
## 5. 交付边界
|
||||||
|
|
||||||
|
- 此接口只读直接来源,没有链式递归、来源全文或逆向全量任务搜索;状态和执行历史使用对应查询接口。
|
||||||
|
- tasks:read只授予当前任务的有限来源引用,完整计划/版本仍独立鉴权。来源查询不产生执行权或恢复权限。
|
||||||
|
- 关联损坏返回503,需要排查;不自动猜测或修复归属。所有者与来源由写入事务固定,读取不依赖数据库外键。
|
||||||
|
- Weave流程来源随流程引擎实现,当前未知所有者明确拒绝;旧节点缺少新路由时404不能作为DIRECT使用。
|
||||||
|
- 本轮不新增执行/持久化协议,也不提供生产吞吐、数据清理或数据库故障切换保证;这些仍按P6验证。
|
||||||
@@ -32,13 +32,14 @@ ORBIT_URL=http://127.0.0.1:8080 \
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
liveness=alive readiness=ready scope=database_and_schema
|
liveness=alive readiness=ready scope=database_and_schema
|
||||||
|
source_kind=DIRECT
|
||||||
task=<32位任务ID> duplicate=true listed=1 final_state=CANCELLED revision=2
|
task=<32位任务ID> duplicate=true listed=1 final_state=CANCELLED revision=2
|
||||||
```
|
```
|
||||||
|
|
||||||
重复运行示例会新建一个独立任务并取消它,listed 数量可能增加,最多显示当前页 10 条。数据和审计保留,不执行硬删除。示例只演示受理闭环,尚不执行文件转换。
|
重复运行示例会新建一个独立任务并取消它,listed 数量可能增加,最多显示当前页 10 条。数据和审计保留,不执行硬删除。示例只演示受理闭环,尚不执行文件转换。
|
||||||
|
|
||||||
- [健康示例源码](../../sdk/go/examples/health/main.go):只读,无业务资源写入。
|
- [健康示例源码](../../sdk/go/examples/health/main.go):只读,无业务资源写入。
|
||||||
- [任务示例源码](../../sdk/go/examples/tasks/main.go):提交两次同一身份、获取当前状态、列表、带版本取消。
|
- [任务示例源码](../../sdk/go/examples/tasks/main.go):提交两次同一身份、获取当前状态、列表、带版本取消,再核对直接来源。
|
||||||
- 任务示例也接受 ORBIT_TOKEN 和 ORBIT_QUEUE_ID,环境值优先于凭据文件。
|
- 任务示例也接受 ORBIT_TOKEN 和 ORBIT_QUEUE_ID,环境值优先于凭据文件。
|
||||||
- 示例发生错误会打印错误并非零退出;打印内容不包含原始 token。
|
- 示例发生错误会打印错误并非零退出;打印内容不包含原始 token。
|
||||||
- 初始凭据 30 天有效。初始化行为和错误核对方式见 [凭据文档](../api/authentication.md)。
|
- 初始凭据 30 天有效。初始化行为和错误核对方式见 [凭据文档](../api/authentication.md)。
|
||||||
@@ -119,6 +120,7 @@ fmt.Println(cancelled.Task.State)
|
|||||||
| Liveness / Readiness | Health | [健康](../api/health.md) |
|
| Liveness / Readiness | Health | [健康](../api/health.md) |
|
||||||
| SubmitTask | SubmitTaskRequest / SubmitTaskResult | [任务](../api/tasks.md) |
|
| SubmitTask | SubmitTaskRequest / SubmitTaskResult | [任务](../api/tasks.md) |
|
||||||
| GetTask | taskID / Task | [任务](../api/tasks.md) |
|
| GetTask | taskID / Task | [任务](../api/tasks.md) |
|
||||||
|
| GetTaskSource | taskID / TaskSource,直接来源及计划固定版本 | [来源查询](../api/task-source.md) |
|
||||||
| ListTasks | ListTasksRequest / TaskPage,业务键及创建时间范围 | [查询](../api/task-queries.md) |
|
| ListTasks | ListTasksRequest / TaskPage,业务键及创建时间范围 | [查询](../api/task-queries.md) |
|
||||||
| CancelTask | taskID、key、CancelTaskRequest / CancelTaskResult | [任务](../api/tasks.md) |
|
| CancelTask | taskID、key、CancelTaskRequest / CancelTaskResult | [任务](../api/tasks.md) |
|
||||||
| CreateQueue | key、CreateQueueRequest / CreateQueueResult | [队列](../api/queues.md) |
|
| CreateQueue | key、CreateQueueRequest / CreateQueueResult | [队列](../api/queues.md) |
|
||||||
|
|||||||
@@ -64,7 +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 INDEX ix_task_queue_business_created, DROP INDEX ix_task_queue_business_state_created, MODIFY COLUMN business_key VARCHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '业务侧关联键,用于检索追踪,不自动提供业务幂等'`)
|
f.exec(`ALTER TABLE pulse_tasks DROP INDEX ix_task_queue_business_created, DROP INDEX ix_task_queue_business_state_created, MODIFY COLUMN business_key VARCHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NULL COMMENT '业务侧关联键,用于检索追踪,不自动提供业务幂等'`)
|
||||||
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(`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`)
|
||||||
|
|||||||
90
internal/storage/mysql/task_source.go
Normal file
90
internal/storage/mysql/task_source.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *TaskStore) GetTaskSource(ctx context.Context, t auth.Token, id core.ID) (service.TaskSourceView, error) {
|
||||||
|
out := service.TaskSourceView{TaskID: id}
|
||||||
|
err := s.transaction(ctx, t, "tasks:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||||
|
var queue core.ID
|
||||||
|
var owner sql.NullString
|
||||||
|
var ownerID, gate, retry *core.ID
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT queue_id,owner_kind,owner_id,gate_id,retry_of FROM pulse_tasks WHERE namespace_id=? AND id=?`, t.NamespaceID, id).Scan(&queue, &owner, &ownerID, &gate, &retry)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fault.NotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !p.AllowsQueue(queue) {
|
||||||
|
return fault.NotFound
|
||||||
|
}
|
||||||
|
// 来源字段由创建事务固定;当前未实现的门控/所有者不能被误判为普通受理。
|
||||||
|
if gate != nil || owner.Valid != (ownerID != nil) || owner.Valid && retry != nil {
|
||||||
|
return fault.Unavailable
|
||||||
|
}
|
||||||
|
if owner.Valid {
|
||||||
|
if owner.String != "schedule" {
|
||||||
|
return fault.Unavailable
|
||||||
|
}
|
||||||
|
var source service.TaskScheduleSource
|
||||||
|
// 每条关联均显式限定命名空间、计划、版本与任务队列,不依赖数据库外键。
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT o.schedule_id,o.schedule_version,o.id,o.scheduled_at,o.decision,o.covered_until
|
||||||
|
FROM pulse_schedule_occurrences o
|
||||||
|
JOIN pulse_schedules s ON s.namespace_id=o.namespace_id AND s.id=o.schedule_id AND s.queue_id=?
|
||||||
|
JOIN pulse_schedule_versions v ON v.namespace_id=s.namespace_id AND v.schedule_id=s.id AND v.version=o.schedule_version AND v.version<=s.current_version
|
||||||
|
WHERE o.namespace_id=? AND o.task_id=? AND o.schedule_id=? AND JSON_UNQUOTE(JSON_EXTRACT(v.task_template,'$.queue_id'))=?`, queue, t.NamespaceID, id, *ownerID, string(queue)).Scan(&source.ScheduleID, &source.ScheduleVersion, &source.OccurrenceID, &source.ScheduledAt, &source.Decision, &source.CoveredUntil)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fault.Unavailable
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if source.ScheduleVersion == 0 || !sourceTimeValid(source.ScheduledAt) || source.CoveredUntil != nil && !sourceTimeValid(*source.CoveredUntil) || source.Decision == "FIRED" && source.CoveredUntil != nil || source.Decision == "COALESCED" && (source.CoveredUntil == nil || !source.CoveredUntil.After(source.ScheduledAt)) || source.Decision != "FIRED" && source.Decision != "COALESCED" {
|
||||||
|
return fault.Unavailable
|
||||||
|
}
|
||||||
|
out.Kind = "SCHEDULE"
|
||||||
|
out.Schedule = &source
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var occurrences int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM pulse_schedule_occurrences WHERE namespace_id=? AND task_id=?`, t.NamespaceID, id).Scan(&occurrences); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if occurrences != 0 {
|
||||||
|
return fault.Unavailable
|
||||||
|
}
|
||||||
|
if retry == nil {
|
||||||
|
out.Kind = "DIRECT"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var parent core.ID
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT id FROM pulse_tasks WHERE namespace_id=? AND id=? AND id<>? AND queue_id=? AND owner_kind IS NULL AND owner_id IS NULL AND gate_id IS NULL AND state IN ('FAILED','CANCELLED')`, t.NamespaceID, *retry, id, queue).Scan(&parent)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fault.Unavailable
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Kind = "MANUAL_RETRY"
|
||||||
|
out.RetryOf = &parent
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return service.TaskSourceView{}, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceTimeValid(t time.Time) bool {
|
||||||
|
return t.Year() >= 2000 && t.Year() <= 9998 && t.Nanosecond()%1000 == 0
|
||||||
|
}
|
||||||
200
internal/storage/mysql/task_source_integration_test.go
Normal file
200
internal/storage/mysql/task_source_integration_test.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/schedule"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIntegrationTaskSource(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, 16, 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err = Migrate(ctx, db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Run("direct_and_manual_retry_are_read_only_and_scoped", func(t *testing.T) {
|
||||||
|
f := newExecutionFixture(t, ctx, db)
|
||||||
|
grantTaskRecovery(f)
|
||||||
|
original := f.submit(1)
|
||||||
|
source, err := f.tasks.GetTaskSource(ctx, f.token, original.ID)
|
||||||
|
if err != nil || source.TaskID != original.ID || source.Kind != "DIRECT" || source.Schedule != nil || source.RetryOf != nil {
|
||||||
|
t.Fatal(source, err)
|
||||||
|
}
|
||||||
|
cancelled, err := f.tasks.CancelTask(ctx, f.token, original.ID, "cancel", service.CancelInput{ExpectedRevision: original.Revision, Reason: "retry source regression"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
child, err := f.tasks.RetryTask(ctx, f.token, original.ID, "retry", manualRetryInput(cancelled.Task))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source, err = f.tasks.GetTaskSource(ctx, f.token, child.Task.ID)
|
||||||
|
if err != nil || source.Kind != "MANUAL_RETRY" || source.Schedule != nil || source.RetryOf == nil || *source.RetryOf != original.ID {
|
||||||
|
t.Fatal(source, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(f.get(original.ID), cancelled.Task) || !reflect.DeepEqual(f.get(child.Task.ID), child.Task) {
|
||||||
|
t.Fatal("source read changed task")
|
||||||
|
}
|
||||||
|
other := newExecutionFixture(t, ctx, db)
|
||||||
|
_, err = other.tasks.GetTaskSource(ctx, other.token, child.Task.ID)
|
||||||
|
expectWorkerCode(t, err, "NOT_FOUND")
|
||||||
|
f.exec(`UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.queues',JSON_ARRAY(?)) WHERE namespace_id=? AND id=?`, string(other.resources.QueueID), f.resources.NamespaceID, f.resources.CredentialID)
|
||||||
|
_, err = f.tasks.GetTaskSource(ctx, f.token, child.Task.ID)
|
||||||
|
expectWorkerCode(t, err, "NOT_FOUND")
|
||||||
|
f.exec(`UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.queues',JSON_ARRAY('*')) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID)
|
||||||
|
// 模拟无外键库的缺失与跨命名空间原任务,不能返回未验证的标识。
|
||||||
|
for _, parent := range []core.ID{executionID(t), other.submit(1).ID} {
|
||||||
|
f.exec(`UPDATE pulse_tasks SET retry_of=? WHERE namespace_id=? AND id=?`, parent, f.resources.NamespaceID, child.Task.ID)
|
||||||
|
_, err = f.tasks.GetTaskSource(ctx, f.token, child.Task.ID)
|
||||||
|
expectWorkerCode(t, err, "UNAVAILABLE")
|
||||||
|
}
|
||||||
|
f.exec(`UPDATE pulse_tasks SET retry_of=? WHERE namespace_id=? AND id=?`, original.ID, f.resources.NamespaceID, child.Task.ID)
|
||||||
|
f.exec(`UPDATE orbit_credentials SET revoked_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID)
|
||||||
|
_, err = f.tasks.GetTaskSource(ctx, f.token, child.Task.ID)
|
||||||
|
expectWorkerCode(t, err, "UNAUTHENTICATED")
|
||||||
|
})
|
||||||
|
t.Run("schedule_source_preserves_published_version_and_permission_boundary", func(t *testing.T) {
|
||||||
|
f := newScheduleFixture(t, newExecutionFixture(t, ctx, db))
|
||||||
|
in := f.definition()
|
||||||
|
plan := f.create(in)
|
||||||
|
f.due(plan, 2*time.Second)
|
||||||
|
f.tick()
|
||||||
|
o := f.occurrences(plan.ID)[0]
|
||||||
|
if o.TaskID == nil {
|
||||||
|
t.Fatal("missing task")
|
||||||
|
}
|
||||||
|
before := f.get(*o.TaskID)
|
||||||
|
source, err := f.tasks.GetTaskSource(ctx, f.token, *o.TaskID)
|
||||||
|
if err != nil || source.Kind != "SCHEDULE" || source.Schedule == nil || source.RetryOf != nil {
|
||||||
|
t.Fatal(source, err)
|
||||||
|
}
|
||||||
|
link := source.Schedule
|
||||||
|
if link.ScheduleID != plan.ID || link.OccurrenceID != o.ID || link.ScheduleVersion != o.ScheduleVersion || !link.ScheduledAt.Equal(o.ScheduledAt) || link.Decision != o.Decision {
|
||||||
|
t.Fatal("incorrect schedule lineage", link, o)
|
||||||
|
}
|
||||||
|
in.Task.Payload = json.RawMessage(`{"version":2}`)
|
||||||
|
now := f.current(plan.ID)
|
||||||
|
published, err := f.api.Publish(ctx, f.token, plan.ID, "publish", schedule.PublishInput{ExpectedRevision: now.Revision, Reason: "source stays on original version", Definition: in})
|
||||||
|
if err != nil || published.Schedule.CurrentVersion != 2 {
|
||||||
|
t.Fatal(published, err)
|
||||||
|
}
|
||||||
|
after, err := f.tasks.GetTaskSource(ctx, f.token, *o.TaskID)
|
||||||
|
if err != nil || !reflect.DeepEqual(source, after) || !reflect.DeepEqual(before, f.get(*o.TaskID)) {
|
||||||
|
t.Fatal("latest publication changed source", after, err)
|
||||||
|
}
|
||||||
|
// tasks:read仅授予此任务的来源引用,不因此授予完整计划/模板查询权限。
|
||||||
|
f.exec(`UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.actions',JSON_ARRAY('tasks:read')) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID)
|
||||||
|
if _, err = f.tasks.GetTaskSource(ctx, f.token, *o.TaskID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = f.api.Get(ctx, f.token, plan.ID)
|
||||||
|
expectWorkerCode(t, err, "PERMISSION_DENIED")
|
||||||
|
server := httptest.NewServer(httpserver.New("", func(context.Context) error { return nil }, f.tasks).Handler)
|
||||||
|
defer server.Close()
|
||||||
|
call := func(path string, status int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
r, _ := http.NewRequestWithContext(ctx, "GET", server.URL+path, nil)
|
||||||
|
r.Header.Set("Authorization", "Bearer "+f.resources.Token)
|
||||||
|
resp, err := server.Client().Do(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != status {
|
||||||
|
t.Fatalf("status=%d want=%d body=%s", resp.StatusCode, status, raw)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
path := "/v1/tasks/" + string(*o.TaskID) + "/source"
|
||||||
|
raw := call(path, 200)
|
||||||
|
for _, secret := range []string{"task_template", "payload", "lease_secret", "secret_digest", "token"} {
|
||||||
|
if strings.Contains(string(raw), secret) {
|
||||||
|
t.Fatal("source leaked unrelated data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, suffix := range []string{"?limit=1", "?namespace_id=x", "?unknown=%zz"} {
|
||||||
|
call(path+suffix, 400)
|
||||||
|
}
|
||||||
|
call("/v1/tasks/not-an-id/source", 400)
|
||||||
|
call("/v1/tasks/"+string(executionID(t))+"/source", 404)
|
||||||
|
})
|
||||||
|
t.Run("coalesced_source_retains_exclusive_range", func(t *testing.T) {
|
||||||
|
f := newScheduleFixture(t, newExecutionFixture(t, ctx, db))
|
||||||
|
in := f.definition()
|
||||||
|
in.Policy.Misfire = "COALESCE"
|
||||||
|
plan := f.create(in)
|
||||||
|
f.due(plan, 3*time.Second)
|
||||||
|
f.tick()
|
||||||
|
found := false
|
||||||
|
for _, o := range f.occurrences(plan.ID) {
|
||||||
|
if o.Decision != "COALESCED" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
source, err := f.tasks.GetTaskSource(ctx, f.token, *o.TaskID)
|
||||||
|
if err != nil || source.Schedule == nil || source.Schedule.CoveredUntil == nil || !source.Schedule.CoveredUntil.Equal(*o.CoveredUntil) || !source.Schedule.ScheduledAt.Equal(o.ScheduledAt) {
|
||||||
|
t.Fatal(source, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("coalesced trigger missing")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("dangling_mismatched_and_unknown_owners_fail_closed", func(t *testing.T) {
|
||||||
|
f := newScheduleFixture(t, newExecutionFixture(t, ctx, db))
|
||||||
|
plan := f.create(f.definition())
|
||||||
|
f.due(plan, time.Second)
|
||||||
|
f.tick()
|
||||||
|
o := f.occurrences(plan.ID)[0]
|
||||||
|
id := *o.TaskID
|
||||||
|
broken := func() {
|
||||||
|
t.Helper()
|
||||||
|
out, err := f.tasks.GetTaskSource(ctx, f.token, id)
|
||||||
|
expectWorkerCode(t, err, "UNAVAILABLE")
|
||||||
|
if out.TaskID != "" || out.Schedule != nil {
|
||||||
|
t.Fatal("partial invalid source returned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.exec(`UPDATE pulse_tasks SET owner_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, id)
|
||||||
|
broken()
|
||||||
|
f.exec(`UPDATE pulse_tasks SET owner_id=? WHERE namespace_id=? AND id=?`, plan.ID, f.resources.NamespaceID, id)
|
||||||
|
f.exec(`UPDATE pulse_schedule_occurrences SET schedule_version=999 WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, o.ID)
|
||||||
|
broken()
|
||||||
|
f.exec(`UPDATE pulse_schedule_occurrences SET schedule_version=? WHERE namespace_id=? AND id=?`, o.ScheduleVersion, f.resources.NamespaceID, o.ID)
|
||||||
|
f.exec(`UPDATE pulse_schedules SET queue_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, plan.ID)
|
||||||
|
broken()
|
||||||
|
f.exec(`UPDATE pulse_schedules SET queue_id=? WHERE namespace_id=? AND id=?`, f.resources.QueueID, f.resources.NamespaceID, plan.ID)
|
||||||
|
f.exec(`UPDATE pulse_tasks SET owner_kind=NULL,owner_id=NULL WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, id)
|
||||||
|
broken()
|
||||||
|
f.exec(`UPDATE pulse_tasks SET owner_kind='workflow',owner_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, id)
|
||||||
|
broken()
|
||||||
|
f.exec(`UPDATE pulse_tasks SET owner_kind='schedule',owner_id=?,gate_id=? WHERE namespace_id=? AND id=?`, plan.ID, executionID(t), f.resources.NamespaceID, id)
|
||||||
|
broken()
|
||||||
|
f.exec(`UPDATE pulse_tasks SET gate_id=NULL WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, id)
|
||||||
|
if _, err = f.tasks.GetTaskSource(ctx, f.token, id); err != nil {
|
||||||
|
t.Fatal("restored valid source still refused", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -32,6 +32,13 @@ 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}/source", 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.GetTaskSource(r.Context(), t, core.ID(r.PathValue("task")))
|
||||||
|
return out, 200, err
|
||||||
|
}))
|
||||||
mux.HandleFunc("GET /v1/tasks/{task}/operations", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
mux.HandleFunc("GET /v1/tasks/{task}/operations", 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 {
|
||||||
|
|||||||
43
pulse/service/source.go
Normal file
43
pulse/service/source.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskSourceView 只读的直接来源,不遍历整条重试链,也不授予来源资源管理权限。
|
||||||
|
type TaskSourceView struct {
|
||||||
|
// TaskID 本次已授权查询的任务标识。
|
||||||
|
TaskID core.ID `json:"task_id"`
|
||||||
|
// Kind 当前支持DIRECT、MANUAL_RETRY、SCHEDULE;分别是普通受理、人工派生、计划触发。
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
// RetryOf 人工重试的直接原任务,限同一命名空间及队列;其他种类省略。
|
||||||
|
RetryOf *core.ID `json:"retry_of,omitempty"`
|
||||||
|
// Schedule 计划触发的固定来源,仅SCHEDULE返回,不包含任务模板或计划配置。
|
||||||
|
Schedule *TaskScheduleSource `json:"schedule,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskScheduleSource 关联已生成任务的触发记录及当时的不可变计划版本。
|
||||||
|
type TaskScheduleSource struct {
|
||||||
|
// ScheduleID 原计划标识,不因发布新版本而变化。
|
||||||
|
ScheduleID core.ID `json:"schedule_id"`
|
||||||
|
// ScheduleVersion 触发时使用的固定版本,JSON字符串避免精度丢失。
|
||||||
|
ScheduleVersion uint64 `json:"schedule_version,string"`
|
||||||
|
// OccurrenceID 唯一关联该任务的触发记录标识。
|
||||||
|
OccurrenceID core.ID `json:"occurrence_id"`
|
||||||
|
// ScheduledAt 原计划时间,UTC微秒精度,不等于实际受理/执行时间。
|
||||||
|
ScheduledAt time.Time `json:"scheduled_at"`
|
||||||
|
// Decision FIRED为单点触发,COALESCED为合并触发;跳过不会产生任务。
|
||||||
|
Decision string `json:"decision"`
|
||||||
|
// CoveredUntil 合并范围的排除边界,仅COALESCED返回。
|
||||||
|
CoveredUntil *time.Time `json:"covered_until,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetTaskSource(ctx context.Context, t auth.Token, id core.ID) (TaskSourceView, error) {
|
||||||
|
if err := validateHistoryIDs(id); err != nil {
|
||||||
|
return TaskSourceView{}, err
|
||||||
|
}
|
||||||
|
return s.store.GetTaskSource(ctx, t, id)
|
||||||
|
}
|
||||||
@@ -224,6 +224,7 @@ type QueueResult struct {
|
|||||||
|
|
||||||
// Store 实现命名空间隔离、事务授权与持久化,不在事务外产生业务副作用。
|
// Store 实现命名空间隔离、事务授权与持久化,不在事务外产生业务副作用。
|
||||||
type Store interface {
|
type Store interface {
|
||||||
|
GetTaskSource(context.Context, auth.Token, core.ID) (TaskSourceView, error)
|
||||||
ReviewTask(context.Context, auth.Token, core.ID, string, ReviewInput) (ReviewResult, error)
|
ReviewTask(context.Context, auth.Token, core.ID, string, ReviewInput) (ReviewResult, error)
|
||||||
RetryTask(context.Context, auth.Token, core.ID, string, ManualRetryInput) (ManualRetryResult, error)
|
RetryTask(context.Context, auth.Token, core.ID, string, ManualRetryInput) (ManualRetryResult, error)
|
||||||
ListTaskOperations(context.Context, auth.Token, core.ID, HistoryPageInput) (TaskOperationPage, error)
|
ListTaskOperations(context.Context, auth.Token, core.ID, HistoryPageInput) (TaskOperationPage, error)
|
||||||
|
|||||||
@@ -177,6 +177,14 @@ func run() error {
|
|||||||
if _, err := w.Capacity(ctx, id, orbit.WorkerCapacity{Sequence: 2, Drain: true}); err != nil {
|
if _, err := w.Capacity(ctx, id, orbit.WorkerCapacity{Sequence: 2, Drain: true}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
lineage, err := client.GetTaskSource(ctx, child.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if lineage.Kind != "MANUAL_RETRY" || lineage.RetryOf == nil || *lineage.RetryOf != original.ID {
|
||||||
|
return errors.New("manual retry source mismatch")
|
||||||
|
}
|
||||||
|
fmt.Printf("source_kind=%s parent=%s\n", lineage.Kind, *lineage.RetryOf)
|
||||||
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))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ func run() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if task.State == orbit.TaskSucceeded {
|
if task.State == orbit.TaskSucceeded {
|
||||||
|
origin, err := client.GetTaskSource(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if origin.Kind != "SCHEDULE" || origin.Schedule == nil || origin.Schedule.ScheduleID != created.Schedule.ID || origin.Schedule.OccurrenceID != item.ID || origin.Schedule.ScheduleVersion != item.ScheduleVersion {
|
||||||
|
return errors.New("task source did not match frozen occurrence")
|
||||||
|
}
|
||||||
|
fmt.Printf("source_kind=%s fixed_version=%d\n", origin.Kind, origin.Schedule.ScheduleVersion)
|
||||||
completed[task.ID] = true
|
completed[task.ID] = true
|
||||||
fmt.Printf("schedule=%s occurrence=%s task=%s version=%d state=%s\n", created.Schedule.ID, item.ID, task.ID, item.ScheduleVersion, task.State)
|
fmt.Printf("schedule=%s occurrence=%s task=%s version=%d state=%s\n", created.Schedule.ID, item.ID, task.ID, item.ScheduleVersion, task.State)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ func run() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
origin, err := client.GetTaskSource(ctx, current.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if origin.Kind != "DIRECT" {
|
||||||
|
return fmt.Errorf("expected direct task source")
|
||||||
|
}
|
||||||
|
fmt.Printf("source_kind=%s\n", origin.Kind)
|
||||||
fmt.Printf("task=%s duplicate=%t listed=%d final_state=%s revision=%d\n", current.ID, again.Deduplicated, len(page.Items), cancelled.Task.State, cancelled.Task.Revision)
|
fmt.Printf("task=%s duplicate=%t listed=%d final_state=%s revision=%d\n", current.ID, again.Deduplicated, len(page.Items), cancelled.Task.State, cancelled.Task.Revision)
|
||||||
if cancelled.Task.State != orbit.TaskCancelled {
|
if cancelled.Task.State != orbit.TaskCancelled {
|
||||||
return fmt.Errorf("expected CANCELLED")
|
return fmt.Errorf("expected CANCELLED")
|
||||||
|
|||||||
@@ -126,6 +126,14 @@ func TestIntegrationTaskRecoverySDK(t *testing.T) {
|
|||||||
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 {
|
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)
|
t.Fatal(child, err)
|
||||||
}
|
}
|
||||||
|
lineage, err := api.GetTaskSource(ctx, child.Task.ID)
|
||||||
|
if err != nil || lineage.Kind != "MANUAL_RETRY" || lineage.RetryOf == nil || *lineage.RetryOf != unknown.ID || lineage.Schedule != nil {
|
||||||
|
t.Fatal("manual retry source", lineage, err)
|
||||||
|
}
|
||||||
|
originalSource, err := api.GetTaskSource(ctx, unknown.ID)
|
||||||
|
if err != nil || originalSource.Kind != "DIRECT" || originalSource.RetryOf != nil {
|
||||||
|
t.Fatal("direct source", originalSource, err)
|
||||||
|
}
|
||||||
assigned, err := w.NextAssignment(ctx)
|
assigned, err := w.NextAssignment(ctx)
|
||||||
if err != nil || assigned.Task.ID != child.Task.ID || assigned.Task.RetryOf == nil || *assigned.Task.RetryOf != unknown.ID {
|
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)
|
t.Fatal("wire lost retry lineage", assigned, err)
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ func TestIntegrationScheduleLifecycle(t *testing.T) {
|
|||||||
if err != nil || updated.Schedule.CurrentVersion != 2 || !updated.Schedule.Paused {
|
if err != nil || updated.Schedule.CurrentVersion != 2 || !updated.Schedule.Paused {
|
||||||
t.Fatal("publish schedule", err)
|
t.Fatal("publish schedule", err)
|
||||||
}
|
}
|
||||||
|
source, err := api.GetTaskSource(ctx, task.ID)
|
||||||
|
if err != nil || source.Kind != "SCHEDULE" || source.Schedule == nil || source.Schedule.ScheduleID != paused.Schedule.ID || source.Schedule.OccurrenceID != occurrence.ID || source.Schedule.ScheduleVersion != 1 || !source.Schedule.ScheduledAt.Equal(occurrence.ScheduledAt) {
|
||||||
|
t.Fatal("published version changed task source", source, err)
|
||||||
|
}
|
||||||
old, err := api.GetScheduleVersion(ctx, paused.Schedule.ID, 1)
|
old, err := api.GetScheduleVersion(ctx, paused.Schedule.ID, 1)
|
||||||
if err != nil || old.Definition.Rule.Kind != "FIXED_RATE" {
|
if err != nil || old.Definition.Rule.Kind != "FIXED_RATE" {
|
||||||
t.Fatal("old version lost", err)
|
t.Fatal("old version lost", err)
|
||||||
|
|||||||
68
sdk/go/task_source.go
Normal file
68
sdk/go/task_source.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package orbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskSource 只读的直接来源,不遍历整条重试链,也不授予来源资源管理权限。
|
||||||
|
type TaskSource struct {
|
||||||
|
// TaskID 本次已授权查询的任务标识。
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
// Kind 当前支持DIRECT、MANUAL_RETRY、SCHEDULE;分别是普通受理、人工派生、计划触发。
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
// RetryOf 人工重试的直接原任务,限同一命名空间及队列;其他种类省略。
|
||||||
|
RetryOf *string `json:"retry_of,omitempty"`
|
||||||
|
// Schedule 计划触发的固定来源,仅SCHEDULE返回,不包含任务模板或计划配置。
|
||||||
|
Schedule *TaskScheduleSource `json:"schedule,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskScheduleSource 关联已生成任务的触发记录及当时的不可变计划版本。
|
||||||
|
type TaskScheduleSource struct {
|
||||||
|
// ScheduleID 原计划标识,不因发布新版本而变化。
|
||||||
|
ScheduleID string `json:"schedule_id"`
|
||||||
|
// ScheduleVersion 触发时使用的固定版本,JSON字符串避免精度丢失。
|
||||||
|
ScheduleVersion uint64 `json:"schedule_version,string"`
|
||||||
|
// OccurrenceID 唯一关联该任务的触发记录标识。
|
||||||
|
OccurrenceID string `json:"occurrence_id"`
|
||||||
|
// ScheduledAt 原计划时间,UTC微秒精度,不等于实际受理/执行时间。
|
||||||
|
ScheduledAt time.Time `json:"scheduled_at"`
|
||||||
|
// Decision FIRED为单点触发,COALESCED为合并触发;跳过不会产生任务。
|
||||||
|
Decision string `json:"decision"`
|
||||||
|
// CoveredUntil 合并范围的排除边界,仅COALESCED返回。
|
||||||
|
CoveredUntil *time.Time `json:"covered_until,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTaskSource 查询直接来源;不会授予来源计划的读取/管理或任务执行权限。
|
||||||
|
func (c *Client) GetTaskSource(ctx context.Context, id string) (TaskSource, error) {
|
||||||
|
var out TaskSource
|
||||||
|
if !validID(id) {
|
||||||
|
return out, errors.New("invalid task id")
|
||||||
|
}
|
||||||
|
if err := c.call(ctx, "GET", "/v1/tasks/"+id+"/source", "", nil, &out); err != nil {
|
||||||
|
return TaskSource{}, err
|
||||||
|
}
|
||||||
|
if out.TaskID != id {
|
||||||
|
return TaskSource{}, errors.New("source does not match requested task")
|
||||||
|
}
|
||||||
|
valid := false
|
||||||
|
switch out.Kind {
|
||||||
|
case "DIRECT":
|
||||||
|
valid = out.RetryOf == nil && out.Schedule == nil
|
||||||
|
case "MANUAL_RETRY":
|
||||||
|
valid = out.RetryOf != nil && validID(*out.RetryOf) && *out.RetryOf != id && out.Schedule == nil
|
||||||
|
case "SCHEDULE":
|
||||||
|
s := out.Schedule
|
||||||
|
if s != nil && out.RetryOf == nil && validID(s.ScheduleID) && validID(s.OccurrenceID) && s.ScheduleVersion > 0 && validSourceTime(s.ScheduledAt) {
|
||||||
|
valid = s.Decision == "FIRED" && s.CoveredUntil == nil || s.Decision == "COALESCED" && s.CoveredUntil != nil && validSourceTime(*s.CoveredUntil) && s.CoveredUntil.After(s.ScheduledAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return TaskSource{}, errors.New("invalid or unsupported task source response")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func validSourceTime(t time.Time) bool {
|
||||||
|
return t.Year() >= 2000 && t.Year() <= 9998 && t.Nanosecond()%1000 == 0
|
||||||
|
}
|
||||||
61
sdk/go/task_source_test.go
Normal file
61
sdk/go/task_source_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package orbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTaskSourceValidationAndPrecision(t *testing.T) {
|
||||||
|
const task = "11111111111111111111111111111111"
|
||||||
|
const parent = "22222222222222222222222222222222"
|
||||||
|
const occurrence = "33333333333333333333333333333333"
|
||||||
|
at := time.Date(2026, 9, 17, 0, 0, 0, 123456000, time.UTC)
|
||||||
|
until := at.Add(time.Minute)
|
||||||
|
var reply atomic.Value
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
if r.Method != "GET" || r.URL.Path != "/v1/tasks/"+task+"/source" || r.Header.Get("Authorization") != "Bearer example-token" || r.Header.Get("Idempotency-Key") != "" {
|
||||||
|
t.Error("invalid source request")
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"request_id": task, "data": reply.Load()})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client, _ := NewClient(server.URL, Options{Token: "example-token"})
|
||||||
|
p := parent
|
||||||
|
valid := []TaskSource{{TaskID: task, Kind: "DIRECT"}, {TaskID: task, Kind: "MANUAL_RETRY", RetryOf: &p}, {TaskID: task, Kind: "SCHEDULE", Schedule: &TaskScheduleSource{ScheduleID: parent, ScheduleVersion: 9007199254740993, OccurrenceID: occurrence, ScheduledAt: at, Decision: "FIRED"}}, {TaskID: task, Kind: "SCHEDULE", Schedule: &TaskScheduleSource{ScheduleID: parent, ScheduleVersion: 1, OccurrenceID: occurrence, ScheduledAt: at, Decision: "COALESCED", CoveredUntil: &until}}}
|
||||||
|
for _, v := range valid {
|
||||||
|
reply.Store(v)
|
||||||
|
out, err := client.GetTaskSource(context.Background(), task)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v.Schedule != nil && out.Schedule.ScheduleVersion != v.Schedule.ScheduleVersion {
|
||||||
|
t.Fatal("version precision lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self := task
|
||||||
|
invalid := []TaskSource{{TaskID: parent, Kind: "DIRECT"}, {TaskID: task, Kind: "unknown"}, {TaskID: task, Kind: "DIRECT", RetryOf: &p}, {TaskID: task, Kind: "MANUAL_RETRY"}, {TaskID: task, Kind: "MANUAL_RETRY", RetryOf: &self}, {TaskID: task, Kind: "SCHEDULE"}}
|
||||||
|
for _, change := range []func(*TaskScheduleSource){func(v *TaskScheduleSource) { v.ScheduleVersion = 0 }, func(v *TaskScheduleSource) { v.Decision = "SKIPPED" }, func(v *TaskScheduleSource) { v.Decision = "COALESCED" }, func(v *TaskScheduleSource) { v.CoveredUntil = &until }, func(v *TaskScheduleSource) { v.ScheduledAt = time.Time{} }, func(v *TaskScheduleSource) { v.OccurrenceID = "invalid" }} {
|
||||||
|
v := valid[2]
|
||||||
|
s := *v.Schedule
|
||||||
|
change(&s)
|
||||||
|
v.Schedule = &s
|
||||||
|
invalid = append(invalid, v)
|
||||||
|
}
|
||||||
|
for _, v := range invalid {
|
||||||
|
reply.Store(v)
|
||||||
|
if _, err := client.GetTaskSource(context.Background(), task); err == nil {
|
||||||
|
t.Fatal("invalid source accepted", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
before := calls.Load()
|
||||||
|
if _, err := client.GetTaskSource(context.Background(), "../tasks"); err == nil || calls.Load() != before {
|
||||||
|
t.Fatal("unsafe source path sent")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user