diff --git a/AGENTS.md b/AGENTS.md index fe49929..277c48d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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,旧历史与原幂等回执仍可读取;旧写节点停止后迁移,禁止混跑忽略退役的服务端。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 077f9db..2502afd 100644 --- a/CHANGELOG.md +++ b/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 之后已完成的控制台、运维和业务接入能力,并统一示例和接入文档。 diff --git a/README.md b/README.md index 8c82cae..d7fea94 100644 --- a/README.md +++ b/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)。 diff --git a/deploy/local/README.md b/deploy/local/README.md index 3c5269f..39fb877 100644 --- a/deploy/local/README.md +++ b/deploy/local/README.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)。多队列时先明确选择目标,浏览器按命名空间记忆选择;退役队列不再可导出为新接入配置,历史查询仍开放。 diff --git a/docs/07-data-model.md b/docs/07-data-model.md index cdc0cf4..de65d0a 100644 --- a/docs/07-data-model.md +++ b/docs/07-data-model.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)` 用于活动名额统计。无外键、不删除历史、不回收名称;审计与幂等回执和退役状态同事务。 diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index 6e1cdaa..737402d 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -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、控制台、历史升级与完整回归验收中;最终结果见后续报告。 diff --git a/docs/api/README.md b/docs/api/README.md index b70009c..350261d 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -69,3 +69,5 @@ P1 业务接口已遵守下述约定,后续接口继续沿用: - [周期归档与作业管理](archive-scheduler.md) - [控制台应用接入与 Token 管理](console-applications.md) + +- [通用资源退役](resource-retirement.md):订阅结清、活动名额释放、队列依赖保护、SDK 与控制台操作。 diff --git a/docs/api/queues.md b/docs/api/queues.md index 57d5ba7..1a52f3f 100644 --- a/docs/api/queues.md +++ b/docs/api/queues.md @@ -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`;暂停仍允许受理,退役禁止新工作。历史读取保持可用。 diff --git a/docs/api/resource-retirement.md b/docs/api/resource-retirement.md new file mode 100644 index 0000000..116d6ca --- /dev/null +++ b/docs/api/resource-retirement.md @@ -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,再启动新版本;不要混跑不认识退役状态的旧服务端。现有资源默认未退役,不自动处理任何应用或测试数据。新库基线仍为零外键。历史保留、归档导出继续按现有配置执行;本次退役不引入自动删除机制。 diff --git a/docs/api/subscriptions.md b/docs/api/subscriptions.md index 39cb96b..23b1b24 100644 --- a/docs/api/subscriptions.md +++ b/docs/api/subscriptions.md @@ -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` 区分暂停与退役。未确认、过期或尚未物化的匹配结果都会阻止退役。 diff --git a/docs/console.md b/docs/console.md index 6c29584..eeb67ba 100644 --- a/docs/console.md +++ b/docs/console.md @@ -89,3 +89,9 @@ export ORBIT_CONSOLE_QUEUE_ID='queue_xxx' 切换采用完整导航,隔离旧请求和详情;有未保存策略、未确认操作或打开的详情时需先处理完。URL 只保存非敏感 ID。无效范围显示错误,不降级读取默认应用。总览仍是最近查询窗口的统计,不是全库累计。 只有部署指定管理员可以切换应用;其他账号只展示自己的应用和授权队列。后台停用应用、撤销绑定凭据或退出账号后,原页面范围不能继续授予权限。[范围接口及授权规则](api/console-scope.md)。 + +## 资源退役与明确接入目标 + +应用接入页按命名空间记忆所选队列;多队列且没有记忆时要求明确选择,不根据名称猜测业务用途。下拉框显示暂停/退役状态,已退役队列不能导出成新接入配置;凭据无该队列权限时直接提示,不自动换目标。 + +结果订阅详情和应用接入页分别提供订阅、队列退役;每次提交需要原因和当前版本,网络不明确时保留原键重报。活动订阅名额包含暂停订阅,退役后释放,历史仍可查询;阻塞原因明确显示。未补存当前 Token 时,预览入口与过渡提示都要求先补存,避免误认为已有可复制的新配置。 diff --git a/docs/reports/resource-retirement.md b/docs/reports/resource-retirement.md new file mode 100644 index 0000000..993a80a --- /dev/null +++ b/docs/reports/resource-retirement.md @@ -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。隔离回归不代替目标生产环境的容量、跨故障域或真实业务验收。 diff --git a/docs/sdk/README.md b/docs/sdk/README.md index 4c7bd17..00c7695 100644 --- a/docs/sdk/README.md +++ b/docs/sdk/README.md @@ -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. 查询执行历史与证据 diff --git a/docs/sdk/release.md b/docs/sdk/release.md index 95841e9..d4293de 100644 --- a/docs/sdk/release.md +++ b/docs/sdk/release.md @@ -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 不需要同步升级。 diff --git a/examples/README.md b/examples/README.md index eaf1504..3e113e7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 源码直接运行;只创建和退役本次独占的空队列及订阅,验证幂等与历史保留。 diff --git a/internal/consoleauth/applications.go b/internal/consoleauth/applications.go index 2dd3e3c..4d6875a 100644 --- a/internal/consoleauth/applications.go +++ b/internal/consoleauth/applications.go @@ -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 描述创建应用或变更凭据的管理员意图。 diff --git a/internal/fault/fault.go b/internal/fault/fault.go index 8e04608..deb9db4 100644 --- a/internal/fault/fault.go +++ b/internal/fault/fault.go @@ -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") diff --git a/internal/storage/mysql/application_resources_upgrade_integration_test.go b/internal/storage/mysql/application_resources_upgrade_integration_test.go index e79e45f..3048306 100644 --- a/internal/storage/mysql/application_resources_upgrade_integration_test.go +++ b/internal/storage/mysql/application_resources_upgrade_integration_test.go @@ -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`) diff --git a/internal/storage/mysql/baseline.sql b/internal/storage/mysql/baseline.sql index 6fea4ed..edc4d9f 100644 --- a/internal/storage/mysql/baseline.sql +++ b/internal/storage/mysql/baseline.sql @@ -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)); diff --git a/internal/storage/mysql/console_applications.go b/internal/storage/mysql/console_applications.go index 130c7d0..b826bbe 100644 --- a/internal/storage/mysql/console_applications.go +++ b/internal/storage/mysql/console_applications.go @@ -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 { diff --git a/internal/storage/mysql/console_scope.go b/internal/storage/mysql/console_scope.go index 49b2062..7b55567 100644 --- a/internal/storage/mysql/console_scope.go +++ b/internal/storage/mysql/console_scope.go @@ -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) diff --git a/internal/storage/mysql/event_subscriptions.go b/internal/storage/mysql/event_subscriptions.go index 3721fbc..f13471f 100644 --- a/internal/storage/mysql/event_subscriptions.go +++ b/internal/storage/mysql/event_subscriptions.go @@ -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 } diff --git a/internal/storage/mysql/migrate.go b/internal/storage/mysql/migrate.go index bf89bb0..da6e908 100644 --- a/internal/storage/mysql/migrate.go +++ b/internal/storage/mysql/migrate.go @@ -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 diff --git a/internal/storage/mysql/migrations/000034_resource_retirement.up.sql b/internal/storage/mysql/migrations/000034_resource_retirement.up.sql new file mode 100644 index 0000000..940d8ca --- /dev/null +++ b/internal/storage/mysql/migrations/000034_resource_retirement.up.sql @@ -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)); diff --git a/internal/storage/mysql/no_foreign_keys_integration_test.go b/internal/storage/mysql/no_foreign_keys_integration_test.go index 1310073..ceb5b09 100644 --- a/internal/storage/mysql/no_foreign_keys_integration_test.go +++ b/internal/storage/mysql/no_foreign_keys_integration_test.go @@ -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) } diff --git a/internal/storage/mysql/resource_retirement.go b/internal/storage/mysql/resource_retirement.go new file mode 100644 index 0000000..e899e9d --- /dev/null +++ b/internal/storage/mysql/resource_retirement.go @@ -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 +} diff --git a/internal/storage/mysql/resource_retirement_integration_test.go b/internal/storage/mysql/resource_retirement_integration_test.go new file mode 100644 index 0000000..cc75e69 --- /dev/null +++ b/internal/storage/mysql/resource_retirement_integration_test.go @@ -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) + } +} diff --git a/internal/storage/mysql/result_subscription_upgrade_integration_test.go b/internal/storage/mysql/result_subscription_upgrade_integration_test.go index 26b16d8..db678be 100644 --- a/internal/storage/mysql/result_subscription_upgrade_integration_test.go +++ b/internal/storage/mysql/result_subscription_upgrade_integration_test.go @@ -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 diff --git a/internal/storage/mysql/schedule_store.go b/internal/storage/mysql/schedule_store.go index c700ec8..7d2df18 100644 --- a/internal/storage/mysql/schedule_store.go +++ b/internal/storage/mysql/schedule_store.go @@ -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 diff --git a/internal/storage/mysql/task_store.go b/internal/storage/mysql/task_store.go index 6d1fe8f..7db9dc0 100644 --- a/internal/storage/mysql/task_store.go +++ b/internal/storage/mysql/task_store.go @@ -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 } diff --git a/internal/storage/mysql/worker_execution.go b/internal/storage/mysql/worker_execution.go index 8afef23..76f12f0 100644 --- a/internal/storage/mysql/worker_execution.go +++ b/internal/storage/mysql/worker_execution.go @@ -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 } diff --git a/internal/storage/mysql/worker_sessions.go b/internal/storage/mysql/worker_sessions.go index fe83b3d..d3e1675 100644 --- a/internal/storage/mysql/worker_sessions.go +++ b/internal/storage/mysql/worker_sessions.go @@ -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 diff --git a/internal/storage/mysql/workflow_instances.go b/internal/storage/mysql/workflow_instances.go index 1ab912b..7667d0c 100644 --- a/internal/storage/mysql/workflow_instances.go +++ b/internal/storage/mysql/workflow_instances.go @@ -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") } diff --git a/internal/transport/httpserver/console/app.js b/internal/transport/httpserver/console/app.js index fb6e1d0..35778c0 100644 --- a/internal/transport/httpserver/console/app.js +++ b/internal/transport/httpserver/console/app.js @@ -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 = '
⌁
当前没有结果订阅当前应用与队列还没有创建订阅
'; return; } - el.innerHTML = items.map(item => `
⌁
${escapeHTML(item.name || shortID(item.id))}待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}
${scopeBadge()}
${item.enabled === false ? '已暂停' : '已启用'}
`).join(''); + el.innerHTML = items.map(item => `
⌁
${escapeHTML(item.name || shortID(item.id))}待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}
${scopeBadge()}
${item.retired_at ? '已退役' : item.enabled === false ? '已暂停' : '已启用'}
`).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 => `
${escapeHTML(item.state || 'UNKNOWN')} · 序号 ${escapeHTML(item.sequence || '—')}${escapeHTML(formatTime(item.created_at))} · 尝试 ${escapeHTML(item.attempts ?? 0)}/${escapeHTML(item.attempt_budget ?? '—')} · 可用 ${escapeHTML(formatTime(item.available_at))}
${String(item.state || '') === 'EXPIRED' ? `` : `${escapeHTML(shortID(item.event_id))}`}
`); $('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); })); diff --git a/internal/transport/httpserver/console/applications.js b/internal/transport/httpserver/console/applications.js index 14f83c9..121399a 100644 --- a/internal/transport/httpserver/console/applications.js +++ b/internal/transport/httpserver/console/applications.js @@ -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 => ``).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 = '' + detail.queues.map(q => ``).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 ? `` : c.revoked_at ? `

