feat: add bounded task priority and fair application dispatch

This commit is contained in:
2026-09-17 18:45:54 +08:00
parent 958b4a8cea
commit 309ed75666
42 changed files with 1277 additions and 61 deletions

View File

@@ -6,6 +6,7 @@
- SQL 数据表和字段同步维护中文 COMMENT;已应用的迁移不得回改,后续演进新增前向迁移。
- 数据库禁止外键及级联删除。关联关系由服务端在同一事务中按命名空间校验;保留查询索引、唯一约束和 CHECK。历史外键由 000008 前向移除,后续迁移不得重新引入,真实 MySQL 回归必须断言外键为 0。
- Pulse 涉及用量的事务先锁应用额度再锁队列,恢复和调度遵守同一顺序;不得在已锁队列后新增应用额度锁。应用额度与身份行分离,不能升级认证共享锁。
- 优先级冻结为 0–9,每级30秒排序优势;dispatch_at 是数据库生成排序字段,不能替代 available_at 的实际就绪检查。节点按认证应用与队列轮转领取,内存轮转/空探测缓存不得授予执行权或改变持久化配额。
- 状态变更、租约、门控、许可、事件及审计必须按设计的事务边界实现,不用内存状态代替持久化确认。
- 研发任务持续更新 docs/08-development-plan.md。接口、SDK、可运行示例和对应回归随功能同步交付。
- 模型/迁移变更运行真实 MySQL 集成测试;执行和恢复变更覆盖竞争、重复、迟到及故障场景。以实际测试报告判断完成,不把设计文档当作已实现能力。

View File

