feat: expose result subscription HTTP and WebSocket APIs
This commit is contained in:
@@ -14,4 +14,4 @@
|
||||
|
||||
- 结果日志的序号在独立命名空间流行锁下分配,与任务状态及原事件同事务提交;禁止用最大事件ID或时间戳推断提交水位。生产者不得锁订阅行。
|
||||
- 创建订阅先锁队列再锁流行;消费按订阅→交付持锁,不能再取得任务、队列、额度或流行锁。ACK只验证最近代次及证明,过期未被替代的证明仍可确认;重复确认不能再次扣减pending_count。
|
||||
- 可靠订阅当前只完成持久化核心;HTTP/WSS、独立SDK、可运行Inbox示例和真实进程故障回归完成前,不标记P3订阅能力完成。
|
||||
- 可靠订阅已完成核心、HTTP/WSS及管理SDK;事件消费SDK、可运行Inbox示例和真实订阅进程故障回归完成前,不标记P3订阅能力完成。
|
||||
|
||||
@@ -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/09-result-subscription-core.md) 已实现持久化核心,HTTP/WSS 和 SDK 接入待完成;剩余 Pulse 能力与流程引擎按 [持续开发计划](docs/08-development-plan.md) 继续实现。
|
||||
已完成 P0–P2 阶段验收:35 个数据模型、版本化迁移、凭据及范围校验、队列与任务 API、WSS 执行、独立 SDK、自动 Worker 和 [执行尝试/证据查询](docs/api/execution-history.md)。[Worker Schema](docs/api/worker-schema.md) 与真实进程强杀恢复回归已通过;[安全重试](docs/api/retries.md)、[定时计划](docs/api/schedules.md)、[运行中取消](docs/api/cancellation.md) 、[队列资源控制](docs/api/queues.md) 、[应用共享配额](docs/api/application-resources.md) 、[资源键互斥](docs/api/keyed-concurrency.md) 和 [优先级/节点公平派发](docs/api/priority-and-fairness.md) 已接入服务端和 SDK,[可靠结果订阅](docs/api/subscriptions.md) 已实现持久化核心、HTTP/WSS、管理 SDK 与示例,消费 SDK 与 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)。
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/resources"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/schedule"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
@@ -64,6 +65,15 @@ func run() error {
|
||||
}
|
||||
hub := httpserver.NewWorkerHub(root, worker.New(executions))
|
||||
httpserver.RegisterWorkers(server, hub)
|
||||
eventService := events.New(mysql.NewEventStore(db))
|
||||
eventHub := httpserver.NewEventHub(root, eventService)
|
||||
httpserver.RegisterEventSubscriptions(server, eventService)
|
||||
httpserver.RegisterEventConsumers(server, eventHub)
|
||||
defer func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = eventHub.Shutdown(ctx)
|
||||
}()
|
||||
schedules, err := mysql.NewScheduleStore(db, node)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -122,7 +132,7 @@ func run() error {
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- server.Serve(listener) }()
|
||||
slog.Info("Orbit HTTP server starting", "address", listener.Addr().String(), "scope", "health, task, queue, schedule and Worker APIs", "node_id", node)
|
||||
slog.Info("Orbit HTTP server starting", "address", listener.Addr().String(), "scope", "health, task, queue, schedule, subscription and Worker APIs", "node_id", node)
|
||||
select {
|
||||
case err := <-done:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
|
||||
@@ -163,7 +163,7 @@ SDK 可以在进程存活期间缓存未确认消息;不得宣称内存缓存
|
||||
|
||||
## 7. 可靠结果订阅
|
||||
|
||||
建议独立使用 WSS 入口 `/v1/events/connect` 与子协议 `orbit.events.v1`,区分 Worker 消费权和结果订阅权。具体路径待评审。
|
||||
当前已实现独立 WSS 入口 `/v1/events/connect` 与子协议 `orbit.events.v1`,区分 Worker 消费权和结果订阅权。当前请求、响应、限制与机器可读 Schema 以 [订阅接口](api/subscriptions.md) 为准;下述涉及流程事件的描述仍为规划。
|
||||
|
||||
事件包含 `event_id`、资源类型、资源 ID、状态版本、事件类型及结果引用,可表达任务与流程事件。平台先持久化事件与交付状态,再发送 `event.deliver`;接收方完成自身的可靠处理后发送 `event.ack`。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 数据模型与实现基线
|
||||
|
||||
更新:2026-09-17。数据库统一禁止外键。当前已实现任务执行、安全重试、运行中取消、计划调度、队列限速/背压和应用配额;优先级及节点公平领取已实现;可靠订阅持久化核心已实现,HTTP/WSS 与 SDK 接入及流程编排继续开发。
|
||||
更新:2026-09-17。数据库统一禁止外键。当前已实现任务执行、安全重试、运行中取消、计划调度、队列限速/背压和应用配额;优先级及节点公平领取已实现;可靠订阅核心、HTTP/WSS 与管理 SDK 已实现,消费 SDK 及流程编排继续开发。
|
||||
|
||||
## 1. 模块与依赖
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
|
||||
禁止“先查后另开事务写入”。需要防止并发状态改变的关联行在事务内持锁,统一锁顺序并保留必要索引。当前不提供硬删除;将来增加清理时,必须核验在途任务、引用、幂等恢复窗口与审计保留期,按有限批次处理,不能依赖数据库级联。直接绕过服务写 SQL 不受应用校验保护。
|
||||
|
||||
P1 已完成动作/队列授权、幂等摘要与原回执、受理事件及取消 CAS/审计事务;P3 扩展到运行中取消。订阅已实现服务层及持久化核心,包含事务内队列、所有者和交付归属校验;HTTP/WSS 和 SDK 尚未接入。流程目前仍只具备模型,后续引擎也必须补齐事务内父资源归属验证。
|
||||
P1 已完成动作/队列授权、幂等摘要与原回执、受理事件及取消 CAS/审计事务;P3 扩展到运行中取消。订阅已实现服务层及持久化核心,包含事务内队列、所有者和交付归属校验;HTTP/WSS 和管理 SDK 已接入,消费 SDK 待完成。流程目前仍只具备模型,后续引擎也必须补齐事务内父资源归属验证。
|
||||
|
||||
P2 服务端已新增会话隔离、两步派发、租约、并发许可、结果回执/证据和恢复扫描,见 [执行协议](api/worker.md)。底层及自动 Worker SDK、尝试/证据查询、Schema 与进程故障恢复已通过 [P2 阶段验收](reports/phase-2.md)。
|
||||
|
||||
@@ -151,4 +151,8 @@ P2 服务端已新增会话隔离、两步派发、租约、并发许可、结
|
||||
|
||||
## 13. 可靠结果订阅核心
|
||||
|
||||
000013_result_subscriptions 新增 EventStream、ResultEvent,扩展 Subscription 和 Delivery。289 个直接声明字段均有中文注释。生产事务在独立流行锁下分配序号,与任务、原事件和结果快照一起提交;消费事务不锁流行、任务、队列或应用额度。交付记录和扫描位置一起落库,每条交付单独持有租约和证明。重复确认不重复扣减积压,耗尽保留记录,人工追加预算不重置历史。详见 [订阅核心](09-result-subscription-core.md)。对外 HTTP/WSS 与 SDK 尚未实现,P3 继续推进。
|
||||
000013_result_subscriptions 新增 EventStream、ResultEvent,扩展 Subscription 和 Delivery。289 个直接声明字段均有中文注释。生产事务在独立流行锁下分配序号,与任务、原事件和结果快照一起提交;消费事务不锁流行、任务、队列或应用额度。交付记录和扫描位置一起落库,每条交付单独持有租约和证明。重复确认不重复扣减积压,耗尽保留记录,人工追加预算不重置历史。详见 [订阅核心](09-result-subscription-core.md)。对外 HTTP/WSS 与管理 SDK 已实现,消费 SDK 与完整场景继续推进。
|
||||
|
||||
## 14. 交付历史查询索引
|
||||
|
||||
000014_delivery_history 增加按订阅及可选状态过滤的创建时间分页索引,领取、确认和重试不改变排序键。不增加模型字段或外键,仍为35个实体、289个中文注释字段。查询按凭据所有者、命名空间和订阅校验,游标绑定过滤条件;详情按来源事件ID读取不可变快照且不返回交付秘密。见 [订阅查询接口](api/subscriptions.md)。
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
| P0 基础与数据模型 | Go workspace、独立 SDK module、公共/Pulse/Weave 模型、MySQL 迁移、配置、健康检查 | 编译/竞态/静态检查;真实 MySQL 建表、隔离、唯一性、预算、不可变版本、迁移失败防护;SDK 健康调用 | 已完成并通过阶段回归(见 P0 报告) |
|
||||
| P1 任务受理闭环 | 应用凭据与作用域权限、队列、幂等提交、查询、待执行取消、受理事件;SDK 客户端 | API/SDK 创建查询闭环;重复键与内容冲突、响应未确认重报、跨命名空间拒绝、参数边界 | 已完成并通过阶段回归(见 P1 报告;真实断路/提交 ACK 丢失注入留后续故障阶段) |
|
||||
| P2 Worker 执行闭环 | WSS 接入、能力/容量、领取与开始、租约、续租、结果确认、排空与恢复;Worker SDK | 真 Worker 执行;失联/重连/重复确认/旧尝试拒绝;业务效果未知转核查;节点故障接管 | 已完成并通过阶段回归:服务端、自动 SDK、历史查询、完整 Schema 与真实进程强杀恢复;测试拓扑和剩余生产故障矩阵见 P2 验收报告 |
|
||||
| P3 Pulse 完整能力 | 安全重试、延时、Cron/固定间隔、漏跑策略、计划版本、运行中取消、并发许可、配额/背压、可靠结果订阅 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 进行中:安全重试、计划调度、运行中取消、队列控制/限速/积压背压、跨命名空间应用配额、显式资源键许可及 SDK 场景已实现并通过全量回归;优先级及节点公平派发已实现并通过全量回归;可靠订阅持久化核心已实现,HTTP/WSS、SDK 及断线故障回归继续开发 |
|
||||
| P3 Pulse 完整能力 | 安全重试、延时、Cron/固定间隔、漏跑策略、计划版本、运行中取消、并发许可、配额/背压、可靠结果订阅 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 进行中:安全重试、计划调度、运行中取消、队列控制/限速/积压背压、跨命名空间应用配额、显式资源键许可及 SDK 场景已实现并通过全量回归;优先级及节点公平派发已实现并通过全量回归;可靠订阅核心、HTTP/WSS、管理 SDK 和管理示例已实现并回归;消费 SDK、Inbox 场景及订阅进程故障回归继续开发 |
|
||||
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 待开发 |
|
||||
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 待开发 |
|
||||
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 |
|
||||
@@ -48,7 +48,7 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最
|
||||
| 跨命名空间应用配额 | first 受理 → second 全局满额 → 释放 → 原键成功 → 恢复配置 | P3,已实现完整示例 |
|
||||
| 跨队列共享资源 | 两队列同键 → 一个有效许可 → 递增序号 → 资源端拒绝旧写入 | P3,已实现完整示例 |
|
||||
| 优先级与应用隔离 | 近同时到期按优先级 → 旧任务等待补偿 → 应用/队列轮转 | P3,已实现完整示例 |
|
||||
| 可靠结果订阅 | 冻结结果 → 持久化交付 → Inbox 去重 → 确认/补发 → 耗尽后人工重试 | P3,已实现服务与存储核心;HTTP/WSS 和 SDK 待接入 |
|
||||
| 可靠结果订阅 | 冻结结果 → 持久化交付 → Inbox 去重 → 确认/补发 → 耗尽后人工重试 | P3,核心、HTTP/WSS、管理 SDK/示例已实现;消费 SDK 与 Inbox 场景待完成 |
|
||||
| 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4 |
|
||||
| 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4 |
|
||||
| 外部作业轮询 | 提交一次 → 有界查询处理中状态 → 回执或最终结果 → 超限核查 | P5 |
|
||||
@@ -77,6 +77,7 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最
|
||||
- [数据模型](07-data-model.md)
|
||||
- [可靠结果订阅核心](09-result-subscription-core.md)
|
||||
- [P3 订阅核心回归](reports/phase-3-result-subscription-core.md)
|
||||
- [P3 订阅通信与管理 SDK 回归](reports/phase-3-result-subscription-transport.md)
|
||||
- [接口手册](api/README.md)
|
||||
- [SDK 接入](sdk/README.md)
|
||||
- [通用场景示例](examples/README.md)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 可靠结果订阅:持久化核心
|
||||
|
||||
状态:P3 的存储与服务层已实现并通过本地全量回归,见 [验证记录](reports/phase-3-result-subscription-core.md)。HTTP 管理接口、事件 WSS、独立 SDK 和可运行订阅示例尚未接入;不能把本页的方法当作已经发布的 SDK。对外接口接通后再冻结消息 Schema 和完整调用手册。
|
||||
状态:P3 的存储与服务层已实现并通过本地全量回归,见 [验证记录](reports/phase-3-result-subscription-core.md)。[HTTP/WSS、管理 SDK 与管理示例](api/subscriptions.md) 已接入。事件连接 SDK、自动消费和持久化 Inbox 示例继续开发;本页的存储方法属于服务端内部模块。
|
||||
|
||||
## 1. 已实现范围
|
||||
|
||||
@@ -61,7 +61,7 @@ bootstrap 新增显式 --event-access,用于签发包含这三项动作的开
|
||||
| ACKED | 已可靠确认;重复确认返回原确认时间,不再次扣减 pending_count,也不重复写确认审计 |
|
||||
| EXPIRED | 已耗尽交付预算,仍保留事件和积压占用;允许带原因的人工追加预算,或接受尚未被替换的最近证明的迟到确认 |
|
||||
|
||||
租约固定 30 秒,每次领取增加 lease_epoch 和 attempts,并产生新的 32 字节随机秘密。数据库只保存摘要;确认时使用常量时间摘要比较。当前内部领取按服务端连接 ID 计算容量,单连接容量 1–32。连接 ID 由后续传输层生成,不能接受客户端自报来绕过容量。
|
||||
租约固定 30 秒,每次领取增加 lease_epoch 和 attempts,并产生新的 32 字节随机秘密。数据库只保存摘要;确认时使用常量时间摘要比较。当前内部领取按服务端连接 ID 计算容量,单连接容量 1–32。连接 ID 由传输层生成,不能接受客户端自报来绕过容量。
|
||||
|
||||
下一次启用状态下的领取,会先回收该订阅的过期交付。未耗尽时使用 `min(2^(attempts-1), 30)` 秒退避;耗尽时转 EXPIRED,不无限自动发送。当前没有独立订阅清扫进程:无人消费或订阅暂停期间,租约状态可能保持 LEASED,直至下一次领取执行恢复。
|
||||
|
||||
@@ -91,4 +91,4 @@ retained_from_sequence 初始且目前固定为 1。未来实现清理后,如
|
||||
|
||||
## 8. 下一步
|
||||
|
||||
接入 HTTP 创建/查询/分页、暂停/继续、交付与耗尽记录查询、人工重试;实现 orbit.events.v1 WSS 的认证、hello、交付、ACK、心跳、连接上限、背压和退出;补独立 Go SDK、Inbox 去重示例、严格消息 Schema 和完整接口手册。随后做断路、ACK 响应丢失、节点进程强杀及 SDK 端到端回归,再验收 P3 的可靠订阅能力。
|
||||
HTTP 创建/查询/分页、暂停/继续、交付与耗尽记录查询、人工重试,以及 orbit.events.v1 WSS、消息 Schema、管理 SDK 和管理示例已实现,见 [接口手册](api/subscriptions.md)。接下来完成事件连接 SDK、自动消费及持久化 Inbox 去重示例,并补 SDK 断线/ACK 丢失和节点进程强杀回归,再验收 P3 的可靠订阅能力。
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
| 任务冻结策略与 Worker `retryable` | P3 安全重试已实现 | [安全重试](retries.md) |
|
||||
| `/v1/tasks/{task_id}:cancel` 与 `task.cancel` 通知 | P3 运行中取消已实现 | [取消及 SDK 场景](cancellation.md) |
|
||||
| `/v1/schedules`、不可变版本、暂停/恢复及触发查询 | P3 已实现 | [计划接口与 SDK 场景](schedules.md) |
|
||||
| `/v1/events/connect` 及订阅管理 | 持久化核心已实现;HTTP/WSS 和 SDK 尚未接入 | [订阅核心及边界](../09-result-subscription-core.md) |
|
||||
| `/v1/subscriptions` 及管理、交付查询与重试 | 已实现 HTTP、管理 SDK 和完整管理示例 | [订阅手册](subscriptions.md) |
|
||||
| `/v1/events/connect` | 已实现服务端 WSS 与消息 Schema;消费 SDK 待交付 | [消息协议](subscriptions.md#5-websocket-建连与边界) |
|
||||
| `/v1/workflows` 与流程管理 | 计划 P4–P5,尚未实现 | [流程接口规划](workflows.md) |
|
||||
|
||||
未实现路由返回 404。健康检查就绪不表示任务执行、定时调度或流程引擎已经可用。
|
||||
|
||||
@@ -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 结果订阅核心新增 `events:read`、`events:manage` 和 `events:consume`,bootstrap 仅显式 `--event-access` 时授予。订阅绑定创建凭据与单个队列,不能用同命名空间的另一张凭据消费或管理。当前 HTTP/WSS 和独立 SDK 尚未接入,详见 [订阅核心边界](../09-result-subscription-core.md)。
|
||||
P3 结果订阅核心新增 `events:read`、`events:manage` 和 `events:consume`,bootstrap 仅显式 `--event-access` 时授予。订阅绑定创建凭据与单个队列,不能用同命名空间的另一张凭据消费或管理。当前 HTTP/WSS 和管理 SDK 已接入;消费 SDK、凭据重绑继续开发,详见 [订阅接口](subscriptions.md)。
|
||||
|
||||
327
docs/api/subscriptions.md
Normal file
327
docs/api/subscriptions.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# 可靠结果订阅 HTTP 与 WebSocket
|
||||
|
||||
状态:已实现服务端 HTTP/WSS、管理 SDK 和管理示例。事件连接 SDK、自动重连消费器、持久化 Inbox 示例及真实进程强杀验证继续开发,完整 P3 订阅验收尚未完成。[存储语义与升级边界](../09-result-subscription-core.md),[本阶段验证](../reports/phase-3-result-subscription-transport.md)。
|
||||
|
||||
## 1. 授权与共同规则
|
||||
|
||||
所有 HTTP 请求使用 Authorization: Bearer 请求头,管理写请求还需要稳定的 Idempotency-Key。生产通过 HTTPS/WSS 接入;本地示例可使用回环 HTTP。凭据、交付秘密不能放在 URL。请求不接受自报 namespace_id、application_id 或 credential_id。
|
||||
|
||||
订阅固定绑定创建凭据和一个队列。除动作权限外,每次操作都验证队列范围和订阅所有者。另一张凭据即使属于相同命名空间也不能操作此订阅;凭据重新绑定尚未开放。bootstrap 使用显式 --event-access 才授予三个事件动作。
|
||||
|
||||
| 方法与路径 | 权限 | SDK 方法 |
|
||||
| --- | --- | --- |
|
||||
| POST /v1/subscriptions | events:manage | CreateSubscription |
|
||||
| GET /v1/subscriptions?queue_id=... | events:read | ListSubscriptions |
|
||||
| GET /v1/subscriptions/{id} | events:read | GetSubscription |
|
||||
| POST /v1/subscriptions/{id}:configure | events:manage | ConfigureSubscription |
|
||||
| GET /v1/subscriptions/{id}/deliveries | events:read | ListEventDeliveries |
|
||||
| GET /v1/subscriptions/{id}/deliveries/{delivery_id} | events:read | GetEventDelivery |
|
||||
| POST /v1/subscriptions/{id}/deliveries/{delivery_id}:retry | events:manage | RetryEventDelivery |
|
||||
| GET /v1/events/connect(WebSocket Upgrade) | events:consume | 连接 SDK 待交付,可按下文协议接入 |
|
||||
|
||||
HTTP 使用统一 request_id + data/error 信封,响应带 X-Request-ID 和 Cache-Control: no-store。管理请求 8 秒服务端超时、JSON 最大 512 KiB。未知字段、大小写别名、重复 JSON 键、未知或重复查询参数均被拒绝。计数/版本以规范十进制字符串传输,不能带正号、前导零或小数。
|
||||
|
||||
除两个列表接口外,不接受查询参数。没有公开硬删除、修改过滤、修改历史起点或替换所属凭据的接口。
|
||||
|
||||
## 2. 创建订阅
|
||||
|
||||
POST /v1/subscriptions,Content-Type: application/json,Idempotency-Key: 调用方持久化的操作身份。
|
||||
|
||||
| 字段 | 要求 |
|
||||
| --- | --- |
|
||||
| name | 必填,1–128 个 ASCII 字符,首字符字母/数字,其余允许字母/数字/点/下划线/连字符;命名空间内唯一 |
|
||||
| queue_id | 必填,32 位小写十六进制 ID;固定为该队列 |
|
||||
| types | 可选数组,省略或 [] 表示四类全部;否则必须为四类的非重复子集 |
|
||||
| from_sequence | 可选 uint64 十进制字符串;省略或 null 从创建时已提交流头之后开始;"0" 从现存结果日志开始 |
|
||||
| max_pending | 可选整数,省略或 0 使用 100,自定义 1–1000 |
|
||||
| max_attempts | 可选整数,省略或 0 使用 20,自定义 1–100 |
|
||||
|
||||
四类结果为 task.succeeded、task.failed、task.cancelled、task.needs_review。安全重试的 task.requeued 不在此集合。needs_review 不是成功或确定失败;同一个任务以后可能产生另一条权威结果,必须按 event_id 去重,不能只按 task_id 去重。
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "object-results",
|
||||
"queue_id": "11111111111111111111111111111111",
|
||||
"types": ["task.succeeded", "task.failed", "task.cancelled", "task.needs_review"],
|
||||
"max_pending": 100,
|
||||
"max_attempts": 20
|
||||
}
|
||||
```
|
||||
|
||||
首次成功 HTTP 201;相同身份/内容重报 HTTP 200,deduplicated=true,返回原快照。请求尚未确认时必须保留原键、原请求和原 from_sequence,不能换键重新追到最新位置。
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "77777777777777777777777777777777",
|
||||
"data": {
|
||||
"subscription": {
|
||||
"id": "22222222222222222222222222222222",
|
||||
"queue_id": "11111111111111111111111111111111",
|
||||
"credential_id": "88888888888888888888888888888888",
|
||||
"name": "object-results",
|
||||
"revision": "1",
|
||||
"enabled": true,
|
||||
"types": ["task.cancelled", "task.failed", "task.needs_review", "task.succeeded"],
|
||||
"cursor_sequence": "12",
|
||||
"pending_count": 0,
|
||||
"max_pending": 100,
|
||||
"max_attempts": 20,
|
||||
"created_at": "2026-09-17T10:00:00Z",
|
||||
"updated_at": "2026-09-17T10:00:00Z"
|
||||
},
|
||||
"deduplicated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
类型集合按固定顺序规范化。cursor_sequence 表示已经扫描并建立交付记录的位置,**不是确认水位**。pending_count 包含等待、租约中和次数耗尽记录。配置 revision 不随物化/ACK 用量变化。
|
||||
|
||||
每命名空间最多 64 个订阅。from_sequence 不能超过已提交流头,也不能早于已清理的保留边界。现存日志从迁移 000013 启用,不补造此前的任务结果。
|
||||
|
||||
## 3. 查询和控制订阅
|
||||
|
||||
GET /v1/subscriptions/{id} 返回 data 为上节 subscription 对象,HTTP 200。幂等写回执是原状态;查询接口才读取当前配置和用量。
|
||||
|
||||
GET /v1/subscriptions 必须带 queue_id;可选 limit=1–100(省略 20)和 cursor。返回 data.items 为 subscription 数组,另有可选 next_cursor。列表只包含当前凭据在这个队列中的订阅,按 created_at DESC、id DESC 排序。游标不能换队列、凭据或命名空间使用。
|
||||
|
||||
POST /v1/subscriptions/{id}:configure 只改变 enabled,要求以下三个字段全部显式提供:
|
||||
|
||||
```json
|
||||
{
|
||||
"expected_revision": "1",
|
||||
"enabled": false,
|
||||
"reason": "receiver maintenance"
|
||||
}
|
||||
```
|
||||
|
||||
expected_revision 必须匹配当前配置版本,reason 为非空字符串、最多 1024 个 Unicode 字符。成功 HTTP 200,data 为 SubscriptionResult,即 `{subscription, deduplicated}`。恢复时设置 enabled=true,并使用新的操作身份与查询取得的版本。重复原命令必须保留第一次的版本,不能把响应的新版本放回原键。
|
||||
|
||||
暂停停止新物化/领取,保留已有结果、交付和租约;仍允许消费者确认已可靠保存的结果。暂停不意味着已发消息被撤回。相同布尔值的新命令仍作为新的带审计配置操作增加版本。
|
||||
|
||||
## 4. 交付历史、详情和人工重试
|
||||
|
||||
GET /v1/subscriptions/{id}/deliveries 支持 limit、cursor,以及可选 state:PENDING、LEASED、ACKED、EXPIRED。不带 state 返回全部已物化交付。返回 data 为 `{items: [...], next_cursor?: "..."}`,按创建时间及 ID 倒序分页。游标绑定订阅、凭据、命名空间与状态过滤,不能混用。
|
||||
|
||||
列表项 EventDelivery 的字段如下:
|
||||
|
||||
| 字段 | 类型与含义 |
|
||||
| --- | --- |
|
||||
| id / subscription_id / event_id | 32 位小写十六进制身份;同一事件向同一订阅的交付 ID 不因补发变化 |
|
||||
| sequence | 来源结果序号,uint64 字符串 |
|
||||
| revision | 交付并发版本,uint64 字符串;领取、恢复、确认和人工重试时变化 |
|
||||
| state | 四种交付状态 |
|
||||
| created_at / available_at | UTC 创建时间和最早可重发时间 |
|
||||
| lease_epoch | uint64 字符串,从未发送时为 "0",每次领取递增 |
|
||||
| lease_until | 可选 UTC 租约截止;仅 LEASED 存在 |
|
||||
| attempts / attempt_budget | 已用次数和累计预算,整数;人工重试不重置 attempts |
|
||||
| acknowledged_at | 可选 UTC 首次有效确认时间,仅 ACKED 存在 |
|
||||
|
||||
GET /v1/subscriptions/{id}/deliveries/{delivery_id} 返回 data 为 `{delivery: EventDelivery, event: ResultEvent}`。事件字段见下一节的 event.deliver 示例。该接口不返回 lease_secret 或 token_digest,也不允许跨订阅查询同一个交付 ID。
|
||||
|
||||
分页是运维查询,不是一致性快照或可靠消费协议。并发状态变化可能改变过滤结果,不能通过列表遍历代替交付确认。未物化结果尚无交付记录,不会出现在该列表。
|
||||
|
||||
POST /v1/subscriptions/{id}/deliveries/{delivery_id}:retry 只允许 EXPIRED,带稳定 Idempotency-Key:
|
||||
|
||||
```json
|
||||
{
|
||||
"expected_revision": "3",
|
||||
"reason": "receiver persistence failure repaired",
|
||||
"additional_attempts": 2
|
||||
}
|
||||
```
|
||||
|
||||
成功 HTTP 200,data 为 `{delivery: EventDelivery, deduplicated: false}`;交付变为 PENDING,追加 1–100 次预算,累计预算最多 1000000。已有次数、代次、事件身份和积压占用不归零。重报返回原回执,不再追加。
|
||||
|
||||
当前租约恢复由启用订阅的后续领取触发;没有消费者或订阅暂停时,租约可能在截止后仍显示 LEASED。查询本身不修改状态,人工重试也不能绕过状态要求。独立后台清扫、历史清理和凭据安全重绑仍待后续完成。
|
||||
|
||||
## 5. WebSocket 建连与边界
|
||||
|
||||
入口 /v1/events/connect,Sec-WebSocket-Protocol: orbit.events.v1,Authorization 使用请求头。拒绝 URL 参数及跨来源 Origin;不使用 Cookie。升级前先验证 events:consume,随后 hello 再校验具体订阅所有者和队列范围。读权限不能替代消费权限,消费权限也不隐含读取所有历史的权限。
|
||||
|
||||
每帧为一个文本 JSON,最大 512 KiB,禁用压缩。固定 protocol_version="1",message_id 是调用方生成的 32 位随机十六进制标识。服务端响应使用新的 message_id,以 reply_to 关联请求。message_id 只用于相关性;ACK 的持久化去重依据是交付 ID、代次和秘密。
|
||||
|
||||
连接后 5 秒内发送 hello,不能带 session_id、reply_to 或 error:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol_version": "1",
|
||||
"type": "events.hello",
|
||||
"message_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"body": {"subscription_id": "22222222222222222222222222222222", "capacity": 1}
|
||||
}
|
||||
```
|
||||
|
||||
capacity 必填整数 1–32,表示该连接允许持有的交付数量,在数据库事务内校验。首版连接期间不可修改,调整容量需要关闭并重新连接。订阅仍可以由同一凭据的多个连接共同消费,各连接竞争持久化交付。
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol_version": "1",
|
||||
"type": "events.welcome",
|
||||
"message_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"reply_to": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"session_id": "44444444444444444444444444444444",
|
||||
"body": {
|
||||
"session_id": "44444444444444444444444444444444",
|
||||
"subscription_id": "22222222222222222222222222222222",
|
||||
"capacity": 1,
|
||||
"lease_duration_ms": 30000,
|
||||
"heartbeat_interval_ms": 15000,
|
||||
"server_time": "2026-09-17T10:00:00Z",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
session_id 由服务端生成,仅标识当前连接。后续请求必须带这个值。断线后不恢复旧连接身份,也不删除租约;新的连接使用新 session_id,但可以用上一连接保留的最近证明核对 ACK。
|
||||
|
||||
节点最多 128 条事件连接。领取池全局最多 4 个在途、每应用最多 1 个;等待加在途最多 128,按认证应用及订阅轮转。每连接读队列 4、写队列 8,每个扫描轮次最多排入 4 次领取。慢 SQL 领取和控制收发分开;领取权及容量仍由 MySQL 决定。
|
||||
|
||||
这些是当前节点保护参数,不是生产吞吐承诺,也没有跨节点全局公平或应用连接席位预留。Worker 连接使用独立入口、协议与连接池。
|
||||
|
||||
## 6. 交付与可靠确认
|
||||
|
||||
服务端先持久化交付租约,再发送 event.deliver。以下 lease_secret 是全零字节编码的文档占位值,实际由服务端生成随机秘密:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol_version": "1",
|
||||
"type": "event.deliver",
|
||||
"message_id": "cccccccccccccccccccccccccccccccc",
|
||||
"session_id": "44444444444444444444444444444444",
|
||||
"body": {
|
||||
"delivery": {
|
||||
"created_at": "2026-09-17T10:00:00Z",
|
||||
"id": "33333333333333333333333333333333",
|
||||
"subscription_id": "22222222222222222222222222222222",
|
||||
"event_id": "55555555555555555555555555555555",
|
||||
"sequence": "13",
|
||||
"revision": "2",
|
||||
"state": "LEASED",
|
||||
"available_at": "2026-09-17T10:00:00Z",
|
||||
"lease_epoch": "1",
|
||||
"lease_until": "2026-09-17T10:00:30Z",
|
||||
"attempts": 1,
|
||||
"attempt_budget": 20
|
||||
},
|
||||
"event": {
|
||||
"id": "55555555555555555555555555555555",
|
||||
"sequence": "13",
|
||||
"type": "task.cancelled",
|
||||
"aggregate_kind": "task",
|
||||
"aggregate_id": "66666666666666666666666666666666",
|
||||
"aggregate_revision": "2",
|
||||
"occurred_at": "2026-09-17T10:00:00Z",
|
||||
"details": {"state": "CANCELLED", "reason": "CANCEL_REQUESTED"},
|
||||
"snapshot": {
|
||||
"schema_version": 1,
|
||||
"task_id": "66666666666666666666666666666666",
|
||||
"queue_id": "11111111111111111111111111111111",
|
||||
"state": "CANCELLED",
|
||||
"revision": "2",
|
||||
"effect_uncertain": false
|
||||
}
|
||||
},
|
||||
"lease_secret": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
snapshot 还可包含业务 result 和 current_attempt_id;result 用原始 JSON 文本保留数值精度。details 是来源事件的元信息,按事件类型演进;不含任务输入或交付秘密。结果快照不可变。
|
||||
|
||||
接收方应在本地事务中使用 event.id 作为 Inbox 唯一键,完成可靠保存或业务事务后才确认。不得先 ACK 再把消息仅放到内存队列。耗时业务可先可靠写入本地 Outbox,再由业务侧继续处理。平台首版不提供交付续租;30 秒租约用于尽快持久化接收结果。
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol_version": "1",
|
||||
"type": "event.ack",
|
||||
"message_id": "dddddddddddddddddddddddddddddddd",
|
||||
"session_id": "44444444444444444444444444444444",
|
||||
"body": {
|
||||
"delivery_id": "33333333333333333333333333333333",
|
||||
"epoch": "1",
|
||||
"lease_secret": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol_version": "1",
|
||||
"type": "ack",
|
||||
"message_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"reply_to": "dddddddddddddddddddddddddddddddd",
|
||||
"session_id": "44444444444444444444444444444444",
|
||||
"body": {
|
||||
"delivery_id": "33333333333333333333333333333333",
|
||||
"event_id": "55555555555555555555555555555555",
|
||||
"epoch": "1",
|
||||
"acknowledged_at": "2026-09-17T10:00:01Z",
|
||||
"deduplicated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
确认响应丢失时,保留原 delivery_id、epoch 和 lease_secret 重报;可以在新连接中使用新的 session_id 重报。已确认时返回原 acknowledged_at 和 deduplicated=true,不再次扣减积压或写重复确认审计。
|
||||
|
||||
租约到期后,最新证明若尚未被新代次替换,仍可迟到确认。新的发送产生后旧证明返回 DELIVERY_LEASE_LOST;接收方已保存的 Inbox 仍保留,下一次收到同一事件时检查已有记录,再用新交付证明 ACK。不能把旧证明替换为猜测的新代次。
|
||||
|
||||
交付是至少一次语义;连接关闭、写超时、ACK 响应丢失都可能导致重复。不得依靠本地内存“见过”集合宣称业务恰好一次。
|
||||
|
||||
## 7. 心跳、错误和关闭
|
||||
|
||||
客户端建议每 15 秒发送一次 events.ping,body 为 {};服务端以 ack 回复 `{server_time, enabled}`,同时重新校验凭据与订阅范围。收到结果也不能替代客户端心跳。连续 60 秒没有有效读取到客户端帧会关闭连接;写入超时为 5 秒。暂停中的订阅仍能连接、心跳和确认已有交付。
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol_version": "1",
|
||||
"type": "events.ping",
|
||||
"message_id": "ffffffffffffffffffffffffffffffff",
|
||||
"session_id": "44444444444444444444444444444444",
|
||||
"body": {}
|
||||
}
|
||||
```
|
||||
|
||||
错误帧使用 type="error" 与 error={code,message},无 body。请求错误带 reply_to;后台领取发现授权失效或其他错误时可以没有 reply_to。hello 绑定失败时没有 session_id,绑定后的错误带当前 session_id。非法信封、二进制消息或错误会话直接按 WebSocket 1008 关闭,超限消息由 WebSocket 关闭。
|
||||
|
||||
| 错误码 | HTTP 状态 / WSS 行为 |
|
||||
| --- | --- |
|
||||
| INVALID_ARGUMENT | 400;命令体无效时可返回 WSS error,非法信封直接关闭 |
|
||||
| UNAUTHENTICATED | 401;已连接时发送错误并关闭 |
|
||||
| PERMISSION_DENIED | 403;已连接时发送错误并关闭 |
|
||||
| NOT_FOUND | 404;订阅/交付不在凭据与父资源范围;连接内发生时关闭 |
|
||||
| STATE_CONFLICT | 409;版本、状态或累计预算不允许操作 |
|
||||
| IDEMPOTENCY_CONFLICT | 409;同键内容不同 |
|
||||
| SUBSCRIPTION_LIMIT | 409;命名空间订阅数达到 64 |
|
||||
| EVENT_HISTORY_EXPIRED | 410;WSS 领取发现历史缺口则报错并关闭,不静默跳到最新位置 |
|
||||
| DELIVERY_LEASE_LOST | WSS ACK 证明已替换或无效;连接保留,等待新交付或核对历史 |
|
||||
| PROTOCOL_REQUIRED | 升级前 426,必须指定 orbit.events.v1 |
|
||||
| CAPACITY_EXCEEDED | 升级前 429,Retry-After: 1 |
|
||||
| UNAVAILABLE | 503;WSS 中数据库等临时失败会报错并关闭,重连仍保留原交付/确认身份 |
|
||||
|
||||
断线/节点关闭不清除持久化交付。领取恢复会在预算内补发,或保留 EXPIRED。客户端关闭时应先完成自己的本地持久化、保存未核对 ACK 的证明,再关闭连接;平台不能保证物理撤回已经写入网络的消息。
|
||||
|
||||
## 8. 管理 SDK 与完整可运行示例
|
||||
|
||||
已实现的 Go 类型和方法:[类型](../../sdk/go/subscription_types.go)、[管理方法](../../sdk/go/subscriptions.go)。下面是应用函数内片段,client 已由 NewClient 创建,key 应先持久化:
|
||||
|
||||
```go
|
||||
created, err := client.CreateSubscription(ctx, key, orbit.CreateSubscriptionRequest{
|
||||
Name: "object-results",
|
||||
QueueID: queueID,
|
||||
MaxPending: 100,
|
||||
MaxAttempts: 20,
|
||||
})
|
||||
if err != nil { return err }
|
||||
current, err := client.GetSubscription(ctx, created.Subscription.ID)
|
||||
if err != nil { return err }
|
||||
_, err = client.ConfigureSubscription(ctx, current.ID, pauseKey, orbit.ConfigureSubscriptionRequest{
|
||||
ExpectedRevision: current.Revision,
|
||||
Enabled: false,
|
||||
Reason: "receiver maintenance",
|
||||
})
|
||||
return err
|
||||
```
|
||||
|
||||
[完整管理示例](../../sdk/go/examples/subscriptions/main.go) 的启动、输入输出、失败和保留规则见 [场景文档](../examples/subscriptions.md)。它演示管理与幂等,不启动结果消费者。WSS 连接 SDK、自动重连和持久化 Inbox 示例仍在下一阶段开发,不能把测试中的原始 WebSocket 辅助代码当成正式 SDK。
|
||||
|
||||
机器可读消息契约见 [events.schema.json](../../protocol/eventsv1/events.schema.json),使用 Draft 2020-12,clientFrame/serverFrame 分方向约束。运行时额外拒绝重复 JSON 键、超范围 uint64 及非规范 base64url。Schema 不代替授权、状态机、租约及事务校验。
|
||||
@@ -106,3 +106,12 @@
|
||||
| 暂停与恢复 | 暂停 → 接收在途结果 → 核查 → 继续 | 历史与预算保留,已成功工作不重复 | 暂停/开始竞争、恢复时已过期 | P5 待开发 |
|
||||
|
||||
每个待开发场景最终都需提供独立目录、完整源码、配置模板、启动与验证命令,不能只留下概念流程图。配套回归会先使用可控的模拟处理器或外部服务,不对真实系统产生副作用。
|
||||
|
||||
## 已可运行:订阅管理与幂等回执
|
||||
|
||||
- 源码:[订阅管理示例](../../sdk/go/examples/subscriptions/main.go),[启动、输入输出和异常说明](subscriptions.md)。
|
||||
- 前置:当前迁移及服务,具备 events:manage/read/consume 的专用开发凭据;初始化显式 --event-access。
|
||||
- 步骤:创建 → 同键核对 → 暂停 → 恢复 → 查询与分页 → 最终暂停。
|
||||
- 预期:duplicate=true、resumed_revision=3、final_enabled=false、revision=4、pending=0。
|
||||
- 保留:每次运行留下一个已暂停订阅及审计;没有硬删除,受64个订阅上限约束。
|
||||
- 范围:本例只演示管理SDK;自动消费、断线重连与持久化Inbox例子继续开发。
|
||||
|
||||
49
docs/examples/subscriptions.md
Normal file
49
docs/examples/subscriptions.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 订阅管理与幂等回执示例
|
||||
|
||||
状态:已提供 [完整可运行源码](../../sdk/go/examples/subscriptions/main.go)。演示 HTTP 管理 SDK;完整结果消费、重连及持久化 Inbox 示例继续开发。协议与字段见 [订阅接口](../api/subscriptions.md)。
|
||||
|
||||
## 前置和运行
|
||||
|
||||
需要 Go 1.27.1、已迁移到当前版本的 MySQL 8.4+ 和 Orbit 服务。使用新建专用开发命名空间,按环境注入 ORBIT_MYSQL_DSN;初始化显式开启事件权限:
|
||||
|
||||
```sh
|
||||
go run ./cmd/orbit-migrate up
|
||||
mkdir -p .local
|
||||
chmod 700 .local
|
||||
go run ./cmd/orbit-admin bootstrap \
|
||||
--application subscription-example --namespace default --environment development \
|
||||
--queue results --event-access --output .local/subscriptions.credentials.json
|
||||
go run ./cmd/orbit
|
||||
```
|
||||
|
||||
另一个终端从仓库根目录执行:
|
||||
|
||||
```sh
|
||||
cd sdk/go
|
||||
ORBIT_URL=http://127.0.0.1:8080 \
|
||||
ORBIT_CREDENTIALS_FILE=../../.local/subscriptions.credentials.json \
|
||||
go run ./examples/subscriptions
|
||||
```
|
||||
|
||||
也可设置 ORBIT_TOKEN 和 ORBIT_QUEUE_ID,环境值优先于凭据文件。生产接入使用 HTTPS;不要提交凭据文件或在命令行打印秘密。
|
||||
|
||||
## 调用链和输出
|
||||
|
||||
创建四类结果订阅 → 同键同内容再次创建 → 查询并按版本暂停 → 恢复 → 检查当前配置及队列订阅列表 → 最后暂停本次订阅。
|
||||
|
||||
```text
|
||||
subscription=<本次订阅ID> duplicate=true resumed_revision=3
|
||||
final_enabled=false revision=4 pending=0
|
||||
```
|
||||
|
||||
示例不会创建或执行业务任务,也没有消费者领取,因此不物化交付记录。结果日志仍由正常任务事务生成;此例验证管理调用、显式布尔控制和原幂等回执。
|
||||
|
||||
## 异常、重报与保留
|
||||
|
||||
- 缺少事件动作返回 PERMISSION_DENIED;已有普通凭据不会因新版本自动取得权限,应由有授权的管理流程签发所需权限。
|
||||
- 名称/幂等内容冲突、预期版本过期、订阅数达到 64 均返回明确错误,SDK 不自动换键或覆盖版本。
|
||||
- 网络超时不能证明没有提交。实际应用必须先持久化操作身份及原请求;按相同键和原版本核对。示例中的随机身份用于隔离每次演示,不是生产操作日志实现。
|
||||
- 每次运行留下一个已暂停的独立订阅及审计;没有硬删除。重复运行会占用命名空间的订阅数量,应在专用可重建演示环境执行,不手工删除生产记录来绕过上限。
|
||||
- 示例输出不包含凭据或交付秘密;异常非零退出。
|
||||
|
||||
make integration 会在一次性数据库、真实 Orbit 进程中执行这个例子。管理 SDK 集成还验证实际 30 秒交付租约到期后查询 EXPIRED、带版本追加预算、重新交付和确认;其中消息收发是测试辅助代码,正式消费 SDK 另行交付。
|
||||
82
docs/reports/phase-3-result-subscription-transport.md
Normal file
82
docs/reports/phase-3-result-subscription-transport.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# P3 结果订阅通信与管理 SDK 回归
|
||||
|
||||
验证日期:2026-09-17。已接通 [订阅 HTTP/WSS](../api/subscriptions.md)、独立管理 SDK 和 [管理示例](../examples/subscriptions.md)。消费连接 SDK、自动重连、持久化 Inbox 场景以及订阅真实进程强杀仍未完成,P3 和持续目标继续进行。
|
||||
|
||||
## 1. 本阶段交付
|
||||
|
||||
- [HTTP 路由](../../internal/transport/httpserver/events.go):订阅创建、队列范围分页、当前状态、带版本启停;交付分页、冻结结果详情、EXPIRED 追加预算。
|
||||
- [查询存储](../../internal/storage/mysql/event_queries.go):按认证所有者、命名空间和父资源范围读取,分页游标绑定类别、过滤和凭据。交付详情不暴露证明原文或摘要。
|
||||
- [迁移 000014](../../internal/storage/mysql/migrations/000014_delivery_history.up.sql):为交付创建时间及可选状态过滤添加索引,不改业务字段、已有迁移或外键规则。模型仍为 35 个实体、289 个带中文注释的持久化字段。
|
||||
- [事件 Hub](../../internal/transport/httpserver/event_hub.go):独立 orbit.events.v1 入口,凭据、订阅和队列授权;hello、冻结快照交付、ACK、心跳、会话校验、断线恢复和有界关闭。
|
||||
- 每节点 128 条事件连接;异步领取池 4 个全局在途、同应用 1 个在途、等待加在途总量 128,按认证应用及订阅轮转。控制收发不被同连接的慢领取阻塞。连接容量仍由持久化租约校验。
|
||||
- [严格解码](../../internal/transport/httpserver/event_codec.go) 与 [公开 Schema](../../protocol/eventsv1/events.schema.json):方向、必填、字段精确大小写、规范 uint64、证明格式和消息大小;JSON 重复键、伪造响应字段或错误会话被拒绝。
|
||||
- [管理 SDK](../../sdk/go/subscriptions.go) 与 [独立 DTO](../../sdk/go/subscription_types.go):七个管理/查询方法及类型,保留大整数和显式布尔控制,不依赖服务端或数据库包。
|
||||
- [完整管理例子](../../sdk/go/examples/subscriptions/main.go) 已纳入统一隔离环境脚本。原始 WSS 测试辅助代码不作为正式消费 SDK 对外提供。
|
||||
|
||||
## 2. 验证环境与结果
|
||||
|
||||
macOS、Go 1.27.1、Docker MySQL 8.4.10、一次性数据库、两个 httptest TLS 节点和真实 Orbit 进程。回归随机凭据不打印,脚本结束清理自己的容器。
|
||||
|
||||
```sh
|
||||
make fmt test vet build
|
||||
make integration
|
||||
cd sdk/go
|
||||
GOWORK=off go test -race ./...
|
||||
```
|
||||
|
||||
| 分组 | 本次结果 |
|
||||
| --- | --- |
|
||||
| 全量 MySQL 集成 | PASS,90.688 秒 |
|
||||
| 新 HTTP/WSS 七组场景 | PASS,8.42 秒 |
|
||||
| 原订阅核心 / 提交顺序 | PASS,3.41 / 0.59 秒 |
|
||||
| SDK Worker 协议和运行器 | PASS,87.434 秒 |
|
||||
| SDK 计划生命周期 | PASS,5.645 秒 |
|
||||
| SDK 队列 / 应用额度 | PASS,4.526 / 1.403 秒 |
|
||||
| 新订阅管理 SDK | PASS,32.16 秒,包总计 33.391 秒;包含实际 30 秒租约 |
|
||||
| 既有七组真实进程故障 | PASS,27.534 秒 |
|
||||
| 完整 SDK 示例 | 13 个全部通过 |
|
||||
| fmt / race / vet / build / 独立 SDK 模块 | 全部通过 |
|
||||
|
||||
完整套件后补充交付正向翻页、跨订阅/命名空间详情拒绝及 Schema uint64 上界一致性检查;对应专项数据库与传输回归再次通过,28.651 秒。文档六个完整 WSS 消息也通过公开 Schema 校验。普通不带外部环境的 go test 会跳过集成,不能替代这些证据。
|
||||
|
||||
## 3. HTTP 与 WSS 实际验证
|
||||
|
||||
[真实数据库与 TLS 测试](../../internal/storage/mysql/event_transport_integration_test.go) 覆盖:
|
||||
|
||||
1. HTTP 创建/原键回执、显式 enabled、暂停继续、规范计数字符串、非法/重复查询参数;订阅与交付的稳定分页,游标不能换过滤范围。
|
||||
2. 交付详情只能从正确的命名空间、所属凭据与订阅读取。JSON 不包含 lease_secret 或 token_digest;人工追加预算保留原身份、次数与幂等回执。
|
||||
3. 握手拒绝缺失凭据、仅有 Worker 权限、错误子协议、URL 参数与跨来源 Origin。事件协议与 Worker 协议隔离。
|
||||
4. WSS 交付接近 240 KiB 的结果,包含 9007199254740993 和大量 `<&>`,接收内容保持精确,帧仍在 512 KiB 以内。所有真实收发帧都通过方向对应的 Schema。
|
||||
5. 容量 1 时即使存在第二条结果也只有一个有效交付租约;心跳正常,确认释放容量后继续派发。重复 ACK 使用原确认时间、无重复扣减。
|
||||
6. 节点 A 断线后节点 B 恢复同一交付及事件,产生更大的代次和新秘密,拒绝旧证明。这里通过测试数据库提前设置到期验证路径;自然到期由独立 SDK 测试覆盖。
|
||||
7. 消费者发送 ACK 后,查询数据库确认已提交,再丢弃未读取的响应并关闭连接。新节点使用原证明重报返回 deduplicated=true、原时间和唯一确认审计。
|
||||
8. 已建立连接中的凭据撤销生效;错误 session_id 按 1008 关闭。关闭 Hub 保留已落库租约,拒绝新连接,另一节点仍能核对原证明。
|
||||
|
||||
[节点保护测试](../../internal/transport/httpserver/event_hub_test.go) 人为挂起领取直到 context 取消。在领取仍阻塞时,控制心跳在有界时间内回复;连接满额返回 429 和 Retry-After,断开后释放槽位,关闭时取消在途领取并清空槽位。测试将连接容量缩为 2 以减少资源,另外断言默认值为 128。
|
||||
|
||||
[解码与文档契约测试](../../internal/transport/httpserver/event_codec_test.go) 验证请求不能携带 reply_to/error/错误会话字段,拒绝缺失显式控制意图、重复键和别名。Schema 与实际解码都接受 uint64 最大值并拒绝溢出、零执行代次和前导零;HTTP 起始游标另外允许 "0"、null 或省略。
|
||||
|
||||
## 4. 独立 SDK 与示例证据
|
||||
|
||||
[SDK 管理集成](../../sdk/go/subscriptions_integration_test.go) 在真实进程上创建独立队列及订阅,执行 Create/Get/List/Configure;产生取消结果后通过测试 WSS 辅助连接领取一次并断开。另一个连接保持可领取状态,测试等待真实 30 秒租约到期,没有改数据库时间。
|
||||
|
||||
随后 SDK 通过 ListEventDeliveries 查到 EXPIRED,GetEventDelivery 读到冻结结果和已用次数;RetryEventDelivery 带版本追加一次预算,同键重报不再追加。下一次 WSS 交付保留事件和交付 ID、代次为 2,确认后 SDK 查到 ACKED、pending_count=0,配置 revision 未被消费计数污染。
|
||||
|
||||
[SDK 线格式测试](../../sdk/go/subscriptions_test.go) 核对显式 from_sequence=0 编码为字符串而不丢失,enabled=false 不被省略,9007199254740993 版本往返精确,非法路径和超限分页不发起网络请求。
|
||||
|
||||
管理示例实际输出(ID 每次不同):
|
||||
|
||||
```text
|
||||
subscription=<本次订阅ID> duplicate=true resumed_revision=3
|
||||
final_enabled=false revision=4 pending=0
|
||||
```
|
||||
|
||||
13 个完整示例为 health、tasks、worker-protocol、worker、history、retry、schedules、cancellation、resources、keyed-concurrency、priority、subscriptions、application-resources。原七项进程故障覆盖的是任务/Worker/计划/额度/优先级等既有能力,不能当作新订阅已完成进程强杀验收。
|
||||
|
||||
## 5. 升级与尚未验证事项
|
||||
|
||||
- 真实 v7 升级继续保留 325 项原索引列定义,70 个历史外键最终为 0,错误手工外键仍被就绪检查拒绝。新交付分页索引作为新增项处理;v4、v9、v12 升级与模型映射回归通过。
|
||||
- 完整事件连接 SDK、自动心跳/重连、持久化 Inbox 示例与真实节点进程强杀,仍是 P3 剩余工作。此阶段不把管理 SDK 或测试 WSS 辅助代码算作已经交付消费 SDK。
|
||||
- 当前按启用订阅领取触发租约恢复;独立后台清扫、历史保留/清理、容量告警和安全凭据重新绑定未交付。
|
||||
- 消费至少一次;业务效果需要接收方以事件 ID 去重并事务保存,再 ACK。连接关闭不能物理撤回已发送内容,也不提供业务恰好一次保证。
|
||||
- 当前连接/领取上限是节点保护值,没有证明百万接入规模的吞吐、跨节点公平、全套请求治理或生产高可用。MySQL 故障切换、备份恢复、RPO/RTO 与目标容量仍需 P6 实测。
|
||||
@@ -1,6 +1,6 @@
|
||||
# Go SDK 接入说明
|
||||
|
||||
已实现健康查询、任务提交/查询/分页/待执行取消、队列创建/查询、[自动 Worker](worker-runtime.md)、[Worker 底层协议 SDK](worker.md) 和 [执行历史查询](../api/execution-history.md)。[安全重试](../api/retries.md) 已提供有限策略、WorkerFailure.Retryable、历史查询及完整例子。[计划调度](../api/schedules.md) 已支持创建、查询、版本发布、暂停/恢复、触发历史与完整示例。流程构建等后续接口按 [开发计划](../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 继续开发。流程构建等后续接口按 [开发计划](../08-development-plan.md) 继续实现。
|
||||
|
||||
module:`git.yuntuo.hk/k2safe/orbit/sdk/go`。SDK 使用独立 go.mod,依赖 Go 标准库与 `github.com/coder/websocket v1.8.15`,不引用服务端模型或数据库实现。
|
||||
|
||||
@@ -105,6 +105,9 @@ fmt.Println(cancelled.Task.State)
|
||||
|
||||
| 方法 | 请求 / 返回 | 文档 |
|
||||
| --- | --- | --- |
|
||||
| CreateSubscription / GetSubscription / ListSubscriptions | 订阅受理、当前配置与队列分页 | [订阅](../api/subscriptions.md) |
|
||||
| ConfigureSubscription | 显式暂停/继续与版本、原因 | [订阅](../api/subscriptions.md) |
|
||||
| ListEventDeliveries / GetEventDelivery / RetryEventDelivery | 交付历史、冻结结果和耗尽后追加预算 | [订阅](../api/subscriptions.md) |
|
||||
| CreateSchedule / GetSchedule / ListSchedules | 计划受理、详情与队列分页 | [计划](../api/schedules.md) |
|
||||
| PublishSchedule / GetScheduleVersion | 不可变发布与历史版本 | [计划](../api/schedules.md) |
|
||||
| PauseSchedule / ResumeSchedule / ListScheduleOccurrences | 控制与触发记录 | [计划](../api/schedules.md) |
|
||||
@@ -180,3 +183,7 @@ task=<任务ID> state=SUCCEEDED attempts=1 evidence=1 reason=RESULT_CONFLICT dis
|
||||
## 7. 后续交付
|
||||
|
||||
P2 已提供底层协议、[自动 Worker 运行器及示例](worker-runtime.md) 与历史查询,并通过 [Schema 及进程故障阶段验收](../reports/phase-2.md);P3 已实现 [安全重试](../api/retries.md)、[计划调度](../api/schedules.md)、[运行中取消](../api/cancellation.md) 、[队列资源控制及示例](../api/queues.md) 和 [应用共享配额及示例](../api/application-resources.md),[资源键互斥和单调序号示例](../api/keyed-concurrency.md) 已实现,[优先级场景和节点公平派发](../api/priority-and-fairness.md) 已实现,结果订阅继续开发;P4–P5 提供流程构建发布、信号、等待、轮询与人工恢复。每项随实现同步交付可运行示例及异常回归。
|
||||
|
||||
## 订阅管理示例
|
||||
|
||||
[完整运行方法](../examples/subscriptions.md) 使用显式 --event-access 凭据,执行创建/同键核对/暂停/恢复/查询,再暂停本次订阅并保留审计。源码见 [examples/subscriptions](../../sdk/go/examples/subscriptions/main.go)。WSS 连接 SDK、自动消费与持久化 Inbox 示例尚未交付,不能将此管理示例当作完整可靠消费链路。
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
|
||||
f.exec(`DROP TABLE orbit_application_resources`)
|
||||
f.exec(`DROP TABLE orbit_result_events,orbit_event_streams`)
|
||||
f.exec(`ALTER TABLE orbit_subscriptions DROP CHECK ck_subscription_window, DROP COLUMN queue_id, DROP COLUMN cursor_sequence, DROP COLUMN pending_count, DROP COLUMN max_pending, DROP COLUMN max_attempts`)
|
||||
f.exec(`ALTER TABLE orbit_deliveries DROP INDEX ix_delivery_subscription_due, DROP INDEX ix_delivery_subscription_lease, DROP CHECK ck_delivery_budget, DROP COLUMN attempt_budget, DROP COLUMN token_digest`)
|
||||
f.exec(`ALTER TABLE orbit_deliveries DROP INDEX ix_delivery_history, DROP INDEX ix_delivery_state_history, DROP INDEX ix_delivery_subscription_due, DROP INDEX ix_delivery_subscription_lease, DROP CHECK ck_delivery_budget, DROP COLUMN attempt_budget, DROP COLUMN token_digest`)
|
||||
f.exec(`ALTER TABLE pulse_tasks DROP INDEX ix_task_priority_dispatch, DROP CHECK ck_task_priority, DROP COLUMN dispatch_at`)
|
||||
f.exec(`ALTER TABLE pulse_tasks DROP CHECK ck_task_concurrency_key, DROP COLUMN concurrency_key, DROP COLUMN concurrency_key_digest`)
|
||||
f.exec(`ALTER TABLE pulse_attempts DROP COLUMN concurrency_token`)
|
||||
|
||||
@@ -98,12 +98,12 @@ type storedDelivery struct {
|
||||
|
||||
func readDelivery(ctx context.Context, tx *sql.Tx, ns, sub, id core.ID, lock bool) (storedDelivery, error) {
|
||||
var out storedDelivery
|
||||
q := `SELECT id,subscription_id,event_id,revision,state,available_at,lease_epoch,lease_until,attempts,attempt_budget,acknowledged_at,token_digest FROM orbit_deliveries WHERE namespace_id=? AND subscription_id=? AND id=?`
|
||||
q := `SELECT id,subscription_id,event_id,revision,state,available_at,lease_epoch,lease_until,attempts,attempt_budget,acknowledged_at,token_digest,created_at FROM orbit_deliveries WHERE namespace_id=? AND subscription_id=? AND id=?`
|
||||
if lock {
|
||||
q += ` FOR UPDATE`
|
||||
}
|
||||
v := &out.view
|
||||
err := tx.QueryRowContext(ctx, q, ns, sub, id).Scan(&v.ID, &v.SubscriptionID, &v.EventID, &v.Revision, &v.State, &v.AvailableAt, &v.LeaseEpoch, &v.LeaseUntil, &v.Attempts, &v.AttemptBudget, &v.AcknowledgedAt, &out.digest)
|
||||
err := tx.QueryRowContext(ctx, q, ns, sub, id).Scan(&v.ID, &v.SubscriptionID, &v.EventID, &v.Revision, &v.State, &v.AvailableAt, &v.LeaseEpoch, &v.LeaseUntil, &v.Attempts, &v.AttemptBudget, &v.AcknowledgedAt, &out.digest, &v.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return out, fault.NotFound
|
||||
}
|
||||
|
||||
179
internal/storage/mysql/event_queries.go
Normal file
179
internal/storage/mysql/event_queries.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||
platform "git.yuntuo.hk/k2safe/orbit/platform/model"
|
||||
)
|
||||
|
||||
type eventCursor struct {
|
||||
Version int `json:"v"`
|
||||
Kind string `json:"k"`
|
||||
Namespace core.ID `json:"n"`
|
||||
Credential core.ID `json:"c"`
|
||||
Parent core.ID `json:"p"`
|
||||
State platform.DeliveryState `json:"s"`
|
||||
At time.Time `json:"t"`
|
||||
ID core.ID `json:"i"`
|
||||
}
|
||||
|
||||
func parseEventCursor(raw string, t auth.Token, parent core.ID, kind string, state platform.DeliveryState) (eventCursor, error) {
|
||||
var c eventCursor
|
||||
b, err := base64.RawURLEncoding.Strict().DecodeString(raw)
|
||||
if err != nil || len(b) > 1024 {
|
||||
return c, fault.Invalid("invalid event cursor")
|
||||
}
|
||||
if _, err = jsonvalue.Canonical(b); err != nil {
|
||||
return c, fault.Invalid("invalid event cursor")
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if json.Unmarshal(b, &fields) != nil || len(fields) != 8 {
|
||||
return c, fault.Invalid("invalid event cursor")
|
||||
}
|
||||
for _, k := range []string{"v", "k", "n", "c", "p", "s", "t", "i"} {
|
||||
if _, ok := fields[k]; !ok {
|
||||
return c, fault.Invalid("invalid event cursor")
|
||||
}
|
||||
}
|
||||
d := json.NewDecoder(bytes.NewReader(b))
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(&c) != nil || c.Version != 1 || c.Kind != kind || c.Namespace != t.NamespaceID || c.Credential != t.CredentialID || c.Parent != parent || c.State != state || c.ID.Validate() != nil || c.At.Year() < 2000 || c.At.Year() > 9998 || c.At.Nanosecond()%1000 != 0 {
|
||||
return c, fault.Invalid("cursor does not match event query")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
func makeEventCursor(t auth.Token, parent core.ID, kind string, state platform.DeliveryState, at time.Time, id core.ID) (string, error) {
|
||||
b, err := json.Marshal(eventCursor{1, kind, t.NamespaceID, t.CredentialID, parent, state, at, id})
|
||||
return base64.RawURLEncoding.EncodeToString(b), err
|
||||
}
|
||||
func (s *EventStore) AuthorizeEvents(ctx context.Context, t auth.Token) error {
|
||||
return s.transaction(ctx, t, "events:consume", func(*sql.Tx, auth.Permissions) error { return nil })
|
||||
}
|
||||
func (s *EventStore) BindConsumer(ctx context.Context, t auth.Token, id core.ID) (events.ConsumerBinding, error) {
|
||||
var out events.ConsumerBinding
|
||||
err := s.transaction(ctx, t, "events:consume", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
sub, err := readSubscription(ctx, tx, t, p, id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.SubscriptionID = sub.ID
|
||||
out.QueueID = sub.QueueID
|
||||
out.Enabled = sub.Enabled
|
||||
return tx.QueryRowContext(ctx, `SELECT application_id,UTC_TIMESTAMP(6) FROM orbit_namespaces WHERE id=?`, t.NamespaceID).Scan(&out.ApplicationID, &out.ServerTime)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *EventStore) ListSubscriptions(ctx context.Context, t auth.Token, queue core.ID, in events.PageInput) (events.SubscriptionPage, error) {
|
||||
out := events.SubscriptionPage{Items: []events.Subscription{}}
|
||||
err := s.transaction(ctx, t, "events:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if !p.AllowsQueue(queue) {
|
||||
return fault.Forbidden
|
||||
}
|
||||
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND credential_id=? AND queue_id=?`
|
||||
args := []any{t.NamespaceID, t.CredentialID, queue}
|
||||
if in.Cursor != "" {
|
||||
c, err := parseEventCursor(in.Cursor, t, queue, "subscriptions", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q += ` AND (created_at<? OR (created_at=? AND id<?))`
|
||||
args = append(args, c.At, c.At, c.ID)
|
||||
}
|
||||
q += ` ORDER BY created_at DESC,id DESC LIMIT ?`
|
||||
args = append(args, in.Limit+1)
|
||||
rows, err := tx.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
v, err := scanSubscription(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Items = append(out.Items, v)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(out.Items) > in.Limit {
|
||||
out.Items = out.Items[:in.Limit]
|
||||
last := out.Items[len(out.Items)-1]
|
||||
out.NextCursor, err = makeEventCursor(t, queue, "subscriptions", "", last.CreatedAt, last.ID)
|
||||
}
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *EventStore) ListDeliveries(ctx context.Context, t auth.Token, id core.ID, in events.PageInput) (events.DeliveryPage, error) {
|
||||
out := events.DeliveryPage{Items: []events.Delivery{}}
|
||||
err := s.transaction(ctx, t, "events:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if _, err := readSubscription(ctx, tx, t, p, id, false); err != nil {
|
||||
return err
|
||||
}
|
||||
q := `SELECT d.id,d.subscription_id,d.event_id,r.sequence,d.revision,d.state,d.available_at,d.lease_epoch,d.lease_until,d.attempts,d.attempt_budget,d.acknowledged_at,d.created_at FROM orbit_deliveries d LEFT JOIN orbit_result_events r ON r.namespace_id=d.namespace_id AND r.id=d.event_id WHERE d.namespace_id=? AND d.subscription_id=?`
|
||||
args := []any{t.NamespaceID, id}
|
||||
if in.State != "" {
|
||||
q += ` AND d.state=?`
|
||||
args = append(args, in.State)
|
||||
}
|
||||
if in.Cursor != "" {
|
||||
c, err := parseEventCursor(in.Cursor, t, id, "deliveries", in.State)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q += ` AND (d.created_at<? OR (d.created_at=? AND d.id<?))`
|
||||
args = append(args, c.At, c.At, c.ID)
|
||||
}
|
||||
q += ` ORDER BY d.created_at DESC,d.id DESC LIMIT ?`
|
||||
args = append(args, in.Limit+1)
|
||||
rows, err := tx.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var v events.Delivery
|
||||
if err = rows.Scan(&v.ID, &v.SubscriptionID, &v.EventID, &v.Sequence, &v.Revision, &v.State, &v.AvailableAt, &v.LeaseEpoch, &v.LeaseUntil, &v.Attempts, &v.AttemptBudget, &v.AcknowledgedAt, &v.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Items = append(out.Items, v)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(out.Items) > in.Limit {
|
||||
out.Items = out.Items[:in.Limit]
|
||||
last := out.Items[len(out.Items)-1]
|
||||
out.NextCursor, err = makeEventCursor(t, id, "deliveries", in.State, last.CreatedAt, last.ID)
|
||||
}
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *EventStore) GetDeliveryDetail(ctx context.Context, t auth.Token, sub, id core.ID) (events.DeliveryDetail, error) {
|
||||
var out events.DeliveryDetail
|
||||
err := s.transaction(ctx, t, "events:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if _, err := readSubscription(ctx, tx, t, p, sub, false); err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := readDelivery(ctx, tx, t.NamespaceID, sub, id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Delivery = d.view
|
||||
out.Event, err = readResultEvent(ctx, tx, t.NamespaceID, d.view.EventID)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
@@ -27,13 +27,11 @@ var _ events.Store = (*EventStore)(nil)
|
||||
const subscriptionColumns = `id,queue_id,credential_id,name,revision,enabled,filter,cursor_sequence,pending_count,max_pending,max_attempts,created_at,updated_at`
|
||||
|
||||
func readSubscription(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Permissions, id core.ID, lock bool) (events.Subscription, error) {
|
||||
var out events.Subscription
|
||||
var raw []byte
|
||||
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND id=? AND credential_id=? AND queue_id IS NOT NULL`
|
||||
if lock {
|
||||
q += ` FOR UPDATE`
|
||||
}
|
||||
err := tx.QueryRowContext(ctx, q, t.NamespaceID, id, t.CredentialID).Scan(&out.ID, &out.QueueID, &out.CredentialID, &out.Name, &out.Revision, &out.Enabled, &raw, &out.CursorSequence, &out.PendingCount, &out.MaxPending, &out.MaxAttempts, &out.CreatedAt, &out.UpdatedAt)
|
||||
out, err := scanSubscription(tx.QueryRowContext(ctx, q, t.NamespaceID, id, t.CredentialID))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return out, fault.NotFound
|
||||
}
|
||||
@@ -43,10 +41,17 @@ func readSubscription(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Perm
|
||||
if !p.AllowsQueue(out.QueueID) {
|
||||
return events.Subscription{}, fault.NotFound
|
||||
}
|
||||
if err = json.Unmarshal(raw, &out.Types); err != nil {
|
||||
return out, nil
|
||||
}
|
||||
func scanSubscription(row scanner) (events.Subscription, error) {
|
||||
var out events.Subscription
|
||||
var raw []byte
|
||||
err := row.Scan(&out.ID, &out.QueueID, &out.CredentialID, &out.Name, &out.Revision, &out.Enabled, &raw, &out.CursorSequence, &out.PendingCount, &out.MaxPending, &out.MaxAttempts, &out.CreatedAt, &out.UpdatedAt)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
err = json.Unmarshal(raw, &out.Types)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *EventStore) CreateSubscription(ctx context.Context, t auth.Token, key string, in events.CreateInput) (events.SubscriptionResult, error) {
|
||||
|
||||
502
internal/storage/mysql/event_transport_integration_test.go
Normal file
502
internal/storage/mysql/event_transport_integration_test.go
Normal file
@@ -0,0 +1,502 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/testcontract"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||
platform "git.yuntuo.hk/k2safe/orbit/platform/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
func startEventServer(t *testing.T, ctx context.Context, db *sql.DB) (*httptest.Server, *httpserver.EventHub) {
|
||||
t.Helper()
|
||||
svc := events.New(NewEventStore(db))
|
||||
hub := httpserver.NewEventHub(ctx, svc)
|
||||
server := httpserver.New("", func(context.Context) error { return nil }, service.New(NewTaskStore(db)))
|
||||
httpserver.RegisterEventSubscriptions(server, svc)
|
||||
httpserver.RegisterEventConsumers(server, hub)
|
||||
tls := httptest.NewTLSServer(server.Handler)
|
||||
t.Cleanup(func() {
|
||||
bounded, stop := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer stop()
|
||||
if err := hub.Shutdown(bounded); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
tls.Close()
|
||||
})
|
||||
return tls, hub
|
||||
}
|
||||
func eventHTTP(t *testing.T, ctx context.Context, server *httptest.Server, method, path, token, key string, body any, status int) json.RawMessage {
|
||||
t.Helper()
|
||||
var raw []byte
|
||||
var err error
|
||||
switch b := body.(type) {
|
||||
case nil:
|
||||
case string:
|
||||
raw = []byte(b)
|
||||
default:
|
||||
raw, err = jsonwire.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, server.URL+path, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if key != "" {
|
||||
req.Header.Set("Idempotency-Key", key)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := server.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope struct {
|
||||
RequestID core.ID `json:"request_id"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Error *struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err = json.Unmarshal(data, &envelope); err != nil {
|
||||
t.Fatalf("invalid event HTTP response: status %d", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode != status {
|
||||
code := ""
|
||||
if envelope.Error != nil {
|
||||
code = envelope.Error.Code
|
||||
}
|
||||
t.Fatalf("expected HTTP %d, got %d/%s", status, resp.StatusCode, code)
|
||||
}
|
||||
if envelope.RequestID.Validate() != nil || resp.Header.Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("missing event API envelope metadata")
|
||||
}
|
||||
if status >= 400 {
|
||||
if envelope.Error == nil || len(envelope.Data) != 0 {
|
||||
t.Fatal("invalid event error response")
|
||||
}
|
||||
return data
|
||||
}
|
||||
if envelope.Error != nil {
|
||||
return nil
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
type testEventSocket struct {
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
conn *websocket.Conn
|
||||
session core.ID
|
||||
pending []testWorkerFrame
|
||||
}
|
||||
|
||||
func connectTestEvents(t *testing.T, ctx context.Context, server *httptest.Server, f *eventFixture, sub core.ID, capacity uint32) *testEventSocket {
|
||||
t.Helper()
|
||||
headers := http.Header{}
|
||||
headers.Set("Authorization", "Bearer "+f.resources.Token)
|
||||
conn, resp, err := websocket.Dial(ctx, strings.Replace(server.URL, "https://", "wss://", 1)+"/v1/events/connect", &websocket.DialOptions{HTTPClient: server.Client(), HTTPHeader: headers, Subprotocols: []string{httpserver.EventSubprotocol}})
|
||||
if err != nil {
|
||||
status := 0
|
||||
if resp != nil {
|
||||
status = resp.StatusCode
|
||||
}
|
||||
t.Fatalf("event dial failed HTTP %d", status)
|
||||
}
|
||||
t.Cleanup(func() { conn.CloseNow() })
|
||||
conn.SetReadLimit(512 << 10)
|
||||
c := &testEventSocket{t: t, ctx: ctx, conn: conn}
|
||||
hello := c.send("events.hello", map[string]any{"subscription_id": sub, "capacity": capacity}, "")
|
||||
frame := c.read()
|
||||
if frame.Type != "events.welcome" || frame.ReplyTo != hello || frame.SessionID.Validate() != nil {
|
||||
t.Fatal("missing event welcome")
|
||||
}
|
||||
var welcome struct {
|
||||
SessionID core.ID `json:"session_id"`
|
||||
SubscriptionID core.ID `json:"subscription_id"`
|
||||
Capacity uint32 `json:"capacity"`
|
||||
LeaseDurationMS int `json:"lease_duration_ms"`
|
||||
ServerTime time.Time `json:"server_time"`
|
||||
}
|
||||
if json.Unmarshal(frame.Body, &welcome) != nil || welcome.SessionID != frame.SessionID || welcome.SubscriptionID != sub || welcome.Capacity != capacity || welcome.LeaseDurationMS != 30000 || welcome.ServerTime.IsZero() {
|
||||
t.Fatal("invalid welcome binding")
|
||||
}
|
||||
c.session = frame.SessionID
|
||||
return c
|
||||
}
|
||||
func (c *testEventSocket) send(kind string, body any, id core.ID) core.ID {
|
||||
c.t.Helper()
|
||||
if id == "" {
|
||||
id = executionID(c.t)
|
||||
}
|
||||
payload, err := jsonwire.Marshal(body)
|
||||
if err != nil {
|
||||
c.t.Fatal(err)
|
||||
}
|
||||
raw, err := jsonwire.Marshal(testWorkerFrame{Version: "1", Type: kind, MessageID: id, SessionID: c.session, Body: payload})
|
||||
if err != nil {
|
||||
c.t.Fatal(err)
|
||||
}
|
||||
testcontract.EventFrame(c.t, "client", raw)
|
||||
ctx, stop := context.WithTimeout(c.ctx, 5*time.Second)
|
||||
defer stop()
|
||||
if err = c.conn.Write(ctx, websocket.MessageText, raw); err != nil {
|
||||
c.t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
func (c *testEventSocket) read() testWorkerFrame {
|
||||
c.t.Helper()
|
||||
ctx, stop := context.WithTimeout(c.ctx, 5*time.Second)
|
||||
defer stop()
|
||||
kind, raw, err := c.conn.Read(ctx)
|
||||
if err != nil {
|
||||
c.t.Fatal(err)
|
||||
}
|
||||
testcontract.EventFrame(c.t, "server", raw)
|
||||
var frame testWorkerFrame
|
||||
if kind != websocket.MessageText || json.Unmarshal(raw, &frame) != nil {
|
||||
c.t.Fatal("invalid event frame")
|
||||
}
|
||||
if c.session != "" && frame.SessionID != c.session {
|
||||
c.t.Fatal("event response changed session")
|
||||
}
|
||||
return frame
|
||||
}
|
||||
func (c *testEventSocket) reply(id core.ID, code string) json.RawMessage {
|
||||
c.t.Helper()
|
||||
for {
|
||||
frame := c.read()
|
||||
if frame.Type == "event.deliver" {
|
||||
c.pending = append(c.pending, frame)
|
||||
continue
|
||||
}
|
||||
if frame.ReplyTo != id {
|
||||
c.t.Fatal("uncorrelated event reply")
|
||||
}
|
||||
if code != "" {
|
||||
if frame.Type != "error" || frame.Error == nil || frame.Error.Code != code {
|
||||
c.t.Fatal("unexpected event error")
|
||||
}
|
||||
} else if frame.Type != "ack" || frame.Error != nil {
|
||||
c.t.Fatal("expected event acknowledgement")
|
||||
}
|
||||
return frame.Body
|
||||
}
|
||||
}
|
||||
func (c *testEventSocket) grant() *events.Grant {
|
||||
c.t.Helper()
|
||||
var frame testWorkerFrame
|
||||
if len(c.pending) > 0 {
|
||||
frame = c.pending[0]
|
||||
c.pending = c.pending[1:]
|
||||
} else {
|
||||
frame = c.read()
|
||||
}
|
||||
if frame.Type != "event.deliver" {
|
||||
c.t.Fatalf("expected event delivery, got %s", frame.Type)
|
||||
}
|
||||
var g events.Grant
|
||||
if json.Unmarshal(frame.Body, &g) != nil || g.Delivery.EventID != g.Event.ID || g.Delivery.Sequence != g.Event.Sequence || g.Delivery.State != platform.DeliveryLeased {
|
||||
c.t.Fatal("invalid delivered event")
|
||||
}
|
||||
return &g
|
||||
}
|
||||
func eventProof(g *events.Grant) events.AckInput {
|
||||
return events.AckInput{DeliveryID: g.Delivery.ID, Epoch: g.Delivery.LeaseEpoch, LeaseSecret: g.LeaseSecret}
|
||||
}
|
||||
|
||||
func TestIntegrationEventHTTPAndWebSocket(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable MySQL")
|
||||
}
|
||||
ctx, stop := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer stop()
|
||||
db, err := Open(ctx, dsn, 20, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err = Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serverA, hubA := startEventServer(t, ctx, db)
|
||||
serverB, _ := startEventServer(t, ctx, db)
|
||||
t.Run("HTTP_management_required_fields_and_scoped_pagination", func(t *testing.T) {
|
||||
f := newEventFixture(t, ctx, db)
|
||||
other := newEventFixture(t, ctx, db)
|
||||
in := events.CreateInput{Name: "managed-results", QueueID: f.resources.QueueID, MaxAttempts: 1}
|
||||
raw := eventHTTP(t, ctx, serverA, "POST", "/v1/subscriptions", f.resources.Token, "create", in, 201)
|
||||
var created events.SubscriptionResult
|
||||
if json.Unmarshal(raw, &created) != nil {
|
||||
t.Fatal("invalid subscription receipt")
|
||||
}
|
||||
id := created.Subscription.ID
|
||||
raw = eventHTTP(t, ctx, serverA, "POST", "/v1/subscriptions", f.resources.Token, "create", in, 200)
|
||||
var duplicate events.SubscriptionResult
|
||||
if json.Unmarshal(raw, &duplicate) != nil || !duplicate.Deduplicated || duplicate.Subscription.ID != id {
|
||||
t.Fatal("idempotency changed through HTTP")
|
||||
}
|
||||
eventHTTP(t, ctx, serverA, "GET", "/v1/subscriptions/"+string(id), other.resources.Token, "", nil, 404)
|
||||
eventHTTP(t, ctx, serverA, "POST", "/v1/subscriptions/"+string(id)+":configure", f.resources.Token, "invalid-control", `{"expected_revision":"1","reason":"ambiguous"}`, 400)
|
||||
if !f.subscription(id).Enabled {
|
||||
t.Fatal("missing enabled paused subscription")
|
||||
}
|
||||
eventHTTP(t, ctx, serverA, "POST", "/v1/subscriptions/"+string(id)+":configure", f.resources.Token, "pause", events.ControlInput{ExpectedRevision: 1, Enabled: false, Reason: "maintenance"}, 200)
|
||||
eventHTTP(t, ctx, serverA, "POST", "/v1/subscriptions/"+string(id)+":configure", f.resources.Token, "resume", events.ControlInput{ExpectedRevision: 2, Enabled: true, Reason: "ready"}, 200)
|
||||
for _, bad := range []string{`"00"`, `"+1"`, `1`, `false`, `"18446744073709551616"`} {
|
||||
eventHTTP(t, ctx, serverA, "POST", "/v1/subscriptions", f.resources.Token, "invalid-sequence", `{"name":"invalid","queue_id":"`+string(f.resources.QueueID)+`","from_sequence":`+bad+`}`, 400)
|
||||
}
|
||||
for _, path := range []string{"/v1/subscriptions?queue_id=" + string(f.resources.QueueID) + "&limit=01", "/v1/subscriptions?queue_id=" + string(f.resources.QueueID) + "&limit=101", "/v1/subscriptions?queue_id=" + string(f.resources.QueueID) + "&cursor=a&cursor=b", "/v1/subscriptions/" + string(id) + "?unknown=1"} {
|
||||
eventHTTP(t, ctx, serverA, "GET", path, f.resources.Token, "", nil, 400)
|
||||
}
|
||||
f.create(events.CreateInput{})
|
||||
f.create(events.CreateInput{})
|
||||
path := "/v1/subscriptions?queue_id=" + string(f.resources.QueueID) + "&limit=1"
|
||||
var page events.SubscriptionPage
|
||||
raw = eventHTTP(t, ctx, serverA, "GET", path, f.resources.Token, "", nil, 200)
|
||||
if json.Unmarshal(raw, &page) != nil || len(page.Items) != 1 || page.NextCursor == "" {
|
||||
t.Fatal("missing subscription page")
|
||||
}
|
||||
first := page.Items[0].ID
|
||||
raw = eventHTTP(t, ctx, serverA, "GET", path+"&cursor="+url.QueryEscape(page.NextCursor), f.resources.Token, "", nil, 200)
|
||||
if json.Unmarshal(raw, &page) != nil || page.Items[0].ID == first {
|
||||
t.Fatal("subscription pagination repeated row")
|
||||
}
|
||||
eventHTTP(t, ctx, serverA, "GET", "/v1/subscriptions?queue_id="+string(other.resources.QueueID)+"&cursor="+url.QueryEscape(page.NextCursor), f.resources.Token, "", nil, 400)
|
||||
for range 3 {
|
||||
f.cancelResult()
|
||||
}
|
||||
firstGrant := f.grant(id)
|
||||
f.ack(id, firstGrant)
|
||||
second := f.grant(id)
|
||||
f.expireDelivery(second)
|
||||
third := f.grant(id)
|
||||
path = "/v1/subscriptions/" + string(id) + "/deliveries"
|
||||
var deliveries events.DeliveryPage
|
||||
raw = eventHTTP(t, ctx, serverA, "GET", path+"?limit=1", f.resources.Token, "", nil, 200)
|
||||
if json.Unmarshal(raw, &deliveries) != nil || len(deliveries.Items) != 1 || deliveries.NextCursor == "" {
|
||||
t.Fatal("missing delivery page")
|
||||
}
|
||||
eventHTTP(t, ctx, serverA, "GET", path+"?state=EXPIRED&cursor="+url.QueryEscape(deliveries.NextCursor), f.resources.Token, "", nil, 400)
|
||||
nextRaw := eventHTTP(t, ctx, serverA, "GET", path+"?limit=1&cursor="+url.QueryEscape(deliveries.NextCursor), f.resources.Token, "", nil, 200)
|
||||
var nextPage events.DeliveryPage
|
||||
if json.Unmarshal(nextRaw, &nextPage) != nil || len(nextPage.Items) != 1 || nextPage.Items[0].ID == deliveries.Items[0].ID {
|
||||
t.Fatal("delivery page repeated a row")
|
||||
}
|
||||
eventHTTP(t, ctx, serverA, "GET", path+"/"+string(second.Delivery.ID), other.resources.Token, "", nil, 404)
|
||||
wrongSub := f.create(events.CreateInput{})
|
||||
eventHTTP(t, ctx, serverA, "GET", "/v1/subscriptions/"+string(wrongSub.ID)+"/deliveries/"+string(second.Delivery.ID), f.resources.Token, "", nil, 404)
|
||||
raw = eventHTTP(t, ctx, serverA, "GET", path+"?state=EXPIRED", f.resources.Token, "", nil, 200)
|
||||
if json.Unmarshal(raw, &deliveries) != nil || len(deliveries.Items) != 1 || deliveries.Items[0].ID != second.Delivery.ID {
|
||||
t.Fatal("expired delivery not queryable")
|
||||
}
|
||||
expired := deliveries.Items[0]
|
||||
raw = eventHTTP(t, ctx, serverA, "GET", path+"/"+string(expired.ID), f.resources.Token, "", nil, 200)
|
||||
var detail events.DeliveryDetail
|
||||
if json.Unmarshal(raw, &detail) != nil || detail.Event.ID != second.Event.ID || strings.Contains(string(raw), second.LeaseSecret) || strings.Contains(string(raw), "token_digest") {
|
||||
t.Fatal("unsafe delivery detail")
|
||||
}
|
||||
retry := events.RetryInput{ExpectedRevision: expired.Revision, Reason: "consumer fixed", AdditionalAttempts: 1}
|
||||
eventHTTP(t, ctx, serverA, "POST", path+"/"+string(expired.ID)+":retry", f.resources.Token, "retry", retry, 200)
|
||||
eventHTTP(t, ctx, serverA, "POST", path+"/"+string(expired.ID)+":retry", f.resources.Token, "retry", retry, 200)
|
||||
f.ack(id, third)
|
||||
})
|
||||
t.Run("handshake_authority_protocol_origin_and_URL", func(t *testing.T) {
|
||||
f := newEventFixture(t, ctx, db)
|
||||
plain := newExecutionFixture(t, ctx, db)
|
||||
for _, c := range []struct {
|
||||
token, protocol, path, origin string
|
||||
status int
|
||||
}{
|
||||
{"", httpserver.EventSubprotocol, "/v1/events/connect", "", 401},
|
||||
{plain.resources.Token, httpserver.EventSubprotocol, "/v1/events/connect", "", 403},
|
||||
{f.resources.Token, "pulse.worker.v1", "/v1/events/connect", "", 426},
|
||||
{f.resources.Token, httpserver.EventSubprotocol, "/v1/events/connect?token=forbidden", "", 400},
|
||||
{f.resources.Token, httpserver.EventSubprotocol, "/v1/events/connect", "https://foreign.example", 403},
|
||||
} {
|
||||
headers := http.Header{}
|
||||
if c.token != "" {
|
||||
headers.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if c.origin != "" {
|
||||
headers.Set("Origin", c.origin)
|
||||
}
|
||||
conn, resp, err := websocket.Dial(ctx, strings.Replace(serverA.URL, "https://", "wss://", 1)+c.path, &websocket.DialOptions{HTTPClient: serverA.Client(), HTTPHeader: headers, Subprotocols: []string{c.protocol}})
|
||||
if conn != nil {
|
||||
conn.CloseNow()
|
||||
}
|
||||
if err == nil || resp == nil || resp.StatusCode != c.status {
|
||||
t.Fatalf("event handshake expected HTTP %d", c.status)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("WSS_capacity_ack_receipt_and_result_snapshot", func(t *testing.T) {
|
||||
f := newEventFixture(t, ctx, db)
|
||||
sub := f.create(events.CreateInput{})
|
||||
task := f.submit(1)
|
||||
ref := retryStart(t, f.executionFixture)
|
||||
result := json.RawMessage(`{"n":9007199254740993,"text":"` + strings.Repeat("<&>", 80000) + `"}`)
|
||||
if _, err := f.a.Finish(ctx, f.token, f.binding, executionID(t), worker.FinishInput{Ref: ref, Outcome: "SUCCEEDED", Result: result}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.cancelResult()
|
||||
c := connectTestEvents(t, ctx, serverA, f, sub.ID, 1)
|
||||
first := c.grant()
|
||||
if first.Event.Snapshot.TaskID != task.ID || string(first.Event.Snapshot.Result) != string(result) {
|
||||
t.Fatal("WSS snapshot changed large result")
|
||||
}
|
||||
c.reply(c.send("events.ping", struct{}{}, ""), "")
|
||||
var leased int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_deliveries WHERE namespace_id=? AND subscription_id=? AND state='LEASED'`, f.token.NamespaceID, sub.ID).Scan(&leased); err != nil || leased != 1 {
|
||||
t.Fatal("WSS capacity exceeded", err)
|
||||
}
|
||||
var ack events.AckResult
|
||||
message := c.send("event.ack", eventProof(first), "")
|
||||
if json.Unmarshal(c.reply(message, ""), &ack) != nil || ack.Deduplicated {
|
||||
t.Fatal("first ack not applied")
|
||||
}
|
||||
second := c.grant()
|
||||
if second.Event.ID == first.Event.ID {
|
||||
t.Fatal("confirmed result redelivered")
|
||||
}
|
||||
var repeated events.AckResult
|
||||
if json.Unmarshal(c.reply(c.send("event.ack", eventProof(first), message), ""), &repeated) != nil || !repeated.Deduplicated || !repeated.AcknowledgedAt.Equal(ack.AcknowledgedAt) {
|
||||
t.Fatal("duplicate ACK changed receipt")
|
||||
}
|
||||
c.reply(c.send("event.ack", eventProof(second), ""), "")
|
||||
})
|
||||
t.Run("cross_node_redelivery_fences_old_proof", func(t *testing.T) {
|
||||
f := newEventFixture(t, ctx, db)
|
||||
sub := f.create(events.CreateInput{MaxAttempts: 2})
|
||||
f.cancelResult()
|
||||
a := connectTestEvents(t, ctx, serverA, f, sub.ID, 1)
|
||||
first := a.grant()
|
||||
a.conn.CloseNow()
|
||||
f.expireDelivery(first)
|
||||
b := connectTestEvents(t, ctx, serverB, f, sub.ID, 1)
|
||||
second := b.grant()
|
||||
if second.Event.ID != first.Event.ID || second.Delivery.ID != first.Delivery.ID || second.Delivery.LeaseEpoch != first.Delivery.LeaseEpoch+1 {
|
||||
t.Fatal("cross-node redelivery lost durable identity")
|
||||
}
|
||||
b.reply(b.send("event.ack", eventProof(first), ""), "DELIVERY_LEASE_LOST")
|
||||
b.reply(b.send("event.ack", eventProof(second), ""), "")
|
||||
if f.subscription(sub.ID).PendingCount != 0 {
|
||||
t.Fatal("cross-node ack did not release window")
|
||||
}
|
||||
})
|
||||
t.Run("discarded_ack_response_reconciles_on_new_connection", func(t *testing.T) {
|
||||
f := newEventFixture(t, ctx, db)
|
||||
sub := f.create(events.CreateInput{})
|
||||
f.cancelResult()
|
||||
a := connectTestEvents(t, ctx, serverA, f, sub.ID, 1)
|
||||
g := a.grant()
|
||||
message := a.send("event.ack", eventProof(g), "")
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
var original events.Delivery
|
||||
for time.Now().Before(deadline) {
|
||||
original, err = f.events.Delivery(ctx, f.token, sub.ID, g.Delivery.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if original.State == platform.DeliveryAcked {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if original.State != platform.DeliveryAcked {
|
||||
t.Fatal("ACK did not commit")
|
||||
}
|
||||
a.conn.CloseNow()
|
||||
b := connectTestEvents(t, ctx, serverB, f, sub.ID, 1)
|
||||
var receipt events.AckResult
|
||||
if json.Unmarshal(b.reply(b.send("event.ack", eventProof(g), message), ""), &receipt) != nil || !receipt.Deduplicated || original.AcknowledgedAt == nil || !receipt.AcknowledgedAt.Equal(*original.AcknowledgedAt) {
|
||||
t.Fatal("lost ACK response could not be reconciled")
|
||||
}
|
||||
if f.count("orbit_audit_events", "resource_id=? AND action='delivery.ack'", g.Delivery.ID) != 1 {
|
||||
t.Fatal("replayed ACK duplicated audit")
|
||||
}
|
||||
})
|
||||
t.Run("revocation_and_session_spoofing_close_live_connection", func(t *testing.T) {
|
||||
f := newEventFixture(t, ctx, db)
|
||||
sub := f.create(events.CreateInput{})
|
||||
c := connectTestEvents(t, ctx, serverA, f, sub.ID, 1)
|
||||
f.exec(`UPDATE orbit_credentials SET revoked_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.token.NamespaceID, f.token.CredentialID)
|
||||
// 主动心跳或下一次领取均重验权限;这里主动心跳固定相关响应。
|
||||
msg := c.send("events.ping", struct{}{}, "")
|
||||
frame := c.read()
|
||||
if frame.Type != "error" || frame.Error == nil || frame.Error.Code != "UNAUTHENTICATED" || (frame.ReplyTo != "" && frame.ReplyTo != msg) {
|
||||
t.Fatal("revoked connection retained authority")
|
||||
}
|
||||
g := newEventFixture(t, ctx, db)
|
||||
owned := g.create(events.CreateInput{})
|
||||
d := connectTestEvents(t, ctx, serverA, g, owned.ID, 1)
|
||||
raw := `{"protocol_version":"1","type":"events.ping","message_id":"` + string(executionID(t)) + `","session_id":"` + string(executionID(t)) + `","body":{}}`
|
||||
if err := d.conn.Write(ctx, websocket.MessageText, []byte(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
readCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
_, _, err := d.conn.Read(readCtx)
|
||||
if websocket.CloseStatus(err) != websocket.StatusPolicyViolation {
|
||||
t.Fatal("foreign connection session accepted")
|
||||
}
|
||||
})
|
||||
t.Run("shutdown_closes_socket_preserves_delivery_and_rejects_new_connection", func(t *testing.T) {
|
||||
f := newEventFixture(t, ctx, db)
|
||||
sub := f.create(events.CreateInput{})
|
||||
f.cancelResult()
|
||||
a := connectTestEvents(t, ctx, serverA, f, sub.ID, 1)
|
||||
g := a.grant()
|
||||
bounded, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
if err := hubA.Shutdown(bounded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := f.events.Delivery(ctx, f.token, sub.ID, g.Delivery.ID)
|
||||
if err != nil || v.State != platform.DeliveryLeased || v.LeaseEpoch != g.Delivery.LeaseEpoch {
|
||||
t.Fatal("shutdown erased authoritative lease", err)
|
||||
}
|
||||
eventHTTP(t, ctx, serverA, "GET", "/v1/events/connect", f.resources.Token, "", nil, 426)
|
||||
headers := http.Header{}
|
||||
headers.Set("Authorization", "Bearer "+f.resources.Token)
|
||||
conn, response, err := websocket.Dial(ctx, strings.Replace(serverA.URL, "https://", "wss://", 1)+"/v1/events/connect", &websocket.DialOptions{HTTPClient: serverA.Client(), HTTPHeader: headers, Subprotocols: []string{httpserver.EventSubprotocol}})
|
||||
if conn != nil {
|
||||
conn.CloseNow()
|
||||
}
|
||||
if err == nil || response == nil || response.StatusCode != 503 {
|
||||
t.Fatal("closing hub accepted new connection")
|
||||
}
|
||||
b := connectTestEvents(t, ctx, serverB, f, sub.ID, 1)
|
||||
b.reply(b.send("event.ack", eventProof(g), ""), "")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 面向已物化交付历史的稳定创建时间分页;确认和重新发送不会改变排序键。
|
||||
-- 不使用外键或级联删除;序号和事件归属继续由服务端按命名空间校验。
|
||||
ALTER TABLE orbit_deliveries
|
||||
ADD KEY ix_delivery_history(namespace_id,subscription_id,created_at,id),
|
||||
ADD KEY ix_delivery_state_history(namespace_id,subscription_id,state,created_at,id);
|
||||
@@ -88,7 +88,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
|
||||
}
|
||||
indexes := func() []string {
|
||||
t.Helper()
|
||||
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
||||
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
61
internal/testcontract/events.go
Normal file
61
internal/testcontract/events.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Package testcontract 在回归中校验实际报文与公开 Schema,服务二进制不依赖此包。
|
||||
package testcontract
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/protocol/eventsv1"
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
)
|
||||
|
||||
var eventOnce sync.Once
|
||||
var eventSchemas map[string]*jsonschema.Schema
|
||||
var eventError error
|
||||
|
||||
// EventSchema 编译并缓存两个方向的 Schema;开启 date-time 等格式断言。
|
||||
func EventSchema(t testing.TB, direction string) *jsonschema.Schema {
|
||||
t.Helper()
|
||||
eventOnce.Do(func() {
|
||||
compiler := jsonschema.NewCompiler()
|
||||
compiler.AssertFormat()
|
||||
doc, err := jsonschema.UnmarshalJSON(strings.NewReader(eventsv1.Schema))
|
||||
if err != nil {
|
||||
eventError = err
|
||||
return
|
||||
}
|
||||
const resource = "https://git.yuntuo.hk/k2safe/orbit/schema/events.v1.json"
|
||||
if err := compiler.AddResource(resource, doc); err != nil {
|
||||
eventError = err
|
||||
return
|
||||
}
|
||||
eventSchemas = make(map[string]*jsonschema.Schema)
|
||||
for _, side := range []string{"client", "server"} {
|
||||
eventSchemas[side], eventError = compiler.Compile(resource + "#/$defs/" + side + "Frame")
|
||||
if eventError != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
if eventError != nil {
|
||||
t.Fatal(eventError)
|
||||
}
|
||||
schema := eventSchemas[direction]
|
||||
if schema == nil {
|
||||
t.Fatal("unknown Event protocol direction")
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
// EventFrame 验证真实传输的 JSON,不在错误中输出可能包含秘密的报文正文。
|
||||
func EventFrame(t testing.TB, direction string, raw []byte) {
|
||||
t.Helper()
|
||||
value, err := jsonschema.UnmarshalJSON(strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
t.Fatal("Event frame is not valid JSON")
|
||||
}
|
||||
if EventSchema(t, direction).Validate(value) != nil {
|
||||
t.Fatalf("%s Event frame violates published Schema", direction)
|
||||
}
|
||||
}
|
||||
175
internal/transport/httpserver/event_codec.go
Normal file
175
internal/transport/httpserver/event_codec.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
)
|
||||
|
||||
const EventSubprotocol = "orbit.events.v1"
|
||||
const eventMaxFrame = 512 << 10
|
||||
|
||||
type eventFrame struct {
|
||||
Version string `json:"protocol_version"`
|
||||
Type string `json:"type"`
|
||||
MessageID core.ID `json:"message_id"`
|
||||
ReplyTo core.ID `json:"reply_to,omitempty"`
|
||||
SessionID core.ID `json:"session_id,omitempty"`
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
Error *fault.Error `json:"error,omitempty"`
|
||||
}
|
||||
type eventHello struct {
|
||||
SubscriptionID core.ID `json:"subscription_id"`
|
||||
Capacity uint32 `json:"capacity"`
|
||||
}
|
||||
type eventWelcome struct {
|
||||
SessionID core.ID `json:"session_id"`
|
||||
SubscriptionID core.ID `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"`
|
||||
}
|
||||
|
||||
func decodeEventBody(r *http.Request, out any) error {
|
||||
content, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || content != "application/json" {
|
||||
return fault.New("UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json")
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, eventMaxFrame+1))
|
||||
if err != nil {
|
||||
return fault.Invalid("cannot read request body")
|
||||
}
|
||||
if len(raw) > eventMaxFrame {
|
||||
return fault.New("BODY_TOO_LARGE", "request body exceeds 512 KiB")
|
||||
}
|
||||
return decodeEventJSON(raw, out)
|
||||
}
|
||||
func decodeEventJSON(raw []byte, out any) error {
|
||||
if len(raw) == 0 || len(raw) > eventMaxFrame {
|
||||
return fault.Invalid("invalid event message size")
|
||||
}
|
||||
if _, err := jsonvalue.Canonical(raw); err != nil {
|
||||
return fault.Invalid("invalid event JSON")
|
||||
}
|
||||
typ := reflect.TypeOf(out).Elem()
|
||||
if exactFields(raw, typ) != nil || requiredEventFields(raw, typ) != nil {
|
||||
return fault.Invalid("missing, unknown or invalid event field")
|
||||
}
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(out) != nil {
|
||||
return fault.Invalid("invalid event field value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requiredEventFields 同时验证显式控制意图及规范十进制字符串;可选历史游标允许0和null。
|
||||
func requiredEventFields(raw []byte, typ reflect.Type) error {
|
||||
if typ == reflect.TypeOf(json.RawMessage{}) || typ == reflect.TypeOf(time.Time{}) {
|
||||
return nil
|
||||
}
|
||||
if typ.Kind() == reflect.Pointer {
|
||||
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
return requiredEventFields(raw, typ.Elem())
|
||||
}
|
||||
if typ.Kind() == reflect.Array || typ.Kind() == reflect.Slice {
|
||||
var values []json.RawMessage
|
||||
if json.Unmarshal(raw, &values) != nil {
|
||||
return fault.Invalid("invalid event array")
|
||||
}
|
||||
for _, v := range values {
|
||||
if err := requiredEventFields(v, typ.Elem()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if typ.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &fields) != nil {
|
||||
return fault.Invalid("invalid event object")
|
||||
}
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
tags := strings.Split(field.Tag.Get("json"), ",")
|
||||
if tags[0] == "" || tags[0] == "-" {
|
||||
continue
|
||||
}
|
||||
optional, numberString := false, false
|
||||
for _, v := range tags[1:] {
|
||||
optional = optional || v == "omitempty"
|
||||
numberString = numberString || v == "string"
|
||||
}
|
||||
value, found := fields[tags[0]]
|
||||
if !found {
|
||||
if optional {
|
||||
continue
|
||||
}
|
||||
return fault.Invalid("missing event field")
|
||||
}
|
||||
ft := field.Type
|
||||
nullable := ft.Kind() == reflect.Pointer
|
||||
if nullable {
|
||||
if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
continue
|
||||
}
|
||||
ft = ft.Elem()
|
||||
}
|
||||
if ft.Kind() == reflect.Uint64 && numberString {
|
||||
var text string
|
||||
if json.Unmarshal(value, &text) != nil {
|
||||
return fault.Invalid("event counter must be a decimal string")
|
||||
}
|
||||
n, err := strconv.ParseUint(text, 10, 64)
|
||||
if err != nil || strconv.FormatUint(n, 10) != text || (!nullable && n == 0) {
|
||||
return fault.Invalid("invalid event counter")
|
||||
}
|
||||
}
|
||||
if err := requiredEventFields(value, field.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func decodeEventRequest(raw []byte, hello bool) (eventFrame, error) {
|
||||
var frame eventFrame
|
||||
if err := decodeEventJSON(raw, &frame); err != nil {
|
||||
return frame, err
|
||||
}
|
||||
if frame.Version != "1" || frame.MessageID.Validate() != nil || len(frame.Body) == 0 {
|
||||
return frame, fault.Invalid("invalid event envelope")
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
_ = json.Unmarshal(raw, &fields)
|
||||
if fields["error"] != nil || fields["reply_to"] != nil {
|
||||
return frame, fault.Invalid("response fields are not allowed in event requests")
|
||||
}
|
||||
if hello {
|
||||
if frame.Type != "events.hello" || fields["session_id"] != nil {
|
||||
return frame, fault.Invalid("invalid event hello")
|
||||
}
|
||||
} else if frame.SessionID.Validate() != nil || (frame.Type != "event.ack" && frame.Type != "events.ping") {
|
||||
return frame, fault.Invalid("invalid event session or type")
|
||||
}
|
||||
return frame, nil
|
||||
}
|
||||
141
internal/transport/httpserver/event_codec_test.go
Normal file
141
internal/transport/httpserver/event_codec_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/testcontract"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||
)
|
||||
|
||||
func TestEventFrameContract(t *testing.T) {
|
||||
id := strings.Repeat("1", 32)
|
||||
secret, _, err := events.NewSecret()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
valid := []string{
|
||||
`{"protocol_version":"1","type":"events.hello","message_id":"` + id + `","body":{"subscription_id":"` + id + `","capacity":1}}`,
|
||||
`{"protocol_version":"1","type":"events.ping","message_id":"` + id + `","session_id":"` + id + `","body":{}}`,
|
||||
`{"protocol_version":"1","type":"event.ack","message_id":"` + id + `","session_id":"` + id + `","body":{"delivery_id":"` + id + `","epoch":"1","lease_secret":"` + secret + `"}}`,
|
||||
}
|
||||
for i, raw := range valid {
|
||||
testcontract.EventFrame(t, "client", []byte(raw))
|
||||
f, err := decodeEventRequest([]byte(raw), i == 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch i {
|
||||
case 0:
|
||||
var v eventHello
|
||||
err = decodeEventJSON(f.Body, &v)
|
||||
case 1:
|
||||
var v struct{}
|
||||
err = decodeEventJSON(f.Body, &v)
|
||||
case 2:
|
||||
var v events.AckInput
|
||||
err = decodeEventJSON(f.Body, &v)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, number := range []string{"0", "01", "18446744073709551616", "18446744073709551615"} {
|
||||
raw := strings.Replace(valid[2], `"epoch":"1"`, `"epoch":"`+number+`"`, 1)
|
||||
var frame eventFrame
|
||||
_ = json.Unmarshal([]byte(raw), &frame)
|
||||
var input events.AckInput
|
||||
decodeErr := decodeEventJSON(frame.Body, &input)
|
||||
var value any
|
||||
_ = json.Unmarshal([]byte(raw), &value)
|
||||
schemaErr := testcontract.EventSchema(t, "client").Validate(value)
|
||||
wantValid := number == "18446744073709551615"
|
||||
if (decodeErr == nil) != wantValid || (schemaErr == nil) != wantValid {
|
||||
t.Fatal("event counter range disagrees with contract")
|
||||
}
|
||||
}
|
||||
bad := []string{
|
||||
strings.Replace(valid[0], `"body":`, `"session_id":null,"body":`, 1),
|
||||
strings.Replace(valid[1], `"body":`, `"reply_to":"","body":`, 1),
|
||||
strings.Replace(valid[1], `"body":`, `"error":null,"body":`, 1),
|
||||
strings.Replace(valid[1], `"type":"events.ping"`, `"Type":"events.ping"`, 1),
|
||||
strings.Replace(valid[1], `"protocol_version":"1"`, `"protocol_version":"1","protocol_version":"1"`, 1),
|
||||
strings.Replace(valid[1], `"events.ping"`, `"worker.capacity"`, 1),
|
||||
strings.Replace(valid[1], `"body":{}`, `"body":null`, 1),
|
||||
}
|
||||
for _, raw := range bad {
|
||||
if _, err := decodeEventRequest([]byte(raw), strings.Contains(raw, "events.hello")); err == nil {
|
||||
t.Fatal("accepted invalid event envelope")
|
||||
}
|
||||
}
|
||||
// 标准Schema无法识别重复JSON键;实际解码器负责拒绝,其他方向约束由两者共同验证。
|
||||
for _, raw := range bad[:4] {
|
||||
var value any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if testcontract.EventSchema(t, "client").Validate(value) == nil {
|
||||
t.Fatal("schema accepted direction or field violation")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestEventHTTPRequiredFieldsAndCounterCanonicalization(t *testing.T) {
|
||||
id := strings.Repeat("1", 32)
|
||||
for _, number := range []string{`"00"`, `"+1"`, `"01"`, `1`, `true`, `"18446744073709551616"`} {
|
||||
var in events.CreateInput
|
||||
raw := `{"name":"results","queue_id":"` + id + `","from_sequence":` + number + `}`
|
||||
if decodeEventJSON([]byte(raw), &in) == nil {
|
||||
t.Fatal("accepted invalid sequence")
|
||||
}
|
||||
}
|
||||
for _, number := range []string{`"0"`, `null`, `"18446744073709551615"`} {
|
||||
var in events.CreateInput
|
||||
if err := decodeEventJSON([]byte(`{"name":"results","queue_id":"`+id+`","from_sequence":`+number+`}`), &in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{`{"expected_revision":"1","reason":"pause"}`, `{"expected_revision":"01","reason":"pause","enabled":false}`, `{"expected_revision":"1","reason":"pause","enabled":null}`, `{"expected_revision":"1","reason":"pause","Enabled":false}`} {
|
||||
var in events.ControlInput
|
||||
if decodeEventJSON([]byte(raw), &in) == nil {
|
||||
t.Fatal("accepted ambiguous control intent")
|
||||
}
|
||||
}
|
||||
var good events.ControlInput
|
||||
if err := decodeEventJSON([]byte(`{"expected_revision":"1","reason":"pause","enabled":false}`), &good); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventDocumentationFramesMatchPublishedSchema(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../../docs/api/subscriptions.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frames := 0
|
||||
for _, block := range strings.Split(string(raw), "```json\n")[1:] {
|
||||
snippet := strings.SplitN(block, "\n```", 2)[0]
|
||||
var value map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(snippet), &value) != nil {
|
||||
t.Fatal("invalid documented JSON")
|
||||
}
|
||||
if value["protocol_version"] == nil {
|
||||
continue
|
||||
}
|
||||
var kind string
|
||||
if json.Unmarshal(value["type"], &kind) != nil {
|
||||
t.Fatal("documented frame lacks type")
|
||||
}
|
||||
side := "server"
|
||||
if kind == "events.hello" || kind == "events.ping" || kind == "event.ack" {
|
||||
side = "client"
|
||||
}
|
||||
testcontract.EventFrame(t, side, []byte(snippet))
|
||||
frames++
|
||||
}
|
||||
if frames != 6 {
|
||||
t.Fatalf("expected six complete documented frames, got %d", frames)
|
||||
}
|
||||
}
|
||||
356
internal/transport/httpserver/event_hub.go
Normal file
356
internal/transport/httpserver/event_hub.go
Normal file
@@ -0,0 +1,356 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/dispatch"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// EventHub 仅负责已持久化结果的传输;断线不删除租约,消费者按事件ID持久化去重。
|
||||
// 节点最多128条连接,领取池全局4个、每应用1个在途,队列加在途共128个。
|
||||
type EventHub struct {
|
||||
service *events.Service
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
closing bool
|
||||
slots chan struct{}
|
||||
wg sync.WaitGroup
|
||||
dispatch *dispatch.Pool
|
||||
poll time.Duration
|
||||
}
|
||||
|
||||
func NewEventHub(parent context.Context, service *events.Service) *EventHub {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
h := &EventHub{service: service, ctx: ctx, cancel: cancel, slots: make(chan struct{}, 128), poll: time.Second}
|
||||
h.dispatch = dispatch.New(ctx, 4, 128)
|
||||
h.wg.Add(1)
|
||||
go func() { defer h.wg.Done(); <-h.dispatch.Done() }()
|
||||
return h
|
||||
}
|
||||
func RegisterEventConsumers(server *http.Server, hub *EventHub) {
|
||||
server.Handler.(*http.ServeMux).Handle("GET /v1/events/connect", hub)
|
||||
}
|
||||
func (h *EventHub) Shutdown(ctx context.Context) error {
|
||||
h.mu.Lock()
|
||||
h.closing = true
|
||||
h.cancel()
|
||||
h.mu.Unlock()
|
||||
done := make(chan struct{})
|
||||
go func() { h.wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
func (h *EventHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
requestID, err := core.NewID()
|
||||
if err != nil {
|
||||
writeError(w, "", fault.Unavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-Request-ID", string(requestID))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if err = noEventQuery(r); err != nil {
|
||||
writeError(w, string(requestID), err)
|
||||
return
|
||||
}
|
||||
values := r.Header.Values("Authorization")
|
||||
if len(values) != 1 {
|
||||
writeError(w, string(requestID), fault.Unauthorized)
|
||||
return
|
||||
}
|
||||
scheme, value, ok := strings.Cut(values[0], " ")
|
||||
if !ok || !strings.EqualFold(scheme, "Bearer") {
|
||||
writeError(w, string(requestID), fault.Unauthorized)
|
||||
return
|
||||
}
|
||||
token, err := auth.Parse(value)
|
||||
if err != nil {
|
||||
writeError(w, string(requestID), err)
|
||||
return
|
||||
}
|
||||
supported := false
|
||||
for _, header := range r.Header.Values("Sec-WebSocket-Protocol") {
|
||||
for _, p := range strings.Split(header, ",") {
|
||||
supported = supported || strings.TrimSpace(p) == EventSubprotocol
|
||||
}
|
||||
}
|
||||
if !supported {
|
||||
writeJSON(w, http.StatusUpgradeRequired, map[string]any{"request_id": requestID, "error": fault.New("PROTOCOL_REQUIRED", "orbit.events.v1 subprotocol is required")})
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
if h.closing || h.ctx.Err() != nil {
|
||||
h.mu.Unlock()
|
||||
writeError(w, string(requestID), fault.Unavailable)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case h.slots <- struct{}{}:
|
||||
h.wg.Add(1)
|
||||
default:
|
||||
h.mu.Unlock()
|
||||
w.Header().Set("Retry-After", "1")
|
||||
writeJSON(w, 429, map[string]any{"request_id": requestID, "error": fault.New("CAPACITY_EXCEEDED", "node event connection capacity exceeded")})
|
||||
return
|
||||
}
|
||||
h.mu.Unlock()
|
||||
defer func() { <-h.slots; h.wg.Done() }()
|
||||
authorize, stop := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
err = h.service.Authorize(authorize, token)
|
||||
stop()
|
||||
if err != nil {
|
||||
writeError(w, string(requestID), err)
|
||||
return
|
||||
}
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{EventSubprotocol}, CompressionMode: websocket.CompressionDisabled})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
conn.SetReadLimit(eventMaxFrame)
|
||||
ctx, cancel := context.WithCancel(h.ctx)
|
||||
defer cancel()
|
||||
helloCtx, helloStop := context.WithTimeout(ctx, 5*time.Second)
|
||||
kind, raw, err := conn.Read(helloCtx)
|
||||
helloStop()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
frame, decodeErr := decodeEventRequest(raw, true)
|
||||
var input eventHello
|
||||
if kind != websocket.MessageText || decodeErr != nil || decodeEventJSON(frame.Body, &input) != nil || input.SubscriptionID.Validate() != nil || input.Capacity < 1 || input.Capacity > 32 {
|
||||
_ = conn.Close(websocket.StatusPolicyViolation, "invalid event hello")
|
||||
return
|
||||
}
|
||||
call, callStop := context.WithTimeout(ctx, 8*time.Second)
|
||||
binding, err := h.service.Bind(call, token, input.SubscriptionID)
|
||||
callStop()
|
||||
if err != nil {
|
||||
_ = writeEventFrame(ctx, conn, eventFrame{Version: "1", Type: "error", MessageID: requestID, ReplyTo: frame.MessageID, Error: eventFault(err)})
|
||||
return
|
||||
}
|
||||
session, err := core.NewID()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
welcome := eventWelcome{SessionID: session, SubscriptionID: input.SubscriptionID, Capacity: input.Capacity, LeaseDurationMS: events.LeaseDuration.Milliseconds(), HeartbeatIntervalMS: 15000, ServerTime: binding.ServerTime, Enabled: binding.Enabled}
|
||||
body, err := jsonwire.Marshal(welcome)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err = writeEventFrame(ctx, conn, eventFrame{Version: "1", Type: "events.welcome", MessageID: requestID, ReplyTo: frame.MessageID, SessionID: session, Body: body}); err != nil {
|
||||
return
|
||||
}
|
||||
h.consume(ctx, cancel, conn, token, binding, session, input.Capacity)
|
||||
}
|
||||
|
||||
type eventClaimResult struct {
|
||||
grant *events.Grant
|
||||
err error
|
||||
}
|
||||
|
||||
func (h *EventHub) claim(ctx context.Context, t auth.Token, sub, connection core.ID, capacity uint32) (out eventClaimResult) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
out = eventClaimResult{err: fault.Unavailable}
|
||||
}
|
||||
}()
|
||||
call, stop := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer stop()
|
||||
out.grant, out.err = h.service.Claim(call, t, sub, events.ClaimInput{ConnectionID: connection, Capacity: capacity})
|
||||
return out
|
||||
}
|
||||
func (h *EventHub) consume(ctx context.Context, cancel context.CancelFunc, conn *websocket.Conn, t auth.Token, b events.ConsumerBinding, session core.ID, capacity uint32) {
|
||||
incoming := make(chan eventFrame, 4)
|
||||
outgoing := make(chan outboundFrame, 8)
|
||||
var loops sync.WaitGroup
|
||||
loops.Add(2)
|
||||
go func() {
|
||||
defer loops.Done()
|
||||
defer cancel()
|
||||
for {
|
||||
readCtx, stop := context.WithTimeout(ctx, 60*time.Second)
|
||||
kind, raw, err := conn.Read(readCtx)
|
||||
stop()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
frame, err := decodeEventRequest(raw, false)
|
||||
if kind != websocket.MessageText || err != nil || frame.SessionID != session {
|
||||
_ = conn.Close(websocket.StatusPolicyViolation, "invalid event frame")
|
||||
return
|
||||
}
|
||||
select {
|
||||
case incoming <- frame:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer loops.Done()
|
||||
defer cancel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case frame := <-outgoing:
|
||||
writeCtx, stop := context.WithTimeout(ctx, 5*time.Second)
|
||||
err := conn.Write(writeCtx, websocket.MessageText, frame.data)
|
||||
stop()
|
||||
if frame.done != nil {
|
||||
frame.done <- err
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer func() { cancel(); _ = conn.CloseNow(); loops.Wait() }()
|
||||
send := func(frame eventFrame, wait bool) bool {
|
||||
frame.Version = "1"
|
||||
frame.SessionID = session
|
||||
if frame.MessageID == "" {
|
||||
var err error
|
||||
frame.MessageID, err = core.NewID()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
raw, err := jsonwire.Marshal(frame)
|
||||
if err != nil || len(raw) > eventMaxFrame {
|
||||
return false
|
||||
}
|
||||
out := outboundFrame{data: raw}
|
||||
if wait {
|
||||
out.done = make(chan error, 1)
|
||||
}
|
||||
select {
|
||||
case outgoing <- out:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if !wait {
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case err := <-out.done:
|
||||
return err == nil
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
results := make(chan eventClaimResult, 1)
|
||||
pending, burst := false, 0
|
||||
request := func() {
|
||||
if pending || burst == 0 || len(outgoing) >= 4 {
|
||||
return
|
||||
}
|
||||
pending = h.dispatch.Submit(dispatch.Job{Context: ctx, Application: string(b.ApplicationID), Queue: string(t.NamespaceID) + "/" + string(b.SubscriptionID), Run: func(job context.Context) { results <- h.claim(job, t, b.SubscriptionID, session, capacity) }})
|
||||
if pending {
|
||||
burst--
|
||||
}
|
||||
}
|
||||
ticker := time.NewTicker(h.poll)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case result := <-results:
|
||||
pending = false
|
||||
if result.err != nil {
|
||||
send(eventFrame{Type: "error", Error: eventFault(result.err)}, true)
|
||||
return
|
||||
}
|
||||
if result.grant == nil {
|
||||
burst = 0
|
||||
continue
|
||||
}
|
||||
raw, err := jsonwire.Marshal(result.grant)
|
||||
if err != nil || !send(eventFrame{Type: "event.deliver", Body: raw}, false) {
|
||||
return
|
||||
}
|
||||
request()
|
||||
case frame := <-incoming:
|
||||
call, stop := context.WithTimeout(ctx, 8*time.Second)
|
||||
var body any
|
||||
var err error
|
||||
switch frame.Type {
|
||||
case "event.ack":
|
||||
var in events.AckInput
|
||||
if err = decodeEventJSON(frame.Body, &in); err == nil {
|
||||
body, err = h.service.Ack(call, t, b.SubscriptionID, in)
|
||||
}
|
||||
case "events.ping":
|
||||
var in struct{}
|
||||
if err = decodeEventJSON(frame.Body, &in); err == nil {
|
||||
var current events.ConsumerBinding
|
||||
current, err = h.service.Bind(call, t, b.SubscriptionID)
|
||||
if err == nil {
|
||||
body = eventPong{ServerTime: current.ServerTime, Enabled: current.Enabled}
|
||||
}
|
||||
}
|
||||
default:
|
||||
err = fault.Invalid("unsupported event message type")
|
||||
}
|
||||
stop()
|
||||
reply := eventFrame{Type: "ack", ReplyTo: frame.MessageID}
|
||||
if err != nil {
|
||||
reply.Type = "error"
|
||||
reply.Error = eventFault(err)
|
||||
} else {
|
||||
var marshalErr error
|
||||
reply.Body, marshalErr = jsonwire.Marshal(body)
|
||||
if marshalErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
fatal := err != nil && (fault.Code(err) == "UNAUTHENTICATED" || fault.Code(err) == "PERMISSION_DENIED" || fault.Code(err) == "NOT_FOUND" || fault.Code(err) == "UNAVAILABLE")
|
||||
if !send(reply, fatal) || fatal {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
if !pending {
|
||||
burst = 4
|
||||
request()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func eventFault(err error) *fault.Error {
|
||||
var known *fault.Error
|
||||
if errors.As(err, &known) {
|
||||
return known
|
||||
}
|
||||
return fault.Unavailable
|
||||
}
|
||||
func writeEventFrame(ctx context.Context, conn *websocket.Conn, frame eventFrame) error {
|
||||
raw, err := jsonwire.Marshal(frame)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > eventMaxFrame {
|
||||
return fault.Invalid("event frame too large")
|
||||
}
|
||||
bounded, stop := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer stop()
|
||||
return conn.Write(bounded, websocket.MessageText, raw)
|
||||
}
|
||||
139
internal/transport/httpserver/event_hub_test.go
Normal file
139
internal/transport/httpserver/event_hub_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
type slowEventStore struct {
|
||||
events.Store
|
||||
once sync.Once
|
||||
started chan struct{}
|
||||
finished chan struct{}
|
||||
}
|
||||
|
||||
func (s *slowEventStore) AuthorizeEvents(context.Context, auth.Token) error { return nil }
|
||||
func (s *slowEventStore) BindConsumer(_ context.Context, _ auth.Token, id core.ID) (events.ConsumerBinding, error) {
|
||||
return events.ConsumerBinding{ApplicationID: core.ID(strings.Repeat("1", 32)), SubscriptionID: id, QueueID: core.ID(strings.Repeat("2", 32)), Enabled: true, ServerTime: time.Now().UTC()}, nil
|
||||
}
|
||||
func (s *slowEventStore) ClaimDelivery(ctx context.Context, _ auth.Token, _ core.ID, _ events.ClaimInput) (*events.Grant, error) {
|
||||
s.once.Do(func() { close(s.started) })
|
||||
<-ctx.Done()
|
||||
select {
|
||||
case s.finished <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
func TestEventHubSlowClaimKeepsControlLiveAndBoundsConnections(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
store := &slowEventStore{started: make(chan struct{}), finished: make(chan struct{}, 4)}
|
||||
hub := NewEventHub(ctx, events.New(store))
|
||||
if cap(hub.slots) != 128 {
|
||||
t.Fatal("unexpected production connection bound")
|
||||
}
|
||||
hub.slots = make(chan struct{}, 2)
|
||||
hub.poll = 10 * time.Millisecond
|
||||
server := New("", nil)
|
||||
RegisterEventConsumers(server, hub)
|
||||
tls := httptest.NewTLSServer(server.Handler)
|
||||
defer tls.Close()
|
||||
defer hub.Shutdown(context.Background())
|
||||
id := core.ID(strings.Repeat("3", 32))
|
||||
credential := core.ID(strings.Repeat("4", 32))
|
||||
token, _, err := auth.Issue(id, credential)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headers := http.Header{}
|
||||
headers.Set("Authorization", "Bearer "+token)
|
||||
dial := func() (*websocket.Conn, *http.Response, error) {
|
||||
return websocket.Dial(ctx, strings.Replace(tls.URL, "https://", "wss://", 1)+"/v1/events/connect", &websocket.DialOptions{HTTPClient: tls.Client(), HTTPHeader: headers, Subprotocols: []string{EventSubprotocol}})
|
||||
}
|
||||
connect := func() (*websocket.Conn, core.ID) {
|
||||
t.Helper()
|
||||
conn, _, err := dial()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { conn.CloseNow() })
|
||||
hello, _ := jsonwire.Marshal(eventHello{SubscriptionID: id, Capacity: 1})
|
||||
raw, _ := jsonwire.Marshal(eventFrame{Version: "1", Type: "events.hello", MessageID: id, Body: hello})
|
||||
if err = conn.Write(ctx, websocket.MessageText, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, reply, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var frame eventFrame
|
||||
if json.Unmarshal(reply, &frame) != nil || frame.Type != "events.welcome" {
|
||||
t.Fatal("missing welcome")
|
||||
}
|
||||
return conn, frame.SessionID
|
||||
}
|
||||
first, session := connect()
|
||||
second, _ := connect()
|
||||
select {
|
||||
case <-store.started:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("claim was not dispatched")
|
||||
}
|
||||
blocked, resp, err := dial()
|
||||
if blocked != nil {
|
||||
blocked.CloseNow()
|
||||
}
|
||||
if err == nil || resp == nil || resp.StatusCode != 429 || resp.Header.Get("Retry-After") != "1" {
|
||||
t.Fatal("event connection bound not enforced")
|
||||
}
|
||||
ping, _ := jsonwire.Marshal(eventFrame{Version: "1", Type: "events.ping", MessageID: credential, SessionID: session, Body: json.RawMessage(`{}`)})
|
||||
if err = first.Write(ctx, websocket.MessageText, ping); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bounded, stop := context.WithTimeout(ctx, 500*time.Millisecond)
|
||||
_, raw, err := first.Read(bounded)
|
||||
stop()
|
||||
if err != nil {
|
||||
t.Fatal("slow claim blocked control reply")
|
||||
}
|
||||
var reply eventFrame
|
||||
if json.Unmarshal(raw, &reply) != nil || reply.Type != "ack" || reply.ReplyTo != credential {
|
||||
t.Fatal("missing ping acknowledgement")
|
||||
}
|
||||
second.CloseNow()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for len(hub.slots) == 2 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if len(hub.slots) != 1 {
|
||||
t.Fatal("disconnected consumer leaked connection slot")
|
||||
}
|
||||
third, _ := connect()
|
||||
defer third.CloseNow()
|
||||
bounded, stop = context.WithTimeout(ctx, time.Second)
|
||||
defer stop()
|
||||
if err = hub.Shutdown(bounded); err != nil {
|
||||
t.Fatal("shutdown leaked claim or connection", err)
|
||||
}
|
||||
select {
|
||||
case <-store.finished:
|
||||
default:
|
||||
t.Fatal("shutdown did not cancel in-flight claim")
|
||||
}
|
||||
if len(hub.slots) != 0 {
|
||||
t.Fatal("shutdown leaked connection slots")
|
||||
}
|
||||
}
|
||||
128
internal/transport/httpserver/events.go
Normal file
128
internal/transport/httpserver/events.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||
platform "git.yuntuo.hk/k2safe/orbit/platform/model"
|
||||
)
|
||||
|
||||
func RegisterEventSubscriptions(server *http.Server, s *events.Service) {
|
||||
mux := server.Handler.(*http.ServeMux)
|
||||
mux.HandleFunc("POST /v1/subscriptions", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if err := noEventQuery(r); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var in events.CreateInput
|
||||
if err := decodeEventBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Create(r.Context(), t, key, in)
|
||||
return out, createdStatus(out.Deduplicated), err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/subscriptions", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
queue, in, err := eventQuery(r, true)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.List(r.Context(), t, queue, in)
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/subscriptions/{id}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if err := noEventQuery(r); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Get(r.Context(), t, core.ID(r.PathValue("id")))
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("POST /v1/subscriptions/{command}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
id, command, ok := strings.Cut(r.PathValue("command"), ":")
|
||||
if !ok || command != "configure" {
|
||||
return nil, 0, fault.NotFound
|
||||
}
|
||||
if err := noEventQuery(r); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var in events.ControlInput
|
||||
if err := decodeEventBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Control(r.Context(), t, core.ID(id), key, in)
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/subscriptions/{id}/deliveries", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
_, in, err := eventQuery(r, false)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Deliveries(r.Context(), t, core.ID(r.PathValue("id")), in)
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("GET /v1/subscriptions/{id}/deliveries/{delivery}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
if err := noEventQuery(r); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Detail(r.Context(), t, core.ID(r.PathValue("id")), core.ID(r.PathValue("delivery")))
|
||||
return out, 200, err
|
||||
}))
|
||||
mux.HandleFunc("POST /v1/subscriptions/{id}/deliveries/{command}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
delivery, command, ok := strings.Cut(r.PathValue("command"), ":")
|
||||
if !ok || command != "retry" {
|
||||
return nil, 0, fault.NotFound
|
||||
}
|
||||
if err := noEventQuery(r); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var in events.RetryInput
|
||||
if err := decodeEventBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.Retry(r.Context(), t, core.ID(r.PathValue("id")), core.ID(delivery), key, in)
|
||||
return out, 200, err
|
||||
}))
|
||||
}
|
||||
func noEventQuery(r *http.Request) error {
|
||||
if r.URL.RawQuery != "" || r.URL.ForceQuery {
|
||||
return fault.Invalid("query parameters are not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func eventQuery(r *http.Request, subscriptions bool) (core.ID, events.PageInput, error) {
|
||||
var in events.PageInput
|
||||
q, err := url.ParseQuery(r.URL.RawQuery)
|
||||
if err != nil {
|
||||
return "", in, fault.Invalid("invalid query encoding")
|
||||
}
|
||||
for k, v := range q {
|
||||
if len(v) != 1 || (k != "limit" && k != "cursor" && !(subscriptions && k == "queue_id") && !(!subscriptions && k == "state")) {
|
||||
return "", in, fault.Invalid("invalid subscription query")
|
||||
}
|
||||
}
|
||||
if q.Has("limit") {
|
||||
in.Limit, err = strconv.Atoi(q.Get("limit"))
|
||||
if err != nil || in.Limit < 1 || strconv.Itoa(in.Limit) != q.Get("limit") {
|
||||
return "", in, fault.Invalid("invalid limit")
|
||||
}
|
||||
}
|
||||
in.Cursor = q.Get("cursor")
|
||||
in.State = platform.DeliveryState(q.Get("state"))
|
||||
return core.ID(q.Get("queue_id")), in, nil
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func writeError(w http.ResponseWriter, id string, err error) {
|
||||
if !errors.As(err, &known) {
|
||||
known = fault.Unavailable
|
||||
}
|
||||
status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415}[known.Code]
|
||||
status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415, "DELIVERY_LEASE_LOST": 409, "EVENT_HISTORY_EXPIRED": 410, "SUBSCRIPTION_LIMIT": 409}[known.Code]
|
||||
if status == 0 {
|
||||
status = 503
|
||||
}
|
||||
|
||||
88
platform/events/queries.go
Normal file
88
platform/events/queries.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
platform "git.yuntuo.hk/k2safe/orbit/platform/model"
|
||||
)
|
||||
|
||||
type PageInput struct {
|
||||
Limit int
|
||||
Cursor string
|
||||
State platform.DeliveryState
|
||||
}
|
||||
type SubscriptionPage struct {
|
||||
Items []Subscription `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
type DeliveryPage struct {
|
||||
Items []Delivery `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
type DeliveryDetail struct {
|
||||
Delivery Delivery `json:"delivery"`
|
||||
Event Event `json:"event"`
|
||||
}
|
||||
|
||||
// ConsumerBinding 由认证关系派生,不接受客户端指定应用、队列或凭据归属。
|
||||
type ConsumerBinding struct {
|
||||
ApplicationID core.ID
|
||||
SubscriptionID core.ID
|
||||
QueueID core.ID
|
||||
Enabled bool
|
||||
ServerTime time.Time
|
||||
}
|
||||
|
||||
func normalizePage(in PageInput) (PageInput, error) {
|
||||
if in.Limit == 0 {
|
||||
in.Limit = 20
|
||||
}
|
||||
if in.Limit < 1 || in.Limit > 100 || len(in.Cursor) > 2048 {
|
||||
return in, fault.Invalid("invalid event page")
|
||||
}
|
||||
switch in.State {
|
||||
case "", platform.DeliveryPending, platform.DeliveryLeased, platform.DeliveryAcked, platform.DeliveryExpired:
|
||||
default:
|
||||
return in, fault.Invalid("invalid delivery state")
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
func (s *Service) List(ctx context.Context, t auth.Token, queue core.ID, in PageInput) (SubscriptionPage, error) {
|
||||
in, err := normalizePage(in)
|
||||
if err != nil {
|
||||
return SubscriptionPage{}, err
|
||||
}
|
||||
if queue.Validate() != nil || in.State != "" {
|
||||
return SubscriptionPage{}, fault.Invalid("invalid subscription query")
|
||||
}
|
||||
return s.store.ListSubscriptions(ctx, t, queue, in)
|
||||
}
|
||||
func (s *Service) Deliveries(ctx context.Context, t auth.Token, id core.ID, in PageInput) (DeliveryPage, error) {
|
||||
in, err := normalizePage(in)
|
||||
if err != nil {
|
||||
return DeliveryPage{}, err
|
||||
}
|
||||
if id.Validate() != nil {
|
||||
return DeliveryPage{}, fault.Invalid("invalid subscription_id")
|
||||
}
|
||||
return s.store.ListDeliveries(ctx, t, id, in)
|
||||
}
|
||||
func (s *Service) Detail(ctx context.Context, t auth.Token, sub, id core.ID) (DeliveryDetail, error) {
|
||||
if sub.Validate() != nil || id.Validate() != nil {
|
||||
return DeliveryDetail{}, fault.Invalid("invalid subscription_id or delivery_id")
|
||||
}
|
||||
return s.store.GetDeliveryDetail(ctx, t, sub, id)
|
||||
}
|
||||
func (s *Service) Authorize(ctx context.Context, t auth.Token) error {
|
||||
return s.store.AuthorizeEvents(ctx, t)
|
||||
}
|
||||
func (s *Service) Bind(ctx context.Context, t auth.Token, id core.ID) (ConsumerBinding, error) {
|
||||
if id.Validate() != nil {
|
||||
return ConsumerBinding{}, fault.Invalid("invalid subscription_id")
|
||||
}
|
||||
return s.store.BindConsumer(ctx, t, id)
|
||||
}
|
||||
@@ -99,6 +99,7 @@ type ControlInput struct {
|
||||
}
|
||||
|
||||
type Delivery struct {
|
||||
CreatedAt time.Time `json:"created_at,omitzero"`
|
||||
ID core.ID `json:"id"`
|
||||
SubscriptionID core.ID `json:"subscription_id"`
|
||||
EventID core.ID `json:"event_id"`
|
||||
@@ -152,6 +153,11 @@ type RetryResult struct {
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
AuthorizeEvents(context.Context, auth.Token) error
|
||||
BindConsumer(context.Context, auth.Token, core.ID) (ConsumerBinding, error)
|
||||
ListSubscriptions(context.Context, auth.Token, core.ID, PageInput) (SubscriptionPage, error)
|
||||
ListDeliveries(context.Context, auth.Token, core.ID, PageInput) (DeliveryPage, error)
|
||||
GetDeliveryDetail(context.Context, auth.Token, core.ID, core.ID) (DeliveryDetail, error)
|
||||
CreateSubscription(context.Context, auth.Token, string, CreateInput) (SubscriptionResult, error)
|
||||
GetSubscription(context.Context, auth.Token, core.ID) (Subscription, error)
|
||||
ControlSubscription(context.Context, auth.Token, core.ID, string, ControlInput) (SubscriptionResult, error)
|
||||
|
||||
569
protocol/eventsv1/events.schema.json
Normal file
569
protocol/eventsv1/events.schema.json
Normal file
@@ -0,0 +1,569 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.yuntuo.hk/k2safe/orbit/schema/events.v1.json",
|
||||
"title": "Orbit result events v1",
|
||||
"description": "消息最大512KiB。JSON重复键、非规范uint64和非规范base64url额外由实际解码器拒绝。",
|
||||
"$defs": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{32}$"
|
||||
},
|
||||
"counter": {
|
||||
"type": "string",
|
||||
"pattern": "^(?:[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]{1}|1844674407370955161[0-4]|18446744073709551615)$",
|
||||
"description": "1 至 18446744073709551615 的规范十进制字符串,不允许前导零。"
|
||||
},
|
||||
"time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"secret": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$"
|
||||
},
|
||||
"hello": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subscription_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"capacity": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 32
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"subscription_id",
|
||||
"capacity"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"welcome": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"subscription_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"capacity": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 32
|
||||
},
|
||||
"lease_duration_ms": {
|
||||
"const": 30000
|
||||
},
|
||||
"heartbeat_interval_ms": {
|
||||
"const": 15000
|
||||
},
|
||||
"server_time": {
|
||||
"$ref": "#/$defs/time"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"session_id",
|
||||
"subscription_id",
|
||||
"capacity",
|
||||
"lease_duration_ms",
|
||||
"heartbeat_interval_ms",
|
||||
"server_time",
|
||||
"enabled"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ackInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"delivery_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"epoch": {
|
||||
"$ref": "#/$defs/counter"
|
||||
},
|
||||
"lease_secret": {
|
||||
"$ref": "#/$defs/secret"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"delivery_id",
|
||||
"epoch",
|
||||
"lease_secret"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ackResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"delivery_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"event_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"epoch": {
|
||||
"$ref": "#/$defs/counter"
|
||||
},
|
||||
"acknowledged_at": {
|
||||
"$ref": "#/$defs/time"
|
||||
},
|
||||
"deduplicated": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"delivery_id",
|
||||
"event_id",
|
||||
"epoch",
|
||||
"acknowledged_at",
|
||||
"deduplicated"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"pong": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server_time": {
|
||||
"$ref": "#/$defs/time"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"server_time",
|
||||
"enabled"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"snapshot": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": 1
|
||||
},
|
||||
"task_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"queue_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"state": {
|
||||
"enum": [
|
||||
"SUCCEEDED",
|
||||
"FAILED",
|
||||
"CANCELLED",
|
||||
"NEEDS_REVIEW"
|
||||
]
|
||||
},
|
||||
"revision": {
|
||||
"$ref": "#/$defs/counter"
|
||||
},
|
||||
"result": {},
|
||||
"effect_uncertain": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"current_attempt_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"schema_version",
|
||||
"task_id",
|
||||
"queue_id",
|
||||
"state",
|
||||
"revision",
|
||||
"effect_uncertain"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"event": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"sequence": {
|
||||
"$ref": "#/$defs/counter"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"task.succeeded",
|
||||
"task.failed",
|
||||
"task.cancelled",
|
||||
"task.needs_review"
|
||||
]
|
||||
},
|
||||
"aggregate_kind": {
|
||||
"const": "task"
|
||||
},
|
||||
"aggregate_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"aggregate_revision": {
|
||||
"$ref": "#/$defs/counter"
|
||||
},
|
||||
"occurred_at": {
|
||||
"$ref": "#/$defs/time"
|
||||
},
|
||||
"details": {
|
||||
"type": "object"
|
||||
},
|
||||
"snapshot": {
|
||||
"$ref": "#/$defs/snapshot"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"sequence",
|
||||
"type",
|
||||
"aggregate_kind",
|
||||
"aggregate_id",
|
||||
"aggregate_revision",
|
||||
"occurred_at",
|
||||
"details",
|
||||
"snapshot"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"leasedDelivery": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"$ref": "#/$defs/time"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"subscription_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"event_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"sequence": {
|
||||
"$ref": "#/$defs/counter"
|
||||
},
|
||||
"revision": {
|
||||
"$ref": "#/$defs/counter"
|
||||
},
|
||||
"state": {
|
||||
"const": "LEASED"
|
||||
},
|
||||
"available_at": {
|
||||
"$ref": "#/$defs/time"
|
||||
},
|
||||
"lease_epoch": {
|
||||
"$ref": "#/$defs/counter"
|
||||
},
|
||||
"lease_until": {
|
||||
"$ref": "#/$defs/time"
|
||||
},
|
||||
"attempts": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 1000000
|
||||
},
|
||||
"attempt_budget": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 1000000
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"created_at",
|
||||
"id",
|
||||
"subscription_id",
|
||||
"event_id",
|
||||
"sequence",
|
||||
"revision",
|
||||
"state",
|
||||
"available_at",
|
||||
"lease_epoch",
|
||||
"lease_until",
|
||||
"attempts",
|
||||
"attempt_budget"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"grant": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"delivery": {
|
||||
"$ref": "#/$defs/leasedDelivery"
|
||||
},
|
||||
"event": {
|
||||
"$ref": "#/$defs/event"
|
||||
},
|
||||
"lease_secret": {
|
||||
"$ref": "#/$defs/secret"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"delivery",
|
||||
"event",
|
||||
"lease_secret"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"enum": [
|
||||
"INVALID_ARGUMENT",
|
||||
"UNAUTHENTICATED",
|
||||
"PERMISSION_DENIED",
|
||||
"NOT_FOUND",
|
||||
"STATE_CONFLICT",
|
||||
"DELIVERY_LEASE_LOST",
|
||||
"EVENT_HISTORY_EXPIRED",
|
||||
"UNAVAILABLE"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"clientFrame": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"protocol_version": {
|
||||
"const": "1"
|
||||
},
|
||||
"type": {
|
||||
"const": "events.hello"
|
||||
},
|
||||
"message_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"body": {
|
||||
"$ref": "#/$defs/hello"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"protocol_version",
|
||||
"type",
|
||||
"message_id",
|
||||
"body"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"protocol_version": {
|
||||
"const": "1"
|
||||
},
|
||||
"type": {
|
||||
"const": "event.ack"
|
||||
},
|
||||
"message_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"session_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"body": {
|
||||
"$ref": "#/$defs/ackInput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"protocol_version",
|
||||
"type",
|
||||
"message_id",
|
||||
"session_id",
|
||||
"body"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"protocol_version": {
|
||||
"const": "1"
|
||||
},
|
||||
"type": {
|
||||
"const": "events.ping"
|
||||
},
|
||||
"message_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"session_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"body": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"protocol_version",
|
||||
"type",
|
||||
"message_id",
|
||||
"session_id",
|
||||
"body"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"serverFrame": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"protocol_version": {
|
||||
"const": "1"
|
||||
},
|
||||
"type": {
|
||||
"const": "events.welcome"
|
||||
},
|
||||
"message_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"session_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"reply_to": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"body": {
|
||||
"$ref": "#/$defs/welcome"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"protocol_version",
|
||||
"type",
|
||||
"message_id",
|
||||
"session_id",
|
||||
"reply_to",
|
||||
"body"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"protocol_version": {
|
||||
"const": "1"
|
||||
},
|
||||
"type": {
|
||||
"const": "event.deliver"
|
||||
},
|
||||
"message_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"session_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"body": {
|
||||
"$ref": "#/$defs/grant"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"protocol_version",
|
||||
"type",
|
||||
"message_id",
|
||||
"session_id",
|
||||
"body"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"protocol_version": {
|
||||
"const": "1"
|
||||
},
|
||||
"type": {
|
||||
"const": "ack"
|
||||
},
|
||||
"message_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"session_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"reply_to": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"body": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ackResult"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/pong"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"protocol_version",
|
||||
"type",
|
||||
"message_id",
|
||||
"session_id",
|
||||
"reply_to",
|
||||
"body"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"protocol_version": {
|
||||
"const": "1"
|
||||
},
|
||||
"type": {
|
||||
"const": "error"
|
||||
},
|
||||
"message_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"session_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/$defs/error"
|
||||
},
|
||||
"reply_to": {
|
||||
"$ref": "#/$defs/id"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"protocol_version",
|
||||
"type",
|
||||
"message_id",
|
||||
"error"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/clientFrame"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/serverFrame"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
protocol/eventsv1/schema.go
Normal file
9
protocol/eventsv1/schema.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// Package eventsv1 提供可靠结果订阅v1的机器可读契约;授权和状态由数据库事务决定。
|
||||
package eventsv1
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// Schema 分别定义客户端和服务端消息,禁止将响应字段伪装成请求。
|
||||
//
|
||||
//go:embed events.schema.json
|
||||
var Schema string
|
||||
@@ -48,10 +48,11 @@ done
|
||||
if [ -z "$TASK_HTTP_PORT" ]; then printf '%s\n' 'Bootstrap HTTP server did not start.' >&2; exit 1; fi
|
||||
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" "$TASK_GO" run ./examples/health)
|
||||
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/sdk.credentials.json" "$TASK_GO" run ./examples/tasks)
|
||||
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap --application sdk-worker --namespace default --environment test --queue worker-examples --worker-access --schedule-access --output "$TASK_TEST_DIR/worker.credentials.json"
|
||||
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap --application sdk-worker --namespace default --environment test --queue worker-examples --worker-access --schedule-access --event-access --output "$TASK_TEST_DIR/worker.credentials.json"
|
||||
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run 'IntegrationWorker(Connection|Runtime)' -v .)
|
||||
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationSchedule' -v .)
|
||||
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run '^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_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/history)
|
||||
@@ -61,6 +62,7 @@ ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap -
|
||||
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/resources)
|
||||
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/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/subscriptions)
|
||||
|
||||
|
||||
# 应用配额场景使用独立应用与两个命名空间,普通 Worker 凭据不扩权。
|
||||
|
||||
109
sdk/go/examples/subscriptions/main.go
Normal file
109
sdk/go/examples/subscriptions/main.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// subscriptions 演示订阅管理和幂等回执;事件消费及Inbox完整示例随后独立提供。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"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 err = json.Unmarshal(raw, &credential); err != nil {
|
||||
return err
|
||||
}
|
||||
if token == "" {
|
||||
token = credential.Token
|
||||
}
|
||||
if queue == "" {
|
||||
queue = credential.QueueID
|
||||
}
|
||||
}
|
||||
if token == "" || queue == "" {
|
||||
return errors.New("provide an event-enabled credential and queue via ORBIT_CREDENTIALS_FILE or environment")
|
||||
}
|
||||
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
var random [16]byte
|
||||
if _, err = rand.Read(random[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
operation := hex.EncodeToString(random[:])
|
||||
input := orbit.CreateSubscriptionRequest{Name: "results-" + operation, QueueID: queue, Types: []string{"task.succeeded", "task.failed", "task.cancelled", "task.needs_review"}, MaxPending: 100, MaxAttempts: 20}
|
||||
created, err := client.CreateSubscription(ctx, "create-"+operation, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
duplicate, err := client.CreateSubscription(ctx, "create-"+operation, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !duplicate.Deduplicated || duplicate.Subscription.ID != created.Subscription.ID {
|
||||
return errors.New("subscription identity was not preserved")
|
||||
}
|
||||
sub := created.Subscription
|
||||
configure := func(enabled bool, key string) error {
|
||||
out, err := client.ConfigureSubscription(ctx, sub.ID, key, orbit.ConfigureSubscriptionRequest{ExpectedRevision: sub.Revision, Enabled: enabled, Reason: "subscription management example"})
|
||||
if err == nil {
|
||||
sub = out.Subscription
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err = configure(false, "pause-"+operation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = configure(true, "resume-"+operation); err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := client.GetSubscription(ctx, sub.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !current.Enabled || current.Revision != 3 {
|
||||
return errors.New("subscription control state mismatch")
|
||||
}
|
||||
page, err := client.ListSubscriptions(ctx, queue, orbit.SubscriptionPageRequest{Limit: 100})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for _, item := range page.Items {
|
||||
found = found || item.ID == sub.ID
|
||||
}
|
||||
if !found {
|
||||
return errors.New("subscription absent from scoped listing")
|
||||
}
|
||||
// 示例不启动消费者。结束时暂停本次订阅,保留配置和审计以便核查。
|
||||
if err = configure(false, "finish-"+operation); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("subscription=%s duplicate=true resumed_revision=3\n", sub.ID)
|
||||
fmt.Printf("final_enabled=%t revision=%d pending=%d\n", sub.Enabled, sub.Revision, sub.PendingCount)
|
||||
return nil
|
||||
}
|
||||
126
sdk/go/subscription_types.go
Normal file
126
sdk/go/subscription_types.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateSubscriptionRequest 冻结一个队列的结果类型;已有订阅不能改写过滤或起点。
|
||||
type CreateSubscriptionRequest struct {
|
||||
Name string `json:"name"` // Name 命名空间内唯一名称,1至128个ASCII字符。
|
||||
QueueID string `json:"queue_id"` // QueueID 固定队列,创建和消费都需要相应队列权限。
|
||||
Types []string `json:"types,omitempty"` // Types 四种任务结果的子集;省略使用全部。
|
||||
FromSequence *uint64 `json:"from_sequence,string,omitempty"` // FromSequence 之后的结果纳入订阅;nil从创建时流头开始,指向0从迁移13后的现存日志开始。
|
||||
MaxPending uint32 `json:"max_pending,omitempty"` // MaxPending 未确认窗口,0使用100;自定义1至1000。
|
||||
MaxAttempts uint32 `json:"max_attempts,omitempty"` // MaxAttempts 初始交付次数预算,0使用20;自定义1至100。
|
||||
}
|
||||
|
||||
// Subscription 仅包含订阅配置和窗口进度,不包含任何凭据原文。
|
||||
type Subscription struct {
|
||||
ID string `json:"id"`
|
||||
QueueID string `json:"queue_id"`
|
||||
CredentialID string `json:"credential_id"`
|
||||
Name string `json:"name"`
|
||||
Revision uint64 `json:"revision,string"` // Revision 配置版本,不随领取和ACK计数变化。
|
||||
Enabled bool `json:"enabled"` // Enabled 为false时停止新领取,已有证明仍可确认。
|
||||
Types []string `json:"types"`
|
||||
CursorSequence uint64 `json:"cursor_sequence,string"` // CursorSequence 已扫描并建立交付的位置,不等于确认位置。
|
||||
PendingCount uint32 `json:"pending_count"` // PendingCount 包括PENDING、LEASED和EXPIRED。
|
||||
MaxPending uint32 `json:"max_pending"`
|
||||
MaxAttempts uint32 `json:"max_attempts"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
type SubscriptionResult struct {
|
||||
Subscription Subscription `json:"subscription"`
|
||||
Deduplicated bool `json:"deduplicated"`
|
||||
}
|
||||
|
||||
// ConfigureSubscriptionRequest 显式启停,不改动冻结的队列、过滤和预算配置。
|
||||
type ConfigureSubscriptionRequest struct {
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
type SubscriptionPageRequest struct {
|
||||
Limit int
|
||||
Cursor string
|
||||
}
|
||||
type SubscriptionPage struct {
|
||||
Items []Subscription `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
type DeliveryState string
|
||||
|
||||
const (
|
||||
DeliveryPending DeliveryState = "PENDING"
|
||||
DeliveryLeased DeliveryState = "LEASED"
|
||||
DeliveryAcked DeliveryState = "ACKED"
|
||||
DeliveryExpired DeliveryState = "EXPIRED"
|
||||
)
|
||||
|
||||
// EventDelivery 是一条结果的可靠交付状态;详情查询不返回交付秘密。
|
||||
type EventDelivery struct {
|
||||
CreatedAt time.Time `json:"created_at,omitzero"`
|
||||
ID string `json:"id"`
|
||||
SubscriptionID string `json:"subscription_id"`
|
||||
EventID string `json:"event_id"`
|
||||
Sequence uint64 `json:"sequence,string"`
|
||||
Revision uint64 `json:"revision,string"`
|
||||
State DeliveryState `json:"state"`
|
||||
AvailableAt time.Time `json:"available_at"`
|
||||
LeaseEpoch uint64 `json:"lease_epoch,string"`
|
||||
LeaseUntil *time.Time `json:"lease_until,omitempty"`
|
||||
Attempts uint32 `json:"attempts"`
|
||||
AttemptBudget uint32 `json:"attempt_budget"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||
}
|
||||
|
||||
// TaskResultSnapshot 冻结事件发生时的任务结果;NEEDS_REVIEW不能当作成功或最终失败。
|
||||
type TaskResultSnapshot struct {
|
||||
SchemaVersion uint32 `json:"schema_version"`
|
||||
TaskID string `json:"task_id"`
|
||||
QueueID string `json:"queue_id"`
|
||||
State TaskState `json:"state"`
|
||||
Revision uint64 `json:"revision,string"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
EffectUncertain bool `json:"effect_uncertain"`
|
||||
CurrentAttemptID *string `json:"current_attempt_id,omitempty"`
|
||||
}
|
||||
|
||||
// ResultEvent 的ID用于跨连接及重复投递去重,序号只在当前命名空间内有序。
|
||||
type ResultEvent struct {
|
||||
ID string `json:"id"`
|
||||
Sequence uint64 `json:"sequence,string"`
|
||||
Type string `json:"type"`
|
||||
AggregateKind string `json:"aggregate_kind"`
|
||||
AggregateID string `json:"aggregate_id"`
|
||||
AggregateRevision uint64 `json:"aggregate_revision,string"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Details json.RawMessage `json:"details"`
|
||||
Snapshot TaskResultSnapshot `json:"snapshot"`
|
||||
}
|
||||
type DeliveryDetail struct {
|
||||
Delivery EventDelivery `json:"delivery"`
|
||||
Event ResultEvent `json:"event"`
|
||||
}
|
||||
type DeliveryPageRequest struct {
|
||||
Limit int
|
||||
Cursor string
|
||||
State DeliveryState
|
||||
}
|
||||
type DeliveryPage struct {
|
||||
Items []EventDelivery `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
|
||||
// RetryDeliveryRequest 只给EXPIRED追加预算;次数、代次、事件及交付身份不重置。
|
||||
type RetryDeliveryRequest struct {
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"`
|
||||
Reason string `json:"reason"`
|
||||
AdditionalAttempts uint32 `json:"additional_attempts"`
|
||||
}
|
||||
type RetryDeliveryResult struct {
|
||||
Delivery EventDelivery `json:"delivery"`
|
||||
Deduplicated bool `json:"deduplicated"`
|
||||
}
|
||||
96
sdk/go/subscriptions.go
Normal file
96
sdk/go/subscriptions.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// CreateSubscription 保留原幂等键和请求内容重报;SDK不会自动生成新键或重新指定起点。
|
||||
func (c *Client) CreateSubscription(ctx context.Context, key string, in CreateSubscriptionRequest) (SubscriptionResult, error) {
|
||||
var out SubscriptionResult
|
||||
if !validID(in.QueueID) {
|
||||
return out, errors.New("invalid queue id")
|
||||
}
|
||||
err := c.call(ctx, "POST", "/v1/subscriptions", key, in, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) GetSubscription(ctx context.Context, id string) (Subscription, error) {
|
||||
var out Subscription
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid subscription id")
|
||||
}
|
||||
err := c.call(ctx, "GET", "/v1/subscriptions/"+id, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) ConfigureSubscription(ctx context.Context, id, key string, in ConfigureSubscriptionRequest) (SubscriptionResult, error) {
|
||||
var out SubscriptionResult
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid subscription id")
|
||||
}
|
||||
err := c.call(ctx, "POST", "/v1/subscriptions/"+id+":configure", key, in, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) ListSubscriptions(ctx context.Context, queue string, in SubscriptionPageRequest) (SubscriptionPage, error) {
|
||||
var out SubscriptionPage
|
||||
if !validID(queue) {
|
||||
return out, errors.New("invalid queue id")
|
||||
}
|
||||
q, err := subscriptionQuery(in.Limit, in.Cursor)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
q.Set("queue_id", queue)
|
||||
err = c.call(ctx, "GET", "/v1/subscriptions?"+q.Encode(), "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) ListEventDeliveries(ctx context.Context, id string, in DeliveryPageRequest) (DeliveryPage, error) {
|
||||
var out DeliveryPage
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid subscription id")
|
||||
}
|
||||
q, err := subscriptionQuery(in.Limit, in.Cursor)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
switch in.State {
|
||||
case "", DeliveryPending, DeliveryLeased, DeliveryAcked, DeliveryExpired:
|
||||
default:
|
||||
return out, errors.New("invalid delivery state")
|
||||
}
|
||||
if in.State != "" {
|
||||
q.Set("state", string(in.State))
|
||||
}
|
||||
err = c.call(ctx, "GET", "/v1/subscriptions/"+id+"/deliveries?"+q.Encode(), "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) GetEventDelivery(ctx context.Context, sub, id string) (DeliveryDetail, error) {
|
||||
var out DeliveryDetail
|
||||
if !validID(sub) || !validID(id) {
|
||||
return out, errors.New("invalid subscription or delivery id")
|
||||
}
|
||||
err := c.call(ctx, "GET", "/v1/subscriptions/"+sub+"/deliveries/"+id, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) RetryEventDelivery(ctx context.Context, sub, id, key string, in RetryDeliveryRequest) (RetryDeliveryResult, error) {
|
||||
var out RetryDeliveryResult
|
||||
if !validID(sub) || !validID(id) {
|
||||
return out, errors.New("invalid subscription or delivery id")
|
||||
}
|
||||
err := c.call(ctx, "POST", "/v1/subscriptions/"+sub+"/deliveries/"+id+":retry", key, in, &out)
|
||||
return out, err
|
||||
}
|
||||
func subscriptionQuery(limit int, cursor string) (url.Values, error) {
|
||||
q := url.Values{}
|
||||
if limit < 0 || limit > 100 || len(cursor) > 2048 {
|
||||
return nil, errors.New("invalid subscription page")
|
||||
}
|
||||
if limit != 0 {
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
}
|
||||
if cursor != "" {
|
||||
q.Set("cursor", cursor)
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
181
sdk/go/subscriptions_integration_test.go
Normal file
181
sdk/go/subscriptions_integration_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// 该测试用原始WSS帧驱动交付,用于验证管理SDK;不是待交付的订阅连接SDK替代品。
|
||||
type managementEventFrame struct {
|
||||
Version string `json:"protocol_version"`
|
||||
Type string `json:"type"`
|
||||
MessageID string `json:"message_id"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
ReplyTo string `json:"reply_to,omitempty"`
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
Error *struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func TestIntegrationSubscriptionManagementSDK(t *testing.T) {
|
||||
api, _, _ := integrationWorkerClient(t)
|
||||
ctx, stop := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer stop()
|
||||
queue, err := api.CreateQueue(ctx, workerTestID(t), CreateQueueRequest{Name: "subscription-" + workerTestID(t), MaxConcurrency: 1, RatePerSecond: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := workerTestID(t)
|
||||
in := CreateSubscriptionRequest{Name: "results-" + workerTestID(t), QueueID: queue.Queue.ID, MaxPending: 1, MaxAttempts: 1, Types: []string{"task.cancelled"}}
|
||||
created, err := api.CreateSubscription(ctx, key, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
duplicate, err := api.CreateSubscription(ctx, key, in)
|
||||
if err != nil || !duplicate.Deduplicated || duplicate.Subscription.ID != created.Subscription.ID {
|
||||
t.Fatal("SDK subscription create replay", err)
|
||||
}
|
||||
sub := created.Subscription
|
||||
current, err := api.GetSubscription(ctx, sub.ID)
|
||||
if err != nil || current.ID != sub.ID || !current.Enabled {
|
||||
t.Fatal("SDK subscription get", err)
|
||||
}
|
||||
page, err := api.ListSubscriptions(ctx, queue.Queue.ID, SubscriptionPageRequest{Limit: 1})
|
||||
if err != nil || len(page.Items) != 1 || page.Items[0].ID != sub.ID {
|
||||
t.Fatal("SDK subscription list", err)
|
||||
}
|
||||
paused, err := api.ConfigureSubscription(ctx, sub.ID, workerTestID(t), ConfigureSubscriptionRequest{ExpectedRevision: sub.Revision, Reason: "maintenance", Enabled: false})
|
||||
if err != nil || paused.Subscription.Enabled {
|
||||
t.Fatal("SDK pause", err)
|
||||
}
|
||||
resumed, err := api.ConfigureSubscription(ctx, sub.ID, workerTestID(t), ConfigureSubscriptionRequest{ExpectedRevision: paused.Subscription.Revision, Reason: "ready", Enabled: true})
|
||||
if err != nil || !resumed.Subscription.Enabled {
|
||||
t.Fatal("SDK resume", err)
|
||||
}
|
||||
task, err := api.SubmitTask(ctx, workerTestID(t), SubmitTaskRequest{QueueID: queue.Queue.ID, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = api.CancelTask(ctx, task.Task.ID, workerTestID(t), CancelTaskRequest{ExpectedRevision: task.Task.Revision, Reason: "create result"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
send := func(conn *websocket.Conn, session, kind string, body any) string {
|
||||
t.Helper()
|
||||
id := workerTestID(t)
|
||||
raw, err := marshalWire(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frame, err := marshalWire(managementEventFrame{Version: "1", Type: kind, MessageID: id, SessionID: session, Body: raw})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = conn.Write(ctx, websocket.MessageText, frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
read := func(conn *websocket.Conn) managementEventFrame {
|
||||
t.Helper()
|
||||
bounded, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
_, raw, err := conn.Read(bounded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var frame managementEventFrame
|
||||
if json.Unmarshal(raw, &frame) != nil || frame.Version != "1" || !validID(frame.MessageID) {
|
||||
t.Fatal("invalid management test frame")
|
||||
}
|
||||
return frame
|
||||
}
|
||||
connect := func() (*websocket.Conn, string) {
|
||||
t.Helper()
|
||||
headers := http.Header{}
|
||||
headers.Set("Authorization", "Bearer "+api.token)
|
||||
endpoint := "ws" + strings.TrimPrefix(api.baseURL, "http") + "/v1/events/connect"
|
||||
conn, _, err := websocket.Dial(ctx, endpoint, &websocket.DialOptions{HTTPClient: api.http, HTTPHeader: headers, Subprotocols: []string{"orbit.events.v1"}})
|
||||
if err != nil {
|
||||
t.Fatal("management test event dial failed")
|
||||
}
|
||||
t.Cleanup(func() { conn.CloseNow() })
|
||||
conn.SetReadLimit(512 << 10)
|
||||
msg := send(conn, "", "events.hello", map[string]any{"subscription_id": sub.ID, "capacity": 1})
|
||||
welcome := read(conn)
|
||||
if welcome.Type != "events.welcome" || welcome.ReplyTo != msg || !validID(welcome.SessionID) {
|
||||
t.Fatal("management test welcome missing")
|
||||
}
|
||||
return conn, welcome.SessionID
|
||||
}
|
||||
type grant struct {
|
||||
Delivery EventDelivery `json:"delivery"`
|
||||
Event ResultEvent `json:"event"`
|
||||
Secret string `json:"lease_secret"`
|
||||
}
|
||||
a, _ := connect()
|
||||
frame := read(a)
|
||||
var first grant
|
||||
if frame.Type != "event.deliver" || json.Unmarshal(frame.Body, &first) != nil || first.Event.Snapshot.TaskID != task.Task.ID {
|
||||
t.Fatal("management test delivery missing")
|
||||
}
|
||||
a.CloseNow()
|
||||
b, session := connect()
|
||||
// 使用实际30秒租约,不改数据库时钟或手工更新租约时间。
|
||||
var expired EventDelivery
|
||||
for {
|
||||
deliveries, err := api.ListEventDeliveries(ctx, sub.ID, DeliveryPageRequest{State: DeliveryExpired, Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(deliveries.Items) == 1 {
|
||||
expired = deliveries.Items[0]
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("delivery did not expire through real WSS recovery")
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
detail, err := api.GetEventDelivery(ctx, sub.ID, expired.ID)
|
||||
if err != nil || detail.Event.ID != first.Event.ID || detail.Delivery.Attempts != 1 || detail.Event.Snapshot.State != TaskCancelled {
|
||||
t.Fatal("SDK delivery detail", err)
|
||||
}
|
||||
retryKey := workerTestID(t)
|
||||
retry := RetryDeliveryRequest{ExpectedRevision: expired.Revision, Reason: "receiver fixed", AdditionalAttempts: 1}
|
||||
restored, err := api.RetryEventDelivery(ctx, sub.ID, expired.ID, retryKey, retry)
|
||||
if err != nil || restored.Delivery.Attempts != 1 || restored.Delivery.AttemptBudget != 2 {
|
||||
t.Fatal("SDK retry reset history", err)
|
||||
}
|
||||
replay, err := api.RetryEventDelivery(ctx, sub.ID, expired.ID, retryKey, retry)
|
||||
if err != nil || !replay.Deduplicated || replay.Delivery.Revision != restored.Delivery.Revision {
|
||||
t.Fatal("SDK retry replay", err)
|
||||
}
|
||||
frame = read(b)
|
||||
var second grant
|
||||
if frame.Type != "event.deliver" || json.Unmarshal(frame.Body, &second) != nil || second.Event.ID != first.Event.ID || second.Delivery.LeaseEpoch != 2 {
|
||||
t.Fatal("manual retry failed to deliver")
|
||||
}
|
||||
ackBody := map[string]any{"delivery_id": second.Delivery.ID, "epoch": "2", "lease_secret": second.Secret}
|
||||
msg := send(b, session, "event.ack", ackBody)
|
||||
ack := read(b)
|
||||
if ack.Type != "ack" || ack.ReplyTo != msg {
|
||||
t.Fatal("management test ACK failed")
|
||||
}
|
||||
delivered, err := api.ListEventDeliveries(ctx, sub.ID, DeliveryPageRequest{State: DeliveryAcked})
|
||||
if err != nil || len(delivered.Items) != 1 || delivered.Items[0].ID != expired.ID {
|
||||
t.Fatal("SDK acknowledged history", err)
|
||||
}
|
||||
current, err = api.GetSubscription(ctx, sub.ID)
|
||||
if err != nil || current.PendingCount != 0 || current.Revision != resumed.Subscription.Revision {
|
||||
t.Fatal("SDK pending count or config version", err)
|
||||
}
|
||||
t.Log("management SDK exercised create/get/list/control/detail/expired retry with a real 30-second delivery lease")
|
||||
}
|
||||
69
sdk/go/subscriptions_test.go
Normal file
69
sdk/go/subscriptions_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSubscriptionHTTPWireAndPathValidation(t *testing.T) {
|
||||
id := strings.Repeat("1", 32)
|
||||
zero := uint64(0)
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" || r.Header.Get("Idempotency-Key") != "stable" {
|
||||
t.Error("missing protected request headers")
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/v1/subscriptions":
|
||||
if string(raw["from_sequence"]) != `"0"` {
|
||||
t.Error("explicit zero cursor omitted or encoded as number")
|
||||
}
|
||||
case "/v1/subscriptions/" + id + ":configure":
|
||||
if string(raw["enabled"]) != "false" || string(raw["expected_revision"]) != `"9007199254740993"` {
|
||||
t.Error("control intent or integer precision lost")
|
||||
}
|
||||
default:
|
||||
t.Error("wrong subscription route")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"request_id":"` + id + `","data":{"subscription":{"id":"` + id + `","revision":"9007199254740993"},"deduplicated":false}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
c, err := NewClient(server.URL, Options{Token: "test-token"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := c.CreateSubscription(context.Background(), "stable", CreateSubscriptionRequest{Name: "results", QueueID: id, FromSequence: &zero})
|
||||
if err != nil || out.Subscription.Revision != 9007199254740993 {
|
||||
t.Fatal("subscription counter precision", err)
|
||||
}
|
||||
if _, err = c.ConfigureSubscription(context.Background(), id, "stable", ConfigureSubscriptionRequest{ExpectedRevision: 9007199254740993, Enabled: false, Reason: "pause"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, bad := range []string{"", id + "/deliveries", "../events"} {
|
||||
if _, err := c.GetSubscription(context.Background(), bad); err == nil {
|
||||
t.Fatal("accepted invalid subscription path")
|
||||
}
|
||||
if _, err := c.GetEventDelivery(context.Background(), id, bad); err == nil {
|
||||
t.Fatal("accepted invalid delivery path")
|
||||
}
|
||||
}
|
||||
if _, err = c.ListSubscriptions(context.Background(), id, SubscriptionPageRequest{Limit: 101}); err == nil {
|
||||
t.Fatal("accepted unbounded page")
|
||||
}
|
||||
if _, err = c.ListEventDeliveries(context.Background(), id, DeliveryPageRequest{State: "INVALID"}); err == nil {
|
||||
t.Fatal("accepted invalid delivery state")
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatal("invalid request reached network")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user