feat: add reliable event consumer SDK and process recovery tests

This commit is contained in:
2026-09-17 20:22:41 +08:00
parent 8485a662b2
commit b17d9b1977
25 changed files with 2170 additions and 28 deletions

View File

@@ -14,4 +14,4 @@
- 结果日志的序号在独立命名空间流行锁下分配,与任务状态及原事件同事务提交;禁止用最大事件ID或时间戳推断提交水位。生产者不得锁订阅行。 - 结果日志的序号在独立命名空间流行锁下分配,与任务状态及原事件同事务提交;禁止用最大事件ID或时间戳推断提交水位。生产者不得锁订阅行。
- 创建订阅先锁队列再锁流行;消费按订阅→交付持锁,不能再取得任务、队列、额度或流行锁。ACK只验证最近代次及证明,过期未被替代的证明仍可确认;重复确认不能再次扣减pending_count。 - 创建订阅先锁队列再锁流行;消费按订阅→交付持锁,不能再取得任务、队列、额度或流行锁。ACK只验证最近代次及证明,过期未被替代的证明仍可确认;重复确认不能再次扣减pending_count。
- 可靠订阅已完成核心、HTTP/WSS及管理SDK;事件消费SDK、可运行Inbox示例和真实订阅进程故障回归完成前,不标记P3订阅能力完成。 - 可靠订阅已完成核心、HTTP/WSS、管理及消费SDK和节点进程故障回归;业务侧持久化Inbox示例及消费者进程退出后的事务去重回归完成前,不标记P3订阅能力完成。

View File

@@ -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 与 Inbox 场景待完成;剩余 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) 已实现,Inbox 场景待完成;剩余 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)。

View File

@@ -1,6 +1,6 @@
# 数据模型与实现基线 # 数据模型与实现基线
更新:2026-09-17。数据库统一禁止外键。当前已实现任务执行、安全重试、运行中取消、计划调度、队列限速/背压和应用配额;优先级及节点公平领取已实现;可靠订阅核心、HTTP/WSS 与管理 SDK 已实现,消费 SDK 及流程编排继续开发。 更新:2026-09-17。数据库统一禁止外键。当前已实现任务执行、安全重试、运行中取消、计划调度、队列限速/背压和应用配额;优先级及节点公平领取已实现;可靠订阅核心、HTTP/WSS、管理与消费 SDK 已实现,Inbox 场景及流程编排继续开发。
## 1. 模块与依赖 ## 1. 模块与依赖
@@ -95,7 +95,7 @@
禁止“先查后另开事务写入”。需要防止并发状态改变的关联行在事务内持锁,统一锁顺序并保留必要索引。当前不提供硬删除;将来增加清理时,必须核验在途任务、引用、幂等恢复窗口与审计保留期,按有限批次处理,不能依赖数据库级联。直接绕过服务写 SQL 不受应用校验保护。 禁止“先查后另开事务写入”。需要防止并发状态改变的关联行在事务内持锁,统一锁顺序并保留必要索引。当前不提供硬删除;将来增加清理时,必须核验在途任务、引用、幂等恢复窗口与审计保留期,按有限批次处理,不能依赖数据库级联。直接绕过服务写 SQL 不受应用校验保护。
P1 已完成动作/队列授权、幂等摘要与原回执、受理事件及取消 CAS/审计事务;P3 扩展到运行中取消。订阅已实现服务层及持久化核心,包含事务内队列、所有者和交付归属校验;HTTP/WSS 和管理 SDK 已接入,消费 SDK 待完成。流程目前仍只具备模型,后续引擎也必须补齐事务内父资源归属验证。 P1 已完成动作/队列授权、幂等摘要与原回执、受理事件及取消 CAS/审计事务;P3 扩展到运行中取消。订阅已实现服务层及持久化核心,包含事务内队列、所有者和交付归属校验;HTTP/WSS、管理与消费 SDK 已接入,业务 Inbox 示例待完成。流程目前仍只具备模型,后续引擎也必须补齐事务内父资源归属验证。
P2 服务端已新增会话隔离、两步派发、租约、并发许可、结果回执/证据和恢复扫描,见 [执行协议](api/worker.md)。底层及自动 Worker SDK、尝试/证据查询、Schema 与进程故障恢复已通过 [P2 阶段验收](reports/phase-2.md)。 P2 服务端已新增会话隔离、两步派发、租约、并发许可、结果回执/证据和恢复扫描,见 [执行协议](api/worker.md)。底层及自动 Worker SDK、尝试/证据查询、Schema 与进程故障恢复已通过 [P2 阶段验收](reports/phase-2.md)。
@@ -151,7 +151,7 @@ P2 服务端已新增会话隔离、两步派发、租约、并发许可、结
## 13. 可靠结果订阅核心 ## 13. 可靠结果订阅核心
000013_result_subscriptions 新增 EventStream、ResultEvent,扩展 Subscription 和 Delivery。289 个直接声明字段均有中文注释。生产事务在独立流行锁下分配序号,与任务、原事件和结果快照一起提交;消费事务不锁流行、任务、队列或应用额度。交付记录和扫描位置一起落库,每条交付单独持有租约和证明。重复确认不重复扣减积压,耗尽保留记录,人工追加预算不重置历史。详见 [订阅核心](09-result-subscription-core.md)。对外 HTTP/WSS 与管理 SDK 已实现,消费 SDK 与完整场景继续推进。 000013_result_subscriptions 新增 EventStream、ResultEvent,扩展 Subscription 和 Delivery。289 个直接声明字段均有中文注释。生产事务在独立流行锁下分配序号,与任务、原事件和结果快照一起提交;消费事务不锁流行、任务、队列或应用额度。交付记录和扫描位置一起落库,每条交付单独持有租约和证明。重复确认不重复扣减积压,耗尽保留记录,人工追加预算不重置历史。详见 [订阅核心](09-result-subscription-core.md)。对外 HTTP/WSS、管理与消费 SDK 已实现,持久化 Inbox 完整场景继续推进。
## 14. 交付历史查询索引 ## 14. 交付历史查询索引

View File

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

View File

@@ -1,6 +1,6 @@
# 可靠结果订阅:持久化核心 # 可靠结果订阅:持久化核心
状态:P3 的存储与服务层已实现并通过本地全量回归,见 [验证记录](reports/phase-3-result-subscription-core.md)。[HTTP/WSS、管理 SDK 与管理示例](api/subscriptions.md) 已接入。事件连接 SDK、自动消费和持久化 Inbox 示例继续开发;本页的存储方法属于服务端内部模块。 状态:P3 的存储与服务层已实现并通过本地全量回归,见 [验证记录](reports/phase-3-result-subscription-core.md)。[HTTP/WSS、管理 SDK 与管理示例](api/subscriptions.md) 已接入。[事件连接 SDK 与自动消费器](sdk/event-consumer.md) 已实现,持久化 Inbox 示例继续开发;本页的存储方法属于服务端内部模块。
## 1. 已实现范围 ## 1. 已实现范围
@@ -91,4 +91,4 @@ retained_from_sequence 初始且目前固定为 1。未来实现清理后,如
## 8. 下一步 ## 8. 下一步
HTTP 创建/查询/分页、暂停/继续、交付与耗尽记录查询、人工重试,以及 orbit.events.v1 WSS、消息 Schema、管理 SDK 和管理示例已实现,见 [接口手册](api/subscriptions.md)。接下来完成事件连接 SDK、自动消费及持久化 Inbox 去重示例,并补 SDK 断线/ACK 丢失和节点进程强杀回归,再验收 P3 的可靠订阅能力。 HTTP 创建/查询/分页、暂停/继续、交付与耗尽记录查询、人工重试,以及 orbit.events.v1 WSS、消息 Schema、管理 SDK 和管理示例已实现,见 [接口手册](api/subscriptions.md)。[事件连接 SDK 和自动消费器](sdk/event-consumer.md) 已实现;继续完成持久化 Inbox 去重示例与消费者进程故障回归,再验收 P3 的可靠订阅能力。

View File