${escape(reason)}

` : c.previous_valid_until ? `

旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。

` : ''; + const rotationHelp = transitioning ? `` : c.revoked_at ? `

${escape(reason)}

` : c.previous_valid_until ? `

旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。

` : ''; 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 `
${escape(c.name)} · ${status}
@@ -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 }; })(); diff --git a/internal/transport/httpserver/console/index.html b/internal/transport/httpserver/console/index.html index 0c11a8a..b42c180 100644 --- a/internal/transport/httpserver/console/index.html +++ b/internal/transport/httpserver/console/index.html @@ -223,9 +223,11 @@

本机默认使用当前页面地址;容器内可填写 http://orbit-test:8080,部署后可换成服务域名。

+

+

接入配置预览

-

预览默认隐藏 Token。点击「查看完整接入配置」,即可复制包含当前 Token 的配置,或下载 orbit.env。

+

预览默认隐藏 Token。点击「查看完整接入配置」,即可复制包含当前 Token 的配置,或下载 orbit.env。


               
Token 确定应用和权限,队列决定任务去向。业务服务注册相同名称与版本的 handler 后,即可执行任务。
@@ -374,7 +376,7 @@
事件类型—
最近交付加载中
正在读取交付历史…
- +

交付详情不展示租约秘密;结果确认与人工重试必须通过受保护的消费或管理接口完成。