feat: add generic queue and subscription retirement
This commit is contained in:
@@ -95,3 +95,5 @@
|
||||
- 流程测量核验必须绑定本次发布的不可变版本ID及完整规范,步骤精确为pair/left/right且任务/激活唯一;按任务和尝试关联本地调用,不能用每实例总次数抵充缺失分支。零序号也要求精确必填字段,拒绝重复键/别名/null;报告保留逐请求原键与ID和未确认调用分母。源码更新后重新构建并运行真实样本,原二进制结果不得配上更新后的源码摘要。
|
||||
|
||||
- 管理员接入信息按用户要求支持随时查看/复制当前 Token。000033 的 secret_key_id / secret_ciphertext 仅存 AES-256-GCM 密文,主密钥来自 ORBIT_CREDENTIAL_KEY_FILE,所有节点和管理 CLI 共用;不得记录秘密到日志/审计/幂等回执。查看须管理员会话、同源 POST、核对当前版本和摘要,并确认审计提交后才交付。历史导入只接受当前摘要,不接受过渡期旧代;不轮换身份、不改认证 revision/有效期/过渡截止。轮换原子替换密文,撤销清除密文;没有主密钥的旧 CLI 轮换必须清除过期密文。普通列表仅返回 token_stored,不自动读取秘密。
|
||||
|
||||
- 000034 通用退役不删除历史或回收名称,不含调用方业务判断。订阅退役按操作身份→结果流→订阅持锁,以提交水位核对未物化匹配结果、pending 与实际未确认交付;只有结清后才释放活动名额,暂停仍计入 64。队列退役按应用额度→身份→队列锁,保护未完成/未知任务、许可、活动计划、固定版本引用的活动流程及未退役订阅。所有新工作入口检查 retired_at,旧历史与原幂等回执仍可读取;旧写节点停止后迁移,禁止混跑忽略退役的服务端。
|
||||
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -2,6 +2,17 @@
|
||||
|
||||
代码交付主分支为 `main`。采用 `v主版本.次版本.修订号`,已发布标签不可变;补丁按 `v0.1.1`、`v0.1.2` 递增,业务项目固定具体版本。
|
||||
|
||||
## v0.1.2 — 2026-09-27
|
||||
|
||||
- 通用队列/订阅永久退役、活动订阅名额释放、依赖与积压保护;历史、名称、审计和原幂等回执保留。
|
||||
- SDK 新增 `RetireQueue` / `RetireSubscription`,新增完整接口文档和可运行资源生命周期示例。
|
||||
- 应用接入页明确选择并记忆队列、展示暂停/退役和容量;未补存 Token 文案保持一致,禁止静默替换复制目标。
|
||||
- 汇总 main 已交付的应用接入、默认长期凭据、有界新旧 Token 过渡和管理员加密保管/查看/复制能力。
|
||||
|
||||
服务端标签 `v0.1.2`、SDK 标签 `sdk/go/v0.1.2`;Outbox 未修改,继续使用 `adapters/outbox/mysql/v0.1.1`。服务端升级需停旧写节点、备份后前向迁移到 000034;旧 v0.1.1 SDK 的既有接口保持兼容,退役方法需要新 SDK。退役不删除数据,不代替生产环境容量与高可用验收。
|
||||
|
||||
验证范围见[资源退役报告](docs/reports/resource-retirement.md)。
|
||||
|
||||
## v0.1.1 — 2026-09-24
|
||||
|
||||
本版汇总 v0.1.0 之后已完成的控制台、运维和业务接入能力,并统一示例和接入文档。
|
||||
|
||||
15
README.md
15
README.md
@@ -6,7 +6,7 @@
|
||||
- **Weave · 织流**:研发定义并发布流程,支持顺序、并行、条件分支、等待信号、有界查询和人工恢复。
|
||||
- **Console**:账号密码登录的管理页面,按应用与环境查看任务、计划、流程、结果交付及归档作业。
|
||||
|
||||
主分支:`main`。当前版本:**`v0.1.1`**。源码、SDK 和 Outbox 适配包采用固定版本发布,已发布标签不覆盖。变更见 [CHANGELOG](CHANGELOG.md)。
|
||||
主分支:`main`。当前版本:**`v0.1.2`**。源码、SDK 和 Outbox 适配包采用固定版本发布,已发布标签不覆盖。变更见 [CHANGELOG](CHANGELOG.md)。
|
||||
|
||||
## 从这里开始
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| 使用 Docker 启动,后续接入域名 | [Docker 与域名部署](#docker-与域名部署) |
|
||||
| 了解全部能力和用法 | [功能一览](#功能一览) |
|
||||
| 业务 Go 项目接入 | [SDK 怎么调用](#sdk-怎么调用) |
|
||||
| 直接运行完整场景 | [examples:27 个可运行示例](examples/README.md) |
|
||||
| 直接运行完整场景 | [examples:28 个可运行示例](examples/README.md) |
|
||||
| 嵌入现有 HTTP 服务 | [HTTP + Outbox + Worker + Inbox](examples/http-service/README.md) |
|
||||
| 查字段、错误码、权限及协议 | [HTTP / WebSocket API](docs/api/README.md)、[Go SDK 手册](docs/sdk/README.md) |
|
||||
| 配置、排障、备份、发布 | [配置与运维](#配置与运维)、[运行手册](docs/20-operations-runbook.md) |
|
||||
@@ -36,6 +36,7 @@
|
||||
| 安全重试 | 冻结次数、退避与总时限;明确可重试失败自动再执行,未知效果按安全策略核查 | [retry](examples/retry/README.md) · [重试策略](docs/api/retries.md) |
|
||||
| 取消 | `CancelTask` 保存取消意图;运行中的处理器配合停止并结清 | [cancellation](examples/cancellation/README.md) |
|
||||
| 定时计划 | Cron / 固定间隔、时区、不可变版本、漏跑策略、暂停/恢复和触发历史 | [schedules](examples/schedules/README.md) · [计划接口](docs/api/schedules.md) |
|
||||
| 资源退役 | 结清后退役订阅与队列,释放订阅名额,保留历史和审计 | [运行示例](sdk/go/examples/resource-lifecycle/README.md) · [接口与规则](docs/api/resource-retirement.md) |
|
||||
| 队列资源控制 | 创建/配置队列并发、开始速率、积压上限和暂停;满额返回背压 | [resources](examples/resources/README.md) · [队列接口](docs/api/queues.md) |
|
||||
| 应用共享配额 | 同一应用的多个命名空间/队列共享总额度 | [application-resources](examples/application-resources/README.md) |
|
||||
| 资源键互斥 | `ConcurrencyKey` 协调跨队列同资源任务,资源端核验递增许可序号 | [keyed-concurrency](examples/keyed-concurrency/README.md) |
|
||||
@@ -76,14 +77,14 @@
|
||||
### 1. 获取代码并迁移数据库
|
||||
|
||||
```sh
|
||||
git clone --branch v0.1.1 https://git.yuntuo.hk/k2safe/orbit.git
|
||||
git clone --branch v0.1.2 https://git.yuntuo.hk/k2safe/orbit.git
|
||||
cd orbit
|
||||
export ORBIT_MYSQL_DSN='user:password@tcp(127.0.0.1:3306)/orbit'
|
||||
export ORBIT_HTTP_ADDR='127.0.0.1:8080'
|
||||
go run ./cmd/orbit-migrate up
|
||||
```
|
||||
|
||||
DSN 替换为实际账号密码。`--branch v0.1.1` 检出固定发布版本;参与开发使用 `git switch main`。已有库先备份,再按[升级与发布手册](docs/20-operations-runbook.md)停旧写节点、迁移和核验;不修改已应用的迁移文件。
|
||||
DSN 替换为实际账号密码。`--branch v0.1.2` 检出固定发布版本;参与开发使用 `git switch main`。已有库先备份,再按[升级与发布手册](docs/20-operations-runbook.md)停旧写节点、迁移和核验;不修改已应用的迁移文件。
|
||||
|
||||
### 2. 一次性创建示例资源与管理员
|
||||
|
||||
@@ -146,12 +147,12 @@ worker_drained=true started=3 confirmed=3 running=0
|
||||
在自己的业务项目中执行:
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.1
|
||||
go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.2
|
||||
# 业务事务需要 MySQL Outbox 时再安装:
|
||||
go get git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql@v0.1.1
|
||||
```
|
||||
|
||||
无需本地源码或 `replace`。SDK 是独立 Go module,只依赖标准库和 WebSocket 客户端;MySQL 驱动属于可选适配包。子模块的 Git 标签分别为 `sdk/go/v0.1.1` 和 `adapters/outbox/mysql/v0.1.1`。
|
||||
无需本地源码或 `replace`。SDK 是独立 Go module,只依赖标准库和 WebSocket 客户端;MySQL 驱动属于可选适配包。子模块的 Git 标签分别为 `sdk/go/v0.1.2` 和 `adapters/outbox/mysql/v0.1.1`。
|
||||
|
||||
### 提交、执行、接收结果
|
||||
|
||||
@@ -277,6 +278,6 @@ make console-e2e # 控制台端到端回归
|
||||
|
||||
集成测试需要 Docker、Python 3 和 OpenSSL 等工具,由脚本创建独立临时数据库并清理自建容器。生产容量与故障域验收另见[交付清单](docs/release-checklist.md);有限样本不能代表百万企业规模的生产容量。
|
||||
|
||||
`v0.1.1` 包含账号密码控制台、归档策略/周期作业、Outbox 公共包、HTTP 生命周期示例及统一示例目录;与上一版本的升级说明和验证结果见 [CHANGELOG](CHANGELOG.md) 与 [发布记录](docs/reports/v0.1.1-release.md)。当前未提供源数据删除、其他数据库 Outbox 适配,也不把单机或隔离环境多节点测试等同于生产跨故障域高可用验收。
|
||||
`v0.1.2` 新增通用资源退役;此前已包含账号密码控制台、归档策略/周期作业、Outbox 公共包、HTTP 生命周期示例及统一示例目录;与上一版本的升级说明和验证结果见 [CHANGELOG](CHANGELOG.md) 与 [本次验证](docs/reports/resource-retirement.md)。当前未提供源数据删除、其他数据库 Outbox 适配,也不把单机或隔离环境多节点测试等同于生产跨故障域高可用验收。
|
||||
|
||||
更多资料:[设计文档](docs/README.md) · [持续开发计划](docs/08-development-plan.md) · [接口目录](docs/api/README.md) · [示例目录](examples/README.md) · [发布规则](docs/sdk/release.md)。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 启动
|
||||
|
||||
此部署工具随 `main` 分支交付;使用当前 main 代码,SDK 仍可固定 v0.1.1。需要 Docker Desktop / Docker Compose、Python 3;首次构建镜像需要访问构建依赖。从 Orbit 仓库根目录执行:
|
||||
此部署工具随 `main` 分支交付;使用当前 main 代码,新接入建议固定 SDK v0.1.2;既有 SDK v0.1.1 可继续使用原接口。需要 Docker Desktop / Docker Compose、Python 3;首次构建镜像需要访问构建依赖。从 Orbit 仓库根目录执行:
|
||||
|
||||
```sh
|
||||
./scripts/orbit-local.py up
|
||||
@@ -144,3 +144,5 @@ Worker 返回三条 `SUCCEEDED`;计划示例实际执行后暂停;流程示
|
||||
`up` 自动生成 `.local/compose-test/credential-keys.json`(0600),重复部署复用它。初始化容器将文件放入 `orbit-keys` 卷,供 Orbit 的 65532 用户只读访问;bootstrap/凭据管理 CLI 使用同一源文件。已经启用后发现文件丢失会停止部署,要求恢复原文件,不能生成替代密钥。
|
||||
|
||||
备份数据库时单独备份该文件,不提交 Git;不要删除 `orbit-keys` 卷。密钥环、生产多节点部署、旧凭据导入和主密钥变更见[应用接入接口文档](../../docs/api/console-applications.md#8-部署-token-保管)。本地升级会保留原管理员和所有应用身份,历史 Token 需要经管理员导入补齐。
|
||||
|
||||
资源退役入口及结清规则见[通用资源退役](../../docs/api/resource-retirement.md)。多队列时先明确选择目标,浏览器按命名空间记忆选择;退役队列不再可导出为新接入配置,历史查询仍开放。
|
||||
|
||||
@@ -278,3 +278,10 @@ Event.WorkflowID为只读STORED生成列,从原不可变事件派生流程归
|
||||
### 当前 Token 保管(000033)
|
||||
|
||||
`orbit_credentials.secret_key_id` 指向独立文件中的主密钥;`secret_ciphertext` 保存 AES-256-GCM 的 nonce 与认证密文,两字段同时为空或非空。原文不进入持久化模型、日志和审计。既有摘要保留用于认证,旧记录通过管理员导入当前原文补存,不改变身份、版本、有效期或过渡截止。撤销清空密文,轮换原子替换当前摘要与密文;无外键。
|
||||
|
||||
## 000034:通用资源退役
|
||||
|
||||
- `pulse_queues.retired_at`:UTC 永久退役时间,NULL 表示未退役;退役必须 paused=true。
|
||||
- `orbit_subscriptions.retired_at`:UTC 永久退役时间,NULL 表示仍占活动名额。
|
||||
- `orbit_subscriptions.retired_sequence`:退役事务锁定的命名空间结果流提交水位,NULL 表示未退役;退役后 enabled=false、pending_count=0。
|
||||
- `ix_subscription_active(namespace_id,retired_at)` 用于活动名额统计。无外键、不删除历史、不回收名称;审计与幂等回执和退役状态同事务。
|
||||
|
||||
@@ -312,3 +312,11 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专
|
||||
- [x] 新建/换证/撤销密文与摘要同步,原有效期和过渡策略保持;历史凭据导入校验当前摘要。
|
||||
- [x] 前向迁移 000033、中文模型注释、无外键;本地 Compose 持久主密钥与丢失保护。
|
||||
- [x] 标准回归、完整 MySQL/SDK/进程恢复、控制台端到端和浏览器验证通过;本地数据库升级为 33,原身份保持。见[验收报告](reports/credential-token-vault.md)。历史凭据仅有摘要时仍需导入当前原文,不会自动重置。
|
||||
|
||||
## 2026-09-27:通用资源退役与接入选择
|
||||
|
||||
范围:不含接入方名称或业务判断。迁移 000034、队列/订阅退役 API 与 SDK、活动订阅名额提示、管理端退役入口、按命名空间记忆队列、未补存 Token 文案、可运行示例。
|
||||
|
||||
保护:未完成/未知任务、执行许可、活动计划和流程、未退役订阅阻止队列退役;积压、未确认和耗尽交付阻止订阅退役。退役不删历史或审计,不修改凭据。
|
||||
|
||||
进度:实现已完成,真实 MySQL、HTTP/SDK、控制台、历史升级与完整回归验收中;最终结果见后续报告。
|
||||
|
||||
@@ -69,3 +69,5 @@ P1 业务接口已遵守下述约定,后续接口继续沿用:
|
||||
- [周期归档与作业管理](archive-scheduler.md)
|
||||
|
||||
- [控制台应用接入与 Token 管理](console-applications.md)
|
||||
|
||||
- [通用资源退役](resource-retirement.md):订阅结清、活动名额释放、队列依赖保护、SDK 与控制台操作。
|
||||
|
||||
@@ -145,3 +145,7 @@ unfinished=0 rate_per_second=1 worker_drained=true
|
||||
创建命令省略新字段时,幂等摘要保留旧字段集合。旧创建回执不含新字段,SDK 解码为零,只表示历史快照缺少信息,实时配置另行 GetQueue。配置接口是新能力;自动 SDK 已能处理 RATE_LIMITED,进行降额管理前应先升级 Worker。
|
||||
|
||||
本阶段验证见 [资源控制报告](../reports/phase-3-queue-resources.md)。[应用级跨命名空间配额](application-resources.md) 已实现并同时生效。[资源键许可](keyed-concurrency.md) 与 [优先级/节点公平派发](priority-and-fairness.md) 已实现;可靠事件积压限制及跨节点公平负载和容量验收继续推进。
|
||||
|
||||
## 永久退役
|
||||
|
||||
`POST /v1/queues/{id}:retire`,需要 `queues:manage`、原幂等键、`expected_revision` 和 `reason`。完整前置条件、错误和 SDK 用法见[通用资源退役](resource-retirement.md)。返回原 QueueResult,新增可空 `queue.retired_at`;暂停仍允许受理,退役禁止新工作。历史读取保持可用。
|
||||
|
||||
85
docs/api/resource-retirement.md
Normal file
85
docs/api/resource-retirement.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 通用资源退役
|
||||
|
||||
适用于长期服务、临时环境和自动化回归。平台不根据应用名称、队列名称或测试前缀自动清理。退役是不可恢复的显式管理动作,保留资源 ID、名称、任务、交付、幂等回执与审计;不是硬删除,也不自动归档或缩小数据库。
|
||||
|
||||
## 接口与权限
|
||||
|
||||
| 操作 | HTTP | 权限 | Go SDK |
|
||||
| --- | --- | --- | --- |
|
||||
| 退役订阅 | `POST /v1/subscriptions/{id}:retire` | `events:manage`,原凭据所有权和队列授权 | `RetireSubscription` |
|
||||
| 退役队列 | `POST /v1/queues/{id}:retire` | `queues:manage` 和队列授权 | `RetireQueue` |
|
||||
|
||||
单管理员通过已登录的控制台管理授权范围;普通 SDK 不能操作其他凭据的订阅。请求需要 Bearer 身份及 `Idempotency-Key`,不接受查询参数,正文:
|
||||
|
||||
```json
|
||||
{"expected_revision":"3","reason":"临时资源已结清,结束使用"}
|
||||
```
|
||||
|
||||
成功 HTTP 200,保持现有 `request_id + data/error` 信封。data 分别为 `{subscription,deduplicated}`、`{queue,deduplicated}`。查询及列表增加可选 `retired_at`;订阅同时增加十进制字符串 `retired_sequence`。旧记录不需要回填。
|
||||
|
||||
同键同内容返回原回执,不因状态已经退役而失败;同键不同内容为 `IDEMPOTENCY_CONFLICT`。无明确回执时必须保存并重用原目标、请求和键,不自动生成新键。新命令须携带最新配置 revision。
|
||||
|
||||
## 订阅的结清边界
|
||||
|
||||
退役事务锁定命名空间结果流的已提交水位,再锁订阅。必须同时满足:
|
||||
|
||||
- pending_count=0,实际不存在 PENDING、LEASED 或 EXPIRED 交付。
|
||||
- 截止水位之前,没有该队列和类型过滤范围内尚未物化的结果;缺失来源或历史保留缺口也拒绝退役。
|
||||
|
||||
成功后 enabled=false,记录退役水位并推进 cursor 至该水位,释放一个活动名额。此后新产生的事件不再属于该订阅。管理员应先停止旧生产者并排空消费者;平台无法判断调用方将来是否还会提交任务。
|
||||
|
||||
暂停依然占活动名额;每命名空间最多 64 个未退役订阅。已退役记录仍可列表、读取和查交付历史,不能恢复、重置起点或更换原名称。不存在通过忽略未消费事件强制释放名额的接口。
|
||||
|
||||
## 队列的依赖保护
|
||||
|
||||
队列退役按应用额度、操作身份、队列持锁,与提交、执行和流程推进串行。下列情况返回 `RESOURCE_BUSY` 并说明原因:
|
||||
|
||||
- 有未完成或待核查任务、在途执行尝试或未释放执行许可。
|
||||
- 有启用的定时计划。先暂停计划,已产生任务仍须结清。
|
||||
- 有固定版本引用该队列的未终结流程,包括暂停、等待、尚未执行到该队列的分支。
|
||||
- 有未退役订阅,包括暂停订阅。
|
||||
|
||||
成功后 paused=true,禁止新任务、新订阅、Worker 注册、计划创建/发布/恢复以及使用该队列启动新流程。已发布流程定义和暂停计划作为历史保留,不能绕过退役恢复新工作。任务与流程历史查询继续可用,原已完成命令的冻结回执不重写。队列名称不回收,重新使用需创建新 ID 和名称。
|
||||
|
||||
## SDK 与自动化收尾
|
||||
|
||||
使用 `git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.2`;旧 v0.1.1 消费/提交协议继续兼容,但不包含新方法。当前仓库内可直接从 `sdk/go` 运行示例。
|
||||
|
||||
```go
|
||||
sub, err := client.GetSubscription(ctx, subscriptionID)
|
||||
if err != nil { return err }
|
||||
// 实际服务应把 key 和请求持久保存,未知回执重放原内容。
|
||||
retire := orbit.RetireResourceRequest{
|
||||
ExpectedRevision: sub.Revision,
|
||||
Reason: "临时资源已经结清",
|
||||
}
|
||||
_, err = client.RetireSubscription(ctx, subscriptionID, stableOperationKey, retire)
|
||||
if err != nil { return err } // RESOURCE_BUSY 时先排空/核查,不能跳过结果。
|
||||
|
||||
queue, err := client.GetQueue(ctx, queueID)
|
||||
if err != nil { return err }
|
||||
_, err = client.RetireQueue(ctx, queueID, stableQueueOperationKey,
|
||||
orbit.RetireResourceRequest{ExpectedRevision: queue.Revision, Reason: "停止使用队列"})
|
||||
return err
|
||||
```
|
||||
|
||||
建议顺序:停止生产者和计划 → 结清执行与流程 → 消费并确认所有订阅结果 → 退役订阅 → 退役队列。失败的测试可能保留待核查任务,收尾应报告资源 ID 与阻塞原因,不能把测试失败变成丢弃结果的理由。
|
||||
|
||||
完整可运行示例:[resource-lifecycle](../../sdk/go/examples/resource-lifecycle/README.md)。示例只操作本次新建空资源;进程异常终止仍可能留下资源,需要按输出 ID 核查,不能无限创建来绕过名额。
|
||||
|
||||
## 错误和管理页面
|
||||
|
||||
| HTTP / code | 含义 |
|
||||
| --- | --- |
|
||||
| 409 `RESOURCE_BUSY` | 尚未结清,message 给出依赖或积压原因;状态不变 |
|
||||
| 409 `RESOURCE_RETIRED` | 新命令试图复用已退役资源 |
|
||||
| 409 `STATE_CONFLICT` | 配置版本不匹配 |
|
||||
| 409 `IDEMPOTENCY_CONFLICT` | 原键对应不同内容 |
|
||||
| 410 `EVENT_HISTORY_EXPIRED` | 订阅存在无法验证的历史缺口 |
|
||||
| 401 / 403 / 404 | 保持现有身份、权限和资源隔离语义 |
|
||||
|
||||
控制台“结果订阅”详情提供退役入口;“应用接入”提供所选队列退役和活动订阅名额。多队列时明确选择,浏览器只记忆命名空间对应的队列 ID;暂停/退役状态清晰展示。复制配置不会静默换成另一张凭据有权限的队列。
|
||||
|
||||
## 部署与保留
|
||||
|
||||
先停止旧写节点,再执行迁移 000034,再启动新版本;不要混跑不认识退役状态的旧服务端。现有资源默认未退役,不自动处理任何应用或测试数据。新库基线仍为零外键。历史保留、归档导出继续按现有配置执行;本次退役不引入自动删除机制。
|
||||
@@ -14,10 +14,11 @@
|
||||
| GET /v1/subscriptions?queue_id=... | events:read | ListSubscriptions |
|
||||
| GET /v1/subscriptions/{id} | events:read | GetSubscription |
|
||||
| POST /v1/subscriptions/{id}:configure | events:manage | ConfigureSubscription |
|
||||
| POST /v1/subscriptions/{id}:retire | events:manage | RetireSubscription |
|
||||
| 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 待交付,可按下文协议接入 |
|
||||
| GET /v1/events/connect(WebSocket Upgrade) | events:consume | ConnectEvents / NewEventConsumer |
|
||||
|
||||
HTTP 使用统一 request_id + data/error 信封,响应带 X-Request-ID 和 Cache-Control: no-store。管理请求 8 秒服务端超时、JSON 最大 512 KiB。未知字段、大小写别名、重复 JSON 键、未知或重复查询参数均被拒绝。计数/版本以规范十进制字符串传输,不能带正号、前导零或小数。
|
||||
|
||||
@@ -76,7 +77,7 @@ POST /v1/subscriptions,Content-Type: application/json,Idempotency-Key: 调
|
||||
|
||||
类型集合按固定顺序规范化。cursor_sequence 表示已经扫描并建立交付记录的位置,**不是确认水位**。pending_count 包含等待、租约中和次数耗尽记录。配置 revision 不随物化/ACK 用量变化。
|
||||
|
||||
每命名空间最多 64 个订阅。from_sequence 不能超过已提交流头,也不能早于已清理的保留边界。现存日志从迁移 000013 启用,不补造此前的任务结果。
|
||||
每命名空间最多 64 个未退役订阅;暂停仍占名额,成功退役后释放名额。from_sequence 不能超过已提交流头,也不能早于已清理的保留边界。现存日志从迁移 000013 启用,不补造此前的任务结果。
|
||||
|
||||
## 3. 查询和控制订阅
|
||||
|
||||
@@ -291,7 +292,7 @@ snapshot 还可包含业务 result 和 current_attempt_id;result 用原始 JSO
|
||||
| NOT_FOUND | 404;订阅/交付不在凭据与父资源范围;连接内发生时关闭 |
|
||||
| STATE_CONFLICT | 409;版本、状态或累计预算不允许操作 |
|
||||
| IDEMPOTENCY_CONFLICT | 409;同键内容不同 |
|
||||
| SUBSCRIPTION_LIMIT | 409;命名空间订阅数达到 64 |
|
||||
| SUBSCRIPTION_LIMIT | 409;命名空间未退役订阅数达到 64 |
|
||||
| EVENT_HISTORY_EXPIRED | 410;WSS 领取发现历史缺口则报错并关闭,不静默跳到最新位置 |
|
||||
| DELIVERY_LEASE_LOST | WSS ACK 证明已替换或无效;连接保留,等待新交付或核对历史 |
|
||||
| PROTOCOL_REQUIRED | 升级前 426,必须指定 orbit.events.v1 |
|
||||
@@ -325,3 +326,7 @@ return err
|
||||
[完整管理示例](../../examples/subscriptions/main.go) 的启动、输入输出、失败和保留规则见 [场景文档](../examples/subscriptions.md)。它演示管理与幂等,不启动结果消费者。正式 [事件连接 SDK 与自动消费器](../sdk/event-consumer.md) 已提供接收、ACK、心跳、重连与有界收尾;[完整协议示例](../examples/event-protocol.md) 验证跨连接重报原确认。[独立 MySQL Inbox 示例](../examples/result-inbox.md) 已实现并验证消费者进程退出后的去重。
|
||||
|
||||
机器可读消息契约见 [events.schema.json](../../protocol/eventsv1/events.schema.json),使用 Draft 2020-12,clientFrame/serverFrame 分方向约束。运行时额外拒绝重复 JSON 键、超范围 uint64 及非规范 base64url。Schema 不代替授权、状态机、租约及事务校验。
|
||||
|
||||
## 退役与资源名额
|
||||
|
||||
见[通用资源退役](resource-retirement.md)。退役保留订阅和交付历史,不支持重新启用;通过 `retired_at`、`retired_sequence` 区分暂停与退役。未确认、过期或尚未物化的匹配结果都会阻止退役。
|
||||
|
||||
@@ -89,3 +89,9 @@ export ORBIT_CONSOLE_QUEUE_ID='queue_xxx'
|
||||
切换采用完整导航,隔离旧请求和详情;有未保存策略、未确认操作或打开的详情时需先处理完。URL 只保存非敏感 ID。无效范围显示错误,不降级读取默认应用。总览仍是最近查询窗口的统计,不是全库累计。
|
||||
|
||||
只有部署指定管理员可以切换应用;其他账号只展示自己的应用和授权队列。后台停用应用、撤销绑定凭据或退出账号后,原页面范围不能继续授予权限。[范围接口及授权规则](api/console-scope.md)。
|
||||
|
||||
## 资源退役与明确接入目标
|
||||
|
||||
应用接入页按命名空间记忆所选队列;多队列且没有记忆时要求明确选择,不根据名称猜测业务用途。下拉框显示暂停/退役状态,已退役队列不能导出成新接入配置;凭据无该队列权限时直接提示,不自动换目标。
|
||||
|
||||
结果订阅详情和应用接入页分别提供订阅、队列退役;每次提交需要原因和当前版本,网络不明确时保留原键重报。活动订阅名额包含暂停订阅,退役后释放,历史仍可查询;阻塞原因明确显示。未补存当前 Token 时,预览入口与过渡提示都要求先补存,避免误认为已有可复制的新配置。
|
||||
|
||||
22
docs/reports/resource-retirement.md
Normal file
22
docs/reports/resource-retirement.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 通用资源退役与接入体验验收
|
||||
|
||||
日期:2026-09-27。范围:平台、Go SDK、控制台及通用示例;不包含接入方业务处理器、支付渠道或业务数据变更。
|
||||
|
||||
## 实现
|
||||
|
||||
迁移 000034 增加队列/订阅退役时间及订阅提交水位、活动索引和约束,无外键。退役、不可变审计、原操作回执同事务;新工作不能复用退役队列,已完成流程的原启动回执仍可重放。订阅结清释放名额,暂停仍占名额,不删除或伪造确认历史。
|
||||
|
||||
控制台增加明确队列选择/记忆、暂停/退役状态和名额统计,提供带原因和版本的退役入口。凭据未补存时统一提示补存,不能静默换队列复制。没有修改任何现有应用的 Token、权限或过渡期。
|
||||
|
||||
## 检查进度
|
||||
|
||||
- 单元/race、vet、build、Markdown 链接已通过首轮;最终完整结果待回归结束后记录。
|
||||
- 新增真实 MySQL 8 项场景通过:未物化/租约/耗尽交付保护、64 名额释放且历史保留、任务/订阅依赖、计划恢复阻止、等待流程保护、完成流程原回执和历史、真实锁竞争、审计失败回滚。
|
||||
- 控制台真实 HTTP 验收通过:跨应用管理员范围、CSRF、退役幂等、名额变化、历史读取。
|
||||
- 初轮完整 MySQL 回归发现两处旧升级夹具未计入新约束/索引,已修正:旧结构还原时移除 000034 新字段与约束;原索引对比排除新加活动索引。没有放宽原索引、数据和零外键断言。完整回归重跑中。
|
||||
|
||||
## 明确边界
|
||||
|
||||
不提供强制忽略结果或批量硬删除。历史未确认/未知效果须先核查;没有消费者时需要恢复消费并确认积压。资源名称不回收。归档仍按已有配置导出并保留源记录,退役不缩减物理存储。
|
||||
|
||||
新服务端禁止与忽略退役状态的旧写节点混跑。旧 SDK 既有 API 兼容;新的退役方法使用 v0.1.2。隔离回归不代替目标生产环境的容量、跨故障域或真实业务验收。
|
||||
@@ -151,7 +151,7 @@ Options.HTTPClient 可注入专用 HTTP 客户端。SDK 复制配置,不修改
|
||||
业务项目需要 Go 1.27.1 或更高版本。在业务项目的 `go.mod` 所在目录安装当前接入版本:
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.1
|
||||
go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.2
|
||||
```
|
||||
|
||||
代码中导入:
|
||||
@@ -167,9 +167,9 @@ go mod tidy
|
||||
go mod verify
|
||||
```
|
||||
|
||||
SDK 位于仓库子目录,对应 Git 标签为 `sdk/go/v0.1.1`;`go get` 使用模块版本 `v0.1.1`。服务端对应仓库标签为 `v0.1.1`。业务项目固定版本并提交 `go.mod` 与 `go.sum`,无需本地源码、`replace` 或 Orbit 的 `go.work`。如果此前配置过本地替换,先执行 `go mod edit -dropreplace=git.yuntuo.hk/k2safe/orbit/sdk/go`。
|
||||
SDK 位于仓库子目录,对应 Git 标签为 `sdk/go/v0.1.2`;`go get` 使用模块版本 `v0.1.2`。服务端对应仓库标签为 `v0.1.2`。业务项目固定版本并提交 `go.mod` 与 `go.sum`,无需本地源码、`replace` 或 Orbit 的 `go.work`。如果此前配置过本地替换,先执行 `go mod edit -dropreplace=git.yuntuo.hk/k2safe/orbit/sdk/go`。
|
||||
|
||||
这是开发与测试接入版本;生产部署仍按[交付清单](../release-checklist.md)完成目标环境验收。版本发布和校验规则见[发布说明](release.md),空缓存远端取包结果见[验证报告](../reports/v0.1.1-release.md)。
|
||||
这是开发与测试接入版本;生产部署仍按[交付清单](../release-checklist.md)完成目标环境验收。版本发布和校验规则见[发布说明](release.md),空缓存远端取包结果见[验证报告](../reports/resource-retirement.md)。
|
||||
|
||||
## 6. 查询执行历史与证据
|
||||
|
||||
|
||||
@@ -29,19 +29,21 @@ sh scripts/test-sdk-isolation.sh
|
||||
|
||||
## 接入方式
|
||||
|
||||
当前接入版本为 `v0.1.1`,需要 Go 1.27.1 或更高版本。接入方应固定 SDK 版本,升级前先执行自己的接口和业务幂等回归:
|
||||
当前接入版本为 `v0.1.2`,需要 Go 1.27.1 或更高版本。接入方应固定 SDK 版本,升级前先执行自己的接口和业务幂等回归:
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.1
|
||||
go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.2
|
||||
# 在业务代码导入 SDK 并添加调用后:
|
||||
go mod tidy
|
||||
go mod verify
|
||||
```
|
||||
|
||||
仓库主分支为 `main`。服务端标签 `v0.1.1` 和独立 SDK 标签 `sdk/go/v0.1.1` 指向同一发布提交;Go 子模块标签必须包含 `sdk/go/` 前缀,业务端的模块版本不带此前缀。已发布标签保持不可变,修复使用新的补丁版本。版本发布不替代目标环境验收,正式生产仍需完成[交付核对清单](../release-checklist.md)。
|
||||
仓库主分支为 `main`。服务端标签 `v0.1.2` 和独立 SDK 标签 `sdk/go/v0.1.2` 指向同一发布提交;Go 子模块标签必须包含 `sdk/go/` 前缀,业务端的模块版本不带此前缀。已发布标签保持不可变,修复使用新的补丁版本。版本发布不替代目标环境验收,正式生产仍需完成[交付核对清单](../release-checklist.md)。
|
||||
|
||||
`v0.1.0` 已在仓库外的新业务 module、空模块缓存和 `GOWORK=off` 下通过公开远端下载、`go mod tidy`、编译、SDK HTTP smoke 和 `go mod verify`,解析版本精确为 `v0.1.0` 且不存在 `Replace`。发布提交、校验和与网络条件见[远端验证报告](../reports/phase-7-sdk-remote-release.md)。后续发布继续重复这一步,并按[兼容矩阵](compatibility.md)验证旧/新组合。
|
||||
|
||||
本地联调可以临时添加 `replace`,但不能提交到业务项目的发布分支。SDK 不负责 TLS 终止、凭据托管、对象存储或外部副作用核查;这些边界仍由平台接口手册和接入方运行手册定义。
|
||||
|
||||
Outbox 独立模块使用 `adapters/outbox/mysql/v0.1.1` 标签,安装与版本边界见[适配包手册](outbox-mysql.md)。本次目录整理、发布与远端取包验证见[v0.1.1 记录](../reports/v0.1.1-release.md)。
|
||||
|
||||
`v0.1.2` 发布队列/订阅退役与对应 SDK 方法,数据库升级到 000034;[本次报告](../reports/resource-retirement.md)列出隔离回归与远端安装结果。Outbox 不需要同步升级。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Orbit 可运行示例
|
||||
|
||||
这里集中提供 27 个场景:24 个轻量 SDK 程序和 3 个包含业务数据库的完整工程。每个目录的 README 都有用途、前置条件、运行命令、预期结果及详细接口链接。
|
||||
这里集中提供 28 个场景:25 个轻量 SDK 程序和 3 个包含业务数据库的完整工程。每个目录的 README 都有用途、前置条件、运行命令、预期结果及详细接口链接。
|
||||
|
||||
## 第一次使用
|
||||
|
||||
@@ -104,3 +104,7 @@ go run ./workflow-recovery
|
||||
## 回归
|
||||
|
||||
从仓库根目录执行 `make test vet build` 覆盖全部示例模块;`make integration` 使用临时 MySQL 和真实 Orbit 进程运行场景与故障测试。仅跑 `go test ./...` 而未提供集成环境时,外部服务用例会跳过。
|
||||
|
||||
## 通用资源退役
|
||||
|
||||
[resource-lifecycle](../sdk/go/examples/resource-lifecycle/README.md) 位于 SDK module,可用当前 SDK 源码直接运行;只创建和退役本次独占的空队列及订阅,验证幂等与历史保留。
|
||||
|
||||
@@ -26,8 +26,10 @@ type ApplicationPage struct {
|
||||
|
||||
// AccessQueue 是接入配置可选的队列。
|
||||
type AccessQueue struct {
|
||||
ID core.ID `json:"id"` // ID 队列标识。
|
||||
Name string `json:"name"` // Name 队列名称。
|
||||
Paused bool `json:"paused"` // Paused 暂停执行,不能表示已退役。
|
||||
RetiredAt *time.Time `json:"retired_at,omitempty"` // RetiredAt 永久退役时间,历史仍可查询。
|
||||
ID core.ID `json:"id"` // ID 队列标识。
|
||||
Name string `json:"name"` // Name 队列名称。
|
||||
}
|
||||
|
||||
// AccessCredential 是脱敏的凭据元数据,绝不返回秘密或秘密摘要。
|
||||
@@ -44,11 +46,13 @@ type AccessCredential struct {
|
||||
|
||||
// ApplicationDetail 展示一个命名空间的队列和凭据,读取量有界。
|
||||
type ApplicationDetail struct {
|
||||
TokenVaultEnabled bool `json:"token_vault_enabled"` // TokenVaultEnabled 当前节点是否配置了凭据保管密钥。
|
||||
Entry ApplicationEntry `json:"entry"` // Entry 当前应用与环境。
|
||||
Queues []AccessQueue `json:"queues"` // Queues 最多 100 个队列。
|
||||
Credentials []AccessCredential `json:"credentials"` // Credentials 最多 100 张凭据。
|
||||
Truncated bool `json:"truncated"` // Truncated 为真时不得把当前结果解释为完整清单。
|
||||
ActiveSubscriptions uint64 `json:"active_subscriptions"` // ActiveSubscriptions 未退役订阅数量,包含暂停订阅。
|
||||
SubscriptionLimit uint32 `json:"subscription_limit"` // SubscriptionLimit 命名空间活动订阅上限。
|
||||
TokenVaultEnabled bool `json:"token_vault_enabled"` // TokenVaultEnabled 当前节点是否配置了凭据保管密钥。
|
||||
Entry ApplicationEntry `json:"entry"` // Entry 当前应用与环境。
|
||||
Queues []AccessQueue `json:"queues"` // Queues 最多 100 个队列。
|
||||
Credentials []AccessCredential `json:"credentials"` // Credentials 最多 100 张凭据。
|
||||
Truncated bool `json:"truncated"` // Truncated 为真时不得把当前结果解释为完整清单。
|
||||
}
|
||||
|
||||
// ApplicationInput 描述创建应用或变更凭据的管理员意图。
|
||||
|
||||
@@ -22,6 +22,7 @@ var (
|
||||
Unauthorized = New("UNAUTHENTICATED", "valid credentials are required")
|
||||
Forbidden = New("PERMISSION_DENIED", "operation is not allowed")
|
||||
NotFound = New("NOT_FOUND", "resource not found")
|
||||
Retired = New("RESOURCE_RETIRED", "resource is retired; history remains available")
|
||||
Conflict = New("STATE_CONFLICT", "resource state does not allow this operation")
|
||||
IdempotencyConflict = New("IDEMPOTENCY_CONFLICT", "idempotency key was used with different content")
|
||||
Unavailable = New("UNAVAILABLE", "service temporarily unavailable; retry using the original operation identity")
|
||||
|
||||
@@ -63,6 +63,8 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
|
||||
finished := retryStart(t, g)
|
||||
retryFinish(t, g, finished, "SUCCEEDED", false)
|
||||
assertApplicationCounts(t, f, 4, 2, 1)
|
||||
f.exec(`ALTER TABLE orbit_subscriptions DROP CHECK ck_subscription_retired,DROP INDEX ix_subscription_active,DROP COLUMN retired_at,DROP COLUMN retired_sequence`)
|
||||
f.exec(`ALTER TABLE pulse_queues DROP CHECK ck_queue_retired,DROP COLUMN retired_at`)
|
||||
// 还原后续新增表、字段和索引,得到真实 v9 结构;现有任务内容保持不变。
|
||||
f.exec(`ALTER TABLE orbit_events DROP INDEX ix_event_workflow_history,DROP COLUMN workflow_id`)
|
||||
f.exec(`ALTER TABLE pulse_tasks DROP INDEX ix_task_observe_state_available`)
|
||||
|
||||
@@ -933,3 +933,13 @@ CREATE TABLE orbit_archive_jobs (
|
||||
CONSTRAINT ck_archive_job_config CHECK(revision>0 AND plan_revision>0 AND policy_revision>0 AND batch_size BETWEEN 1 AND 100 AND max_bytes BETWEEN 1048576 AND 1073741824),
|
||||
CONSTRAINT ck_archive_job_lease CHECK((state='RUNNING' AND lease_id IS NOT NULL AND lease_until IS NOT NULL) OR (state<>'RUNNING' AND lease_id IS NULL AND lease_until IS NULL))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='持久归档作业及租约,只读源数据,无外键';
|
||||
|
||||
-- 通用资源退役,只关闭未来工作并释放活动订阅名额,不删除历史或审计;禁止外键。
|
||||
ALTER TABLE pulse_queues
|
||||
ADD retired_at DATETIME(6) NULL COMMENT '永久退役时间,UTC;NULL表示未退役,退役后禁止新工作且不可恢复',
|
||||
ADD CONSTRAINT ck_queue_retired CHECK (retired_at IS NULL OR paused=TRUE);
|
||||
ALTER TABLE orbit_subscriptions
|
||||
ADD retired_at DATETIME(6) NULL COMMENT '永久退役时间,UTC;NULL表示仍占用订阅名额,退役保留历史交付',
|
||||
ADD retired_sequence BIGINT UNSIGNED NULL COMMENT '退役事务锁定的结果流提交水位;NULL表示未退役,之后的事件不再订阅',
|
||||
ADD KEY ix_subscription_active(namespace_id,retired_at),
|
||||
ADD CONSTRAINT ck_subscription_retired CHECK ((retired_at IS NULL AND retired_sequence IS NULL) OR (retired_at IS NOT NULL AND retired_sequence IS NOT NULL AND enabled=FALSE AND pending_count=0));
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"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"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
driver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
@@ -106,13 +107,13 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id,name FROM pulse_queues WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id,name,paused,retired_at FROM pulse_queues WHERE namespace_id=? ORDER BY (retired_at IS NOT NULL),paused,created_at,id LIMIT 101`, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var q consoleauth.AccessQueue
|
||||
if err := rows.Scan(&q.ID, &q.Name); err != nil {
|
||||
if err := rows.Scan(&q.ID, &q.Name, &q.Paused, &q.RetiredAt); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
@@ -123,6 +124,10 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.SubscriptionLimit = events.MaxSubscriptions
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_subscriptions WHERE namespace_id=? AND retired_at IS NULL`, namespace).Scan(&out.ActiveSubscriptions); err != nil {
|
||||
return err
|
||||
}
|
||||
out.TokenVaultEnabled = s.vault != nil
|
||||
rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,previous_valid_until,permissions,secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
|
||||
if err != nil {
|
||||
|
||||
@@ -90,7 +90,7 @@ func (s *ConsoleApplicationStore) GetScope(ctx context.Context, t auth.Token, af
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := `SELECT id,name FROM pulse_queues WHERE namespace_id=?`
|
||||
query := `SELECT id,name,paused,retired_at FROM pulse_queues WHERE namespace_id=?`
|
||||
args := []any{t.NamespaceID}
|
||||
if after != "" {
|
||||
query += ` AND id>?`
|
||||
@@ -120,7 +120,7 @@ func (s *ConsoleApplicationStore) GetScope(ctx context.Context, t auth.Token, af
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var q consoleauth.AccessQueue
|
||||
if err := rows.Scan(&q.ID, &q.Name); err != nil {
|
||||
if err := rows.Scan(&q.ID, &q.Name, &q.Paused, &q.RetiredAt); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Queues = append(out.Queues, q)
|
||||
|
||||
@@ -24,7 +24,7 @@ func NewEventStore(db *sql.DB) *EventStore { return &EventStore{NewTaskStore(db)
|
||||
|
||||
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`
|
||||
const subscriptionColumns = `id,queue_id,credential_id,name,revision,enabled,filter,cursor_sequence,pending_count,max_pending,max_attempts,created_at,updated_at,retired_at,retired_sequence`
|
||||
|
||||
func readSubscription(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Permissions, id core.ID, lock bool) (events.Subscription, error) {
|
||||
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND id=? AND credential_id=? AND queue_id IS NOT NULL`
|
||||
@@ -52,7 +52,7 @@ func readSubscription(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Perm
|
||||
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)
|
||||
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, &out.RetiredAt, &out.RetiredSequence)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -87,6 +87,9 @@ func (s *EventStore) CreateSubscription(ctx context.Context, t auth.Token, key s
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireActiveQueue(ctx, tx, t.NamespaceID, in.QueueID); err != nil {
|
||||
return err
|
||||
}
|
||||
head, retained, err := lockEventStream(ctx, tx, t.NamespaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -102,7 +105,7 @@ func (s *EventStore) CreateSubscription(ctx context.Context, t auth.Token, key s
|
||||
return events.HistoryExpired
|
||||
}
|
||||
var count int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_subscriptions WHERE namespace_id=?`, t.NamespaceID).Scan(&count); err != nil {
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_subscriptions WHERE namespace_id=? AND retired_at IS NULL`, t.NamespaceID).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= events.MaxSubscriptions {
|
||||
@@ -158,6 +161,9 @@ func (s *EventStore) ControlSubscription(ctx context.Context, t auth.Token, id c
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
if before.RetiredAt != nil {
|
||||
return fault.Retired
|
||||
}
|
||||
if before.Revision != in.ExpectedRevision || before.Revision == math.MaxUint64 {
|
||||
return fault.Conflict
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ var migrationFiles embed.FS
|
||||
//go:embed baseline.sql
|
||||
var freshInstallBaseline string
|
||||
|
||||
const freshInstallBaselineVersion uint64 = 33
|
||||
const freshInstallBaselineVersion uint64 = 34
|
||||
|
||||
type Migration struct {
|
||||
Version uint64
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 通用资源退役,只关闭未来工作并释放活动订阅名额,不删除历史或审计;禁止外键。
|
||||
ALTER TABLE pulse_queues
|
||||
ADD retired_at DATETIME(6) NULL COMMENT '永久退役时间,UTC;NULL表示未退役,退役后禁止新工作且不可恢复',
|
||||
ADD CONSTRAINT ck_queue_retired CHECK (retired_at IS NULL OR paused=TRUE);
|
||||
ALTER TABLE orbit_subscriptions
|
||||
ADD retired_at DATETIME(6) NULL COMMENT '永久退役时间,UTC;NULL表示仍占用订阅名额,退役保留历史交付',
|
||||
ADD retired_sequence BIGINT UNSIGNED NULL COMMENT '退役事务锁定的结果流提交水位;NULL表示未退役,之后的事件不再订阅',
|
||||
ADD KEY ix_subscription_active(namespace_id,retired_at),
|
||||
ADD CONSTRAINT ck_subscription_retired CHECK ((retired_at IS NULL AND retired_sequence IS NULL) OR (retired_at IS NOT NULL AND retired_sequence IS NOT NULL AND enabled=FALSE AND pending_count=0));
|
||||
@@ -147,7 +147,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
|
||||
indexes := func() []string {
|
||||
t.Helper()
|
||||
// 排除后续新增/替换索引;原有项在此逐项比较,v24/v25新增项由对应Query/Timeline升级测试核对。
|
||||
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','orbit_console_users','orbit_console_sessions','orbit_retention_policies','orbit_archive_jobs','orbit_archive_plans','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') 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','orbit_console_users','orbit_console_sessions','orbit_retention_policies','orbit_archive_jobs','orbit_archive_plans','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_subscription_active','ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
175
internal/storage/mysql/resource_retirement.go
Normal file
175
internal/storage/mysql/resource_retirement.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
// requireActiveQueue 对创建/恢复工作取得共享锁,与退役排他锁串行;历史读取不使用此检查。
|
||||
func requireActiveQueue(ctx context.Context, tx *sql.Tx, ns, id core.ID) error {
|
||||
var retired *time.Time
|
||||
err := tx.QueryRowContext(ctx, `SELECT retired_at FROM pulse_queues WHERE namespace_id=? AND id=? FOR SHARE`, ns, id).Scan(&retired)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if retired != nil {
|
||||
return fault.Retired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func retirementBusy(message string) error { return fault.New("RESOURCE_BUSY", message) }
|
||||
|
||||
// RetireSubscription 锁定提交水位后锁订阅,既不丢未物化结果,也不丢过期交付。
|
||||
// 与创建共用流锁串行化活动名额;消费只锁订阅,不反向锁流/队列。
|
||||
func (s *EventStore) RetireSubscription(ctx context.Context, t auth.Token, id core.ID, key string, in events.RetireInput) (events.SubscriptionResult, error) {
|
||||
var out events.SubscriptionResult
|
||||
err := s.transaction(ctx, t, "events:manage", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if _, err := readSubscription(ctx, tx, t, p, id, false); err != nil {
|
||||
return err
|
||||
}
|
||||
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "subscription.retire", eventScope(t.CredentialID, id), key, "subscription", id, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cached != nil {
|
||||
if err = json.Unmarshal(cached, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
head, retained, err := lockEventStream(ctx, tx, t.NamespaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := readSubscription(ctx, tx, t, p, id, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if before.RetiredAt != nil {
|
||||
return fault.Retired
|
||||
}
|
||||
if before.Revision != in.ExpectedRevision || before.Revision == math.MaxUint64 {
|
||||
return fault.Conflict
|
||||
}
|
||||
if before.PendingCount != 0 {
|
||||
return retirementBusy("subscription has unacknowledged deliveries; drain or resolve expired deliveries first")
|
||||
}
|
||||
var pending bool
|
||||
if err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM orbit_deliveries WHERE namespace_id=? AND subscription_id=? AND state IN ('PENDING','LEASED','EXPIRED'))`, t.NamespaceID, id).Scan(&pending); err != nil {
|
||||
return err
|
||||
}
|
||||
if pending {
|
||||
return retirementBusy("subscription has unacknowledged deliveries")
|
||||
}
|
||||
if before.CursorSequence < retained-1 {
|
||||
return events.HistoryExpired
|
||||
}
|
||||
filter, err := json.Marshal(before.Types)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 缺失来源事件也拒绝退役,不能将损坏记录误当成不匹配过滤器。
|
||||
if err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM orbit_result_events r LEFT JOIN orbit_events e ON e.namespace_id=r.namespace_id AND e.id=r.id WHERE r.namespace_id=? AND r.queue_id=? AND r.sequence>? AND r.sequence<=? AND (e.id IS NULL OR JSON_CONTAINS(?,JSON_QUOTE(e.type))))`, t.NamespaceID, before.QueueID, before.CursorSequence, head, string(filter)).Scan(&pending); err != nil {
|
||||
return err
|
||||
}
|
||||
if pending {
|
||||
return retirementBusy("subscription has unconsumed results; resume consumption and acknowledge them before retirement")
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE orbit_subscriptions SET enabled=FALSE,retired_at=UTC_TIMESTAMP(6),retired_sequence=?,cursor_sequence=?,revision=revision+1,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, head, head, t.NamespaceID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Subscription, err = readSubscription(ctx, tx, t, p, id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = appendAudit(ctx, tx, t, identity, id, "subscription", "subscription.retire", in.Reason, before, out.Subscription); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// RetireQueue 按应用额度→身份→队列持锁,拒绝尚未结清的工作和未来工作来源。
|
||||
// 暂停计划可以保留;其恢复/发布与本操作共用队列锁,退役后无法重新激活。
|
||||
func (s *TaskStore) RetireQueue(ctx context.Context, t auth.Token, id core.ID, key string, in service.RetireQueueInput) (service.QueueResult, error) {
|
||||
var out service.QueueResult
|
||||
err := s.transaction(ctx, t, "queues:manage", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if !p.AllowsQueue(id) {
|
||||
return fault.NotFound
|
||||
}
|
||||
if _, err := lockApplicationResources(ctx, tx, t.NamespaceID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "queue.retire", string(id), key, "queue", id, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cached != nil {
|
||||
if err = json.Unmarshal(cached, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
if _, _, err = lockQueueAfterApplication(ctx, tx, t.NamespaceID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := readQueue(ctx, tx, t.NamespaceID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if before.Revision != in.ExpectedRevision || before.Revision == math.MaxUint64 {
|
||||
return fault.Conflict
|
||||
}
|
||||
if before.UnfinishedTasks != 0 {
|
||||
return retirementBusy("queue has unfinished tasks, including tasks needing review")
|
||||
}
|
||||
checks := []struct {
|
||||
query, message string
|
||||
args []any
|
||||
}{
|
||||
{`SELECT EXISTS(SELECT 1 FROM pulse_tasks WHERE namespace_id=? AND queue_id=? AND state IN ('WAITING','LEASED','RUNNING','NEEDS_REVIEW'))`, "queue has unfinished or uncertain tasks", []any{t.NamespaceID, id}},
|
||||
{`SELECT EXISTS(SELECT 1 FROM pulse_attempts a JOIN pulse_tasks t ON t.namespace_id=a.namespace_id AND t.id=a.task_id WHERE t.namespace_id=? AND t.queue_id=? AND a.state IN ('LEASED','RUNNING'))`, "queue has active execution attempts", []any{t.NamespaceID, id}},
|
||||
{`SELECT EXISTS(SELECT 1 FROM pulse_permits p JOIN pulse_attempts a ON a.namespace_id=p.namespace_id AND a.id=p.attempt_id JOIN pulse_tasks t ON t.namespace_id=a.namespace_id AND t.id=a.task_id WHERE t.namespace_id=? AND t.queue_id=? AND p.released_at IS NULL)`, "queue has unreleased execution permits", []any{t.NamespaceID, id}},
|
||||
{`SELECT EXISTS(SELECT 1 FROM pulse_schedules WHERE namespace_id=? AND queue_id=? AND paused=FALSE)`, "queue has enabled schedules; pause them first", []any{t.NamespaceID, id}},
|
||||
{`SELECT EXISTS(SELECT 1 FROM weave_instances i LEFT JOIN weave_version_scopes v ON v.namespace_id=i.namespace_id AND v.definition_version_id=i.definition_version_id WHERE i.namespace_id=? AND i.state IN ('RUNNING','WAITING','CANCELLING','NEEDS_REVIEW') AND (v.definition_version_id IS NULL OR JSON_CONTAINS(v.queue_ids,JSON_QUOTE(?))))`, "queue is referenced by unfinished workflows", []any{t.NamespaceID, string(id)}},
|
||||
{`SELECT EXISTS(SELECT 1 FROM orbit_subscriptions WHERE namespace_id=? AND queue_id=? AND retired_at IS NULL)`, "queue has subscriptions; retire them first", []any{t.NamespaceID, id}},
|
||||
}
|
||||
for _, check := range checks {
|
||||
var busy bool
|
||||
if err = tx.QueryRowContext(ctx, check.query, check.args...).Scan(&busy); err != nil {
|
||||
return err
|
||||
}
|
||||
if busy {
|
||||
return retirementBusy(check.message)
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE pulse_queues SET paused=TRUE,retired_at=UTC_TIMESTAMP(6),revision=revision+1,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, t.NamespaceID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Queue, err = readQueue(ctx, tx, t.NamespaceID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = appendAudit(ctx, tx, t, identity, id, "queue", "queue.retire", in.Reason, before, out.Queue); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
241
internal/storage/mysql/resource_retirement_integration_test.go
Normal file
241
internal/storage/mysql/resource_retirement_integration_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/schedule"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/instance"
|
||||
)
|
||||
|
||||
func addRetirementPermission(t *testing.T, f *executionFixture) {
|
||||
t.Helper()
|
||||
var raw []byte
|
||||
var p auth.Permissions
|
||||
if err := f.db.QueryRowContext(f.ctx, `SELECT permissions FROM orbit_credentials WHERE namespace_id=? AND id=?`, f.token.NamespaceID, f.token.CredentialID).Scan(&raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.Actions = append(p.Actions, "queues:manage", "queues:read")
|
||||
raw, _ = json.Marshal(p)
|
||||
f.exec(`UPDATE orbit_credentials SET permissions=? WHERE namespace_id=? AND id=?`, string(raw), f.token.NamespaceID, f.token.CredentialID)
|
||||
}
|
||||
func TestIntegrationResourceRetirement(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, 32, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err = Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Run("no_lost_backlog_leases_or_history", func(t *testing.T) {
|
||||
f := newEventFixture(t, ctx, db)
|
||||
sub := f.create(events.CreateInput{MaxAttempts: 1})
|
||||
task := f.cancelResult()
|
||||
in := events.RetireInput{ExpectedRevision: sub.Revision, Reason: "completed temporary resources"}
|
||||
_, err := f.events.Retire(ctx, f.token, sub.ID, "retire", in)
|
||||
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||
g := f.grant(sub.ID)
|
||||
_, err = f.events.Retire(ctx, f.token, sub.ID, "retire", in)
|
||||
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||
f.expireDelivery(g)
|
||||
noEventGrant(t, f, sub.ID)
|
||||
_, err = f.events.Retire(ctx, f.token, sub.ID, "retire", in)
|
||||
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||
f.ack(sub.ID, g)
|
||||
done, err := f.events.Retire(ctx, f.token, sub.ID, "retire", in)
|
||||
if err != nil || done.Subscription.RetiredAt == nil || done.Subscription.RetiredSequence == nil || done.Subscription.Enabled {
|
||||
t.Fatalf("retire: %+v %v", done, err)
|
||||
}
|
||||
again, err := f.events.Retire(ctx, f.token, sub.ID, "retire", in)
|
||||
if err != nil || !again.Deduplicated || again.Subscription.Revision != done.Subscription.Revision {
|
||||
t.Fatal(again, err)
|
||||
}
|
||||
changed := in
|
||||
changed.Reason = "other"
|
||||
_, err = f.events.Retire(ctx, f.token, sub.ID, "retire", changed)
|
||||
expectWorkerCode(t, err, "IDEMPOTENCY_CONFLICT")
|
||||
_, err = f.events.Control(ctx, f.token, sub.ID, "enable", events.ControlInput{ExpectedRevision: done.Subscription.Revision, Enabled: true, Reason: "attempt resume"})
|
||||
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||
detail, err := f.events.Detail(ctx, f.token, sub.ID, g.Delivery.ID)
|
||||
if err != nil || detail.Event.AggregateID != task.ID {
|
||||
t.Fatal("history unavailable", err)
|
||||
}
|
||||
f.cancelResult()
|
||||
noEventGrant(t, f, sub.ID)
|
||||
if f.count("orbit_audit_events", "action='subscription.retire'") != 1 {
|
||||
t.Fatal("retirement audit missing or duplicated")
|
||||
}
|
||||
})
|
||||
t.Run("paused_counts_retired_releases_capacity", func(t *testing.T) {
|
||||
f := newEventFixture(t, ctx, db)
|
||||
sub := f.create(events.CreateInput{})
|
||||
paused, err := f.events.Control(ctx, f.token, sub.ID, "pause", events.ControlInput{ExpectedRevision: sub.Revision, Enabled: false, Reason: "pause"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 1; i < events.MaxSubscriptions; i++ {
|
||||
f.create(events.CreateInput{Name: fmt.Sprintf("capacity-%d", i)})
|
||||
}
|
||||
_, err = f.events.Create(ctx, f.token, "next", events.CreateInput{Name: "next", QueueID: f.resources.QueueID})
|
||||
expectWorkerCode(t, err, "SUBSCRIPTION_LIMIT")
|
||||
if _, err = f.events.Retire(ctx, f.token, sub.ID, "retire", events.RetireInput{ExpectedRevision: paused.Subscription.Revision, Reason: "retire paused"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = f.events.Create(ctx, f.token, "next", events.CreateInput{Name: "next", QueueID: f.resources.QueueID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.count("orbit_subscriptions", "1=1") != 65 {
|
||||
t.Fatal("history was removed")
|
||||
}
|
||||
})
|
||||
t.Run("queue_protects_tasks_subscriptions_and_original_receipts", func(t *testing.T) {
|
||||
f := newEventFixture(t, ctx, db)
|
||||
q := f.resources.QueueID
|
||||
sub := f.create(events.CreateInput{})
|
||||
task := f.submit(1)
|
||||
in := service.RetireQueueInput{ExpectedRevision: 1, Reason: "queue lifecycle"}
|
||||
_, err := f.tasks.RetireQueue(ctx, f.token, q, "retire", in)
|
||||
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||
if _, err = f.tasks.CancelTask(ctx, f.token, task.ID, "cancel", service.CancelInput{ExpectedRevision: task.Revision, Reason: "finish test"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = f.tasks.RetireQueue(ctx, f.token, q, "retire", in)
|
||||
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||
g := f.grant(sub.ID)
|
||||
f.ack(sub.ID, g)
|
||||
if _, err = f.events.Retire(ctx, f.token, sub.ID, "retire", events.RetireInput{ExpectedRevision: sub.Revision, Reason: "retire"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retired, err := f.tasks.RetireQueue(ctx, f.token, q, "retire", in)
|
||||
if err != nil || retired.Queue.RetiredAt == nil || !retired.Queue.Paused {
|
||||
t.Fatal(retired, err)
|
||||
}
|
||||
replay, err := f.tasks.RetireQueue(ctx, f.token, q, "retire", in)
|
||||
if err != nil || !replay.Deduplicated {
|
||||
t.Fatal(replay, err)
|
||||
}
|
||||
_, err = f.tasks.ConfigureQueue(ctx, f.token, q, "resume", service.QueueConfigInput{ExpectedRevision: retired.Queue.Revision, Reason: "resume", MaxConcurrency: 1, RatePerSecond: 1, MaxUnfinishedTasks: 1})
|
||||
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||
_, err = f.tasks.Submit(ctx, f.token, "new-task", service.SubmitInput{QueueID: q, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)})
|
||||
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||
_, err = f.events.Create(ctx, f.token, "new-sub", events.CreateInput{Name: "new", QueueID: q})
|
||||
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||
if _, err = f.tasks.GetTask(ctx, f.token, task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := newEventFixture(t, ctx, db)
|
||||
_, err = other.events.Retire(ctx, other.token, sub.ID, "foreign", events.RetireInput{ExpectedRevision: 1, Reason: "foreign"})
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
})
|
||||
t.Run("schedule_resume_cannot_revive_retired_queue", func(t *testing.T) {
|
||||
f := newScheduleFixture(t, newExecutionFixture(t, ctx, db))
|
||||
addRetirementPermission(t, f.executionFixture)
|
||||
v := f.create(f.definition())
|
||||
_, err := f.tasks.RetireQueue(ctx, f.token, f.resources.QueueID, "retire", service.RetireQueueInput{ExpectedRevision: 1, Reason: "retire"})
|
||||
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||
pause, err := f.api.Control(ctx, f.token, v.ID, "pause", schedule.ControlInput{ExpectedRevision: v.Revision, Reason: "pause before retire"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = f.tasks.RetireQueue(ctx, f.token, f.resources.QueueID, "retire", service.RetireQueueInput{ExpectedRevision: 1, Reason: "retire"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = f.api.Control(ctx, f.token, v.ID, "resume", schedule.ControlInput{ExpectedRevision: pause.Schedule.Revision, Reason: "resume"}, false)
|
||||
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||
if _, err = f.api.Get(ctx, f.token, v.ID); err != nil {
|
||||
t.Fatal("schedule history", err)
|
||||
}
|
||||
})
|
||||
t.Run("waiting_workflow_with_future_queue_reference_blocks_retirement", func(t *testing.T) {
|
||||
f := newExecutionFixture(t, ctx, db)
|
||||
spec := runtimeSpec(f.resources.QueueID, 1)
|
||||
spec.Body.Steps = append([]definition.Step{{Key: "wait", Kind: "TIMER", Timer: &definition.Timer{DelayMS: 60000}}}, spec.Body.Steps...)
|
||||
runtimeStart(t, f, spec)
|
||||
addRetirementPermission(t, f)
|
||||
_, err := f.tasks.RetireQueue(ctx, f.token, f.resources.QueueID, "retire", service.RetireQueueInput{ExpectedRevision: 1, Reason: "retire"})
|
||||
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||
})
|
||||
t.Run("completed_workflow_receipt_survives_queue_retirement", func(t *testing.T) {
|
||||
f := newExecutionFixture(t, ctx, db)
|
||||
grantWorkflowInstance(t, f)
|
||||
input := publishInstanceSpec(t, f, runtimeSpec(f.resources.QueueID, 1))
|
||||
api := instance.New(NewWorkflowInstanceStore(db))
|
||||
key := string(executionID(t))
|
||||
first, err := api.Start(ctx, f.token, key, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ref := retryStart(t, f)
|
||||
retryFinish(t, f, ref, "SUCCEEDED", false)
|
||||
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
|
||||
runtimeView(t, f, first.Instance.ID, "SUCCEEDED")
|
||||
addRetirementPermission(t, f)
|
||||
if _, err = f.tasks.RetireQueue(ctx, f.token, f.resources.QueueID, "retire", service.RetireQueueInput{ExpectedRevision: 1, Reason: "completed workflow"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replay, err := api.Start(ctx, f.token, key, input)
|
||||
if err != nil || !replay.Deduplicated || replay.Instance.ID != first.Instance.ID {
|
||||
t.Fatal("receipt after retirement", err)
|
||||
}
|
||||
if _, err = api.Get(ctx, f.token, first.Instance.ID); err != nil {
|
||||
t.Fatal("workflow history", err)
|
||||
}
|
||||
_, err = api.Start(ctx, f.token, "new-instance", input)
|
||||
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||
})
|
||||
t.Run("submit_and_retire_serialize_under_real_database_contention", func(t *testing.T) {
|
||||
f := newExecutionFixture(t, ctx, db)
|
||||
key := string(executionID(t))
|
||||
q := f.resources.QueueID
|
||||
results := competeWorkflow(t, f, nil, func() error {
|
||||
_, e := f.tasks.Submit(ctx, f.token, key, service.SubmitInput{QueueID: q, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)})
|
||||
return e
|
||||
}, func() error {
|
||||
_, e := f.tasks.RetireQueue(ctx, f.token, q, "retire", service.RetireQueueInput{ExpectedRevision: 1, Reason: "concurrent retirement"})
|
||||
return e
|
||||
})
|
||||
if results[0] == nil {
|
||||
expectWorkerCode(t, results[1], "RESOURCE_BUSY")
|
||||
} else {
|
||||
expectWorkerCode(t, results[0], "RESOURCE_RETIRED")
|
||||
if results[1] != nil {
|
||||
t.Fatal(results[1])
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("audit_failure_rolls_back_retirement_and_quota", func(t *testing.T) {
|
||||
f := newEventFixture(t, ctx, db)
|
||||
sub := f.create(events.CreateInput{})
|
||||
f.exec(`CREATE TRIGGER retirement_audit_reject BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='retirement audit unavailable'`)
|
||||
defer db.ExecContext(ctx, `DROP TRIGGER IF EXISTS retirement_audit_reject`)
|
||||
_, err := f.events.Retire(ctx, f.token, sub.ID, "retire", events.RetireInput{ExpectedRevision: sub.Revision, Reason: "audit"})
|
||||
if err == nil {
|
||||
t.Fatal("audit failure accepted")
|
||||
}
|
||||
if f.subscription(sub.ID).RetiredAt != nil {
|
||||
t.Fatal("partial retirement committed")
|
||||
}
|
||||
})
|
||||
var foreignKeys int
|
||||
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&foreignKeys); err != nil || foreignKeys != 0 {
|
||||
t.Fatal("foreign keys", foreignKeys, err)
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,13 @@ func TestIntegrationResultSubscriptionsUpgrade(t *testing.T) {
|
||||
if err = CheckSchema(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var retired *time.Time
|
||||
if err = db.QueryRowContext(ctx, `SELECT retired_at FROM pulse_queues WHERE namespace_id=? AND id=?`, ns, queue).Scan(&retired); err != nil || retired != nil {
|
||||
t.Fatal("migration unexpectedly retired legacy queue", err)
|
||||
}
|
||||
if err = db.QueryRowContext(ctx, `SELECT retired_at FROM orbit_subscriptions WHERE namespace_id=? AND id=?`, ns, sub).Scan(&retired); err != nil || retired != nil {
|
||||
t.Fatal("migration unexpectedly retired legacy subscription", err)
|
||||
}
|
||||
var enabled bool
|
||||
var queueID *core.ID
|
||||
var filter string
|
||||
|
||||
@@ -129,6 +129,9 @@ func (s *ScheduleStore) Create(ctx context.Context, t auth.Token, key string, in
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireActiveQueue(ctx, tx, t.NamespaceID, in.Definition.Task.QueueID); err != nil {
|
||||
return err
|
||||
}
|
||||
now, err := dbNow(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -252,6 +255,13 @@ func (s *ScheduleStore) mutate(ctx context.Context, t auth.Token, id core.ID, ke
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
current, err := readSchedule(ctx, tx, t.NamespaceID, id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = requireActiveQueue(ctx, tx, t.NamespaceID, current.QueueID); err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := readSchedule(ctx, tx, t.NamespaceID, id, true)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -374,7 +374,7 @@ func (s *TaskStore) CreateQueue(ctx context.Context, token auth.Token, key strin
|
||||
}
|
||||
func readQueue(ctx context.Context, tx *sql.Tx, ns, id core.ID) (service.QueueView, error) {
|
||||
var q service.QueueView
|
||||
err := tx.QueryRowContext(ctx, "SELECT id,namespace_id,name,paused,max_concurrency,rate_per_second,max_unfinished_tasks,unfinished_tasks,revision FROM pulse_queues WHERE namespace_id=? AND id=?", ns, id).Scan(&q.ID, &q.NamespaceID, &q.Name, &q.Paused, &q.MaxConcurrency, &q.RatePerSecond, &q.MaxUnfinishedTasks, &q.UnfinishedTasks, &q.Revision)
|
||||
err := tx.QueryRowContext(ctx, "SELECT id,namespace_id,name,paused,max_concurrency,rate_per_second,max_unfinished_tasks,unfinished_tasks,revision,retired_at FROM pulse_queues WHERE namespace_id=? AND id=?", ns, id).Scan(&q.ID, &q.NamespaceID, &q.Name, &q.Paused, &q.MaxConcurrency, &q.RatePerSecond, &q.MaxUnfinishedTasks, &q.UnfinishedTasks, &q.Revision, &q.RetiredAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = fault.NotFound
|
||||
}
|
||||
|
||||
@@ -54,7 +54,11 @@ func lockQueue(ctx context.Context, tx *sql.Tx, ns, id core.ID) (paused bool, ma
|
||||
|
||||
// lockQueueAfterApplication 仅在同一事务已持有应用额度排他锁时调用。
|
||||
func lockQueueAfterApplication(ctx context.Context, tx *sql.Tx, ns, id core.ID) (paused bool, max uint32, err error) {
|
||||
err = tx.QueryRowContext(ctx, "SELECT paused,max_concurrency FROM pulse_queues WHERE namespace_id=? AND id=? FOR UPDATE", ns, id).Scan(&paused, &max)
|
||||
var retired *time.Time
|
||||
err = tx.QueryRowContext(ctx, "SELECT paused,max_concurrency,retired_at FROM pulse_queues WHERE namespace_id=? AND id=? FOR UPDATE", ns, id).Scan(&paused, &max, &retired)
|
||||
if err == nil && retired != nil {
|
||||
err = fault.Retired
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = fault.NotFound
|
||||
}
|
||||
|
||||
@@ -103,6 +103,9 @@ func (s *ExecutionStore) Connect(ctx context.Context, token auth.Token, in worke
|
||||
// 不依赖数据库外键:创建会话前验证队列存在且属于当前命名空间。
|
||||
// 进程头在队列之前加锁,与执行命令保持一致;失败时回滚新建的进程头。
|
||||
var queue core.ID
|
||||
if err := requireActiveQueue(ctx, tx, token.NamespaceID, in.QueueID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM pulse_queues WHERE namespace_id=? AND id=? FOR SHARE`, token.NamespaceID, in.QueueID).Scan(&queue); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
|
||||
@@ -129,10 +129,11 @@ func (s *WorkflowInstanceStore) Start(ctx context.Context, t auth.Token, key str
|
||||
if len(version.Analysis.Queues) > 0 && !p.Allows("tasks:submit") {
|
||||
return fault.Forbidden
|
||||
}
|
||||
if err := lockWorkflowQueues(ctx, tx, t.NamespaceID, p, version.Analysis.Queues); err != nil {
|
||||
return err
|
||||
}
|
||||
if cached != nil {
|
||||
// 已完成流程所用队列可以退役;原受理回执仍核对当前读取范围,但不重新申请执行权。
|
||||
if err := authorizeWorkflowQueues(ctx, tx, t.NamespaceID, p, version.Analysis.Queues); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(cached, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -146,6 +147,9 @@ func (s *WorkflowInstanceStore) Start(ctx context.Context, t auth.Token, key str
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
if err := lockWorkflowQueues(ctx, tx, t.NamespaceID, p, version.Analysis.Queues); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := definition.ValidateData(version.Specification.InputType, in.Input); err != nil {
|
||||
return fault.Invalid("workflow input does not match fixed version")
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
const raw = await response.text(); let body = {};
|
||||
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
|
||||
if (response.status === 401) { state.authenticated = false; redirectToLogin(); throw new Error('登录已过期,请重新登录'); }
|
||||
if (!response.ok) { const error = new Error(body?.error?.message || body?.message || `HTTP ${response.status}`); error.status = response.status; throw error; }
|
||||
if (!response.ok) { const error = new Error(window.orbitResourceMessage(body?.error?.message || body?.message || `HTTP ${response.status}`)); error.status = response.status; throw error; }
|
||||
return body;
|
||||
} finally { if (mutation) state.mutations--; }
|
||||
}
|
||||
@@ -88,7 +88,7 @@
|
||||
for (const item of items) {
|
||||
const option = document.createElement('option');
|
||||
option.value = kind === 'application' ? item.namespace_id : item.id;
|
||||
option.textContent = kind === 'application' ? `${item.application} · ${item.environment} / ${item.namespace}${item.enabled ? '' : ' · 已停用'}` : item.name;
|
||||
option.textContent = kind === 'application' ? `${item.application} · ${item.environment} / ${item.namespace}${item.enabled ? '' : ' · 已停用'}` : `${item.name}${item.retired_at ? ' · 已退役' : item.paused ? ' · 已暂停' : ''}`;
|
||||
option.disabled = kind === 'application' && !item.enabled;
|
||||
select.append(option);
|
||||
}
|
||||
@@ -455,7 +455,7 @@
|
||||
const active = items.filter(item => item.enabled !== false).length; $('subscription-meter-fill').style.width = `${items.length ? active / items.length * 100 : 0}%`;
|
||||
text('metric-subscriptions-sub', items.length ? `${active} 个已启用 · 交付自动恢复` : '当前没有结果订阅');
|
||||
const el = $('subscription-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌁</span><div><b>当前没有结果订阅</b><small>当前应用与队列还没有创建订阅</small></div></div>'; return; }
|
||||
el.innerHTML = items.map(item => `<div class="list-row subscription-row" data-subscription-id="${escapeHTML(item.id)}" tabindex="0" title="打开订阅详情"><div class="list-avatar">⌁</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}</small></div><div class="list-application">${scopeBadge()}</div><div class="list-meta">${item.enabled === false ? '已暂停' : '已启用'}</div></div>`).join('');
|
||||
el.innerHTML = items.map(item => `<div class="list-row subscription-row" data-subscription-id="${escapeHTML(item.id)}" tabindex="0" title="打开订阅详情"><div class="list-avatar">⌁</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}</small></div><div class="list-application">${scopeBadge()}</div><div class="list-meta">${item.retired_at ? '已退役' : item.enabled === false ? '已暂停' : '已启用'}</div></div>`).join('');
|
||||
el.querySelectorAll('.subscription-row').forEach(row => {
|
||||
row.addEventListener('click', () => openSubscriptionDetail(row.dataset.subscriptionId));
|
||||
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSubscriptionDetail(row.dataset.subscriptionId); } });
|
||||
@@ -731,9 +731,9 @@
|
||||
}
|
||||
function renderSubscriptionDetail(subscription) {
|
||||
state.selectedSubscription = subscription;
|
||||
text('subscription-detail-title', subscription.name || '结果订阅'); text('subscription-detail-id', subscription.id || '—'); text('subscription-detail-state', subscription.enabled === false ? '已暂停' : '已启用'); $('subscription-detail-state').className = `state-pill ${subscription.enabled === false ? 'waiting' : 'succeeded'}`;
|
||||
text('subscription-detail-title', subscription.name || '结果订阅'); text('subscription-detail-id', subscription.id || '—'); text('subscription-detail-state', subscription.retired_at ? '已退役' : subscription.enabled === false ? '已暂停' : '已启用'); $('subscription-detail-state').className = `state-pill ${subscription.enabled === false ? 'waiting' : 'succeeded'}`;
|
||||
text('subscription-detail-updated', `最后更新 ${formatTime(subscription.updated_at)}`); text('subscription-detail-name', subscription.name || '—'); text('subscription-detail-queue', shortID(subscription.queue_id)); text('subscription-detail-revision', subscription.revision || '—'); text('subscription-detail-pending', subscription.pending_count ?? '0'); text('subscription-detail-max-pending', subscription.max_pending ?? '—'); text('subscription-detail-max-attempts', subscription.max_attempts ?? '—'); text('subscription-detail-cursor', subscription.cursor_sequence ?? '0'); text('subscription-detail-types', Array.isArray(subscription.types) && subscription.types.length ? subscription.types.join(', ') : '全部结果类型');
|
||||
$('subscription-actions').hidden = false; text('subscription-control-button', subscription.enabled === false ? '启用订阅' : '暂停订阅'); text('subscription-detail-note', '订阅控制需要 events:manage 权限;暂停只停止新交付,已有租约仍可确认。过期交付可追加预算重试,但不会重置原次数和事件身份。');
|
||||
$('subscription-actions').hidden = !!subscription.retired_at; text('subscription-control-button', subscription.enabled === false ? '启用订阅' : '暂停订阅'); text('subscription-detail-note', subscription.retired_at ? `退役于 ${formatTime(subscription.retired_at)},截止水位 ${subscription.retired_sequence};未来结果不再订阅,历史交付仍可查询。` : '订阅控制需要 events:manage 权限;暂停只停止新交付,已有租约仍可确认。过期交付可追加预算重试,但不会重置原次数和事件身份。');
|
||||
}
|
||||
function renderSubscriptionDeliveries(payload, error) {
|
||||
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||
@@ -741,16 +741,18 @@
|
||||
renderDetailList('subscription-deliveries', items, error ? '当前凭据没有 events:read 交付历史权限。' : '还没有交付记录。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(item.state || 'UNKNOWN')} · 序号 ${escapeHTML(item.sequence || '—')}</b><small>${escapeHTML(formatTime(item.created_at))} · 尝试 ${escapeHTML(item.attempts ?? 0)}/${escapeHTML(item.attempt_budget ?? '—')} · 可用 ${escapeHTML(formatTime(item.available_at))}</small></div>${String(item.state || '') === 'EXPIRED' ? `<button class="detail-row-action" type="button" data-delivery-id="${escapeHTML(item.id)}">重试</button>` : `<em>${escapeHTML(shortID(item.event_id))}</em>`}</div>`);
|
||||
$('subscription-deliveries').querySelectorAll('.detail-row-action').forEach(button => button.addEventListener('click', event => { event.stopPropagation(); openSubscriptionRetry(button.dataset.deliveryId, items.find(item => item.id === button.dataset.deliveryId)); }));
|
||||
}
|
||||
function openSubscriptionAction() {
|
||||
let subscriptionRetirement = null;
|
||||
function openSubscriptionAction(mode) {
|
||||
if (!state.selectedSubscription) return;
|
||||
subscriptionRetirement = null;
|
||||
state.subscriptionActionMode = state.selectedSubscription.enabled === false ? 'ENABLE' : 'DISABLE'; $('subscription-action-form').hidden = false; $('subscription-action-error').textContent = ''; $('subscription-action-reason').value = '';
|
||||
const enabled = state.subscriptionActionMode === 'ENABLE'; text('subscription-action-title', enabled ? '启用订阅' : '暂停订阅'); text('subscription-action-description', enabled ? '启用后会继续物化和领取新的结果交付。' : '暂停只停止新交付,已有租约和待确认结果仍可由接收方处理。'); text('subscription-action-submit', enabled ? '提交启用 →' : '提交暂停 →'); $('subscription-action-reason').focus();
|
||||
const enabled = state.subscriptionActionMode === 'ENABLE'; text('subscription-action-title', enabled ? '启用订阅' : '暂停订阅'); text('subscription-action-description', enabled ? '启用后会继续物化和领取新的结果交付。' : '暂停只停止新交付,已有租约和待确认结果仍可由接收方处理。'); text('subscription-action-submit', enabled ? '提交启用 →' : '提交暂停 →'); if (mode === 'RETIRE') { state.subscriptionActionMode = 'RETIRE'; text('subscription-action-title','永久退役订阅'); text('subscription-action-description','仅在历史结果全部确认后成功。退役不可恢复,后续结果不再订阅;历史交付和审计保留,活动名额释放。'); text('subscription-action-submit','确认退役 →'); } $('subscription-action-reason').focus();
|
||||
}
|
||||
async function submitSubscriptionAction(event) {
|
||||
event.preventDefault(); if (!state.selectedSubscription || !state.subscriptionActionMode) return;
|
||||
const reason = $('subscription-action-reason').value.trim(); if (!reason) { $('subscription-action-error').textContent = '请填写操作原因。'; return; }
|
||||
const subscription = state.selectedSubscription; const enabled = state.subscriptionActionMode === 'ENABLE'; const button = $('subscription-action-submit'); button.disabled = true; $('subscription-action-error').textContent = '';
|
||||
try { await request(`/v1/subscriptions/${encodeURIComponent(subscription.id)}:configure`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(enabled ? 'subscription-enable' : 'subscription-disable') }, body: JSON.stringify({ expected_revision: String(subscription.revision), enabled, reason }) }); showToast(enabled ? '订阅已启用' : '订阅已暂停'); closeSubscriptionAction(); await refresh(); await openSubscriptionDetail(subscription.id); }
|
||||
try { const retire = state.subscriptionActionMode === 'RETIRE'; if (retire) subscriptionRetirement ||= {key:operationKey('subscription-retire'),body:{expected_revision:String(subscription.revision),reason}}; await request(`/v1/subscriptions/${encodeURIComponent(subscription.id)}:${retire ? 'retire' : 'configure'}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': retire ? subscriptionRetirement.key : operationKey(enabled ? 'subscription-enable' : 'subscription-disable') }, body: JSON.stringify(retire ? subscriptionRetirement.body : { expected_revision: String(subscription.revision), enabled, reason }) }); showToast(retire ? '订阅已退役,历史保留' : enabled ? '订阅已启用' : '订阅已暂停'); closeSubscriptionAction(); await refresh(); await openSubscriptionDetail(subscription.id); }
|
||||
catch (error) { $('subscription-action-error').textContent = error.message || '订阅控制失败'; }
|
||||
finally { button.disabled = false; }
|
||||
}
|
||||
@@ -814,7 +816,7 @@
|
||||
$('retention-restart-button').addEventListener('click', resetRetentionExport);
|
||||
$('retention-form').addEventListener('input', event => { if (event.target.matches('input, textarea')) { retentionUI.dirty = true; syncRetentionControls(); } });
|
||||
$('logout-hero').addEventListener('click', logout); $('logout-button').addEventListener('click', logout); $('logout-inline').addEventListener('click', logout); $('refresh-button').addEventListener('click', refresh); $('retention-form').addEventListener('submit', submitRetention); $('retention-preview-button').addEventListener('click', previewRetention); $('retention-export-button').addEventListener('click', exportRetention);
|
||||
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
|
||||
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-retire-button').addEventListener('click', () => openSubscriptionAction('RETIRE')); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
|
||||
$('workflow-recovery-actions').addEventListener('click', event => { const button = event.target.closest('[data-recovery-action]'); if (!button) return; let context = {}; try { context = JSON.parse(button.dataset.recoveryContext || '{}'); } catch { showToast('恢复按钮上下文无效,请重新读取流程'); return; } openWorkflowRecovery(button.dataset.recoveryAction, context); }); $('workflow-recovery-cancel').addEventListener('click', closeWorkflowRecovery); $('workflow-recovery-form').addEventListener('submit', submitWorkflowRecovery);
|
||||
document.addEventListener('keydown', event => { if (event.key === 'Escape' && !$('workflow-recovery-form').hidden) closeWorkflowRecovery(); });
|
||||
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));
|
||||
|
||||
@@ -4,7 +4,21 @@
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效';
|
||||
let tokenRequest = 0, tokenTarget = null;
|
||||
function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : error.message; }
|
||||
const resourceMessages = {
|
||||
'resource is retired; history remains available':'资源已退役,历史仍可查询。',
|
||||
'queue has unfinished tasks, including tasks needing review':'队列还有未完成或待核查任务,请先结清。',
|
||||
'queue has unfinished or uncertain tasks':'队列还有未完成或结果不确定的任务,请先核查。',
|
||||
'queue has active execution attempts':'队列仍有在途执行,请等待结束。',
|
||||
'queue has unreleased execution permits':'队列仍有未释放的执行许可,请先核查在途工作。',
|
||||
'queue has enabled schedules; pause them first':'队列还有启用中的定时计划,请先暂停计划。',
|
||||
'queue is referenced by unfinished workflows':'队列仍被未结束的流程引用,请先结清流程。',
|
||||
'queue has subscriptions; retire them first':'队列还有未退役订阅,请先结清并退役订阅。',
|
||||
'subscription has unacknowledged deliveries; drain or resolve expired deliveries first':'订阅还有未确认结果,请先消费并确认;过期交付需要核查后重试。',
|
||||
'subscription has unacknowledged deliveries':'订阅还有未确认结果,请先结清。',
|
||||
'subscription has unconsumed results; resume consumption and acknowledge them before retirement':'还有尚未投递的结果,请恢复消费并全部确认后再退役。'
|
||||
};
|
||||
window.orbitResourceMessage = text => resourceMessages[text] || text;
|
||||
function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : window.orbitResourceMessage(error.message); }
|
||||
async function api(path = '', body) {
|
||||
const response = await fetch(`/console/applications${path}`, { method: body ? 'POST' : 'GET', credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json', 'X-Orbit-Console': '1' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
|
||||
const value = await response.json().catch(() => ({}));
|
||||
@@ -12,7 +26,11 @@
|
||||
if (!response.ok) { const error = new Error(value.error?.message || `请求失败 (${response.status})`); error.status = response.status; throw error; }
|
||||
return value.data;
|
||||
}
|
||||
const queueMemory = new Map();
|
||||
function rememberedQueue(namespace) { try { return localStorage.getItem(`orbit.access.queue.${namespace}`) || queueMemory.get(namespace) || ''; } catch { return queueMemory.get(namespace) || ''; } }
|
||||
function rememberQueue() { const value = $('access-queue').value; queueMemory.set(ui.selected, value); try { localStorage.setItem(`orbit.access.queue.${ui.selected}`, value); } catch {} renderConfig(); }
|
||||
function config(token = '••••••••', queue = $('access-queue').value, namespace = ui.selected) {
|
||||
if (!queue) throw new Error('请先选择接入队列');
|
||||
const url = $('access-url').value.trim() || location.origin;
|
||||
let parsed; try { parsed = new URL(url); } catch { throw new Error('请输入完整的 HTTP / HTTPS 服务地址'); }
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || /[\s'"`$\\;&|<>(){}]/.test(url)) throw new Error('服务地址必须是不含账号、查询参数或特殊字符的 HTTP / HTTPS 地址');
|
||||
@@ -30,15 +48,21 @@
|
||||
finally { ui.loading = false; }
|
||||
}
|
||||
async function select(namespace) {
|
||||
if (namespace !== ui.selected) { retirement = null; $('access-retire-form').hidden = true; }
|
||||
const requested = namespace; ui.selected = namespace;
|
||||
try {
|
||||
const detail = await api(`/${encodeURIComponent(namespace)}`); if (ui.selected !== requested) return; ui.detail = detail;
|
||||
$('access-empty').hidden = true; $('access-detail').hidden = false;
|
||||
$('access-title').textContent = detail.entry.application; $('access-status').textContent = detail.entry.enabled ? '已启用' : '已停用';
|
||||
$('access-app-id').textContent = detail.entry.application_id; $('access-ns-id').textContent = namespace; $('access-scope').textContent = `${detail.entry.environment} / ${detail.entry.namespace}`;
|
||||
const previous = $('access-queue').value;
|
||||
$('access-queue').innerHTML = detail.queues.map(q => `<option value="${escape(q.id)}">${escape(q.name)} · ${escape(q.id.slice(0, 8))}</option>`).join('');
|
||||
if (detail.queues.some(q => q.id === previous)) $('access-queue').value = previous;
|
||||
const previous = rememberedQueue(namespace);
|
||||
const usable = detail.queues.filter(q => !q.retired_at);
|
||||
$('access-queue').innerHTML = '<option value="">请选择接入队列</option>' + detail.queues.map(q => `<option value="${escape(q.id)}" ${q.retired_at ? 'disabled' : ''}>${escape(q.name)} · ${q.retired_at ? '已退役' : q.paused ? '已暂停' : '可用'} · ${escape(q.id.slice(0, 8))}</option>`).join('');
|
||||
$('access-queue').value = usable.some(q => q.id === previous) ? previous : (!detail.truncated && usable.length === 1 ? usable[0].id : '');
|
||||
$('access-capacity').textContent = `订阅名额 ${detail.active_subscriptions ?? '—'} / ${detail.subscription_limit ?? '—'} · 暂停仍占名额,结清后退役释放名额。`;
|
||||
const availableCredential = detail.credentials.find(c => !c.revoked_at);
|
||||
$('access-copy-template').textContent = availableCredential?.token_stored ? '查看完整接入配置 ↗' : '补存当前 Token ↗';
|
||||
$('access-config-note').textContent = availableCredential?.token_stored ? '选择正确队列后,可查看、复制含当前 Token 的完整配置。' : '当前 Token 尚未补存。请从已保存的新配置导入当前 Token,再查看或复制;过渡期旧 Token 不能用于补存。';
|
||||
$('access-truncated').hidden = !detail.truncated;
|
||||
const now = Date.now();
|
||||
$('access-credentials').innerHTML = detail.credentials.map(c => {
|
||||
@@ -47,7 +71,7 @@
|
||||
const blocked = !!c.revoked_at || transitioning;
|
||||
const helpID = `access-rotation-help-${c.id}`;
|
||||
const reason = c.revoked_at ? '凭据已撤销,不能再次换证。' : '旧 Token 仍在过渡期,暂不能再次换证,以免中断尚未切换的服务。';
|
||||
const rotationHelp = transitioning ? `<aside class="access-rotation-notice" id="${escape(helpID)}"><strong>旧 Token 仍在过渡期</strong><p>${escape(reason)}</p><p>过渡截止:<time datetime="${escape(c.previous_valid_until)}">${escape(time(c.previous_valid_until))}</time>。到期后,换证按钮随页面刷新恢复可用。</p><p>请在上方复制当前接入配置,更新服务、Worker 和结果消费者。确认全部切换、在途任务收尾后,也可提前点击「结束过渡,停用旧 Token」。</p></aside>` : c.revoked_at ? `<p id="${escape(helpID)}">${escape(reason)}</p>` : c.previous_valid_until ? `<p>旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。</p>` : '';
|
||||
const rotationHelp = transitioning ? `<aside class="access-rotation-notice" id="${escape(helpID)}"><strong>旧 Token 仍在过渡期</strong><p>${escape(reason)}</p><p>过渡截止:<time datetime="${escape(c.previous_valid_until)}">${escape(time(c.previous_valid_until))}</time>。到期后,换证按钮随页面刷新恢复可用。</p><p>${c.token_stored ? '请在上方复制当前接入配置,更新服务、Worker 和结果消费者。' : '当前新 Token 尚未补存,请先从已保存的新配置导入;过渡期旧 Token 不能替代当前新 Token。'}确认全部切换、在途任务收尾后,也可提前点击「结束过渡,停用旧 Token」。</p></aside>` : c.revoked_at ? `<p id="${escape(helpID)}">${escape(reason)}</p>` : c.previous_valid_until ? `<p>旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。</p>` : '';
|
||||
const groups = [['tasks:submit', '任务'], ['workers:consume', 'Worker 执行'], ['events:consume', '结果订阅'], ['schedules:manage', '定时调度'], ['workflows:start', '流程编排'], ['tasks:review', '人工恢复'], ['application:resources:manage', '应用额度']].filter(([action]) => c.permissions.actions.includes(action)).map(([,name]) => name);
|
||||
return `<section class="access-credential">
|
||||
<div class="access-credential-header"><b>${escape(c.name)} · ${status}</b><button class="text-button ${blocked ? 'access-rotation-blocked' : ''}" ${blocked ? `data-access-help="${escape(c.id)}" title="${escape(reason)}" aria-describedby="${escape(helpID)}"` : `data-access-rotate="${escape(c.id)}"`} aria-haspopup="dialog">${c.revoked_at ? '已撤销 · 查看原因 ⓘ' : transitioning ? '暂不可换证 · 查看原因 ⓘ' : '平滑换证 ↗'}</button></div>
|
||||
@@ -63,7 +87,7 @@
|
||||
document.querySelectorAll('[data-access-id]').forEach(el => el.classList.toggle('selected', el.dataset.accessId === namespace)); renderConfig();
|
||||
} catch (error) { $('access-error').textContent = message(error); }
|
||||
}
|
||||
function renderConfig() { try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } }
|
||||
function renderConfig() { const q = ui.detail?.queues.find(q => q.id === $('access-queue').value); $('access-queue-note').textContent = !q ? '请明确选择接入队列,避免复制到错误的目标。' : `当前目标:${q.name} · ${q.paused ? '已暂停,任务暂不执行' : '可用'}。选择只影响复制配置,不修改服务端路由。`; $('access-retire-queue').disabled = !q || !!q.retired_at; try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } }
|
||||
function closeToken() {
|
||||
tokenRequest++; tokenTarget = null;
|
||||
$('access-token-import-submit').disabled = false;
|
||||
@@ -74,7 +98,8 @@
|
||||
closeToken();
|
||||
tokenTarget = { namespace: ui.selected, credential, queue: $('access-queue').value };
|
||||
// 只导出该凭据已授权的队列,不能拼出无法使用的配置。
|
||||
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(tokenTarget.queue)) tokenTarget.queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || '';
|
||||
if (!tokenTarget.queue) { $('access-error').textContent = '请先选择要接入的队列。'; return; }
|
||||
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(tokenTarget.queue)) { $('access-error').textContent = '当前凭据没有所选队列权限,请选择已授权队列。'; return; }
|
||||
$('access-token-scope').textContent = `${ui.detail.entry.application} · ${ui.detail.entry.environment} / ${ui.detail.entry.namespace} · 凭据版本 ${credential.revision}`;
|
||||
$('access-token-error').textContent = '';
|
||||
$('access-token-import').hidden = credential.token_stored;
|
||||
@@ -206,12 +231,29 @@
|
||||
$('access-create').addEventListener('click', () => open()); $('access-close').addEventListener('click', close);
|
||||
$('access-dialog').addEventListener('cancel', e => { e.preventDefault(); close(); });
|
||||
$('access-refresh').addEventListener('click', load); $('access-next').addEventListener('click', () => { ui.after = ui.next; load(); }); $('access-first').addEventListener('click', () => { ui.after = ''; load(); });
|
||||
$('access-url').addEventListener('input', renderConfig); $('access-queue').addEventListener('change', renderConfig);
|
||||
$('access-url').addEventListener('input', renderConfig); $('access-queue').addEventListener('change', rememberQueue);
|
||||
$('access-copy-template').addEventListener('click', () => { const credential = ui.detail?.credentials.find(c => !c.revoked_at); if (credential) showToken(credential); });
|
||||
$('access-form').addEventListener('submit', submit);
|
||||
$('access-copy').addEventListener('click', async () => { if (await copy($('access-result-config').value, $('access-copy'))) ui.saved = true; });
|
||||
$('access-download').addEventListener('click', () => { download('orbit.env', $('access-result-config').value); ui.saved = true; });
|
||||
$('access-backup').addEventListener('click', () => { if (ui.pending) download(`orbit-operation-${ui.pending.operation_id}.json`, JSON.stringify(ui.pending, null, 2), 'application/json'); });
|
||||
window.addEventListener('beforeunload', e => { if (ui.pending || !ui.saved) { e.preventDefault(); e.returnValue = ''; } });
|
||||
let retirement = null;
|
||||
async function queueAPI(path, body, key, namespace = ui.selected) {
|
||||
const response = await fetch(path, {method: body ? 'POST' : 'GET', credentials:'same-origin', cache:'no-store', headers:{'Content-Type':'application/json', 'X-Orbit-Console':'1','X-Orbit-Namespace':namespace,...(key ? {'Idempotency-Key':key} : {})}, ...(body ? {body:JSON.stringify(body)} : {})});
|
||||
const value = await response.json(); if (!response.ok) throw new Error(value.error?.message || '操作失败'); return value.data;
|
||||
}
|
||||
$('access-retire-queue').addEventListener('click', async () => {
|
||||
const id = $('access-queue').value, namespace = ui.selected; if (!id) return;
|
||||
$('access-retire-form').hidden=false; $('access-retire-error').textContent='正在核对队列…'; retirement=null;
|
||||
try { const q=await queueAPI(`/v1/queues/${id}`,null,null,namespace); if (namespace!==ui.selected || id!==$('access-queue').value) return; retirement={id,namespace,revision:q.revision,key:crypto.randomUUID(),body:null}; $('access-retire-reason').value=''; $('access-retire-error').textContent=''; } catch(error) { $('access-retire-error').textContent=message(error); }
|
||||
});
|
||||
$('access-retire-cancel').addEventListener('click',()=>{retirement=null;$('access-retire-form').hidden=true;});
|
||||
$('access-retire-form').addEventListener('submit', async event=>{
|
||||
event.preventDefault(); const target=retirement; if (!target || target.namespace!==ui.selected || target.id!==$('access-queue').value) { $('access-retire-error').textContent='队列已变化,请重新打开退役操作。'; return; }
|
||||
target.body ||= {expected_revision:String(target.revision),reason:$('access-retire-reason').value.trim()};
|
||||
$('access-retire-submit').disabled=true;
|
||||
try { await queueAPI(`/v1/queues/${target.id}:retire`,target.body,target.key,target.namespace); $('access-retire-form').hidden=true; retirement=null; await load(); } catch(error) { $('access-retire-error').textContent=message(error); } finally { $('access-retire-submit').disabled=false; }
|
||||
});
|
||||
window.orbitApplications = { load };
|
||||
})();
|
||||
|
||||
@@ -223,9 +223,11 @@
|
||||
<div class="access-config-grid"><label>服务地址<input id="access-url" type="url" autocomplete="off" spellcheck="false"></label><label>任务队列<select id="access-queue"></select></label></div>
|
||||
<p class="panel-description">本机默认使用当前页面地址;容器内可填写 http://orbit-test:8080,部署后可换成服务域名。</p>
|
||||
<div class="access-credentials" id="access-credentials"></div>
|
||||
<p class="panel-description" id="access-queue-note"></p><p class="panel-description" id="access-capacity"></p><button class="text-button" type="button" id="access-retire-queue">退役所选队列</button>
|
||||
<form id="access-retire-form" hidden><p class="panel-description">退役不可恢复。先暂停关联计划、结清任务与流程,再退役所有订阅;历史和审计继续保留。</p><label>退役原因<input id="access-retire-reason" maxlength="1024" required></label><button class="primary-button compact" type="submit" id="access-retire-submit">确认退役</button><button class="text-button" type="button" id="access-retire-cancel">取消</button><p id="access-retire-error" class="action-error"></p></form>
|
||||
<p class="panel-description" id="access-truncated" hidden>队列或凭据超过 100 项,此页只展示前 100 项。</p>
|
||||
<div class="access-config-heading"><h3>接入配置预览</h3><button class="text-button" id="access-copy-template">查看完整接入配置 ↗</button></div>
|
||||
<p class="panel-description">预览默认隐藏 Token。点击「查看完整接入配置」,即可复制包含当前 Token 的配置,或下载 orbit.env。</p>
|
||||
<p class="panel-description" id="access-config-note">预览默认隐藏 Token。点击「查看完整接入配置」,即可复制包含当前 Token 的配置,或下载 orbit.env。</p>
|
||||
<pre class="access-code" id="access-config"></pre>
|
||||
<div class="access-help">Token 确定应用和权限,队列决定任务去向。业务服务注册相同名称与版本的 handler 后,即可执行任务。</div>
|
||||
</div>
|
||||
@@ -374,7 +376,7 @@
|
||||
<div><span>事件类型</span><b id="subscription-detail-types">—</b></div>
|
||||
</div>
|
||||
<div class="detail-block"><div class="detail-block-heading"><span>最近交付</span><small id="subscription-deliveries-count">加载中</small></div><div class="detail-list" id="subscription-deliveries"><div class="detail-loading">正在读取交付历史…</div></div></div>
|
||||
<div class="drawer-actions" id="subscription-actions" hidden><button class="quiet-button" id="subscription-control-button" type="button">暂停订阅</button></div>
|
||||
<div class="drawer-actions" id="subscription-actions" hidden><button class="quiet-button" id="subscription-retire-button" type="button">退役订阅</button><button class="quiet-button" id="subscription-control-button" type="button">暂停订阅</button></div>
|
||||
<p class="drawer-note" id="subscription-detail-note">交付详情不展示租约秘密;结果确认与人工重试必须通过受保护的消费或管理接口完成。</p>
|
||||
<form class="action-form" id="subscription-action-form" hidden>
|
||||
<div class="action-form-heading"><span class="panel-kicker">EVENTS CONTROL</span><h3 id="subscription-action-title">订阅控制</h3></div>
|
||||
|
||||
@@ -34,7 +34,7 @@ func consoleScopePath(r *http.Request) bool {
|
||||
return r.Method == "GET"
|
||||
}
|
||||
if strings.HasPrefix(p, prefix+"/") {
|
||||
return r.Method == "GET" || (resource != "queues" && r.Method == "POST")
|
||||
return r.Method == "GET" || (r.Method == "POST" && (resource != "queues" || strings.HasSuffix(p, ":retire")))
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -47,12 +47,24 @@ func RegisterEventSubscriptions(server *http.Server, s *events.Service) {
|
||||
}))
|
||||
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" {
|
||||
if !ok || (command != "configure" && command != "retire") {
|
||||
return nil, 0, fault.NotFound
|
||||
}
|
||||
if err := noEventQuery(r); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if command == "retire" {
|
||||
var in events.RetireInput
|
||||
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.Retire(r.Context(), t, core.ID(id), key, in)
|
||||
return out, 200, err
|
||||
}
|
||||
var in events.ControlInput
|
||||
if err := decodeEventBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
|
||||
@@ -150,6 +150,21 @@ func registerTasks(mux *http.ServeMux, s *service.Service) {
|
||||
}))
|
||||
mux.HandleFunc("POST /v1/queues/{command}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||
command := r.PathValue("command")
|
||||
if strings.HasSuffix(command, ":retire") {
|
||||
if err := noEventQuery(r); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var in service.RetireQueueInput
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
key, err := idempotencyKey(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := s.RetireQueue(r.Context(), t, core.ID(strings.TrimSuffix(command, ":retire")), key, in)
|
||||
return out, 200, err
|
||||
}
|
||||
if !strings.HasSuffix(command, ":configure") {
|
||||
return nil, 0, fault.NotFound
|
||||
}
|
||||
@@ -233,7 +248,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, "DELIVERY_LEASE_LOST": 409, "EVENT_HISTORY_EXPIRED": 410, "SUBSCRIPTION_LIMIT": 409, "WORKFLOW_DEFINITION_LIMIT": 409, "WORKFLOW_VERSION_LIMIT": 409, "WORKFLOW_INSTANCE_LIMIT": 409, "WORKFLOW_SIGNAL_LIMIT": 409}[known.Code]
|
||||
status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "RESOURCE_RETIRED": 409, "RESOURCE_BUSY": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415, "DELIVERY_LEASE_LOST": 409, "EVENT_HISTORY_EXPIRED": 410, "SUBSCRIPTION_LIMIT": 409, "WORKFLOW_DEFINITION_LIMIT": 409, "WORKFLOW_VERSION_LIMIT": 409, "WORKFLOW_INSTANCE_LIMIT": 409, "WORKFLOW_SIGNAL_LIMIT": 409}[known.Code]
|
||||
if status == 0 {
|
||||
status = 503
|
||||
}
|
||||
|
||||
@@ -72,19 +72,23 @@ type CreateInput struct {
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
ID core.ID `json:"id"`
|
||||
QueueID core.ID `json:"queue_id"`
|
||||
CredentialID core.ID `json:"credential_id"`
|
||||
Name string `json:"name"`
|
||||
Revision uint64 `json:"revision,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Types []string `json:"types"`
|
||||
CursorSequence uint64 `json:"cursor_sequence,string"`
|
||||
PendingCount uint32 `json:"pending_count"`
|
||||
MaxPending uint32 `json:"max_pending"`
|
||||
MaxAttempts uint32 `json:"max_attempts"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// RetiredAt 永久退役时间;退役后不可重新启用且不占活动订阅名额。
|
||||
RetiredAt *time.Time `json:"retired_at,omitempty"`
|
||||
// RetiredSequence 退役时的结果流截止水位,十进制字符串;之后的结果不再订阅。
|
||||
RetiredSequence *uint64 `json:"retired_sequence,string,omitempty"`
|
||||
ID core.ID `json:"id"`
|
||||
QueueID core.ID `json:"queue_id"`
|
||||
CredentialID core.ID `json:"credential_id"`
|
||||
Name string `json:"name"`
|
||||
Revision uint64 `json:"revision,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Types []string `json:"types"`
|
||||
CursorSequence uint64 `json:"cursor_sequence,string"`
|
||||
PendingCount uint32 `json:"pending_count"`
|
||||
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 {
|
||||
@@ -92,6 +96,12 @@ type SubscriptionResult struct {
|
||||
Deduplicated bool `json:"deduplicated"`
|
||||
}
|
||||
|
||||
// RetireInput 永久停止未来订阅;只在所有历史结果已确认时成功。
|
||||
type RetireInput struct {
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 当前配置版本。
|
||||
Reason string `json:"reason"` // Reason 退役原因,写入不可变审计。
|
||||
}
|
||||
|
||||
type ControlInput struct {
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -160,6 +170,7 @@ type Store interface {
|
||||
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)
|
||||
RetireSubscription(context.Context, auth.Token, core.ID, string, RetireInput) (SubscriptionResult, error)
|
||||
ControlSubscription(context.Context, auth.Token, core.ID, string, ControlInput) (SubscriptionResult, error)
|
||||
GetDelivery(context.Context, auth.Token, core.ID, core.ID) (Delivery, error)
|
||||
ClaimDelivery(context.Context, auth.Token, core.ID, ClaimInput) (*Grant, error)
|
||||
@@ -233,6 +244,15 @@ func (s *Service) Control(ctx context.Context, t auth.Token, id core.ID, key str
|
||||
}
|
||||
return s.store.ControlSubscription(ctx, t, id, key, in)
|
||||
}
|
||||
func (s *Service) Retire(ctx context.Context, t auth.Token, id core.ID, key string, in RetireInput) (SubscriptionResult, error) {
|
||||
if err := keyValid(key); err != nil {
|
||||
return SubscriptionResult{}, err
|
||||
}
|
||||
if id.Validate() != nil || !reasonValid(in.ExpectedRevision, in.Reason) {
|
||||
return SubscriptionResult{}, fault.Invalid("subscription_id, revision and reason are required")
|
||||
}
|
||||
return s.store.RetireSubscription(ctx, t, id, key, in)
|
||||
}
|
||||
func (s *Service) Delivery(ctx context.Context, t auth.Token, id, delivery core.ID) (Delivery, error) {
|
||||
if id.Validate() != nil || delivery.Validate() != nil {
|
||||
return Delivery{}, fault.Invalid("invalid subscription_id or delivery_id")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
)
|
||||
|
||||
@@ -9,6 +11,10 @@ import (
|
||||
type Subscription struct {
|
||||
// Record 包含命名空间、记录标识、并发版本和 UTC 时间。
|
||||
core.Record
|
||||
// RetiredAt 永久退役的UTC时间;空表示未退役,暂停仍占活动名额。
|
||||
RetiredAt *time.Time `db:"retired_at"`
|
||||
// RetiredSequence 退役时锁定的结果流提交水位;空表示未退役,后续结果不再订阅。
|
||||
RetiredSequence *uint64 `db:"retired_sequence"`
|
||||
// Name 可读名称,唯一性范围由对应数据表约束。
|
||||
Name string `db:"name"`
|
||||
// CredentialID 关联的命名空间凭据标识。
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
type Queue struct {
|
||||
// Record 包含命名空间、记录标识、并发版本和 UTC 时间。
|
||||
core.Record
|
||||
// RetiredAt 永久退役的UTC时间;空表示未退役,退役后保留历史且禁止新工作。
|
||||
RetiredAt *time.Time `db:"retired_at"`
|
||||
// Name 可读名称,唯一性范围由对应数据表约束。
|
||||
Name string `db:"name"`
|
||||
// Paused 暂停标记;停止新的受控工作,不表示在途请求已停止。
|
||||
|
||||
@@ -196,6 +196,17 @@ func (s *Service) ConfigureQueue(ctx context.Context, token auth.Token, id core.
|
||||
return s.store.ConfigureQueue(ctx, token, id, key, in)
|
||||
}
|
||||
|
||||
// RetireQueue 只关闭已结清的通用资源,不删除任务、交付或审计。
|
||||
func (s *Service) RetireQueue(ctx context.Context, t auth.Token, id core.ID, key string, in RetireQueueInput) (QueueResult, error) {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return QueueResult{}, err
|
||||
}
|
||||
if id.Validate() != nil || in.ExpectedRevision == 0 || strings.TrimSpace(in.Reason) == "" || !utf8.ValidString(in.Reason) || utf8.RuneCountInString(in.Reason) > 1024 {
|
||||
return QueueResult{}, fault.Invalid("queue id, revision and reason are required")
|
||||
}
|
||||
return s.store.RetireQueue(ctx, t, id, key, in)
|
||||
}
|
||||
|
||||
// ValidateConcurrencyKey 不归一化资源键,字节精确相等才共享许可。
|
||||
func ValidateConcurrencyKey(key string) error {
|
||||
if key == "" {
|
||||
|
||||
@@ -181,6 +181,8 @@ type QueueInput struct {
|
||||
RatePerSecond uint32 `json:"rate_per_second"`
|
||||
}
|
||||
type QueueView struct {
|
||||
// RetiredAt 永久退役时间;为空表示仍可使用,历史查询保持可用。
|
||||
RetiredAt *time.Time `json:"retired_at,omitempty"`
|
||||
// MaxUnfinishedTasks 未终结任务上限,包含待核查;只限制新的受理。
|
||||
MaxUnfinishedTasks uint32 `json:"max_unfinished_tasks"`
|
||||
// UnfinishedTasks 当前未终结任务数,JSON 十进制字符串;运行计数不改变配置版本。
|
||||
@@ -217,6 +219,12 @@ type QueueConfigInput struct {
|
||||
MaxUnfinishedTasks uint32 `json:"max_unfinished_tasks"`
|
||||
}
|
||||
|
||||
// RetireQueueInput 永久关闭已结清的队列,保留全部历史。
|
||||
type RetireQueueInput struct {
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 当前队列配置版本。
|
||||
Reason string `json:"reason"` // Reason 退役原因,写入不可变审计。
|
||||
}
|
||||
|
||||
type QueueResult struct {
|
||||
// Queue 本次操作返回的队列快照。
|
||||
Queue QueueView `json:"queue"`
|
||||
@@ -237,6 +245,7 @@ type Store interface {
|
||||
ListTasks(context.Context, auth.Token, ListInput) (TaskPage, error)
|
||||
CancelTask(context.Context, auth.Token, core.ID, string, CancelInput) (CancelResult, error)
|
||||
CreateQueue(context.Context, auth.Token, string, QueueInput) (QueueResult, error)
|
||||
RetireQueue(context.Context, auth.Token, core.ID, string, RetireQueueInput) (QueueResult, error)
|
||||
ConfigureQueue(context.Context, auth.Token, core.ID, string, QueueConfigInput) (QueueResult, error)
|
||||
GetQueue(context.Context, auth.Token, core.ID) (QueueView, error)
|
||||
ListTaskAttempts(context.Context, auth.Token, core.ID, HistoryPageInput) (AttemptPage, error)
|
||||
|
||||
@@ -83,7 +83,7 @@ def setup():
|
||||
'ORBIT_TEST_BACKEND_SUBNET': '10.253.240.0/24',
|
||||
'ORBIT_TEST_SERVICES_SUBNET': '10.253.241.0/24',
|
||||
'ORBIT_CONSOLE_QUEUE_ID': '', 'ORBIT_ARCHIVE_STORAGE_ID': secrets.token_hex(16),
|
||||
'ORBIT_BUILD_VERSION': 'v0.1.1-local', 'ORBIT_BUILD_REVISION': revision,
|
||||
'ORBIT_BUILD_VERSION': 'v0.1.2-local', 'ORBIT_BUILD_REVISION': revision,
|
||||
'ORBIT_BUILD_TIME': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')})
|
||||
private_write(PRIVATE / 'admin.password', secrets.token_urlsafe(24) + '\n')
|
||||
values = settings()
|
||||
|
||||
@@ -151,6 +151,19 @@ call('/v1/subscriptions/' + subscription_id + ':configure', pause, headers=manag
|
||||
assert call('/v1/subscriptions/' + subscription_id, token=rotate['token'], session=False)['credential_id'] == op['credential_id']
|
||||
assert call('/console/scope')['entry']['namespace_id'] == original_scope['entry']['namespace_id']
|
||||
print('PASS: scoped task/schedule/workflow/subscription/retention/archive reads, subscription management/replay, SDK ownership, CSRF and cross-application isolation')
|
||||
# 通用退役管理:只操作本测试新建空资源,验证跨应用管理员范围和 CSRF。
|
||||
retire_queue = call('/v1/queues', {'name':'retire-'+uuid.uuid4().hex[:8], 'max_concurrency':1, 'rate_per_second':10}, token=rotate['token'], session=False, headers={'Idempotency-Key':uuid.uuid4().hex}, expected=201)['queue']
|
||||
retire_sub = call('/v1/subscriptions', {'name':'retire-'+uuid.uuid4().hex[:8], 'queue_id':retire_queue['id']}, token=rotate['token'], session=False, headers={'Idempotency-Key':uuid.uuid4().hex}, expected=201)['subscription']
|
||||
capacity_before = call('/console/applications/'+op['namespace_id'])['active_subscriptions']
|
||||
retire_body = {'expected_revision':retire_sub['revision'], 'reason':'completed isolated acceptance'}
|
||||
retire_headers = {**scope_headers,'Idempotency-Key':uuid.uuid4().hex}
|
||||
call('/v1/subscriptions/'+retire_sub['id']+':retire', retire_body, headers={**retire_headers,'Origin':'https://other.invalid'}, expected=403)
|
||||
assert call('/v1/subscriptions/'+retire_sub['id']+':retire', retire_body, headers=retire_headers)['subscription']['retired_at']
|
||||
assert call('/v1/subscriptions/'+retire_sub['id']+':retire', retire_body, headers=retire_headers)['deduplicated']
|
||||
assert call('/console/applications/'+op['namespace_id'])['active_subscriptions'] == capacity_before-1
|
||||
assert call('/v1/queues/'+retire_queue['id']+':retire', {'expected_revision':retire_queue['revision'],'reason':'completed isolated acceptance'}, headers={**scope_headers,'Idempotency-Key':uuid.uuid4().hex})['queue']['retired_at']
|
||||
assert call('/v1/queues/'+retire_queue['id'], headers=scope_headers)['retired_at']
|
||||
print('PASS: scoped resource retirement, CSRF, idempotency, historical reads and released subscription capacity')
|
||||
call('/console/auth/logout', {})
|
||||
call(token_path + 'reveal', {'expected_revision':'4'}, expected=401)
|
||||
call('/console/applications', expected=401)
|
||||
|
||||
@@ -84,6 +84,7 @@ if [ -z "$TASK_HTTP_PORT" ]; then printf '%s\n' 'Bootstrap HTTP server did not s
|
||||
(cd examples && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/sdk.credentials.json" "$TASK_GO" run ./task-query)
|
||||
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_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_TEST_DIR/orbit-exec-load" -requests 6 -concurrency 2 -workers 2 -capacity 2 -durations 0ms,100ms -run-timeout 30s)
|
||||
(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/resource-lifecycle)
|
||||
(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 .)
|
||||
|
||||
16
sdk/go/examples/resource-lifecycle/README.md
Normal file
16
sdk/go/examples/resource-lifecycle/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 队列和订阅的通用退役示例
|
||||
|
||||
用途:为一次独立回归创建专属空队列和订阅,然后退役订阅、重报原操作验证幂等、退役队列。不会提交任务,也不会修改现有队列或任何业务凭据。名称随机生成,没有行业或接入方约定。
|
||||
|
||||
需要已部署迁移 000034 的 Orbit、`queues:manage/queues:read/events:manage` 权限及允许创建新队列的范围。配置 `ORBIT_URL` 和 `ORBIT_TOKEN`,或使用 `ORBIT_CREDENTIALS_FILE` 引用自己的 SDK 凭据文件。
|
||||
|
||||
```sh
|
||||
cd sdk/go
|
||||
export ORBIT_URL=http://127.0.0.1:18080
|
||||
export ORBIT_CREDENTIALS_FILE=/absolute/path/to/sdk.credentials.json
|
||||
go run ./examples/resource-lifecycle
|
||||
```
|
||||
|
||||
预期输出只有本次资源 ID,以及 `retired=true history_preserved=true subscription_replay=true`。控制台仍能查询历史,订阅不再占活动名额。
|
||||
|
||||
若中途失败,示例以非零状态退出;保留已经打印的 ID,按[退役规则](../../../../docs/api/resource-retirement.md)核查并收尾。生产部署应持久保存每条退役命令的幂等键、目标及原请求,网络不明确时重报同一条命令。
|
||||
77
sdk/go/examples/resource-lifecycle/main.go
Normal file
77
sdk/go/examples/resource-lifecycle/main.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// resource-lifecycle 创建并退役本次独占的空队列与订阅,不处理任何现有资源。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
func run() error {
|
||||
token := os.Getenv("ORBIT_TOKEN")
|
||||
if path := os.Getenv("ORBIT_CREDENTIALS_FILE"); token == "" && path != "" {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var c struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err = json.Unmarshal(raw, &c); err != nil {
|
||||
return err
|
||||
}
|
||||
token = c.Token
|
||||
}
|
||||
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
var random [16]byte
|
||||
if _, err = rand.Read(random[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
runID := hex.EncodeToString(random[:])
|
||||
q, err := client.CreateQueue(ctx, runID+"-queue", orbit.CreateQueueRequest{Name: "lifecycle-" + runID, MaxConcurrency: 1, RatePerSecond: 10})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("queue=%s\n", q.Queue.ID)
|
||||
sub, err := client.CreateSubscription(ctx, runID+"-sub", orbit.CreateSubscriptionRequest{Name: "lifecycle-" + runID, QueueID: q.Queue.ID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("subscription=%s\n", sub.Subscription.ID)
|
||||
in := orbit.RetireResourceRequest{ExpectedRevision: sub.Subscription.Revision, Reason: "isolated lifecycle example completed"}
|
||||
if _, err = client.RetireSubscription(ctx, sub.Subscription.ID, runID+"-retire-sub", in); err != nil {
|
||||
return err
|
||||
}
|
||||
replay, err := client.RetireSubscription(ctx, sub.Subscription.ID, runID+"-retire-sub", in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !replay.Deduplicated {
|
||||
return fmt.Errorf("retirement did not preserve operation identity")
|
||||
}
|
||||
retired, err := client.RetireQueue(ctx, q.Queue.ID, runID+"-retire-queue", orbit.RetireResourceRequest{ExpectedRevision: q.Queue.Revision, Reason: "isolated lifecycle example completed"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if retired.Queue.RetiredAt == nil {
|
||||
return fmt.Errorf("queue retirement missing")
|
||||
}
|
||||
fmt.Println("retired=true history_preserved=true subscription_replay=true")
|
||||
return nil
|
||||
}
|
||||
32
sdk/go/retirement.go
Normal file
32
sdk/go/retirement.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// RetireResourceRequest 永久退役已结清的资源;保留原键/版本用于未知回执重报。
|
||||
type RetireResourceRequest struct {
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 当前配置版本。
|
||||
Reason string `json:"reason"` // Reason 管理原因,写入服务端审计。
|
||||
}
|
||||
|
||||
// RetireQueue 拒绝新工作并保留历史;未结清资源返回 RESOURCE_BUSY。
|
||||
func (c *Client) RetireQueue(ctx context.Context, id, key string, in RetireResourceRequest) (CreateQueueResult, error) {
|
||||
var out CreateQueueResult
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid queue id")
|
||||
}
|
||||
err := c.call(ctx, "POST", "/v1/queues/"+id+":retire", key, in, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// RetireSubscription 只在结果全部确认后退役,释放活动名额;不可重新启用。
|
||||
func (c *Client) RetireSubscription(ctx context.Context, id, key string, in RetireResourceRequest) (SubscriptionResult, error) {
|
||||
var out SubscriptionResult
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid subscription id")
|
||||
}
|
||||
err := c.call(ctx, "POST", "/v1/subscriptions/"+id+":retire", key, in, &out)
|
||||
return out, err
|
||||
}
|
||||
@@ -17,19 +17,23 @@ type CreateSubscriptionRequest struct {
|
||||
|
||||
// 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"`
|
||||
// RetiredAt 永久退役时间;退役后不可重新启用且不占活动订阅名额。
|
||||
RetiredAt *time.Time `json:"retired_at,omitempty"`
|
||||
// RetiredSequence 退役时的结果流截止水位,十进制字符串;之后的结果不再订阅。
|
||||
RetiredSequence *uint64 `json:"retired_sequence,string,omitempty"`
|
||||
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"`
|
||||
|
||||
@@ -177,5 +177,22 @@ func TestIntegrationSubscriptionManagementSDK(t *testing.T) {
|
||||
if err != nil || current.PendingCount != 0 || current.Revision != resumed.Subscription.Revision {
|
||||
t.Fatal("SDK pending count or config version", err)
|
||||
}
|
||||
retireKey := workerTestID(t)
|
||||
retireInput := RetireResourceRequest{ExpectedRevision: current.Revision, Reason: "completed SDK subscription regression"}
|
||||
retired, err := api.RetireSubscription(ctx, sub.ID, retireKey, retireInput)
|
||||
if err != nil || retired.Subscription.RetiredAt == nil {
|
||||
t.Fatal("SDK retire subscription", err)
|
||||
}
|
||||
repeated, err := api.RetireSubscription(ctx, sub.ID, retireKey, retireInput)
|
||||
if err != nil || !repeated.Deduplicated {
|
||||
t.Fatal("SDK retire replay", err)
|
||||
}
|
||||
if _, err = api.GetEventDelivery(ctx, sub.ID, expired.ID); err != nil {
|
||||
t.Fatal("retired delivery history", err)
|
||||
}
|
||||
queueRetired, err := api.RetireQueue(ctx, queue.Queue.ID, workerTestID(t), RetireResourceRequest{ExpectedRevision: queue.Queue.Revision, Reason: "completed SDK queue regression"})
|
||||
if err != nil || queueRetired.Queue.RetiredAt == nil {
|
||||
t.Fatal("SDK retire queue", err)
|
||||
}
|
||||
t.Log("management SDK exercised create/get/list/control/detail/expired retry with a real 30-second delivery lease")
|
||||
}
|
||||
|
||||
@@ -176,6 +176,8 @@ type CreateQueueRequest struct {
|
||||
RatePerSecond uint32 `json:"rate_per_second"`
|
||||
}
|
||||
type Queue struct {
|
||||
// RetiredAt 永久退役时间;为空表示仍可使用,历史查询保持可用。
|
||||
RetiredAt *time.Time `json:"retired_at,omitempty"`
|
||||
// MaxUnfinishedTasks 未终结任务上限,只限制新的受理。
|
||||
MaxUnfinishedTasks uint32 `json:"max_unfinished_tasks"`
|
||||
// UnfinishedTasks 未终结任务数,线上为十进制字符串;变化不增加配置版本。
|
||||
|
||||
Reference in New Issue
Block a user