feat: enforce shared application resource quotas across namespaces

This commit is contained in:
2026-09-17 17:57:54 +08:00
parent 5c8825e4bc
commit 993786476e
43 changed files with 1606 additions and 49 deletions

View File

@@ -5,6 +5,7 @@
- 新增或修改数据模型必须提供中文注释,覆盖模型职责及每个持久化字段的含义。明确单位、可空语义、版本/幂等/租约/预算等约束,避免仅重复字段名。
- SQL 数据表和字段同步维护中文 COMMENT;已应用的迁移不得回改,后续演进新增前向迁移。
- 数据库禁止外键及级联删除。关联关系由服务端在同一事务中按命名空间校验;保留查询索引、唯一约束和 CHECK。历史外键由 000008 前向移除,后续迁移不得重新引入,真实 MySQL 回归必须断言外键为 0。
- Pulse 涉及用量的事务先锁应用额度再锁队列,恢复和调度遵守同一顺序;不得在已锁队列后新增应用额度锁。应用额度与身份行分离,不能升级认证共享锁。
- 状态变更、租约、门控、许可、事件及审计必须按设计的事务边界实现,不用内存状态代替持久化确认。
- 研发任务持续更新 docs/08-development-plan.md。接口、SDK、可运行示例和对应回归随功能同步交付。
- 模型/迁移变更运行真实 MySQL 集成测试;执行和恢复变更覆盖竞争、重复、迟到及故障场景。以实际测试报告判断完成,不把设计文档当作已实现能力。

View File

@@ -8,7 +8,7 @@ Orbit 是面向多个业务项目的通用任务调度与流程编排平台,
## 当前进度
已完成 P0–P2 阶段验收:32 个数据模型、版本化迁移、凭据及范围校验、队列与任务 API、WSS 执行、独立 SDK、自动 Worker 和 [执行尝试/证据查询](docs/api/execution-history.md)。[Worker Schema](docs/api/worker-schema.md) 与真实进程强杀恢复回归已通过;[安全重试](docs/api/retries.md)、[定时计划](docs/api/schedules.md)、[运行中取消](docs/api/cancellation.md) 和 [队列资源控制](docs/api/queues.md) 已接入服务端和 SDK,剩余 Pulse 能力与流程引擎按 [持续开发计划](docs/08-development-plan.md) 继续实现。
已完成 P0–P2 阶段验收:33 个数据模型、版本化迁移、凭据及范围校验、队列与任务 API、WSS 执行、独立 SDK、自动 Worker 和 [执行尝试/证据查询](docs/api/execution-history.md)。[Worker Schema](docs/api/worker-schema.md) 与真实进程强杀恢复回归已通过;[安全重试](docs/api/retries.md)、[定时计划](docs/api/schedules.md)、[运行中取消](docs/api/cancellation.md) 、[队列资源控制](docs/api/queues.md) 和 [应用共享配额](docs/api/application-resources.md) 已接入服务端和 SDK,剩余 Pulse 能力与流程引擎按 [持续开发计划](docs/08-development-plan.md) 继续实现。
数据库禁止外键,关联一致性通过服务端事务校验,索引、唯一约束和 CHECK 保留;readiness 同时检查迁移及零外键要求。这不表示完整平台已具备生产服务能力。验证证据见 [P0](docs/reports/phase-0.md)、[P1](docs/reports/phase-1.md)、[P2 阶段验收](docs/reports/phase-2.md)、[安全重试](docs/reports/phase-3-safe-retry.md)、[计划调度](docs/reports/phase-3-schedules.md)、[取消与无外键回归](docs/reports/phase-3-cancellation.md) 及 [队列资源回归](docs/reports/phase-3-queue-resources.md)。

View File