@@ -21,7 +21,7 @@
| `/v1/tasks/{task_id}:cancel` 与 `task.cancel` 通知 | P3 运行中取消已实现 | [取消及 SDK 场景](cancellation.md) | | `/v1/tasks/{task_id}:cancel` 与 `task.cancel` 通知 | P3 运行中取消已实现 | [取消及 SDK 场景](cancellation.md) |
| `/v1/schedules`、不可变版本、暂停/恢复及触发查询 | P3 已实现 | [计划接口与 SDK 场景](schedules.md) | | `/v1/schedules`、不可变版本、暂停/恢复及触发查询 | P3 已实现 | [计划接口与 SDK 场景](schedules.md) |
| `/v1/subscriptions` 及管理、交付查询与重试 | 已实现 HTTP、管理 SDK 和完整管理示例 | [订阅手册](subscriptions.md) | | `/v1/subscriptions` 及管理、交付查询与重试 | 已实现 HTTP、管理 SDK 和完整管理示例 | [订阅手册](subscriptions.md) |
| `/v1/events/connect` | 已实现服务端 WSS 与消息 Schema;消费 SDK 待交付 | [消息协议](subscriptions.md#5-websocket-建连与边界) | | `/v1/events/connect` | 已实现 WSS、消息 Schema、连接 SDK 和自动消费器 | [消息协议](subscriptions.md#5-websocket-建连与边界) / [消费 SDK](../sdk/event-consumer.md) |
| `/v1/workflows` 与流程管理 | 计划 P4–P5,尚未实现 | [流程接口规划](workflows.md) | | `/v1/workflows` 与流程管理 | 计划 P4–P5,尚未实现 | [流程接口规划](workflows.md) |
未实现路由返回 404。健康检查就绪不表示任务执行、定时调度或流程引擎已经可用。 未实现路由返回 404。健康检查就绪不表示任务执行、定时调度或流程引擎已经可用。

View File

@@ -58,4 +58,4 @@ P2 增加 workers:connect 和 workers:consume。bootstrap 只有显式提供 --w
P3 应用额度管理新增 `application:resources:read` 和 `application:resources:manage`。只有显式 `--resource-access` 才授予,作用域是所属应用的全部命名空间,普通队列管理不能替代。查询返回聚合计数,不开放其他命名空间的任务数据。`bootstrap --application-id <现有应用ID>` 可由数据库管理员增加命名空间,与 `--application` 二选一;验证应用启用及额度记录存在后,同事务创建新资源,所有秘密交付约定保持不变。应用默认未终结上限 1000000、并发 10000、开始速率 100000/秒;详见 [应用共享配额](application-resources.md)。 P3 应用额度管理新增 `application:resources:read` 和 `application:resources:manage`。只有显式 `--resource-access` 才授予,作用域是所属应用的全部命名空间,普通队列管理不能替代。查询返回聚合计数,不开放其他命名空间的任务数据。`bootstrap --application-id <现有应用ID>` 可由数据库管理员增加命名空间,与 `--application` 二选一;验证应用启用及额度记录存在后,同事务创建新资源,所有秘密交付约定保持不变。应用默认未终结上限 1000000、并发 10000、开始速率 100000/秒;详见 [应用共享配额](application-resources.md)。
P3 结果订阅核心新增 `events:read`、`events:manage` 和 `events:consume`,bootstrap 仅显式 `--event-access` 时授予。订阅绑定创建凭据与单个队列,不能用同命名空间的另一张凭据消费或管理。当前 HTTP/WSS 和管理 SDK 已接入;消费 SDK、凭据重绑继续开发,详见 [订阅接口](subscriptions.md)。 P3 结果订阅核心新增 `events:read`、`events:manage` 和 `events:consume`,bootstrap 仅显式 `--event-access` 时授予。订阅绑定创建凭据与单个队列,不能用同命名空间的另一张凭据消费或管理。当前 HTTP/WSS、管理及消费 SDK 已接入;凭据重绑继续开发,详见 [订阅接口](subscriptions.md)。

View File

@@ -322,6 +322,6 @@ _, err = client.ConfigureSubscription(ctx, current.ID, pauseKey, orbit.Configure
return err return err
``` ```
[完整管理示例](../../sdk/go/examples/subscriptions/main.go) 的启动、输入输出、失败和保留规则见 [场景文档](../examples/subscriptions.md)。它演示管理与幂等,不启动结果消费者。WSS 连接 SDK、自动重连和持久化 Inbox 示例仍在下一阶段开发,不能把测试中的原始 WebSocket 辅助代码当成正式 SDK。 [完整管理示例](../../sdk/go/examples/subscriptions/main.go) 的启动、输入输出、失败和保留规则见 [场景文档](../examples/subscriptions.md)。它演示管理与幂等,不启动结果消费者。正式 [事件连接 SDK 与自动消费器](../sdk/event-consumer.md) 已提供接收、ACK、心跳、重连与有界收尾;[完整协议示例](../examples/event-protocol.md) 验证跨连接重报原确认。持久化 Inbox 示例继续开发。
机器可读消息契约见 [events.schema.json](../../protocol/eventsv1/events.schema.json),使用 Draft 2020-12,clientFrame/serverFrame 分方向约束。运行时额外拒绝重复 JSON 键、超范围 uint64 及非规范 base64url。Schema 不代替授权、状态机、租约及事务校验。 机器可读消息契约见 [events.schema.json](../../protocol/eventsv1/events.schema.json),使用 Draft 2020-12,clientFrame/serverFrame 分方向约束。运行时额外拒绝重复 JSON 键、超范围 uint64 及非规范 base64url。Schema 不代替授权、状态机、租约及事务校验。

View File

@@ -114,4 +114,11 @@
- 步骤:创建 → 同键核对 → 暂停 → 恢复 → 查询与分页 → 最终暂停。 - 步骤:创建 → 同键核对 → 暂停 → 恢复 → 查询与分页 → 最终暂停。
- 预期:duplicate=true、resumed_revision=3、final_enabled=false、revision=4、pending=0。 - 预期:duplicate=true、resumed_revision=3、final_enabled=false、revision=4、pending=0。
- 保留:每次运行留下一个已暂停订阅及审计;没有硬删除,受64个订阅上限约束。 - 保留:每次运行留下一个已暂停订阅及审计;没有硬删除,受64个订阅上限约束。
- 范围:本例只演示管理SDK;自动消费、断线重连与持久化Inbox例子继续开发。 - 范围:本例只演示管理SDK;自动消费 SDK 已实现,持久化Inbox例子继续开发。
## 已可运行:结果消费协议与跨连接确认
- 源码:[事件协议示例](../../sdk/go/examples/event-protocol/main.go),[启动与输出说明](event-protocol.md)。
- 操作:创建订阅 → 提交并取消任务 → 接收冻结结果 → ACK → 重连重报原证明 → 核对原确认时间 → 暂停订阅。
- 输出:ack_replayed=true、same_receipt=true、pending=0、subscription_enabled=false。
- 范围:本例只做读取验证,不写业务数据;[自动消费器](../sdk/event-consumer.md) 的生产接入须提供事务Inbox,不能把示例当作业务恰好一次保证。

View File

@@ -0,0 +1,30 @@
# 结果消费协议示例
[完整代码](../../sdk/go/examples/event-protocol/main.go) 使用正式 Go SDK 创建取消结果订阅,提交并取消一个待执行任务,接收并验证冻结结果,然后 ACK;关闭连接后在新连接中重报原证明,验证原确认时间和去重回执,最后暂停本次订阅。
本例只读取并验证结果,没有业务数据库写入。生产业务应使用 [自动消费器及事务去重约定](../sdk/event-consumer.md)。持久化 Inbox 完整示例尚待交付。
## 启动
先按 [订阅管理示例](subscriptions.md) 建立隔离示例队列和带 --event-access 的凭据,启动 Orbit。凭据需要 tasks:submit、tasks:cancel、events:manage/read/consume 及该队列范围;订阅绑定创建凭据,不能临时换凭据消费。
从 sdk/go 目录运行:
```sh
ORBIT_URL=http://127.0.0.1:8080 \
ORBIT_CREDENTIALS_FILE=../../.local/event.credentials.json \
go run ./examples/event-protocol
```
也可以传 ORBIT_TOKEN 与 ORBIT_QUEUE_ID,环境变量覆盖凭据文件对应值。每次运行新建随机订阅及任务身份,全部流程最多 15 秒。本例是短流程,两个连接均短于一次心跳周期;长期运行必须用自动消费器或主动 Ping。
## 输出与核对
```text
event=<本次事件ID> task=<本次任务ID> state=CANCELLED
ack_replayed=true same_receipt=true pending=0 subscription_enabled=false
```
该输出验证的是事件快照和平台 ACK 去重,不表示业务副作用获得恰好一次保证。任何请求失败、收到非本次任务的事件或确认时间不一致,都非零退出;保留已生成的状态供核查。
需要独立示例队列,避免其他程序同时产生取消结果。正常结束暂停本次订阅,保留任务、交付和审计;不删除历史。每个命名空间最多 64 个订阅,重复演示应使用独立测试环境。`make integration` 在一次性 MySQL 和真实进程上实际执行本例。

View File

@@ -0,0 +1,75 @@
# P3 事件消费 SDK 与节点故障回归
验证日期:2026-09-17。已交付 [消费 SDK](../sdk/event-consumer.md) 和 [协议示例](../examples/event-protocol.md)。业务端持久化 Inbox 示例及消费者进程强杀恢复尚未完成,P3 和持续目标继续进行。
## 1. 实现范围
- [EventConnection](../../sdk/go/event_connection.go):严格事件协议、接收、请求关联、ACK、心跳和关闭;控制最多32个在途,接收缓冲为声明容量,超时关闭连接后保留原证明核对。
- [EventConsumer](../../sdk/go/event_consumer.go):自动心跳、指数退避重连、本地1–32个在途事件、成功后确认、ACK不明时原证明核对、处理器失败不确认、取消与有界收尾。
- [类型及秘密隐藏](../../sdk/go/event_types.go):ResultEvent不给业务处理器租约秘密;Grant/Proof仍可显式序列化用于受保护的存储,普通fmt隐藏敏感数据。
- [严格解码](../../sdk/go/event_codec.go):大小写、重复键、方向、会话、规范uint64、证明编码和事件/交付/快照关联。大整数不经float64转换。协议类WebSocket关闭按永久错误处理。
- [协议例子](../../sdk/go/examples/event-protocol/main.go):产生取消结果、读取、ACK、重连重报、验证原确认时间、暂停订阅。它只读取结果,不执行接收方业务写入。
- SDK保持独立模块,无新增数据库驱动、服务端包或持久化模型。本阶段不增加迁移、外键或级联删除。
## 2. 验证环境与入口
macOS、Go1.27.1、Docker MySQL8.4.10、一次性数据库;普通SDK集成连接真实Orbit进程,故障网关使用本地TLS,进程回归使用真实子进程和SIGKILL。注入后用Wait确认进程已经退出,不能以发送了Kill调用代替终止证据。
```sh
make fmt test vet build
make integration
cd sdk/go
GOWORK=off go test -race ./...
```
| 分组 | 结果 |
| --- | --- |
| 全量MySQL集成 | PASS,92.995秒 |
| SDK Worker连接/运行器 | PASS,88.494秒 |
| SDK计划、队列、订阅管理、应用配额 | PASS,5.704 / 4.466 / 33.386 / 1.363秒 |
| 新消费SDK三项集成 | PASS,2.18 / 15.28 / 33.14秒,包总计51.843秒 |
| 新节点交付租约恢复 | PASS,32.15秒 |
| 新ACK提交/丢响应/节点强杀 | PASS,1.31秒 |
| 九项真实进程故障(原七项加新两项) | PASS,包总计61.810秒 |
| 完整SDK示例 | 14个全部通过,包括新的event-protocol |
| fmt、race、vet、build | PASS |
| GOWORK=off独立SDK模块 | PASS,编译及竞态测试不依赖服务端workspace |
完整SDK示例输出包含:
```text
event=<本次事件ID> task=<本次任务ID> state=CANCELLED
ack_replayed=true same_receipt=true pending=0 subscription_enabled=false
```
## 3. 协议与运行器边界
[连接单元回归](../../sdk/go/event_connection_test.go) 在TLS服务器上混合投递与乱序控制响应,验证独立关联、精确数字、秘密隐藏;取消Next不关闭连接,已发ACK取消关闭连接并报告未知确认状态。32个控制槽满后拒绝新控制,交付缓冲满后关闭。拒绝重复键、大小写别名、非规范计数、null布尔/错误、缺失创建时间、错误事件关联、状态不匹配、额外字段及不匹配ACK;1008关闭不无限重连。
[运行器回归](../../sdk/go/event_consumer_test.go) 验证ACK回应丢失后原证明重连,不重复运行仍保留的已提交处理器;新代次替换后重新进入业务去重;error/panic/Goexit均不确认、不立即本地重试。取消后收集已提交结果,忽略取消的处理器在收尾期限到达后报告Running与Unconfirmed。永久401只连接一次,实例不能重复Run,容量2实际并行处理并限制并发。
处理器测试中的原子计数只用于确认调用次数,不能作为已实现持久化Inbox的证据。
## 4. 真实服务与MySQL
[三项SDK集成](../../sdk/go/event_integration_test.go):
1. 接收已取消任务的冻结快照,暂停后Ping返回enabled=false,已有Proof仍能ACK;关闭连接后重报返回同一确认时间。暂停时新增结果不投递,恢复后继续消费,最终pending_count=0。
2. 处理器持续等待真实15秒自动心跳,故障网关收到上游心跳ACK才放行业务完成。随后上游提交事件ACK,网关丢弃回应并断开。消费器重连核对原证明,处理器调用数和交付次数均保持1。
3. 首次处理器返回error,SDK不ACK。测试等待服务端真实30秒租约及退避,不修改数据库时间;同一交付以第二代次发送,处理成功后确认,最终attempts=2、HandlerFailures=1。
## 5. 真实节点强杀
[进程故障测试](../../sdk/go/event_process_integration_test.go):
- **领取提交后节点退出**:节点A成功投递后被SIGKILL并确认退出。节点B读取同一MySQL,在租约自然到期后恢复相同event_id/delivery_id,代次和次数增加到2。旧Proof返回DELIVERY_LEASE_LOST,新Proof成功ACK,订阅积压清零。
- **ACK提交后响应丢失**:网关截获成功ACK回应并阻断后续重连;通过查询确认ACKED和已保存的确认时间,然后强杀节点A。启动节点B,切换网关并恢复连接;Consumer仍持有原Proof,返回原确认时间,不新建交付尝试,不重跑处理器。
这两项强杀的是Orbit服务端进程。第二项消费器保持运行,其内存证明未丢失;不能将此证据当作消费者进程被强杀后业务去重也已验证。
## 6. 保留边界
- 后续需要交付独立业务数据库的Inbox事务例子,验证提交后/ACK前消费者进程退出、并发重复投递以及接收方事务回滚/提交不明等窗口,再验收P3可靠订阅。
- Consumer取消或传输断线不能撤销已经发出的业务效果;交付租约不是业务互斥锁。生产业务需要持久化唯一键和事务,外部副作用需要业务幂等/Outbox/核查。
- 当前仍按启用订阅的领取活动恢复过期交付;独立清扫、历史保留与清理、凭据安全重绑和生产容量/RPO/RTO仍属后续阶段。
- 结果证明只覆盖本报告的单数据库、进程/TLS网关拓扑;未将本机回归等同于百万租户容量或生产高可用验收。

View File

@@ -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 继续开发。流程构建等后续接口按 [开发计划](../08-development-plan.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`,不引用服务端模型或数据库实现。
@@ -108,6 +108,8 @@ fmt.Println(cancelled.Task.State)
| 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) |
| DialEvents / Next / Ack / Ping | 单连接交付、确认与心跳,可用原证明跨连接重报 | [消费 SDK](event-consumer.md) |
| NewEventConsumer / Run / Events / Stats | 自动心跳、重连、受控处理器并发及有界收尾 | [消费 SDK](event-consumer.md) |
| CreateSchedule / GetSchedule / ListSchedules | 计划受理、详情与队列分页 | [计划](../api/schedules.md) | | CreateSchedule / GetSchedule / ListSchedules | 计划受理、详情与队列分页 | [计划](../api/schedules.md) |
| PublishSchedule / GetScheduleVersion | 不可变发布与历史版本 | [计划](../api/schedules.md) | | PublishSchedule / GetScheduleVersion | 不可变发布与历史版本 | [计划](../api/schedules.md) |
| PauseSchedule / ResumeSchedule / ListScheduleOccurrences | 控制与触发记录 | [计划](../api/schedules.md) | | PauseSchedule / ResumeSchedule / ListScheduleOccurrences | 控制与触发记录 | [计划](../api/schedules.md) |
@@ -186,4 +188,4 @@ P2 已提供底层协议、[自动 Worker 运行器及示例](worker-runtime.md)
## 订阅管理示例 ## 订阅管理示例
[完整运行方法](../examples/subscriptions.md) 使用显式 --event-access 凭据,执行创建/同键核对/暂停/恢复/查询,再暂停本次订阅并保留审计。源码见 [examples/subscriptions](../../sdk/go/examples/subscriptions/main.go)。WSS 连接 SDK、自动消费与持久化 Inbox 示例尚未交付,不能将此管理示例当作完整可靠消费链路。 [完整运行方法](../examples/subscriptions.md) 使用显式 --event-access 凭据,执行创建/同键核对/暂停/恢复/查询,再暂停本次订阅并保留审计。源码见 [examples/subscriptions](../../sdk/go/examples/subscriptions/main.go)。WSS 连接 SDK 与自动消费器见 [消费手册](event-consumer.md),可运行 [协议示例](../examples/event-protocol.md) 验证跨连接 ACK;持久化 Inbox 示例继续开发。

View File

@@ -0,0 +1,94 @@
# Go 结果消费 SDK
对应 [HTTP/WSS 契约](../api/subscriptions.md)。SDK 是独立模块,只依赖 HTTP/WebSocket,不导入服务端模型、存储或数据库驱动。订阅先用管理 API 创建,并固定在创建凭据与队列范围内。
已提供 [EventConnection](../../sdk/go/event_connection.go)、[EventConsumer](../../sdk/go/event_consumer.go)、[公开类型](../../sdk/go/event_types.go) 与 [可运行协议示例](../examples/event-protocol.md)。业务 MySQL Inbox 示例和消费者进程强杀验证尚在后续阶段,不能把 SDK 内存状态作为持久化去重。
## 1. 自动消费
下面是可复用的应用函数。`applyOnce` 由接收方提供,必须在同一数据库事务内保存事件去重记录和业务变更;事务明确提交或读取到已经提交的同一事件后才返回 nil。它不能仅向内存队列投递后返回。
```go
func consumeResults(
ctx context.Context,
client *orbit.Client,
subscriptionID string,
applyOnce func(context.Context, orbit.ResultEvent) error,
) error {
consumer, err := orbit.NewEventConsumer(client, orbit.EventConsumerOptions{
SubscriptionID: subscriptionID,
Capacity: 4,
Handle: applyOnce,
ShutdownTimeout: 30 * time.Second,
ReconnectMinDelay: 200 * time.Millisecond,
ReconnectMaxDelay: 5 * time.Second,
})
if err != nil { return err }
return consumer.Run(ctx)
}
```
所在文件需导入 `context`、`time` 和 `orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"`。同一 Consumer 只能 Run 一次;多实例可共同绑定同一个订阅,按服务端交付租约竞争,不保证事件处理/完成顺序。
| 配置 | 默认 / 范围 |
| --- | --- |
| SubscriptionID | 必填;有效的 32 位小写十六进制 ID |
| Handle | 必填;处理冻结的 ResultEvent,不接收交付秘密 |
| Capacity | 默认 4;1–32 个本地在途事件及处理器 |
| ShutdownTimeout | 默认 30 秒;1 毫秒至 5 分钟 |
| ReconnectMinDelay | 默认 200 毫秒;至少 10 毫秒 |
| ReconnectMaxDelay | 默认 5 秒;不小于起点且不超过 30 秒 |
每个连接另有 Capacity 条未读交付缓冲,满后关闭连接并按退避重连。处理器结果缓冲不超过 Capacity,本地观察缓冲 128;不会为积压无限创建 goroutine。自动消费器每条连接至多一个 ACK 在途,心跳独立并发,慢 ACK 不阻塞 WebSocket 读取。
## 2. 提交、补发和去重
推荐的接收方事务步骤:
1. 使用固定消费者用途与事件 ID 建立唯一键,例如 `(consumer_name, event_id)`。不要使用连接 ID、交付代次或消息 ID 作为业务去重键。
2. 在一个业务事务中插入 Inbox 记录并更新业务数据。唯一键竞争必须等待对方事务提交或回滚;遇到重复键时核对已有记录,而不是忽略所有 SQL 错误。
3. 业务事务提交成功后返回 nil,SDK 再发送 ACK。提交结果未知时返回 error,保留核查信息,下次投递先按唯一键核对。
4. ACK 已提交但回应丢失,SDK 保留原证明,在新连接上重报;在该本地事件仍保留期间不会重新调用处理器。
5. 原证明已被新代次替代时,SDK 记录 proof_lost,等待新投递。新投递会再次进入处理器,必须命中持久化 Inbox 后才能确认新证明。
处理器 error、panic 或 Goexit 都不会 ACK,也不会在 SDK 内存中立即重新执行。平台在原 30 秒租约自然到期后,按退避和累计预算重新投递;耗尽后保留 EXPIRED,占用订阅窗口,需要管理 API 追加预算。仅在启用订阅有领取活动时触发恢复,当前尚无独立后台交付清扫器。
事件交付租约不提供业务独占执行授权,Ping 不延长它。长于租约的处理、网络分区或多个接收进程可能造成同一事件并行进入业务侧;本地 Capacity 和事件合并不能代替跨进程的唯一约束及事务。外部 HTTP 副作用也不能和 Inbox SQL 自动组成原子事务,需要接收方 Outbox、业务幂等及结果核查设计。
`ResultEvent.Snapshot` 是原事件时刻的冻结快照,不能通过后来查询到的当前任务状态替换。SDK 保留 RawMessage 中大整数及业务 JSON;例如 `9007199254740993` 不经 float64 重编码。`task.needs_review` 表示需要核查,不能按业务成功或最终失败处理。
## 3. 重连与关闭
自动心跳间隔为当前 v1 固定的 15 秒。心跳也重新验证凭据和订阅权限,暂停的订阅仍可心跳及确认已有交付。
网络中断、超时、接收缓冲耗尽、UNAVAILABLE、HTTP 408/429/5xx 使用指数退避及半区间抖动重连;延迟上限为 ReconnectMaxDelay。确认成功后重置退避。认证、权限、资源不存在、历史缺口、协议错误及其他永久错误停止消费,返回支持 `errors.Is/As` 的 EventConsumerRunError。
取消 Run 的 context 后,SDK 停止启动新处理器并取消已有处理器 context。在 ShutdownTimeout 内收集处理器返回,已提交的结果仍尝试 ACK 或重连核对;正常收尾返回 nil。未成功处理的交付保留给平台按预算恢复。
超时返回 `ErrEventShutdownTimeout` 包装错误:`Unconfirmed` 是本进程已知且未确认的事件数,`Running` 是仍未退出的处理器数。Go 不能强杀忽略 context 的函数,不能在收到超时后声称这些函数的业务效果已经撤销。永久连接错误也会取消并等待在途函数,等待受相同收尾时限约束。
调用方主动停止后,暂停订阅是独立管理操作;关闭一个 Consumer 不会暂停共享订阅。进程被强杀时,SDK 内存中的结果和证明不保证保留,接收方 Inbox 必须独立持久化。
## 4. 底层连接
| 方法 | 语义 |
| --- | --- |
| Client.DialEvents(ctx, EventRegistration) | 使用 Authorization 与 orbit.events.v1 握手;ctx 只控制握手,容量必须显式传 1–32 |
| Welcome() | 初始握手快照;不是实时订阅状态 |
| Next(ctx) | 等待交付;等待取消不关闭连接 |
| Ack(ctx, messageID, EventProof) | 确认原交付,可在同订阅的新连接中重报 |
| Ping(ctx, messageID) | 查询 Enabled 并维持连接,不续交付租约 |
| Done() / Err() | 等待读取循环退出及查询关闭原因 |
| Close() | 关闭传输并等待读取循环退出,不取消服务端交付 |
EventConnection 不自动心跳、重连或执行处理器。应用必须每 15 秒 Ping,并在退出时 Close。最多 32 个在途控制请求;超出返回 ErrEventBusy。控制请求总时限最多 10 秒,包含写入排队;握手最多 8 秒;每条文本帧最多 512 KiB。控制超时关闭连接,防止无法关联的迟到回应被当作另一次操作成功。
`grant.Proof()` 包含订阅、事件、交付、代次和秘密;SDK 本地核对订阅与回执事件,线上 ACK 只发送交付 ID、代次和秘密。重连后使用原 Proof,可通过 NewEventMessageID 生成新的关联 ID。ACK 持久化去重不依赖消息 ID。
SDK 严格验证消息方向、会话、回复关联、规范十进制 uint64、秘密编码、必填字段、重复键、字段大小写和交付/事件/快照关联。无效响应返回 ErrEventProtocol 并关闭连接;不会将无效回执当作已确认。
## 5. 观察与敏感信息
`Stats()` 返回进程内 Running、Started、HandlerFailures、Acknowledged、Reconnects 和 DroppedEvents;重启清零。`Events()` 返回有界本地通知,不读取也不会阻塞执行,Run 返回时关闭。它们不能替代数据库状态和持久化审计;Acknowledged 包括对已提交 ACK 的重报确认。
EventGrant、EventProof、EventConnection、EventConsumer 和 EventError 的常见 fmt 输出隐藏秘密、业务 JSON 或远端错误正文。显式 JSON 序列化 Grant/Proof 仍包含租约秘密,不可放到普通日志;若业务确需保存 Proof,应加密并限制访问。调用方记录自己的 handler error 时也应脱敏,SDK 观察事件只给出 HANDLER_FAILED 分类。

View File

@@ -53,6 +53,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 '^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 '^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_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/worker-protocol) (cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/worker-protocol)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/worker) (cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/worker)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/history) (cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/history)
@@ -63,6 +64,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/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)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/event-protocol)
# 应用配额场景使用独立应用与两个命名空间,普通 Worker 凭据不扩权。 # 应用配额场景使用独立应用与两个命名空间,普通 Worker 凭据不扩权。
@@ -76,4 +78,4 @@ ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap -
kill "$TASK_SERVER_PID" kill "$TASK_SERVER_PID"
wait "$TASK_SERVER_PID" wait "$TASK_SERVER_PID"
TASK_SERVER_PID= TASK_SERVER_PID=
(cd sdk/go && ORBIT_TEST_APP_CREDENTIALS_FILE="$TASK_TEST_DIR/app.credentials.json" ORBIT_TEST_APP_SECOND_CREDENTIALS_FILE="$TASK_TEST_DIR/app-second.credentials.json" ORBIT_TEST_SERVER_BINARY="$TASK_TEST_DIR/orbit" 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 '^TestIntegrationWorkerProcess' -v .) (cd sdk/go && ORBIT_TEST_APP_CREDENTIALS_FILE="$TASK_TEST_DIR/app.credentials.json" ORBIT_TEST_APP_SECOND_CREDENTIALS_FILE="$TASK_TEST_DIR/app-second.credentials.json" ORBIT_TEST_SERVER_BINARY="$TASK_TEST_DIR/orbit" 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 '^TestIntegration(Worker|Event)Process' -v .)

