feat: add idempotent batch task submission and recovery examples
This commit is contained in:
@@ -14,4 +14,6 @@
|
|||||||
|
|
||||||
- 结果日志的序号在独立命名空间流行锁下分配,与任务状态及原事件同事务提交;禁止用最大事件ID或时间戳推断提交水位。生产者不得锁订阅行。
|
- 结果日志的序号在独立命名空间流行锁下分配,与任务状态及原事件同事务提交;禁止用最大事件ID或时间戳推断提交水位。生产者不得锁订阅行。
|
||||||
- 创建订阅先锁队列再锁流行;消费按订阅→交付持锁,不能再取得任务、队列、额度或流行锁。ACK只验证最近代次及证明,过期未被替代的证明仍可确认;重复确认不能再次扣减pending_count。
|
- 创建订阅先锁队列再锁流行;消费按订阅→交付持锁,不能再取得任务、队列、额度或流行锁。ACK只验证最近代次及证明,过期未被替代的证明仍可确认;重复确认不能再次扣减pending_count。
|
||||||
- 可靠订阅已完成核心、HTTP/WSS、管理及消费SDK、独立业务Inbox示例、节点及消费者进程故障回归。P3仍需补齐批量受理、独立任务核查/人工重试、任务查询筛选和事务Outbox接入;不能因订阅完成而跳过原功能范围。
|
- 可靠订阅已完成核心、HTTP/WSS、管理及消费SDK、独立业务Inbox示例、节点及消费者进程故障回归。批量受理已提供逐项幂等与未知结果分类。P3仍需补齐独立任务核查/人工重试、任务查询筛选和事务Outbox接入;不能因订阅完成而跳过原功能范围。
|
||||||
|
|
||||||
|
- 批量受理最多32项、512 KiB;每项复用单项身份与事务,未知结果必须原键核对。任务提交按认证→应用额度→提交幂等身份→队列持锁,禁止恢复为先抢提交唯一键后等待额度锁;原回执仍须先于配额上限检查返回。
|
||||||
|
|||||||
@@ -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) 已实现;剩余 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 示例;剩余 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 HTTP 与 WebSocket 协议草案
|
# Orbit HTTP 与 WebSocket 协议草案
|
||||||
|
|
||||||
> 版本 0.2 · 2026-09-17。HTTP + WS 已确认。本文的具体路径、消息名、字段及状态码是首版设计建议,业务协议尚未实现,需评审后冻结;健康接口的已实现契约见接口手册。
|
> 版本 0.2 · 2026-09-17。HTTP + WS 已确认。本文保留首版设计建议;已实现能力以[正式接口手册](api/README.md)为准,其余路径、消息名、字段和状态码仍待对应阶段实现及验证。
|
||||||
|
|
||||||
实现更新:HTTP 任务/队列接口已完成 P1;Worker 服务端已进入 P2,实际帧名、字段、回执与边界见 [Worker 契约](api/worker.md)。本文保留早期完整设计,未实现项目不能据此视为已交付。
|
实现更新:HTTP 任务/队列接口已完成 P1;Worker 服务端已进入 P2,实际帧名、字段、回执与边界见 [Worker 契约](api/worker.md)。本文保留早期完整设计,未实现项目不能据此视为已交付。
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
| 方法与路径 | 行为 |
|
| 方法与路径 | 行为 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `POST /v1/tasks` | 幂等提交一项任务 |
|
| `POST /v1/tasks` | 幂等提交一项任务 |
|
||||||
| `POST /v1/tasks:batchSubmit` | 批量逐项提交,每项独立幂等 |
|
| `POST /v1/tasks:batch` | 已实现;逐项身份、四类结果与限制见[批量手册](api/batch-tasks.md) |
|
||||||
| `GET /v1/tasks/{task_id}` | 查询权威任务状态与结果摘要 |
|
| `GET /v1/tasks/{task_id}` | 查询权威任务状态与结果摘要 |
|
||||||
| `GET /v1/tasks/{task_id}/attempts` | 分页查询执行尝试 |
|
| `GET /v1/tasks/{task_id}/attempts` | 分页查询执行尝试 |
|
||||||
| `GET /v1/tasks` | 按有索引的字段分页查询,支持提交幂等键精确查找 |
|
| `GET /v1/tasks` | 按有索引的字段分页查询,支持提交幂等键精确查找 |
|
||||||
@@ -55,7 +55,7 @@ Weave 的补充接口建议如下,功能范围已确认,具体技术细则
|
|||||||
|
|
||||||
- 首次创建任务、流程或信号记录的建议响应为 `201 Created`,表示资源及幂等记录已可靠提交,不表示业务执行或信号消费完成。
|
- 首次创建任务、流程或信号记录的建议响应为 `201 Created`,表示资源及幂等记录已可靠提交,不表示业务执行或信号消费完成。
|
||||||
- 相同幂等请求返回 `200` 和原资源 ID,标记 `deduplicated: true`;相同键不同语义请求返回 `409`。
|
- 相同幂等请求返回 `200` 和原资源 ID,标记 `deduplicated: true`;相同键不同语义请求返回 `409`。
|
||||||
- 单项提交使用 `Idempotency-Key`;批量每项使用 `submission_key`,由 SDK 统一映射为提交幂等标识。
|
- 单项提交使用 `Idempotency-Key`;已实现的批量每项使用 `idempotency_key`,与单项共用受理身份;不接受整批幂等头,见[批量接口](api/batch-tasks.md)。
|
||||||
- 普通任务提交幂等作用域为命名空间内的任务提交操作;流程启动使用独立操作作用域,信号按命名空间、实例与信号 ID 去重。其他管理写操作按命名空间、操作和资源界定作用域,避免同一个键在不同 API 间意外冲突。
|
- 普通任务提交幂等作用域为命名空间内的任务提交操作;流程启动使用独立操作作用域,信号按命名空间、实例与信号 ID 去重。其他管理写操作按命名空间、操作和资源界定作用域,避免同一个键在不同 API 间意外冲突。
|
||||||
- HTTP `request_id` 仅用于追踪,与业务幂等键分开。
|
- HTTP `request_id` 仅用于追踪,与业务幂等键分开。
|
||||||
- 请求超时后,使用原幂等键查询或重试;不能通过换一个键来猜测原请求是否失败。
|
- 请求超时后,使用原幂等键查询或重试;不能通过换一个键来猜测原请求是否失败。
|
||||||
|
|||||||
@@ -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响应丢失及消费者进程故障回归已完成;批量受理、独立任务核查/人工重试、业务键/时间筛选与事务Outbox接入待完成 |
|
| P3 Pulse 完整能力 | 安全重试、延时、Cron/固定间隔、漏跑策略、计划版本、运行中取消、并发许可、配额/背压、可靠结果订阅、批量受理、独立任务核查/人工重试、查询筛选与事务Outbox接入 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 进行中:安全重试、计划调度、运行中取消、队列控制/限速/积压背压、跨命名空间应用配额、显式资源键许可及 SDK 场景已实现并通过全量回归;优先级及节点公平派发已实现并通过全量回归;可靠订阅核心、HTTP/WSS、管理 SDK 和管理示例已实现并回归;消费 SDK、协议示例及两项节点强杀回归已完成;业务 Inbox 场景、COMMIT响应丢失及消费者进程故障回归已完成;批量受理、SDK、接口与部分补交示例已完成全量回归;独立任务核查/人工重试、业务键/时间筛选与事务Outbox接入待完成 |
|
||||||
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 待开发 |
|
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 待开发 |
|
||||||
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 待开发 |
|
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 待开发 |
|
||||||
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 |
|
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 |
|
||||||
@@ -37,11 +37,11 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最
|
|||||||
|
|
||||||
### P3 功能范围核对
|
### P3 功能范围核对
|
||||||
|
|
||||||
可靠订阅链路完成后,重新对照 [原功能范围](01-functional-spec.md) 和 [事务衔接设计](04-reliability-and-data.md),以下原定首版能力仍有缺口。不能只按已实现模块反推P3完成;先补齐并回归,再进入完整阶段验收。
|
可靠订阅链路完成后,重新对照 [原功能范围](01-functional-spec.md) 和 [事务衔接设计](04-reliability-and-data.md),以下原定首版能力按核对结果逐项补齐。不能只按已实现模块反推P3完成;全部补齐并回归后,再进入完整阶段验收。
|
||||||
|
|
||||||
| 剩余项 | 当前证据与后续交付 |
|
| 核对项 | 当前证据与后续交付 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 批量任务受理 | [任务API](api/tasks.md) 明确尚未提供;需有界逐项结果、独立幂等、部分失败补交、SDK与运行示例 |
|
| 批量任务受理 | 已实现[批量API](api/batch-tasks.md)、独立SDK及[补交示例](examples/batch.md);真实MySQL部分失败与HTTP响应丢失用例已通过,同键并发20轮及全量回归已通过,见[报告](reports/phase-3-batch-tasks.md) |
|
||||||
| 独立任务人工核查与重试 | [重试API](api/retries.md) 仍未提供解除不确定性的核查接口;需证据、权限、版本、审计和历史保留,人工重试身份按关联新任务实现并冻结契约 |
|
| 独立任务人工核查与重试 | [重试API](api/retries.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/数据库依赖 |
|
||||||
@@ -63,6 +63,7 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最
|
|||||||
| 跨队列共享资源 | 两队列同键 → 一个有效许可 → 递增序号 → 资源端拒绝旧写入 | P3,已实现完整示例 |
|
| 跨队列共享资源 | 两队列同键 → 一个有效许可 → 递增序号 → 资源端拒绝旧写入 | P3,已实现完整示例 |
|
||||||
| 优先级与应用隔离 | 近同时到期按优先级 → 旧任务等待补偿 → 应用/队列轮转 | P3,已实现完整示例 |
|
| 优先级与应用隔离 | 近同时到期按优先级 → 旧任务等待补偿 → 应用/队列轮转 | P3,已实现完整示例 |
|
||||||
| 可靠结果订阅 | 冻结结果 → 持久化交付 → Inbox 去重 → 确认/补发 → 耗尽后人工重试 | P3,核心、HTTP/WSS、管理及消费 SDK、独立Inbox示例与真实进程故障回归已完成 |
|
| 可靠结果订阅 | 冻结结果 → 持久化交付 → Inbox 去重 → 确认/补发 → 耗尽后人工重试 | P3,核心、HTTP/WSS、管理及消费 SDK、独立Inbox示例与真实进程故障回归已完成 |
|
||||||
|
| 批量文件作业受理 | 逐项身份 → 部分结果 → 同键核对 → 释放额度 → 只补交失败项 | P3,已实现完整示例 |
|
||||||
| 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4 |
|
| 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4 |
|
||||||
| 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4 |
|
| 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4 |
|
||||||
| 外部作业轮询 | 提交一次 → 有界查询处理中状态 → 回执或最终结果 → 超限核查 | P5 |
|
| 外部作业轮询 | 提交一次 → 有界查询处理中状态 → 回执或最终结果 → 超限核查 | P5 |
|
||||||
@@ -94,6 +95,7 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最
|
|||||||
- [P3 订阅通信与管理 SDK 回归](reports/phase-3-result-subscription-transport.md)
|
- [P3 订阅通信与管理 SDK 回归](reports/phase-3-result-subscription-transport.md)
|
||||||
- [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)
|
||||||
- [接口手册](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 流程编排模块与公共能力。平台面向多个业务系统复用。
|
||||||
|
|
||||||
## 阅读顺序
|
## 阅读顺序
|
||||||
|
|||||||
@@ -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` 提交、查询、分页与待执行取消 | P1 已实现 | [任务接口](tasks.md) |
|
| `/v1/tasks` 提交、查询、分页与待执行取消 | P1 已实现 | [任务接口](tasks.md) |
|
||||||
|
| `POST /v1/tasks:batch` | P3 已实现逐项受理、独立幂等、部分结果与补交 | [批量任务](batch-tasks.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) |
|
||||||
|
|||||||
160
docs/api/batch-tasks.md
Normal file
160
docs/api/batch-tasks.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# 批量任务受理 v1
|
||||||
|
|
||||||
|
状态:P3 已实现。`POST /v1/tasks:batch` 与 SDK `SubmitTasks` 提交一组独立任务,返回每项的受理结果。它不提供流程顺序、整批原子事务或业务效果恰好一次保证;步骤依赖由后续 Weave 管理。
|
||||||
|
|
||||||
|
## 1. 请求与权限
|
||||||
|
|
||||||
|
请求头必须有 `Authorization: Bearer <token>` 与 `Content-Type: application/json`。**不传 `Idempotency-Key` 请求头**,传入会返回400;每项在正文使用自己的 `idempotency_key`。凭据必须具备 `tasks:submit`。预认证后,每项写入仍在自己的事务内重新校验凭据、动作、目标队列范围和命名空间归属。
|
||||||
|
|
||||||
|
| 字段 | 类型与约束 |
|
||||||
|
| --- | --- |
|
||||||
|
| items | 数组,1至32项,保持输入顺序处理 |
|
||||||
|
| items[].idempotency_key | 1至128个ASCII可打印非空白字符,同一批内不能重复;调用方发送前持久化 |
|
||||||
|
| items[].task | 与 [POST /v1/tasks](tasks.md#post-v1tasks) 完全相同的任务对象、默认值及归一化规则 |
|
||||||
|
|
||||||
|
总请求不超过512 KiB,每项payload仍不超过256 KiB。服务端请求上下文最多8秒;调用方可能更早取消。SDK不自动拆批或并发发送,32项是保护边界,不是吞吐承诺。不要通过增加批次大小规避背压。
|
||||||
|
|
||||||
|
示意请求(将队列ID替换为初始化结果):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"idempotency_key": "conversion-001",
|
||||||
|
"task": {
|
||||||
|
"queue_id": "11111111111111111111111111111111",
|
||||||
|
"handler": "file.convert",
|
||||||
|
"handler_version": "v1",
|
||||||
|
"payload_version": "v1",
|
||||||
|
"payload": {"source_ref": "objects/input-001.txt", "sequence": 9007199254740993}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idempotency_key": "conversion-002",
|
||||||
|
"task": {
|
||||||
|
"queue_id": "11111111111111111111111111111111",
|
||||||
|
"handler": "file.convert",
|
||||||
|
"handler_version": "v1",
|
||||||
|
"payload_version": "v1",
|
||||||
|
"payload": {"source_ref": "objects/input-002.txt"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
保存为 `batch.json` 后调用:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -i "$ORBIT_URL/v1/tasks:batch" \
|
||||||
|
-H "Authorization: Bearer $ORBIT_TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data-binary @batch.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 响应与逐项状态
|
||||||
|
|
||||||
|
整批格式与初始权限检查通过后返回HTTP200,即使其中部分或全部项目被拒绝。必须读取 `data.items`,不能只依据HTTP状态判断受理成功。每个输入恰好对应一条结果,`index` 为**本次请求**内从0开始的索引。只补交子集时索引重新编号,稳定身份仍由原幂等键确定。
|
||||||
|
|
||||||
|
| status | 字段 | 本次请求的含义与处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ACCEPTED | receipt,无error | 已确认受理事务提交或核对到原回执;任务执行结果另查或订阅 |
|
||||||
|
| REJECTED | error,无receipt | 本项明确拒绝,不因本次调用新增任务;需按错误原因处理,既有同键任务可能仍存在 |
|
||||||
|
| UNKNOWN | error.code=UNAVAILABLE | 无法确认提交结果,包括数据库/提交响应异常;必须用原键和原参数核对 |
|
||||||
|
| NOT_ATTEMPTED | error.code=BATCH_STOPPED | 本请求未尝试该项;之前其他请求可能已受理,仍须沿用原身份 |
|
||||||
|
|
||||||
|
例如第一项受理、第二项队列满额(ID和时间仅作示意):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id": "44444444444444444444444444444444",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"status": "ACCEPTED",
|
||||||
|
"receipt": {
|
||||||
|
"task_id": "22222222222222222222222222222222",
|
||||||
|
"queue_id": "11111111111111111111111111111111",
|
||||||
|
"revision": "1",
|
||||||
|
"created_at": "2026-09-17T00:00:00Z",
|
||||||
|
"deduplicated": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"status": "REJECTED",
|
||||||
|
"error": {
|
||||||
|
"code": "QUEUE_FULL",
|
||||||
|
"message": "queue unfinished task limit reached; retry later using the original identity",
|
||||||
|
"retry_after_ms": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`receipt` 是原始受理回执的精简视图,字段如下:
|
||||||
|
|
||||||
|
| 字段 | 类型与含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| task_id / queue_id | 32位小写十六进制标识 |
|
||||||
|
| revision | 十进制字符串,原受理版本;任务已执行或取消后重报仍返回原版本 |
|
||||||
|
| created_at | 原任务创建时间,UTC RFC3339微秒精度 |
|
||||||
|
| deduplicated | bool,本项是否复用已有受理回执 |
|
||||||
|
|
||||||
|
回执不重复携带payload、结果或策略,32项返回大小有界。需要当前完整状态时调用 `GetTask`;取消、核查等后续操作应先查询当前版本。
|
||||||
|
|
||||||
|
## 3. 整批拒绝与部分失败
|
||||||
|
|
||||||
|
开始处理任何项目之前,完整读取并严格解析整个请求。以下情况整批返回错误信封,当前请求不写任务:
|
||||||
|
|
||||||
|
- HTTP400:空批/超过32项、无效或重复的项幂等键、传整批幂等头、未知字段/大小写别名/重复JSON键、字段类型或ID/时间编码错误、非法JSON/Unicode、非可空字段为null。
|
||||||
|
- HTTP413:总请求超过512 KiB;HTTP415:Content-Type不支持。
|
||||||
|
- HTTP401/403:初始凭据无效或无tasks:submit动作权限。
|
||||||
|
- HTTP503:预认证数据库不可用;此时还未进入逐项受理。
|
||||||
|
|
||||||
|
结构正确后,任务语义错误按项返回 `REJECTED/INVALID_ARGUMENT`,例如处理器为空、优先级超过9、有效时间范围不满足要求。队列不存在或跨命名空间为NOT_FOUND,缺少队列范围为PERMISSION_DENIED,同身份不同内容为IDEMPOTENCY_CONFLICT。队列或应用未终结上限分别为QUEUE_FULL、APPLICATION_FULL,按项提供1000毫秒退避提示;HTTP200不附整批Retry-After头。
|
||||||
|
|
||||||
|
处理遇到未知数据库结果后立即停止余项:当前项UNKNOWN,余项NOT_ATTEMPTED。中途凭据失效时当前项REJECTED/UNAUTHENTICATED,余项停止。上下文取消时,已确认提交的前项保留ACCEPTED,尚未开始的后项NOT_ATTEMPTED;在途项若无法确认提交则UNKNOWN。响应本身也可能丢失,不能依赖一定能收到这些状态。
|
||||||
|
|
||||||
|
一项队列越权、语义错误、幂等冲突或额度不足,不阻止后续独立项尝试。不在请求之间保存批次游标或整批快照,也不撤销此前已经受理的项目。每项任务、幂等记录、额度、事件与审计继续同事务提交;没有新增数据库表、外键或级联操作。
|
||||||
|
|
||||||
|
## 4. 幂等与补交
|
||||||
|
|
||||||
|
1. 在业务自己的事务中保存每项稳定键和完整请求内容,再发给平台。业务变更与平台受理仍有跨系统事务边界,后续事务Outbox示例单独交付。
|
||||||
|
2. 批量与单项提交使用相同的 `task.submit` 命名空间身份。批量受理后用同键调用SubmitTask会去重;反方向相同。换批次、重排或只补交子集不会改变身份。
|
||||||
|
3. 原回执核对先于当前积压额度检查,队列满额时仍可获取已受理项目的回执;当前凭据与队列授权仍须有效。
|
||||||
|
4. UNKNOWN、超时、连接断开或无法验证的响应:保留原键、原参数和原相对时限;不要重算available_at,不生成新键。不确定哪些项成功时可重报整批。
|
||||||
|
5. 明确额度不足时,退避并释放/等待配额后,可只补交被拒项目,仍用原身份。参数错误需要修正时先确认该操作从未受理,不能把“换键”当作解决幂等冲突的方法。
|
||||||
|
6. 幂等记录当前标记90天过期但尚无在线清理;生产清理窗口按后续保留策略执行,业务不得假定无限去重。
|
||||||
|
|
||||||
|
队列暂停仍允许受理。暂停、取消和任务执行与批量接口独立,提交顺序不保证执行顺序。业务效果去重仍由Worker/接收方负责。
|
||||||
|
|
||||||
|
## 5. Go SDK
|
||||||
|
|
||||||
|
```go
|
||||||
|
// items 来自业务库中的持久化待发记录。
|
||||||
|
result, err := client.SubmitTasks(ctx, orbit.SubmitTasksRequest{Items: items})
|
||||||
|
if err != nil {
|
||||||
|
// 网络/响应错误无法证明未提交;保留所有原身份,稍后核对。
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, item := range result.Items {
|
||||||
|
switch item.Status {
|
||||||
|
case orbit.BatchAccepted:
|
||||||
|
// 将task_id与对应的持久化操作关联;此处不代表业务执行成功。
|
||||||
|
fmt.Println(item.Index, item.Receipt.TaskID, item.Receipt.Deduplicated)
|
||||||
|
case orbit.BatchRejected:
|
||||||
|
// 根据item.Error.Code决定修正、人工处理或按RetryAfterMS退避。
|
||||||
|
fmt.Println(item.Index, item.Error.Code)
|
||||||
|
case orbit.BatchUnknown, orbit.BatchNotAttempted:
|
||||||
|
// 使用items[item.Index]的原键和原参数保留待核对状态。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SDK验证输入数量/身份/整体体积及返回数量、顺序、状态和回执归属;不自动重试、拆批或创建新键。`err == nil` 只表示取得可解释的逐项响应。局部错误为 `BatchItemError`,不是顶层APIError;整批HTTP错误仍为APIError,网络错误沿用标准库。独立SDK无数据库依赖。
|
||||||
|
|
||||||
|
完整运行命令、输入输出和收尾见 [批量场景](../examples/batch.md),源码见 [SDK示例](../../sdk/go/examples/batch/main.go),覆盖范围和实际证据见 [回归报告](../reports/phase-3-batch-tasks.md)。旧客户端继续使用单项接口;新SDK调用旧服务该路由会返回404,不会静默退化为其他语义。
|
||||||
@@ -6,7 +6,7 @@ P2 进展:服务端执行内核、WSS 与独立自动 Worker SDK 已可产生
|
|||||||
|
|
||||||
## 公共约定
|
## 公共约定
|
||||||
|
|
||||||
业务接口需要 `Authorization: Bearer <token>`;写入需要 `Content-Type: application/json` 和稳定的 `Idempotency-Key`。凭据决定命名空间,请求体不能覆盖它。详见 [鉴权与初始化](authentication.md)。
|
业务接口需要 `Authorization: Bearer <token>`;单项写入需要 `Content-Type: application/json` 和稳定的 `Idempotency-Key`;[批量受理](batch-tasks.md) 在每项正文提供独立幂等键。凭据决定命名空间,请求体不能覆盖它。详见 [鉴权与初始化](authentication.md)。
|
||||||
|
|
||||||
请求体上限 512 KiB。拒绝未知字段、大小写别名、重复 JSON 键、无效 UTF-8、孤立 Unicode 代理项和超过 32 层的嵌套。除可空时间外,字段不能显式传 null。请求追踪 ID 由服务端生成,与幂等键分开。
|
请求体上限 512 KiB。拒绝未知字段、大小写别名、重复 JSON 键、无效 UTF-8、孤立 Unicode 代理项和超过 32 层的嵌套。除可空时间外,字段不能显式传 null。请求追踪 ID 由服务端生成,与幂等键分开。
|
||||||
|
|
||||||
@@ -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) 已实现;批量受理和人工重试尚未实现。[执行历史](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) 已实现逐项结果与原身份补交;独立任务人工重试仍待实现。[执行历史](execution-history.md) 已提供尝试与证据的列表和详情。完整可运行程序见 [SDK 任务示例](../../sdk/go/examples/tasks/main.go),已验证异常场景见 [P1 回归报告](../reports/phase-1.md) 及 [取消回归](../reports/phase-3-cancellation.md)。SDK 忽略未知响应字段;更改已有字段语义需升级契约和回归。
|
||||||
|
|||||||
@@ -130,3 +130,11 @@
|
|||||||
- 输出:inbox_duplicate=true、applied_events=1、ack_confirmed=1、pending=0。
|
- 输出:inbox_duplicate=true、applied_events=1、ack_confirmed=1、pending=0。
|
||||||
- 覆盖:并发重复、旧版本保护、回滚、真实MySQL COMMIT响应丢失、业务提交后/ACK前消费者SIGKILL及新进程去重。
|
- 覆盖:并发重复、旧版本保护、回滚、真实MySQL COMMIT响应丢失、业务提交后/ACK前消费者SIGKILL及新进程去重。
|
||||||
- 边界:只覆盖接收方本地SQL效果;核心SDK不依赖数据库,外部副作用需要接收方自己的幂等/Outbox设计。
|
- 边界:只覆盖接收方本地SQL效果;核心SDK不依赖数据库,外部副作用需要接收方自己的幂等/Outbox设计。
|
||||||
|
|
||||||
|
## 已可运行:批量受理与部分补交
|
||||||
|
|
||||||
|
- 源码:[batch](../../sdk/go/examples/batch/main.go),[完整启动命令和输出](batch.md)。
|
||||||
|
- 步骤:独立暂停队列 → 三项批量提交、两项成功一项满额 → 整批核对原回执 → 取消释放额度 → 仅原身份补交第三项 → 取消收尾。
|
||||||
|
- 预期:accepted=2、replayed=2、retried_items=1、unfinished=0。
|
||||||
|
- 异常:逐项区分REJECTED/UNKNOWN/NOT_ATTEMPTED;真实HTTP响应丢失后恢复无重复,保留当前权限和额度控制。
|
||||||
|
- 范围:示例只演示受理,不转换文件、不提供业务Outbox;失败非零退出,每项60秒总时限,保留队列及审计。
|
||||||
|
|||||||
50
docs/examples/batch.md
Normal file
50
docs/examples/batch.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 批量文件作业受理与部分补交
|
||||||
|
|
||||||
|
[完整源码](../../sdk/go/examples/batch/main.go) 使用独立Go SDK,不导入服务端模型或数据库驱动。它演示受理与取消,不启动Worker或转换真实文件。
|
||||||
|
|
||||||
|
## 前置与运行
|
||||||
|
|
||||||
|
使用已迁移的MySQL和当前服务。开发凭据需要 `queues:manage/read`、`tasks:submit/read/cancel` 和当前命名空间全部队列范围;标准bootstrap默认具备这些权限。只在专用开发命名空间运行。
|
||||||
|
|
||||||
|
仓库根目录中,先通过环境配置专用 `ORBIT_MYSQL_DSN`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/orbit-migrate up
|
||||||
|
mkdir -p .local
|
||||||
|
chmod 700 .local
|
||||||
|
go run ./cmd/orbit-admin bootstrap \
|
||||||
|
--application batch-demo --namespace default --environment development \
|
||||||
|
--queue files --output .local/batch.credentials.json
|
||||||
|
go run ./cmd/orbit
|
||||||
|
```
|
||||||
|
|
||||||
|
另开终端:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd sdk/go
|
||||||
|
ORBIT_URL=http://127.0.0.1:8080 \
|
||||||
|
ORBIT_CREDENTIALS_FILE=../../.local/batch.credentials.json \
|
||||||
|
go run ./examples/batch
|
||||||
|
```
|
||||||
|
|
||||||
|
也接受ORBIT_TOKEN,环境值优先。示例自行创建并暂停一个独立队列,未终结上限为2,不改变已有队列或应用配置。前置应用剩余额度至少2。
|
||||||
|
|
||||||
|
## 调用链与输出
|
||||||
|
|
||||||
|
1. 三项文件引用与精确整数输入,使用三个稳定键一次提交。
|
||||||
|
2. 前两项受理,第三项QUEUE_FULL;整批重报确认前两项ID不变且去重。
|
||||||
|
3. 查询并取消第一项释放额度,仅以第三项原键和原参数补交。
|
||||||
|
4. 取消剩余两项,核对unfinished=0,队列保持暂停。
|
||||||
|
|
||||||
|
```text
|
||||||
|
accepted=2 rejected=1 code=QUEUE_FULL
|
||||||
|
replayed=2 same_task_ids=true
|
||||||
|
retried_items=1 same_identity=true accepted=true
|
||||||
|
queue=<实际32位ID> paused=true unfinished=0
|
||||||
|
```
|
||||||
|
|
||||||
|
每项总时限60秒,示例总调用上下文20秒。所有响应都校验,失败非零退出且不打印token或业务参数。网络异常时示例直接报错,不循环生成新身份;真正生产发送器应从业务库恢复原身份再核对。示例每次运行生成独立身份,**不是持久化业务Outbox实现**。
|
||||||
|
|
||||||
|
正常完成保留已取消任务、队列和审计,不硬删除。异常中断可能留下任务;恢复扫描器按总时限处理,示例不能保证强杀后的主动清理。对有外部副作用的真实任务仍需核查执行状态。
|
||||||
|
|
||||||
|
[真实SDK回归](../../sdk/go/batch_integration_test.go) 还通过HTTP代理在服务已返回受理回执后强制断线,再用原身份重报,确认没有重复任务。全部失败分类、部分提交与单项/批量互通见 [接口手册](../api/batch-tasks.md)。
|
||||||
95
docs/reports/phase-3-batch-tasks.md
Normal file
95
docs/reports/phase-3-batch-tasks.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# P3 批量任务受理回归
|
||||||
|
|
||||||
|
验证日期:2026-09-17。本阶段增加批量受理、SDK及部分补交场景;不代表P3或全平台验收完成。完整集成已通过,下面记录实际覆盖范围与剩余边界。
|
||||||
|
|
||||||
|
## 1. 实现与证据入口
|
||||||
|
|
||||||
|
- [服务契约](../../pulse/service/batch.go):最多32项,逐项独立事务与幂等,ACCEPTED/REJECTED/UNKNOWN/NOT_ATTEMPTED四类结果。未知数据库结果或凭据失效后停止余项,上下文结束保留已确认前项。
|
||||||
|
- [HTTP入口](../../internal/transport/httpserver/tasks.go):`POST /v1/tasks:batch`,全体正文严格解析后才受理,每项正文幂等键,总请求512 KiB。完整响应使用HTTP200,逐项标记结果。
|
||||||
|
- [独立SDK](../../sdk/go/batch.go):SubmitTasks及中文字段说明,不自动生成身份、拆批或重试;校验返回数量、顺序、状态与回执归属。
|
||||||
|
- [运行示例](../../sdk/go/examples/batch/main.go):两项受理、一项满额,原键核对后释放配额,仅补交被拒项并取消收尾。
|
||||||
|
- [接口手册](../api/batch-tasks.md)、[场景说明](../examples/batch.md)、[开发计划](../08-development-plan.md) 同步维护。
|
||||||
|
|
||||||
|
不增加数据库模型或迁移,平台仍为35个实体、289个中文注释持久化字段;不增加外键、级联或SDK数据库依赖。
|
||||||
|
|
||||||
|
## 2. 验证环境与命令
|
||||||
|
|
||||||
|
macOS、Go1.27.1、Docker单实例MySQL8.4.10。使用一次性数据库、随机测试凭据及回环端口,结束后自动删除,不修改已有业务库。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make fmt test vet build
|
||||||
|
make integration
|
||||||
|
```
|
||||||
|
|
||||||
|
普通测试没有数据库环境时会跳过Integration;数据库/HTTP/进程证据来自真实集成入口。并发修复另在一次性数据库重复执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test -race -count=20 \
|
||||||
|
-run '^TestIntegrationTaskBatch/concurrent_replay_and_shared_application_backpressure$' \
|
||||||
|
-v ./internal/storage/mysql
|
||||||
|
```
|
||||||
|
|
||||||
|
该定向命令要求 `ORBIT_TEST_MYSQL_DSN` 指向一次性数据库;正式完整脚本会自动准备隔离环境。
|
||||||
|
|
||||||
|
| 验证组 | 实际结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| fmt / race / vet / build | 已通过 |
|
||||||
|
| 定向批量MySQL六组场景 | PASS,5.559秒,含包开销 |
|
||||||
|
| 真实HTTP丢失批量响应及SDK补交 | PASS,1.458秒,含包开销 |
|
||||||
|
| 完整批量示例 | 已通过,unfinished=0 |
|
||||||
|
| 同键并发与共享配额20次重复 | PASS,10.110秒;每轮8个调用者 |
|
||||||
|
| 全量MySQL集成 | PASS,94.874秒 |
|
||||||
|
| SDK Worker完整组 | PASS,88.099秒 |
|
||||||
|
| SDK调度 / 队列资源 / 批量受理 | PASS,5.640 / 4.474 / 1.361秒 |
|
||||||
|
| SDK订阅管理 / 事件消费 | PASS,33.508 / 51.852秒 |
|
||||||
|
| Inbox六组真实数据库/消费者故障 | PASS,35.783秒 |
|
||||||
|
| SDK应用配额 | PASS,1.426秒 |
|
||||||
|
| 九项节点/Worker进程故障 | PASS,69.057秒;连同Inbox消费者共十项真实进程故障 |
|
||||||
|
| 全部运行示例 | 15个核心SDK示例 + 1个独立Inbox示例通过 |
|
||||||
|
|
||||||
|
上述完整脚本正常退出,状态码0;没有以尚在运行的日志推断完成。文档链接与JSON代码块核对通过,核心SDK依赖检查未包含服务端模型或MySQL驱动。以上是单次回归耗时,不是吞吐或性能指标。
|
||||||
|
|
||||||
|
## 3. 事务、权限与配额
|
||||||
|
|
||||||
|
[数据库回归](../../internal/storage/mysql/task_batch_integration_test.go) 实际验证:
|
||||||
|
|
||||||
|
1. 有效/参数无效/不存在队列/队列满额混合批次,每项结果独立;数据库仅有两份任务、受理幂等、事件及审计。
|
||||||
|
2. 单项与批量共用原始回执;取消后重报仍返回原受理版本,满额也不阻止已提交身份核对。释放配额后原失败身份受理成功,同键不同内容仍冲突。
|
||||||
|
3. 跨命名空间队列与队列权限拒绝;即使已有回执也不能绕过当前队列范围。
|
||||||
|
4. 第一项提交后撤销凭据,第二项重新认证拒绝,第三项未尝试;只有第一项任务存在。
|
||||||
|
5. 第一项提交后取消请求上下文,后缀未写入;恢复后整批原键重报,首项去重、后缀受理。
|
||||||
|
6. 真实审计触发器故障使新项事务回滚,任务/幂等/事件/配额不残留;既有已受理项保留。接口保守返回UNKNOWN,修复故障后原身份补交成功。
|
||||||
|
7. 八个调用者同时重报四个相同身份,应用上限3;前三个稳定任务ID、仅三次新受理,第四项APPLICATION_FULL,另一命名空间也受共享额度限制。
|
||||||
|
|
||||||
|
单元及HTTP边界还覆盖整批非法键/重复键/超量、全项参数无效时仍认证、未知字段/大小写别名/重复JSON键/null、超大正文、禁止整批幂等头、精确整数、不完整或矛盾的SDK回执,以及上下文和内部错误不泄露细节。
|
||||||
|
|
||||||
|
## 4. 并发回归发现与修复
|
||||||
|
|
||||||
|
最初同键并发满额场景实际出现MySQL 1213死锁,API按既有保守规则报告UNKNOWN。测试没有将UNKNOWN改判成功,也没有通过放宽断言隐藏问题。
|
||||||
|
|
||||||
|
任务提交原来先抢占幂等唯一键,再等待应用额度锁。在满额回滚和同键INSERT竞争中触发锁冲突。本轮调整为:认证 → 应用额度锁 → 提交幂等身份 → 队列 → 任务/事件/审计。取得应用锁时不检查或扣减额度;读取已有回执仍在配额检查之前。
|
||||||
|
|
||||||
|
提取只在应用锁已持有时使用的队列锁辅助函数,避免重复查询应用锁。每项事务结束后释放全部锁,整个批次不持有跨项锁。没有新增外部锁服务、数据库外键或盲目重放事务。当前场景通过不表示全部MySQL负载不再可能死锁;其他未知数据库错误仍必须原身份核对。
|
||||||
|
|
||||||
|
## 5. 真实HTTP故障与示例
|
||||||
|
|
||||||
|
[SDK集成](../../sdk/go/batch_integration_test.go) 启动真实HTTP代理:先读完平台已经生成的受理响应,再强制关闭客户端连接、丢弃响应。SDK返回网络错误,未自动补发。再次用原键/原参数请求,两项返回同一任务且deduplicated=true,第三项保持QUEUE_FULL;查询仅存在两行任务。取消一项后只补交原第三项,受理成功。
|
||||||
|
|
||||||
|
完整示例输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
accepted=2 rejected=1 code=QUEUE_FULL
|
||||||
|
replayed=2 same_task_ids=true
|
||||||
|
retried_items=1 same_identity=true accepted=true
|
||||||
|
queue=<实际ID> paused=true unfinished=0
|
||||||
|
```
|
||||||
|
|
||||||
|
这验证受理与HTTP回执恢复,不等同于Worker业务效果恰好一次,也不是业务事务Outbox。任务、队列和审计保留,无硬删除。
|
||||||
|
|
||||||
|
## 6. 兼容与尚未验收范围
|
||||||
|
|
||||||
|
- 无数据库迁移变更;批量与单项受理的身份、策略、事件和审计相同。新SDK调用旧节点返回404,不静默降级。
|
||||||
|
- 单项与批量均使用调整后的锁顺序。部署应统一升级写入节点;未做新旧二进制混合压力或滚动发布验收。
|
||||||
|
- 批次逐项串行,8秒HTTP预算不保证每次32项全部尝试;大参数回执不重复携带原输入。调用方必须处理部分提交和响应全丢。
|
||||||
|
- 本轮为本机单MySQL正确性验证,未验证数据库故障切换、跨区域网络、百万租户容量或生产RPO/RTO。
|
||||||
|
- P3仍缺独立任务人工核查/重试、业务键及时间筛选、事务Outbox示例;P4–P7仍按原目标推进。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Go SDK 接入说明
|
# Go SDK 接入说明
|
||||||
|
|
||||||
已实现健康查询、任务提交/查询/分页/待执行取消、队列创建/查询、[自动 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/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,7 @@ fmt.Println(cancelled.Task.State)
|
|||||||
|
|
||||||
| 方法 | 请求 / 返回 | 文档 |
|
| 方法 | 请求 / 返回 | 文档 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 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) |
|
||||||
| ListEventDeliveries / GetEventDelivery / RetryEventDelivery | 交付历史、冻结结果和耗尽后追加预算 | [订阅](../api/subscriptions.md) |
|
| ListEventDeliveries / GetEventDelivery / RetryEventDelivery | 交付历史、冻结结果和耗尽后追加预算 | [订阅](../api/subscriptions.md) |
|
||||||
|
|||||||
230
internal/storage/mysql/task_batch_integration_test.go
Normal file
230
internal/storage/mysql/task_batch_integration_test.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func taskBatch(f *executionFixture, n int) service.BatchSubmitInput {
|
||||||
|
in := service.BatchSubmitInput{}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
in.Items = append(in.Items, service.BatchSubmitItem{IdempotencyKey: fmt.Sprintf("batch-%d", i), Task: service.SubmitInput{QueueID: f.resources.QueueID, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"n":9007199254740993}`)}})
|
||||||
|
}
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
func requireBatchStatuses(t *testing.T, out service.BatchSubmitResult, err error, wants ...service.BatchItemStatus) {
|
||||||
|
t.Helper()
|
||||||
|
if err != nil || len(out.Items) != len(wants) {
|
||||||
|
t.Fatal(out, err)
|
||||||
|
}
|
||||||
|
for i, want := range wants {
|
||||||
|
if out.Items[i].Index != i || out.Items[i].Status != want {
|
||||||
|
t.Fatalf("item %d=%+v want=%s", i, out.Items[i], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchAfterCommitStore struct {
|
||||||
|
service.Store
|
||||||
|
after func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *batchAfterCommitStore) Submit(ctx context.Context, token auth.Token, key string, in service.SubmitInput) (service.SubmitResult, error) {
|
||||||
|
out, err := s.Store.Submit(ctx, token, key, in)
|
||||||
|
if err == nil && s.after != nil {
|
||||||
|
fn := s.after
|
||||||
|
s.after = nil
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationTaskBatch(t *testing.T) {
|
||||||
|
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("requires disposable MySQL")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
db, err := Open(ctx, dsn, 32, 8)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := Migrate(ctx, db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fixture := func(t *testing.T) *executionFixture { return newExecutionFixture(t, ctx, db) }
|
||||||
|
t.Run("partial_rejections_replay_and_quota", func(t *testing.T) {
|
||||||
|
f := fixture(t)
|
||||||
|
configureResources(t, f, 10, 4, 2, true)
|
||||||
|
in := taskBatch(f, 5)
|
||||||
|
in.Items[1].Task.Handler = ""
|
||||||
|
in.Items[2].Task.QueueID = executionID(t)
|
||||||
|
out, err := f.tasks.SubmitBatch(ctx, f.token, in)
|
||||||
|
requireBatchStatuses(t, out, err, service.BatchAccepted, service.BatchRejected, service.BatchRejected, service.BatchAccepted, service.BatchRejected)
|
||||||
|
if out.Items[1].Error.Code != "INVALID_ARGUMENT" || out.Items[2].Error.Code != "NOT_FOUND" || out.Items[4].Error.Code != "QUEUE_FULL" || out.Items[4].Error.RetryAfterMS != 1000 {
|
||||||
|
t.Fatal(out)
|
||||||
|
}
|
||||||
|
assertQueueCount(t, f, 2)
|
||||||
|
assertApplicationCounts(t, f, 2, 0, 0)
|
||||||
|
for table, filter := range map[string]string{"orbit_idempotency_keys": "operation='task.submit'", "orbit_audit_events": "action='task.submit'", "orbit_events": "type='task.accepted'"} {
|
||||||
|
if f.count(table, filter) != 2 {
|
||||||
|
t.Fatal("partial batch leaked writes", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if string(f.get(out.Items[0].Receipt.TaskID).Payload) != `{"n":9007199254740993}` {
|
||||||
|
t.Fatal("payload precision lost")
|
||||||
|
}
|
||||||
|
one, err := f.tasks.Submit(ctx, f.token, in.Items[0].IdempotencyKey, in.Items[0].Task)
|
||||||
|
if err != nil || !one.Deduplicated || one.Task.ID != out.Items[0].Receipt.TaskID {
|
||||||
|
t.Fatal("single batch identity diverged", err)
|
||||||
|
}
|
||||||
|
first := out.Items[0].Receipt
|
||||||
|
if _, err := f.tasks.CancelTask(ctx, f.token, first.TaskID, "cancel", service.CancelInput{ExpectedRevision: first.Revision, Reason: "free batch slot"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
in.Items = []service.BatchSubmitItem{in.Items[0], in.Items[4]}
|
||||||
|
again, err := f.tasks.SubmitBatch(ctx, f.token, in)
|
||||||
|
requireBatchStatuses(t, again, err, service.BatchAccepted, service.BatchAccepted)
|
||||||
|
if !again.Items[0].Receipt.Deduplicated || again.Items[0].Receipt.Revision != 1 || again.Items[1].Receipt.Deduplicated {
|
||||||
|
t.Fatal("receipt/rejected identity not reusable")
|
||||||
|
}
|
||||||
|
in.Items[0].Task.Payload = json.RawMessage(`{"changed":true}`)
|
||||||
|
conflict, err := f.tasks.SubmitBatch(ctx, f.token, in)
|
||||||
|
requireBatchStatuses(t, conflict, err, service.BatchRejected, service.BatchAccepted)
|
||||||
|
if conflict.Items[0].Error.Code != "IDEMPOTENCY_CONFLICT" {
|
||||||
|
t.Fatal(conflict)
|
||||||
|
}
|
||||||
|
assertQueueCount(t, f, 2)
|
||||||
|
assertApplicationCounts(t, f, 2, 0, 0)
|
||||||
|
})
|
||||||
|
t.Run("cross_namespace_and_queue_scope", func(t *testing.T) {
|
||||||
|
f, g := fixture(t), fixture(t)
|
||||||
|
in := taskBatch(f, 2)
|
||||||
|
in.Items[0].Task.QueueID = g.resources.QueueID
|
||||||
|
out, err := f.tasks.SubmitBatch(ctx, f.token, in)
|
||||||
|
requireBatchStatuses(t, out, err, service.BatchRejected, service.BatchAccepted)
|
||||||
|
if out.Items[0].Error.Code != "NOT_FOUND" {
|
||||||
|
t.Fatal(out)
|
||||||
|
}
|
||||||
|
f.exec(`UPDATE orbit_credentials SET permissions=? WHERE namespace_id=? AND id=?`, `{"actions":["tasks:submit"],"queues":["`+string(g.resources.QueueID)+`"]}`, f.resources.NamespaceID, f.resources.CredentialID)
|
||||||
|
out, err = f.tasks.SubmitBatch(ctx, f.token, service.BatchSubmitInput{Items: in.Items[1:]})
|
||||||
|
requireBatchStatuses(t, out, err, service.BatchRejected)
|
||||||
|
if out.Items[0].Error.Code != "PERMISSION_DENIED" || out.Items[0].Receipt != nil {
|
||||||
|
t.Fatal("cached receipt bypassed current queue scope")
|
||||||
|
}
|
||||||
|
if g.count("pulse_tasks", "1=1") != 0 {
|
||||||
|
t.Fatal("cross namespace write")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("revoke_between_items_is_rechecked", func(t *testing.T) {
|
||||||
|
f := fixture(t)
|
||||||
|
s := &batchAfterCommitStore{Store: NewTaskStore(db), after: func() {
|
||||||
|
f.exec(`UPDATE orbit_credentials SET revoked_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID)
|
||||||
|
}}
|
||||||
|
out, err := service.New(s).SubmitBatch(ctx, f.token, taskBatch(f, 3))
|
||||||
|
requireBatchStatuses(t, out, err, service.BatchAccepted, service.BatchRejected, service.BatchNotAttempted)
|
||||||
|
if out.Items[1].Error.Code != "UNAUTHENTICATED" || f.count("pulse_tasks", "1=1") != 1 {
|
||||||
|
t.Fatal(out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("cancel_after_first_commit_leaves_recoverable_prefix", func(t *testing.T) {
|
||||||
|
f := fixture(t)
|
||||||
|
run, stop := context.WithCancel(ctx)
|
||||||
|
defer stop()
|
||||||
|
s := &batchAfterCommitStore{Store: NewTaskStore(db), after: stop}
|
||||||
|
in := taskBatch(f, 3)
|
||||||
|
out, err := service.New(s).SubmitBatch(run, f.token, in)
|
||||||
|
requireBatchStatuses(t, out, err, service.BatchAccepted, service.BatchNotAttempted, service.BatchNotAttempted)
|
||||||
|
if f.count("pulse_tasks", "1=1") != 1 {
|
||||||
|
t.Fatal("cancelled suffix wrote tasks")
|
||||||
|
}
|
||||||
|
again, err := f.tasks.SubmitBatch(ctx, f.token, in)
|
||||||
|
requireBatchStatuses(t, again, err, service.BatchAccepted, service.BatchAccepted, service.BatchAccepted)
|
||||||
|
if !again.Items[0].Receipt.Deduplicated || again.Items[0].Receipt.TaskID != out.Items[0].Receipt.TaskID {
|
||||||
|
t.Fatal("lost committed prefix")
|
||||||
|
}
|
||||||
|
assertApplicationCounts(t, f, 3, 0, 0)
|
||||||
|
})
|
||||||
|
t.Run("audit_failure_rolls_back_item_and_preserves_previous_commit", func(t *testing.T) {
|
||||||
|
f := fixture(t)
|
||||||
|
in := taskBatch(f, 3)
|
||||||
|
if _, err := f.tasks.Submit(ctx, f.token, in.Items[0].IdempotencyKey, in.Items[0].Task); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
trigger := "batch_audit_" + string(executionID(t))
|
||||||
|
f.exec("CREATE TRIGGER " + trigger + " BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='private injected audit fault'")
|
||||||
|
out, err := f.tasks.SubmitBatch(ctx, f.token, in)
|
||||||
|
f.exec("DROP TRIGGER " + trigger)
|
||||||
|
requireBatchStatuses(t, out, err, service.BatchAccepted, service.BatchUnknown, service.BatchNotAttempted)
|
||||||
|
if out.Items[1].Error.Code != "UNAVAILABLE" {
|
||||||
|
t.Fatal(out)
|
||||||
|
}
|
||||||
|
assertQueueCount(t, f, 1)
|
||||||
|
assertApplicationCounts(t, f, 1, 0, 0)
|
||||||
|
for table, filter := range map[string]string{"orbit_idempotency_keys": "operation='task.submit'", "orbit_audit_events": "action='task.submit'", "orbit_events": "type='task.accepted'"} {
|
||||||
|
if f.count(table, filter) != 1 {
|
||||||
|
t.Fatal("atomic item rollback failed", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
again, err := f.tasks.SubmitBatch(ctx, f.token, in)
|
||||||
|
requireBatchStatuses(t, again, err, service.BatchAccepted, service.BatchAccepted, service.BatchAccepted)
|
||||||
|
assertQueueCount(t, f, 3)
|
||||||
|
})
|
||||||
|
t.Run("concurrent_replay_and_shared_application_backpressure", func(t *testing.T) {
|
||||||
|
f := fixture(t)
|
||||||
|
grantApplicationResources(f)
|
||||||
|
configureApplication(t, f, 100, 10, 3)
|
||||||
|
in := taskBatch(f, 4)
|
||||||
|
type result struct {
|
||||||
|
out service.BatchSubmitResult
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan result, 8)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() { defer wg.Done(); out, err := f.tasks.SubmitBatch(ctx, f.token, in); ch <- result{out, err} }()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(ch)
|
||||||
|
ids := map[int]core.ID{}
|
||||||
|
fresh := 0
|
||||||
|
for r := range ch {
|
||||||
|
requireBatchStatuses(t, r.out, r.err, service.BatchAccepted, service.BatchAccepted, service.BatchAccepted, service.BatchRejected)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
receipt := r.out.Items[i].Receipt
|
||||||
|
if previous := ids[i]; previous != "" && previous != receipt.TaskID {
|
||||||
|
t.Fatal("duplicate identity")
|
||||||
|
}
|
||||||
|
ids[i] = receipt.TaskID
|
||||||
|
if !receipt.Deduplicated {
|
||||||
|
fresh++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.out.Items[3].Error.Code != "APPLICATION_FULL" {
|
||||||
|
t.Fatal(r.out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fresh != 3 || f.count("pulse_tasks", "1=1") != 3 {
|
||||||
|
t.Fatal("duplicate creation", fresh)
|
||||||
|
}
|
||||||
|
assertApplicationCounts(t, f, 3, 0, 0)
|
||||||
|
g := newExecutionFixture(t, ctx, db, f.resources.ApplicationID)
|
||||||
|
out, err := g.tasks.SubmitBatch(ctx, g.token, taskBatch(g, 1))
|
||||||
|
requireBatchStatuses(t, out, err, service.BatchRejected)
|
||||||
|
if out.Items[0].Error.Code != "APPLICATION_FULL" {
|
||||||
|
t.Fatal(out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ func NewTaskStore(db *sql.DB) *TaskStore { return &TaskStore{db: db} }
|
|||||||
|
|
||||||
var _ service.Store = (*TaskStore)(nil)
|
var _ service.Store = (*TaskStore)(nil)
|
||||||
|
|
||||||
|
// AuthorizeSubmit 用于批量请求的只读预认证;每项事务仍独立校验凭据、动作与队列范围。
|
||||||
|
func (s *TaskStore) AuthorizeSubmit(ctx context.Context, token auth.Token) error {
|
||||||
|
return s.transaction(ctx, token, "tasks:submit", func(*sql.Tx, auth.Permissions) error { return nil })
|
||||||
|
}
|
||||||
|
|
||||||
func (s *TaskStore) transaction(ctx context.Context, token auth.Token, action string, fn func(*sql.Tx, auth.Permissions) error) error {
|
func (s *TaskStore) transaction(ctx context.Context, token auth.Token, action string, fn func(*sql.Tx, auth.Permissions) error) error {
|
||||||
return WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
return WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
p, err := authenticate(ctx, tx, token)
|
p, err := authenticate(ctx, tx, token)
|
||||||
@@ -159,6 +164,12 @@ func (s *TaskStore) Submit(ctx context.Context, token auth.Token, key string, in
|
|||||||
if !p.AllowsQueue(in.QueueID) {
|
if !p.AllowsQueue(in.QueueID) {
|
||||||
return fault.Forbidden
|
return fault.Forbidden
|
||||||
}
|
}
|
||||||
|
// 先按既有应用额度锁串行进入受理,再占用幂等唯一键。
|
||||||
|
// 满额回滚时,同键并发INSERT的重复检查锁不能与应用额度锁互相等待。
|
||||||
|
// 此处只取得锁,不检查/扣减额度;已有回执在满额时仍可返回。
|
||||||
|
if _, err := lockApplicationResources(ctx, tx, token.NamespaceID, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
taskID, err := core.NewID()
|
taskID, err := core.NewID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -174,7 +185,7 @@ func (s *TaskStore) Submit(ctx context.Context, token auth.Token, key string, in
|
|||||||
out.Deduplicated = true
|
out.Deduplicated = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if _, _, err := lockQueue(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)
|
now, err := dbNow(ctx, tx)
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ func lockQueue(ctx context.Context, tx *sql.Tx, ns, id core.ID) (paused bool, ma
|
|||||||
if _, err = lockApplicationResources(ctx, tx, ns, false); err != nil {
|
if _, err = lockApplicationResources(ctx, tx, ns, false); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
return lockQueueAfterApplication(ctx, tx, ns, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockQueueAfterApplication 仅在同一事务已持有应用额度排他锁时调用。
|
||||||
|
func lockQueueAfterApplication(ctx context.Context, tx *sql.Tx, ns, id core.ID) (paused bool, max uint32, err error) {
|
||||||
err = tx.QueryRowContext(ctx, "SELECT paused,max_concurrency FROM pulse_queues WHERE namespace_id=? AND id=? FOR UPDATE", ns, id).Scan(&paused, &max)
|
err = tx.QueryRowContext(ctx, "SELECT paused,max_concurrency FROM pulse_queues WHERE namespace_id=? AND id=? FOR UPDATE", ns, id).Scan(&paused, &max)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
err = fault.NotFound
|
err = fault.NotFound
|
||||||
|
|||||||
79
internal/transport/httpserver/batch_test.go
Normal file
79
internal/transport/httpserver/batch_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type batchAuthStore struct {
|
||||||
|
service.Store
|
||||||
|
authCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchHTTPToken(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
token, _, err := auth.Issue(core.ID(strings.Repeat("1", 32)), core.ID(strings.Repeat("2", 32)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return "Bearer " + token
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *batchAuthStore) AuthorizeSubmit(context.Context, auth.Token) error {
|
||||||
|
s.authCalls++
|
||||||
|
return fault.Unauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchHTTPRejectsWholeMalformedEnvelopeBeforeWrites(t *testing.T) {
|
||||||
|
for _, test := range []struct{ body, key, code string }{
|
||||||
|
{`{"items":[{"idempotency_key":"a","task":{}},{"idempotency_key":"b","task":{"unexpected":1}}]}`, "", "INVALID_ARGUMENT"},
|
||||||
|
{`{"items":[{"idempotency_key":"a","task":{"retry":{"MaxAttempts":1}}}]}`, "", "INVALID_ARGUMENT"},
|
||||||
|
{`{"items":[{"idempotency_key":"a","task":{"payload":{"x":1,"x":2}}}]}`, "", "INVALID_ARGUMENT"},
|
||||||
|
{`{"items":[null]}`, "", "INVALID_ARGUMENT"},
|
||||||
|
{`{"items":[{"idempotency_key":"a","task":null}]}`, "", "INVALID_ARGUMENT"},
|
||||||
|
{`{"items":[{"idempotency_key":"a","task":{}},{"idempotency_key":"a","task":{}}]}`, "", "INVALID_ARGUMENT"},
|
||||||
|
{`{"items":[]}`, "", "INVALID_ARGUMENT"},
|
||||||
|
{`{"items":[{"idempotency_key":"a","task":{}}]}`, "whole-batch", "INVALID_ARGUMENT"},
|
||||||
|
{`{"items":[{"idempotency_key":"a","task":{"payload":"` + strings.Repeat("x", 512<<10) + `"}}]}`, "", "BODY_TOO_LARGE"},
|
||||||
|
} {
|
||||||
|
s := &batchAuthStore{}
|
||||||
|
h := New("", nil, service.New(s)).Handler
|
||||||
|
r := httptest.NewRequest("POST", "/v1/tasks:batch", strings.NewReader(test.body))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
r.Header.Set("Authorization", batchHTTPToken(t))
|
||||||
|
if test.key != "" {
|
||||||
|
r.Header.Set("Idempotency-Key", test.key)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
var got struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || got.Error.Code != test.code || s.authCalls != 0 {
|
||||||
|
t.Fatalf("got=%s auth=%d want=%s", w.Body.String(), s.authCalls, test.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchHTTPAuthenticatesAllInvalidItems(t *testing.T) {
|
||||||
|
s := &batchAuthStore{}
|
||||||
|
r := httptest.NewRequest("POST", "/v1/tasks:batch", strings.NewReader(`{"items":[{"idempotency_key":"a","task":{}}]}`))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
r.Header.Set("Authorization", batchHTTPToken(t))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
New("", nil, service.New(s)).Handler.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusUnauthorized || s.authCalls != 1 {
|
||||||
|
t.Fatal(w.Body.String(), s.authCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,17 @@ import (
|
|||||||
type endpoint func(*http.Request, auth.Token) (any, int, error)
|
type endpoint func(*http.Request, auth.Token) (any, int, error)
|
||||||
|
|
||||||
func registerTasks(mux *http.ServeMux, s *service.Service) {
|
func registerTasks(mux *http.ServeMux, s *service.Service) {
|
||||||
|
mux.HandleFunc("POST /v1/tasks:batch", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||||
|
if len(r.Header.Values("Idempotency-Key")) != 0 {
|
||||||
|
return nil, 0, fault.Invalid("batch requires per-item idempotency_key, not an Idempotency-Key header")
|
||||||
|
}
|
||||||
|
var in service.BatchSubmitInput
|
||||||
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out, err := s.SubmitBatch(r.Context(), t, in)
|
||||||
|
return out, http.StatusOK, err
|
||||||
|
}))
|
||||||
mux.HandleFunc("POST /v1/tasks", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
mux.HandleFunc("POST /v1/tasks", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||||
var in service.SubmitInput
|
var in service.SubmitInput
|
||||||
if err := decodeBody(r, &in); err != nil {
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
|||||||
127
pulse/service/batch.go
Normal file
127
pulse/service/batch.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxBatchTasks 限制一次请求的独立任务数;HTTP 总请求仍限制为512 KiB。
|
||||||
|
const MaxBatchTasks = 32
|
||||||
|
|
||||||
|
type BatchSubmitInput struct {
|
||||||
|
// Items 按输入顺序分别受理,不保证整批原子性。
|
||||||
|
Items []BatchSubmitItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchSubmitItem struct {
|
||||||
|
// IdempotencyKey 必须由调用方持久化,与单项提交共用命名空间内的幂等身份。
|
||||||
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
|
// Task 与单项受理完全相同的输入,重报时保留原值。
|
||||||
|
Task SubmitInput `json:"task"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchItemStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BatchAccepted BatchItemStatus = "ACCEPTED"
|
||||||
|
BatchRejected BatchItemStatus = "REJECTED"
|
||||||
|
BatchUnknown BatchItemStatus = "UNKNOWN"
|
||||||
|
BatchNotAttempted BatchItemStatus = "NOT_ATTEMPTED"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BatchTaskReceipt 是原始受理回执的精简视图,避免重复返回大参数。
|
||||||
|
type BatchTaskReceipt struct {
|
||||||
|
// TaskID 已持久化的任务标识,业务结果需另行查询或订阅。
|
||||||
|
TaskID core.ID `json:"task_id"`
|
||||||
|
// QueueID 原始回执所属的队列标识。
|
||||||
|
QueueID core.ID `json:"queue_id"`
|
||||||
|
// Revision 原始受理版本,十进制字符串,不代表当前任务版本。
|
||||||
|
Revision uint64 `json:"revision,string"`
|
||||||
|
// CreatedAt 原始受理时间,UTC微秒精度。
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
// Deduplicated 本项是否返回已有幂等回执。
|
||||||
|
Deduplicated bool `json:"deduplicated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchItemError struct {
|
||||||
|
// Code 稳定错误码;UNKNOWN不能被视为确认回滚。
|
||||||
|
Code string `json:"code"`
|
||||||
|
// Message 对外说明,不包含数据库错误或任务输入。
|
||||||
|
Message string `json:"message"`
|
||||||
|
// RetryAfterMS 建议最小退避毫秒数,省略为未指定,不代表可更换幂等键。
|
||||||
|
RetryAfterMS uint32 `json:"retry_after_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchSubmitItemResult struct {
|
||||||
|
// Index 本次请求内从0开始的索引,结果始终与输入同序且数量相同。
|
||||||
|
Index int `json:"index"`
|
||||||
|
// Status 区分已受理、明确拒绝、结果未知及本次未尝试。
|
||||||
|
Status BatchItemStatus `json:"status"`
|
||||||
|
// Receipt 仅ACCEPTED包含原始受理回执。
|
||||||
|
Receipt *BatchTaskReceipt `json:"receipt,omitempty"`
|
||||||
|
// Error 仅非ACCEPTED包含稳定错误及退避提示。
|
||||||
|
Error *BatchItemError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchSubmitResult struct {
|
||||||
|
// Items 每项独立结果,HTTP成功不能替代逐项检查。
|
||||||
|
Items []BatchSubmitItemResult `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitBatch 逐项重用单项事务;不建立整批幂等身份或跨项锁。
|
||||||
|
func (s *Service) SubmitBatch(ctx context.Context, token auth.Token, in BatchSubmitInput) (BatchSubmitResult, error) {
|
||||||
|
if len(in.Items) < 1 || len(in.Items) > MaxBatchTasks {
|
||||||
|
return BatchSubmitResult{}, fault.Invalid("items must contain 1 to 32 tasks")
|
||||||
|
}
|
||||||
|
keys := make(map[string]struct{}, len(in.Items))
|
||||||
|
for _, item := range in.Items {
|
||||||
|
if err := ValidateKey(item.IdempotencyKey); err != nil {
|
||||||
|
return BatchSubmitResult{}, err
|
||||||
|
}
|
||||||
|
if _, exists := keys[item.IdempotencyKey]; exists {
|
||||||
|
return BatchSubmitResult{}, fault.Invalid("item idempotency keys must be distinct within a batch")
|
||||||
|
}
|
||||||
|
keys[item.IdempotencyKey] = struct{}{}
|
||||||
|
}
|
||||||
|
// 即使全部任务参数无效,也必须先验证凭据和动作权限;每项写入会再次认证。
|
||||||
|
if err := s.store.AuthorizeSubmit(ctx, token); err != nil {
|
||||||
|
return BatchSubmitResult{}, err
|
||||||
|
}
|
||||||
|
out := BatchSubmitResult{Items: make([]BatchSubmitItemResult, len(in.Items))}
|
||||||
|
stopped := false
|
||||||
|
for i, item := range in.Items {
|
||||||
|
result := BatchSubmitItemResult{Index: i}
|
||||||
|
if stopped || ctx.Err() != nil {
|
||||||
|
result.Status = BatchNotAttempted
|
||||||
|
result.Error = &BatchItemError{Code: "BATCH_STOPPED", Message: "this item was not attempted in this request; retry using the original identity", RetryAfterMS: 1000}
|
||||||
|
} else {
|
||||||
|
accepted, err := s.Submit(ctx, token, item.IdempotencyKey, item.Task)
|
||||||
|
if err == nil {
|
||||||
|
result.Status = BatchAccepted
|
||||||
|
result.Receipt = &BatchTaskReceipt{TaskID: accepted.Task.ID, QueueID: accepted.Task.QueueID, Revision: accepted.Task.Revision, CreatedAt: accepted.Task.CreatedAt, Deduplicated: accepted.Deduplicated}
|
||||||
|
} else {
|
||||||
|
var public *fault.Error
|
||||||
|
if !errors.As(err, &public) || public.Code == "UNAVAILABLE" {
|
||||||
|
// 驱动超时、COMMIT响应丢失等不能证明未提交,停止继续占用数据库。
|
||||||
|
public = fault.Unavailable
|
||||||
|
result.Status, stopped = BatchUnknown, true
|
||||||
|
} else {
|
||||||
|
result.Status = BatchRejected
|
||||||
|
stopped = public.Code == "UNAUTHENTICATED"
|
||||||
|
}
|
||||||
|
result.Error = &BatchItemError{Code: public.Code, Message: public.Message}
|
||||||
|
switch public.Code {
|
||||||
|
case "QUEUE_FULL", "APPLICATION_FULL", "UNAVAILABLE":
|
||||||
|
result.Error.RetryAfterMS = 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Items[i] = result
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
128
pulse/service/batch_test.go
Normal file
128
pulse/service/batch_test.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type batchStore struct {
|
||||||
|
Store
|
||||||
|
authErr error
|
||||||
|
authCalls, calls int
|
||||||
|
submit func(int, string, SubmitInput) (SubmitResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *batchStore) AuthorizeSubmit(context.Context, auth.Token) error {
|
||||||
|
s.authCalls++
|
||||||
|
return s.authErr
|
||||||
|
}
|
||||||
|
func (s *batchStore) Submit(_ context.Context, _ auth.Token, key string, in SubmitInput) (SubmitResult, error) {
|
||||||
|
s.calls++
|
||||||
|
return s.submit(s.calls, key, in)
|
||||||
|
}
|
||||||
|
func batchInput(n int) BatchSubmitInput {
|
||||||
|
in := BatchSubmitInput{}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
in.Items = append(in.Items, BatchSubmitItem{IdempotencyKey: string(rune('a' + i)), Task: SubmitInput{QueueID: core.ID(strings.Repeat("1", 32)), Handler: "file.convert", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"n":9007199254740993}`)}})
|
||||||
|
}
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
func acceptedBatchTask(in SubmitInput) SubmitResult {
|
||||||
|
return SubmitResult{Task: TaskView{ID: core.ID(strings.Repeat("2", 32)), QueueID: in.QueueID, Revision: 1, CreatedAt: time.Now().UTC()}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchEnvelopeBeforeWritesAndAuthorization(t *testing.T) {
|
||||||
|
for _, n := range []int{0, 33} {
|
||||||
|
s := &batchStore{}
|
||||||
|
if _, err := New(s).SubmitBatch(context.Background(), auth.Token{}, batchInput(n)); fault.Code(err) != "INVALID_ARGUMENT" || s.authCalls != 0 || s.calls != 0 {
|
||||||
|
t.Fatal(n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{"", "a b", strings.Repeat("a", 129), "a"} {
|
||||||
|
in := batchInput(2)
|
||||||
|
in.Items[1].IdempotencyKey = key
|
||||||
|
s := &batchStore{}
|
||||||
|
if _, err := New(s).SubmitBatch(context.Background(), auth.Token{}, in); fault.Code(err) != "INVALID_ARGUMENT" || s.calls != 0 {
|
||||||
|
t.Fatal(key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, denied := range []error{fault.Unauthorized, fault.Forbidden, errors.New("database private detail")} {
|
||||||
|
s := &batchStore{authErr: denied}
|
||||||
|
in := batchInput(1)
|
||||||
|
in.Items[0].Task.Handler = ""
|
||||||
|
if _, err := New(s).SubmitBatch(context.Background(), auth.Token{}, in); err != denied || s.calls != 0 || s.authCalls != 1 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchPartialResultsAndIndependentNormalization(t *testing.T) {
|
||||||
|
in := batchInput(5)
|
||||||
|
in.Items[1].Task.Priority = 10
|
||||||
|
s := &batchStore{submit: func(n int, key string, in SubmitInput) (SubmitResult, error) {
|
||||||
|
if in.TaskTimeoutMS == 0 || in.Retry.MaxAttempts != 1 || string(in.Payload) != `{"n":9007199254740993}` {
|
||||||
|
t.Fatal("normalization or precision lost")
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "c":
|
||||||
|
return SubmitResult{}, fault.QueueFull
|
||||||
|
case "d":
|
||||||
|
return SubmitResult{}, fault.IdempotencyConflict
|
||||||
|
}
|
||||||
|
out := acceptedBatchTask(in)
|
||||||
|
out.Deduplicated = key == "e"
|
||||||
|
return out, nil
|
||||||
|
}}
|
||||||
|
out, err := New(s).SubmitBatch(context.Background(), auth.Token{}, in)
|
||||||
|
if err != nil || len(out.Items) != 5 || s.calls != 4 {
|
||||||
|
t.Fatal(out, err)
|
||||||
|
}
|
||||||
|
for i, want := range []BatchItemStatus{BatchAccepted, BatchRejected, BatchRejected, BatchRejected, BatchAccepted} {
|
||||||
|
if out.Items[i].Status != want || out.Items[i].Index != i {
|
||||||
|
t.Fatal(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.Items[1].Error.Code != "INVALID_ARGUMENT" || out.Items[2].Error.RetryAfterMS != 1000 || !out.Items[4].Receipt.Deduplicated {
|
||||||
|
t.Fatal(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchUnknownAndCancellationNeverClaimRollback(t *testing.T) {
|
||||||
|
for _, fail := range []error{errors.New("secret database error"), context.DeadlineExceeded, fault.Unavailable, fault.Unauthorized} {
|
||||||
|
s := &batchStore{submit: func(n int, _ string, in SubmitInput) (SubmitResult, error) {
|
||||||
|
if n == 1 {
|
||||||
|
return acceptedBatchTask(in), nil
|
||||||
|
}
|
||||||
|
return SubmitResult{}, fail
|
||||||
|
}}
|
||||||
|
out, err := New(s).SubmitBatch(context.Background(), auth.Token{}, batchInput(4))
|
||||||
|
if err != nil || s.calls != 2 || out.Items[0].Status != BatchAccepted || out.Items[2].Status != BatchNotAttempted || out.Items[3].Status != BatchNotAttempted {
|
||||||
|
t.Fatal(out, err)
|
||||||
|
}
|
||||||
|
want := BatchUnknown
|
||||||
|
if fail == fault.Unauthorized {
|
||||||
|
want = BatchRejected
|
||||||
|
}
|
||||||
|
if out.Items[1].Status != want || strings.Contains(out.Items[1].Error.Message, "secret") {
|
||||||
|
t.Fatal(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
s := &batchStore{submit: func(_ int, _ string, in SubmitInput) (SubmitResult, error) {
|
||||||
|
cancel()
|
||||||
|
return acceptedBatchTask(in), nil
|
||||||
|
}}
|
||||||
|
out, err := New(s).SubmitBatch(ctx, auth.Token{}, batchInput(3))
|
||||||
|
if err != nil || s.calls != 1 || out.Items[0].Status != BatchAccepted || out.Items[1].Status != BatchNotAttempted {
|
||||||
|
t.Fatal(out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -216,6 +216,7 @@ type QueueResult struct {
|
|||||||
|
|
||||||
// Store 实现命名空间隔离、事务授权与持久化,不在事务外产生业务副作用。
|
// Store 实现命名空间隔离、事务授权与持久化,不在事务外产生业务副作用。
|
||||||
type Store interface {
|
type Store interface {
|
||||||
|
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)
|
||||||
ListTasks(context.Context, auth.Token, ListInput) (TaskPage, error)
|
ListTasks(context.Context, auth.Token, ListInput) (TaskPage, error)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap -
|
|||||||
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run 'IntegrationWorker(Connection|Runtime)' -v .)
|
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run 'IntegrationWorker(Connection|Runtime)' -v .)
|
||||||
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationSchedule' -v .)
|
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationSchedule' -v .)
|
||||||
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationQueueResourcesSDK$' -v .)
|
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationQueueResourcesSDK$' -v .)
|
||||||
|
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationTaskBatchSDK$' -v .)
|
||||||
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationSubscriptionManagementSDK$' -v .)
|
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationSubscriptionManagementSDK$' -v .)
|
||||||
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationEvent(Connection|Consumer)' -v .)
|
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationEvent(Connection|Consumer)' -v .)
|
||||||
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/worker-protocol)
|
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/worker-protocol)
|
||||||
@@ -61,6 +62,7 @@ ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap -
|
|||||||
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/schedules)
|
(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/schedules)
|
||||||
(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/cancellation)
|
(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/cancellation)
|
||||||
(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/resources)
|
(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/resources)
|
||||||
|
(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/batch)
|
||||||
(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/keyed-concurrency)
|
(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/keyed-concurrency)
|
||||||
(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/priority)
|
(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/priority)
|
||||||
(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)
|
||||||
|
|||||||
121
sdk/go/batch.go
Normal file
121
sdk/go/batch.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package orbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxBatchTasks 是单次批量受理的项数上限,总请求还受512 KiB限制。
|
||||||
|
const MaxBatchTasks = 32
|
||||||
|
|
||||||
|
type SubmitTasksRequest struct {
|
||||||
|
// Items 1至32项独立任务,总请求不超过512 KiB,不保证整批原子性。
|
||||||
|
Items []SubmitTasksItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubmitTasksItem struct {
|
||||||
|
// IdempotencyKey 调用方持久化的稳定键,与SubmitTask共用幂等身份;批内不得重复。
|
||||||
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
|
// Task 原始任务参数,重报必须保留原值。
|
||||||
|
Task SubmitTaskRequest `json:"task"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchItemStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BatchAccepted BatchItemStatus = "ACCEPTED"
|
||||||
|
BatchRejected BatchItemStatus = "REJECTED"
|
||||||
|
BatchUnknown BatchItemStatus = "UNKNOWN"
|
||||||
|
BatchNotAttempted BatchItemStatus = "NOT_ATTEMPTED"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BatchTaskReceipt struct {
|
||||||
|
// TaskID 已受理任务标识,实际结果需要查询或订阅。
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
// QueueID 任务所属队列。
|
||||||
|
QueueID string `json:"queue_id"`
|
||||||
|
// Revision 原始受理版本,JSON为十进制字符串,不代表当前版本。
|
||||||
|
Revision uint64 `json:"revision,string"`
|
||||||
|
// CreatedAt 原始受理时间,UTC微秒精度。
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
// Deduplicated 是否复用相同身份的已有回执。
|
||||||
|
Deduplicated bool `json:"deduplicated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchItemError struct {
|
||||||
|
// Code 稳定错误码;UNKNOWN结果使用UNAVAILABLE,不证明回滚。
|
||||||
|
Code string `json:"code"`
|
||||||
|
// Message 不包含数据库错误或输入的对外说明。
|
||||||
|
Message string `json:"message"`
|
||||||
|
// RetryAfterMS 建议最小退避毫秒数,零为未指定,SDK不自动重试。
|
||||||
|
RetryAfterMS uint32 `json:"retry_after_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubmitTasksItemResult struct {
|
||||||
|
// Index 本次请求内从0开始的索引,与Items输入同序。
|
||||||
|
Index int `json:"index"`
|
||||||
|
// Status 每项独立状态;HTTP成功不表示全部任务已受理。
|
||||||
|
Status BatchItemStatus `json:"status"`
|
||||||
|
// Receipt 仅ACCEPTED存在,省略大参数以保证回执大小有界。
|
||||||
|
Receipt *BatchTaskReceipt `json:"receipt,omitempty"`
|
||||||
|
// Error 仅非ACCEPTED存在,调用方需分别处理明确拒绝、未知及未尝试。
|
||||||
|
Error *BatchItemError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubmitTasksResult struct {
|
||||||
|
// Items 与输入数量和顺序一致的逐项结果。
|
||||||
|
Items []SubmitTasksItemResult `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitTasks 逐项提交独立任务。返回error时整批可能已部分或全部受理,必须保留原键及参数核对。
|
||||||
|
// error为nil时仍需检查每项Status。此方法不会自动重试、拆批、生成新键或启动并发请求。
|
||||||
|
func (c *Client) SubmitTasks(ctx context.Context, in SubmitTasksRequest) (SubmitTasksResult, error) {
|
||||||
|
if len(in.Items) < 1 || len(in.Items) > MaxBatchTasks {
|
||||||
|
return SubmitTasksResult{}, errors.New("items must contain 1 to 32 tasks")
|
||||||
|
}
|
||||||
|
keys := make(map[string]struct{}, len(in.Items))
|
||||||
|
for _, item := range in.Items {
|
||||||
|
if err := validateIdempotencyKey(item.IdempotencyKey); err != nil {
|
||||||
|
return SubmitTasksResult{}, err
|
||||||
|
}
|
||||||
|
if _, exists := keys[item.IdempotencyKey]; exists {
|
||||||
|
return SubmitTasksResult{}, errors.New("item idempotency keys must be distinct within a batch")
|
||||||
|
}
|
||||||
|
keys[item.IdempotencyKey] = struct{}{}
|
||||||
|
}
|
||||||
|
var out SubmitTasksResult
|
||||||
|
if err := c.callJSON(ctx, "POST", "/v1/tasks:batch", "", in, &out); err != nil {
|
||||||
|
return SubmitTasksResult{}, err
|
||||||
|
}
|
||||||
|
// 拒绝不完整或错误关联的回执,避免调用方将截断的成功响应误认为全部已受理。
|
||||||
|
if len(out.Items) != len(in.Items) {
|
||||||
|
return SubmitTasksResult{}, errors.New("invalid Orbit batch result count")
|
||||||
|
}
|
||||||
|
for i, item := range out.Items {
|
||||||
|
valid := item.Index == i
|
||||||
|
switch item.Status {
|
||||||
|
case BatchAccepted:
|
||||||
|
r := item.Receipt
|
||||||
|
valid = valid && item.Error == nil && r != nil && validID(r.TaskID) && r.QueueID == in.Items[i].Task.QueueID && validID(r.QueueID) && r.Revision > 0 && !r.CreatedAt.IsZero()
|
||||||
|
case BatchRejected, BatchUnknown, BatchNotAttempted:
|
||||||
|
e := item.Error
|
||||||
|
valid = valid && item.Receipt == nil && e != nil && len(e.Code) > 0 && len(e.Code) <= 64 && len(e.Message) > 0 && len(e.Message) <= 1024 && e.RetryAfterMS <= 3600000
|
||||||
|
if valid && item.Status == BatchUnknown {
|
||||||
|
valid = e.Code == "UNAVAILABLE"
|
||||||
|
}
|
||||||
|
if valid && item.Status == BatchNotAttempted {
|
||||||
|
valid = e.Code == "BATCH_STOPPED"
|
||||||
|
}
|
||||||
|
if valid && item.Status == BatchRejected {
|
||||||
|
valid = e.Code != "UNAVAILABLE" && e.Code != "BATCH_STOPPED"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return SubmitTasksResult{}, errors.New("invalid Orbit batch item result; retry using original identities")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
107
sdk/go/batch_integration_test.go
Normal file
107
sdk/go/batch_integration_test.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package orbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIntegrationTaskBatchSDK(t *testing.T) {
|
||||||
|
api, _, _ := integrationWorkerClient(t)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
created, err := api.CreateQueue(ctx, workerTestID(t), CreateQueueRequest{Name: "batch." + workerTestID(t), MaxConcurrency: 2, RatePerSecond: 10, MaxUnfinishedTasks: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
q := created.Queue
|
||||||
|
if _, err := api.ConfigureQueue(ctx, q.ID, workerTestID(t), ConfigureQueueRequest{ExpectedRevision: q.Revision, Reason: "batch acceptance regression", Paused: true, MaxConcurrency: 2, RatePerSecond: 10, MaxUnfinishedTasks: 2}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input := SubmitTaskRequest{QueueID: q.ID, Handler: "file.convert", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"n":9007199254740993}`)}
|
||||||
|
in := SubmitTasksRequest{Items: []SubmitTasksItem{{IdempotencyKey: workerTestID(t), Task: input}, {IdempotencyKey: workerTestID(t), Task: input}, {IdempotencyKey: workerTestID(t), Task: input}}}
|
||||||
|
// 真实HTTP代理先完整读到数据库受理回执,再强制断开客户端连接,丢弃整批响应。
|
||||||
|
target, _ := url.Parse(api.baseURL)
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
var dropped atomic.Bool
|
||||||
|
var calls atomic.Int32
|
||||||
|
proxy.ModifyResponse = func(r *http.Response) error {
|
||||||
|
if r.Request.URL.Path != "/v1/tasks:batch" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
calls.Add(1)
|
||||||
|
if dropped.CompareAndSwap(false, true) {
|
||||||
|
raw, err := io.ReadAll(r.Body)
|
||||||
|
r.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.StatusCode != 200 || !strings.Contains(string(raw), `"ACCEPTED"`) || !strings.Contains(string(raw), `"QUEUE_FULL"`) {
|
||||||
|
t.Error("unexpected pre-drop server result", string(raw))
|
||||||
|
}
|
||||||
|
return io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
conn, _, hijackErr := w.(http.Hijacker).Hijack()
|
||||||
|
if hijackErr == nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(proxy)
|
||||||
|
defer server.Close()
|
||||||
|
client, err := NewClient(server.URL, Options{Token: api.token})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := client.SubmitTasks(ctx, in); err == nil || !dropped.Load() || calls.Load() != 1 {
|
||||||
|
t.Fatal("response loss not surfaced without silent retry", err)
|
||||||
|
}
|
||||||
|
// 原连接已关闭;用原键和原内容核对,前两项去重,满额项保持明确拒绝。
|
||||||
|
out, err := client.SubmitTasks(ctx, in)
|
||||||
|
if err != nil || len(out.Items) != 3 {
|
||||||
|
t.Fatal(out, err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if out.Items[i].Status != BatchAccepted || !out.Items[i].Receipt.Deduplicated {
|
||||||
|
t.Fatal("lost response duplicated task", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.Items[2].Status != BatchRejected || out.Items[2].Error.Code != "QUEUE_FULL" || out.Items[2].Error.RetryAfterMS != 1000 {
|
||||||
|
t.Fatal(out)
|
||||||
|
}
|
||||||
|
page, err := api.ListTasks(ctx, ListTasksRequest{QueueID: q.ID})
|
||||||
|
if err != nil || len(page.Items) != 2 {
|
||||||
|
t.Fatal("duplicate durable rows", page, err)
|
||||||
|
}
|
||||||
|
single, err := api.SubmitTask(ctx, in.Items[0].IdempotencyKey, input)
|
||||||
|
if err != nil || !single.Deduplicated || single.Task.ID != out.Items[0].Receipt.TaskID || string(single.Task.Payload) != `{"n":9007199254740993}` {
|
||||||
|
t.Fatal("batch/single mismatch", err)
|
||||||
|
}
|
||||||
|
if _, err := api.CancelTask(ctx, single.Task.ID, workerTestID(t), CancelTaskRequest{ExpectedRevision: single.Task.Revision, Reason: "free slot for partial retry"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
suffix, err := api.SubmitTasks(ctx, SubmitTasksRequest{Items: in.Items[2:]})
|
||||||
|
if err != nil || suffix.Items[0].Status != BatchAccepted || suffix.Items[0].Receipt.Deduplicated || suffix.Items[0].Index != 0 {
|
||||||
|
t.Fatal(suffix, err)
|
||||||
|
}
|
||||||
|
for _, id := range []string{out.Items[1].Receipt.TaskID, suffix.Items[0].Receipt.TaskID} {
|
||||||
|
current, err := api.GetTask(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := api.CancelTask(ctx, id, workerTestID(t), CancelTaskRequest{ExpectedRevision: current.Revision, Reason: "finish batch regression"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Log("lost real HTTP response: two committed tasks recovered by identity, no duplicates; backpressured suffix independently accepted after capacity released")
|
||||||
|
}
|
||||||
75
sdk/go/batch_test.go
Normal file
75
sdk/go/batch_test.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package orbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBatchSDKWireValidationAndNoAutomaticRetry(t *testing.T) {
|
||||||
|
id := strings.Repeat("1", 32)
|
||||||
|
var calls atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
if r.URL.Path != "/v1/tasks:batch" || r.Method != "POST" || r.Header.Get("Authorization") != "Bearer test-token" || r.Header.Get("Idempotency-Key") != "" {
|
||||||
|
t.Error("invalid batch transport")
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
if !strings.Contains(string(raw), `"idempotency_key":"one"`) || !strings.Contains(string(raw), `9007199254740993`) {
|
||||||
|
t.Error("identity/precision lost")
|
||||||
|
}
|
||||||
|
if calls.Load() == 1 {
|
||||||
|
w.WriteHeader(503)
|
||||||
|
fmt.Fprintf(w, `{"request_id":%q,"error":{"code":"UNAVAILABLE","message":"retry original identities"}}`, id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `{"request_id":%q,"data":{"items":[{"index":0,"status":"ACCEPTED","receipt":{"task_id":%q,"queue_id":%q,"revision":"9007199254740993","created_at":"2026-09-17T00:00:00Z","deduplicated":true}}]}}`, id, id, id)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c, _ := NewClient(srv.URL, Options{Token: "test-token"})
|
||||||
|
in := SubmitTasksRequest{Items: []SubmitTasksItem{{IdempotencyKey: "one", Task: SubmitTaskRequest{QueueID: id, Payload: json.RawMessage(`{"n":9007199254740993}`)}}}}
|
||||||
|
if _, err := c.SubmitTasks(context.Background(), in); err == nil || calls.Load() != 1 {
|
||||||
|
t.Fatal("request silently retried", err)
|
||||||
|
}
|
||||||
|
out, err := c.SubmitTasks(context.Background(), in)
|
||||||
|
if err != nil || out.Items[0].Receipt.Revision != 9007199254740993 || !out.Items[0].Receipt.Deduplicated {
|
||||||
|
t.Fatal(out, err)
|
||||||
|
}
|
||||||
|
for _, bad := range []SubmitTasksRequest{{}, {Items: make([]SubmitTasksItem, 33)}, {Items: []SubmitTasksItem{in.Items[0], in.Items[0]}}, {Items: []SubmitTasksItem{{IdempotencyKey: " "}}}} {
|
||||||
|
if _, err := c.SubmitTasks(context.Background(), bad); err == nil {
|
||||||
|
t.Fatal("accepted invalid batch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in.Items[0].Task.Payload = json.RawMessage(`"` + strings.Repeat("x", 512<<10) + `"`)
|
||||||
|
if _, err := c.SubmitTasks(context.Background(), in); err == nil || calls.Load() != 2 {
|
||||||
|
t.Fatal("unbounded batch reached network")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchSDKRejectsIncompleteAndContradictoryReceipts(t *testing.T) {
|
||||||
|
id := strings.Repeat("1", 32)
|
||||||
|
for _, data := range []string{
|
||||||
|
`{"items":[]}`,
|
||||||
|
`{"items":[{"index":1,"status":"REJECTED","error":{"code":"INVALID_ARGUMENT","message":"bad"}}]}`,
|
||||||
|
`{"items":[{"index":0,"status":"UNKNOWN","error":{"code":"INVALID_ARGUMENT","message":"bad"}}]}`,
|
||||||
|
`{"items":[{"index":0,"status":"REJECTED","error":{"code":"UNAVAILABLE","message":"unknown"}}]}`,
|
||||||
|
`{"items":[{"index":0,"status":"NOT_ATTEMPTED","error":{"code":"UNAVAILABLE","message":"bad"}}]}`,
|
||||||
|
`{"items":[{"index":0,"status":"ACCEPTED"}]}`,
|
||||||
|
`{"items":[{"index":0,"status":"REJECTED","receipt":{},"error":{"code":"QUEUE_FULL","message":"full"}}]}`,
|
||||||
|
`{"items":[{"index":0,"status":"NEW_STATUS"}]}`,
|
||||||
|
} {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `{"request_id":%q,"data":%s}`, id, data) }))
|
||||||
|
c, _ := NewClient(srv.URL, Options{Token: "test-token"})
|
||||||
|
out, err := c.SubmitTasks(context.Background(), SubmitTasksRequest{Items: []SubmitTasksItem{{IdempotencyKey: "one", Task: SubmitTaskRequest{QueueID: id}}}})
|
||||||
|
srv.Close()
|
||||||
|
if err == nil || len(out.Items) != 0 {
|
||||||
|
t.Fatal("malformed response exposed as success", out, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Package orbit 是 Orbit 的独立 Go SDK,提供健康、任务、队列、计划及结果订阅。
|
// Package orbit 是 Orbit 的独立 Go SDK,提供健康、任务、队列、计划及结果订阅。
|
||||||
// WorkerConnection 是底层接口;NewWorker/Run 自动管理处理器、续租、恢复与排空。
|
// WorkerConnection 是底层接口;NewWorker/Run 自动管理处理器、续租、恢复与排空。
|
||||||
// EventConnection 是结果交付协议;EventConsumer 自动管理心跳、重连与确认,业务端负责事务去重。
|
// EventConnection 是结果交付协议;EventConsumer 自动管理心跳、重连与确认,业务端负责事务去重。
|
||||||
|
// SubmitTasks 提供有界批量受理,每项沿用独立稳定身份并返回可核对的受理状态。
|
||||||
// 流程 SDK 等后续能力按阶段继续开发。
|
// 流程 SDK 等后续能力按阶段继续开发。
|
||||||
// SDK 不导入服务端模型、迁移或数据库依赖。
|
// SDK 不导入服务端模型、迁移或数据库依赖。
|
||||||
package orbit
|
package orbit
|
||||||
|
|||||||
122
sdk/go/examples/batch/main.go
Normal file
122
sdk/go/examples/batch/main.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// batch 演示批量部分受理、稳定身份核对和释放配额后仅补交失败项。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"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 := os.Getenv("ORBIT_TOKEN")
|
||||||
|
if token == "" && os.Getenv("ORBIT_CREDENTIALS_FILE") != "" {
|
||||||
|
raw, err := os.ReadFile(os.Getenv("ORBIT_CREDENTIALS_FILE"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read credentials: %w", err)
|
||||||
|
}
|
||||||
|
var c struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &c) != nil {
|
||||||
|
return fmt.Errorf("invalid credentials file")
|
||||||
|
}
|
||||||
|
token = c.Token
|
||||||
|
}
|
||||||
|
api, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var random [16]byte
|
||||||
|
if _, err := rand.Read(random[:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key := "batch-example-" + hex.EncodeToString(random[:])
|
||||||
|
q, err := api.CreateQueue(ctx, key+"-queue", orbit.CreateQueueRequest{Name: key, MaxConcurrency: 2, RatePerSecond: 10, MaxUnfinishedTasks: 2})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := api.ConfigureQueue(ctx, q.Queue.ID, key+"-pause", orbit.ConfigureQueueRequest{ExpectedRevision: q.Queue.Revision, Reason: "isolated batch acceptance example", Paused: true, MaxConcurrency: 2, RatePerSecond: 10, MaxUnfinishedTasks: 2}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request := orbit.SubmitTasksRequest{}
|
||||||
|
// 演示运行生成独立身份;生产必须先将每项键与完整参数持久化到业务库,再发送。
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
payload := json.RawMessage(fmt.Sprintf(`{"source_ref":"objects/input-%d.txt","sequence":9007199254740993}`, i))
|
||||||
|
request.Items = append(request.Items, orbit.SubmitTasksItem{IdempotencyKey: fmt.Sprintf("%s-%d", key, i), Task: orbit.SubmitTaskRequest{QueueID: q.Queue.ID, Handler: "file.convert", HandlerVersion: "v1", PayloadVersion: "v1", Payload: payload, TaskTimeoutMS: 60000}})
|
||||||
|
}
|
||||||
|
first, err := api.SubmitTasks(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if first.Items[0].Status != orbit.BatchAccepted || first.Items[1].Status != orbit.BatchAccepted || first.Items[2].Status != orbit.BatchRejected || first.Items[2].Error.Code != "QUEUE_FULL" {
|
||||||
|
return fmt.Errorf("unexpected partial acceptance")
|
||||||
|
}
|
||||||
|
fmt.Println("accepted=2 rejected=1 code=QUEUE_FULL")
|
||||||
|
replay, err := api.SubmitTasks(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if replay.Items[i].Status != orbit.BatchAccepted || !replay.Items[i].Receipt.Deduplicated || replay.Items[i].Receipt.TaskID != first.Items[i].Receipt.TaskID {
|
||||||
|
return fmt.Errorf("batch replay changed identity")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if replay.Items[2].Status != orbit.BatchRejected || replay.Items[2].Error.Code != "QUEUE_FULL" {
|
||||||
|
return fmt.Errorf("backpressure changed unexpectedly")
|
||||||
|
}
|
||||||
|
fmt.Println("replayed=2 same_task_ids=true")
|
||||||
|
cancelTask := func(id string) error {
|
||||||
|
current, err := api.GetTask(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := api.CancelTask(ctx, id, key+"-cancel-"+id, orbit.CancelTaskRequest{ExpectedRevision: current.Revision, Reason: "finish batch example"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if out.Task.State != orbit.TaskCancelled {
|
||||||
|
return fmt.Errorf("expected confirmed cancellation")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := cancelTask(first.Items[0].Receipt.TaskID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 子批次索引重新从0开始;键和参数仍来自最初被拒项,不重新生成。
|
||||||
|
retry, err := api.SubmitTasks(ctx, orbit.SubmitTasksRequest{Items: request.Items[2:]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if retry.Items[0].Status != orbit.BatchAccepted || retry.Items[0].Receipt.Deduplicated {
|
||||||
|
return fmt.Errorf("failed item was not independently accepted")
|
||||||
|
}
|
||||||
|
fmt.Println("retried_items=1 same_identity=true accepted=true")
|
||||||
|
for _, id := range []string{first.Items[1].Receipt.TaskID, retry.Items[0].Receipt.TaskID} {
|
||||||
|
if err := cancelTask(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final, err := api.GetQueue(ctx, q.Queue.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if final.UnfinishedTasks != 0 {
|
||||||
|
return fmt.Errorf("unfinished tasks remain")
|
||||||
|
}
|
||||||
|
fmt.Printf("queue=%s paused=%t unfinished=%d\n", final.ID, final.Paused, final.UnfinishedTasks)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -93,19 +93,31 @@ func validID(id string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
func (c *Client) call(ctx context.Context, method, path, key string, in, out any) error {
|
func (c *Client) call(ctx context.Context, method, path, key string, in, out any) error {
|
||||||
|
if method != "GET" {
|
||||||
|
if err := validateIdempotencyKey(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.callJSON(ctx, method, path, key, in, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateIdempotencyKey(key string) error {
|
||||||
|
if len(key) < 1 || len(key) > 128 {
|
||||||
|
return errors.New("a stable Idempotency-Key of 1 to 128 ASCII characters is required")
|
||||||
|
}
|
||||||
|
for _, ch := range key {
|
||||||
|
if ch < 33 || ch > 126 {
|
||||||
|
return errors.New("Idempotency-Key must not contain spaces or control characters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// callJSON 仅执行有界HTTP交换;写入调用者负责校验整项或批量内的独立幂等键。
|
||||||
|
func (c *Client) callJSON(ctx context.Context, method, path, key string, in, out any) error {
|
||||||
if c.token == "" {
|
if c.token == "" {
|
||||||
return errors.New("Orbit token is required for business APIs")
|
return errors.New("Orbit token is required for business APIs")
|
||||||
}
|
}
|
||||||
if method != "GET" {
|
|
||||||
if len(key) < 1 || len(key) > 128 {
|
|
||||||
return errors.New("a stable Idempotency-Key of 1 to 128 ASCII characters is required")
|
|
||||||
}
|
|
||||||
for _, ch := range key {
|
|
||||||
if ch < 33 || ch > 126 {
|
|
||||||
return errors.New("Idempotency-Key must not contain spaces or control characters")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var body []byte
|
var body []byte
|
||||||
var err error
|
var err error
|
||||||
if in != nil {
|
if in != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user