@@ -12,6 +12,7 @@ import (
"git.yuntuo.hk/k2safe/orbit/internal/config"
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
core "git.yuntuo.hk/k2safe/orbit/model"
)
func main() {
@@ -22,12 +23,17 @@ func main() {
}
func run() error {
if len(os.Args) < 2 || os.Args[1] != "bootstrap" {
return errors.New("usage: orbit-admin bootstrap --application NAME --namespace NAME --environment NAME --queue NAME --output NEW_FILE")
return errors.New("usage: orbit-admin bootstrap (--application NAME | --application-id ID) --namespace NAME --environment NAME --queue NAME --output NEW_FILE")
}
flags := flag.NewFlagSet("bootstrap", flag.ContinueOnError)
var in mysql.BootstrapInput
var output string
flags.StringVar(&in.Application, "application", "", "new application name")
flags.Func("application-id", "existing application id; mutually exclusive with --application", func(v string) error {
in.ExistingApplicationID = core.ID(v)
return in.ExistingApplicationID.Validate()
})
flags.BoolVar(&in.ResourceAccess, "resource-access", false, "grant application-wide resource read and manage actions")
flags.StringVar(&in.Namespace, "namespace", "", "new namespace name")
flags.StringVar(&in.Environment, "environment", "", "environment name")
flags.StringVar(&in.Queue, "queue", "", "initial queue name")

View File

@@ -15,6 +15,7 @@ import (
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/resources"
"git.yuntuo.hk/k2safe/orbit/pulse/schedule"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
@@ -52,6 +53,7 @@ func run() error {
}
return mysql.CheckSchema(ctx, db)
}, service.New(mysql.NewTaskStore(db)))
httpserver.RegisterApplicationResources(server, resources.New(mysql.NewTaskStore(db)))
node, err := core.NewID()
if err != nil {
return err

View File

@@ -1,6 +1,6 @@
# 数据模型与实现基线
更新:2026-09-17。数据库统一禁止外键。当前已实现任务执行、安全重试、运行中取消、计划调度和队列限速/背压;应用配额、公平性、订阅及流程编排继续开发。
更新:2026-09-17。数据库统一禁止外键。当前已实现任务执行、安全重试、运行中取消、计划调度、队列限速/背压和应用配额;公平性、订阅及流程编排继续开发。
## 1. 模块与依赖
@@ -35,11 +35,12 @@
## 3. 实体清单
当前共 32 个实体:初始迁移包含 30 个,000003 增加进程会话头和迟到证据。迁移自身另有 `orbit_schema_migrations` 记录。
当前共 33 个实体:初始迁移包含 30 个,000003 增加进程会话头和迟到证据,000010 增加应用共享额度。迁移自身另有 `orbit_schema_migrations` 记录。
| 模块 | Go 模型 | 数据表 |
| --- | --- | --- |
| platform | `Application` | `orbit_applications` |
| platform | `ApplicationResources` | `orbit_application_resources` |
| platform | `Namespace` | `orbit_namespaces` |
| platform | `Credential` | `orbit_credentials` |
| platform | `IdempotencyKey` | `orbit_idempotency_keys` |
@@ -127,3 +128,7 @@ P2 服务端已新增会话隔离、两步派发、租约、并发许可、结
000009_queue_resources 增加 Queue.MaxUnfinishedTasks、UnfinishedTasks、StartWindowUntil、StartsInWindow,Go 字段与 SQL COMMENT 均有中文语义,实体仍为 32。未终结任务按 WAITING/LEASED/RUNNING/NEEDS_REVIEW 回填;窗口初始为空、计数为零。运行计数与任务/开始授权同事务维护,不改变队列配置 revision,配置变更不能重置它们。降额允许已有使用量暂时超过新上限,不删除任务或撤销已有许可;详见 [队列契约](api/queues.md) 和 [资源回归](reports/phase-3-queue-resources.md)。
命令与验证记录见 [开发计划](08-development-plan.md)、[基础阶段验证报告](reports/phase-0.md) 与 [取消及无外键回归](reports/phase-3-cancellation.md)。历史阶段报告描述当时结构,当前约束以本页为准。
## 12. 应用共享额度
000010_application_resources 新增 ApplicationResources,使用 BINARY(16) 应用主键,12 个持久化字段均有中文注释和 SQL COMMENT,无外键。共享未终结任务数、活跃尝试数、未开始预占数与一秒开始窗口;配置 revision 不随用量变化。停止旧写入节点后回填所有应用,包含跨命名空间的既有在途状态。关联身份行与额度行分开,认证后按应用额度 → 队列 → 任务/尝试 → 许可顺序持锁。后台恢复不因应用停用而停止释放到期尝试。详细字段、权限、SDK 场景及并发边界见 [应用配额接口](api/application-resources.md)。

View File

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

View File

@@ -2,7 +2,7 @@
- 文档版本:0.2
- 整理日期:2026-09-17
- 阶段:P0–P2 已通过阶段验收;P3 安全重试、计划调度、运行中取消和队列资源控制已通过回归,数据库已改为无外键,P3–P7 继续推进,尚未完成全平台生产验收
- 阶段:P0–P2 已通过阶段验收;P3 安全重试、计划调度、运行中取消、队列资源控制和应用共享配额已通过回归,数据库已改为无外键,P3–P7 继续推进,尚未完成全平台生产验收
- 适用范围:Orbit 统一平台,包含 Pulse 任务调度模块、Weave 流程编排模块与公共能力。平台面向多个业务系统复用。
## 阅读顺序

View File

@@ -12,6 +12,7 @@
| `/v1/tasks/{task_id}/attempts` 与尝试详情 | P2 已实现 | [执行历史](execution-history.md) |
| `/v1/tasks/{task_id}/attempts/{attempt_id}/evidence` 与证据详情 | P2 已实现 | [执行历史](execution-history.md) |
| `/v1/queues` 创建、查询及 `:configure` 控制 | 已实现队列并发、开始速率、积压背压与暂停/恢复 | [队列接口与 SDK 场景](queues.md) |
| `/v1/application/resources` 查询及 `:configure` 管理 | P3 已实现跨命名空间应用配额 | [应用配额与 SDK 场景](application-resources.md) |
| 凭据校验与初始化 CLI | P1 已实现 | [鉴权](authentication.md) |
| `/v1/worker/connect` | P2 已实现并通过阶段验收 | [Worker 契约](worker.md)、[Schema](worker-schema.md) |
| 任务冻结策略与 Worker `retryable` | P3 安全重试已实现 | [安全重试](retries.md) |

View File

@@ -0,0 +1,161 @@
# 应用级共享配额
状态:P3 已实现。应用下所有命名空间和队列共享未终结任务额度、执行并发和开始速率;与 [队列控制](queues.md) 同时生效。研发接入仍使用独立 Go SDK,额度不依赖内存、外部队列或缓存。
## 1. 作用域与权限
| 操作 | HTTP | 权限 |
| --- | --- | --- |
| 当前应用配置及用量 | `GET /v1/application/resources` | `application:resources:read` |
| 完整替换三个上限 | `POST /v1/application/resources:configure` | `application:resources:manage` |
应用由 Bearer 凭据所属命名空间推导,不能通过 URL 或请求体指定其他应用。应用级动作独立授权,允许查看或管理该应用跨命名空间的聚合额度,不受 `queues` 列表缩小范围;普通 `queues:manage`、`workers:consume` 或 `queues:["*"]` 都不会自动获得应用级权限。通常只给运维管理凭据授予应用动作,业务 Worker 不需要这些权限。
初始化 CLI 显式传 `--resource-access` 才授予这两项权限。已有凭据不会自动扩权。`--application-id` 可在指定现有且启用的应用中创建新命名空间,与 `--application` 二选一;它仍要求数据库管理权限,不是公共 HTTP 开放的跨命名空间创建入口。
## 2. 查询配置及用量
```sh
curl "$ORBIT_URL/v1/application/resources" \
-H "Authorization: Bearer $ORBIT_TOKEN"
```
不接受查询参数。成功 HTTP 200:
```json
{
"request_id": "11111111111111111111111111111111",
"data": {
"application_id": "22222222222222222222222222222222",
"revision": "1",
"max_unfinished_tasks": 1000000,
"unfinished_tasks": "5",
"max_concurrency": 10000,
"active_attempts": "2",
"pending_starts": "1",
"rate_per_second": 100000
}
}
```
- `unfinished_tasks`:WAITING、LEASED、RUNNING、NEEDS_REVIEW 的任务总数。待核查仍占用额度;自动重试不重复占用。
- `active_attempts`:已预占或运行的尝试总数;未 Start 的尝试也占并发。每次权威结果或到期恢复只释放一次。
- `pending_starts`:已预占但未开始的尝试,是活跃尝试的子集。跨窗口仍保留预占,到期恢复或取消才释放未使用预占。
- `revision`:仅配置变更增加。用量变化不使配置版本失效;查询返回单次数据库读取快照,之后可能继续变化。
- 版本和累计用量用十进制字符串,SDK 解为 `uint64`;三个配置上限为 JSON 整数。
默认值是开发基线,不代表实测容量。
## 3. 配置及幂等
请求头必须有 `Authorization`、`Content-Type: application/json` 和 `Idempotency-Key`。键为 1–128 个非空白可打印 ASCII 字符;每次不同管理操作使用不同键,未确认结果重报保留原键和原内容。
| 字段 | 类型 | 要求 |
| --- | --- | --- |
| expected_revision | 十进制字符串 | 必填,非零,匹配当前配置版本 |
| reason | 字符串 | 必填,非空白,最多 1024 个 Unicode 字符,写入审计 |
| max_unfinished_tasks | uint32 | 必填,1–100000000;默认 1000000 |
| max_concurrency | uint32 | 必填,1–1000000;默认 10000 |
| rate_per_second | uint32 | 必填,1–10000000;默认 100000 |
这是完整替换,缺项或零值不能解释为沿用旧值。未知字段、重复 JSON 键、错误大小写及 null 被拒绝。
```sh
curl -X POST "$ORBIT_URL/v1/application/resources:configure" \
-H "Authorization: Bearer $ORBIT_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: application-budget-change-001' \
--data '{"expected_revision":"1","reason":"调整应用资源预算","max_unfinished_tasks":2000000,"max_concurrency":2000,"rate_per_second":5000}'
```
成功 HTTP 200:
```json
{
"request_id": "33333333333333333333333333333333",
"data": {
"resources": {
"application_id": "22222222222222222222222222222222",
"revision": "2",
"max_unfinished_tasks": 2000000,
"unfinished_tasks": "5",
"max_concurrency": 2000,
"active_attempts": "2",
"pending_starts": "1",
"rate_per_second": 5000
},
"deduplicated": false
}
}
```
配置、版本、管理审计、事件和幂等回执在一个事务中提交。相同键与内容返回原成功快照,`deduplicated=true`,其中用量也保留原值;实时用量应重新 GET。审计/事件失败则整体回滚。同一应用下两个命名空间的幂等键空间相互独立,共享配置版本;并发修改以 CAS 判定,不能借另一个命名空间重放管理身份。
降额不删除任务、不撤销已取得的并发、不重置速率窗口,也不延长租约。用量可暂时高于新上限。新受理和新领取会停止,等待已有任务释放;已取得执行并发的尝试仍可在有效租约内开始,但 Start 必须满足当前开始速率。
## 4. 执行与背压
- 直接提交和定时计划生成任务同时检查应用与队列积压上限。两者都允许才占用空间;任一失败不能留下另一侧的计数、任务或幂等记录。
- 原受理回执重报先核对身份,满额也能返回原回执。满额失败未受理,调用方退避后可用原身份再次提交;SDK 不自动重试写请求。
- 每次 Claim 同时受应用并发、队列并发、Worker 容量和两级开始窗口余量约束。没有额度时不派发、不增加尝试次数。
- Start 使用数据库时间的一秒固定窗口:第一次成功授权建立窗口;之后有效开始计数增加,重复回执和续租不计数。应用与队列窗口分别维护,边界可能出现短时突发,不承诺任意滑动一秒内都不超限,也不控制处理器内部的业务请求速率。
- 尚未 Start 的预占也计入窗口可用空间,避免其他队列把额度再次领走。配置降速后,既有预占可能超过新速率;Start 返回 `RATE_LIMITED`,自动 Worker SDK 仅在有效租约内使用原身份退避核对,过期按正常恢复处理。
- 运行中取消只保存意图,收到权威结果或到期恢复才释放并发;效果未知进入 NEEDS_REVIEW,继续占用未终结任务名额。应用停用后拒绝新用户操作,后台恢复仍可回收已到期尝试。
- 计划生成时满额,已成功处理的批次部分提交,未受理的原触发点保留,下一轮按冻结的计划策略处理,不把满额伪造为漏跑跳过。窗口超过策略期限后仍可能按原漏跑策略跳过。
## 5. 错误与重报
| 错误 | HTTP / WS | 处理 |
| --- | --- | --- |
| INVALID_ARGUMENT | HTTP 400 | 校正字段,不盲目重报 |
| UNAUTHENTICATED | HTTP 401 | 核对秘密、过期/撤销、应用及命名空间启停 |
| PERMISSION_DENIED | HTTP 403 | 申请明确应用动作;队列管理不能代替 |
| STATE_CONFLICT | HTTP 409 | 查询新配置,确认意图后以新版本及新键发起操作 |
| IDEMPOTENCY_CONFLICT | HTTP 409 | 保留原操作身份,查明请求内容为何改变 |
| APPLICATION_FULL | HTTP 429,`Retry-After: 1` | 当前应用满额,退避后保留原受理身份核对 |
| QUEUE_FULL | HTTP 429,`Retry-After: 1` | 当前队列满额,处理同上 |
| RATE_LIMITED | Worker 错误帧 | 应用或队列 Start 限流,保留消息身份及租约边界 |
| UNAVAILABLE / 网络结果未知 | HTTP 503 / 连接错误 | 保留原键、版本和内容核对,不能假定写入失败 |
`APPLICATION_FULL` 来自任务受理接口,额度管理本身不会因满额而被禁止。两级积压同时满时优先报告应用满额。`Retry-After` 不是空间保证。
## 6. SDK 与可运行场景
SDK 方法:`GetApplicationResources(ctx)` 和 `ConfigureApplicationResources(ctx, key, request)`。完整双命名空间场景源码:[application-resources](../../sdk/go/examples/application-resources/main.go)。
在仓库根目录,先用已迁移数据库启动服务,并初始化一个独立、空闲的示例应用:
```sh
mkdir -p .local
chmod 700 .local
go run ./cmd/orbit-admin bootstrap \
--application application-resources-demo --namespace first --environment development \
--queue first --resource-access --output .local/app-first.credentials.json
ORBIT_EXAMPLE_APP_ID=$(sed -n 's/.*"application_id":"\([^"]*\)".*/\1/p' .local/app-first.credentials.json)
go run ./cmd/orbit-admin bootstrap \
--application-id "$ORBIT_EXAMPLE_APP_ID" --namespace second --environment development \
--queue second --resource-access --output .local/app-second.credentials.json
cd sdk/go
ORBIT_URL=http://127.0.0.1:8080 \
ORBIT_CREDENTIALS_FILE=../../.local/app-first.credentials.json \
ORBIT_SECOND_CREDENTIALS_FILE=../../.local/app-second.credentials.json \
go run ./examples/application-resources
```
预期:
```text
cross_namespace_backpressure=APPLICATION_FULL http=429
same_identity_retry_accepted=true
unfinished=0 active=0 pending_starts=0
```
示例将应用上限设为 1:命名空间 first 受理任务 → second 被全局满额拒绝 → first 取消释放 → second 以原键成功受理 → 取消收尾。正常或报错退出都会尝试按原配置版本恢复上限,遇到其他管理者修改则报冲突,不覆盖新配置。进程被强杀不能保证运行清理;未收尾任务会按总截止时间恢复,结果未知时仍应保留原身份查询。无业务 Worker 和外部副作用,资源及历史保留,不提供硬删除。
## 7. 存储、升级与边界
000010 新增 `orbit_application_resources`,应用标识使用既有 `BINARY(16)`,全部模型及字段提供中文注释,无外键。回填所有应用的未终结、活跃及预占数,空应用为零;历史任务、尝试、队列配置不改写。停止全部旧写入节点 → 执行迁移 → 启动新版;旧版不维护应用计数,不能混跑。大表回填时间和在线迁移仍需部署环境验证。
统一顺序为认证 → Worker 会话(需要时)→ 应用额度 → 队列 → 任务/尝试 → 许可。额度与身份分表,避免把认证共享锁升级为排他锁。恢复和调度对忙碌额度行使用 SKIP LOCKED,保留后续扫描;不采用全表计数参与每次领取。额度配置审计记录在发起凭据的命名空间,应用标识明确保存。
同一应用共享一条额度行,严格额度会使该应用的相关短事务串行化。当前范围是同一 MySQL 主库;未宣称跨库原子配额或百万租户性能,后续容量验证需测单个高流量应用的锁等待、租约续期和恢复延迟。业务键许可、公平派发、可靠订阅与完整生产容量/故障验收继续按开发计划推进。真实验证记录见 [应用配额回归](../reports/phase-3-application-resources.md)。

View File

@@ -55,3 +55,5 @@ SDK 使用 `orbit.Options{Token: token}`。健康查询不发送凭据。当前
P3 增加 schedules:read 和 schedules:manage。bootstrap 显式传 --schedule-access 才授予;创建/发布计划还需要 tasks:submit 与队列范围。已有计划属于命名空间,创建凭据到期或撤销不自动暂停计划;停止新触发应暂停计划,或禁用应用/命名空间。详见 [计划接口](schedules.md)。
P2 增加 workers:connect 和 workers:consume。bootstrap 只有显式提供 --worker-access 时才同时授予这两项开发权限;现有凭据不自动变化。Worker 还需独立的进程恢复秘密和尝试秘密,见 [Worker 契约](worker.md)。
P3 应用额度管理新增 `application:resources:read` 和 `application:resources:manage`。只有显式 `--resource-access` 才授予,作用域是所属应用的全部命名空间,普通队列管理不能替代。查询返回聚合计数,不开放其他命名空间的任务数据。`bootstrap --application-id <现有应用ID>` 可由数据库管理员增加命名空间,与 `--application` 二选一;验证应用启用及额度记录存在后,同事务创建新资源,所有秘密交付约定保持不变。应用默认未终结上限 1000000、并发 10000、开始速率 100000/秒;详见 [应用共享配额](application-resources.md)。

View File

@@ -144,4 +144,4 @@ unfinished=0 rate_per_second=1 worker_drained=true
创建命令省略新字段时,幂等摘要保留旧字段集合。旧创建回执不含新字段,SDK 解码为零,只表示历史快照缺少信息,实时配置另行 GetQueue。配置接口是新能力;自动 SDK 已能处理 RATE_LIMITED,进行降额管理前应先升级 Worker。
本阶段验证见 [资源控制报告](../reports/phase-3-queue-resources.md)。仍需应用级跨命名空间配额、同业务键许可、可配置优先级及应用间公平性、可靠事件积压限制和容量验收;不能用单队列控制替代这些要求。
本阶段验证见 [资源控制报告](../reports/phase-3-queue-resources.md)。[应用级跨命名空间配额](application-resources.md) 已实现并同时生效。仍需同业务键许可、可配置优先级及应用间公平性、可靠事件积压限制和容量验收;不能用单队列控制替代这些要求。

View File

@@ -180,6 +180,7 @@ SDK:`Client.CancelTask(ctx, taskID, key, request)`。此操作不承诺回滚
| 409 STATE_CONFLICT | 当前状态或版本不允许操作,先查询 |
| 413 BODY_TOO_LARGE | 减小请求或改为传递业务对象引用 |
| 415 UNSUPPORTED_MEDIA_TYPE | 使用 application/json |
| 429 APPLICATION_FULL | 应用下所有命名空间共享额度已满;按 Retry-After 退避并保留原身份,详见 [应用配额](application-resources.md) |
| 429 QUEUE_FULL | 本次未受理,按 Retry-After 有限退避并保留原身份;已受理的原回执不受当前满额限制 |
| 503 UNAVAILABLE | 结果可能未知;保留原幂等身份,有限退避后核对 |

View File

@@ -127,11 +127,11 @@ receipt 是原操作的不可变确认;lease 是本次重新核验的执行权
## 5. 领取、并发与回收
平台在同一事务中分配队列并发许可、创建尝试、递增任务执行代次和次数、保存 LEASED 与派发事件。领取本身消耗一次 max_attempts 预算,包括最终未获开始授权的尝试,预算不会因重连或过期恢复重置。
平台在同一事务中预占应用并发与开始额度、分配队列并发许可、创建尝试、递增任务执行代次和次数、保存 LEASED 与派发事件。领取本身消耗一次 max_attempts 预算,包括最终未获开始授权的尝试,预算不会因重连或过期恢复重置。
运行中的进程总容量跨其所有旧/新会话统计。声明容量减少、断开连接或重复结果,都不能重复释放额度。已实施队列 max_concurrency、rate_per_second 及未终结任务上限;参数含义、窗口突发、暂停及降额语义见 [队列资源控制](queues.md)。应用级配额、业务键许可与公平性仍在 P3 推进。
运行中的进程总容量跨其所有旧/新会话统计。声明容量减少、断开连接或重复结果,都不能重复释放额度。已实施队列 max_concurrency、rate_per_second 及未终结任务上限;参数含义、窗口突发、暂停及降额语义见 [队列资源控制](queues.md)。[应用级配额](application-resources.md) 已同时生效;业务键许可与公平性继续推进。
领取前核对速率窗口及已有 LEASED 预留,满额时不新建尝试。Start 在同一队列事务内消费开始预算,原 ACK 重报不重复计数。领取后若配置降额,Start 可能返回 RATE_LIMITED;保持原消息身份在剩余租约内退避核对。续租、结果确认、取消及恢复不消费新开始额度。
领取前核对速率窗口及已有 LEASED 预留,满额时不新建尝试。Start 在同一事务内消费应用与队列的开始预算,原 ACK 重报不重复计数。领取后若配置降额,Start 可能返回 RATE_LIMITED;保持原消息身份在剩余租约内退避核对。续租、结果确认、取消及恢复不消费新开始额度。
每个节点每 2 秒发起一批恢复扫描,每批最多 100 项:

View File

@@ -81,6 +81,15 @@
- 异常:网络结果未知保留原身份,降额不清零用量,待核查任务仍占名额;跨节点及重启证据见 [资源回归](../reports/phase-3-queue-resources.md)。
- 清理:每次新建独立队列,任务 20 秒总截止;无外部副作用,历史保留,失败非零退出并有界收尾。
## 已可运行:跨命名空间共享配额
- 源码:[应用资源示例](../../sdk/go/examples/application-resources/main.go),[双命名空间初始化、字段和输出](../api/application-resources.md)。
- 前置:同一独立、空闲示例应用下的两个命名空间,凭据显式具备应用资源管理权限;不需要 Worker。
- 步骤:应用上限设为 1 → first 受理 → second 全局满额 → 取消 first → second 以原受理键成功 → 取消收尾并按 CAS 恢复配置。
- 预期:APPLICATION_FULL/429、same_identity_retry_accepted=true、三个用量归零。
- 异常:普通队列管理员不能改应用额度;恢复配置遇到新版本冲突时不覆盖。进程强杀后不能保证清理,遗留任务受总时限约束,须按原身份核对。
- 清理:正常完成取消示例任务并恢复配置;历史保留,无业务副作用、无硬删除。
## 计划场景与验收标准
| 场景 | 输入与步骤 | 预期结果 | 必测异常 | 状态 |

View File

@@ -0,0 +1,94 @@
# P3 应用级配额与跨命名空间回归
验证日期:2026-09-17。本阶段补齐应用共享的未终结任务上限、执行并发及开始速率,同步 HTTP、独立 Go SDK、CLI 和双命名空间示例。P3 业务键许可、公平性及可靠订阅,以及 P4–P7 仍未完成,持续目标保持进行中。
## 1. 交付范围
- [应用配额接口](../api/application-resources.md):读取当前应用额度及用量、显式应用管理权限、CAS/幂等管理、同事务审计和事件、降额保留在途任务。
- [ApplicationResources 模型](../../platform/model/application_resources.go) 与 000010 前向迁移:12 个持久化字段提供中文注释,SQL 同步 COMMENT,应用主键 BINARY(16),数据库外键仍为零,当前模型共 33 个。
- 受理、计划生成、领取、Start、取消、Finish、安全重试及到期恢复共同维护应用计数,跨命名空间与队列共享。空轮询与失败受理不消耗计数,重复回执不重复计费。
- 额度行与认证身份分离,统一按应用额度 → 队列 → 任务/尝试 → 许可顺序取锁;后台扫描跳过忙碌额度,应用停用后仍可释放到期尝试。
- 管理 CLI 支持 `--application-id` 在已有启用应用下增加命名空间;应用管理权限必须显式 `--resource-access`,普通队列及 Worker 凭据不扩权。
- [双命名空间 SDK 示例](../../sdk/go/examples/application-resources/main.go):第一侧受理,第二侧 APPLICATION_FULL,第一侧取消释放,第二侧原身份重报成功,取消收尾并按 CAS 恢复配置。
## 2. 环境和实际回归
macOS 本机,Go 1.27.1,Docker MySQL 8.4.10;服务进程与数据库仅监听回环地址。使用随机凭据及一次性数据库,测试结束删除测试容器,没有修改已有业务数据库。
执行入口:
```sh
make fmt test vet build
make integration
```
完整集成通过。测试分组的本次结果:
| 分组 | 结果 |
| --- | --- |
| 全部数据库集成(含模型、无外键、配额、计划、执行及竞争) | PASS,68.475 秒 |
| v9 应用计数回填与在途释放 | PASS,3.94 秒 |
| v7 无外键升级、索引及数据保留 | PASS,3.85 秒 |
| SDK Worker 连接、运行器、重试、取消和断线处理 | PASS,78.509 秒 |
| SDK 计划生命周期 | PASS,含包开销 4.733 秒 |
| SDK 队列资源控制 | PASS,含包开销 4.456 秒 |
| SDK 双命名空间应用配额 | PASS,含包开销 1.401 秒 |
| 原有真实进程故障组 | PASS,23.638 秒 |
| 十个可运行示例 | 全部通过 |
十个示例为 health、tasks、worker-protocol、worker、history、retry、schedules、cancellation、resources、application-resources。上述用时仅为回归耗时,不是吞吐或容量结论。未配置集成环境的普通 `go test` 会跳过外部数据库及进程用例,实际证据来自已执行的集成入口。
本轮发现原计划版本边界用例使用宿主机时钟等待,而服务按数据库时间判定。用例改为从 MySQL 时间建立边界并等待真实数据库时刻,保留原版本、触发身份和结束状态断言;完整数据库回归随后通过。
## 3. 配额与事务证据
[数据库用例](../../internal/storage/mysql/application_resources_integration_test.go) 覆盖:
1. 普通队列凭据读取及管理应用额度被拒绝;显式授权后同应用另一个命名空间可读聚合额度,其他应用保持独立。
2. 配置 CAS、同身份原快照重报、内容冲突;审计故障注入使配置、事件及幂等记录一起回滚,恢复后原身份可成功提交。
3. 两个命名空间各自队列同时发起 16 次受理,上限为 3 时仅 3 次成功;满额原成功回执仍可核对。降额至 1 不删已有任务,逐项取消后重新受理。
4. 队列已满和事件写入失败均不能泄漏应用额度;应用与队列计数同时回滚,原请求可以重报。
5. 两个节点/命名空间争用应用并发 1,仅一个取得尝试;预占取消释放未开始额度,成功结果及重复 Finish 不重复释放。
6. 预占后降低并发及开始速率,已有尝试保留;Start 事件故障回滚用量;原 Start ACK 重报不收费;未开始的预占跨窗口保留,不能被其他任务抢占。
7. 明确失败重试不重复占用积压,已开始失联保留未知效果及未完成额度,未开始到期和待执行截止释放空间。应用停用不阻止后台回收执行额度。
8. 定时计划遇到应用满额时保留未受理触发点,已生成的批次部分提交;释放空间后恢复同一原触发点,两级计数一致。
9. 一个应用额度行被显式持锁时,另一应用的恢复仍能推进;缺失、停用或参数歧义的应用不能由初始化 CLI 创建新命名空间。
所有计数断言同时查询真实任务状态进行核对,不只断言接口里的同一计数字段。
## 4. 迁移和无外键
[应用额度升级测试](../../internal/storage/mysql/application_resources_upgrade_integration_test.go) 在隔离库建立有效任务、尝试、Worker 会话和许可,然后还原为 v9 结构再运行正式迁移:
- 同一应用两个命名空间包含 WAITING、LEASED、RUNNING、NEEDS_REVIEW 和已成功任务,回填为 unfinished=4、active=2、pending=1;成功任务排除在外。
- 空应用三个用量均为零,配置默认值、版本与空窗口正确。
- 历史任务状态及版本不变;重复执行迁移不重算或覆盖当前计数。
- 迁移前已存在的预占取消和运行尝试完成后,共享用量正确变为 unfinished=2、active=0、pending=0。
v4 升级继续核对冻结策略、未知标记及应用未完成数。v7 无外键升级仍移除全部 70 个历史外键,已有 325 条索引列定义保留;新应用额度表自身主键另计。错误队列归属、错误会话关系及手工外键漂移继续被拒绝。
## 5. SDK 与进程验证
[SDK 集成用例](../../sdk/go/application_resources_integration_test.go) 通过实际 HTTP 验证两个命名空间共享配置、普通队列管理员无应用权限、跨命名空间幂等键空间独立、错误字段及应用归属覆盖被拒绝。第二侧收到 HTTP 429 / APPLICATION_FULL / Retry-After: 1;第一侧释放后,原键提交成功,最终三个用量为零。
示例实际输出:
```text
cross_namespace_backpressure=APPLICATION_FULL http=429
same_identity_retry_accepted=true
unfinished=0 active=0 pending_starts=0
```
原有服务 ACK 丢失接管、Worker 强杀后未知核查、计划强杀补跑和队列背压进程用例同步通过。
最后定向回归新增的额度泄漏断言与应用强杀场景也通过:数据库应用配额九组场景 10.24 秒(含包开销 11.572 秒),SDK 应用配额 0.15 秒,完整示例再次通过;`TestIntegrationWorkerProcessApplicationBackpressure` 0.14 秒(含包开销 1.818 秒)。该场景强杀并 Wait 确认旧服务退出,再启动不同 node_id:共享上限和未完成计数保留,原受理回执可重放,另一个命名空间继续被全局满额拒绝,原任务取消后才能用被拒原键受理。这项证明应用受理额度跨进程持久化,不将它扩大为全部数据库故障模型或开始速率窗口的进程验收。
文档校验通过 320 个本地链接及 26 个 JSON 示例;269 个直接声明的持久化字段全部有中文注释。
## 6. 当前边界
- 共享配额限于同一 MySQL 主库。单个应用的额度行是串行化点,必须在生产容量阶段测锁等待、吞吐、续租与恢复延迟;没有宣称百万租户容量或跨库原子配额。
- 开始限流为固定一秒窗口,允许边界突发;控制的是平台授权次数,不是处理器内部业务请求。
- 停止旧写入节点后迁移再启动新版,不能与不维护应用用量的旧节点混跑。大表回填和混合版本在线升级未验收。
- 数据库关联依靠服务端事务,没有外键;直接 SQL 修改归属或删除额度记录不属于支持的业务操作。
- 本轮为单数据库、多服务进程及故障注入验证;数据库复制切换、跨故障域网络分区、备份恢复、实际 RPO/RTO 和峰值容量仍在 P6。

View File

@@ -179,4 +179,4 @@ task=<任务ID> state=SUCCEEDED attempts=1 evidence=1 reason=RESULT_CONFLICT dis
## 7. 后续交付
P2 已提供底层协议、[自动 Worker 运行器及示例](worker-runtime.md) 与历史查询,并通过 [Schema 及进程故障阶段验收](../reports/phase-2.md);P3 已实现 [安全重试](../api/retries.md)、[计划调度](../api/schedules.md)、[运行中取消](../api/cancellation.md) 和 [队列资源控制及示例](../api/queues.md),应用级配额、业务键许可、优先级/公平性和结果订阅继续开发;P4–P5 提供流程构建发布、信号、等待、轮询与人工恢复。每项随实现同步交付可运行示例及异常回归。
P2 已提供底层协议、[自动 Worker 运行器及示例](worker-runtime.md) 与历史查询,并通过 [Schema 及进程故障阶段验收](../reports/phase-2.md);P3 已实现 [安全重试](../api/retries.md)、[计划调度](../api/schedules.md)、[运行中取消](../api/cancellation.md) 、[队列资源控制及示例](../api/queues.md) 和 [应用共享配额及示例](../api/application-resources.md),业务键许可、优先级/公平性和结果订阅继续开发;P4–P5 提供流程构建发布、信号、等待、轮询与人工恢复。每项随实现同步交付可运行示例及异常回归。

View File

@@ -26,6 +26,7 @@ var (
IdempotencyConflict = New("IDEMPOTENCY_CONFLICT", "idempotency key was used with different content")
Unavailable = New("UNAVAILABLE", "service temporarily unavailable; retry using the original operation identity")
QueueFull = New("QUEUE_FULL", "queue unfinished task limit reached; retry later using the original identity")
ApplicationFull = New("APPLICATION_FULL", "application unfinished task limit reached; retry later using the original identity")
)
func Invalid(message string) error { return New("INVALID_ARGUMENT", message) }

View File

@@ -0,0 +1,142 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"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/resources"
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
)
// 归属不允许在线修改;认证已经锁定身份。恢复也需释放被停用应用的额度。
const applicationForNamespace = `(SELECT application_id FROM orbit_namespaces WHERE id=?)`
// 统一顺序:认证 → Worker 会话(如有)→ 应用额度 → 队列 → 任务/尝试 → 许可。
// 与身份行分离,避免认证共享锁升级造成的互相等待。
func lockApplicationResources(ctx context.Context, tx *sql.Tx, ns core.ID, skip bool) (bool, error) {
q := `SELECT application_id FROM orbit_application_resources WHERE application_id=` + applicationForNamespace + ` FOR UPDATE`
if skip {
q += ` SKIP LOCKED`
}
var id core.ID
err := tx.QueryRowContext(ctx, q, ns).Scan(&id)
if errors.Is(err, sql.ErrNoRows) && skip {
return false, nil
}
if errors.Is(err, sql.ErrNoRows) {
return false, errors.New("application resources missing")
}
return err == nil, err
}
func readApplicationResources(ctx context.Context, tx *sql.Tx, ns core.ID) (resources.View, error) {
var v resources.View
err := tx.QueryRowContext(ctx, `SELECT application_id,revision,max_unfinished_tasks,unfinished_tasks,max_concurrency,active_attempts,pending_starts,rate_per_second FROM orbit_application_resources WHERE application_id=`+applicationForNamespace, ns).Scan(&v.ApplicationID, &v.Revision, &v.MaxUnfinishedTasks, &v.UnfinishedTasks, &v.MaxConcurrency, &v.ActiveAttempts, &v.PendingStarts, &v.RatePerSecond)
return v, err
}
func applicationStartBudget(ctx context.Context, tx *sql.Tx, ns core.ID, now time.Time) (rate, used uint32, pending, active uint64, max uint32, until *time.Time, err error) {
err = tx.QueryRowContext(ctx, `SELECT rate_per_second,starts_in_window,pending_starts,active_attempts,max_concurrency,start_window_until FROM orbit_application_resources WHERE application_id=`+applicationForNamespace, ns).Scan(&rate, &used, &pending, &active, &max, &until)
if until == nil || !until.After(now) {
used, until = 0, nil
}
return
}
func applicationCanClaim(ctx context.Context, tx *sql.Tx, ns core.ID, now time.Time) (bool, error) {
rate, used, pending, active, max, _, err := applicationStartBudget(ctx, tx, ns, now)
return err == nil && active < uint64(max) && used < rate && pending < uint64(rate-used), err
}
func reserveApplicationAttempt(ctx context.Context, tx *sql.Tx, ns core.ID) error {
r, err := tx.ExecContext(ctx, `UPDATE orbit_application_resources SET active_attempts=active_attempts+1,pending_starts=pending_starts+1 WHERE application_id=`+applicationForNamespace+` AND active_attempts<max_concurrency`, ns)
return exactlyOne(r, err)
}
func consumeApplicationStart(ctx context.Context, tx *sql.Tx, ns core.ID, now time.Time) error {
rate, used, _, _, _, until, err := applicationStartBudget(ctx, tx, ns, now)
if err != nil {
return err
}
if used >= rate {
return worker.RateLimited
}
if until == nil {
t := now.Add(time.Second)
until = &t
}
r, err := tx.ExecContext(ctx, `UPDATE orbit_application_resources SET starts_in_window=?,start_window_until=?,pending_starts=pending_starts-1 WHERE application_id=`+applicationForNamespace+` AND pending_starts>0`, used+1, until, ns)
return exactlyOne(r, err)
}
func releaseApplicationAttempt(ctx context.Context, tx *sql.Tx, ns, attempt core.ID) error {
var unstarted bool
if err := tx.QueryRowContext(ctx, `SELECT authorized_at IS NULL FROM pulse_attempts WHERE namespace_id=? AND id=?`, ns, attempt).Scan(&unstarted); err != nil {
return err
}
var pending uint64
if unstarted {
pending = 1
}
r, err := tx.ExecContext(ctx, `UPDATE orbit_application_resources SET active_attempts=active_attempts-1,pending_starts=pending_starts-? WHERE application_id=`+applicationForNamespace+` AND active_attempts>0 AND pending_starts>=?`, pending, ns, pending)
return exactlyOne(r, err)
}
var _ resources.Store = (*TaskStore)(nil)
func (s *TaskStore) GetApplicationResources(ctx context.Context, token auth.Token) (resources.View, error) {
var out resources.View
err := s.transaction(ctx, token, "application:resources:read", func(tx *sql.Tx, p auth.Permissions) error {
var err error
out, err = readApplicationResources(ctx, tx, token.NamespaceID)
return err
})
return out, err
}
func (s *TaskStore) ConfigureApplicationResources(ctx context.Context, token auth.Token, key string, in resources.ConfigureInput) (resources.Result, error) {
var out resources.Result
err := s.transaction(ctx, token, "application:resources:manage", func(tx *sql.Tx, p auth.Permissions) error {
// 应用级动作显式授权;不从 queues:* 推导跨命名空间权限。
v, err := readApplicationResources(ctx, tx, token.NamespaceID)
if err != nil {
return err
}
identity, cached, err := reserveIdentity(ctx, tx, token.NamespaceID, "application.resources.configure", string(v.ApplicationID), key, "application", v.ApplicationID, 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 := lockApplicationResources(ctx, tx, token.NamespaceID, false); err != nil {
return err
}
before, err := readApplicationResources(ctx, tx, token.NamespaceID)
if err != nil {
return err
}
if before.Revision != in.ExpectedRevision {
return fault.Conflict
}
r, err := tx.ExecContext(ctx, `UPDATE orbit_application_resources SET max_unfinished_tasks=?,max_concurrency=?,rate_per_second=?,revision=revision+1,updated_at=UTC_TIMESTAMP(6) WHERE application_id=? AND revision=?`, in.MaxUnfinishedTasks, in.MaxConcurrency, in.RatePerSecond, before.ApplicationID, in.ExpectedRevision)
if err := exactlyOne(r, err); err != nil {
return err
}
out.Resources, err = readApplicationResources(ctx, tx, token.NamespaceID)
if err != nil {
return err
}
if err := appendAudit(ctx, tx, token, identity, before.ApplicationID, "application", "application.resources.configure", in.Reason, before, out.Resources); err != nil {
return err
}
if err := emitEvent(ctx, tx, token.NamespaceID, before.ApplicationID, out.Resources.Revision, "application", "application.resources.configured", out.Resources); err != nil {
return err
}
return saveIdentity(ctx, tx, token.NamespaceID, identity, out)
})
return out, err
}

View File

@@ -0,0 +1,425 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"os"
"sync"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/resources"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
)
func grantApplicationResources(f *executionFixture) {
f.exec(`UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.actions',JSON_ARRAY_APPEND(JSON_EXTRACT(permissions,'$.actions'),'$','application:resources:read','$','application:resources:manage')) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID)
}
func applicationAPI(f *executionFixture) *resources.Service { return resources.New(NewTaskStore(f.db)) }
func configureApplication(t *testing.T, f *executionFixture, rate, concurrency, unfinished uint32) resources.View {
t.Helper()
api := applicationAPI(f)
v, err := api.Get(f.ctx, f.token)
if err != nil {
t.Fatal(err)
}
out, err := api.Configure(f.ctx, f.token, string(executionID(t)), resources.ConfigureInput{ExpectedRevision: v.Revision, Reason: "application quota regression", RatePerSecond: rate, MaxConcurrency: concurrency, MaxUnfinishedTasks: unfinished})
if err != nil {
t.Fatal(err)
}
return out.Resources
}
func assertApplicationCounts(t *testing.T, f *executionFixture, unfinished, active, pending uint64) {
t.Helper()
// 数据库核对不依赖当前应用是否停用,恢复仍应释放额度。
var got [3]uint64
if err := f.db.QueryRowContext(f.ctx, `SELECT unfinished_tasks,active_attempts,pending_starts FROM orbit_application_resources WHERE application_id=?`, f.resources.ApplicationID).Scan(&got[0], &got[1], &got[2]); err != nil {
t.Fatal(err)
}
if got != [3]uint64{unfinished, active, pending} {
t.Fatalf("application counters=%v want=%v", got, [3]uint64{unfinished, active, pending})
}
var actual [3]uint64
if err := f.db.QueryRowContext(f.ctx, `SELECT COUNT(*),COALESCE(SUM(t.state IN ('LEASED','RUNNING')),0),COALESCE(SUM(t.state='LEASED'),0) FROM pulse_tasks t JOIN orbit_namespaces n ON n.id=t.namespace_id WHERE n.application_id=? AND t.state IN ('WAITING','LEASED','RUNNING','NEEDS_REVIEW')`, f.resources.ApplicationID).Scan(&actual[0], &actual[1], &actual[2]); err != nil {
t.Fatal(err)
}
if actual != got {
t.Fatalf("application counters=%v actual states=%v", got, actual)
}
}
func TestIntegrationApplicationResources(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
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)
}
fixture := func(t *testing.T, app ...core.ID) *executionFixture {
f := newExecutionFixture(t, ctx, db, app...)
grantApplicationResources(f)
return f
}
t.Run("explicit_authority_CAS_idempotency_and_atomic_audit", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
api := applicationAPI(f)
_, err := api.Get(ctx, f.token)
expectWorkerCode(t, err, "PERMISSION_DENIED")
in := resources.ConfigureInput{ExpectedRevision: 1, Reason: "configure", MaxConcurrency: 2, RatePerSecond: 10, MaxUnfinishedTasks: 10}
_, err = api.Configure(ctx, f.token, "manage", in)
expectWorkerCode(t, err, "PERMISSION_DENIED")
grantApplicationResources(f)
v, err := api.Configure(ctx, f.token, "manage", in)
if err != nil || v.Resources.Revision != 2 {
t.Fatal(v, err)
}
f.submit(1)
replay, err := api.Configure(ctx, f.token, "manage", in)
if err != nil || !replay.Deduplicated || replay.Resources.UnfinishedTasks != 0 {
t.Fatal("receipt changed with usage", err)
}
_, err = api.Configure(ctx, f.token, "stale", in)
expectWorkerCode(t, err, "STATE_CONFLICT")
in.RatePerSecond = 20
_, err = api.Configure(ctx, f.token, "manage", in)
expectWorkerCode(t, err, "IDEMPOTENCY_CONFLICT")
in.ExpectedRevision = 2
trigger := "test_app_audit_" + string(executionID(t))
f.exec("CREATE TRIGGER " + trigger + " BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='injected audit failure'")
_, err = api.Configure(ctx, f.token, "rollback", in)
f.exec("DROP TRIGGER " + trigger)
if err == nil {
t.Fatal("audit fault ignored")
}
v2, err := api.Get(ctx, f.token)
if err != nil || v2.Revision != 2 || v2.RatePerSecond != 10 {
t.Fatal("configuration did not roll back", v2, err)
}
if _, err := api.Configure(ctx, f.token, "rollback", in); err != nil {
t.Fatal(err)
}
if f.count("orbit_audit_events", "action='application.resources.configure'") != 2 || f.count("orbit_events", "type='application.resources.configured'") != 2 {
t.Fatal("duplicate or missing audit/event")
}
g := fixture(t, f.resources.ApplicationID)
shared, err := applicationAPI(g).Get(ctx, g.token)
if err != nil || shared.Revision != 3 || shared.UnfinishedTasks != 1 {
t.Fatal(shared, err)
}
other := fixture(t)
if v, err := applicationAPI(other).Get(ctx, other.token); err != nil || v.Revision != 1 || v.UnfinishedTasks != 0 {
t.Fatal("other application leaked", v, err)
}
})
t.Run("concurrent_admission_across_namespaces_and_full_replay", func(t *testing.T) {
f := fixture(t)
g := fixture(t, f.resources.ApplicationID)
configureApplication(t, f, 100, 100, 3)
type answer struct {
f *executionFixture
key string
in service.SubmitInput
out service.SubmitResult
err error
}
ch := make(chan answer, 16)
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
target := f
if i%2 == 1 {
target = g
}
key := string(executionID(t))
wg.Add(1)
go func() {
defer wg.Done()
in := service.SubmitInput{QueueID: target.resources.QueueID, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)}
out, err := target.tasks.Submit(ctx, target.token, key, in)
ch <- answer{target, key, in, out, err}
}()
}
wg.Wait()
close(ch)
var accepted []answer
for a := range ch {
if a.err == nil {
accepted = append(accepted, a)
} else {
expectWorkerCode(t, a.err, "APPLICATION_FULL")
}
}
if len(accepted) != 3 {
t.Fatalf("accepted=%d", len(accepted))
}
assertApplicationCounts(t, f, 3, 0, 0)
a := accepted[0]
replay, err := a.f.tasks.Submit(ctx, a.f.token, a.key, a.in)
if err != nil || !replay.Deduplicated || replay.Task.ID != a.out.Task.ID {
t.Fatal("full app lost receipt", err)
}
configureApplication(t, g, 100, 100, 1)
assertApplicationCounts(t, f, 3, 0, 0)
for _, a := range accepted {
_, err := a.f.tasks.CancelTask(ctx, a.f.token, a.out.Task.ID, "cancel-"+a.key, service.CancelInput{ExpectedRevision: 1, Reason: "release"})
if err != nil {
t.Fatal(err)
}
}
assertApplicationCounts(t, g, 0, 0, 0)
g.submit(1)
assertApplicationCounts(t, f, 1, 0, 0)
})
t.Run("queue_rejection_and_failed_admission_do_not_leak_application_budget", func(t *testing.T) {
f := fixture(t)
configureApplication(t, f, 100, 10, 10)
configureResources(t, f, 100, 10, 1, false)
first := f.submit(1)
in := service.SubmitInput{QueueID: f.resources.QueueID, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)}
_, err := f.tasks.Submit(ctx, f.token, "second", in)
expectWorkerCode(t, err, "QUEUE_FULL")
assertApplicationCounts(t, f, 1, 0, 0)
if _, err := f.tasks.CancelTask(ctx, f.token, first.ID, "cancel-first", service.CancelInput{ExpectedRevision: first.Revision, Reason: "release"}); err != nil {
t.Fatal(err)
}
trigger := "test_app_admission_" + string(executionID(t))
f.exec("CREATE TRIGGER " + trigger + " BEFORE INSERT ON orbit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='injected admission failure'")
_, err = f.tasks.Submit(ctx, f.token, "second", in)
f.exec("DROP TRIGGER " + trigger)
if err == nil {
t.Fatal("admission failure ignored")
}
assertApplicationCounts(t, f, 0, 0, 0)
assertQueueCount(t, f, 0)
if _, err := f.tasks.Submit(ctx, f.token, "second", in); err != nil {
t.Fatal(err)
}
assertApplicationCounts(t, f, 1, 0, 0)
assertQueueCount(t, f, 1)
})
t.Run("shared_concurrency_and_exact_release", func(t *testing.T) {
f := fixture(t)
g := fixture(t, f.resources.ApplicationID)
configureApplication(t, f, 100, 1, 10)
f.submit(1)
g.submit(1)
type claim struct {
f *executionFixture
a *worker.Assignment
err error
}
ch := make(chan claim, 2)
for _, target := range []*executionFixture{f, g} {
go func() { a, err := target.a.Claim(ctx, target.token, target.binding); ch <- claim{target, a, err} }()
}
var winner claim
for i := 0; i < 2; i++ {
a := <-ch
if a.err != nil {
t.Fatal(a.err)
}
if a.a != nil {
if winner.a != nil {
t.Fatal("cross namespace concurrency exceeded")
}
winner = a
}
}
if winner.a == nil {
t.Fatal("no claim")
}
assertApplicationCounts(t, f, 2, 1, 1)
task := winner.a.Task
_, err := winner.f.tasks.CancelTask(ctx, winner.f.token, task.ID, "cancel-leased", service.CancelInput{ExpectedRevision: task.Revision, Reason: "release before start"})
if err != nil {
t.Fatal(err)
}
assertApplicationCounts(t, f, 1, 0, 0)
other := f
if winner.f == f {
other = g
}
ref := retryStart(t, other)
assertApplicationCounts(t, f, 1, 1, 0)
message := executionID(t)
input := worker.FinishInput{Ref: ref, Outcome: "SUCCEEDED", Result: json.RawMessage(`{}`)}
if _, err := other.a.Finish(ctx, other.token, other.binding, message, input); err != nil {
t.Fatal(err)
}
if out, err := other.a.Finish(ctx, other.token, other.binding, message, input); err != nil || !out.Deduplicated {
t.Fatal("finish replay", err)
}
assertApplicationCounts(t, f, 0, 0, 0)
})
t.Run("shared_start_window_downgrade_and_rollback", func(t *testing.T) {
f := fixture(t)
g := fixture(t, f.resources.ApplicationID)
configureApplication(t, f, 2, 10, 10)
f.submit(1)
g.submit(1)
_, a := f.claim()
_, b := g.claim()
assertApplicationCounts(t, f, 2, 2, 2)
configureApplication(t, g, 1, 1, 10)
trigger := "test_app_start_" + string(executionID(t))
f.exec("CREATE TRIGGER " + trigger + " BEFORE INSERT ON orbit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='injected start failure'")
message := executionID(t)
_, err := f.a.Start(ctx, f.token, f.binding, message, a)
f.exec("DROP TRIGGER " + trigger)
if err == nil {
t.Fatal("start failure ignored")
}
assertApplicationCounts(t, f, 2, 2, 2)
if _, err := f.a.Start(ctx, f.token, f.binding, message, a); err != nil {
t.Fatal(err)
}
// 固定尚未过期窗口,避免慢测试主机把竞争断言跨到下一秒。
f.exec(`UPDATE orbit_application_resources SET start_window_until=DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR) WHERE application_id=?`, f.resources.ApplicationID)
if out, err := f.a.Start(ctx, f.token, f.binding, message, a); err != nil || !out.Deduplicated {
t.Fatal("start replay charged again", err)
}
_, err = g.a.Start(ctx, g.token, g.binding, executionID(t), b)
expectWorkerCode(t, err, "RATE_LIMITED")
assertApplicationCounts(t, f, 2, 2, 1)
retryFinish(t, f, a, "SUCCEEDED", false)
configureApplication(t, f, 1, 10, 10)
g.submit(1)
if a, err := g.a.Claim(ctx, g.token, g.binding); err != nil || a != nil {
t.Fatal("pending start reservation bypassed", err)
}
g.exec(`UPDATE orbit_application_resources SET start_window_until=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 SECOND) WHERE application_id=?`, g.resources.ApplicationID)
if a, err := g.a.Claim(ctx, g.token, g.binding); err != nil || a != nil {
t.Fatal("unstarted reservation lost across window", err)
}
if _, err := g.a.Start(ctx, g.token, g.binding, executionID(t), b); err != nil {
t.Fatal(err)
}
assertApplicationCounts(t, f, 2, 1, 0)
})
t.Run("retry_recovery_unknown_and_disabled_application", func(t *testing.T) {
f := fixture(t)
configureApplication(t, f, 100, 10, 10)
task := retryTask(t, f, 2, pulse.RecoveryReconcileOnly)
ref := retryStart(t, f)
retryFinish(t, f, ref, "FAILED", true)
assertApplicationCounts(t, f, 1, 0, 0)
makeRetryDue(f, task.ID)
ref = retryStart(t, f)
f.expire(ref)
f.exec(`UPDATE orbit_applications SET enabled=FALSE WHERE id=?`, f.resources.ApplicationID)
if _, err := f.storeB.Recover(ctx, 100); err != nil {
t.Fatal(err)
}
assertApplicationCounts(t, f, 1, 0, 0)
if _, err := f.storeA.Recover(ctx, 100); err != nil {
t.Fatal(err)
}
assertApplicationCounts(t, f, 1, 0, 0)
f.exec(`UPDATE orbit_applications SET enabled=TRUE WHERE id=?`, f.resources.ApplicationID)
if f.get(task.ID).State != pulse.TaskNeedsReview {
t.Fatal("unknown effect lost")
}
f.submit(1)
_, leased := f.claim()
f.expire(leased)
if _, err := f.storeB.Recover(ctx, 100); err != nil {
t.Fatal(err)
}
assertApplicationCounts(t, f, 1, 0, 0)
waiting := f.submit(1)
f.exec(`UPDATE pulse_tasks SET available_at=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 2 SECOND),deadline_at=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 SECOND) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, waiting.ID)
if _, err := f.storeB.Recover(ctx, 100); err != nil {
t.Fatal(err)
}
assertApplicationCounts(t, f, 1, 0, 0)
})
t.Run("schedule_partial_batch_and_global_backpressure", func(t *testing.T) {
f := fixture(t)
g := fixture(t, f.resources.ApplicationID)
configureApplication(t, f, 100, 10, 2)
g.submit(1)
sf := newScheduleFixture(t, f)
v := sf.create(sf.definition())
sf.due(v, 2*time.Second)
sf.tick()
items := sf.occurrences(v.ID)
if len(items) != 1 || items[0].TaskID == nil {
t.Fatal("application schedule limit exceeded", len(items))
}
next := sf.current(v.ID).NextFireAt
if next == nil || !next.Equal(items[0].ScheduledAt.Add(time.Second)) {
t.Fatal("backpressure advanced unaccepted cursor")
}
assertApplicationCounts(t, f, 2, 0, 0)
assertQueueCount(t, f, 1)
assertQueueCount(t, g, 1)
_, err := f.tasks.CancelTask(ctx, f.token, *items[0].TaskID, "free-schedule", service.CancelInput{ExpectedRevision: 1, Reason: "free one"})
if err != nil {
t.Fatal(err)
}
sf.due(v, 0)
sf.tick()
items = sf.occurrences(v.ID)
if len(items) != 2 {
t.Fatal("original point not resumed")
}
assertApplicationCounts(t, f, 2, 0, 0)
assertQueueCount(t, f, 1)
})
t.Run("busy_application_is_skipped_during_recovery", func(t *testing.T) {
f := fixture(t)
g := fixture(t)
for _, v := range []*executionFixture{f, g} {
v.submit(1)
_, ref := v.claim()
v.expire(ref)
}
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
if _, err := lockApplicationResources(ctx, tx, f.resources.NamespaceID, false); err != nil {
t.Fatal(err)
}
short, stop := context.WithTimeout(ctx, time.Second)
defer stop()
if _, err := g.storeB.Recover(short, 100); err != nil {
t.Fatal("hot application blocked unrelated recovery", err)
}
assertApplicationCounts(t, f, 1, 1, 1)
assertApplicationCounts(t, g, 0, 0, 0)
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
if _, err := f.storeB.Recover(ctx, 100); err != nil {
t.Fatal(err)
}
assertApplicationCounts(t, f, 0, 0, 0)
})
t.Run("bootstrap_existing_application_rejects_invalid_parent", func(t *testing.T) {
f := fixture(t)
f.exec(`UPDATE orbit_applications SET enabled=FALSE WHERE id=?`, f.resources.ApplicationID)
for _, in := range []BootstrapInput{
{ExistingApplicationID: executionID(t), Namespace: "missing", Environment: "test", Queue: "q"},
{ExistingApplicationID: f.resources.ApplicationID, Namespace: "disabled", Environment: "test", Queue: "q"},
{Application: "new", ExistingApplicationID: f.resources.ApplicationID, Namespace: "ambiguous", Environment: "test", Queue: "q"},
} {
delivered := false
err := Bootstrap(ctx, db, in, func(BootstrapResult) error { delivered = true; return nil })
if err == nil || delivered {
t.Fatal("invalid application accepted")
}
}
})
}