View File

@@ -1,5 +1,6 @@
// Package orbit 是 Orbit 的独立 Go SDK,提供健康、任务、队列与 Worker 底层协议。 // Package orbit 是 Orbit 的独立 Go SDK,提供健康、任务、队列、计划及结果订阅。
// WorkerConnection 是底层接口;NewWorker/Run 自动管理处理器、续租、恢复与排空。 // WorkerConnection 是底层接口;NewWorker/Run 自动管理处理器、续租、恢复与排空。
// EventConnection 是结果交付协议;EventConsumer 自动管理心跳、重连与确认,业务端负责事务去重。
// 流程 SDK 等后续能力按阶段继续开发。 // 流程 SDK 等后续能力按阶段继续开发。
// SDK 不导入服务端模型、迁移或数据库依赖。 // SDK 不导入服务端模型、迁移或数据库依赖。
package orbit package orbit

152
sdk/go/event_codec.go Normal file
View File

@@ -0,0 +1,152 @@
package orbit
import (
"bytes"
"encoding/base64"
"encoding/json"
"reflect"
"strconv"
"strings"
"time"
"github.com/coder/websocket"
)
type eventFrame struct {
Version string `json:"protocol_version"`
Type string `json:"type"`
MessageID string `json:"message_id"`
ReplyTo string `json:"reply_to,omitempty"`
SessionID string `json:"session_id,omitempty"`
Body json.RawMessage `json:"body,omitempty"`
Error *EventError `json:"error,omitempty"`
}
// 仅协议结构严格匹配字段;业务RawMessage保持原数字、键和值。
func eventFields(raw []byte, typ reflect.Type) error {
if typ == reflect.TypeOf(json.RawMessage{}) {
return nil
}
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return ErrEventProtocol
}
if typ.Kind() == reflect.Pointer {
return eventFields(raw, typ.Elem())
}
if typ == reflect.TypeOf(time.Time{}) {
return nil
}
if typ.Kind() != reflect.Struct {
return nil
}
var fields map[string]json.RawMessage
if json.Unmarshal(raw, &fields) != nil || fields == nil {
return ErrEventProtocol
}
for i := 0; i < typ.NumField(); i++ {
f := typ.Field(i)
tags := strings.Split(f.Tag.Get("json"), ",")
if tags[0] == "" || tags[0] == "-" {
continue
}
v, found := fields[tags[0]]
optional, quoted := false, false
for _, tag := range tags[1:] {
optional = optional || tag == "omitempty" || tag == "omitzero"
quoted = quoted || tag == "string"
}
if !found {
if optional {
continue
}
return ErrEventProtocol
}
delete(fields, tags[0])
if f.Type.Kind() == reflect.Uint64 && quoted {
var s string
if json.Unmarshal(v, &s) != nil {
return ErrEventProtocol
}
n, err := strconv.ParseUint(s, 10, 64)
if err != nil || n == 0 || strconv.FormatUint(n, 10) != s {
return ErrEventProtocol
}
}
if err := eventFields(v, f.Type); err != nil {
return err
}
}
if len(fields) > 0 {
return ErrEventProtocol
}
return nil
}
func decodeEventValue(raw []byte, out any) error {
if validateWorkerJSON(raw) != nil || eventFields(raw, reflect.TypeOf(out).Elem()) != nil || json.Unmarshal(raw, out) != nil {
return ErrEventProtocol
}
return nil
}
func parseEventFrame(kind websocket.MessageType, raw []byte) (eventFrame, error) {
var f eventFrame
if kind != websocket.MessageText || len(raw) > 512<<10 || decodeEventValue(raw, &f) != nil || f.Version != "1" || !validID(f.MessageID) {
return f, ErrEventProtocol
}
var fields map[string]json.RawMessage
_ = json.Unmarshal(raw, &fields)
if fields["reply_to"] != nil && !validID(f.ReplyTo) || fields["session_id"] != nil && !validID(f.SessionID) {
return f, ErrEventProtocol
}
switch f.Type {
case "error":
if f.Error == nil || fields["body"] != nil || len(f.Error.Message) == 0 || len(f.Error.Message) > 1024 {
return f, ErrEventProtocol
}
switch f.Error.Code {
case "INVALID_ARGUMENT", "UNAUTHENTICATED", "PERMISSION_DENIED", "NOT_FOUND", "STATE_CONFLICT", "DELIVERY_LEASE_LOST", "EVENT_HISTORY_EXPIRED", "UNAVAILABLE":
default:
return f, ErrEventProtocol
}
case "events.welcome", "ack", "event.deliver":
if fields["error"] != nil || len(f.Body) == 0 || bytes.Equal(bytes.TrimSpace(f.Body), []byte("null")) || !validID(f.SessionID) {
return f, ErrEventProtocol
}
if f.Type == "event.deliver" {
if fields["reply_to"] != nil {
return f, ErrEventProtocol
}
} else if !validID(f.ReplyTo) {
return f, ErrEventProtocol
}
default:
return f, ErrEventProtocol
}
return f, nil
}
func validEventProof(p EventProof) bool {
b, err := base64.RawURLEncoding.Strict().DecodeString(p.LeaseSecret)
return validID(p.SubscriptionID) && validID(p.EventID) && validID(p.DeliveryID) && p.Epoch > 0 && len(p.LeaseSecret) == 43 && err == nil && len(b) == 32
}
func validEventGrant(g EventGrant, sub string) bool {
d, e, s := g.Delivery, g.Event, g.Event.Snapshot
if !validEventProof(g.Proof()) || d.SubscriptionID != sub || d.EventID != e.ID || d.Sequence != e.Sequence || d.Sequence == 0 || d.Revision == 0 || d.State != DeliveryLeased || d.CreatedAt.IsZero() || d.AvailableAt.IsZero() || d.LeaseUntil == nil || d.LeaseUntil.IsZero() || d.AcknowledgedAt != nil || d.Attempts == 0 || d.Attempts > d.AttemptBudget || d.AttemptBudget > 1000000 {
return false
}
if e.AggregateKind != "task" || !validID(e.AggregateID) || e.AggregateID != s.TaskID || e.AggregateRevision != s.Revision || s.Revision == 0 || e.OccurredAt.IsZero() || s.SchemaVersion != 1 || !validID(s.QueueID) || s.CurrentAttemptID != nil && !validID(*s.CurrentAttemptID) {
return false
}
if len(bytes.TrimSpace(e.Details)) == 0 || bytes.TrimSpace(e.Details)[0] != '{' {
return false
}
switch e.Type {
case "task.succeeded":
return s.State == TaskSucceeded
case "task.failed":
return s.State == TaskFailed
case "task.cancelled":
return s.State == TaskCancelled
case "task.needs_review":
return s.State == TaskNeedsReview
}
return false
}

