feat: expose result subscription HTTP and WebSocket APIs

This commit is contained in:
2026-09-17 19:57:55 +08:00
parent 1c3300cc5b
commit 8485a662b2
38 changed files with 3462 additions and 26 deletions

View File

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

View File

@@ -8,7 +8,7 @@ Orbit 是面向多个业务项目的通用任务调度与流程编排平台,
## 当前进度
已完成 P0–P2 阶段验收:35 个数据模型、版本化迁移、凭据及范围校验、队列与任务 API、WSS 执行、独立 SDK、自动 Worker 和 [执行尝试/证据查询](docs/api/execution-history.md)。[Worker Schema](docs/api/worker-schema.md) 与真实进程强杀恢复回归已通过;[安全重试](docs/api/retries.md)、[定时计划](docs/api/schedules.md)、[运行中取消](docs/api/cancellation.md) 、[队列资源控制](docs/api/queues.md) 、[应用共享配额](docs/api/application-resources.md) 、[资源键互斥](docs/api/keyed-concurrency.md) 和 [优先级/节点公平派发](docs/api/priority-and-fairness.md) 已接入服务端和 SDK,[可靠结果订阅](docs/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)。

View File

@@ -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) {

View File

@@ -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`。

View File

@@ -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)。

View File

@@ -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)

View File

@@ -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 的可靠订阅能力。

View File

@@ -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。健康检查就绪不表示任务执行、定时调度或流程引擎已经可用。

View File

@@ -58,4 +58,4 @@ P2 增加 workers:connect 和 workers:consume。bootstrap 只有显式提供 --w
P3 应用额度管理新增 `application:resources:read` 和 `application:resources:manage`。只有显式 `--resource-access` 才授予,作用域是所属应用的全部命名空间,普通队列管理不能替代。查询返回聚合计数,不开放其他命名空间的任务数据。`bootstrap --application-id <现有应用ID>` 可由数据库管理员增加命名空间,与 `--application` 二选一;验证应用启用及额度记录存在后,同事务创建新资源,所有秘密交付约定保持不变。应用默认未终结上限 1000000、并发 10000、开始速率 100000/秒;详见 [应用共享配额](application-resources.md)。
P3 结果订阅核心新增 `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
View 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 不代替授权、状态机、租约及事务校验。

View File

@@ -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例子继续开发。

View 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 另行交付。

View 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 实测。

View File

@@ -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 示例尚未交付,不能将此管理示例当作完整可靠消费链路。

View File

@@ -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`)

View File

@@ -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
}

View 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
}

View File

@@ -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) {

View 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), ""), "")
})
}

View File

@@ -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);

View File

@@ -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)
}

View 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)
}
}

View 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
}

View 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)
}
}

View 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)
}

View 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")
}
}

View 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
}

View File

@@ -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
}

View 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)
}

View File

@@ -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)

View 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"
}
]
}

View File

@@ -0,0 +1,9 @@
// Package eventsv1 提供可靠结果订阅v1的机器可读契约;授权和状态由数据库事务决定。
package eventsv1
import _ "embed"
// Schema 分别定义客户端和服务端消息,禁止将响应字段伪装成请求。
//
//go:embed events.schema.json
var Schema string

View File

@@ -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 凭据不扩权。

View 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
}

View 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
View 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
}

View 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")
}

View 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")
}
}