View File

@@ -0,0 +1,96 @@
package mysql
import (
"context"
"os"
"testing"
"time"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
mysqldriver "github.com/go-sql-driver/mysql"
)
// 建立有效在途任务,再将一次性库还原为结构完全相同的 v9,验证新增额度的真实回填和继续释放。
func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
admin, err := Open(ctx, dsn, 2, 1)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
name := "orbit_app_upgrade_" + string(executionID(t))
if _, err := admin.ExecContext(ctx, "CREATE DATABASE `"+name+"`"); err != nil {
t.Fatal(err)
}
defer func() {
cleanup, stop := context.WithTimeout(context.Background(), 10*time.Second)
defer stop()
if _, err := admin.ExecContext(cleanup, "DROP DATABASE `"+name+"`"); err != nil {
t.Error(err)
}
}()
config, err := mysqldriver.ParseDSN(dsn)
if err != nil {
t.Fatal("invalid test DSN")
}
config.DBName = name
db, err := Open(ctx, config.FormatDSN(), 8, 2)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
f := newExecutionFixture(t, ctx, db)
g := newExecutionFixture(t, ctx, db, f.resources.ApplicationID)
empty := newExecutionFixture(t, ctx, db)
f.submit(1)
ref := retryStart(t, f)
retryFinish(t, f, ref, "UNKNOWN", false)
f.submit(1)
_, leased := f.claim()
g.submit(1)
running := retryStart(t, g)
waiting := f.submit(1)
g.submit(1)
finished := retryStart(t, g)
retryFinish(t, g, finished, "SUCCEEDED", false)
assertApplicationCounts(t, f, 4, 2, 1)
// v10 只新增此表;其余列、索引与数据均是 v9 结构。
f.exec(`DROP TABLE orbit_application_resources`)
f.exec(`DELETE FROM orbit_schema_migrations WHERE version=10`)
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(ctx, db); err != nil {
t.Fatal(err)
}
assertApplicationCounts(t, f, 4, 2, 1)
assertApplicationCounts(t, empty, 0, 0, 0)
var revision, rate, maxConcurrency, maxUnfinished uint64
var until *time.Time
if err := db.QueryRowContext(ctx, `SELECT revision,rate_per_second,max_concurrency,max_unfinished_tasks,start_window_until FROM orbit_application_resources WHERE application_id=?`, f.resources.ApplicationID).Scan(&revision, &rate, &maxConcurrency, &maxUnfinished, &until); err != nil || revision != 1 || rate != 100000 || maxConcurrency != 10000 || maxUnfinished != 1000000 || until != nil {
t.Fatal("invalid backfill defaults", err)
}
if v := f.get(waiting.ID); v.State != pulse.TaskWaiting || v.Revision != waiting.Revision {
t.Fatal("migration modified task")
}
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
assertApplicationCounts(t, f, 4, 2, 1)
current := f.get(leased.TaskID)
if _, err := f.tasks.CancelTask(ctx, f.token, current.ID, "cancel-upgraded", service.CancelInput{ExpectedRevision: current.Revision, Reason: "verify migrated lease release"}); err != nil {
t.Fatal(err)
}
retryFinish(t, g, running, "SUCCEEDED", false)
assertApplicationCounts(t, f, 2, 0, 0)
t.Log("v9 backfill: shared unfinished=4 active=2 pending=1; empty app=0; repeated migration preserved counts; old attempts released correctly")
}