285
sdk/go/event_connection.go Normal file
View File

@@ -0,0 +1,285 @@
package orbit
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/coder/websocket"
)
const eventSubprotocol = "orbit.events.v1"
const eventMaxPending = 32
// EventConnection 管理单次连接、请求关联和有界收发。可并发调用Ack/Ping。
// 本类型不自动心跳/重连/调用处理器;高层消费请使用EventConsumer。
type EventConnection struct {
conn *websocket.Conn
ctx context.Context
cancel context.CancelFunc
welcome EventWelcome
deliveries chan EventGrant
done chan struct{}
mu sync.Mutex
err error
pending map[string]chan eventFrame
}
// DialEvents 的ctx仅控制握手;成功后必须Close,取消握手ctx不会关闭已建立的连接。
func (c *Client) DialEvents(ctx context.Context, in EventRegistration) (*EventConnection, error) {
if c.token == "" || !validID(in.SubscriptionID) || in.Capacity < 1 || in.Capacity > 32 {
return nil, errors.New("authenticated client, valid subscription and capacity 1..32 are required")
}
bounded, stop := context.WithTimeout(ctx, 8*time.Second)
defer stop()
headers := http.Header{}
headers.Set("Authorization", "Bearer "+c.token)
conn, response, err := websocket.Dial(bounded, c.baseURL+"/v1/events/connect", &websocket.DialOptions{HTTPClient: c.http, HTTPHeader: headers, Subprotocols: []string{eventSubprotocol}, CompressionMode: websocket.CompressionDisabled})
if err != nil {
if response != nil && response.StatusCode != http.StatusSwitchingProtocols {
return nil, &HTTPError{StatusCode: response.StatusCode}
}
return nil, eventIOError(err)
}
ok := false
defer func() {
if !ok {
_ = conn.CloseNow()
}
}()
if conn.Subprotocol() != eventSubprotocol {
return nil, ErrEventProtocol
}
conn.SetReadLimit(512 << 10)
id, err := NewEventMessageID()
if err != nil {
return nil, err
}
body, _ := marshalWire(in)
raw, _ := marshalWire(eventFrame{Version: "1", Type: "events.hello", MessageID: id, Body: body})
if err = conn.Write(bounded, websocket.MessageText, raw); err != nil {
return nil, eventIOError(err)
}
kind, raw, err := conn.Read(bounded)
if err != nil {
return nil, eventIOError(err)
}
f, err := parseEventFrame(kind, raw)
if err != nil || f.ReplyTo != id {
return nil, ErrEventProtocol
}
if f.Type == "error" {
return nil, f.Error
}
var welcome EventWelcome
if f.Type != "events.welcome" || decodeEventValue(f.Body, &welcome) != nil || welcome.SessionID != f.SessionID || welcome.SubscriptionID != in.SubscriptionID || welcome.Capacity != in.Capacity || welcome.ServerTime.IsZero() || welcome.LeaseDurationMS != 30000 || welcome.HeartbeatIntervalMS != 15000 {
return nil, ErrEventProtocol
}
lifetime, cancel := context.WithCancel(context.Background())
e := &EventConnection{conn: conn, ctx: lifetime, cancel: cancel, welcome: welcome, deliveries: make(chan EventGrant, in.Capacity), done: make(chan struct{}), pending: make(map[string]chan eventFrame)}
ok = true
go e.readLoop()
return e, nil
}
func (e *EventConnection) Welcome() EventWelcome { return e.welcome }
func (e *EventConnection) Done() <-chan struct{} { return e.done }
func (e *EventConnection) Err() error { e.mu.Lock(); defer e.mu.Unlock(); return e.err }
func (e *EventConnection) Close() error { e.fail(ErrEventClosed); <-e.done; return nil }
func (e *EventConnection) fail(err error) {
e.mu.Lock()
if e.err != nil {
e.mu.Unlock()
return
}
e.err = err
e.cancel()
e.mu.Unlock()
_ = e.conn.CloseNow()
}
func (e *EventConnection) readLoop() {
defer close(e.done)
for {
ctx, stop := context.WithTimeout(e.ctx, 60*time.Second)
kind, raw, err := e.conn.Read(ctx)
stop()
if err != nil {
e.fail(eventIOError(err))
return
}
f, err := parseEventFrame(kind, raw)
if err != nil || f.SessionID != e.welcome.SessionID {
e.fail(ErrEventProtocol)
return
}
if f.Type == "event.deliver" {
var grant EventGrant
if decodeEventValue(f.Body, &grant) != nil || !validEventGrant(grant, e.welcome.SubscriptionID) {
e.fail(ErrEventProtocol)
return
}
select {
case e.deliveries <- grant:
default:
e.fail(ErrEventBackpressure)
return
}
continue
}
if f.Type != "ack" && f.Type != "error" {
e.fail(ErrEventProtocol)
return
}
if f.Type == "error" && f.ReplyTo == "" {
e.fail(f.Error)
return
}
e.mu.Lock()
waiter := e.pending[f.ReplyTo]
e.mu.Unlock()
if waiter == nil {
e.fail(ErrEventProtocol)
return
}
select {
case waiter <- f:
default:
e.fail(ErrEventProtocol)
return
}
if f.Error != nil && (f.Error.Code == "UNAUTHENTICATED" || f.Error.Code == "PERMISSION_DENIED" || f.Error.Code == "NOT_FOUND" || f.Error.Code == "UNAVAILABLE") {
e.fail(f.Error)
return
}
}
}
// Next 等待下一条交付。ctx到期只中断本次等待;未确认交付按服务端预算补发。
func (e *EventConnection) Next(ctx context.Context) (EventGrant, error) {
if err := ctx.Err(); err != nil {
return EventGrant{}, err
}
select {
case <-ctx.Done():
return EventGrant{}, ctx.Err()
case <-e.ctx.Done():
return EventGrant{}, e.Err()
case g := <-e.deliveries:
if err := e.Err(); err != nil {
return EventGrant{}, err
}
return g, nil
}
}
func (e *EventConnection) call(ctx context.Context, kind, id string, in, out any) error {
if !validID(id) {
return errors.New("invalid event message id")
}
if err := ctx.Err(); err != nil {
return err
}
bounded, stop := context.WithTimeout(ctx, 10*time.Second)
defer stop()
waiter := make(chan eventFrame, 1)
e.mu.Lock()
if e.err != nil {
err := e.err
e.mu.Unlock()
return err
}
if len(e.pending) >= eventMaxPending {
e.mu.Unlock()
return ErrEventBusy
}
if e.pending[id] != nil {
e.mu.Unlock()
return errors.New("event message id is already in flight")
}
e.pending[id] = waiter
e.mu.Unlock()
defer func() { e.mu.Lock(); delete(e.pending, id); e.mu.Unlock() }()
body, err := marshalWire(in)
if err != nil {
return errors.New("invalid event request")
}
raw, err := marshalWire(eventFrame{Version: "1", Type: kind, MessageID: id, SessionID: e.welcome.SessionID, Body: body})
if err != nil || len(raw) > 512<<10 {
return errors.New("event request exceeds frame limit")
}
if err = e.conn.Write(bounded, websocket.MessageText, raw); err != nil {
err = eventIOError(err)
e.fail(err)
return err
}
var f eventFrame
select {
case f = <-waiter:
case <-e.ctx.Done():
select {
case f = <-waiter:
default:
return e.Err()
}
case <-bounded.Done():
e.fail(bounded.Err())
return bounded.Err()
}
if f.Error != nil {
return f.Error
}
if decodeEventValue(f.Body, out) != nil {
e.fail(ErrEventProtocol)
return ErrEventProtocol
}
return nil
}
// Ack 仅在业务事务已提交或持久化去重命中后调用。重连可用原Proof重报,MessageID可新建。
// 超时/断线不证明ACK未提交;DELIVERY_LEASE_LOST表示证明已失效,不应撤销已经提交的业务。
func (e *EventConnection) Ack(ctx context.Context, id string, p EventProof) (EventAckResult, error) {
var out EventAckResult
if !validEventProof(p) || p.SubscriptionID != e.welcome.SubscriptionID {
return out, errors.New("invalid event proof or subscription")
}
in := struct {
DeliveryID string `json:"delivery_id"`
Epoch uint64 `json:"epoch,string"`
LeaseSecret string `json:"lease_secret"`
}{p.DeliveryID, p.Epoch, p.LeaseSecret}
err := e.call(ctx, "event.ack", id, in, &out)
if err == nil && (out.DeliveryID != p.DeliveryID || out.EventID != p.EventID || out.Epoch != p.Epoch || out.AcknowledgedAt.IsZero()) {
err = ErrEventProtocol
e.fail(err)
}
return out, err
}
// Ping 重新核验凭据及订阅,并维持连接;不会续期交付租约。
func (e *EventConnection) Ping(ctx context.Context, id string) (EventPong, error) {
var out EventPong
err := e.call(ctx, "events.ping", id, struct{}{}, &out)
if err == nil && out.ServerTime.IsZero() {
err = ErrEventProtocol
e.fail(err)
}
return out, err
}
func eventIOError(err error) error {
if errors.Is(err, context.Canceled) {
return context.Canceled
}
if errors.Is(err, context.DeadlineExceeded) {
return context.DeadlineExceeded
}
switch websocket.CloseStatus(err) {
case websocket.StatusProtocolError, websocket.StatusUnsupportedData, websocket.StatusInvalidFramePayloadData, websocket.StatusPolicyViolation, websocket.StatusMessageTooBig:
return ErrEventProtocol
}
return ErrEventDisconnected
}
func (e *EventConnection) String() string {
return fmt.Sprintf("EventConnection{SubscriptionID:%s SessionID:%s secrets:redacted}", e.welcome.SubscriptionID, e.welcome.SessionID)
}
func (e *EventConnection) GoString() string { return e.String() }

View File