@@ -8,7 +8,7 @@ Orbit 是面向多个业务项目的通用任务调度与流程编排平台,
## 当前进度
已完成 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) 和 [资源键互斥](docs/api/keyed-concurrency.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) 、[资源键互斥](docs/api/keyed-concurrency.md) 和 [优先级/节点公平派发](docs/api/priority-and-fairness.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

@@ -1,6 +1,6 @@
# 数据模型与实现基线
更新:2026-09-17。数据库统一禁止外键。当前已实现任务执行、安全重试、运行中取消、计划调度、队列限速/背压和应用配额;公平性、订阅及流程编排继续开发。
更新:2026-09-17。数据库统一禁止外键。当前已实现任务执行、安全重试、运行中取消、计划调度、队列限速/背压和应用配额;优先级及节点公平领取已实现;订阅及流程编排继续开发。
## 1. 模块与依赖
@@ -135,8 +135,14 @@ P2 服务端已新增会话隔离、两步派发、租约、并发许可、结
## 13. 显式资源键许可
000011_keyed_concurrency 增加 Task.ConcurrencyKey / ConcurrencyKeyDigest、Attempt.ConcurrencyToken、WorkerSession.ConcurrencyKeys,共四个中文注释与 SQL COMMENT 字段。历史值分别为空/空、零和 false,原 BusinessKey 语义不变;33 个实体、273 个直接声明的持久化字段,最终外键数保持零。
000011_keyed_concurrency 增加 Task.ConcurrencyKey / ConcurrencyKeyDigest、Attempt.ConcurrencyToken、WorkerSession.ConcurrencyKeys,共四个中文注释与 SQL COMMENT 字段。历史值分别为空/空、零和 false,原 BusinessKey 语义不变;截至 000011 为 33 个实体、273 个直接声明的持久化字段,最终外键数保持零。
资源限制复用 ConcurrencyLimit:scope_key=key,key_digest 为域分隔 SHA-256,命名空间内唯一;每键最大许可固定 1。原始 UTF-8 字节精确匹配,不依赖 MySQL 的文本排序规则。一个有键尝试持有队列和资源键两条 Permit,应用活跃尝试只计一次;续租、释放、恢复与状态同事务完成。
键行 Revision 在领取和释放时递增,领取后的值保存到 Attempt.ConcurrencyToken;同尝试重连不改变序号,资源行不得删除或重置。达到整数边界时拒绝新领取,禁止回绕。SDK 交付序号,资源端负责原子比较与业务幂等;历史未知效果与执行许可释放分别维护。字段、升级、调用和异常语义见 [资源键契约](api/keyed-concurrency.md)。
## 14. 任务优先级和节点公平领取
000012_dispatch_priority 开放既有 Priority,范围 0–9,并增加对应 CHECK。新增只读生成列 DispatchAt(AvailableAt 减 Priority 乘30秒)及 `(namespace_id,queue_id,state,dispatch_at,available_at,id)` 排序索引;历史默认优先级 0 的排序时间等于 AvailableAt。Generated 字段随退避和恢复自动重新计算,不允许独立写入。33 个实体、274 个直接声明的持久化字段全部保留中文注释,新增 SQL 字段和 Priority 的 COMMENT 同步更新,无外键。
派发先检查实际到期、截止、能力、门控和资源键,再按排序时间选择。节点公平领取池根据认证后的 ApplicationID 分组,再按命名空间/队列轮转;内存队列和短期空探测缓存可在重启后重建,不保存授权事实。详细约束、SDK 和升级步骤见 [优先级与公平派发](api/priority-and-fairness.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 按真实拓扑验证 | 待开发 |
@@ -47,6 +47,7 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最
| 队列限速与背压 | 暂停 → 满额拒绝 → 原回执核对 → 释放空间 → 限速执行 | P3,已实现完整示例 |
| 跨命名空间应用配额 | first 受理 → second 全局满额 → 释放 → 原键成功 → 恢复配置 | P3,已实现完整示例 |
| 跨队列共享资源 | 两队列同键 → 一个有效许可 → 递增序号 → 资源端拒绝旧写入 | P3,已实现完整示例 |
| 优先级与应用隔离 | 近同时到期按优先级 → 旧任务等待补偿 → 应用/队列轮转 | P3,已实现完整示例 |
| 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4 |
| 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4 |
| 外部作业轮询 | 提交一次 → 有界查询处理中状态 → 回执或最终结果 → 超限核查 | P5 |
@@ -86,6 +87,7 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最
- [P3 队列资源控制回归](reports/phase-3-queue-resources.md)
- [P3 应用共享配额回归](reports/phase-3-application-resources.md)
- [P3 资源键许可回归](reports/phase-3-keyed-concurrency.md)
- [P3 优先级和公平派发回归](reports/phase-3-priority-fairness.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

@@ -14,6 +14,7 @@
| `/v1/queues` 创建、查询及 `:configure` 控制 | 已实现队列并发、开始速率、积压背压与暂停/恢复 | [队列接口与 SDK 场景](queues.md) |
| `/v1/application/resources` 查询及 `:configure` 管理 | P3 已实现跨命名空间应用配额 | [应用配额与 SDK 场景](application-resources.md) |
| 任务 `concurrency_key` 与尝试 `concurrency_token` | P3 已实现跨队列资源键互斥 | [资源键接口与 SDK 场景](keyed-concurrency.md) |
| 任务 `priority` 与节点领取池 | P3 已实现有界优先级加速、应用/队列轮转 | [优先级与公平派发](priority-and-fairness.md) |
| 凭据校验与初始化 CLI | P1 已实现 | [鉴权](authentication.md) |
| `/v1/worker/connect` | P2 已实现并通过阶段验收 | [Worker 契约](worker.md)、[Schema](worker-schema.md) |
| 任务冻结策略与 Worker `retryable` | P3 安全重试已实现 | [安全重试](retries.md) |

View File

@@ -158,4 +158,4 @@ unfinished=0 active=0 pending_starts=0
统一顺序为认证 → Worker 会话(需要时)→ 应用额度 → 队列 → 任务/尝试 → 许可。额度与身份分表,避免把认证共享锁升级为排他锁。恢复和调度对忙碌额度行使用 SKIP LOCKED,保留后续扫描;不采用全表计数参与每次领取。额度配置审计记录在发起凭据的命名空间,应用标识明确保存。
同一应用共享一条额度行,严格额度会使该应用的相关短事务串行化。当前范围是同一 MySQL 主库;未宣称跨库原子配额或百万租户性能,后续容量验证需测单个高流量应用的锁等待、租约续期和恢复延迟。业务键许可、公平派发、可靠订阅与完整生产容量/故障验收继续按开发计划推进。真实验证记录见 [应用配额回归](../reports/phase-3-application-resources.md)。
同一应用共享一条额度行,严格额度会使该应用的相关短事务串行化。当前范围是同一 MySQL 主库;未宣称跨库原子配额或百万租户性能,后续容量验证需测单个高流量应用的锁等待、租约续期和恢复延迟。[资源键许可](keyed-concurrency.md) 和 [优先级/节点公平领取](priority-and-fairness.md) 已接入;可靠订阅与完整生产容量/故障验收继续按开发计划推进。真实验证记录见 [应用配额回归](../reports/phase-3-application-resources.md)。

View File

@@ -114,4 +114,4 @@ resource_writes=2 stale_write_rejected=true worker_drained=true
000011 为历史任务填空键/空摘要、历史尝试填零序号、历史会话填 false,不改变原 business_key。新增四个模型字段均有中文注释和 SQL COMMENT,实体数仍为 33;数据库最终外键数为零。
当前基于一个 MySQL 主库及应用额度锁串行化事务,资源键记录随不同键数量持续增长。尚未提供可调的每键并发大于 1、资源管理/清理接口、优先级公平性或生产容量结论。验证证据见 [本阶段报告](../reports/phase-3-keyed-concurrency.md)。
当前基于一个 MySQL 主库及应用额度锁串行化事务,资源键记录随不同键数量持续增长。尚未提供可调的每键并发大于 1、资源管理/清理接口、生产容量结论;[优先级与节点公平派发](priority-and-fairness.md) 已补齐。验证证据见 [本阶段报告](../reports/phase-3-keyed-concurrency.md)。

View File

@@ -0,0 +1,117 @@
# 任务优先级与公平派发
状态:P3 已实现。优先级使用持久化任务字段,节点公平派发控制领取机会;任务执行权、应用配额与两类并发许可仍由 MySQL 事务决定。平台不增加缓存或消息队列服务。
## 1. 任务优先级
在 [POST /v1/tasks](tasks.md) 或 [计划版本的 definition.task](schedules.md) 中指定:
| 字段 | 类型 / 默认 | 含义 |
| --- | --- | --- |
| priority | 可选 JSON 整数,默认 0,范围 0–9 | 受理时固定的任务优先级;每级最多取得 30 秒排序优势 |
负数、超过 9、浮点表示、字符串、bool 和 null 均返回 HTTP 400 / INVALID_ARGUMENT。Go SDK 对应 `Priority int16`。零值省略以保留旧请求摘要;任务详情、摘要和 Worker 派发在非零时返回 priority。查询到零或省略都表示普通优先级。
权限、请求头、提交结果、受理幂等、取消和截止规则沿用任务接口,没有单独的优先级管理权限或运行中改级接口。相同受理键改变 priority 返回 409 IDEMPOTENCY_CONFLICT。安全重试保持冻结优先级,下一次 available_at 改变时重新计算排序时间;计划触发继承对应不可变版本里的 priority。
```json
{
"queue_id":"11111111111111111111111111111111",
"handler":"object.prepare",
"handler_version":"v1",
"payload_version":"v1",
"payload":{"object_ref":"objects/example","operation_ref":"prepare-001"},
"priority":9
}
```
成功响应仍是任务接口的完整信封,首次 201、重复受理 200;任务对象包含 `"priority":9`。提交确认不代表已执行。
## 2. 排序与等待补偿
同一队列、与当前 Worker 版本和能力匹配且满足门控/租约/许可条件的到期任务,按下面三个键升序排列:
1. `dispatch_at = available_at - priority × 30 秒`。
2. available_at。
3. task_id(只用于确定性打破同时间相等,不表示业务顺序)。
dispatch_at 为数据库生成的只读字段,并有相应派发索引;它不对外提供执行授权。真正的最早执行时间始终是 available_at,派发仍检查截止、次数、门控、能力和资源键占用。
| 两条已就绪任务 | 先选择的任务 |
| --- | --- |
| A:0 级、5 秒前到期;B:9 级、刚到期 | B,获得 270 秒排序优势 |
| A:0 级、271 秒前到期;B:9 级、刚到期 | A,旧任务的等待已超过最大优势 |
| A:0 级、已到期;B:9 级、1 小时后才到期 | A,B 不具备领取资格 |
| 同一优先级、不同 available_at | 更早到期者 |
由此限制新高优先级工作插队的时间优势,避免纯粹按优先级排序造成持续饥饿。**270 秒不是最大排队时长**:旧积压、队列/应用额度、兼容 Worker、暂停、共享资源和每个任务执行时长仍影响等待。已被预占或正在运行的任务不被抢占;没有全局 FIFO 或跨队列优先级比较。
## 3. 节点内应用与队列轮转
Worker 连接不再各自直接并发发起一批数据库领取。节点采用有界领取池:
| 约束 | 当前开发基线 |
| --- | --- |
| 同时执行的领取 SQL 操作 | 每节点最多 4 个 |
| 一个应用同时在途的领取 | 每节点最多 1 个,其他应用可以继续领取 |
| 待处理和在途领取请求合计 | 每节点最多 128 个 |
| 每个连接未完成领取请求 | 最多 1 个 |
| 选择顺序 | 活跃应用轮转 → 该应用的队列轮转 → 队列内连接请求顺序 |
| 单个连接派发节奏 | 每个扫描周期最多 4 次领取,当前周期 1 秒 |
应用身份来自服务端验证过的命名空间归属,客户端不能通过增加命名空间或伪造应用字段分散公平组。一个应用增加队列或 Worker 数量,也不会在所有应用都持续有待处理请求时得到额外轮次。某个应用没有请求或已有在途领取时,其他应用继续使用空闲池容量。
同命名空间、队列、精确处理器能力集合及资源键支持声明的空领取结果,在本节点合并一个扫描周期,缓存最多 128 项。不同能力不共用空结果。缓存过期重新查询;任务新到达或容量释放可能多等待一个周期。这只抑制重复探测,不能据此授权任务或更改持久化状态。
领取在后台有界执行,WS 控制循环继续接收心跳、Start、续租、Finish 和排空。关闭节点时取消待领取操作并等待在途收尾;已提交但尚未发送的预占仍由数据库租约恢复。任务处理器运行在业务 Worker,完全不占这个 SQL 领取池。
这是**节点内领取机会的公平性**。各节点分别轮转,没有宣称跨节点完全相等吞吐、全局权重或严格最大延迟。跨节点执行并发和开始额度仍由已实现的 [应用配额](application-resources.md) 与 [队列控制](queues.md) 原子保证。连接接入总上限仍为每节点 128;该机制不提供每应用连接预留,也不代替请求层限流或生产容量规划。
## 4. SDK 完整场景
源码:[priority/main.go](../../sdk/go/examples/priority/main.go)。创建专用队列,先按 0、9、4 的顺序提交三条任务,再启动容量为 1 的自动 Worker;三条任务在近同时到期的条件下按 9、4、0 执行,每条只有一次尝试。
从仓库根目录准备专用开发数据库,设置 ORBIT_MYSQL_DSN 和 `ORBIT_HTTP_ADDR=127.0.0.1:8080` 后:
```sh
go run ./cmd/orbit-migrate up
go run ./cmd/orbit-admin bootstrap --application priority-example --namespace default \
--environment test --queue initial --worker-access --output /tmp/orbit-priority.credentials.json
go run ./cmd/orbit
```
凭据需要全部队列范围的 queues:manage、tasks:submit/read、workers:connect/consume。另一个终端执行:
```sh
cd sdk/go
ORBIT_URL=http://127.0.0.1:8080 \
ORBIT_CREDENTIALS_FILE=/tmp/orbit-priority.credentials.json go run ./examples/priority
```
预期输出:
```text
submitted_priorities=0,9,4 executed_priorities=9,4,0
completed=3 attempts_per_task=1 worker_drained=true
```
示例返回输入对象及精确大整数,不访问外部业务资源。总任务截止为 20 秒,整个场景 25 秒;每次创建独立队列/处理器与任务,正常结束排空,历史保留。异常非零退出并有界收尾。这个短场景演示优先级加速,等待补偿的 271 秒边界由真实数据库夹具验证,不让读者实际等待几分钟。
调用片段(上下文、客户端和队列已初始化):
```go
accepted, err := api.SubmitTask(ctx, "prepare-operation-001", orbit.SubmitTaskRequest{
QueueID: queueID, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1",
Payload: json.RawMessage(`{"object_ref":"objects/example"}`), Priority: 9,
})
```
在处理器中通过 `e.Task.Priority` 读取冻结值;调度顺序由平台确定,业务 Worker 不应绕过 Start 或自行重排已授权任务。
## 5. 升级与验收边界
停止旧写入节点,应用 000012,再启新版服务。历史任务优先级为 0,生成排序时间等于原 available_at;既有任务、索引和历史保留,新增索引单独建立。优先级限制附 CHECK,generated 字段与 Go 模型均有中文说明,数据库最终仍无外键。
新版 SDK 开始提交非零 priority 前,先升级服务端;旧服务严格拒绝未知输入字段。公开 Worker Schema 已扩展可选 priority,其他语言的严格验证器需同步升级;默认 0 的派发报文保持原形状。
单节点公平领取及真实 MySQL/WSS 故障隔离已纳入回归。多节点不均匀连接分布、混合负载下各应用延迟、SQL 锁等待、海量未来任务扫描及峰值容量在 P6 验收,当前参数不能作为百万租户容量承诺。完整记录见 [本阶段报告](../reports/phase-3-priority-fairness.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)。[应用级跨命名空间配额](application-resources.md) 已实现并同时生效。仍需同业务键许可、可配置优先级及应用间公平性、可靠事件积压限制和容量验收;不能用单队列控制替代这些要求。
本阶段验证见 [资源控制报告](../reports/phase-3-queue-resources.md)。[应用级跨命名空间配额](application-resources.md) 已实现并同时生效。[资源键许可](keyed-concurrency.md) 与 [优先级/节点公平派发](priority-and-fairness.md) 已实现;可靠事件积压限制及跨节点公平负载和容量验收继续推进。

View File

@@ -25,6 +25,7 @@ P2 进展:服务端执行内核、WSS 与独立自动 Worker SDK 已可产生
| payload | JSON | 必填 | 非 null JSON,最大 256 KiB;平台不解释业务字段 |
| business_key | string | 可选 | 最多 191 个 Unicode 字符,无 NUL/换行;用于关联,不提供业务幂等 |
| concurrency_key | string | 可选,空为关闭 | 1–191 个 Unicode 字符,无控制字符和首尾空白;显式资源互斥键,同命名空间跨队列共享,见 [资源键契约](keyed-concurrency.md) |
| priority | 整数 | 可选,默认 0 | 0–9,冻结优先级,每级30秒排序优势;不提前延时任务,见 [优先级与公平派发](priority-and-fairness.md) |
| available_at | time / null | 受理时数据库时间 | 最早可执行时间;必须早于总截止时间,仅允许最多 1 秒的过去时间容差 |
| start_deadline_at | time / null | 无独立限制 | 必须晚于当前时间,不早于 available_at,不晚于总截止时间 |
| task_timeout_ms | uint64 | 0 或省略为 86400000 | 从受理时计算的总时限,最大 30 天(2592000000 毫秒) |

View File

@@ -35,6 +35,7 @@ ACK 的 body 根据原请求选择:worker.capacity → welcome,worker.resume
- hello.concurrency_keys 可选且默认 false,未声明时不派发带资源键任务;task.assign.task.concurrency_key 与非零字符串 concurrency_token 必须同时出现。Schema 检查字段配对,SDK 另核验支持声明和键格式,事务维护许可;见 [资源键契约](keyed-concurrency.md)。
- finish.retryable 为可选布尔值,默认 false;true 仅允许 FAILED,Schema 与服务端都校验。派发任务可带 effect_uncertain=true,表示此前授权执行的效果尚未核实。业务重试条件见 [安全重试](retries.md)。
- 返回时间使用 UTC RFC 3339,最多微秒精度。验证器应开启 format 断言以校验实际日历日期。
- task.assign.task.priority 为可选 0–9 整数,零值省略;平台在实际到期后结合等待时间排序,详见 [优先级与公平派发](priority-and-fairness.md)。
P2 收紧了开发期此前允许的省略控制字段、非规范计数字符串和数组内字段别名。当前 SDK 一直发送完整规范字段,不需迁移;其他自研客户端应按本文件更新。SDK 尚未发布首个版本。将来扩展消息类型或改变字段语义必须同时更新 Schema、服务端、SDK 与兼容回归。

View File

@@ -130,7 +130,7 @@ receipt 是原操作的不可变确认;lease 是本次重新核验的执行权
平台在同一事务中预占应用并发与开始额度、分配队列并发许可、创建尝试、递增任务执行代次和次数、保存 LEASED 与派发事件。领取本身消耗一次 max_attempts 预算,包括最终未获开始授权的尝试,预算不会因重连或过期恢复重置。
运行中的进程总容量跨其所有旧/新会话统计。声明容量减少、断开连接或重复结果,都不能重复释放额度。已实施队列 max_concurrency、rate_per_second 及未终结任务上限;参数含义、窗口突发、暂停及降额语义见 [队列资源控制](queues.md)。[应用级配额](application-resources.md) 与 [显式资源键许可](keyed-concurrency.md) 同时生效;公平性继续推进。
运行中的进程总容量跨其所有旧/新会话统计。声明容量减少、断开连接或重复结果,都不能重复释放额度。已实施队列 max_concurrency、rate_per_second 及未终结任务上限;参数含义、窗口突发、暂停及降额语义见 [队列资源控制](queues.md)。[应用级配额](application-resources.md) 与 [显式资源键许可](keyed-concurrency.md) 同时生效;[任务优先级和节点公平领取](priority-and-fairness.md) 已接入。
同命名空间的 concurrency_key 跨队列互斥,领取事务同时保存键许可和 concurrency_token;原 business_key 仅追踪。没有声明 concurrency_keys 的 Worker 不领取带键任务。资源端需用单调序号和操作幂等保护实际写入,平台无法撤回外部已发出的请求;未知结果释放执行许可并保留核查状态,详细边界和升级顺序见资源键契约。
@@ -147,8 +147,8 @@ receipt 是原操作的不可变确认;lease 是本次重新核验的执行权
## 6. 连接保护与当前边界
当前节点上限 128 条 Worker 连接;每连接入站队列 4 帧、出站队列 8 帧,派发保留 ACK 缓冲空间,每秒最多发起 4 次新派发尝试。读空闲期限 60 秒、写期限 5 秒、单个数据库控制操作期限 8 秒。慢消费者导致连接关闭时,已提交状态仍由稳定身份恢复。
当前节点上限 128 条 Worker 连接;每连接入站队列 4 帧、出站队列 8 帧,派发保留 ACK 缓冲空间,每个扫描周期最多发起 4 次新派发尝试;节点总领取池并发 4、待处理与在途总数 128、每应用在途领取最多 1,按应用及队列轮转,同能力空探测合并一个周期。读空闲期限 60 秒、写期限 5 秒、单个数据库控制操作期限 8 秒。慢消费者导致连接关闭时,已提交状态仍由稳定身份恢复。
这些是有界的开发基线,不代表经过容量压测的生产参数。当前同队列操作使用较粗的行锁协调,吞吐与公平性需要 P6 按负载验证。服务退出会显式关闭被 HTTP Upgrade 接管的连接;断开不会直接撤销外部已发出的请求。
这些是有界的开发基线,不代表经过容量压测的生产参数。当前同队列操作使用较粗的行锁协调,节点内公平领取已实现;吞吐、多节点连接分布和应用延迟隔离需要 P6 按负载验证。服务退出会显式关闭被 HTTP Upgrade 接管的连接;断开不会直接撤销外部已发出的请求。
协议 Schema 和真实进程强杀恢复已通过回归,见 [P2 阶段验收](../reports/phase-2.md)。[运行中取消](cancellation.md) 已实现并通过回归;凭据轮换、数据库故障切换、跨主机演练与容量验收属于后续生产验证。自动运行器对非协作处理器提供有界退出与状态报告,不能强制停止其外部副作用。

View File

@@ -92,6 +92,8 @@
## 计划场景与验收标准
已可运行的 [优先级场景](../api/priority-and-fairness.md#4-sdk-完整场景) 提供 [完整源码](../../sdk/go/examples/priority/main.go):按 0、9、4 受理后,以单槽 Worker 按 9、4、0 完成近同时到期的任务。前置为当前迁移/服务与队列、任务及 Worker 权限;预期 completed=3、attempts_per_task=1、worker_drained=true。无外部业务副作用,历史保留,失败非零退出,任务自带总时限;长等待补偿和其他应用隔离由真实数据库回归验证。
已可运行的 [跨队列资源互斥场景](../api/keyed-concurrency.md#5-完整可运行场景) 提供 [完整源码](../../sdk/go/examples/keyed-concurrency/main.go):两个队列的自动 Worker 操作同一资源,核对递增序号,并模拟资源端拒绝旧写入。输入使用显式 concurrency_key;预期 completed=2、resource_writes=2、stale_write_rejected=true。示例仅使用受控内存资源,无外部副作用;正常排空、保留历史,任务自带总时限。内存 mutex 不是生产分布式存储,生产必须在资源端原子持久化序号、操作身份和实际更新。
| 场景 | 输入与步骤 | 预期结果 | 必测异常 | 状态 |

View File

@@ -0,0 +1,91 @@
# P3 优先级与节点公平派发回归
验证日期:2026-09-17。完成 [任务优先级、等待补偿、节点应用/队列轮转及 SDK 场景](../api/priority-and-fairness.md)。P3 可靠订阅、P4–P5 Weave、P6 生产负载/故障验收和 P7 最终交付核对仍未完成,持续目标保持进行中。
## 1. 交付内容
- POST /v1/tasks、查询/分页、计划模板、Worker 派发及独立 Go SDK 接入 Priority。值为 0–9,默认 0,随受理冻结、随安全重试保留。
- [000012 迁移](../../internal/storage/mysql/migrations/000012_dispatch_priority.up.sql) 新增只读生成字段 DispatchAt 和排序索引,对既有 priority 增加范围 CHECK 和中文 COMMENT;生成时间等于 available_at 减 priority × 30 秒。真正的就绪时间、截止、权限、能力、门控和许可条件保持生效。
- 以最多 270 秒的排序优势实现优先级加速;等待超过此优势的旧任务不被不断到来的最高级新任务持续插队。没有承诺 270 秒内完成或严格 FIFO。
- [节点领取池](../../internal/dispatch/pool.go) 将原来的各连接并发轮询改为应用 → 队列 → 连接请求轮转。总并发 4、单应用在途 1、待处理加在途最多 128;每连接最多一个未完成请求。应用归属由认证后的数据库关系取得,不能按自报应用或不同命名空间分组。
- 同命名空间/队列/精确能力的空探测在节点内合并一个周期,缓存最多 128 项;控制收发继续运行。所有任务状态、许可、预算和执行授权仍由数据库事务决定。
- [可运行示例](../../sdk/go/examples/priority/main.go)、协议 Schema、接口与 SDK 文档同步更新。当前仍为 33 个实体,274 个直接声明持久化字段全部有中文注释,最终数据库外键为零。
## 2. 环境和执行
macOS 本机、Go 1.27.1、Docker MySQL 8.4.10、回环 HTTP/WSS 和一次性数据库。测试使用随机私密凭据,结束清理自建容器,不连接既有业务库。
```sh
make fmt test vet build
make integration
cd sdk/go
GOWORK=off go test -race ./...
```
上述检查通过。真实数据库、SDK、示例和进程故障来自 scripts/test-mysql.sh;普通未配置环境的 go test 会跳过这些外部用例,不能替代完整集成。
| 分组 | 本次结果 |
| --- | --- |
| 全部 MySQL 集成 | PASS,74.570 秒 |
| 优先级四组数据库场景 | PASS,0.56 秒 |
| 公平领取应用隔离 | PASS,1.43 秒 |
| SDK Worker 连接、运行器、重试、取消、优先级与断线 | PASS,88.849 秒 |
| SDK 优先级输入与执行顺序 | PASS,3.16 秒 |
| SDK 计划生命周期 | PASS,5.659 秒(含包开销) |
| SDK 队列 / 应用资源 | PASS,4.470 / 1.416 秒(含包开销) |
| 七项真实进程故障组 | PASS,28.296 秒 |
| 其中优先级强杀恢复 | PASS,3.14 秒 |
| 十二个完整 SDK 示例 | 全部通过 |
十二个示例:health、tasks、worker-protocol、worker、history、retry、schedules、cancellation、resources、keyed-concurrency、priority、application-resources。回归耗时不代表吞吐、延迟 SLO 或生产容量。
## 3. 优先级与数据库证据
[优先级数据库用例](../../internal/storage/mysql/priority_integration_test.go) 验证:
1. 9 级任务先于 5 秒前到期的 0 级任务领取;尚有一小时才到期的 9 级任务不被提前领取。详情、摘要及派发保留 priority。
2. 0 级任务已等待 271 秒时,即使候选前缀有 24 条新 9 级任务,也先取得原任务,避免候选数量截断造成的饥饿。
3. 明确失败进入退避后保留 9 级,数据库生成字段与 available_at 差 270 秒;退避前不领取,到期后第二次尝试正常成功。
4. 默认 0 的原键原内容继续取回原回执;同键改优先级冲突;负数和 10 被拒绝。直接写入超范围 priority 和独立修改 generated 字段均由数据库拒绝。
[计划及资源键集成](../../internal/storage/mysql/keyed_concurrency_integration_test.go) 同时验证不可变计划模板传递 Priority=7、资源键和递增许可序号;资源互斥不被优先级绕过。
升级回归继续验证 v4 安全策略、v9 应用配额及旧在途任务释放。v7 升级保留全部 325 个原有索引列定义,移除 70 个外键,既有任务内容不变;新增生成列对旧默认 0 任务等于原 available_at,新排序索引单独添加。模型/表列映射与完整零外键检查同步通过。
## 4. 公平领取与资源隔离证据
[领取池竞态用例](../../internal/dispatch/pool_test.go) 验证:
- 同应用 40 个繁忙队列连接请求,不能挤掉该应用另一个队列以及另一个应用的轮次。
- 全局在途数与单应用在途数不超上限;等待和执行中的请求共同受总容量约束,满额拒绝追加,不能把请求无限移入内部切片。
- 一个应用阻塞时其他应用仍进入领取;取消的待处理请求不执行、不占用后续轮次,释放容量并移除空组。
- 并发提交和关闭竞争时不再接新工作,取消在途操作,最终没有领取槽泄漏。
[空探测用例](../../internal/transport/httpserver/worker_dispatch_test.go) 验证 20 次同能力空探测只调用一次存储;能力集合顺序不影响身份,不同资源键支持声明分别探测;过期后再次访问数据库,缓存不超过 128 项,领取 panic 被隔离且不暴露原始异常。
[真实 MySQL/WSS 用例](../../internal/storage/mysql/fair_dispatch_integration_test.go) 使用一个节点、两个应用、13 条 Worker 连接以及仅 4 条连接的数据库池。显式持有繁忙应用的额度行锁,启用该应用 12 条消费连接,并通过 performance_schema.data_lock_waits 确认真实领取事务已经等待。保持锁未释放时,另一个应用成功更新容量并收到自身任务的持久化派发。随后才释放故障锁。
这个用例证明节点领取池不会因一个应用的多条连接等待额度而耗尽全部数据库连接。它没有把“领取隔离”扩大为所有管理/续租请求的资源隔离,也没有宣称跨节点完全相等吞吐或为新应用保留连接席位。
## 5. SDK 与进程恢复
[SDK 集成用例](../../sdk/go/priority_integration_test.go) 通过真实 HTTP 拒绝 -1、10、非整数、null、字符串和 bool;按 0、9、4 受理后,单槽自动 Worker 按 9、4、0 完成,任务查询和处理器看到相同冻结值。
进程用例在节点 A 保存三条不同优先级任务后强杀并 Wait 确认退出,启动不同 node_id 的 B,再接入 Worker。B 按 9、4、0 从数据库恢复领取,逐条 Start/Finish 成功,排序不依赖 A 的内存。
完整示例实际输出:
```text
submitted_priorities=0,9,4 executed_priorities=9,4,0
completed=3 attempts_per_task=1 worker_drained=true
```
原 Start/Finish ACK 丢失、Worker 强杀未知核查、计划强杀补跑、跨队列资源键恢复和应用额度持久化故障用例均在本次完整套件中通过。异步领取未使这些既有执行不变量失效。
## 6. 仍需验证的边界
- 调度池是节点内领取机会轮转。跨节点配额仍依赖共享 MySQL;连接分布不均时没有严格相同全局份额或全局权重。
- 每节点仍只接受 128 条 Worker 连接,没有每应用席位预留;请求级限流、恶意连接占用和全套资源隔离需要生产接入治理。
- 优先级不抢占已有执行,未来任务不会提前执行。暂停、额度不足、资源键占用、无兼容 Worker、旧积压和长任务都可能延长等待;排序优势不是时限承诺。
- 同能力空探测最多增加一个扫描周期的发现等待;满额、失去权限、节点重启或缓存丢失均不能授权执行。内存队列被丢弃后由重新扫描及持久化租约恢复。
- 生产还需验证 MySQL 索引选择、未来任务和高基数能力下的扫描成本、锁等待、混合负载公平性、队列/应用热点、数据库故障切换、RPO/RTO 和容量余量。迁移生成列和建索引需要目标规模维护窗口演练。

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) 和 [应用共享配额及示例](../api/application-resources.md),[资源键互斥和单调序号示例](../api/keyed-concurrency.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),[资源键互斥和单调序号示例](../api/keyed-concurrency.md) 已实现,[优先级场景和节点公平派发](../api/priority-and-fairness.md) 已实现,结果订阅继续开发;P4–P5 提供流程构建发布、信号、等待、轮询与人工恢复。每项随实现同步交付可运行示例及异常回归。

196
internal/dispatch/pool.go Normal file
View File

@@ -0,0 +1,196 @@
// Package dispatch 对节点内领取机会按应用、队列轮转;数据库仍决定真实授权与配额。
package dispatch
import (
"context"
"sync"
)
// Job 只执行一个有界领取操作;不在此运行外部业务处理器。
type Job struct {
Context context.Context
Application string
Queue string
Run func(context.Context)
release func()
}
// Pool 的内存队列可随节点重建,不保存任务状态。每个应用最多一个在途领取。
type Pool struct {
mu sync.Mutex
closed bool
ctx context.Context
input chan Job
done chan struct{}
workers int
slots chan struct{}
}
func New(ctx context.Context, workers, capacity int) *Pool {
if workers < 1 || capacity < 1 {
panic("dispatch limits must be positive")
}
p := &Pool{ctx: ctx, input: make(chan Job, capacity), done: make(chan struct{}), workers: workers, slots: make(chan struct{}, capacity)}
go p.run()
return p
}
func (p *Pool) Done() <-chan struct{} { return p.done }
// Submit 非阻塞;调用者每个连接最多保存一个未完成 Job,满额后等待下一次扫描。
func (p *Pool) Submit(job Job) bool {
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return false
}
if job.Context == nil || job.Run == nil || job.Application == "" || job.Queue == "" || p.ctx.Err() != nil {
return false
}
select {
case p.slots <- struct{}{}:
default:
return false
}
job.release = func() { <-p.slots }
select {
case p.input <- job:
return true
default:
job.release()
return false
}
}
type queue struct {
name string
jobs []Job
}
type application struct {
name string
busy bool
queues []*queue
}
type roster struct{ apps []*application }
func (r *roster) add(j Job) {
var a *application
for _, v := range r.apps {
if v.name == j.Application {
a = v
break
}
}
if a == nil {
a = &application{name: j.Application}
r.apps = append(r.apps, a)
}
for _, q := range a.queues {
if q.name == j.Queue {
q.jobs = append(q.jobs, j)
return
}
}
a.queues = append(a.queues, &queue{name: j.Queue, jobs: []Job{j}})
}
func (r *roster) next() (Job, bool) {
for range len(r.apps) {
a := r.apps[0]
r.apps = append(r.apps[1:], a)
if a.busy {
continue
}
for len(a.queues) > 0 {
q := a.queues[0]
j := q.jobs[0]
q.jobs[0] = Job{}
q.jobs = q.jobs[1:]
a.queues = a.queues[1:]
if len(q.jobs) > 0 {
a.queues = append(a.queues, q)
}
if j.Context.Err() != nil {
if j.release != nil {
j.release()
}
continue
}
a.busy = true
return j, true
}
}
return Job{}, false
}
func (r *roster) finish(name string) {
for _, a := range r.apps {
if a.name == name {
a.busy = false
break
}
}
r.prune()
}
func (r *roster) prune() {
kept := r.apps[:0]
for _, a := range r.apps {
if a.busy || len(a.queues) > 0 {
kept = append(kept, a)
}
}
clear(r.apps[len(kept):])
r.apps = kept
}
func (p *Pool) run() {
defer close(p.done)
var r roster
finished := make(chan string, p.workers)
running := 0
for {
if p.ctx.Err() != nil {
p.mu.Lock()
p.closed = true
for len(p.input) > 0 {
j := <-p.input
j.release()
}
p.mu.Unlock()
for _, a := range r.apps {
for _, q := range a.queues {
for _, j := range q.jobs {
j.release()
}
}
}
for running > 0 {
<-finished
running--
}
return
}
for running < p.workers {
j, ok := r.next()
if !ok {
r.prune()
break
}
running++
go func() {
defer func() { j.release(); finished <- j.Application }()
ctx, cancel := context.WithCancel(j.Context)
stop := context.AfterFunc(p.ctx, cancel)
defer cancel()
defer stop()
if ctx.Err() == nil {
j.Run(ctx)
}
}()
}
select {
case <-p.ctx.Done():
case j := <-p.input:
r.add(j)
case name := <-finished:
running--
r.finish(name)
}
}
}

View File

@@ -0,0 +1,127 @@
package dispatch
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestRoundRobinApplicationsAndQueues(t *testing.T) {
var r roster
for i := 0; i < 40; i++ {
r.add(Job{Context: context.Background(), Application: "noisy", Queue: "many-workers"})
}
for i := 0; i < 4; i++ {
r.add(Job{Context: context.Background(), Application: "noisy", Queue: "other-queue"})
r.add(Job{Context: context.Background(), Application: "quiet", Queue: "only-worker"})
}
want := []string{"noisy/many-workers", "quiet/only-worker", "noisy/other-queue", "quiet/only-worker", "noisy/many-workers", "quiet/only-worker", "noisy/other-queue", "quiet/only-worker"}
for _, expected := range want {
j, ok := r.next()
if !ok || j.Application+"/"+j.Queue != expected {
t.Fatalf("dispatch=%s/%s want=%s", j.Application, j.Queue, expected)
}
r.finish(j.Application)
}
}
func TestPoolBoundsCancellationAndIsolation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p := New(ctx, 2, 4)
started := make(chan string, 4)
var active, same atomic.Int32
block := func(app string) Job {
return Job{Context: ctx, Application: app, Queue: "queue", Run: func(ctx context.Context) {
if active.Add(1) > 2 {
t.Error("global dispatch concurrency exceeded")
}
defer active.Add(-1)
if app == "noisy" {
if same.Add(1) > 1 {
t.Error("application ran concurrent claims")
}
defer same.Add(-1)
}
started <- app
<-ctx.Done()
}}
}
for _, app := range []string{"noisy", "noisy", "noisy", "quiet"} {
if !p.Submit(block(app)) {
t.Fatal("unexpected rejection")
}
}
if p.Submit(block("overflow")) {
t.Fatal("unbounded pending jobs")
}
seen := map[string]bool{}
for range 2 {
select {
case app := <-started:
seen[app] = true
case <-time.After(time.Second):
t.Fatal("one application blocked another")
}
}
if !seen["quiet"] || !seen["noisy"] {
t.Fatal("missing isolated application")
}
cancel()
select {
case <-p.Done():
case <-time.After(time.Second):
t.Fatal("shutdown did not cancel claims")
}
if len(p.slots) != 0 || active.Load() != 0 || p.Submit(block("late")) {
t.Fatal("shutdown leaked slots or accepted more work")
}
}
func TestPoolConcurrentSubmitAndShutdown(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
p := New(ctx, 4, 32)
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
p.Submit(Job{Context: ctx, Application: fmt.Sprint(i % 5), Queue: fmt.Sprint(i % 3), Run: func(ctx context.Context) {
select {
case <-ctx.Done():
case <-time.After(time.Millisecond):
}
}})
}
}()
}
cancel()
wg.Wait()
select {
case <-p.Done():
case <-time.After(time.Second):
t.Fatal("shutdown stuck")
}
if len(p.slots) != 0 {
t.Fatal("pending jobs leaked")
}
}
func TestCancelledJobsDoNotUseQueueTurnOrCapacity(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
var r roster
released := 0
r.add(Job{Context: ctx, Application: "stale", Queue: "gone", release: func() { released++ }})
r.add(Job{Context: context.Background(), Application: "ready", Queue: "ready"})
j, ok := r.next()
if !ok || j.Application != "ready" || released != 1 {
t.Fatal("cancelled job blocked live work")
}
r.finish("ready")
if len(r.apps) != 0 {
t.Fatal("empty applications retained")
}
}