View File

@@ -14,17 +14,19 @@ import (
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
type BootstrapInput struct {
Application string // Application 应用名称,每次初始化创建独立应用。
Application string // Application 新应用名称,与 ExistingApplicationID 二选一。
Namespace string // Namespace 命名空间名称。
Environment string // Environment 环境名称,必须显式指定。
Queue string // Queue 初始队列名称。
WorkerAccess bool // WorkerAccess 显式增加开发用 Worker 接入及消费权限。
ScheduleAccess bool // ScheduleAccess 显式增加开发用计划查询及管理权限。
ResourceAccess bool // ResourceAccess 显式授予整个应用的额度查询及管理权限。
ExistingApplicationID core.ID // ExistingApplicationID 在指定现有应用下创建命名空间,与 Application 二选一。
}
// BootstrapResult 包含一次性交付的原始凭据,禁止记录到日志或提交到版本库。
type BootstrapResult struct {
ApplicationID core.ID `json:"application_id"` // ApplicationID 新建应用标识。
ApplicationID core.ID `json:"application_id"` // ApplicationID 创建或关联的应用标识。
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 新建隔离范围。
QueueID core.ID `json:"queue_id"` // QueueID 初始队列标识。
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据记录标识。
@@ -33,11 +35,14 @@ type BootstrapResult struct {
}
func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error) error {
for _, v := range []string{in.Application, in.Namespace, in.Queue} {
for _, v := range []string{in.Namespace, in.Queue} {
if !service.ValidName(v, 128) {
return fmt.Errorf("invalid bootstrap resource name")
}
}
if (in.ExistingApplicationID == "" && !service.ValidName(in.Application, 128)) || (in.ExistingApplicationID != "" && (in.ExistingApplicationID.Validate() != nil || in.Application != "")) {
return fmt.Errorf("exactly one new application name or existing application id is required")
}
if !service.ValidName(in.Environment, 32) || deliver == nil {
return fmt.Errorf("environment and secure delivery are required")
}
@@ -45,7 +50,15 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
return err
}
var out BootstrapResult
for _, dest := range []*core.ID{&out.ApplicationID, &out.NamespaceID, &out.QueueID, &out.CredentialID} {
var err error
out.ApplicationID = in.ExistingApplicationID
if out.ApplicationID == "" {
out.ApplicationID, err = core.NewID()
if err != nil {
return err
}
}
for _, dest := range []*core.ID{&out.NamespaceID, &out.QueueID, &out.CredentialID} {
v, err := core.NewID()
if err != nil {
return err
@@ -64,6 +77,9 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
if in.ScheduleAccess {
permissions.Actions = append(permissions.Actions, "schedules:read", "schedules:manage")
}
if in.ResourceAccess {
permissions.Actions = append(permissions.Actions, "application:resources:read", "application:resources:manage")
}
raw, err := json.Marshal(permissions)
if err != nil {
return err
@@ -74,9 +90,19 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
return err
}
out.ExpiresAt = now.Add(30 * 24 * time.Hour)
if in.ExistingApplicationID == "" {
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_applications(id,name) VALUES (?,?)", out.ApplicationID, in.Application); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_application_resources(application_id) VALUES (?)", out.ApplicationID); err != nil {
return err
}
} else {
var id core.ID
if err := tx.QueryRowContext(ctx, `SELECT a.id FROM orbit_applications a JOIN orbit_application_resources r ON r.application_id=a.id WHERE a.id=? AND a.enabled=TRUE FOR SHARE`, out.ApplicationID).Scan(&id); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,?)", out.NamespaceID, out.ApplicationID, in.Namespace, in.Environment); err != nil {
return err
}

View File

@@ -184,6 +184,7 @@ func checkModelColumns(t *testing.T, ctx context.Context, db *sql.DB) {
t.Helper()
models := []tableModel{
platform.Application{},
platform.ApplicationResources{},
platform.Namespace{},
platform.Credential{},
platform.IdempotencyKey{},

View File

@@ -0,0 +1,29 @@
-- 停止旧服务后执行。身份与额度分表,避免认证共享锁升级;禁止数据库外键。
CREATE TABLE orbit_application_resources (
application_id BINARY(16) NOT NULL COMMENT '所属应用标识及主键,跨命名空间共享额度',
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '配置乐观版本,用量变化不增加版本',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,UTC 微秒',
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后配置更新时间,UTC 微秒',
max_unfinished_tasks INT UNSIGNED NOT NULL DEFAULT 1000000 COMMENT '应用未终结任务上限,降额保留已有任务',
unfinished_tasks BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '等待、预占、运行及待核查任务总数,重试不重复计数',
max_concurrency INT UNSIGNED NOT NULL DEFAULT 10000 COMMENT '预占与运行尝试总上限,降额不撤销已有尝试',
active_attempts BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前预占与运行尝试总数,结果确认或到期恢复释放',
pending_starts BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已预占但未开始尝试数,包含在活跃尝试中',
rate_per_second INT UNSIGNED NOT NULL DEFAULT 100000 COMMENT '一秒固定窗口内开始授权上限,不是业务请求速率',
start_window_until DATETIME(6) NULL COMMENT '数据库时间的一秒窗口结束时刻,空表示尚未建立窗口',
starts_in_window INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '窗口中已确认的开始次数,重复回执不计数',
PRIMARY KEY (application_id),
CONSTRAINT ck_app_resources_revision CHECK (revision > 0),
CONSTRAINT ck_app_resources_limits CHECK (max_unfinished_tasks > 0 AND max_concurrency > 0 AND rate_per_second > 0),
CONSTRAINT ck_app_resources_pending CHECK (pending_starts <= active_attempts),
CONSTRAINT ck_app_resources_window CHECK (start_window_until IS NOT NULL OR starts_in_window=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='应用跨命名空间共享额度与持久化用量,无数据库外键';
INSERT INTO orbit_application_resources(application_id,unfinished_tasks,active_attempts,pending_starts)
SELECT a.id,COALESCE(t.unfinished,0),COALESCE(t.active,0),COALESCE(t.pending,0)
FROM orbit_applications a LEFT JOIN (
SELECT n.application_id,COUNT(*) AS unfinished,
SUM(t.state IN ('LEASED','RUNNING')) AS active,SUM(t.state='LEASED') AS pending
FROM pulse_tasks t JOIN orbit_namespaces n ON n.id=t.namespace_id
WHERE t.state IN ('WAITING','LEASED','RUNNING','NEEDS_REVIEW') GROUP BY n.application_id
) t ON t.application_id=a.id;

View File

@@ -88,7 +88,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
}
indexes := func() []string {
t.Helper()
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() 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<>'orbit_application_resources' ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
if err != nil {
t.Fatal(err)
}

View File

@@ -13,8 +13,15 @@ import (
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
)
// 以下额度操作要求调用者已取得队列排他锁,且在同一事务中写入任务/尝试。
// 以下额度操作要求调用者先取得应用额度锁,再取得队列排他锁,且在同一事务中写入任务/尝试。
func reserveQueueTask(ctx context.Context, tx *sql.Tx, ns, queue core.ID) error {
v, err := readApplicationResources(ctx, tx, ns)
if err != nil {
return err
}
if v.UnfinishedTasks >= uint64(v.MaxUnfinishedTasks) {
return fault.ApplicationFull
}
r, err := tx.ExecContext(ctx, `UPDATE pulse_queues SET unfinished_tasks=unfinished_tasks+1 WHERE namespace_id=? AND id=? AND unfinished_tasks<max_unfinished_tasks`, ns, queue)
if err != nil {
return err
@@ -26,11 +33,16 @@ func reserveQueueTask(ctx context.Context, tx *sql.Tx, ns, queue core.ID) error
if n != 1 {
return fault.QueueFull
}
return nil
r, err = tx.ExecContext(ctx, `UPDATE orbit_application_resources SET unfinished_tasks=unfinished_tasks+1 WHERE application_id=? AND unfinished_tasks<max_unfinished_tasks`, v.ApplicationID)
return exactlyOne(r, err)
}
func releaseQueueTask(ctx context.Context, tx *sql.Tx, ns, queue core.ID) error {
r, err := tx.ExecContext(ctx, `UPDATE pulse_queues SET unfinished_tasks=unfinished_tasks-1 WHERE namespace_id=? AND id=? AND unfinished_tasks>0`, ns, queue)
r, err := tx.ExecContext(ctx, `UPDATE orbit_application_resources SET unfinished_tasks=unfinished_tasks-1 WHERE application_id=`+applicationForNamespace+` AND unfinished_tasks>0`, ns)
if err := exactlyOne(r, err); err != nil {
return err
}
r, err = tx.ExecContext(ctx, `UPDATE pulse_queues SET unfinished_tasks=unfinished_tasks-1 WHERE namespace_id=? AND id=? AND unfinished_tasks>0`, ns, queue)
return exactlyOne(r, err)
}
@@ -51,6 +63,9 @@ func queueStartBudget(ctx context.Context, tx *sql.Tx, ns, queue core.ID, now ti
// queueCanClaim 把尚未 Start 的预占也算入当前窗口可用空间,避免限流时空耗尝试预算。
func queueCanClaim(ctx context.Context, tx *sql.Tx, ns, queue core.ID, now time.Time) (bool, error) {
if ok, err := applicationCanClaim(ctx, tx, ns, now); err != nil || !ok {
return false, err
}
rate, used, _, err := queueStartBudget(ctx, tx, ns, queue, now)
if err != nil || used >= rate {
return false, err
@@ -72,6 +87,9 @@ func consumeQueueStart(ctx context.Context, tx *sql.Tx, ns, queue core.ID, now t
end := now.Add(time.Second)
until = &end
}
if err := consumeApplicationStart(ctx, tx, ns, now); err != nil {
return err
}
r, err := tx.ExecContext(ctx, `UPDATE pulse_queues SET start_window_until=?,starts_in_window=? WHERE namespace_id=? AND id=?`, until, used+1, ns, queue)
return exactlyOne(r, err)
}

View File

@@ -100,6 +100,10 @@ func TestIntegrationSafeRetryUpgrade(t *testing.T) {
if err := db.QueryRowContext(ctx, `SELECT unfinished_tasks,max_unfinished_tasks,revision FROM pulse_queues WHERE namespace_id=? AND id=?`, ns, queue).Scan(&unfinished, &maxUnfinished, &queueRevision); err != nil || unfinished != 2 || maxUnfinished != 100000 || queueRevision != 1 {
t.Fatalf("resource upgrade changed queue or missed states: count=%d max=%d revision=%d err=%v", unfinished, maxUnfinished, queueRevision, err)
}
var appCount, appActive, appPending uint64
if err := db.QueryRowContext(ctx, `SELECT unfinished_tasks,active_attempts,pending_starts FROM orbit_application_resources WHERE application_id=?`, app).Scan(&appCount, &appActive, &appPending); err != nil || appCount != 2 || appActive != 0 || appPending != 0 {
t.Fatalf("application backfill incorrect: %d %d %d %v", appCount, appActive, appPending, err)
}
for state, id := range ids {
var gotState, gotPolicy string
var uncertain bool

View File

@@ -106,7 +106,11 @@ func TestIntegrationSchedules(t *testing.T) {
f := fixture(t)
finite := func(delay time.Duration) schedule.Definition {
in := f.definition()
in.Rule.StartAt = time.Now().UTC().Truncate(time.Microsecond).Add(delay)
var now time.Time
if err := db.QueryRowContext(ctx, `SELECT UTC_TIMESTAMP(6)`).Scan(&now); err != nil {
t.Fatal(err)
}
in.Rule.StartAt = now.Add(delay)
end := in.Rule.StartAt.Add(time.Millisecond)
in.Rule.EndAt = &end
return in
@@ -122,14 +126,22 @@ func TestIntegrationSchedules(t *testing.T) {
}
waitUntil := func(at time.Time) {
t.Helper()
timer := time.NewTimer(time.Until(at.Add(10 * time.Millisecond)))
defer timer.Stop()
// 调度使用数据库时间,不能依赖容器与宿主机时钟完全同步。
for {
var now time.Time
if err := db.QueryRowContext(ctx, `SELECT UTC_TIMESTAMP(6)`).Scan(&now); err != nil {
t.Fatal(err)
}
if now.After(at) {
return
}
select {
case <-timer.C:
case <-time.After(10 * time.Millisecond):
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}
v := f.create(finite(300 * time.Millisecond))
waitUntil(*v.NextFireAt)
delay := 300 * time.Millisecond
@@ -146,7 +158,7 @@ func TestIntegrationSchedules(t *testing.T) {
f.tick() // 第一版本结束,第二版本尚未计算第一个触发点。
pending := f.current(v.ID)
if pending.ProcessingVersion != 2 || pending.NextFireAt != nil || pending.CheckAt == nil {
t.Fatal("expected pending initialization of version 2")
t.Fatalf("expected pending initialization of version 2: processing=%d next=%v check=%v current=%d occurrences=%d", pending.ProcessingVersion, pending.NextFireAt, pending.CheckAt, pending.CurrentVersion, len(f.occurrences(v.ID)))
}
if !emptyMiddle {
waitUntil(middle.Rule.StartAt)

View File

@@ -72,7 +72,10 @@ func (s *ScheduleStore) tickOne(ctx context.Context, ns, id, queue core.ID) (int
if err != nil {
return err
}
// 和任务执行一致先锁队列,再锁计划;其他扫描节点不等待同一计划所在队列。
// 应用额度 → 队列 → 计划;忙碌资源留给下一批。
if ok, err := lockApplicationResources(ctx, tx, ns, true); err != nil || !ok {
return err
}
err = tx.QueryRowContext(ctx, `SELECT id FROM pulse_queues WHERE namespace_id=? AND id=? FOR UPDATE SKIP LOCKED`, ns, queue).Scan(&allowed)
if errors.Is(err, sql.ErrNoRows) {
return nil
@@ -156,7 +159,7 @@ func (s *ScheduleStore) tickOne(ctx context.Context, ns, id, queue core.ID) (int
backpressured := false
for _, decision := range batch.Decisions {
if err := s.saveOccurrence(ctx, tx, v, version, decision, now); err != nil {
if errors.Is(err, fault.QueueFull) {
if errors.Is(err, fault.QueueFull) || errors.Is(err, fault.ApplicationFull) {
// 已提交本批较早的决定仍保留;满额的原触发点留待下次按冻结策略处理。
t := decision.ScheduledAt
next, backpressured = &t, true

View File

@@ -37,7 +37,7 @@ func (s *TaskStore) CancelTask(ctx context.Context, token auth.Token, id core.ID
out.Deduplicated = true
return nil
}
// 队列 → 任务 → 尝试 → 许可;取消不获取 Worker 会话锁。
// 应用额度 → 队列 → 任务 → 尝试 → 许可;取消不获取 Worker 会话锁。
if _, _, err := lockQueue(ctx, tx, token.NamespaceID, view.QueueID); err != nil {
return err
}

View File

@@ -170,10 +170,7 @@ func (s *TaskStore) Submit(ctx context.Context, token auth.Token, key string, in
out.Deduplicated = true
return nil
}
var queue core.ID
if err := tx.QueryRowContext(ctx, "SELECT id FROM pulse_queues WHERE namespace_id=? AND id=? FOR UPDATE", token.NamespaceID, in.QueueID).Scan(&queue); errors.Is(err, sql.ErrNoRows) {
return fault.NotFound
} else if err != nil {
if _, _, err := lockQueue(ctx, tx, token.NamespaceID, in.QueueID); err != nil {
return err
}
now, err := dbNow(ctx, tx)

View File

@@ -46,6 +46,9 @@ func readExecutionTask(ctx context.Context, tx *sql.Tx, ns, id core.ID) (executi
return row, err
}
func lockQueue(ctx context.Context, tx *sql.Tx, ns, id core.ID) (paused bool, max uint32, err error) {
if _, err = lockApplicationResources(ctx, tx, ns, false); err != nil {
return
}
err = tx.QueryRowContext(ctx, "SELECT paused,max_concurrency FROM pulse_queues WHERE namespace_id=? AND id=? FOR UPDATE", ns, id).Scan(&paused, &max)
if errors.Is(err, sql.ErrNoRows) {
err = fault.NotFound
@@ -232,6 +235,9 @@ func (s *ExecutionStore) Claim(ctx context.Context, token auth.Token, b worker.B
if err := exactlyOne(changed, err); err != nil {
return err
}
if err := reserveApplicationAttempt(ctx, tx, token.NamespaceID); err != nil {
return err
}
view, err := readTask(ctx, tx, token.NamespaceID, locked, false)
if err != nil {
return err
@@ -417,6 +423,9 @@ func releasePermits(ctx context.Context, tx *sql.Tx, ns, attempt core.ID, now ti
if len(permits) == 0 {
return errors.New("active attempt has no permit to release")
}
if err := releaseApplicationAttempt(ctx, tx, ns, attempt); err != nil {
return err
}
for _, p := range permits {
result, err := tx.ExecContext(ctx, `UPDATE pulse_concurrency_limits SET used_permits=used_permits-1,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND used_permits>0`, now, ns, p.limit)
if err := exactlyOne(result, err); err != nil {

View File

@@ -41,10 +41,16 @@ func executionID(t *testing.T) core.ID {
}
return id
}
func newExecutionFixture(t *testing.T, ctx context.Context, db *sql.DB) *executionFixture {
func newExecutionFixture(t *testing.T, ctx context.Context, db *sql.DB, application ...core.ID) *executionFixture {
t.Helper()
f := &executionFixture{t: t, ctx: ctx, db: db, tasks: service.New(NewTaskStore(db))}
err := Bootstrap(ctx, db, BootstrapInput{Application: "worker-" + string(executionID(t)), Namespace: "default", Environment: "test", Queue: "work"}, func(v BootstrapResult) error { f.resources = v; return nil })
in := BootstrapInput{Application: "worker-" + string(executionID(t)), Namespace: "default", Environment: "test", Queue: "work"}
if len(application) > 0 {
in.Application = ""
in.ExistingApplicationID = application[0]
in.Namespace = "ns-" + string(executionID(t))
}
err := Bootstrap(ctx, db, in, func(v BootstrapResult) error { f.resources = v; return nil })
if err != nil {
t.Fatal(err)
}

View File

@@ -59,7 +59,10 @@ func (s *ExecutionStore) Recover(ctx context.Context, limit int) (worker.Recover
out.Examined++
var changed pulse.TaskState
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
// 队列 → 任务 → 尝试 → 许可;忙碌资源留给下一批。
// 应用额度 → 队列 → 任务 → 尝试 → 许可;忙碌资源留给下一批。
if ok, err := lockApplicationResources(ctx, tx, c.ns, true); err != nil || !ok {
return err
}
var queue core.ID
err := tx.QueryRowContext(ctx, `SELECT id FROM pulse_queues WHERE namespace_id=? AND id=? FOR UPDATE SKIP LOCKED`, c.ns, c.queue).Scan(&queue)
if errors.Is(err, sql.ErrNoRows) {

View File

@@ -0,0 +1,32 @@
package httpserver
import (
"net/http"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"git.yuntuo.hk/k2safe/orbit/platform/resources"
)
// RegisterApplicationResources 只操作已认证凭据所属应用,不接受请求体覆盖归属。
func RegisterApplicationResources(server *http.Server, s *resources.Service) {
mux := server.Handler.(*http.ServeMux)
mux.HandleFunc("GET /v1/application/resources", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
if _, err := historyQuery(r, false); err != nil {
return nil, 0, err
}
out, err := s.Get(r.Context(), t)
return out, 200, err
}))
mux.HandleFunc("POST /v1/application/resources:configure", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
var in resources.ConfigureInput
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.Configure(r.Context(), t, key, in)
return out, 200, err
}))
}

View File

@@ -189,7 +189,7 @@ func writeError(w http.ResponseWriter, id string, err error) {
if status == 0 {
status = 503
}
if known.Code == "QUEUE_FULL" {
if known.Code == "QUEUE_FULL" || known.Code == "APPLICATION_FULL" {
status = http.StatusTooManyRequests
w.Header().Set("Retry-After", "1")
}

View File

@@ -50,7 +50,7 @@ func Parse(value string) (Token, error) {
return Token{NamespaceID: ns, CredentialID: id, Digest: core.Digest(sha256.Sum256(secret))}, nil
}
// Permissions 是凭据的授权快照,动作与队列范围必须同时匹配。
// Permissions 是凭据的授权快照;队列动作同时核验队列范围,显式应用动作作用于整个所属应用。
type Permissions struct {
// Actions 明确授权的动作集合,首版不使用动作通配符。
Actions []string `json:"actions"`
@@ -58,7 +58,7 @@ type Permissions struct {
Queues []string `json:"queues"`
}
var supported = map[string]bool{"tasks:submit": true, "tasks:read": true, "tasks:cancel": true, "queues:read": true, "queues:manage": true, "workers:connect": true, "workers:consume": true, "schedules:read": true, "schedules:manage": true}
var supported = map[string]bool{"application:resources:read": true, "application:resources:manage": true, "tasks:submit": true, "tasks:read": true, "tasks:cancel": true, "queues:read": true, "queues:manage": true, "workers:connect": true, "workers:consume": true, "schedules:read": true, "schedules:manage": true}
func (p Permissions) Validate() error {
if len(p.Actions) == 0 || len(p.Actions) > 32 || len(p.Queues) == 0 || len(p.Queues) > 128 {

View File

@@ -0,0 +1,38 @@
package model
import (
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
)
// ApplicationResources 是应用下全部命名空间和队列共享的额度与持久化用量。
// 独立于身份行,避免认证共享锁升级;不使用数据库外键。
type ApplicationResources struct {
// ApplicationID 所属应用标识,同时是主键;应用归属不可在线迁移。
ApplicationID core.ID `db:"application_id"`
// Revision 配置版本从 1 开始;用量变化不修改配置版本。
Revision uint64 `db:"revision"`
// CreatedAt 创建时间,UTC 微秒。
CreatedAt time.Time `db:"created_at"`
// UpdatedAt 最后配置变更时间,UTC 微秒;不是用量采样时间。
UpdatedAt time.Time `db:"updated_at"`
// MaxUnfinishedTasks 未终结任务上限;降额允许当前用量暂时高于上限。
MaxUnfinishedTasks uint32 `db:"max_unfinished_tasks"`
// UnfinishedTasks 等待、预占、运行和待核查的总数;重试不重复占用。
UnfinishedTasks uint64 `db:"unfinished_tasks"`
// MaxConcurrency 允许预占和运行的尝试总数;降额不撤销已有尝试。
MaxConcurrency uint32 `db:"max_concurrency"`
// ActiveAttempts 当前预占或运行的尝试数,到期恢复或结果确认时释放。
ActiveAttempts uint64 `db:"active_attempts"`
// PendingStarts 已预占但未开始的尝试数,属于 ActiveAttempts 的子集。
PendingStarts uint64 `db:"pending_starts"`
// RatePerSecond 一秒固定窗口允许的开始授权次数,不是业务 HTTP 请求限流。
RatePerSecond uint32 `db:"rate_per_second"`
// StartWindowUntil 数据库时间计算的窗口结束时刻;空表示尚未建立窗口。
StartWindowUntil *time.Time `db:"start_window_until"`
// StartsInWindow 当前窗口已确认的开始次数;回执重报不重复计数。
StartsInWindow uint32 `db:"starts_in_window"`
}
func (ApplicationResources) TableName() string { return "orbit_application_resources" }

View File

@@ -0,0 +1,79 @@
// Package resources 管理当前凭据所属应用的共享额度,应用管理动作需单独授权。
package resources
import (
"context"
"strings"
"unicode/utf8"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
// View 返回配置与当前用量;运行计数不参与配置版本。
type View struct {
// ApplicationID 所属应用标识,由凭据归属确定。
ApplicationID core.ID `json:"application_id"`
// Revision 仅配置变更增加的版本;JSON 使用十进制字符串。
Revision uint64 `json:"revision,string"`
// MaxUnfinishedTasks 跨命名空间的未终结任务上限,包含待核查任务。
MaxUnfinishedTasks uint32 `json:"max_unfinished_tasks"`
// UnfinishedTasks 当前未终结任务总数;自动重试不重复占用。
UnfinishedTasks uint64 `json:"unfinished_tasks,string"`
// MaxConcurrency 跨队列的预占及运行尝试总上限。
MaxConcurrency uint32 `json:"max_concurrency"`
// ActiveAttempts 当前预占与运行的尝试数。
ActiveAttempts uint64 `json:"active_attempts,string"`
// PendingStarts 已预占但尚未开始的尝试数,属于 ActiveAttempts。
PendingStarts uint64 `json:"pending_starts,string"`
// RatePerSecond 每秒固定窗口允许的开始授权次数。
RatePerSecond uint32 `json:"rate_per_second"`
}
// ConfigureInput 完整替换三个正整数上限;要求版本和审计原因。
type ConfigureInput struct {
// ExpectedRevision 管理命令预期配置版本,须保留原值重报。
ExpectedRevision uint64 `json:"expected_revision,string"`
// Reason 非空审计原因,最多 1024 个 Unicode 字符。
Reason string `json:"reason"`
// MaxUnfinishedTasks 跨命名空间的未终结任务上限,包含待核查任务。
MaxUnfinishedTasks uint32 `json:"max_unfinished_tasks"`
// MaxConcurrency 跨队列的预占及运行尝试总上限。
MaxConcurrency uint32 `json:"max_concurrency"`
// RatePerSecond 每秒固定窗口允许的开始授权次数。
RatePerSecond uint32 `json:"rate_per_second"`
}
type Result struct {
// Resources 配置成功时的原用量快照,重报不刷新。
Resources View `json:"resources"`
// Deduplicated 是否返回先前已提交的同身份回执。
Deduplicated bool `json:"deduplicated"`
}
type Store interface {
GetApplicationResources(context.Context, auth.Token) (View, error)
ConfigureApplicationResources(context.Context, auth.Token, string, ConfigureInput) (Result, error)
}
type Service struct{ store Store }
func New(store Store) *Service { return &Service{store: store} }
func (s *Service) Get(ctx context.Context, token auth.Token) (View, error) {
return s.store.GetApplicationResources(ctx, token)
}
func (s *Service) Configure(ctx context.Context, token auth.Token, key string, in ConfigureInput) (Result, error) {
if len(key) < 1 || len(key) > 128 {
return Result{}, fault.Invalid("invalid Idempotency-Key")
}
for _, c := range key {
if c < 33 || c > 126 {
return Result{}, fault.Invalid("invalid Idempotency-Key")
}
}
if in.ExpectedRevision == 0 || strings.TrimSpace(in.Reason) == "" || !utf8.ValidString(in.Reason) || utf8.RuneCountInString(in.Reason) > 1024 {
return Result{}, fault.Invalid("reason and nonzero expected_revision are required")
}
if in.MaxUnfinishedTasks < 1 || in.MaxUnfinishedTasks > 100000000 || in.MaxConcurrency < 1 || in.MaxConcurrency > 1000000 || in.RatePerSecond < 1 || in.RatePerSecond > 10000000 {
return Result{}, fault.Invalid("application limits are invalid")
}
return s.store.ConfigureApplicationResources(ctx, token, key, in)
}

View File

@@ -18,7 +18,7 @@ import (
var StaleSession = fault.New("STALE_SESSION", "connection is expired or superseded")
var LeaseLost = fault.New("LEASE_LOST", "attempt no longer grants execution authority")
var GateClosed = fault.New("EXECUTION_PAUSED", "queue or execution gate is paused")
var RateLimited = fault.New("RATE_LIMITED", "queue start limit reached; retry the original start within its lease")
var RateLimited = fault.New("RATE_LIMITED", "application or queue start limit reached; retry the original start within its lease")
type Service struct{ store Store }

View File

@@ -60,8 +60,16 @@ ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap -
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/cancellation)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/resources)
# 应用配额场景使用独立应用与两个命名空间,普通 Worker 凭据不扩权。
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap --application sdk-app-resources --namespace first --environment test --queue first --resource-access --output "$TASK_TEST_DIR/app.credentials.json"
TASK_APP_ID=$(sed -n 's/.*"application_id":"\([^"]*\)".*/\1/p' "$TASK_TEST_DIR/app.credentials.json")
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap --application-id "$TASK_APP_ID" --namespace second --environment test --queue second --resource-access --output "$TASK_TEST_DIR/app-second.credentials.json"
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" ORBIT_TEST_APP_CREDENTIALS_FILE="$TASK_TEST_DIR/app.credentials.json" ORBIT_TEST_APP_SECOND_CREDENTIALS_FILE="$TASK_TEST_DIR/app-second.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationApplicationResourcesSDK$' -v .)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/app.credentials.json" ORBIT_SECOND_CREDENTIALS_FILE="$TASK_TEST_DIR/app-second.credentials.json" "$TASK_GO" run ./examples/application-resources)
# 停止原服务后再做进程故障演练,避免其他恢复扫描器掩盖接管验证。
kill "$TASK_SERVER_PID"
wait "$TASK_SERVER_PID"
TASK_SERVER_PID=
(cd sdk/go && ORBIT_TEST_SERVER_BINARY="$TASK_TEST_DIR/orbit" ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationWorkerProcess' -v .)
(cd sdk/go && ORBIT_TEST_APP_CREDENTIALS_FILE="$TASK_TEST_DIR/app.credentials.json" ORBIT_TEST_APP_SECOND_CREDENTIALS_FILE="$TASK_TEST_DIR/app-second.credentials.json" ORBIT_TEST_SERVER_BINARY="$TASK_TEST_DIR/orbit" ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationWorkerProcess' -v .)

View File

@@ -0,0 +1,55 @@
package orbit
import "context"
// ApplicationResources 是当前应用下所有命名空间共享的配置与用量快照。
// 配置变更需要应用级权限;修改计数不会增加 Revision。
type ApplicationResources struct {
// ApplicationID 所属应用标识,由凭据归属确定。
ApplicationID string `json:"application_id"`
// Revision 仅配置变更增加的版本;JSON 使用十进制字符串。
Revision uint64 `json:"revision,string"`
// MaxUnfinishedTasks 跨命名空间的未终结任务上限,包含待核查任务。
MaxUnfinishedTasks uint32 `json:"max_unfinished_tasks"`
// UnfinishedTasks 当前未终结任务总数;自动重试不重复占用。
UnfinishedTasks uint64 `json:"unfinished_tasks,string"`
// MaxConcurrency 跨队列的预占及运行尝试总上限。
MaxConcurrency uint32 `json:"max_concurrency"`
// ActiveAttempts 当前预占与运行的尝试数。
ActiveAttempts uint64 `json:"active_attempts,string"`
// PendingStarts 已预占但尚未开始的尝试数,属于 ActiveAttempts。
PendingStarts uint64 `json:"pending_starts,string"`
// RatePerSecond 每秒固定窗口允许的开始授权次数。
RatePerSecond uint32 `json:"rate_per_second"`
}
// ConfigureApplicationResourcesRequest 完整替换上限,重复请求必须保留原版本与幂等键。
type ConfigureApplicationResourcesRequest struct {
// ExpectedRevision 管理命令预期配置版本,须保留原值重报。
ExpectedRevision uint64 `json:"expected_revision,string"`
// Reason 非空审计原因,最多 1024 个 Unicode 字符。
Reason string `json:"reason"`
// MaxUnfinishedTasks 跨命名空间的未终结任务上限,包含待核查任务。
MaxUnfinishedTasks uint32 `json:"max_unfinished_tasks"`
// MaxConcurrency 跨队列的预占及运行尝试总上限。
MaxConcurrency uint32 `json:"max_concurrency"`
// RatePerSecond 每秒固定窗口允许的开始授权次数。
RatePerSecond uint32 `json:"rate_per_second"`
}
type ConfigureApplicationResourcesResult struct {
// Resources 配置成功时的原用量快照,重报不刷新。
Resources ApplicationResources `json:"resources"`
// Deduplicated 是否返回先前已提交的同身份回执。
Deduplicated bool `json:"deduplicated"`
}
func (c *Client) GetApplicationResources(ctx context.Context) (ApplicationResources, error) {
var out ApplicationResources
err := c.call(ctx, "GET", "/v1/application/resources", "", nil, &out)
return out, err
}
func (c *Client) ConfigureApplicationResources(ctx context.Context, key string, in ConfigureApplicationResourcesRequest) (ConfigureApplicationResourcesResult, error) {
var out ConfigureApplicationResourcesResult
err := c.call(ctx, "POST", "/v1/application/resources:configure", key, in, &out)
return out, err
}

View File

@@ -0,0 +1,188 @@
package orbit
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"time"
)
func applicationIntegrationClient(t *testing.T, key string) (*Client, string) {
t.Helper()
base := os.Getenv("ORBIT_TEST_WORKER_URL")
path := os.Getenv(key)
if base == "" || path == "" {
t.Skip("requires disposable application resource fixtures")
}
if !strings.HasPrefix(base, "http://127.0.0.1:") {
t.Fatal("requires isolated loopback server")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var v struct {
Token string `json:"token"`
QueueID string `json:"queue_id"`
}
if json.Unmarshal(raw, &v) != nil || v.Token == "" || !validID(v.QueueID) {
t.Fatal("invalid private credentials")
}
api, err := NewClient(base, Options{Token: v.Token})
if err != nil {
t.Fatal(err)
}
return api, v.QueueID
}
func TestIntegrationApplicationResourcesSDK(t *testing.T) {
a, qa := applicationIntegrationClient(t, "ORBIT_TEST_APP_CREDENTIALS_FILE")
b, qb := applicationIntegrationClient(t, "ORBIT_TEST_APP_SECOND_CREDENTIALS_FILE")
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
before, err := a.GetApplicationResources(ctx)
if err != nil {
t.Fatal(err)
}
peer, err := b.GetApplicationResources(ctx)
if err != nil || peer.ApplicationID != before.ApplicationID || qa == qb {
t.Fatal("invalid cross namespace fixture", err)
}
ordinary, _, _ := integrationWorkerClient(t)
if _, err := ordinary.GetApplicationResources(ctx); err == nil {
t.Fatal("queue admin inherited application permission")
} else {
var ae *APIError
if !errors.As(err, &ae) || ae.StatusCode != 403 {
t.Fatal(err)
}
}
key := workerTestID(t)
in := ConfigureApplicationResourcesRequest{ExpectedRevision: before.Revision, Reason: "SDK application quota regression", MaxConcurrency: 2, RatePerSecond: 10, MaxUnfinishedTasks: 1}
configured, err := a.ConfigureApplicationResources(ctx, key, in)
if err != nil {
t.Fatal(err)
}
replay, err := a.ConfigureApplicationResources(ctx, key, in)
if err != nil || !replay.Deduplicated || replay.Resources.Revision != configured.Resources.Revision {
t.Fatal("configure replay failed", err)
}
if _, err := b.ConfigureApplicationResources(ctx, key, in); err == nil {
t.Fatal("same key in different namespace replayed authority")
} else {
var ae *APIError
if !errors.As(err, &ae) || ae.Code != "STATE_CONFLICT" {
t.Fatal(err)
}
}
var ignored ConfigureApplicationResourcesResult
for _, body := range []any{
map[string]any{"expected_revision": "2", "reason": "missing limits"},
map[string]any{"expected_revision": 2, "reason": "bad number", "max_unfinished_tasks": 1, "max_concurrency": 1, "rate_per_second": 1},
map[string]any{"expected_revision": "2", "reason": "override application", "max_unfinished_tasks": 1, "max_concurrency": 1, "rate_per_second": 1, "application_id": before.ApplicationID},
} {
if err := a.call(ctx, "POST", "/v1/application/resources:configure", workerTestID(t), body, &ignored); err == nil {
t.Fatal("invalid configuration accepted")
} else {
var ae *APIError
if !errors.As(err, &ae) || ae.StatusCode != 400 {
t.Fatal(err)
}
}
}
input := SubmitTaskRequest{QueueID: qa, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"n":9007199254740993}`)}
first, err := a.SubmitTask(ctx, key+"-one", input)
if err != nil {
t.Fatal(err)
}
input.QueueID = qb
_, err = b.SubmitTask(ctx, key+"-two", input)
var ae *APIError
if !errors.As(err, &ae) || ae.Code != "APPLICATION_FULL" || ae.StatusCode != 429 || ae.RetryAfter != time.Second {
t.Fatal("missing application backpressure", err)
}
peer, err = b.GetApplicationResources(ctx)
if err != nil || peer.UnfinishedTasks != 1 || peer.Revision != configured.Resources.Revision {
t.Fatal(peer, err)
}
if _, err := a.CancelTask(ctx, first.Task.ID, key+"-cancel", CancelTaskRequest{ExpectedRevision: 1, Reason: "free shared slot"}); err != nil {
t.Fatal(err)
}
second, err := b.SubmitTask(ctx, key+"-two", input)
if err != nil {
t.Fatal(err)
}
if _, err := b.CancelTask(ctx, second.Task.ID, key+"-cancel-two", CancelTaskRequest{ExpectedRevision: 1, Reason: "finish regression"}); err != nil {
t.Fatal(err)
}
after, err := a.GetApplicationResources(ctx)
if err != nil || after.UnfinishedTasks != 0 || after.ActiveAttempts != 0 || after.PendingStarts != 0 {
t.Fatal(after, err)
}
if _, err := a.ConfigureApplicationResources(ctx, key+"-restore", ConfigureApplicationResourcesRequest{ExpectedRevision: configured.Resources.Revision, Reason: "restore sample defaults", MaxUnfinishedTasks: before.MaxUnfinishedTasks, MaxConcurrency: before.MaxConcurrency, RatePerSecond: before.RatePerSecond}); err != nil {
t.Fatal(err)
}
}
func TestIntegrationWorkerProcessApplicationBackpressure(t *testing.T) {
p := startOrbitProcess(t)
ca, qa := applicationIntegrationClient(t, "ORBIT_TEST_APP_CREDENTIALS_FILE")
cb, qb := applicationIntegrationClient(t, "ORBIT_TEST_APP_SECOND_CREDENTIALS_FILE")
a := processClient(t, p, ca.token)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
before, err := a.GetApplicationResources(ctx)
if err != nil || before.UnfinishedTasks != 0 {
t.Fatal("dedicated app must be idle", err)
}
key := workerTestID(t)
changed, err := a.ConfigureApplicationResources(ctx, key, ConfigureApplicationResourcesRequest{ExpectedRevision: before.Revision, Reason: "application crash regression", MaxUnfinishedTasks: 1, MaxConcurrency: 1, RatePerSecond: 1})
if err != nil {
t.Fatal(err)
}
input := SubmitTaskRequest{QueueID: qa, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)}
first, err := a.SubmitTask(ctx, key+"-first", input)
if err != nil {
t.Fatal(err)
}
p.kill(t)
next := startOrbitProcess(t)
if next.nodeID == p.nodeID {
t.Fatal("node identity reused")
}
a = processClient(t, next, ca.token)
b := processClient(t, next, cb.token)
v, err := b.GetApplicationResources(ctx)
if err != nil || v.Revision != changed.Resources.Revision || v.UnfinishedTasks != 1 || v.MaxUnfinishedTasks != 1 {
t.Fatal("restart lost shared configuration or usage", v, err)
}
if out, err := a.SubmitTask(ctx, key+"-first", input); err != nil || !out.Deduplicated || out.Task.ID != first.Task.ID {
t.Fatal("restart lost admission receipt", err)
}
input.QueueID = qb
_, err = b.SubmitTask(ctx, key+"-second", input)
var rejected *APIError
if !errors.As(err, &rejected) || rejected.Code != "APPLICATION_FULL" {
t.Fatal("restart allowed another namespace to exceed quota", err)
}
if _, err := a.CancelTask(ctx, first.Task.ID, key+"-cancel-first", CancelTaskRequest{ExpectedRevision: 1, Reason: "release after restart"}); err != nil {
t.Fatal(err)
}
second, err := b.SubmitTask(ctx, key+"-second", input)
if err != nil {
t.Fatal(err)
}
if _, err := b.CancelTask(ctx, second.Task.ID, key+"-cancel-second", CancelTaskRequest{ExpectedRevision: 1, Reason: "finish process regression"}); err != nil {
t.Fatal(err)
}
v, err = a.GetApplicationResources(ctx)
if err != nil || v.UnfinishedTasks != 0 || v.ActiveAttempts != 0 || v.PendingStarts != 0 {
t.Fatal("release after restart leaked usage", err)
}
if _, err := a.ConfigureApplicationResources(ctx, key+"-restore", ConfigureApplicationResourcesRequest{ExpectedRevision: changed.Resources.Revision, Reason: "restore defaults", MaxUnfinishedTasks: before.MaxUnfinishedTasks, MaxConcurrency: before.MaxConcurrency, RatePerSecond: before.RatePerSecond}); err != nil {
t.Fatal(err)
}
t.Log("server killed and exited; new node preserved shared quota and receipt; second namespace blocked until original task cancelled")
}

View File

@@ -0,0 +1,101 @@
// application-resources 演示同一应用的两个命名空间共享积压额度。
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func client(path string) (*orbit.Client, string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, "", err
}
var v struct {
Token string `json:"token"`
QueueID string `json:"queue_id"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return nil, "", err
}
c, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: v.Token})
return c, v.QueueID, err
}
func run() (err error) {
a, qa, err := client(os.Getenv("ORBIT_CREDENTIALS_FILE"))
if err != nil {
return err
}
b, qb, err := client(os.Getenv("ORBIT_SECOND_CREDENTIALS_FILE"))
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
before, err := a.GetApplicationResources(ctx)
if err != nil {
return err
}
peer, err := b.GetApplicationResources(ctx)
if err != nil {
return err
}
if before.ApplicationID != peer.ApplicationID || qa == qb || before.UnfinishedTasks != 0 {
return errors.New("use two namespaces in the same idle, dedicated example application")
}
op, err := orbit.NewWorkerMessageID()
if err != nil {
return err
}
config, err := a.ConfigureApplicationResources(ctx, "config-"+op, orbit.ConfigureApplicationResourcesRequest{ExpectedRevision: before.Revision, Reason: "cross namespace resource example", MaxUnfinishedTasks: 1, MaxConcurrency: 2, RatePerSecond: 10})
if err != nil {
return err
}
defer func() {
cleanup, stop := context.WithTimeout(context.Background(), 8*time.Second)
defer stop()
_, restoreErr := a.ConfigureApplicationResources(cleanup, "restore-"+op, orbit.ConfigureApplicationResourcesRequest{ExpectedRevision: config.Resources.Revision, Reason: "restore example configuration", MaxUnfinishedTasks: before.MaxUnfinishedTasks, MaxConcurrency: before.MaxConcurrency, RatePerSecond: before.RatePerSecond})
err = errors.Join(err, restoreErr)
}()
input := orbit.SubmitTaskRequest{QueueID: qa, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"object_ref":"objects/example"}`), TaskTimeoutMS: 20000, Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 10000}}
first, err := a.SubmitTask(ctx, "first-"+op, input)
if err != nil {
return err
}
input.QueueID = qb
_, err = b.SubmitTask(ctx, "second-"+op, input)
var full *orbit.APIError
if !errors.As(err, &full) || full.Code != "APPLICATION_FULL" {
return fmt.Errorf("expected APPLICATION_FULL, got %v", err)
}
fmt.Printf("cross_namespace_backpressure=%s http=%d\n", full.Code, full.StatusCode)
if _, err := a.CancelTask(ctx, first.Task.ID, "cancel-first-"+op, orbit.CancelTaskRequest{ExpectedRevision: 1, Reason: "free shared slot"}); err != nil {
return err
}
second, err := b.SubmitTask(ctx, "second-"+op, input)
if err != nil {
return err
}
fmt.Printf("same_identity_retry_accepted=%t\n", second.Task.State == orbit.TaskWaiting)
if _, err := b.CancelTask(ctx, second.Task.ID, "cancel-second-"+op, orbit.CancelTaskRequest{ExpectedRevision: 1, Reason: "complete example"}); err != nil {
return err
}
after, err := a.GetApplicationResources(ctx)
if err != nil {
return err
}
fmt.Printf("unfinished=%d active=%d pending_starts=%d\n", after.UnfinishedTasks, after.ActiveAttempts, after.PendingStarts)
return nil
}