@@ -0,0 +1,283 @@
package orbit
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/coder/websocket"
)
const eventTestSub = "55555555555555555555555555555555"
const eventTestID = "66666666666666666666666666666666"
const eventTestDelivery = "77777777777777777777777777777777"
func eventTestGrant() EventGrant {
now := time.Now().UTC()
until := now.Add(30 * time.Second)
return EventGrant{Delivery: EventDelivery{ID: eventTestDelivery, SubscriptionID: eventTestSub, EventID: eventTestID, Sequence: 9007199254740993, Revision: 2, State: DeliveryLeased, CreatedAt: now, AvailableAt: now, LeaseEpoch: 1, LeaseUntil: &until, Attempts: 1, AttemptBudget: 20}, Event: ResultEvent{ID: eventTestID, Sequence: 9007199254740993, Type: "task.succeeded", AggregateKind: "task", AggregateID: workerTestTask, AggregateRevision: 9007199254740993, OccurredAt: now, Details: json.RawMessage(`{}`), Snapshot: TaskResultSnapshot{SchemaVersion: 1, TaskID: workerTestTask, QueueID: workerTestQueue, State: TaskSucceeded, Revision: 9007199254740993, Result: json.RawMessage(`{"n":9007199254740993,"text":"<&>"}`)}}, LeaseSecret: strings.Repeat("A", 43)}
}
func eventTestRead(ctx context.Context, c *websocket.Conn) (eventFrame, error) {
kind, raw, err := c.Read(ctx)
var f eventFrame
if err != nil {
return f, err
}
if kind != websocket.MessageText || json.Unmarshal(raw, &f) != nil {
return f, ErrEventProtocol
}
return f, nil
}
func eventTestWrite(ctx context.Context, c *websocket.Conn, f eventFrame) error {
f.Version = "1"
f.SessionID = workerTestSession
f.MessageID, _ = NewEventMessageID()
raw, err := marshalWire(f)
if err != nil {
return err
}
return c.Write(ctx, websocket.MessageText, raw)
}
func eventBody(in any) json.RawMessage { b, _ := marshalWire(in); return b }
func eventMock(t *testing.T, serve func(context.Context, *websocket.Conn, EventRegistration, int)) *Client {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
var count atomic.Int64
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/events/connect" || r.URL.RawQuery != "" || r.Header.Get("Authorization") != "Bearer event-test-token" {
t.Error("unexpected event handshake")
}
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{eventSubprotocol}})
if err != nil {
return
}
defer c.CloseNow()
f, err := eventTestRead(ctx, c)
var in EventRegistration
if err != nil || f.Type != "events.hello" || f.SessionID != "" || !validID(f.MessageID) || json.Unmarshal(f.Body, &in) != nil {
t.Error("invalid event hello")
return
}
welcome := EventWelcome{SessionID: workerTestSession, SubscriptionID: in.SubscriptionID, Capacity: in.Capacity, LeaseDurationMS: 30000, HeartbeatIntervalMS: 15000, ServerTime: time.Now().UTC(), Enabled: true}
if eventTestWrite(ctx, c, eventFrame{Type: "events.welcome", ReplyTo: f.MessageID, Body: eventBody(welcome)}) != nil {
return
}
serve(ctx, c, in, int(count.Add(1)))
}))
t.Cleanup(func() { cancel(); server.Close() })
client, err := NewClient(server.URL, Options{Token: "event-test-token", HTTPClient: server.Client()})
if err != nil {
t.Fatal(err)
}
return client
}
func eventTestDial(t *testing.T, client *Client, capacity uint32) *EventConnection {
t.Helper()
ctx, stop := context.WithTimeout(context.Background(), 3*time.Second)
defer stop()
c, err := client.DialEvents(ctx, EventRegistration{SubscriptionID: eventTestSub, Capacity: capacity})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { c.Close() })
return c
}
func eventAckFor(g EventGrant) EventAckResult {
return EventAckResult{DeliveryID: g.Delivery.ID, EventID: g.Event.ID, Epoch: g.Delivery.LeaseEpoch, AcknowledgedAt: time.Now().UTC()}
}
func TestEventConnectionMultiplexAndPrecision(t *testing.T) {
g := eventTestGrant()
client := eventMock(t, func(ctx context.Context, c *websocket.Conn, _ EventRegistration, _ int) {
if eventTestWrite(ctx, c, eventFrame{Type: "event.deliver", Body: eventBody(g)}) != nil {
return
}
first, err := eventTestRead(ctx, c)
if err != nil {
return
}
second, err := eventTestRead(ctx, c)
if err != nil {
return
}
for _, f := range []eventFrame{second, first} {
var body any = EventPong{ServerTime: time.Now().UTC(), Enabled: true}
if f.Type == "event.ack" {
body = eventAckFor(g)
}
if eventTestWrite(ctx, c, eventFrame{Type: "ack", ReplyTo: f.MessageID, Body: eventBody(body)}) != nil {
return
}
}
_, _ = eventTestRead(ctx, c)
})
c := eventTestDial(t, client, 1)
ctx, stop := context.WithTimeout(context.Background(), 3*time.Second)
defer stop()
got, err := c.Next(ctx)
if err != nil || got.Event.Sequence != 9007199254740993 || string(got.Event.Snapshot.Result) != string(g.Event.Snapshot.Result) {
t.Fatal("event snapshot changed", err)
}
errs := make(chan error, 2)
go func() { _, err := c.Ping(ctx, workerTestID(t)); errs <- err }()
go func() { _, err := c.Ack(ctx, workerTestID(t), got.Proof()); errs <- err }()
for range 2 {
if err := <-errs; err != nil {
t.Fatal(err)
}
}
if c.Err() != nil {
t.Fatal(c.Err())
}
for _, value := range []any{g, g.Proof(), c, &EventError{Code: "UNAVAILABLE", Message: "secret-text"}} {
for _, format := range []string{"%v", "%+v", "%#v"} {
printed := fmt.Sprintf(format, value)
if strings.Contains(printed, g.LeaseSecret) || strings.Contains(printed, "secret-text") || strings.Contains(printed, "9007199254740993") {
t.Fatal("event formatter exposed secrets or business data")
}
}
}
}
func TestEventConnectionWaitCancellationAndAckUncertainty(t *testing.T) {
received := make(chan struct{}, 1)
client := eventMock(t, func(ctx context.Context, c *websocket.Conn, _ EventRegistration, _ int) {
f, err := eventTestRead(ctx, c)
if err != nil {
return
}
if f.Type != "event.ack" {
t.Error("unexpected control")
}
received <- struct{}{}
_, _ = eventTestRead(ctx, c)
})
c := eventTestDial(t, client, 1)
short, stop := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer stop()
if _, err := c.Next(short); !errors.Is(err, context.DeadlineExceeded) || c.Err() != nil {
t.Fatal("Next timeout closed connection", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { _, err := c.Ack(ctx, workerTestID(t), eventTestGrant().Proof()); done <- err }()
<-received
cancel()
if err := <-done; !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
select {
case <-c.Done():
case <-time.After(time.Second):
t.Fatal("uncertain ACK did not close connection")
}
}
func TestEventConnectionBoundedControlsAndDeliveries(t *testing.T) {
t.Run("controls", func(t *testing.T) {
received := make(chan struct{}, 40)
client := eventMock(t, func(ctx context.Context, c *websocket.Conn, _ EventRegistration, _ int) {
for {
if _, err := eventTestRead(ctx, c); err != nil {
return
}
received <- struct{}{}
}
})
c := eventTestDial(t, client, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 32)
for range 32 {
go func() { _, err := c.Ping(ctx, workerTestID(t)); done <- err }()
<-received
}
if _, err := c.Ping(ctx, workerTestID(t)); !errors.Is(err, ErrEventBusy) {
t.Fatal("unbounded pending controls", err)
}
cancel()
for range 32 {
<-done
}
})
t.Run("deliveries", func(t *testing.T) {
client := eventMock(t, func(ctx context.Context, c *websocket.Conn, _ EventRegistration, _ int) {
for range 2 {
_ = eventTestWrite(ctx, c, eventFrame{Type: "event.deliver", Body: eventBody(eventTestGrant())})
}
_, _ = eventTestRead(ctx, c)
})
c := eventTestDial(t, client, 1)
select {
case <-c.Done():
case <-time.After(time.Second):
t.Fatal("unbounded deliveries")
}
if !errors.Is(c.Err(), ErrEventBackpressure) {
t.Fatal(c.Err())
}
})
}
func TestEventConnectionRejectsUntrustedProtocol(t *testing.T) {
g := eventTestGrant()
valid := eventFrame{Version: "1", Type: "event.deliver", MessageID: workerTestQueue, SessionID: workerTestSession, Body: eventBody(g)}
raw, _ := marshalWire(valid)
cases := map[string][]byte{
"duplicate": []byte(strings.Replace(string(raw), `"protocol_version":"1"`, `"protocol_version":"1","protocol_version":"1"`, 1)),
"alias": []byte(strings.Replace(string(raw), `"lease_secret"`, `"Lease_Secret"`, 1)),
"noncanonical": []byte(strings.Replace(string(raw), `"sequence":"9007199254740993"`, `"sequence":"09007199254740993"`, 1)),
"null_bool": []byte(strings.Replace(string(raw), `"effect_uncertain":false`, `"effect_uncertain":null`, 1)),
"null_error": []byte(strings.Replace(string(raw), `"body":`, `"error":null,"body":`, 1)),
"mismatched_event": []byte(strings.Replace(string(raw), `"event_id":"`+eventTestID+`"`, `"event_id":"`+workerTestTask+`"`, 1)),
"missing_created": []byte(strings.Replace(string(raw), `"created_at":`+string(eventBody(g.Delivery.CreatedAt))+`,`, "", 1)),
"wrong_state": []byte(strings.Replace(string(raw), `"state":"SUCCEEDED"`, `"state":"FAILED"`, 1)),
"unknown_body_field": []byte(strings.Replace(string(raw), `"schema_version":1`, `"extra":true,"schema_version":1`, 1)),
}
for name, bad := range cases {
t.Run(name, func(t *testing.T) {
client := eventMock(t, func(ctx context.Context, c *websocket.Conn, _ EventRegistration, _ int) {
_ = c.Write(ctx, websocket.MessageText, bad)
_, _ = eventTestRead(ctx, c)
})
c := eventTestDial(t, client, 1)
ctx, stop := context.WithTimeout(context.Background(), time.Second)
defer stop()
if _, err := c.Next(ctx); !errors.Is(err, ErrEventProtocol) {
t.Fatal("invalid response accepted", err)
}
})
}
}
func TestEventConnectionRejectsMismatchedAck(t *testing.T) {
client := eventMock(t, func(ctx context.Context, c *websocket.Conn, _ EventRegistration, _ int) {
f, err := eventTestRead(ctx, c)
if err != nil {
return
}
out := eventAckFor(eventTestGrant())
out.Epoch++
_ = eventTestWrite(ctx, c, eventFrame{Type: "ack", ReplyTo: f.MessageID, Body: eventBody(out)})
_, _ = eventTestRead(ctx, c)
})
c := eventTestDial(t, client, 1)
if _, err := c.Ack(context.Background(), workerTestID(t), eventTestGrant().Proof()); !errors.Is(err, ErrEventProtocol) {
t.Fatal("wrong receipt accepted", err)
}
}
func TestEventConnectionPolicyClosureIsPermanent(t *testing.T) {
client := eventMock(t, func(ctx context.Context, c *websocket.Conn, _ EventRegistration, _ int) {
_ = c.Close(websocket.StatusPolicyViolation, "private peer reason")
})
c := eventTestDial(t, client, 1)
ctx, stop := context.WithTimeout(context.Background(), time.Second)
defer stop()
if _, err := c.Next(ctx); !errors.Is(err, ErrEventProtocol) || eventRetryable(err) {
t.Fatal("policy violation classified as retryable", err)
}
}

368
sdk/go/event_consumer.go Normal file
View File

@@ -0,0 +1,368 @@
package orbit
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"sync/atomic"
"time"
)
var ErrEventConsumerAlreadyRun = errors.New("event consumer may only Run once")
var ErrEventShutdownTimeout = errors.New("event consumer shutdown grace period expired")
// EventConsumerOptions 定义自动消费器。Handle必须以事件ID做持久化去重,
// 在去重记录和业务效果同一事务提交后才返回nil;提交结果未知时返回error。
// error或panic不ACK,平台在租约到期后按原预算补发;SDK不在内存中立即重跑。
type EventConsumerOptions struct {
SubscriptionID string
Capacity uint32 // Capacity 最大本地在途事件/处理器,默认4,范围1至32。
Handle func(context.Context, ResultEvent) error // Handle不接收租约秘密;必须响应context取消。
ShutdownTimeout time.Duration // ShutdownTimeout停止时取消处理器,等待返回及ACK,默认30秒,最多5分钟。
ReconnectMinDelay time.Duration // ReconnectMinDelay默认200毫秒,至少10毫秒。
ReconnectMaxDelay time.Duration // ReconnectMaxDelay默认5秒,最多30秒;指数退避并加入抖动。
}
// EventConsumerEvent 只用于本地观察,缓冲满可丢弃;不是可靠结果或审计。
type EventConsumerEvent struct {
Type string // Type为connected、reconnecting、handler_started、handler_failed、acknowledged、proof_lost、draining。
EventID string
Code string // Code不含业务异常、数据或秘密。
}
type EventConsumerStats struct {
Running int64
Started uint64
HandlerFailures uint64
Acknowledged uint64
Reconnects uint64
DroppedEvents uint64
}
type EventConsumerRunError struct {
Cause error
Unconfirmed int // Unconfirmed本进程已知且未确认的事件数,不是订阅全局积压。
Running int64 // Running仍未退出的处理器;Go不能强杀忽略context的函数。
}
func (e *EventConsumerRunError) Error() string {
return fmt.Sprintf("event consumer stopped with %d unconfirmed events and %d running handlers: %v", e.Unconfirmed, e.Running, e.Cause)
}
func (e *EventConsumerRunError) Unwrap() error { return e.Cause }
// EventConsumer 自动维护心跳、重连、处理器并发和ACK核对。只能Run一次。
// 服务端交付租约不是业务锁;跨进程重复及并行消费由业务Inbox唯一键和事务处理。
type EventConsumer struct {
client *Client
options EventConsumerOptions
used atomic.Bool
events chan EventConsumerEvent
running atomic.Int64
started, failures, acked, reconnects, dropped atomic.Uint64
}
func NewEventConsumer(client *Client, in EventConsumerOptions) (*EventConsumer, error) {
if client == nil || client.token == "" || !validID(in.SubscriptionID) || in.Handle == nil {
return nil, errors.New("authenticated client, subscription and durable event handler are required")
}
if in.Capacity == 0 {
in.Capacity = 4
}
if in.ShutdownTimeout == 0 {
in.ShutdownTimeout = 30 * time.Second
}
if in.ReconnectMinDelay == 0 {
in.ReconnectMinDelay = 200 * time.Millisecond
}
if in.ReconnectMaxDelay == 0 {
in.ReconnectMaxDelay = 5 * time.Second
}
if in.Capacity > 32 || in.ShutdownTimeout < time.Millisecond || in.ShutdownTimeout > 5*time.Minute || in.ReconnectMinDelay < 10*time.Millisecond || in.ReconnectMaxDelay < in.ReconnectMinDelay || in.ReconnectMaxDelay > 30*time.Second {
return nil, errors.New("invalid event consumer capacity or durations")
}
return &EventConsumer{client: client, options: in, events: make(chan EventConsumerEvent, 128)}, nil
}
func (c *EventConsumer) Events() <-chan EventConsumerEvent { return c.events }
func (c *EventConsumer) Stats() EventConsumerStats {
return EventConsumerStats{Running: c.running.Load(), Started: c.started.Load(), HandlerFailures: c.failures.Load(), Acknowledged: c.acked.Load(), Reconnects: c.reconnects.Load(), DroppedEvents: c.dropped.Load()}
}
func (c *EventConsumer) emit(typ, id, code string) {
select {
case c.events <- EventConsumerEvent{Type: typ, EventID: id, Code: code}:
default:
c.dropped.Add(1)
}
}
func (c *EventConsumer) String() string {
return fmt.Sprintf("EventConsumer{SubscriptionID:%s Capacity:%d secrets:redacted}", c.options.SubscriptionID, c.options.Capacity)
}
func (c *EventConsumer) GoString() string { return c.String() }
type consumerJob struct {
proof EventProof
complete bool
}
type consumerResult struct {
id string
failed bool
}
// Run在ctx取消后停止启动新处理器,取消在途处理器,在收尾时限内确认已提交结果。
// 收到认证/协议等永久错误时也取消并等待在途处理器;未确认结果仍保留在服务端。
// 重连先核对已完成处理器的原证明;证明被替代时等待新投递,由业务去重命中后确认。
func (c *EventConsumer) Run(parent context.Context) error {
if !c.used.CompareAndSwap(false, true) {
return ErrEventConsumerAlreadyRun
}
defer close(c.events)
if parent.Err() != nil {
return nil
}
ctx, cancel := context.WithCancel(context.WithoutCancel(parent))
defer cancel()
handlerCtx, stopHandlers := context.WithCancel(ctx)
defer stopHandlers()
abort := make(chan struct{})
watchDone := make(chan struct{})
go func() {
defer close(watchDone)
select {
case <-ctx.Done():
return
case <-parent.Done():
case <-abort:
}
stopHandlers()
timer := time.NewTimer(c.options.ShutdownTimeout)
defer timer.Stop()
select {
case <-ctx.Done():
case <-timer.C:
cancel()
}
}()
defer func() { cancel(); <-watchDone }()
var conn *EventConnection
var heartbeatDone <-chan struct{}
closeConnection := func() {
if conn != nil {
_ = conn.Close()
<-heartbeatDone
conn = nil
}
}
defer closeConnection()
jobs := make(map[string]*consumerJob)
results := make(chan consumerResult, c.options.Capacity)
draining := false
var cause error
beginDrain := func(err error) {
if !draining {
draining = true
stopHandlers()
close(abort)
c.emit("draining", "", "")
}
if cause == nil {
cause = err
}
}
finish := func(err error) error {
if err == nil {
return nil
}
return &EventConsumerRunError{Cause: err, Unconfirmed: len(jobs), Running: c.running.Load()}
}
backoff := c.options.ReconnectMinDelay
var dialAt time.Time
everConnected := false
disconnect := func(err error) {
closeConnection()
if !eventRetryable(err) {
beginDrain(err)
return
}
floor := backoff / 2
dialAt = time.Now().Add(floor + time.Duration(rand.Int64N(int64(backoff-floor)+1)))
backoff = min(backoff*2, c.options.ReconnectMaxDelay)
c.emit("reconnecting", "", eventErrorCode(err))
}
for {
if parent.Err() != nil {
beginDrain(nil)
}
if ctx.Err() != nil {
return finish(ErrEventShutdownTimeout)
}
if draining && (len(jobs) == 0 || cause != nil && c.running.Load() == 0) {
return finish(cause)
}
if conn == nil && cause == nil && !time.Now().Before(dialAt) {
if everConnected {
c.reconnects.Add(1)
}
var err error
conn, err = c.client.DialEvents(ctx, EventRegistration{SubscriptionID: c.options.SubscriptionID, Capacity: c.options.Capacity})
if err != nil {
disconnect(err)
continue
}
everConnected = true
heartbeatDone = startEventHeartbeat(ctx, conn)
c.emit("connected", "", "")
continue
}
if conn != nil {
if err := conn.Err(); err != nil {
disconnect(err)
continue
}
// 一个ACK在途;读取与自动心跳独立,不需要接收方不断调用Next维持连接。
acknowledged := false
for id, j := range jobs {
if !j.complete {
continue
}
messageID, err := NewEventMessageID()
if err == nil {
_, err = conn.Ack(ctx, messageID, j.proof)
}
if err == nil {
delete(jobs, id)
c.acked.Add(1)
c.emit("acknowledged", id, "")
backoff = c.options.ReconnectMinDelay
} else {
var remote *EventError
if errors.As(err, &remote) && remote.Code == "DELIVERY_LEASE_LOST" {
delete(jobs, id)
c.emit("proof_lost", id, remote.Code)
} else {
disconnect(err)
}
}
acknowledged = true
break
}
if acknowledged {
continue
}
}
var deliveries <-chan EventGrant
var disconnected <-chan struct{}
if conn != nil {
disconnected = conn.Done()
if !draining && len(jobs) < int(c.options.Capacity) {
deliveries = conn.deliveries
}
}
var parentDone <-chan struct{}
if !draining {
parentDone = parent.Done()
}
// 定时器只负责重连;活跃连接没有每轮轮询。
var wake <-chan time.Time
var timer *time.Timer
if conn == nil && cause == nil {
timer = time.NewTimer(max(time.Millisecond, time.Until(dialAt)))
wake = timer.C
}
select {
case <-ctx.Done():
case <-parentDone:
beginDrain(nil)
case <-wake:
case <-disconnected:
disconnect(conn.Err())
case result := <-results:
j := jobs[result.id]
if result.failed {
delete(jobs, result.id)
c.failures.Add(1)
c.emit("handler_failed", result.id, "HANDLER_FAILED")
} else {
j.complete = true
}
case grant := <-deliveries:
if parent.Err() != nil || conn.Err() != nil {
break
}
id := grant.Event.ID
if j := jobs[id]; j != nil {
if j.proof.DeliveryID != grant.Delivery.ID {
disconnect(ErrEventProtocol)
break
}
if grant.Delivery.LeaseEpoch > j.proof.Epoch {
j.proof = grant.Proof()
}
break
}
jobs[id] = &consumerJob{proof: grant.Proof()}
c.running.Add(1)
c.started.Add(1)
c.emit("handler_started", id, "")
go func(event ResultEvent) {
failed := true
defer func() {
_ = recover()
c.running.Add(-1)
select {
case results <- consumerResult{id: event.ID, failed: failed}:
case <-ctx.Done():
}
}()
failed = c.options.Handle(handlerCtx, event) != nil
}(grant.Event)
}
if timer != nil {
timer.Stop()
}
}
}
func startEventHeartbeat(ctx context.Context, conn *EventConnection) <-chan struct{} {
done := make(chan struct{})
go func() {
defer close(done)
ticker := time.NewTicker(time.Duration(conn.Welcome().HeartbeatIntervalMS) * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-conn.Done():
return
case <-ticker.C:
id, err := NewEventMessageID()
if err == nil {
_, err = conn.Ping(ctx, id)
}
if err != nil {
conn.fail(err)
return
}
}
}
}()
return done
}
func eventRetryable(err error) bool {
if errors.Is(err, ErrEventDisconnected) || errors.Is(err, ErrEventBackpressure) || errors.Is(err, context.DeadlineExceeded) {
return true
}
var remote *EventError
if errors.As(err, &remote) {
return remote.Code == "UNAVAILABLE"
}
var httpErr *HTTPError
if errors.As(err, &httpErr) {
return httpErr.StatusCode == 408 || httpErr.StatusCode == 429 || httpErr.StatusCode >= 500 && httpErr.StatusCode <= 599
}
return false
}
func eventErrorCode(err error) string {
var remote *EventError
if errors.As(err, &remote) {
return remote.Code
}
return "TRANSPORT_INTERRUPTED"
}