View File

@@ -65,6 +65,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
assertApplicationCounts(t, f, 4, 2, 1)
// 还原后续新增表和字段,得到真实 v9 结构;现有任务内容保持不变。
f.exec(`DROP TABLE orbit_application_resources`)
f.exec(`ALTER TABLE pulse_tasks DROP INDEX ix_task_priority_dispatch, DROP CHECK ck_task_priority, DROP COLUMN dispatch_at`)
f.exec(`ALTER TABLE pulse_tasks DROP CHECK ck_task_concurrency_key, DROP COLUMN concurrency_key, DROP COLUMN concurrency_key_digest`)
f.exec(`ALTER TABLE pulse_attempts DROP COLUMN concurrency_token`)
f.exec(`ALTER TABLE pulse_worker_sessions DROP COLUMN concurrency_keys`)

View File

@@ -0,0 +1,104 @@
package mysql
import (
"context"
"encoding/json"
"net/http/httptest"
"os"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
)
func TestIntegrationFairDispatchApplicationIsolation(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 4, 2)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err = Migrate(ctx, db); err != nil {
t.Fatal(err)
}
noisy := newExecutionFixture(t, ctx, db)
quiet := newExecutionFixture(t, ctx, db)
if noisy.binding.ApplicationID != noisy.resources.ApplicationID || quiet.binding.ApplicationID != quiet.resources.ApplicationID {
t.Fatal("dispatch group was not derived from authenticated namespace")
}
hub := httpserver.NewWorkerHub(ctx, noisy.a)
server := httpserver.New("", func(context.Context) error { return nil })
httpserver.RegisterWorkers(server, hub)
tls := httptest.NewTLSServer(server.Handler)
defer tls.Close()
defer hub.Shutdown(ctx)
var connections []*testWorkerSocket
for range 12 {
f := *noisy
f.hello.InstanceID = executionID(t)
c := connectTestWorker(t, ctx, tls, &f)
id := c.send("worker.capacity", worker.CapacityInput{Sequence: 1, Capacity: 0}, "")
if reply := c.read(); reply.ReplyTo != id || reply.Error != nil {
t.Fatal("could not disable initial claims")
}
connections = append(connections, c)
}
quiet.hello.InstanceID = executionID(t)
q := connectTestWorker(t, ctx, tls, quiet)
id := q.send("worker.capacity", worker.CapacityInput{Sequence: 1, Capacity: 0}, "")
if reply := q.read(); reply.ReplyTo != id || reply.Error != nil {
t.Fatal("quiet worker setup failed")
}
noisy.submit(1)
want := quiet.submit(1)
lock, err := db.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer lock.Rollback()
var ignored string
if err = lock.QueryRowContext(ctx, `SELECT HEX(application_id) FROM orbit_application_resources WHERE application_id=? FOR UPDATE`, noisy.resources.ApplicationID).Scan(&ignored); err != nil {
t.Fatal(err)
}
for _, c := range connections {
id := c.send("worker.capacity", worker.CapacityInput{Sequence: 2, Capacity: 1}, "")
if reply := c.read(); reply.ReplyTo != id || reply.Error != nil {
t.Fatal("noisy worker control blocked by claims")
}
}
// 等到真的有领取事务在额度行上等待,再验证另一应用;不靠固定 sleep 猜测故障已发生。
for {
var waiting int
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM performance_schema.data_lock_waits`).Scan(&waiting); err != nil {
t.Fatal(err)
}
if waiting > 0 {
break
}
select {
case <-ctx.Done():
t.Fatal("held application did not receive a claim")
case <-time.After(20 * time.Millisecond):
}
}
id = q.send("worker.capacity", worker.CapacityInput{Sequence: 2, Capacity: 1}, "")
if reply := q.read(); reply.ReplyTo != id || reply.Error != nil {
t.Fatal("other application heartbeat starved")
}
reply := q.read()
var assignment worker.Assignment
if reply.Type != "task.assign" || json.Unmarshal(reply.Body, &assignment) != nil || assignment.Task.ID != want.ID {
t.Fatal("quiet application could not claim while noisy application was blocked")
}
// 此时锁仍未释放;静默应用的真实领取已提交,证明没有耗尽四连接数据库池。
if err := lock.Rollback(); err != nil {
t.Fatal(err)
}
t.Log("12 noisy connections blocked on one application; another application claimed with a four-connection database pool before lock release")
}

View File

@@ -309,11 +309,12 @@ func TestIntegrationKeyedConcurrency(t *testing.T) {
sf := newScheduleFixture(t, f)
def := sf.definition()
def.Task.ConcurrencyKey = "schedule/resource"
def.Task.Priority = 7
v := sf.create(def)
sf.due(v, 2*time.Second)
sf.tick()
a, ref := f.claim()
if a.Task.ConcurrencyKey != def.Task.ConcurrencyKey || a.ConcurrencyToken == 0 {
if a.Task.ConcurrencyKey != def.Task.ConcurrencyKey || a.ConcurrencyToken == 0 || a.Task.Priority != 7 {
t.Fatal("schedule omitted frozen key")
}
noKeyClaim(t, f)

View File

@@ -0,0 +1,7 @@
-- 任务优先级只影响已到期任务的排序,最多提前 270 秒的排序优势;不提前执行延时任务。
-- 生成列随受理、退避及恢复后的 available_at 自动变化,禁止独立修改。
ALTER TABLE pulse_tasks
MODIFY COLUMN priority SMALLINT NOT NULL DEFAULT 0 COMMENT '冻结任务优先级,0至9;每级带来30秒排序优势,不改变最早执行时间',
ADD CONSTRAINT ck_task_priority CHECK (priority BETWEEN 0 AND 9),
ADD COLUMN dispatch_at DATETIME(6) GENERATED ALWAYS AS (TIMESTAMPADD(SECOND, -30 * priority, available_at)) STORED COMMENT '派发排序时间,由最早执行时间减优先级乘30秒生成;不是执行授权或可用时间',
ADD KEY ix_task_priority_dispatch(namespace_id,queue_id,state,dispatch_at,available_at,id);

View File

@@ -88,7 +88,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
}
indexes := func() []string {
t.Helper()
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME<>'orbit_application_resources' 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' AND INDEX_NAME<>'ix_task_priority_dispatch' ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
if err != nil {
t.Fatal(err)
}
@@ -135,6 +135,14 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
}
result := make([]string, 0, len(cols))
for i, value := range values {
if cols[i] == "dispatch_at" {
for j, col := range cols {
if col == "available_at" && value != values[j] {
t.Fatal("legacy default priority changed ordering")
}
}
continue
}
if cols[i] == "concurrency_key" || cols[i] == "concurrency_key_digest" {
if value.Valid {
t.Fatal("legacy task unexpectedly enabled keyed concurrency")

View File

@@ -0,0 +1,126 @@
package mysql
import (
"context"
"encoding/json"
"os"
"testing"
"time"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
)
func TestIntegrationDispatchPriority(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 16, 4)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err = Migrate(ctx, db); err != nil {
t.Fatal(err)
}
submit := func(f *executionFixture, p int16, max uint32) service.TaskView {
t.Helper()
out, err := f.tasks.Submit(ctx, f.token, string(executionID(t)), service.SubmitInput{QueueID: f.resources.QueueID, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`), Priority: p, Retry: pulse.RetryPolicy{MaxAttempts: max}})
if err != nil {
t.Fatal(err)
}
return out.Task
}
t.Run("priority_overtakes_recent_work_but_not_future_release", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
low := submit(f, 0, 1)
high := submit(f, 9, 1)
later := submit(f, 9, 1)
f.exec(`UPDATE pulse_tasks SET available_at=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 5 SECOND) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, low.ID)
f.exec(`UPDATE pulse_tasks SET available_at=DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, later.ID)
a, _ := f.claim()
if a.Task.ID != high.ID || a.Task.Priority != 9 {
t.Fatal("priority not honored")
}
a, _ = f.claim()
if a.Task.ID != low.ID {
t.Fatal("priority bypassed actual availability")
}
noKeyClaim(t, f)
page, err := f.tasks.ListTasks(ctx, f.token, service.ListInput{QueueID: f.resources.QueueID, Limit: 20})
if err != nil {
t.Fatal(err)
}
found := false
for _, v := range page.Items {
if v.ID == high.ID {
found = v.Priority == 9
}
}
if !found {
t.Fatal("summary lost frozen priority")
}
})
t.Run("old_low_priority_cannot_be_overtaken_by_unbounded_new_high_work", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
old := submit(f, 0, 1)
f.exec(`UPDATE pulse_tasks SET available_at=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 271 SECOND) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, old.ID)
for range 24 {
submit(f, 9, 1)
}
a, _ := f.claim()
if a.Task.ID != old.ID {
t.Fatal("aged task starved behind fresh high priority prefix")
}
})
t.Run("retry_keeps_priority_and_recomputes_sort_time", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
task := submit(f, 9, 2)
first := retryStart(t, f)
retryFinish(t, f, first, "FAILED", true)
var gap int
var priority int16
if err := db.QueryRowContext(ctx, `SELECT TIMESTAMPDIFF(SECOND,dispatch_at,available_at),priority FROM pulse_tasks WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, task.ID).Scan(&gap, &priority); err != nil {
t.Fatal(err)
}
if gap != 270 || priority != 9 {
t.Fatal("retry lost priority or sort derivation")
}
noKeyClaim(t, f)
f.exec(`UPDATE pulse_tasks SET available_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, task.ID)
second := retryStart(t, f)
retryFinish(t, f, second, "SUCCEEDED", false)
if f.get(task.ID).Priority != 9 {
t.Fatal("completed task lost priority")
}
})
t.Run("identity_validation_and_database_constraint", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
in := service.SubmitInput{QueueID: f.resources.QueueID, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`), Priority: 0}
first, err := f.tasks.Submit(ctx, f.token, "same", in)
if err != nil {
t.Fatal(err)
}
dupe, err := f.tasks.Submit(ctx, f.token, "same", in)
if err != nil || !dupe.Deduplicated || dupe.Task.ID != first.Task.ID {
t.Fatal("default priority identity changed")
}
in.Priority = 9
_, err = f.tasks.Submit(ctx, f.token, "same", in)
expectWorkerCode(t, err, "IDEMPOTENCY_CONFLICT")
for _, p := range []int16{-1, 10} {
in.Priority = p
_, err = f.tasks.Submit(ctx, f.token, string(executionID(t)), in)
expectWorkerCode(t, err, "INVALID_ARGUMENT")
}
if _, err := db.ExecContext(ctx, `UPDATE pulse_tasks SET priority=10 WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, first.Task.ID); err == nil {
t.Fatal("database priority range unchecked")
}
if _, err := db.ExecContext(ctx, `UPDATE pulse_tasks SET dispatch_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, first.Task.ID); err == nil {
t.Fatal("generated dispatch order independently writable")
}
})
}