View File

@@ -0,0 +1,342 @@
package orbit
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"runtime"
"sync/atomic"
"testing"
"time"
"github.com/coder/websocket"
)
func runEventConsumer(t *testing.T, client *Client, handle func(context.Context, ResultEvent) error) (*EventConsumer, context.CancelFunc, <-chan error) {
t.Helper()
consumer, err := NewEventConsumer(client, EventConsumerOptions{SubscriptionID: eventTestSub, Capacity: 1, Handle: handle, ReconnectMinDelay: 10 * time.Millisecond, ReconnectMaxDelay: 50 * time.Millisecond, ShutdownTimeout: time.Second})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- consumer.Run(ctx) }()
t.Cleanup(cancel)
return consumer, cancel, done
}
func awaitConsumerEvent(t *testing.T, c *EventConsumer, typ string) EventConsumerEvent {
t.Helper()
timer := time.NewTimer(3 * time.Second)
defer timer.Stop()
for {
select {
case event, ok := <-c.Events():
if !ok {
t.Fatal("consumer stopped before " + typ)
}
if event.Type == typ {
return event
}
case <-timer.C:
t.Fatal("consumer did not emit " + typ)
}
}
}
func awaitConsumerStop(t *testing.T, done <-chan error) error {
t.Helper()
select {
case err := <-done:
return err
case <-time.After(3 * time.Second):
t.Fatal("consumer did not stop")
return nil
}
}
func TestEventConsumerReconcilesLostAckWithoutRerunningHandler(t *testing.T) {
g := eventTestGrant()
var calls atomic.Int64
var proofs [2]EventProof
client := eventMock(t, func(ctx context.Context, c *websocket.Conn, _ EventRegistration, n int) {
if n > 2 {
t.Error("unexpected third connection")
return
}
if n == 1 {
_ = eventTestWrite(ctx, c, eventFrame{Type: "event.deliver", Body: eventBody(g)})
}
f, err := eventTestRead(ctx, c)
if err != nil {
return
}
if f.Type != "event.ack" || json.Unmarshal(f.Body, &proofs[n-1]) != nil {
t.Error("missing ACK proof")
return
}
if n == 1 {
return
} // ACK已在服务端提交,回应在网络中丢失。
if proofs[0].DeliveryID != proofs[1].DeliveryID || proofs[0].Epoch != proofs[1].Epoch || proofs[0].LeaseSecret != proofs[1].LeaseSecret {
t.Error("reconnect changed acknowledgement identity")
}
out := eventAckFor(g)
out.Deduplicated = true
_ = eventTestWrite(ctx, c, eventFrame{Type: "ack", ReplyTo: f.MessageID, Body: eventBody(out)})
_, _ = eventTestRead(ctx, c)
})
consumer, cancel, done := runEventConsumer(t, client, func(context.Context, ResultEvent) error { calls.Add(1); return nil })
awaitConsumerEvent(t, consumer, "acknowledged")
cancel()
if err := awaitConsumerStop(t, done); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 || consumer.Stats().Acknowledged != 1 || consumer.Stats().Reconnects != 1 {
t.Fatal("lost receipt re-executed committed handler", consumer.Stats())
}
if !errors.Is(consumer.Run(context.Background()), ErrEventConsumerAlreadyRun) {
t.Fatal("consumer ran twice")
}
}
func TestEventConsumerSupersededProofRequiresDurableDeduplication(t *testing.T) {
g := eventTestGrant()
var calls, effects atomic.Int64
client := eventMock(t, func(ctx context.Context, c *websocket.Conn, _ EventRegistration, _ int) {
_ = eventTestWrite(ctx, c, eventFrame{Type: "event.deliver", Body: eventBody(g)})
f, err := eventTestRead(ctx, c)
if err != nil {
return
}
_ = eventTestWrite(ctx, c, eventFrame{Type: "error", ReplyTo: f.MessageID, Error: &EventError{Code: "DELIVERY_LEASE_LOST", Message: "proof superseded"}})
next := g
next.Delivery.LeaseEpoch = 2
next.Delivery.Attempts = 2
next.Delivery.Revision = 4
_ = eventTestWrite(ctx, c, eventFrame{Type: "event.deliver", Body: eventBody(next)})
f, err = eventTestRead(ctx, c)
if err != nil {
return
}
_ = eventTestWrite(ctx, c, eventFrame{Type: "ack", ReplyTo: f.MessageID, Body: eventBody(eventAckFor(next))})
_, _ = eventTestRead(ctx, c)
})
consumer, cancel, done := runEventConsumer(t, client, func(context.Context, ResultEvent) error { calls.Add(1); effects.CompareAndSwap(0, 1); return nil })
awaitConsumerEvent(t, consumer, "acknowledged")
cancel()
if err := awaitConsumerStop(t, done); err != nil {
t.Fatal(err)
}
if calls.Load() != 2 || effects.Load() != 1 {
t.Fatal("replacement proof bypassed handler deduplication")
}
}
func TestEventConsumerHandlerFailureDoesNotAcknowledge(t *testing.T) {
for _, panicHandler := range []bool{false, true} {
name := "error"
if panicHandler {
name = "panic"
}
t.Run(name, func(t *testing.T) {
g := eventTestGrant()
retry := make(chan struct{})
var calls atomic.Int64
client := eventMock(t, func(ctx context.Context, c *websocket.Conn, _ EventRegistration, _ int) {
_ = eventTestWrite(ctx, c, eventFrame{Type: "event.deliver", Body: eventBody(g)})
select {
case <-retry:
case <-ctx.Done():
return
}
next := g
next.Delivery.LeaseEpoch = 2
next.Delivery.Attempts = 2
_ = eventTestWrite(ctx, c, eventFrame{Type: "event.deliver", Body: eventBody(next)})
f, err := eventTestRead(ctx, c)
if err != nil {
return
}
var proof EventProof
if f.Type != "event.ack" || json.Unmarshal(f.Body, &proof) != nil || proof.Epoch != 2 {
t.Error("failed handler acknowledged original delivery")
}
_ = eventTestWrite(ctx, c, eventFrame{Type: "ack", ReplyTo: f.MessageID, Body: eventBody(eventAckFor(next))})
_, _ = eventTestRead(ctx, c)
})
consumer, cancel, done := runEventConsumer(t, client, func(context.Context, ResultEvent) error {
if calls.Add(1) == 1 {
if panicHandler {
panic("private handler data")
}
return errors.New("private handler data")
}
return nil
})
awaitConsumerEvent(t, consumer, "handler_failed")
if calls.Load() != 1 {
t.Fatal("handler retried locally")
}
close(retry)
awaitConsumerEvent(t, consumer, "acknowledged")
cancel()
if err := awaitConsumerStop(t, done); err != nil {
t.Fatal(err)
}
if consumer.Stats().HandlerFailures != 1 || consumer.Stats().Acknowledged != 1 {
t.Fatal(consumer.Stats())
}
})
}
}
func TestEventConsumerShutdownCancelsAndConfirmsCommittedHandler(t *testing.T) {
g := eventTestGrant()
started := make(chan struct{})
client := eventMock(t, func(ctx context.Context, c *websocket.Conn, _ EventRegistration, _ int) {
_ = eventTestWrite(ctx, c, eventFrame{Type: "event.deliver", Body: eventBody(g)})
f, err := eventTestRead(ctx, c)
if err != nil {
return
}
_ = eventTestWrite(ctx, c, eventFrame{Type: "ack", ReplyTo: f.MessageID, Body: eventBody(eventAckFor(g))})
_, _ = eventTestRead(ctx, c)
})
consumer, cancel, done := runEventConsumer(t, client, func(ctx context.Context, _ ResultEvent) error { close(started); <-ctx.Done(); return nil })
<-started
cancel()
if err := awaitConsumerStop(t, done); err != nil {
t.Fatal(err)
}
if consumer.Stats().Acknowledged != 1 || consumer.Stats().Running != 0 {
t.Fatal("committed result not drained", consumer.Stats())
}
}
func TestEventConsumerShutdownBoundsIgnoringHandler(t *testing.T) {
started, release := make(chan struct{}), make(chan struct{})
defer close(release)
client := eventMock(t, func(ctx context.Context, c *websocket.Conn, _ EventRegistration, _ int) {
_ = eventTestWrite(ctx, c, eventFrame{Type: "event.deliver", Body: eventBody(eventTestGrant())})
_, _ = eventTestRead(ctx, c)
})
consumer, err := NewEventConsumer(client, EventConsumerOptions{SubscriptionID: eventTestSub, Capacity: 1, ShutdownTimeout: 30 * time.Millisecond, Handle: func(context.Context, ResultEvent) error { close(started); <-release; return nil }})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- consumer.Run(ctx) }()
<-started
cancel()
err = awaitConsumerStop(t, done)
var run *EventConsumerRunError
if !errors.Is(err, ErrEventShutdownTimeout) || !errors.As(err, &run) || run.Unconfirmed != 1 || run.Running != 1 {
t.Fatal("uncooperative handler not reported", err)
}
}
func TestEventConsumerPermanentFailureDoesNotReconnect(t *testing.T) {
var calls atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1); w.WriteHeader(http.StatusUnauthorized) }))
defer server.Close()
client, _ := NewClient(server.URL, Options{Token: "token"})
_, _, done := runEventConsumer(t, client, func(context.Context, ResultEvent) error { t.Error("unauthorized consumer invoked handler"); return nil })
err := awaitConsumerStop(t, done)
var status *HTTPError
if !errors.As(err, &status) || status.StatusCode != 401 || calls.Load() != 1 {
t.Fatal("permanent handshake error retried", err)
}
}
func TestEventConsumerHandlerGoexitReleasesSlotWithoutAck(t *testing.T) {
client := eventMock(t, func(ctx context.Context, c *websocket.Conn, _ EventRegistration, _ int) {
_ = eventTestWrite(ctx, c, eventFrame{Type: "event.deliver", Body: eventBody(eventTestGrant())})
if f, err := eventTestRead(ctx, c); err == nil && f.Type == "event.ack" {
t.Error("Goexit handler was acknowledged")
}
})
consumer, cancel, done := runEventConsumer(t, client, func(context.Context, ResultEvent) error { runtime.Goexit(); return nil })
awaitConsumerEvent(t, consumer, "handler_failed")
cancel()
if err := awaitConsumerStop(t, done); err != nil || consumer.Stats().Running != 0 {
t.Fatal("Goexit leaked handler capacity", err)
}
}
func TestEventConsumerConcurrentCapacity(t *testing.T) {
start := make(chan string, 3)
release := make(chan struct{})
var active, peak atomic.Int64
grants := []EventGrant{eventTestGrant(), eventTestGrant(), eventTestGrant()}
for i := range grants {
grants[i].Event.ID = workerTestID(t)
grants[i].Delivery.ID = workerTestID(t)
grants[i].Delivery.EventID = grants[i].Event.ID
}
client := eventMock(t, func(ctx context.Context, c *websocket.Conn, in EventRegistration, _ int) {
if in.Capacity != 2 {
t.Error("capacity changed")
}
for _, g := range grants[:2] {
_ = eventTestWrite(ctx, c, eventFrame{Type: "event.deliver", Body: eventBody(g)})
}
for i := 0; i < 3; i++ {
f, err := eventTestRead(ctx, c)
if err != nil {
return
}
var p EventProof
if f.Type != "event.ack" || json.Unmarshal(f.Body, &p) != nil {
t.Error("missing ACK")
return
}
var grant EventGrant
for _, g := range grants {
if g.Delivery.ID == p.DeliveryID {
grant = g
}
}
_ = eventTestWrite(ctx, c, eventFrame{Type: "ack", ReplyTo: f.MessageID, Body: eventBody(eventAckFor(grant))})
if i == 0 {
_ = eventTestWrite(ctx, c, eventFrame{Type: "event.deliver", Body: eventBody(grants[2])})
}
}
_, _ = eventTestRead(ctx, c)
})
consumer, err := NewEventConsumer(client, EventConsumerOptions{SubscriptionID: eventTestSub, Capacity: 2, Handle: func(ctx context.Context, event ResultEvent) error {
n := active.Add(1)
defer active.Add(-1)
for {
old := peak.Load()
if n <= old || peak.CompareAndSwap(old, n) {
break
}
}
start <- event.ID
select {
case <-release:
return nil
case <-ctx.Done():
return ctx.Err()
}
}})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
done := make(chan error, 1)
go func() { done <- consumer.Run(ctx) }()
for range 2 {
select {
case <-start:
case <-ctx.Done():
t.Fatal("capacity not utilized")
}
}
close(release)
for range 3 {
awaitConsumerEvent(t, consumer, "acknowledged")
}
cancel()
if err := awaitConsumerStop(t, done); err != nil || peak.Load() != 2 || consumer.Stats().Started != 3 {
t.Fatal("capacity or drain invariant", err, consumer.Stats())
}
}

View File

@@ -0,0 +1,190 @@
package orbit
import (
"context"
"encoding/json"
"errors"
"sync/atomic"
"testing"
"time"
)
func integrationEventSubscription(t *testing.T, ctx context.Context, api *Client, maxAttempts uint32) Subscription {
t.Helper()
queue, err := api.CreateQueue(ctx, workerTestID(t), CreateQueueRequest{Name: "event-sdk-" + workerTestID(t), MaxConcurrency: 1, RatePerSecond: 10})
if err != nil {
t.Fatal(err)
}
sub, err := api.CreateSubscription(ctx, workerTestID(t), CreateSubscriptionRequest{Name: "sdk-" + workerTestID(t), QueueID: queue.Queue.ID, Types: []string{"task.cancelled"}, MaxPending: 10, MaxAttempts: maxAttempts})
if err != nil {
t.Fatal(err)
}
return sub.Subscription
}
func integrationCancelledEvent(t *testing.T, ctx context.Context, api *Client, sub Subscription) Task {
t.Helper()
task, err := api.SubmitTask(ctx, workerTestID(t), SubmitTaskRequest{QueueID: sub.QueueID, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)})
if err != nil {
t.Fatal(err)
}
cancelled, err := api.CancelTask(ctx, task.Task.ID, workerTestID(t), CancelTaskRequest{ExpectedRevision: task.Task.Revision, Reason: "subscription SDK regression"})
if err != nil {
t.Fatal(err)
}
return cancelled.Task
}
func eventDialIntegration(t *testing.T, ctx context.Context, api *Client, sub Subscription, capacity uint32) *EventConnection {
t.Helper()
c, err := api.DialEvents(ctx, EventRegistration{SubscriptionID: sub.ID, Capacity: capacity})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { c.Close() })
return c
}
func TestIntegrationEventConnectionSDK(t *testing.T) {
api, _, _ := integrationWorkerClient(t)
ctx, stop := context.WithTimeout(context.Background(), 15*time.Second)
defer stop()
sub := integrationEventSubscription(t, ctx, api, 3)
task := integrationCancelledEvent(t, ctx, api, sub)
a := eventDialIntegration(t, ctx, api, sub, 1)
grant, err := a.Next(ctx)
if err != nil || grant.Event.Snapshot.TaskID != task.ID || grant.Event.Snapshot.Revision != task.Revision || grant.Event.Type != "task.cancelled" {
t.Fatal("SDK snapshot mismatch", err)
}
paused, err := api.ConfigureSubscription(ctx, sub.ID, workerTestID(t), ConfigureSubscriptionRequest{ExpectedRevision: sub.Revision, Reason: "pause with active delivery", Enabled: false})
if err != nil {
t.Fatal(err)
}
pong, err := a.Ping(ctx, workerTestID(t))
if err != nil || pong.Enabled {
t.Fatal("paused subscription heartbeat", err)
}
first, err := a.Ack(ctx, workerTestID(t), grant.Proof())
if err != nil {
t.Fatal(err)
}
a.Close()
b := eventDialIntegration(t, ctx, api, sub, 1)
duplicate, err := b.Ack(ctx, workerTestID(t), grant.Proof())
if err != nil || !duplicate.Deduplicated || !duplicate.AcknowledgedAt.Equal(first.AcknowledgedAt) {
t.Fatal("ACK proof could not cross connections", err)
}
_ = integrationCancelledEvent(t, ctx, api, sub)
short, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
_, err = b.Next(short)
cancel()
if !errors.Is(err, context.DeadlineExceeded) || b.Err() != nil {
t.Fatal("paused subscription delivered or Next closed connection", err)
}
if _, err = api.ConfigureSubscription(ctx, sub.ID, workerTestID(t), ConfigureSubscriptionRequest{ExpectedRevision: paused.Subscription.Revision, Reason: "resume", Enabled: true}); err != nil {
t.Fatal(err)
}
second, err := b.Next(ctx)
if err != nil || second.Event.ID == grant.Event.ID {
t.Fatal("resume failed", err)
}
if _, err = b.Ack(ctx, workerTestID(t), second.Proof()); err != nil {
t.Fatal(err)
}
current, err := api.GetSubscription(ctx, sub.ID)
if err != nil || current.PendingCount != 0 {
t.Fatal("SDK acknowledgement accounting", err)
}
}
func TestIntegrationEventConsumerHeartbeatAndLostAck(t *testing.T) {
api, _, token := integrationWorkerClient(t)
ctx, stop := context.WithTimeout(context.Background(), 25*time.Second)
defer stop()
sub := integrationEventSubscription(t, ctx, api, 3)
_ = integrationCancelledEvent(t, ctx, api, sub)
gateway := newProtocolFaultGateway(t, api.baseURL, "/v1/events/connect", eventSubprotocol)
gateway.drop.Store("event.ack")
client, err := NewClient(gateway.server.URL, Options{Token: token, HTTPClient: gateway.server.Client()})
if err != nil {
t.Fatal(err)
}
var calls atomic.Int64
consumer, err := NewEventConsumer(client, EventConsumerOptions{SubscriptionID: sub.ID, Capacity: 1, ShutdownTimeout: time.Second, Handle: func(work context.Context, event ResultEvent) error {
calls.Add(1)
// 慢业务期间真实等待15秒自动心跳;确认计数来自上游服务端ACK,不是客户端写入。
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
for gateway.confirmedEventPings.Load() == 0 {
select {
case <-work.Done():
return work.Err()
case <-ticker.C:
}
}
return nil
}})
if err != nil {
t.Fatal(err)
}
run, cancel := context.WithCancel(ctx)
defer cancel()
done := make(chan error, 1)
go func() { done <- consumer.Run(run) }()
for consumer.Stats().Acknowledged == 0 {
select {
case <-ctx.Done():
t.Fatal("consumer did not reconcile committed ACK")
case err := <-done:
t.Fatal("consumer stopped", err)
case <-time.After(20 * time.Millisecond):
}
}
cancel()
if err := awaitConsumerStop(t, done); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 || consumer.Stats().Reconnects < 1 || gateway.confirmedEventPings.Load() < 1 {
t.Fatal("heartbeat or reconciliation missing", consumer.Stats())
}
page, err := api.ListEventDeliveries(ctx, sub.ID, DeliveryPageRequest{State: DeliveryAcked})
if err != nil || len(page.Items) != 1 || page.Items[0].Attempts != 1 {
t.Fatal("lost ACK created another delivery attempt", err)
}
t.Log("real 15-second heartbeat and committed ACK loss recovered; handler and delivery attempt each remained one")
}
func TestIntegrationEventConsumerFailureRedeliversNaturally(t *testing.T) {
api, _, _ := integrationWorkerClient(t)
ctx, stop := context.WithTimeout(context.Background(), 45*time.Second)
defer stop()
sub := integrationEventSubscription(t, ctx, api, 2)
_ = integrationCancelledEvent(t, ctx, api, sub)
var calls atomic.Int64
consumer, err := NewEventConsumer(api, EventConsumerOptions{SubscriptionID: sub.ID, Capacity: 1, ShutdownTimeout: time.Second, Handle: func(context.Context, ResultEvent) error {
if calls.Add(1) == 1 {
return errors.New("receiver transaction rolled back")
}
return nil
}})
if err != nil {
t.Fatal(err)
}
run, cancel := context.WithCancel(ctx)
defer cancel()
done := make(chan error, 1)
go func() { done <- consumer.Run(run) }()
for consumer.Stats().Acknowledged == 0 {
select {
case <-ctx.Done():
t.Fatal("delivery did not retry after natural lease expiry")
case err := <-done:
t.Fatal("consumer stopped", err)
case <-time.After(50 * time.Millisecond):
}
}
cancel()
if err := awaitConsumerStop(t, done); err != nil {
t.Fatal(err)
}
page, err := api.ListEventDeliveries(ctx, sub.ID, DeliveryPageRequest{State: DeliveryAcked})
if err != nil || len(page.Items) != 1 || page.Items[0].Attempts != 2 || calls.Load() != 2 || consumer.Stats().HandlerFailures != 1 {
t.Fatal("handler error or retry budget mishandled", err)
}
t.Log("handler error caused no ACK; same delivery reappeared after its real 30-second lease and backoff, then acknowledged at attempt two")
}

View File