View File

@@ -224,7 +224,7 @@ func (s *ScheduleStore) saveOccurrence(ctx context.Context, tx *sql.Tx, v schedu
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO pulse_tasks(namespace_id,id,queue_id,handler,handler_version,payload_version,payload,policy_snapshot,recovery_safety,business_key,concurrency_key,concurrency_key_digest,owner_kind,owner_id,available_at,deadline_at,attempt_timeout_ms,max_attempts,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,NULLIF(?,''),?,?,'schedule',?,?,?,?,?,?,?)`, v.NamespaceID, value, v.QueueID, in.Handler, in.HandlerVersion, in.PayloadVersion, string(in.Payload), string(policy), in.RecoverySafety, in.BusinessKey, concurrencyKeyValue(in.ConcurrencyKey), concurrencyDigest(in.ConcurrencyKey), v.ID, now, now.Add(time.Duration(in.TaskTimeoutMS)*time.Millisecond), in.Retry.AttemptTimeoutMS, in.Retry.MaxAttempts, now, now)
_, err = tx.ExecContext(ctx, `INSERT INTO pulse_tasks(namespace_id,id,queue_id,handler,handler_version,payload_version,payload,policy_snapshot,recovery_safety,business_key,concurrency_key,concurrency_key_digest,priority,owner_kind,owner_id,available_at,deadline_at,attempt_timeout_ms,max_attempts,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,NULLIF(?,''),?,?,?,'schedule',?,?,?,?,?,?,?)`, v.NamespaceID, value, v.QueueID, in.Handler, in.HandlerVersion, in.PayloadVersion, string(in.Payload), string(policy), in.RecoverySafety, in.BusinessKey, concurrencyKeyValue(in.ConcurrencyKey), concurrencyDigest(in.ConcurrencyKey), in.Priority, v.ID, now, now.Add(time.Duration(in.TaskTimeoutMS)*time.Millisecond), in.Retry.AttemptTimeoutMS, in.Retry.MaxAttempts, now, now)
if err != nil {
return err
}

View File

@@ -195,8 +195,8 @@ func (s *TaskStore) Submit(ctx context.Context, token auth.Token, key string, in
if err := reserveQueueTask(ctx, tx, token.NamespaceID, in.QueueID); err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO pulse_tasks(namespace_id,id,queue_id,handler,handler_version,payload_version,payload,policy_snapshot,recovery_safety,business_key,concurrency_key,concurrency_key_digest,available_at,start_deadline_at,deadline_at,attempt_timeout_ms,max_attempts,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,NULLIF(?,''),?,?,?,?,?,?,?,?,?)`, token.NamespaceID, taskID, in.QueueID, in.Handler, in.HandlerVersion, in.PayloadVersion, string(in.Payload), string(policy), in.RecoverySafety, in.BusinessKey, concurrencyKeyValue(in.ConcurrencyKey), concurrencyDigest(in.ConcurrencyKey), available, in.StartDeadlineAt, deadline, in.Retry.AttemptTimeoutMS, in.Retry.MaxAttempts, now, now)
_, err = tx.ExecContext(ctx, `INSERT INTO pulse_tasks(namespace_id,id,queue_id,handler,handler_version,payload_version,payload,policy_snapshot,recovery_safety,business_key,concurrency_key,concurrency_key_digest,priority,available_at,start_deadline_at,deadline_at,attempt_timeout_ms,max_attempts,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,NULLIF(?,''),?,?,?,?,?,?,?,?,?,?)`, token.NamespaceID, taskID, in.QueueID, in.Handler, in.HandlerVersion, in.PayloadVersion, string(in.Payload), string(policy), in.RecoverySafety, in.BusinessKey, concurrencyKeyValue(in.ConcurrencyKey), concurrencyDigest(in.ConcurrencyKey), in.Priority, available, in.StartDeadlineAt, deadline, in.Retry.AttemptTimeoutMS, in.Retry.MaxAttempts, now, now)
if err != nil {
return err
}
@@ -215,14 +215,14 @@ func (s *TaskStore) Submit(ctx context.Context, token auth.Token, key string, in
return out, err
}
const taskColumns = `id,namespace_id,queue_id,state,revision,handler,handler_version,payload_version,payload,result,COALESCE(business_key,''),COALESCE(concurrency_key,''),available_at,start_deadline_at,deadline_at,recovery_safety,policy_snapshot,attempt_count,cancel_requested_at,completed_at,created_at,updated_at,effect_uncertain`
const taskColumns = `id,namespace_id,queue_id,state,revision,handler,handler_version,payload_version,payload,result,COALESCE(business_key,''),COALESCE(concurrency_key,''),priority,available_at,start_deadline_at,deadline_at,recovery_safety,policy_snapshot,attempt_count,cancel_requested_at,completed_at,created_at,updated_at,effect_uncertain`
type scanner interface{ Scan(...any) error }
func scanTask(row scanner) (service.TaskView, error) {
var v service.TaskView
var policy, payload, result []byte
err := row.Scan(&v.ID, &v.NamespaceID, &v.QueueID, &v.State, &v.Revision, &v.Handler, &v.HandlerVersion, &v.PayloadVersion, &payload, &result, &v.BusinessKey, &v.ConcurrencyKey, &v.AvailableAt, &v.StartDeadlineAt, &v.DeadlineAt, &v.RecoverySafety, &policy, &v.AttemptCount, &v.CancelRequestedAt, &v.CompletedAt, &v.CreatedAt, &v.UpdatedAt, &v.EffectUncertain)
err := row.Scan(&v.ID, &v.NamespaceID, &v.QueueID, &v.State, &v.Revision, &v.Handler, &v.HandlerVersion, &v.PayloadVersion, &payload, &result, &v.BusinessKey, &v.ConcurrencyKey, &v.Priority, &v.AvailableAt, &v.StartDeadlineAt, &v.DeadlineAt, &v.RecoverySafety, &policy, &v.AttemptCount, &v.CancelRequestedAt, &v.CompletedAt, &v.CreatedAt, &v.UpdatedAt, &v.EffectUncertain)
if err != nil {
return v, err
}
@@ -272,7 +272,7 @@ func (s *TaskStore) ListTasks(ctx context.Context, token auth.Token, in service.
if !p.AllowsQueue(in.QueueID) {
return fault.Forbidden
}
q := `SELECT id,queue_id,state,revision,handler,handler_version,COALESCE(business_key,''),COALESCE(concurrency_key,''),available_at,deadline_at,attempt_count,created_at,updated_at,effect_uncertain FROM pulse_tasks WHERE namespace_id=? AND queue_id=?`
q := `SELECT id,queue_id,state,revision,handler,handler_version,COALESCE(business_key,''),COALESCE(concurrency_key,''),priority,available_at,deadline_at,attempt_count,created_at,updated_at,effect_uncertain FROM pulse_tasks WHERE namespace_id=? AND queue_id=?`
args := []any{token.NamespaceID, in.QueueID}
if in.State != "" {
q += " AND state=?"
@@ -296,7 +296,7 @@ func (s *TaskStore) ListTasks(ctx context.Context, token auth.Token, in service.
defer rows.Close()
for rows.Next() {
var v service.TaskSummary
if err := rows.Scan(&v.ID, &v.QueueID, &v.State, &v.Revision, &v.Handler, &v.HandlerVersion, &v.BusinessKey, &v.ConcurrencyKey, &v.AvailableAt, &v.DeadlineAt, &v.AttemptCount, &v.CreatedAt, &v.UpdatedAt, &v.EffectUncertain); err != nil {
if err := rows.Scan(&v.ID, &v.QueueID, &v.State, &v.Revision, &v.Handler, &v.HandlerVersion, &v.BusinessKey, &v.ConcurrencyKey, &v.Priority, &v.AvailableAt, &v.DeadlineAt, &v.AttemptCount, &v.CreatedAt, &v.UpdatedAt, &v.EffectUncertain); err != nil {
return err
}
out.Items = append(out.Items, v)

View File

@@ -152,7 +152,7 @@ func (s *ExecutionStore) Claim(ctx context.Context, token auth.Token, b worker.B
if !state.concurrencyKeys {
keyFilter = "concurrency_key_digest IS NULL"
}
rows, err := tx.QueryContext(ctx, `SELECT id,gate_id FROM pulse_tasks WHERE namespace_id=? AND queue_id=? AND state='WAITING' AND available_at<=UTC_TIMESTAMP(6) AND deadline_at>UTC_TIMESTAMP(6) AND (start_deadline_at IS NULL OR start_deadline_at>UTC_TIMESTAMP(6)) AND attempt_count<max_attempts AND (gate_id IS NULL OR EXISTS (SELECT 1 FROM pulse_execution_gates g WHERE g.namespace_id=pulse_tasks.namespace_id AND g.id=pulse_tasks.gate_id AND g.paused=FALSE)) AND (`+strings.Join(matches, " OR ")+`) AND `+keyFilter+` ORDER BY available_at,priority DESC,id LIMIT 16`, args...)
rows, err := tx.QueryContext(ctx, `SELECT id,gate_id FROM pulse_tasks WHERE namespace_id=? AND queue_id=? AND state='WAITING' AND available_at<=UTC_TIMESTAMP(6) AND deadline_at>UTC_TIMESTAMP(6) AND (start_deadline_at IS NULL OR start_deadline_at>UTC_TIMESTAMP(6)) AND attempt_count<max_attempts AND (gate_id IS NULL OR EXISTS (SELECT 1 FROM pulse_execution_gates g WHERE g.namespace_id=pulse_tasks.namespace_id AND g.id=pulse_tasks.gate_id AND g.paused=FALSE)) AND (`+strings.Join(matches, " OR ")+`) AND `+keyFilter+` ORDER BY dispatch_at,available_at,id LIMIT 16`, args...)
if err != nil {
return err
}

View File

@@ -118,6 +118,9 @@ func (s *ExecutionStore) Connect(ctx context.Context, token auth.Token, in worke
return err
}
binding = worker.Binding{InstanceID: in.InstanceID, SessionID: session, Epoch: epoch + 1, Proof: proof}
if err := tx.QueryRowContext(ctx, `SELECT application_id FROM orbit_namespaces WHERE id=?`, token.NamespaceID).Scan(&binding.ApplicationID); err != nil {
return err
}
state := sessionState{queue: in.QueueID, capacity: in.Capacity, draining: draining, expires: now.Add(s.options.SessionTTL), capabilities: in.Capabilities, concurrencyKeys: in.ConcurrencyKeys}
caps, err := json.Marshal(in.Capabilities)
if err != nil {

View File

@@ -12,6 +12,7 @@ import (
"sync"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/dispatch"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
@@ -33,11 +34,17 @@ type WorkerHub struct {
closing bool
wg sync.WaitGroup
poll time.Duration
dispatch *dispatch.Pool
emptyPolls map[[32]byte]time.Time
}
func NewWorkerHub(parent context.Context, service *worker.Service) *WorkerHub {
ctx, cancel := context.WithCancel(parent)
return &WorkerHub{service: service, ctx: ctx, cancel: cancel, slots: make(chan struct{}, 128), poll: time.Second}
h := &WorkerHub{service: service, ctx: ctx, cancel: cancel, slots: make(chan struct{}, 128), poll: time.Second, emptyPolls: make(map[[32]byte]time.Time)}
h.dispatch = dispatch.New(ctx, 4, 128)
h.wg.Add(1)
go func() { defer h.wg.Done(); <-h.dispatch.Done() }()
return h
}
func RegisterWorkers(server *http.Server, hub *WorkerHub) {
server.Handler.(*http.ServeMux).Handle("GET /v1/worker/connect", hub)
@@ -249,12 +256,53 @@ func (h *WorkerHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
eligible := false
leases := map[core.ID]time.Time{}
cancelSent := map[core.ID]bool{}
results := make(chan claimResult, 1)
pending, burstLeft := false, 0
pollKey := workerPollKey(token.NamespaceID, input)
canRequest := func() bool {
return eligible && !draining && capacity > 0 && len(leases) < int(capacity) && len(outgoing) < 4
}
requestClaim := func() {
if pending || burstLeft == 0 || !canRequest() {
return
}
pending = h.dispatch.Submit(dispatch.Job{Context: ctx, Application: string(binding.ApplicationID), Queue: string(token.NamespaceID) + "/" + string(input.QueueID), Run: func(jobCtx context.Context) {
results <- h.claimFair(jobCtx, token, binding, pollKey)
}})
if pending {
burstLeft--
}
}
ticker := time.NewTicker(h.poll)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case result := <-results:
pending = false
if result.err != nil {
if fault.Code(result.err) == "STALE_SESSION" || fault.Code(result.err) == "UNAUTHENTICATED" || fault.Code(result.err) == "PERMISSION_DENIED" {
send(workerFrame{Type: "error", Error: publicWorkerError(result.err)}, true)
return
}
burstLeft = 0
continue
}
if result.assignment == nil {
burstLeft = 0
continue
}
assignment := result.assignment
raw, err := jsonwire.Marshal(assignment)
if err != nil {
return
}
leases[assignment.AttemptID] = assignment.LeaseUntil
if !send(workerFrame{Type: "task.assign", Body: raw}, false) {
return
}
requestClaim()
case frame := <-incoming:
call, stop := context.WithTimeout(ctx, 8*time.Second)
var body any
@@ -370,31 +418,9 @@ func (h *WorkerHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
delete(leases, attempt)
}
}
if !eligible || draining || capacity == 0 || len(leases) >= int(capacity) || len(outgoing) >= 4 {
continue
}
for burst := 0; burst < 4 && len(leases) < int(capacity) && len(outgoing) < 4; burst++ {
call, stop := context.WithTimeout(ctx, 8*time.Second)
assignment, err := h.service.Claim(call, token, binding)
stop()
if err != nil {
if fault.Code(err) == "STALE_SESSION" || fault.Code(err) == "UNAUTHENTICATED" || fault.Code(err) == "PERMISSION_DENIED" {
send(workerFrame{Type: "error", Error: publicWorkerError(err)}, true)
return
}
break
}
if assignment == nil {
break
}
raw, err := jsonwire.Marshal(assignment)
if err != nil {
return
}
leases[assignment.AttemptID] = assignment.LeaseUntil
if !send(workerFrame{Type: "task.assign", Body: raw}, false) {
return
}
if !pending {
burstLeft = 4
requestClaim()
}
}
}

View File

@@ -194,6 +194,7 @@ func TestWorkerPublishedContract(t *testing.T) {
assignment := worker.Assignment{Task: task, AttemptID: id, Epoch: 1, LeaseSecret: secret, LeaseUntil: lease.LeaseUntil, ServerTime: now}
keyed := assignment
keyed.Task.ConcurrencyKey = "object/共享资源"
keyed.Task.Priority = 9
keyed.ConcurrencyToken = ^uint64(0) - 1
for _, tc := range []struct {
kind string
@@ -215,6 +216,8 @@ func TestWorkerPublishedContract(t *testing.T) {
keyedBody, _ := jsonwire.Marshal(keyed)
keyedFrame, _ := jsonwire.Marshal(workerFrame{Version: "1", Type: "task.assign", MessageID: id, SessionID: id, Body: keyedBody})
for _, raw := range []string{
strings.Replace(string(keyedFrame), `"priority":9`, `"priority":10`, 1),
strings.Replace(string(keyedFrame), `"priority":9`, `"priority":-1`, 1),
strings.Replace(string(keyedFrame), `"concurrency_token":"18446744073709551614",`, "", 1),
strings.Replace(string(keyedFrame), `"concurrency_key":"object/共享资源",`, "", 1),
strings.Replace(string(keyedFrame), `"concurrency_token":"18446744073709551614"`, `"concurrency_token":0`, 1),

View File

@@ -0,0 +1,60 @@
package httpserver
import (
"context"
"crypto/sha256"
"encoding/json"
"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/pulse/worker"
)
type claimResult struct {
assignment *worker.Assignment
err error
}
func workerPollKey(ns core.ID, in worker.ConnectInput) [32]byte {
// 正式注册已校验;再次规范化仅取得稳定能力集合,不包含任何秘密。
in, _ = worker.NormalizeConnect(in)
raw, _ := json.Marshal(struct {
Namespace, Queue core.ID
Capabilities []worker.Capability
Keys bool
}{ns, in.QueueID, in.Capabilities, in.ConcurrencyKeys})
return sha256.Sum256(raw)
}
func (h *WorkerHub) claimFair(ctx context.Context, t auth.Token, b worker.Binding, key [32]byte) (out claimResult) {
// HTTP handler 原本具有 panic 隔离;异步领取同样不能因一次异常杀死其他连接。
defer func() {
if recover() != nil {
out = claimResult{err: fault.Unavailable}
}
}()
h.mu.Lock()
until := h.emptyPolls[key]
h.mu.Unlock()
if until.After(time.Now()) {
return out
}
call, stop := context.WithTimeout(ctx, 8*time.Second)
defer stop()
out.assignment, out.err = h.service.Claim(call, t, b)
if out.assignment == nil && out.err == nil {
h.mu.Lock()
now := time.Now()
for k, v := range h.emptyPolls {
if !v.After(now) {
delete(h.emptyPolls, k)
}
}
if len(h.emptyPolls) < 128 {
h.emptyPolls[key] = now.Add(h.poll)
}
h.mu.Unlock()
}
return out
}

View File

@@ -0,0 +1,75 @@
package httpserver
import (
"context"
"strings"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
)
type emptyClaimStore struct {
worker.Store
calls int
panicNext bool
}
func (s *emptyClaimStore) Claim(context.Context, auth.Token, worker.Binding) (*worker.Assignment, error) {
s.calls++
if s.panicNext {
s.panicNext = false
panic("private database details")
}
return nil, nil
}
func TestEmptyPollCoalescesExactCapabilitiesAndBoundsCache(t *testing.T) {
store := &emptyClaimStore{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
h := NewWorkerHub(ctx, worker.New(store))
defer h.Shutdown(context.Background())
in := worker.ConnectInput{InstanceID: core.ID(strings.Repeat("1", 32)), InstanceSecret: strings.Repeat("A", 43), QueueID: core.ID(strings.Repeat("2", 32)), Capacity: 1, Capabilities: []worker.Capability{{Handler: "a", HandlerVersion: "v1", PayloadVersion: "v1"}, {Handler: "b", HandlerVersion: "v1", PayloadVersion: "v1"}}}
ns := core.ID(strings.Repeat("3", 32))
key := workerPollKey(ns, in)
for range 20 {
if out := h.claimFair(ctx, auth.Token{}, worker.Binding{}, key); out.err != nil || out.assignment != nil {
t.Fatal("unexpected poll result")
}
}
if store.calls != 1 {
t.Fatal("identical idle connections scanned separately")
}
in.Capabilities[0], in.Capabilities[1] = in.Capabilities[1], in.Capabilities[0]
if workerPollKey(ns, in) != key {
t.Fatal("capability order changed coalescing identity")
}
in.ConcurrencyKeys = true
keyed := workerPollKey(ns, in)
if keyed == key {
t.Fatal("legacy and keyed workers shared an eligibility cache")
}
h.claimFair(ctx, auth.Token{}, worker.Binding{}, keyed)
if store.calls != 2 {
t.Fatal("different capabilities hidden by cache")
}
h.emptyPolls[key] = time.Now().Add(-time.Second)
h.claimFair(ctx, auth.Token{}, worker.Binding{}, key)
if store.calls != 3 {
t.Fatal("expired cache prevented rediscovery")
}
for i := 0; i < 256; i++ {
k := [32]byte{byte(i), 1}
h.claimFair(ctx, auth.Token{}, worker.Binding{}, k)
}
if len(h.emptyPolls) > 128 {
t.Fatal("unbounded empty poll cache")
}
store.panicNext = true
out := h.claimFair(ctx, auth.Token{}, worker.Binding{}, [32]byte{1, 2, 3})
if out.err == nil || strings.Contains(out.err.Error(), "private") {
t.Fatal("claim panic escaped isolation")
}
}

View File

@@ -531,6 +531,11 @@
"minLength": 1,
"maxLength": 191,
"description": "命名空间内字节精确匹配的互斥资源键;空白边界及控制字符由运行时拒绝。"
},
"priority": {
"type": "integer",
"minimum": 0,
"maximum": 9
}
},
"required": [

View File

@@ -43,8 +43,10 @@ type Task struct {
OwnerKind *string `db:"owner_kind"`
// OwnerID 来源资源标识,必须与来源类型同时有值或同时为空。
OwnerID *core.ID `db:"owner_id"`
// Priority 同一调度范围内的优先级,不承诺跨队列全局顺序。
// Priority 冻结优先级,0至9,每级30秒排序优势;不提前最早执行时间。
Priority int16 `db:"priority"`
// DispatchAt 数据库生成的排序时间,AvailableAt 减 Priority 乘30秒;只读,不是执行授权。
DispatchAt time.Time `db:"dispatch_at"`
// AvailableAt 最早可被领取或处理的时间,统一 UTC。
AvailableAt time.Time `db:"available_at"`
// StartDeadlineAt 最晚允许新尝试开始的时间;为空时仅受总时限约束。

View File

@@ -55,6 +55,9 @@ func NormalizeSubmit(in SubmitInput) (SubmitInput, error) {
if err := ValidateConcurrencyKey(in.ConcurrencyKey); err != nil {
return in, err
}
if in.Priority < 0 || in.Priority > 9 {
return in, fault.Invalid("priority must be between 0 and 9")
}
if in.TaskTimeoutMS == 0 {
in.TaskTimeoutMS = 24 * 60 * 60 * 1000
}

View File

@@ -27,6 +27,8 @@ type SubmitInput struct {
BusinessKey string `json:"business_key,omitempty"`
// ConcurrencyKey 显式互斥资源键,同命名空间跨队列最多一个有效尝试;不代替业务幂等。
ConcurrencyKey string `json:"concurrency_key,omitempty"`
// Priority 冻结优先级,0至9,每级30秒排序优势,不改变最早执行时间。
Priority int16 `json:"priority,omitempty"`
// AvailableAt 最早执行时间;为空表示受理后即可进入派发范围。
AvailableAt *time.Time `json:"available_at,omitempty"`
// StartDeadlineAt 最晚开始时间;为空表示使用任务总截止时间。
@@ -67,6 +69,8 @@ type TaskView struct {
BusinessKey string `json:"business_key,omitempty"`
// ConcurrencyKey 显式互斥资源键,同命名空间跨队列最多一个有效尝试;不代替业务幂等。
ConcurrencyKey string `json:"concurrency_key,omitempty"`
// Priority 冻结优先级,0至9,每级30秒排序优势,不改变最早执行时间。
Priority int16 `json:"priority,omitempty"`
// AvailableAt 最早可执行时间,UTC。
AvailableAt time.Time `json:"available_at"`
// StartDeadlineAt 最晚开始时间;为空时使用任务总截止时间。
@@ -137,6 +141,8 @@ type TaskSummary struct {
BusinessKey string `json:"business_key,omitempty"`
// ConcurrencyKey 显式互斥资源键,同命名空间跨队列最多一个有效尝试;不代替业务幂等。
ConcurrencyKey string `json:"concurrency_key,omitempty"`
// Priority 冻结优先级,0至9,每级30秒排序优势,不改变最早执行时间。
Priority int16 `json:"priority,omitempty"`
// AvailableAt 最早可执行时间,UTC。
AvailableAt time.Time `json:"available_at"`
// DeadlineAt 任务总截止时间,UTC。

View File

@@ -32,6 +32,7 @@ type ConnectInput struct {
// Binding 是接入层持有的认证结果,不作为公开请求字段接受或写入日志。
type Binding struct {
ApplicationID core.ID // ApplicationID 服务端认证后的应用归属,用于节点公平派发分组,不接受客户端覆盖。
InstanceID core.ID // InstanceID 已证明秘密的进程标识。
SessionID core.ID // SessionID 当前连接标识。
Epoch uint64 // Epoch 连接代次。

View File

@@ -60,6 +60,7 @@ ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap -
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/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)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/keyed-concurrency)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/priority)
# 应用配额场景使用独立应用与两个命名空间,普通 Worker 凭据不扩权。

View File

@@ -0,0 +1,116 @@
// priority 演示近同时到期任务的优先级顺序,真实受理与执行由 Orbit 完成。
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 run() error {
token := os.Getenv("ORBIT_TOKEN")
if path := os.Getenv("ORBIT_CREDENTIALS_FILE"); path != "" && token == "" {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
var c struct {
Token string `json:"token"`
}
if err = json.Unmarshal(raw, &c); err != nil {
return err
}
token = c.Token
}
api, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
op, err := orbit.NewWorkerMessageID()
if err != nil {
return err
}
name := "priority." + op
created, err := api.CreateQueue(ctx, "queue-"+op, orbit.CreateQueueRequest{Name: name, MaxConcurrency: 1, RatePerSecond: 100, MaxUnfinishedTasks: 3})
if err != nil {
return err
}
var tasks []orbit.Task
for _, priority := range []int16{0, 9, 4} {
in := orbit.SubmitTaskRequest{QueueID: created.Queue.ID, Handler: name, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"object_ref":"objects/example","n":9007199254740993}`), Priority: priority, TaskTimeoutMS: 20000, Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 10000}}
out, err := api.SubmitTask(ctx, fmt.Sprintf("task-%s-%d", op, priority), in)
if err != nil {
return err
}
tasks = append(tasks, out.Task)
}
order := make(chan int16, 3)
w, err := orbit.NewWorker(api, orbit.WorkerOptions{QueueID: created.Queue.ID, Capacity: 1, ShutdownTimeout: 5 * time.Second, Handlers: []orbit.WorkerHandler{{Capability: orbit.WorkerCapability{Handler: name, HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: func(ctx context.Context, e orbit.WorkerExecution) (json.RawMessage, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
order <- e.Task.Priority
return e.Task.Payload, nil
}}}})
if err != nil {
return err
}
run, stop := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- w.Run(run) }()
waited := false
defer func() {
stop()
if !waited {
<-done
}
}()
for _, task := range tasks {
for {
v, err := api.GetTask(ctx, task.ID)
if err != nil {
return err
}
if v.State == orbit.TaskSucceeded {
if v.Priority != task.Priority || v.AttemptCount != 1 {
return errors.New("frozen priority or execution count changed")
}
break
}
if v.State == orbit.TaskFailed || v.State == orbit.TaskNeedsReview || v.State == orbit.TaskCancelled {
return fmt.Errorf("unexpected task state: %s", v.State)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(80 * time.Millisecond):
}
}
}
got := []int16{<-order, <-order, <-order}
if got[0] != 9 || got[1] != 4 || got[2] != 0 {
return fmt.Errorf("unexpected dispatch order: %v", got)
}
stop()
err = <-done
waited = true
if err != nil {
return err
}
fmt.Println("submitted_priorities=0,9,4 executed_priorities=9,4,0")
fmt.Println("completed=3 attempts_per_task=1 worker_drained=true")
return nil
}

View File

@@ -0,0 +1,114 @@
package orbit
import (
"context"
"encoding/json"
"errors"
"reflect"
"testing"
"time"
)
func TestIntegrationWorkerRuntimePriority(t *testing.T) {
api, _, _ := integrationWorkerClient(t)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
name := "priority." + workerTestID(t)
q, err := api.CreateQueue(ctx, name, CreateQueueRequest{Name: name, MaxConcurrency: 1, RatePerSecond: 100})
if err != nil {
t.Fatal(err)
}
base := map[string]any{"queue_id": q.Queue.ID, "handler": name, "handler_version": "v1", "payload_version": "v1", "payload": map[string]any{}}
for _, invalid := range []any{-1, 10, 1.5, nil, "9", true} {
base["priority"] = invalid
var ignored SubmitTaskResult
err := api.call(ctx, "POST", "/v1/tasks", workerTestID(t), base, &ignored)
var rejected *APIError
if !errors.As(err, &rejected) || rejected.StatusCode != 400 {
t.Fatalf("invalid priority %v accepted: %v", invalid, err)
}
}
var tasks []Task
for _, priority := range []int16{0, 9, 4} {
out, err := api.SubmitTask(ctx, workerTestID(t), SubmitTaskRequest{QueueID: q.Queue.ID, Handler: name, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"n":9007199254740993}`), Priority: priority})
if err != nil || out.Task.Priority != priority {
t.Fatal("priority not accepted", err)
}
tasks = append(tasks, out.Task)
}
observed := make(chan int16, 3)
w, err := NewWorker(api, WorkerOptions{QueueID: q.Queue.ID, Capacity: 1, Handlers: []WorkerHandler{runtimeHandler(name, func(ctx context.Context, e WorkerExecution) (json.RawMessage, error) {
observed <- e.Task.Priority
return e.Task.Payload, nil
})}})
if err != nil {
t.Fatal(err)
}
run, stop := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- w.Run(run) }()
defer func() {
stop()
if err := <-done; err != nil {
t.Error(err)
}
}()
for _, task := range tasks {
v := waitRuntimeState(t, ctx, api, task.ID, TaskSucceeded)
if v.Priority != task.Priority {
t.Fatal("priority changed")
}
}
got := []int16{<-observed, <-observed, <-observed}
if !reflect.DeepEqual(got, []int16{9, 4, 0}) {
t.Fatalf("dispatch priorities %v", got)
}
}
func TestIntegrationWorkerProcessPriorityRecovery(t *testing.T) {
a := startOrbitProcess(t)
_, _, token := integrationWorkerClient(t)
api := processClient(t, a, token)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
name := "priority-crash." + workerTestID(t)
q, err := api.CreateQueue(ctx, name, CreateQueueRequest{Name: name, MaxConcurrency: 1, RatePerSecond: 100})
if err != nil {
t.Fatal(err)
}
for _, p := range []int16{0, 9, 4} {
if _, err := api.SubmitTask(ctx, workerTestID(t), SubmitTaskRequest{QueueID: q.Queue.ID, Handler: name, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`), Priority: p}); err != nil {
t.Fatal(err)
}
}
a.kill(t)
b := startOrbitProcess(t)
if a.nodeID == b.nodeID {
t.Fatal("node id reused")
}
api = processClient(t, b, token)
r, err := NewWorkerRegistration(q.Queue.ID, 1, []WorkerCapability{{Handler: name, HandlerVersion: "v1", PayloadVersion: "v1"}})
if err != nil {
t.Fatal(err)
}
w, err := api.DialWorker(ctx, r)
if err != nil {
t.Fatal(err)
}
defer w.Close()
if _, err = w.Capacity(ctx, workerTestID(t), WorkerCapacity{Sequence: 1, Capacity: 1}); err != nil {
t.Fatal(err)
}
for _, want := range []int16{9, 4, 0} {
a, err := w.NextAssignment(ctx)
if err != nil || a.Task.Priority != want {
t.Fatal("restart lost frozen dispatch order", err)
}
if _, err = w.Start(ctx, workerTestID(t), a.Attempt()); err != nil {
t.Fatal(err)
}
if _, err = w.Finish(ctx, workerTestID(t), WorkerFinish{Ref: a.Attempt(), Outcome: WorkerSucceeded, Result: json.RawMessage(`{}`)}); err != nil {
t.Fatal(err)
}
}
}

View File

@@ -22,6 +22,8 @@ type SubmitTaskRequest struct {
BusinessKey string `json:"business_key,omitempty"`
// ConcurrencyKey 显式互斥资源键,同命名空间跨队列最多一个有效尝试;不代替业务幂等。
ConcurrencyKey string `json:"concurrency_key,omitempty"`
// Priority 冻结优先级,0至9,每级30秒排序优势,不改变最早执行时间。
Priority int16 `json:"priority,omitempty"`
// AvailableAt 最早执行时间;为空表示受理后即可进入派发范围。
AvailableAt *time.Time `json:"available_at,omitempty"`
// StartDeadlineAt 最晚开始时间;为空表示使用任务总截止时间。
@@ -62,6 +64,8 @@ type Task struct {
BusinessKey string `json:"business_key,omitempty"`
// ConcurrencyKey 显式互斥资源键,同命名空间跨队列最多一个有效尝试;不代替业务幂等。
ConcurrencyKey string `json:"concurrency_key,omitempty"`
// Priority 冻结优先级,0至9,每级30秒排序优势,不改变最早执行时间。
Priority int16 `json:"priority,omitempty"`
// AvailableAt 最早可执行时间,UTC。
AvailableAt time.Time `json:"available_at"`
// StartDeadlineAt 最晚开始时间;为空时使用任务总截止时间。
@@ -132,6 +136,8 @@ type TaskSummary struct {
BusinessKey string `json:"business_key,omitempty"`
// ConcurrencyKey 显式互斥资源键,同命名空间跨队列最多一个有效尝试;不代替业务幂等。
ConcurrencyKey string `json:"concurrency_key,omitempty"`
// Priority 冻结优先级,0至9,每级30秒排序优势,不改变最早执行时间。
Priority int16 `json:"priority,omitempty"`
// AvailableAt 最早可执行时间,UTC。
AvailableAt time.Time `json:"available_at"`
// DeadlineAt 任务总截止时间,UTC。

View File

@@ -399,6 +399,9 @@ func (w *WorkerConnection) command(ctx context.Context, op, id string, ref Worke
}
func (w *WorkerConnection) validAssignment(a WorkerAssignment) bool {
if a.Task.Priority < 0 || a.Task.Priority > 9 {
return false
}
if (a.Task.ConcurrencyKey == "") != (a.ConcurrencyToken == 0) || (a.Task.ConcurrencyKey != "" && (!w.registration.ConcurrencyKeys || !validConcurrencyKey(a.Task.ConcurrencyKey))) {
return false
}