@@ -0,0 +1,103 @@
package orbit
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
)
func TestIntegrationEventProcessLeaseRecovery(t *testing.T) {
a := startOrbitProcess(t)
_, _, token := integrationWorkerClient(t)
api := processClient(t, a, token)
ctx, stop := context.WithTimeout(context.Background(), 45*time.Second)
defer stop()
sub := integrationEventSubscription(t, ctx, api, 3)
_ = integrationCancelledEvent(t, ctx, api, sub)
firstConn := eventDialIntegration(t, ctx, api, sub, 1)
first, err := firstConn.Next(ctx)
if err != nil {
t.Fatal(err)
}
a.kill(t)
select {
case <-firstConn.Done():
case <-time.After(3 * time.Second):
t.Fatal("dead server connection remained live")
}
b := startOrbitProcess(t)
other := processClient(t, b, token)
secondConn := eventDialIntegration(t, ctx, other, sub, 1)
second, err := secondConn.Next(ctx)
if err != nil || first.Event.ID != second.Event.ID || first.Delivery.ID != second.Delivery.ID || second.Delivery.LeaseEpoch != 2 || second.Delivery.Attempts != 2 {
t.Fatal("node restart did not recover original delivery", err)
}
_, err = secondConn.Ack(ctx, workerTestID(t), first.Proof())
var remote *EventError
if !errors.As(err, &remote) || remote.Code != "DELIVERY_LEASE_LOST" {
t.Fatal("dead node proof acknowledged newer lease", err)
}
if _, err = secondConn.Ack(ctx, workerTestID(t), second.Proof()); err != nil {
t.Fatal(err)
}
current, err := other.GetSubscription(ctx, sub.ID)
if err != nil || current.PendingCount != 0 {
t.Fatal("recovered acknowledgement not persisted", err)
}
t.Log("SIGKILL confirmed by Wait; new process recovered original delivery after natural lease expiry, rejected old proof and committed new ACK")
}
func TestIntegrationEventProcessAckCommitResponseLoss(t *testing.T) {
a := startOrbitProcess(t)
_, _, token := integrationWorkerClient(t)
api := processClient(t, a, token)
ctx, stop := context.WithTimeout(context.Background(), 20*time.Second)
defer stop()
sub := integrationEventSubscription(t, ctx, api, 3)
_ = integrationCancelledEvent(t, ctx, api, sub)
gateway := newProtocolFaultGateway(t, a.url, "/v1/events/connect", eventSubprotocol)
gateway.drop.Store("event.ack")
gateway.dropOffline.Store(true)
client, err := NewClient(gateway.server.URL, Options{Token: token, HTTPClient: gateway.server.Client()})
if err != nil {
t.Fatal(err)
}
var calls atomic.Int64
consumer, err := NewEventConsumer(client, EventConsumerOptions{SubscriptionID: sub.ID, Capacity: 1, ShutdownTimeout: time.Second, Handle: func(context.Context, ResultEvent) error { calls.Add(1); return nil }})
if err != nil {
t.Fatal(err)
}
run, cancel := context.WithCancel(ctx)
defer cancel()
done := make(chan error, 1)
go func() { done <- consumer.Run(run) }()
awaitProcessDrop(t, ctx, gateway, "event.ack")
before, err := api.ListEventDeliveries(ctx, sub.ID, DeliveryPageRequest{State: DeliveryAcked})
if err != nil || len(before.Items) != 1 || before.Items[0].AcknowledgedAt == nil {
t.Fatal("fault occurred before durable ACK", err)
}
a.kill(t)
b := startOrbitProcess(t)
other := processClient(t, b, token)
gateway.target.Store(b.url)
gateway.offline.Store(false)
for consumer.Stats().Acknowledged == 0 {
select {
case <-ctx.Done():
t.Fatal("consumer failed to reconcile ACK on new process")
case err := <-done:
t.Fatal("consumer stopped", err)
case <-time.After(20 * time.Millisecond):
}
}
cancel()
if err := awaitConsumerStop(t, done); err != nil {
t.Fatal(err)
}
after, err := other.ListEventDeliveries(ctx, sub.ID, DeliveryPageRequest{State: DeliveryAcked})
if err != nil || len(after.Items) != 1 || after.Items[0].Attempts != 1 || after.Items[0].AcknowledgedAt == nil || !after.Items[0].AcknowledgedAt.Equal(*before.Items[0].AcknowledgedAt) || calls.Load() != 1 {
t.Fatal("ACK receipt changed or committed handler reran", err)
}
t.Log("ACK commit verified before response loss and SIGKILL; replacement process returned original receipt without another handler or delivery attempt")
}

87
sdk/go/event_types.go Normal file
View File

@@ -0,0 +1,87 @@
package orbit
import (
"errors"
"fmt"
"time"
)
var (
ErrEventClosed = errors.New("event connection closed")
ErrEventDisconnected = errors.New("event connection interrupted; acknowledgement may be unconfirmed")
ErrEventProtocol = errors.New("invalid event protocol response")
ErrEventBusy = errors.New("event control request limit reached")
ErrEventBackpressure = errors.New("event delivery buffer exhausted")
)
// EventError 是服务端公开错误。Error/String/GoString只展示分类,不输出远端正文。
type EventError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func (e *EventError) Error() string { return "orbit event " + e.Code }
func (e *EventError) String() string { return e.Error() }
func (e *EventError) GoString() string { return e.Error() }
// EventRegistration 绑定一个已有订阅。容量固定为1至32,重连可以重新选择。
type EventRegistration struct {
SubscriptionID string `json:"subscription_id"` // SubscriptionID 已创建且属于当前凭据的订阅。
Capacity uint32 `json:"capacity"`
}
// EventWelcome 是当前连接的握手快照;Enabled变化通过Ping查询。
type EventWelcome struct {
SessionID string `json:"session_id"`
SubscriptionID string `json:"subscription_id"`
Capacity uint32 `json:"capacity"`
LeaseDurationMS int64 `json:"lease_duration_ms"`
HeartbeatIntervalMS int64 `json:"heartbeat_interval_ms"`
ServerTime time.Time `json:"server_time"`
Enabled bool `json:"enabled"`
}
type EventPong struct {
ServerTime time.Time `json:"server_time"`
Enabled bool `json:"enabled"`
}
// EventGrant 包含冻结结果及当前交付证明。不得将JSON写到普通日志。
// 租约只控制平台补发,不授权接收方跳过去重;不同消费者仍可能并行收到同一事件。
type EventGrant struct {
Delivery EventDelivery `json:"delivery"`
Event ResultEvent `json:"event"`
LeaseSecret string `json:"lease_secret"`
}
// EventProof 保留原身份及秘密,在同订阅的新连接上仍可核对ACK。
// SubscriptionID和EventID用于SDK本地关联校验;只把后三个字段发送到服务端。
type EventProof struct {
SubscriptionID string `json:"subscription_id"`
EventID string `json:"event_id"`
DeliveryID string `json:"delivery_id"`
Epoch uint64 `json:"epoch,string"`
LeaseSecret string `json:"lease_secret"`
}
func (g EventGrant) Proof() EventProof {
return EventProof{SubscriptionID: g.Delivery.SubscriptionID, EventID: g.Event.ID, DeliveryID: g.Delivery.ID, Epoch: g.Delivery.LeaseEpoch, LeaseSecret: g.LeaseSecret}
}
type EventAckResult struct {
DeliveryID string `json:"delivery_id"`
EventID string `json:"event_id"`
Epoch uint64 `json:"epoch,string"`
AcknowledgedAt time.Time `json:"acknowledged_at"`
Deduplicated bool `json:"deduplicated"`
}
// NewEventMessageID 生成控制消息关联ID;ACK的持久化身份是交付ID、代次和证明。
func NewEventMessageID() (string, error) { return NewWorkerMessageID() }
func (g EventGrant) String() string {
return fmt.Sprintf("EventGrant{EventID:%s DeliveryID:%s data:redacted}", g.Event.ID, g.Delivery.ID)
}
func (g EventGrant) GoString() string { return g.String() }
func (p EventProof) String() string {
return fmt.Sprintf("EventProof{DeliveryID:%s Epoch:%d secrets:redacted}", p.DeliveryID, p.Epoch)
}
func (p EventProof) GoString() string { return p.String() }

View File

@@ -0,0 +1,112 @@
// event-protocol 验证冻结结果、连接切换与ACK重报;不执行业务写入。
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
token, queue := os.Getenv("ORBIT_TOKEN"), os.Getenv("ORBIT_QUEUE_ID")
if path := os.Getenv("ORBIT_CREDENTIALS_FILE"); path != "" {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
var credential struct {
Token string `json:"token"`
QueueID string `json:"queue_id"`
}
if json.Unmarshal(raw, &credential) != nil {
return errors.New("invalid credential file")
}
if token == "" {
token = credential.Token
}
if queue == "" {
queue = credential.QueueID
}
}
if token == "" || queue == "" {
return errors.New("event-enabled token and isolated example queue are required")
}
api, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
if err != nil {
return err
}
ctx, stop := context.WithTimeout(context.Background(), 15*time.Second)
defer stop()
id, err := orbit.NewEventMessageID()
if err != nil {
return err
}
created, err := api.CreateSubscription(ctx, "sub-"+id, orbit.CreateSubscriptionRequest{Name: "protocol-" + id, QueueID: queue, Types: []string{"task.cancelled"}, MaxPending: 10, MaxAttempts: 3})
if err != nil {
return err
}
sub := created.Subscription
task, err := api.SubmitTask(ctx, "task-"+id, orbit.SubmitTaskRequest{QueueID: queue, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)})
if err != nil {
return err
}
if _, err = api.CancelTask(ctx, task.Task.ID, "cancel-"+id, orbit.CancelTaskRequest{ExpectedRevision: task.Task.Revision, Reason: "event protocol example"}); err != nil {
return err
}
first, err := api.DialEvents(ctx, orbit.EventRegistration{SubscriptionID: sub.ID, Capacity: 1})
if err != nil {
return err
}
defer first.Close()
grant, err := first.Next(ctx)
if err != nil {
return err
}
if grant.Event.Snapshot.TaskID != task.Task.ID || grant.Event.Snapshot.State != orbit.TaskCancelled {
return errors.New("unexpected example queue event")
}
// 本例只读取并验证结果,没有业务数据库写入;业务处理必须先提交Inbox事务再ACK。
ackID, err := orbit.NewEventMessageID()
if err != nil {
return err
}
confirmed, err := first.Ack(ctx, ackID, grant.Proof())
if err != nil {
return err
}
first.Close()
second, err := api.DialEvents(ctx, orbit.EventRegistration{SubscriptionID: sub.ID, Capacity: 1})
if err != nil {
return err
}
defer second.Close()
replayID, err := orbit.NewEventMessageID()
if err != nil {
return err
}
replayed, err := second.Ack(ctx, replayID, grant.Proof())
if err != nil {
return err
}
if !replayed.Deduplicated || !replayed.AcknowledgedAt.Equal(confirmed.AcknowledgedAt) {
return errors.New("acknowledgement receipt changed across connections")
}
paused, err := api.ConfigureSubscription(ctx, sub.ID, "pause-"+id, orbit.ConfigureSubscriptionRequest{ExpectedRevision: sub.Revision, Enabled: false, Reason: "protocol example completed"})
if err != nil {
return err
}
fmt.Printf("event=%s task=%s state=%s\n", grant.Event.ID, task.Task.ID, grant.Event.Snapshot.State)
fmt.Printf("ack_replayed=%t same_receipt=true pending=%d subscription_enabled=%t\n", replayed.Deduplicated, paused.Subscription.PendingCount, paused.Subscription.Enabled)
return nil
}

View File

@@ -48,6 +48,7 @@ func integrationWorkerClient(t *testing.T) (*Client, string, string) {
// workerFaultGateway 在实际服务端确认后丢弃指定 ACK;SDK 到网关使用真实 TLS。 // workerFaultGateway 在实际服务端确认后丢弃指定 ACK;SDK 到网关使用真实 TLS。
// 只丢响应,不伪造任务状态,测试凭据不进入 URL 或输出。 // 只丢响应,不伪造任务状态,测试凭据不进入 URL 或输出。
type workerFaultGateway struct { type workerFaultGateway struct {
confirmedEventPings atomic.Uint64
drop atomic.Value drop atomic.Value
target atomic.Value target atomic.Value
dropOffline atomic.Bool dropOffline atomic.Bool
@@ -60,6 +61,10 @@ type workerFaultGateway struct {
} }
func newWorkerFaultGateway(t *testing.T, upstream string) *workerFaultGateway { func newWorkerFaultGateway(t *testing.T, upstream string) *workerFaultGateway {
return newProtocolFaultGateway(t, upstream, "/v1/worker/connect", workerSubprotocol)
}
func newProtocolFaultGateway(t *testing.T, upstream, path, protocol string) *workerFaultGateway {
t.Helper() t.Helper()
g := &workerFaultGateway{dropped: make(chan string, 4), active: make(map[*websocket.Conn]struct{})} g := &workerFaultGateway{dropped: make(chan string, 4), active: make(map[*websocket.Conn]struct{})}
g.drop.Store("") g.drop.Store("")
@@ -77,14 +82,14 @@ func newWorkerFaultGateway(t *testing.T, upstream string) *workerFaultGateway {
defer stop() defer stop()
header := http.Header{} header := http.Header{}
header.Set("Authorization", r.Header.Get("Authorization")) header.Set("Authorization", r.Header.Get("Authorization"))
up, _, err := websocket.Dial(bridge, g.target.Load().(string)+"/v1/worker/connect", &websocket.DialOptions{HTTPHeader: header, Subprotocols: []string{workerSubprotocol}}) up, _, err := websocket.Dial(bridge, g.target.Load().(string)+path, &websocket.DialOptions{HTTPHeader: header, Subprotocols: []string{protocol}})
if err != nil { if err != nil {
http.Error(w, "upstream unavailable", 503) http.Error(w, "upstream unavailable", 503)
return return
} }
defer up.CloseNow() defer up.CloseNow()
up.SetReadLimit(512 << 10) up.SetReadLimit(512 << 10)
down, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{workerSubprotocol}}) down, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{protocol}})
if err != nil { if err != nil {
return return
} }
@@ -138,6 +143,9 @@ func newWorkerFaultGateway(t *testing.T, upstream string) *workerFaultGateway {
operation := operations[f.ReplyTo] operation := operations[f.ReplyTo]
delete(operations, f.ReplyTo) delete(operations, f.ReplyTo)
mu.Unlock() mu.Unlock()
if f.Type == "ack" && operation == "events.ping" {
g.confirmedEventPings.Add(1)
}
if f.Type == "task.cancel" && g.dropCancel.CompareAndSwap(true, false) { if f.Type == "task.cancel" && g.dropCancel.CompareAndSwap(true, false) {
g.dropped <- "task.cancel" g.dropped <- "task.cancel"
return return