feat: support durable task cancellation without foreign keys

This commit is contained in:
2026-09-17 17:03:23 +08:00
parent 6805506450
commit 3ce7d2c104
48 changed files with 1642 additions and 164 deletions

View File

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

View File

@@ -8,9 +8,9 @@ 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) 已接入服务端和 SDK,剩余 Pulse 能力与流程引擎按 [持续开发计划](docs/08-development-plan.md) 继续实现。 已完成 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) 已接入服务端和 SDK,剩余 Pulse 能力与流程引擎按 [持续开发计划](docs/08-development-plan.md) 继续实现。
readiness 只表示数据库与迁移记录可用,不表示完整平台已具备生产服务能力。验证证据见 [P0](docs/reports/phase-0.md)、[P1](docs/reports/phase-1.md) 与 [P2 阶段验收](docs/reports/phase-2.md),后者汇总服务端、SDK、执行历史及故障验证的详细记录。P3 的当前进展见 [安全重试报告](docs/reports/phase-3-safe-retry.md) 和 [计划调度报告](docs/reports/phase-3-schedules.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)。
## 目录 ## 目录

View File

@@ -126,7 +126,7 @@ Weave 的补充接口建议如下,功能范围已确认,具体技术细则
| `task.complete` | Worker → 平台 | 提交结果或结果引用,等待持久化确认 | | `task.complete` | Worker → 平台 | 提交结果或结果引用,等待持久化确认 |
| `task.fail` | Worker → 平台 | 提交错误分类及已知业务结果,平台按冻结策略决定后续行为 | | `task.fail` | Worker → 平台 | 提交错误分类及已知业务结果,平台按冻结策略决定后续行为 |
| `task.cancel` | 平台 → Worker | 传递已保存的取消意图,不代表副作用被撤销 | | `task.cancel` | 平台 → Worker | 传递已保存的取消意图,不代表副作用被撤销 |
| `task.cancelled` | Worker → 平台 | 报告处理器停止及已知效果;未知效果转核查,不自动成为取消终态 | | `task.finish` 的 `outcome=CANCELLED` | Worker → 平台 | 已实现的取消确认;需已保存取消意图且本次停止可确认,详见 [取消契约](api/cancellation.md);不使用单独的 task.cancelled 消息 |
| `worker.drain` | 双向 | 停止接收新任务,受控完成已有任务 | | `worker.drain` | 双向 | 停止接收新任务,受控完成已有任务 |
| `ack` / `error` | 双向 | 关联明确操作的确认或错误,不能用连接 Pong 替代 | | `ack` / `error` | 双向 | 关联明确操作的确认或错误,不能用连接 Pong 替代 |

View File

@@ -1,6 +1,6 @@
# 数据模型与实现基线 # 数据模型与实现基线
更新:2026-09-17。当前已实现任务执行、安全重试与计划调度;剩余资源控制、订阅及流程编排继续开发。 更新:2026-09-17。数据库统一禁止外键。当前已实现任务执行、安全重试、运行中取消与计划调度;剩余资源控制、订阅及流程编排继续开发。
## 1. 模块与依赖 ## 1. 模块与依赖
@@ -21,13 +21,14 @@
| Go 工具链 | Go 1.27.1;服务端和 SDK 分别编译测试 | | Go 工具链 | Go 1.27.1;服务端和 SDK 分别编译测试 |
| 标识 | 128 位加密随机 ID;数据库 `BINARY(16)`,对外 32 位小写十六进制字符串 | | 标识 | 128 位加密随机 ID;数据库 `BINARY(16)`,对外 32 位小写十六进制字符串 |
| 摘要 | SHA-256,数据库 `BINARY(32)`;执行令牌及凭据原文不进入模型 | | 摘要 | SHA-256,数据库 `BINARY(32)`;执行令牌及凭据原文不进入模型 |
| 租户隔离 | 运行实体以 `(namespace_id, id)` 为主键,具体资源关联使用包含命名空间的复合外键 | | 租户隔离 | 运行实体以 `(namespace_id, id)` 为主键;资源存在性、命名空间及父资源归属由服务端事务校验 |
| 外键 | 禁止数据库外键;保留关联字段、查询索引、唯一约束和 CHECK |
| 时间 | `DATETIME(6)`,数据库会话与驱动统一 UTC;运行授权依赖数据库时间 | | 时间 | `DATETIME(6)`,数据库会话与驱动统一 UTC;运行授权依赖数据库时间 |
| 状态 | Go 类型常量 + SQL `CHECK`;状态版本独立于时间戳 | | 状态 | Go 类型常量 + SQL `CHECK`;状态版本独立于时间戳 |
| CAS | `revision` 从 1 开始;服务层必须带期望版本条件更新并检查受影响行数 | | CAS | `revision` 从 1 开始;服务层必须带期望版本条件更新并检查受影响行数 |
| 暂停 | 实例执行状态与 `paused` 分开,控制版本与通用门控同事务协调 | | 暂停 | 实例执行状态与 `paused` 分开,控制版本与通用门控同事务协调 |
| 参数 | 版本化 JSON 文档或业务侧引用;业务内容使用 MEDIUMTEXT + JSON_VALID 保留数字精度,平台元数据使用原生 JSON | | 参数 | 版本化 JSON 文档或业务侧引用;业务内容使用 MEDIUMTEXT + JSON_VALID 保留数字精度,平台元数据使用原生 JSON |
| 删除 | 关联数据默认 `RESTRICT`,没有级联删除执行历史 | | 删除 | 禁止级联删除;当前无公开硬删除接口,历史保留。后续清理须由服务端核验引用及保留窗口 |
| 不可变历史 | 流程版本、计划版本、状态事件、审计事件的 `UPDATE` 被数据库触发器拒绝 | | 不可变历史 | 流程版本、计划版本、状态事件、审计事件的 `UPDATE` 被数据库触发器拒绝 |
随机 ID 的索引成本需要后续容量测试,不将该选择描述为已经验证的大规模吞吐保证。运行账户应最小授权,迁移与保留清理使用不同身份;触发器不是对数据库管理员的防篡改保证。 随机 ID 的索引成本需要后续容量测试,不将该选择描述为已经验证的大规模吞吐保证。运行账户应最小授权,迁移与保留清理使用不同身份;触发器不是对数据库管理员的防篡改保证。
@@ -76,18 +77,22 @@
## 4. 已落实的数据库约束 ## 4. 已落实的数据库约束
- 幂等身份在命名空间、操作和资源作用域内唯一;保存请求摘要和原响应。 - 幂等身份在命名空间、操作和资源作用域内唯一;保存请求摘要和原响应。
- 一个任务的执行代次唯一,当前尝试必须属于同命名空间、同任务。 - 一个任务的执行代次唯一;当前尝试归属由执行事务另行校验。
- 计划触发按计划与 UTC 原触发时间唯一,计划修改不会绕过触发去重。 - 计划触发按计划与 UTC 原触发时间唯一,计划修改不会绕过触发去重。
- 定义版本不可覆盖,实例绑定明确的版本记录。 - 定义版本不可覆盖;实例保存版本 ID,版本归属校验随流程引擎实现。
- 步骤属于指定实例,激活属于指定步骤;避免同租户内错接另一个流程。 - 步骤和激活保留父资源 ID 及关联索引,数据库不校验跨表归属。
- 查询轮次与任务分别唯一;轮次、单轮尝试预算和截止时间独立保存。 - 查询轮次与任务分别唯一;轮次、单轮尝试预算和截止时间独立保存。
- 内部事件与外部订阅交付有去重键,租用状态要求有效租用字段。 - 内部事件与外部订阅交付有去重键,租用状态要求有效租用字段。
- 终态必须有结束时间,待执行任务不能伪造有效执行租约。 - 终态必须有结束时间,待执行任务不能伪造有效执行租约。
- effect_uncertain=true 不能和成功、失败、取消终态共存;尝试/证据 retryable=true 只允许明确失败。 - effect_uncertain=true 不能和成功、失败、取消终态共存;尝试/证据 retryable=true 只允许明确失败。
## 5. 仍需服务层事务保证的条件 ## 5. 服务层关联与事务规则
外键与 `CHECK` 无法代替执行引擎。P1 已完成 HTTP 动作/队列授权、幂等摘要与原回执、受理事件,以及待执行取消的 CAS/审计事务。执行相关的以下条件继续分阶段实现: 数据库禁止外键,关联一致性由应用负责;CHECK 仅保证单行约束。任务提交、计划创建和 Worker 注册在同一事务中按 namespace_id 校验队列存在性,校验失败回滚所有新增记录。鉴权校验凭据、命名空间及所属应用;执行入口核验进程、会话、执行代次、任务和尝试归属。接口不能仅相信调用方传入的资源 ID。
禁止“先查后另开事务写入”。需要防止并发状态改变的关联行在事务内持锁,统一锁顺序并保留必要索引。当前不提供硬删除;将来增加清理时,必须核验在途任务、引用、幂等恢复窗口与审计保留期,按有限批次处理,不能依赖数据库级联。直接绕过服务写 SQL 不受应用校验保护。
P1 已完成动作/队列授权、幂等摘要与原回执、受理事件及取消 CAS/审计事务;P3 扩展到运行中取消。流程及订阅目前只具备模型,后续实现也必须补齐事务内父资源归属验证,不能把模型存在当作已完成引擎。
P2 服务端已新增会话隔离、两步派发、租约、并发许可、结果回执/证据和恢复扫描,见 [执行协议](api/worker.md)。底层及自动 Worker SDK、尝试/证据查询、Schema 与进程故障恢复已通过 [P2 阶段验收](reports/phase-2.md)。 P2 服务端已新增会话隔离、两步派发、租约、并发许可、结果回执/证据和恢复扫描,见 [执行协议](api/worker.md)。底层及自动 Worker SDK、尝试/证据查询、Schema 与进程故障恢复已通过 [P2 阶段验收](reports/phase-2.md)。
@@ -103,7 +108,7 @@ P2 服务端已新增会话隔离、两步派发、租约、并发许可、结
## 6. 迁移与回归 ## 6. 迁移与回归
入口是 `orbit-migrate up|status`。应用进程只检查迁移状态,不执行 DDL。迁移持有数据库级命名锁,逐语句应用;失败保留 dirty 标记。由于 MySQL DDL 不整体回滚,修复必须先对照语句和实际表结构完成核查,不提供自动清除 dirty 的命令。 入口是 `orbit-migrate up|status`。应用启动及 readiness 检查迁移状态和 Orbit 表的外键数量,发现外键会拒绝启动/就绪,不执行 DDL。这不是全部手工结构漂移审计。迁移持有数据库级命名锁,逐语句应用;失败保留 dirty 标记。由于 MySQL DDL 不整体回滚,修复必须先对照语句和实际表结构完成核查,不提供自动清除 dirty 的命令。
已应用的迁移不得修改。后续使用新的前向迁移;回归环境使用独立临时数据库。初始迁移尚未在生产部署。 已应用的迁移不得修改。后续使用新的前向迁移;回归环境使用独立临时数据库。初始迁移尚未在生产部署。
@@ -115,4 +120,8 @@ P2 服务端已新增会话隔离、两步派发、租约、并发许可、结
000006_schedule_runtime 增加 Schedule.QueueID / ProcessingVersion / CheckAt 以及 ScheduleVersion.CatchupWindowMS / GraceMS。字段、单位、可空语义均有中文注释;CoveredUntil 明确为合并或跳过范围的排除边界。实体数不变,增加扫描、列表和任务来源索引。时间规则 JSON 冻结 CalendarVersion,服务内置相应时区快照。调度记录、生成任务、事件和进度同事务写入,旧版本积压不会被新发布覆盖;详见 [计划契约](api/schedules.md)。 000006_schedule_runtime 增加 Schedule.QueueID / ProcessingVersion / CheckAt 以及 ScheduleVersion.CatchupWindowMS / GraceMS。字段、单位、可空语义均有中文注释;CoveredUntil 明确为合并或跳过范围的排除边界。实体数不变,增加扫描、列表和任务来源索引。时间规则 JSON 冻结 CalendarVersion,服务内置相应时区快照。调度记录、生成任务、事件和进度同事务写入,旧版本积压不会被新发布覆盖;详见 [计划契约](api/schedules.md)。
命令与验证记录见 [开发计划](08-development-plan.md)、[基础阶段验证报告](reports/phase-0.md) 与 [执行历史回归](reports/phase-2-execution-history.md)。 000007_task_cancellation 允许尝试证据记录 CANCELLED,不新增实体;取消意图、结果和历史不确定效果的规则见 [取消契约](api/cancellation.md)。
000008_no_foreign_keys 移除历史 70 个外键,保留索引、唯一约束、CHECK、触发器和数据。旧迁移已在回归库应用,按不可变校验和约定保留原文件;新装按顺序运行至当前版本,最终结构同样为零外键。后续迁移禁止再引入外键。升级旧库时先停用旧服务,应用完整迁移再启动新版;迁移需要 DDL 锁,应在目标部署环境演练维护窗口。
命令与验证记录见 [开发计划](08-development-plan.md)、[基础阶段验证报告](reports/phase-0.md) 与 [取消及无外键回归](reports/phase-3-cancellation.md)。历史阶段报告描述当时结构,当前约束以本页为准。

View File

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

View File

@@ -2,7 +2,7 @@
- 文档版本:0.2 - 文档版本:0.2
- 整理日期:2026-09-17 - 整理日期:2026-09-17
- 阶段:P0–P2 已通过阶段验收,包括协议 Schema 与真实进程故障恢复;P3 安全重试与计划调度已通过回归,P3–P7 继续推进,尚未完成全平台生产验收 - 阶段:P0–P2 已通过阶段验收;P3 安全重试、计划调度与运行中取消已通过回归,数据库已改为无外键,P3–P7 继续推进,尚未完成全平台生产验收
- 适用范围:Orbit 统一平台,包含 Pulse 任务调度模块、Weave 流程编排模块与公共能力。平台面向多个业务系统复用。 - 适用范围:Orbit 统一平台,包含 Pulse 任务调度模块、Weave 流程编排模块与公共能力。平台面向多个业务系统复用。
## 阅读顺序 ## 阅读顺序
@@ -29,6 +29,7 @@
| 项目与部署 | 一个统一项目,内部保留 Pulse 与 Weave;平台独立于业务系统部署,业务方通过 SDK 接入 | | 项目与部署 | 一个统一项目,内部保留 Pulse 与 Weave;平台独立于业务系统部署,业务方通过 SDK 接入 |
| 开发语言 | Go | | 开发语言 | Go |
| 持久化 | MySQL 为任务、执行、计划、流程及结果的权威存储 | | 持久化 | MySQL 为任务、执行、计划、流程及结果的权威存储 |
| 数据库关联 | 禁止外键及级联删除,关联存在性与归属由服务端事务验证;保留必要索引、唯一约束和 CHECK |
| 通信方向 | HTTP + WebSocket;生产使用 HTTPS / WSS | | 通信方向 | HTTP + WebSocket;生产使用 HTTPS / WSS |
| HTTP 职责 | 任务提交、流程启动与信号、查询、计划管理、配置与管理操作 | | HTTP 职责 | 任务提交、流程启动与信号、查询、计划管理、配置与管理操作 |
| WS 职责 | Worker 接入、能力与容量上报、任务派发、续租、结果上报、取消通知 | | WS 职责 | Worker 接入、能力与容量上报、任务派发、续租、结果上报、取消通知 |
@@ -77,6 +78,8 @@ Orbit 不内置特定行业规则;Pulse 管理执行,Weave 管理步骤依
## 决策更新 ## 决策更新
2026-09-17,用户明确禁止数据库外键:通过 000008 前向迁移移除全部历史外键,同步服务端关联校验、启动检查及真实数据库回归;详见 [模型约定](07-data-model.md) 和 [验证记录](reports/phase-3-cancellation.md)。
2026-09-17,文档更新至 0.2:用户确认研发定义发布流程及 Weave 首版功能,明确纳入有界重复查询和暂停/继续等生产管理能力。技术细则与参数继续按建议或待定标注,未将功能确认等同于实现或验收通过。 2026-09-17,文档更新至 0.2:用户确认研发定义发布流程及 Weave 首版功能,明确纳入有界重复查询和暂停/继续等生产管理能力。技术细则与参数继续按建议或待定标注,未将功能确认等同于实现或验收通过。
持续开发目标已建立。当前实现和验收进度以 [开发计划](08-development-plan.md)、[P0 报告](reports/phase-0.md)、[P1 报告](reports/phase-1.md) 与 [P2 阶段验收](reports/phase-2.md) 、[P3 安全重试报告](reports/phase-3-safe-retry.md) 和 [P3 计划调度报告](reports/phase-3-schedules.md) 为准;早期设计稿中的“未实现”描述保留其编写阶段语境。 持续开发目标已建立。当前实现和验收进度以 [开发计划](08-development-plan.md)、[P0 报告](reports/phase-0.md)、[P1 报告](reports/phase-1.md) 与 [P2 阶段验收](reports/phase-2.md) 、[P3 安全重试报告](reports/phase-3-safe-retry.md) 和 [P3 计划调度报告](reports/phase-3-schedules.md) 为准;早期设计稿中的“未实现”描述保留其编写阶段语境。

View File

@@ -7,7 +7,7 @@
| 接口 | 状态 | 手册 | | 接口 | 状态 | 手册 |
| --- | --- | --- | | --- | --- | --- |
| `GET /health/live` | 已实现 | [健康检查](health.md) | | `GET /health/live` | 已实现 | [健康检查](health.md) |
| `GET /health/ready` | 已实现,仅验证数据库与迁移记录 | [健康检查](health.md) | | `GET /health/ready` | 已实现,验证数据库、迁移记录及零外键要求 | [健康检查](health.md) |
| `/v1/tasks` 提交、查询、分页与待执行取消 | P1 已实现 | [任务接口](tasks.md) | | `/v1/tasks` 提交、查询、分页与待执行取消 | P1 已实现 | [任务接口](tasks.md) |
| `/v1/tasks/{task_id}/attempts` 与尝试详情 | P2 已实现 | [执行历史](execution-history.md) | | `/v1/tasks/{task_id}/attempts` 与尝试详情 | P2 已实现 | [执行历史](execution-history.md) |
| `/v1/tasks/{task_id}/attempts/{attempt_id}/evidence` 与证据详情 | P2 已实现 | [执行历史](execution-history.md) | | `/v1/tasks/{task_id}/attempts/{attempt_id}/evidence` 与证据详情 | P2 已实现 | [执行历史](execution-history.md) |
@@ -15,6 +15,7 @@
| 凭据校验与初始化 CLI | P1 已实现 | [鉴权](authentication.md) | | 凭据校验与初始化 CLI | P1 已实现 | [鉴权](authentication.md) |
| `/v1/worker/connect` | P2 已实现并通过阶段验收 | [Worker 契约](worker.md)、[Schema](worker-schema.md) | | `/v1/worker/connect` | P2 已实现并通过阶段验收 | [Worker 契约](worker.md)、[Schema](worker-schema.md) |
| 任务冻结策略与 Worker `retryable` | P3 安全重试已实现 | [安全重试](retries.md) | | 任务冻结策略与 Worker `retryable` | P3 安全重试已实现 | [安全重试](retries.md) |
| `/v1/tasks/{task_id}:cancel` 与 `task.cancel` 通知 | P3 运行中取消已实现 | [取消及 SDK 场景](cancellation.md) |
| `/v1/schedules`、不可变版本、暂停/恢复及触发查询 | P3 已实现 | [计划接口与 SDK 场景](schedules.md) | | `/v1/schedules`、不可变版本、暂停/恢复及触发查询 | P3 已实现 | [计划接口与 SDK 场景](schedules.md) |
| `/v1/events/connect` | 计划 P3,尚未实现 | [协议草案](../03-http-websocket-protocol.md) | | `/v1/events/connect` | 计划 P3,尚未实现 | [协议草案](../03-http-websocket-protocol.md) |
| `/v1/workflows` 与流程管理 | 计划 P4–P5,尚未实现 | [流程接口规划](workflows.md) | | `/v1/workflows` 与流程管理 | 计划 P4–P5,尚未实现 | [流程接口规划](workflows.md) |

108
docs/api/cancellation.md Normal file
View File

@@ -0,0 +1,108 @@
# 任务取消与结果确认
状态:已实现 HTTP 取消意图、开始前取消、WSS 通知、重连核对及 Go Worker 自动停止。取消记录、审计、事件和回执使用 MySQL 事务;运行结果仍以有效尝试的确认或到期核查为准。
## 1. HTTP 接口
`POST /v1/tasks/{task_id}:cancel`。需要当前 `tasks:cancel` 动作和目标队列权限;Bearer、JSON 和 Idempotency-Key 规则同 [任务接口](tasks.md)。适用于普通任务和计划生成任务;流程拥有的任务由流程控制管理,此入口返回 409。
```json
{"expected_revision":"3","reason":"用户终止本次处理"}
```
| 当前状态 | 提交后行为 | HTTP 状态 |
| --- | --- | --- |
| WAITING | 阻止后续尝试,通常成为 CANCELLED | 200 |
| LEASED | 与开始事务竞争;尚未授权时结束尝试并释放许可 | 200 |
| RUNNING | 保持 RUNNING,记录 cancel_requested_at;许可暂不释放 | 202 |
| NEEDS_REVIEW | 保持核查状态,保存取消意图,不清除未知效果 | 200 |
| 已有取消意图或终态 | 新操作身份返回 STATE_CONFLICT;原键仍返回首次回执 | 409 或原回执状态 |
成功的 data 为 `{"task":<操作提交时的完整任务快照>,"deduplicated":false}`,与 CancelTaskResult 一致。202 只表示取消意图已经可靠保存,不能解释为处理器已经停止。
WAITING/LEASED 若承接先前未知效果,取消后进入 NEEDS_REVIEW,保留 effect_uncertain=true 和空 completed_at。本次未开始不能证明此前尝试没有产生效果。
请求结果不明时,保留原键、原 expected_revision 和原 reason 重报。只有明确收到 STATE_CONFLICT 后,才读取新状态并创建新的管理操作;任务的续租也会更新 revision。已保存的 RUNNING 取消回执即使任务后来结束,重报仍是原快照和 202,当前状态用 GET 查询。
取消与开始竞争有两种有效结果:取消先提交则 Start 不获授权;Start 先提交则旧 expected_revision 冲突,重新查询后可记录运行中取消。权限、名称空间、幂等回执、版本和审计都在事务内核对。
## 2. Worker 协议
Hello 可选 `cancellation_notifications:true` 表示接收异步通知。低层 SDK 的 WorkerRegistration 默认为 false,自动 NewWorker 显式开启。未开启的连接不会收到新通知类型。
服务端通知示例,session_id 是当前连接,消息没有 reply_to:
```json
{
"protocol_version":"1",
"type":"task.cancel",
"message_id":"11111111111111111111111111111111",
"session_id":"22222222222222222222222222222222",
"body":{
"task_id":"33333333333333333333333333333333",
"attempt_id":"44444444444444444444444444444444",
"epoch":"1",
"requested_at":"2026-09-17T08:00:00.123456Z"
}
}
```
每次连接按数据库事实扫描当前拥有的运行尝试,通常约一秒发现取消,实际延迟受数据库和网络影响。通知允许重复、迟到、断线丢失,只作用于 TaskID/AttemptID/Epoch 全部匹配的尝试。单次读取最多 256 条,连接发送与 SDK 接收缓冲有界;慢消费者断开后重新核对。
Start/Renew/Resume 返回的当前 Lease 也包含可选 `cancel_requested_at`;即使原 Start 回执是已批准执行,当前 Lease 中的取消意图也不能忽略。Active=true 表示原尝试仍可在有效期内提交权威结果,**有取消意图时不得继续业务处理**。SDK 的 ExecutionDeadline 在此情况下返回 false。
取消后 Renew 不延长任何执行期限。处理器应尽快停止并提交已知结果;原租约或尝试截止到期后,未确认效果转 UNKNOWN/NEEDS_REVIEW,禁止因任务幂等声明自动再试。服务停止、通知丢失或 Worker 不配合,都不会自动成为 CANCELLED。
结果继续使用 `task.finish`,新增 outcome=CANCELLED;error_code 必填稳定分类,retryable 不得为 true,result 是非 null JSON 或核查证据引用。不存在已保存取消意图时,此结果被拒绝。
| 有效尝试上报 | 任务结果 |
| --- | --- |
| SUCCEEDED | SUCCEEDED,成功不被取消意图覆盖 |
| FAILED | FAILED,停止后续自动重试;先前效果未知则仍需核查 |
| UNKNOWN | NEEDS_REVIEW,不能把 context 错误当作撤销证明 |
| CANCELLED | 本次确认停止且无遗留效果;若此前存在未知效果,仍为 NEEDS_REVIEW |
| 租约已失效或结果冲突 | 追加 EVIDENCE_ONLY,不覆盖权威状态 |
新版要求先停用旧服务、应用全部迁移(000007 扩展取消证据,000008 移除外键),再启动新版服务,最后升级自动 Worker。旧服务会拒绝新增 Hello 字段或 CANCELLED 结果;不能将带新能力的连接随机路由至旧节点。旧低层客户端可以继续原协议,但必须升级后才能依赖自动取消通知;取消后的续租不延长期限,同样适用于旧客户端。数据库升级细节见 [数据模型](../07-data-model.md)。
## 3. Go SDK 接入
自动 Worker 将业务 context 的 cause 设置为 `orbit.ErrTaskCancellationRequested`。仅当接入方能确认本次已停止且无未结清效果时返回 `*orbit.WorkerCancellationConfirmed`:
```go
<-ctx.Done()
if !errors.Is(context.Cause(ctx), orbit.ErrTaskCancellationRequested) {
return nil, ctx.Err()
}
// 先完成本次外部操作核实;此处的类型是业务声明,不替代核实。
return nil, &orbit.WorkerCancellationConfirmed{
Details: json.RawMessage(`{"evidence_ref":"objects/stopped"}`),
}
```
普通 ctx.Err()、panic、网络错误和无效结果仍属于 UNKNOWN。处理器已经成功时返回真实结果,即使此时 context 也被取消,平台仍按有效结果确认。开始批准已提交但 ACK 丢失时,SDK 重连先检查取消意图;如果处理器尚未被调用,可以明确报告本次没有副作用,禁止重启处理器。
低层接口设置 WorkerRegistration.CancellationNotifications 后,使用 NextCancellation 持续读取,并同时检查每次 Start/Renew/Resume 的 Lease。NextCancellation 的等待 context 取消只停止等待,不关闭连接。取消响应丢失后的 Finish 重报必须保留相同消息身份和结果。
任务取消不等同于取消 Run context:前者停止指定业务处理器,后者要求整个 Worker 排空。无法强杀不配合 context 的 goroutine;它仍占本地物理槽,ShutdownTimeout 到期会报告未收尾数量。
## 4. 可运行场景
源码:[cancellation/main.go](../../sdk/go/examples/cancellation/main.go)。使用 [Worker 初始化](../sdk/worker-runtime.md) 的私有凭据文件,需 tasks:submit/read/cancel、workers:connect/consume 和队列范围。此示例只等待停止提示,没有外部业务副作用。
```sh
cd sdk/go
ORBIT_URL=http://127.0.0.1:8080 \
ORBIT_CREDENTIALS_FILE=/private/path/worker.credentials.json \
go run ./examples/cancellation
```
预期输出:
```text
task=<任务ID> cancel_requested=true accepted_state=RUNNING
final_state=CANCELLED attempts=1
worker_drained=true running=0
```
每次运行使用新任务/处理器身份;正常执行保留任务、尝试和审计记录,不删除历史。错误时非零退出并尝试有界收尾。网络结果不明时示例停止,真实集成应保存原幂等身份以便核对。

View File

@@ -18,7 +18,7 @@ HTTP 200,`Content-Type: application/json`:
## GET /health/ready ## GET /health/ready
检查数据库连接和迁移记录是否与当前二进制的版本、校验和一致且没有 dirty 标记。内部检查最多 2 秒。 检查数据库连接和迁移记录是否与当前二进制的版本、校验和一致且没有 dirty 标记,并检查 Orbit 表的数据库外键为 0。内部检查最多 2 秒。
```sh ```sh
curl -i http://127.0.0.1:8080/health/ready curl -i http://127.0.0.1:8080/health/ready
@@ -30,7 +30,7 @@ curl -i http://127.0.0.1:8080/health/ready
{"status":"ready","scope":"database_and_schema"} {"status":"ready","scope":"database_and_schema"}
``` ```
数据库不可用、迁移缺失、记录被修改或存在 dirty 标记时 HTTP 503: 数据库不可用、迁移缺失、记录被修改、存在 dirty 标记或 Orbit 表被添加外键时 HTTP 503:
```json ```json
{"status":"not_ready"} {"status":"not_ready"}

View File

@@ -1,6 +1,6 @@
# 安全重试 v1 # 安全重试 v1
状态:P3 的安全重试已实现。使用现有任务提交与 Worker 结果接口,无独立的自动重试路由;[Cron/固定间隔计划](schedules.md) 已接入;运行中取消、人工恢复与可靠结果订阅继续按 [计划](../08-development-plan.md) 开发。验证证据见 [本阶段报告](../reports/phase-3-safe-retry.md)。 状态:P3 的安全重试已实现。使用现有任务提交与 Worker 结果接口,无独立的自动重试路由;[Cron/固定间隔计划](schedules.md) 和 [运行中取消](cancellation.md) 已接入;人工恢复与可靠结果订阅继续按 [计划](../08-development-plan.md) 开发。验证证据见 [本阶段报告](../reports/phase-3-safe-retry.md)。
## 1. 三种不同的重报 ## 1. 三种不同的重报
@@ -54,7 +54,7 @@ available_at = 本次结束事务中的数据库时间 + delay_ms
`task.requeued` 等持久化事件记录 decision、delay_ms、next_available_at、attempt_count、max_attempts、effect_uncertain。decision 包括 RETRYABLE_FAILURE、IDEMPOTENT_UNKNOWN、UNSTARTED_LEASE_EXPIRED、ATTEMPT_LIMIT、DEADLINE_EXCEEDED、RECONCILIATION_REQUIRED 等。当前可靠事件订阅尚未开放。 `task.requeued` 等持久化事件记录 decision、delay_ms、next_available_at、attempt_count、max_attempts、effect_uncertain。decision 包括 RETRYABLE_FAILURE、IDEMPOTENT_UNKNOWN、UNSTARTED_LEASE_EXPIRED、ATTEMPT_LIMIT、DEADLINE_EXCEEDED、RECONCILIATION_REQUIRED 等。当前可靠事件订阅尚未开放。
当前取消接口仅支持不归属流程的 WAITING 任务。若等待的是历史效果不明后的重试,取消会记录意图、停止后续尝试并进入 NEEDS_REVIEW,不能宣称此前业务已撤销;其他 WAITING 任务可进入 CANCELLED。运行中取消仍未开放。 取消接口支持独立任务和计划任务的 WAITING、LEASED、RUNNING 与 NEEDS_REVIEW。取消意图阻止后续尝试;运行中继续收集结果,普通取消错误或失联保持 UNKNOWN,不因 IDEMPOTENT 声明再次执行。此前未核实的效果不会被本次确认取消清除,详见 [取消状态与结果表](cancellation.md)。
## 4. SDK 接入 ## 4. SDK 接入

View File

@@ -152,7 +152,7 @@ curl "$ORBIT_URL/v1/tasks?queue_id=$ORBIT_QUEUE_ID&state=WAITING&limit=20" \
## POST /v1/tasks/{task_id}:cancel ## POST /v1/tasks/{task_id}:cancel
权限:tasks:cancel 和队列范围。当前只允许取消 WAITING 状态且不归属流程的任务。LEASED/RUNNING 或流程任务暂时返回 409,后续由执行与流程控制实现安全取消。 权限:tasks:cancel 和队列范围。支持独立任务和计划生成任务的 WAITING、LEASED、RUNNING、NEEDS_REVIEW;流程所属任务仍由后续流程控制管理。运行中只记录取消意图,返回 202,最终状态等待有效结果或超时核查。完整规则及 SDK 示例见 [取消契约](cancellation.md)。
```sh ```sh
curl "$ORBIT_URL/v1/tasks/$TASK_ID:cancel" \ curl "$ORBIT_URL/v1/tasks/$TASK_ID:cancel" \
@@ -164,7 +164,7 @@ curl "$ORBIT_URL/v1/tasks/$TASK_ID:cancel" \
expected_revision 必填,为非零 uint64 的十进制字符串;reason 必填且不能全为空白,最多 1024 个 Unicode 字符。服务端以版本和状态条件更新,只有一个竞争请求能成功。 expected_revision 必填,为非零 uint64 的十进制字符串;reason 必填且不能全为空白,最多 1024 个 Unicode 字符。服务端以版本和状态条件更新,只有一个竞争请求能成功。
200 的 data 为 `{"task":<完整最新任务>,"deduplicated":false}`。示例任务会变为 state=CANCELLED、revision="2",写入 cancel_requested_at/completed_at,并同事务追加取消事件及审计。首次取消之后使用相同键、原因和**原 expected_revision**重报,返回原取消回执且 deduplicated=true。换键重报已取消任务返回 409。若 WAITING 来自未知效果后的自动重试,取消会保存 cancel_requested_at 并转 NEEDS_REVIEW,保留 effect_uncertain=true、completed_at 为空,停止后续尝试但不宣称此前效果已经撤销。 200/202 的 data 为 `{"task":<本次取消的任务快照>,"deduplicated":false}`。上方等待态示例会变为 state=CANCELLED、revision="2",写入 cancel_requested_at/completed_at,并同事务追加取消事件及审计。首次取消之后使用相同键、原因和**原 expected_revision**重报,返回原取消回执且 deduplicated=true;原回执为 RUNNING 时仍返回 202,最新状态另行 GetTask。换键重报已有取消意图或终态任务返回 409。WAITING/LEASED 若承接历史未知效果,取消转 NEEDS_REVIEW,保留 effect_uncertain=true、completed_at 为空;已在核查的任务保持核查。
SDK:`Client.CancelTask(ctx, taskID, key, request)`。此操作不承诺回滚任何已发生的外部业务效果。 SDK:`Client.CancelTask(ctx, taskID, key, request)`。此操作不承诺回滚任何已发生的外部业务效果。
@@ -186,4 +186,4 @@ SDK:`Client.CancelTask(ctx, taskID, key, request)`。此操作不承诺回滚
{"request_id":"44444444444444444444444444444444","error":{"code":"IDEMPOTENCY_CONFLICT","message":"idempotency key was used with different content"}} {"request_id":"44444444444444444444444444444444","error":{"code":"IDEMPOTENCY_CONFLICT","message":"idempotency key was used with different content"}}
``` ```
有限自动重试已实现,具体分类、退避、回执和升级边界见 [安全重试](retries.md)。批量受理、人工重试和运行中取消尚未实现。[执行历史](execution-history.md) 已提供尝试与证据的列表和详情。完整可运行程序见 [SDK 任务示例](../../sdk/go/examples/tasks/main.go),已验证异常场景见 [P1 回归报告](../reports/phase-1.md)。SDK 忽略未知响应字段;更改已有字段语义需升级契约和回归。 有限自动重试已实现,具体分类、退避、回执和升级边界见 [安全重试](retries.md)。[运行中取消](cancellation.md) 已实现;批量受理和人工重试尚未实现。[执行历史](execution-history.md) 已提供尝试与证据的列表和详情。完整可运行程序见 [SDK 任务示例](../../sdk/go/examples/tasks/main.go),已验证异常场景见 [P1 回归报告](../reports/phase-1.md) 及 [取消回归](../reports/phase-3-cancellation.md)。SDK 忽略未知响应字段;更改已有字段语义需升级契约和回归。

View File

@@ -17,6 +17,7 @@ Schema 根接受双向消息,调用方应根据方向选择更严格的定义
| `#/$defs/resumeFrame` | 尝试归属恢复请求 | | `#/$defs/resumeFrame` | 尝试归属恢复请求 |
| `#/$defs/startFrame`、`renewFrame`、`finishFrame` | 开始、续租、结果报告 | | `#/$defs/startFrame`、`renewFrame`、`finishFrame` | 开始、续租、结果报告 |
| `#/$defs/welcomeFrame`、`assignmentFrame` | 注册确认与主动派发 | | `#/$defs/welcomeFrame`、`assignmentFrame` | 注册确认与主动派发 |
| `#/$defs/cancellationFrame` | 已选择通知的连接接收 task.cancel,无 reply_to |
| `#/$defs/ackFrame`、`errorFrame` | 操作确认与公开错误 | | `#/$defs/ackFrame`、`errorFrame` | 操作确认与公开错误 |
除首条 hello 外,请求必须提供当前 session_id。请求不得包含响应专用字段,包括 `reply_to:""` 或 `error:null`。主动派发没有 reply_to;操作响应通过 reply_to 关联原消息。error 在注册失败时不带 session_id;会话内错误带 session_id,主动致命错误不带 reply_to。 除首条 hello 外,请求必须提供当前 session_id。请求不得包含响应专用字段,包括 `reply_to:""` 或 `error:null`。主动派发没有 reply_to;操作响应通过 reply_to 关联原消息。error 在注册失败时不带 session_id;会话内错误带 session_id,主动致命错误不带 reply_to。
@@ -29,7 +30,8 @@ ACK 的 body 根据原请求选择:worker.capacity → welcome,worker.resume
- 结构体必填字段必须显式提供,包括 capacity=0、drain=false;缺少字段不能被解释为调用者明确选择零值。 - 结构体必填字段必须显式提供,包括 capacity=0、drain=false;缺少字段不能被解释为调用者明确选择零值。
- epoch、sequence、revision 为规范十进制字符串:范围 1–18446744073709551615,无前导零和正号。SDK 使用 uint64,线上使用字符串。 - epoch、sequence、revision 为规范十进制字符串:范围 1–18446744073709551615,无前导零和正号。SDK 使用 uint64,线上使用字符串。
- instance_secret/lease_secret 必须是 32 字节秘密的严格无填充 base64url,长度 43;知道资源 ID 无法替代秘密。 - instance_secret/lease_secret 必须是 32 字节秘密的严格无填充 base64url,长度 43;知道资源 ID 无法替代秘密。
- 结果为非 null JSON;FAILED/UNKNOWN 必须携带稳定 error_code;SUCCEEDED 省略或使用空 error_code。 - 结果为非 null JSON;FAILED/UNKNOWN/CANCELLED 必须携带稳定 error_code;SUCCEEDED 省略或使用空 error_code。
- hello.cancellation_notifications 可选且默认 false;Lease 可返回 cancel_requested_at。CANCELLED 需要事务核验已保存意图;通知、取消后执行边界与升级顺序见 [取消契约](cancellation.md)。
- finish.retryable 为可选布尔值,默认 false;true 仅允许 FAILED,Schema 与服务端都校验。派发任务可带 effect_uncertain=true,表示此前授权执行的效果尚未核实。业务重试条件见 [安全重试](retries.md)。 - finish.retryable 为可选布尔值,默认 false;true 仅允许 FAILED,Schema 与服务端都校验。派发任务可带 effect_uncertain=true,表示此前授权执行的效果尚未核实。业务重试条件见 [安全重试](retries.md)。
- 返回时间使用 UTC RFC 3339,最多微秒精度。验证器应开启 format 断言以校验实际日历日期。 - 返回时间使用 UTC RFC 3339,最多微秒精度。验证器应开启 format 断言以校验实际日历日期。

View File

@@ -65,6 +65,7 @@ go run ./cmd/orbit-admin bootstrap \
| queue_id | 当前进程绑定队列 | | queue_id | 当前进程绑定队列 |
| capabilities | 1–32 个 handler / handler_version / payload_version 精确版本组合,不可重复 | | capabilities | 1–32 个 handler / handler_version / payload_version 精确版本组合,不可重复 |
| capacity | 总执行槽数量,1–256,不是可用槽的累加值 | | capacity | 总执行槽数量,1–256,不是可用槽的累加值 |
| cancellation_notifications | 可选 bool,默认 false;true 接收 task.cancel 通知,自动 Worker 默认开启 |
重连必须保持凭据、队列、处理器集合和进程秘密,处理器集合的排列顺序不影响身份。新连接递增会话代次并使旧会话失效。知道 instance_id 或 task_id 不能接管执行。 重连必须保持凭据、队列、处理器集合和进程秘密,处理器集合的排列顺序不影响身份。新连接递增会话代次并使旧会话失效。知道 instance_id 或 task_id 不能接管执行。
@@ -90,7 +91,7 @@ capacity 使用单调递增序号和覆盖值。同序号同内容可重复,
| task.renew | AttemptRef | 只续期当前 RUNNING;新消息可续租,重复原消息只取回原回执 | | task.renew | AttemptRef | 只续期当前 RUNNING;新消息可续租,重复原消息只取回原回执 |
| task.finish | ref: AttemptRef、outcome、result、error_code、可选 retryable | 保存明确结果或未知效果;返回操作回执和当前授权 | | task.finish | ref: AttemptRef、outcome、result、error_code、可选 retryable | 保存明确结果或未知效果;返回操作回执和当前授权 |
outcome 为 SUCCEEDED / FAILED / UNKNOWN。result 为非 null JSON 或证据引用。FAILED/UNKNOWN 必须带 1–64 字符的稳定 error_code;SUCCEEDED 不带错误分类。retryable 省略为 false,只有 FAILED 可设 true,表示此次明确失败可安全重试。FAILED/true 受次数与时限限制进入退避;UNKNOWN 仅在冻结策略为 IDEMPOTENT 且预算允许时自动恢复,其他情况转 NEEDS_REVIEW。历史不确定效果不会被后续失败覆盖;完整规则见 [安全重试](retries.md)。 outcome 为 SUCCEEDED / FAILED / UNKNOWN / CANCELLED。result 为非 null JSON 或证据引用。非成功结果必须带 1–64 字符的稳定 error_code;SUCCEEDED 不带错误分类。retryable 省略为 false,只有 FAILED 可设 true。CANCELLED 要求已保存取消意图且确认本次停止,不能用普通 context 错误推断;协议与升级顺序见 [取消契约](cancellation.md)。FAILED/true 受次数与时限限制进入退避;UNKNOWN 仅在冻结策略为 IDEMPOTENT、未取消且预算允许时自动恢复,其他情况转 NEEDS_REVIEW。历史不确定效果不会被后续失败或取消覆盖;完整规则见 [安全重试](retries.md)。
开始、续租和结束返回 CommandResult: 开始、续租和结束返回 CommandResult:
@@ -116,7 +117,7 @@ outcome 为 SUCCEEDED / FAILED / UNKNOWN。result 为非 null JSON 或证据引
receipt 是原操作的不可变确认;lease 是本次重新核验的执行权,两者不能混用: receipt 是原操作的不可变确认;lease 是本次重新核验的执行权,两者不能混用:
- 只有 lease.active=true、lease.state=RUNNING,且仍在保守的本地有效期内,才允许处理器推进。LEASED 的 active 只表示预占有效。 - 只有 lease.active=true、lease.state=RUNNING、没有 cancel_requested_at,且仍在保守的本地有效期内,才允许处理器推进。LEASED 的 active 只表示预占有效。取消后的 active 可为 true,用于确认当前结果;不允许继续业务执行或延长期限。
- 原 start 回执可能仍表示 RUNNING,但当前 lease.active=false;旧 ACK 不能让已过期、已结束或已被替换的尝试复活。 - 原 start 回执可能仍表示 RUNNING,但当前 lease.active=false;旧 ACK 不能让已过期、已结束或已被替换的尝试复活。
- 有效期不超过租约窗口、会话期限、任务总截止和从 authorized_at 起计算的单次尝试时限。 - 有效期不超过租约窗口、会话期限、任务总截止和从 authorized_at 起计算的单次尝试时限。
- 客户端需要扣除请求总耗时及安全余量,用本地单调时钟停止执行,不能假定本地墙上时钟与数据库一致。 - 客户端需要扣除请求总耗时及安全余量,用本地单调时钟停止执行,不能假定本地墙上时钟与数据库一致。
@@ -145,4 +146,4 @@ receipt 是原操作的不可变确认;lease 是本次重新核验的执行权
这些是有界的开发基线,不代表经过容量压测的生产参数。当前同队列操作使用较粗的行锁协调,吞吐与公平性需要 P6 按负载验证。服务退出会显式关闭被 HTTP Upgrade 接管的连接;断开不会直接撤销外部已发出的请求。 这些是有界的开发基线,不代表经过容量压测的生产参数。当前同队列操作使用较粗的行锁协调,吞吐与公平性需要 P6 按负载验证。服务退出会显式关闭被 HTTP Upgrade 接管的连接;断开不会直接撤销外部已发出的请求。
协议 Schema 和真实进程强杀恢复已通过回归,见 [P2 阶段验收](../reports/phase-2.md)。运行中取消属于 P3;凭据轮换、数据库故障切换、跨主机演练与容量验收属于后续生产验证。自动运行器对非协作处理器提供有界退出与状态报告,不能强制停止其外部副作用。 协议 Schema 和真实进程强杀恢复已通过回归,见 [P2 阶段验收](../reports/phase-2.md)。[运行中取消](cancellation.md) 已实现并通过回归;凭据轮换、数据库故障切换、跨主机演练与容量验收属于后续生产验证。自动运行器对非协作处理器提供有界退出与状态报告,不能强制停止其外部副作用。

View File

@@ -63,6 +63,15 @@
- 异常:串行前项未知时不新建后续任务;恢复按版本与漏跑策略处理,超过窗口记录跳过。暂停不撤销已生成任务。 - 异常:串行前项未知时不新建后续任务;恢复按版本与漏跑策略处理,超过窗口记录跳过。暂停不撤销已生成任务。
- 清理:计划最终暂停,自带十秒结束边界;失败非零退出,历史保留,无外部业务副作用。 - 清理:计划最终暂停,自带十秒结束边界;失败非零退出,历史保留,无外部业务副作用。
## 已可运行:运行中任务取消
- 源码:[取消示例](../../sdk/go/examples/cancellation/main.go),[初始化、调用与输出](../api/cancellation.md)。
- 前置:当前迁移、全部服务节点升级完成,凭据具备任务提交/读取/取消和 Worker 权限。
- 步骤:提交并开始受控任务 → 保存取消意图 → 处理器遵守 context 停止 → 明确确认本次无未结清效果 → 查询最终状态并排空。
- 预期:accepted_state=RUNNING、final_state=CANCELLED、attempts=1、running=0。
- 异常:普通 context 错误、通知丢失和未配合停止不能自动判定取消成功;未知效果进入核查。CAS 冲突先查询,网络结果未知保留原身份核对。
- 清理:无外部业务副作用;历史保留,每次运行独立身份,失败非零退出并有界收尾。
## 计划场景与验收标准 ## 计划场景与验收标准
| 场景 | 输入与步骤 | 预期结果 | 必测异常 | 状态 | | 场景 | 输入与步骤 | 预期结果 | 必测异常 | 状态 |

View File

@@ -0,0 +1,104 @@
# P3 取消与无外键回归记录
验证日期:2026-09-17。本阶段交付任务取消闭环,并落实用户要求的无数据库外键设计。P3 配额/背压、可靠订阅及 P4–P7 仍未完成;持续目标保持进行中。
## 1. 已实现行为
- WAITING、LEASED、RUNNING、NEEDS_REVIEW 的取消控制,动作与队列授权、CAS、稳定幂等身份、取消事件及审计同事务提交。
- 开始前取消阻止 Start 并释放许可;运行中只保存意图、返回 HTTP 202,不提前释放许可或伪造完成。
- 取消后不延长期限、不产生后续重试;有效成功结果仍能确认,普通取消错误或失联保持未知效果。
- 明确取消结果使用 task.finish / CANCELLED;本次确认停止不能清除此前未核实的外部效果。
- 可选择的 task.cancel 通知、Resume/Start/Renew 中的当前取消意图、独立 SDK 自动停止处理器和有界收尾。
- [完整取消场景](../../sdk/go/examples/cancellation/main.go)、[接口与升级说明](../api/cancellation.md) 已同步交付。
- 迁移 000007 扩展取消证据;000008 移除 70 个历史外键,保留索引、唯一约束、CHECK、触发器和数据。实体数仍为 32,没有新增持久化字段。
- 开发规范明确禁止再引入外键。应用启动/readiness 核验迁移及零外键要求;任务、计划及 Worker 关联由服务端事务校验。
## 2. 环境、命令及结果
macOS 本机、Go 1.27.1、Docker 单实例 MySQL 8.4.10。完整集成使用一次性数据库、随机凭据及回环端口;测试结束删除容器,没有修改已有业务库。
```sh
make fmt test vet build
make integration
go test -race ./internal/transport/httpserver -run '^TestWorkerPublishedContract$'
cd sdk/go
GOWORK=off go test -race ./...
GOWORK=off go vet ./...
```
上述检查均通过。普通单元运行未配置集成环境时会跳过数据库/外部进程用例,真实证据来自完整 integration。
| 测试组 | 本轮实际结果 |
| --- | --- |
| MySQL 全部集成 | PASS,52.851 秒 |
| 历史 v7 → 当前无外键升级 | PASS,3.99 秒 |
| 无外键条件下的资源归属校验 | PASS,0.08 秒 |
| 取消事务、恢复及竞争 | PASS,2.89 秒 |
| SDK Worker 连接与运行器全部集成 | PASS,78.267 秒 |
| 其中自动取消六种故障/结果场景 | PASS,15.51 秒 |
| SDK 计划生命周期 | PASS,含包开销 4.586 秒 |
| 真实进程故障组 | PASS,21.500 秒 |
| 八个完整示例 | health / tasks / worker-protocol / worker / history / retry / schedules / cancellation 全部通过 |
上述耗时是单次测试用时,不是性能或容量指标。
## 3. 零外键与关联校验证据
[无外键集成用例](../../internal/storage/mysql/no_foreign_keys_integration_test.go) 在独立库重建真实 v7,再使用正式 Migrate 入口升级:
- information_schema 中外键从 70 变为 0,新建完整结构也断言外键为 0。
- 升级前后的全部 325 条索引列定义完全一致,包含索引名、唯一性、列顺序、前缀、类型及可见性。
- 预置任务的所有字段逐一核对不变,包括大整数参数、状态、版本、策略和时间;再次执行迁移成功。
- 仅在一次性测试库故意添加违规外键,验证 CheckSchema 拒绝;移除后恢复通过。这是漂移注入测试,正常迁移与服务不创建该测试外键。
- 对不存在的队列及另一命名空间队列,任务提交、计划创建、Worker 注册均返回 NOT_FOUND;无残留任务、计划、幂等记录或新 Worker 会话。
- 会话的父进程关系被故意改坏后,控制请求返回 STALE_SESSION,不能借用错误关联继续消费。
Worker 注册补充了事务内队列存在性检查;会话使用核验进程、凭据及代次。已有任务、计划和执行事务保留其命名空间、父资源与尝试身份校验。
历史迁移文件保留原校验和,通过 000008 前向移除外键;新装也运行至当前版本。取消/恢复不再取得为历史外键检查准备的会话共享锁,使用队列 → 任务 → 尝试 → 许可的锁顺序。此前开始/取消竞争曾因隐式外键锁发生死锁,本轮在无外键结构上通过该竞争用例;这不表示所有负载下不存在其他锁竞争。
应用校验不能防止绕过服务的直接 SQL 错写。当前无公开硬删除,后续清理及流程/订阅引擎必须遵守 [关联与保留规则](../07-data-model.md)。
## 4. 取消事务证据
[数据库用例](../../internal/storage/mysql/worker_cancel_integration_test.go) 覆盖:
- LEASED 取消后禁止 Start,许可只释放一次,迟到成功仅作为证据。
- RUNNING 收到取消时保持任务/尝试运行及许可;有效成功、失败、未知、确认取消分别按真实结果处理,不创建重试。
- 旧 Start ACK 保留原回执,同时带当前取消意图;取消后 Renew 不延长期限。
- 新节点会话隔离旧连接,Resume 后取消意图仍存在;原取消请求在任务最终完成后重报,仍返回原 RUNNING 快照。
- 先前未知效果不能被后一次确认取消抹掉;无取消意图的 CANCELLED 报告被拒绝。
- 测试触发器使审计写入失败,取消意图和版本整体回滚;解除故障后可用原身份重试。
- 已取消的授权尝试到期进入核查,迟到取消结果只保存证据。
- 12 次并发 Start/Cancel:取消获胜阻止开始,Start 获胜使旧版本取消冲突,随后可按新版本记录运行中意图。
## 5. SDK 与故障证据
[SDK 真实服务用例](../../sdk/go/worker_cancel_integration_test.go) 覆盖六种情况:
1. 明确确认停止,丢失 Finish ACK 后原结果重报,最终 CANCELLED 且一次尝试。
2. 普通 context 错误返回 UNKNOWN,任务进入 NEEDS_REVIEW。
3. 取消后仍取得真实成功结果,成功和精确整数保留。
4. 丢失取消通知并断开连接,重连 Resume 仍能停止原处理器。
5. Start ACK 丢失且暂时断网,取消后重连,处理器调用次数为 0。
6. 处理器不配合取消,任务到期核查,Run 在 ShutdownTimeout 后返回未收尾错误,不能假称已排空。
协议 Schema 与实际解析共同验证可选 Hello 字段、取消通知、当前 Lease 意图、CANCELLED 结果和 retryable 分类。低层 SDK 验证通知读取、取消等待不关闭连接,以及带取消意图的 Lease 不提供继续执行权。
完整取消示例实际输出:
```text
task=<独立任务ID> cancel_requested=true accepted_state=RUNNING
final_state=CANCELLED attempts=1
worker_drained=true running=0
```
原有 WSS、安全重试、计划调度及真实进程故障回归同步通过;进程组包括 ACK 丢失后节点接管、Worker 强杀后未知核查、调度强杀与服务间隙后的两节点补跑。
## 6. 当前边界与后续
- 取消要求业务遵守 context;不能强杀 goroutine,也不能撤回外部已经发生的副作用。普通错误不证明停止成功。
- 取消通知是有界提示,持久化意图和结果才是权威。服务端取消扫描每批最多 256 项,SDK 未读通知最多 32 条。
- 先停旧服务、应用全部迁移、启动新版服务,再升级自动 SDK;没有宣称通过混合版本滚动升级验收。
- 本次验证单数据库及本机进程故障,未验证数据库复制切换、跨故障域网络分区、大表在线 DDL、备份恢复或百万租户吞吐。
- 后续继续 P3 配额/背压与可靠订阅,随后推进 Weave。当前没有把取消闭环或无外键改造等同于最终生产交付。

View File

@@ -97,7 +97,7 @@ if err != nil {
fmt.Println(cancelled.Task.State) fmt.Println(cancelled.Task.State)
``` ```
取消重报必须保留第一次取消时的 expected_revision,不能将响应中的新版本塞回同一幂等请求。当前只取消 WAITING 且不归属流程的任务。若此前存在未知效果,取消结果为 NEEDS_REVIEW,不能解释为已撤销外部效果。 取消重报必须保留第一次取消时的 expected_revision,不能将响应中的新版本塞回同一幂等请求。已支持独立任务和计划任务的等待、预占、运行和核查状态;运行中受理返回 202,任务状态仍为 RUNNING,需另行查询最终状态。完整语义和运行示例见 [取消契约](../api/cancellation.md)。若此前存在未知效果,本次确认取消不能清除它。
业务错误返回 *orbit.APIError,支持 errors.As 转为 *orbit.HTTPError 查看状态。健康错误或无法识别的网关错误使用 *orbit.HTTPError,不带未知响应正文。网络/上下文错误按标准库错误返回;超时并不能证明服务端没有提交。 业务错误返回 *orbit.APIError,支持 errors.As 转为 *orbit.HTTPError 查看状态。健康错误或无法识别的网关错误使用 *orbit.HTTPError,不带未知响应正文。网络/上下文错误按标准库错误返回;超时并不能证明服务端没有提交。
@@ -179,4 +179,4 @@ task=<任务ID> state=SUCCEEDED attempts=1 evidence=1 reason=RESULT_CONFLICT dis
## 7. 后续交付 ## 7. 后续交付
P2 已提供底层协议、[自动 Worker 运行器及示例](worker-runtime.md) 与历史查询,并通过 [Schema 及进程故障阶段验收](../reports/phase-2.md);P3 已实现 [安全重试与示例](../api/retries.md) 和 [计划调度与示例](../api/schedules.md),运行中取消、资源配额和结果订阅继续开发;P4–P5 提供流程构建发布、信号、等待、轮询与人工恢复。每项随实现同步交付可运行示例及异常回归。 P2 已提供底层协议、[自动 Worker 运行器及示例](worker-runtime.md) 与历史查询,并通过 [Schema 及进程故障阶段验收](../reports/phase-2.md);P3 已实现 [安全重试](../api/retries.md)、[计划调度](../api/schedules.md) 和 [运行中取消及示例](../api/cancellation.md),资源配额和结果订阅继续开发;P4–P5 提供流程构建发布、信号、等待、轮询与人工恢复。每项随实现同步交付可运行示例及异常回归。

View File

@@ -72,6 +72,7 @@ SDK 的 Run 每个实例只能调用一次,包括已经正常退出的实例
## 4. 执行、续租与恢复 ## 4. 执行、续租与恢复
- 只有 Start 当前授权有效才调用处理器;同进程中的已知尝试最多调用一次。重复确认与重新连接只核对控制操作。 - 只有 Start 当前授权有效才调用处理器;同进程中的已知尝试最多调用一次。重复确认与重新连接只核对控制操作。
- 自动 Worker 开启取消通知,收到匹配尝试的通知或 Resume/Start/Renew 返回取消意图后,立即取消处理器 context,原因是 ErrTaskCancellationRequested,停止续租并继续上报真实结果。该函数未返回前仍占本地物理槽。
- 处理器在独立 goroutine 执行,网络控制由事件循环管理;每连接当前只保留一个执行器控制请求在途。不同处理器可以并行,控制请求顺序与业务并发上限分开。 - 处理器在独立 goroutine 执行,网络控制由事件循环管理;每连接当前只保留一个执行器控制请求在途。不同处理器可以并行,控制请求顺序与业务并发上限分开。
- 会话心跳按服务端 TTL 的三分之一计算,上限 5 秒。任务续租按当前保守租约剩余时间的三分之一安排,下限 20 毫秒;服务器仍限制总截止与单次尝试时限。 - 会话心跳按服务端 TTL 的三分之一计算,上限 5 秒。任务续租按当前保守租约剩余时间的三分之一安排,下限 20 毫秒;服务器仍限制总截止与单次尝试时限。
- 每个处理器的租约监视器独立取消 context,断网或控制请求阻塞也不会延长本地执行权限。最新会话若缩短租约,监视器会采用较短期限。已到期的处理器不会被后续确认复活。 - 每个处理器的租约监视器独立取消 context,断网或控制请求阻塞也不会延长本地执行权限。最新会话若缩短租约,监视器会采用较短期限。已到期的处理器不会被后续确认复活。
@@ -91,12 +92,15 @@ Run 的重连恢复同一次控制操作。平台的 [安全重试](../api/retri
| panic / runtime.Goexit | UNKNOWN / HANDLER_PANIC 或 HANDLER_ABORTED | | panic / runtime.Goexit | UNKNOWN / HANDLER_PANIC 或 HANDLER_ABORTED |
| 非法 JSON、超限结果、不支持数值或嵌套 | UNKNOWN / INVALID_HANDLER_RESULT | | 非法 JSON、超限结果、不支持数值或嵌套 | UNKNOWN / INVALID_HANDLER_RESULT |
| 处理器因租约取消返回错误 | UNKNOWN / LEASE_LOST | | 处理器因租约取消返回错误 | UNKNOWN / LEASE_LOST |
| 收到任务取消后返回 `&orbit.WorkerCancellationConfirmed{Details:...}` | CANCELLED;只用于确认本次停止且无未结清效果,历史未知效果仍需核查 |
| 报告到达时租约已失效、或结果与权威状态冲突 | 服务端可只保存 EVIDENCE_ONLY;不把证据计为任务成功 | | 报告到达时租约已失效、或结果与权威状态冲突 | 服务端可只保存 EVIDENCE_ONLY;不把证据计为任务成功 |
原始异常和 panic 内容不写入结果或通知。Result/Details 最多 256 KiB,应优先使用对象引用。SDK 保留精确数字文本,并按服务端数字范围及完整帧的嵌套上限预检,避免产生永远无法接受的报告。 原始异常和 panic 内容不写入结果或通知。Result/Details 最多 256 KiB,应优先使用对象引用。SDK 保留精确数字文本,并按服务端数字范围及完整帧的嵌套上限预检,避免产生永远无法接受的报告。
普通网络错误不能证明副作用未发生,因此不会自动解释为明确失败。UNKNOWN 由服务端结合真实幂等声明决定是否重试;迟到证据不能清除历史不确定效果。完整规则及可运行示例见 [安全重试](../api/retries.md)。 普通网络错误不能证明副作用未发生,因此不会自动解释为明确失败。UNKNOWN 由服务端结合真实幂等声明决定是否重试;迟到证据不能清除历史不确定效果。完整规则及可运行示例见 [安全重试](../api/retries.md)。
任务取消也不能自动解释为已停止。普通 ctx.Err() 仍报告 UNKNOWN;只有业务明确确认后才返回 WorkerCancellationConfirmed。已经成功时返回真实成功结果,不能因为 context 同时取消而覆盖成功。开始 ACK 丢失且处理器尚未调用时,SDK 重连核对意图后可以报告本次未执行;用法、升级顺序和完整示例见 [取消契约](../api/cancellation.md)。
## 6. 退出与故障边界 ## 6. 退出与故障边界
取消 Run context 后,运行器请求持久化排空,收集排空 ACK 之前已派发的工作,继续为在途任务续租并确认结果。进程应等待 Run 返回,而不是取消 context 后立即退出。 取消 Run context 后,运行器请求持久化排空,收集排空 ACK 之前已派发的工作,继续为在途任务续租并确认结果。进程应等待 Run 返回,而不是取消 context 后立即退出。

View File

@@ -66,6 +66,7 @@ defer conn.Close()
| `client.DialWorker` | registration | *WorkerConnection | 握手后尚未启用派发 | | `client.DialWorker` | registration | *WorkerConnection | 握手后尚未启用派发 |
| `conn.Capacity` | messageID、WorkerCapacity | WorkerWelcome | 总容量覆盖值;同时续期会话 | | `conn.Capacity` | messageID、WorkerCapacity | WorkerWelcome | 总容量覆盖值;同时续期会话 |
| `conn.NextAssignment` | ctx | WorkerAssignment | 只表示预占,不能直接运行处理器 | | `conn.NextAssignment` | ctx | WorkerAssignment | 只表示预占,不能直接运行处理器 |
| `conn.NextCancellation` | ctx | WorkerCancellation | 注册 CancellationNotifications=true 后持续读取,只停止匹配 task/attempt/epoch 的处理器 |
| `conn.Start` | messageID、WorkerAttempt | WorkerCommandResult | 持久化开始后确认,检查 Lease 的当前授权 | | `conn.Start` | messageID、WorkerAttempt | WorkerCommandResult | 持久化开始后确认,检查 Lease 的当前授权 |
| `conn.Renew` | messageID、WorkerAttempt | WorkerCommandResult | 新续租用新消息;原消息只核对原操作 | | `conn.Renew` | messageID、WorkerAttempt | WorkerCommandResult | 新续租用新消息;原消息只核对原操作 |
| `conn.Finish` | messageID、WorkerFinish | WorkerCommandResult | 区分权威结果 APPLIED 与证据 EVIDENCE_ONLY | | `conn.Finish` | messageID、WorkerFinish | WorkerCommandResult | 区分权威结果 APPLIED 与证据 EVIDENCE_ONLY |
@@ -92,7 +93,7 @@ defer cancel()
// 将 work 与业务输入传给处理器,不传执行秘密。 // 将 work 与业务输入传给处理器,不传执行秘密。
``` ```
`ExecutionDeadline()` 只认可 `Active=true`、`State=RUNNING` 和仍未到期的本地期限。SDK 以服务端 `lease_until-server_time` 为窗口,扣除从调用开始起的全部耗时(含等待写入)及 250 毫秒安全余量,用本地单调时钟表示。数据库墙钟与客户端相差一天也不会额外获得一天授权。 `ExecutionDeadline()` 只认可 `Active=true`、`State=RUNNING`、CancelRequestedAt 为空和仍未到期的本地期限。取消后 Active 仍可为 true,用于报告当前结果,不授予继续执行权。SDK 以服务端 `lease_until-server_time` 为窗口,扣除从调用开始起的全部耗时(含等待写入)及 250 毫秒安全余量,用本地单调时钟表示。数据库墙钟与客户端相差一天也不会额外获得一天授权。
只有 SDK 控制响应会设置内部本地期限;自行构造/JSON 解码的 WorkerLease 不会得到执行授权。旧 start 回执即使包含历史 RUNNING 和未来 lease_until,也不能越过当前 Lease 的失效判断。 只有 SDK 控制响应会设置内部本地期限;自行构造/JSON 解码的 WorkerLease 不会得到执行授权。旧 start 回执即使包含历史 RUNNING 和未来 lease_until,也不能越过当前 Lease 的失效判断。
@@ -107,6 +108,7 @@ defer cancel()
| Finish 请求结果不明 | 保留原 finishID、Outcome、Result、ErrorCode,重连核对后原样重报 | | Finish 请求结果不明 | 保留原 finishID、Outcome、Result、ErrorCode,重连核对后原样重报 |
| 明确业务成功 | `WorkerSucceeded`,不带 ErrorCode | | 明确业务成功 | `WorkerSucceeded`,不带 ErrorCode |
| 可确认的业务失败 | `WorkerFailed`,带稳定 ErrorCode | | 可确认的业务失败 | `WorkerFailed`,带稳定 ErrorCode |
| 收到取消意图,确认已停止且本次无未结清效果 | `WorkerCancelled`,带稳定 ErrorCode;普通 context 错误不足以证明 |
| 超时、失联或不能确认外部效果 | `WorkerUnknown`,带稳定 ErrorCode;默认转核查,仅 IDEMPOTENT 且预算允许时自动重试 | | 超时、失联或不能确认外部效果 | `WorkerUnknown`,带稳定 ErrorCode;默认转核查,仅 IDEMPOTENT 且预算允许时自动重试 |
| `Disposition=EVIDENCE_ONLY` | 内容已留作证据;不能当作任务成功,另查 GetTask 的权威状态 | | `Disposition=EVIDENCE_ONLY` | 内容已留作证据;不能当作任务成功,另查 GetTask 的权威状态 |
@@ -118,6 +120,7 @@ Result 是不超过 256 KiB 的非 null JSON,可传对象引用;原始异常
- 传输中断返回 `ErrWorkerDisconnected`;消息格式/版本不兼容返回 `ErrWorkerProtocol`;主动关闭为 `ErrWorkerClosed`;超时/取消支持标准 context 错误。 - 传输中断返回 `ErrWorkerDisconnected`;消息格式/版本不兼容返回 `ErrWorkerProtocol`;主动关闭为 `ErrWorkerClosed`;超时/取消支持标准 context 错误。
- 最多 32 个在途控制请求,达到上限返回 `ErrWorkerBusy`,请求未发送。同一消息 ID 已在途时也拒绝第二个并发等待者。 - 最多 32 个在途控制请求,达到上限返回 `ErrWorkerBusy`,请求未发送。同一消息 ID 已在途时也拒绝第二个并发等待者。
- 派发缓冲最多 8 条;读取不及时会以 `ErrWorkerBackpressure` 关闭连接。持久化预占由服务端恢复,不无限积压内存。 - 派发缓冲最多 8 条;读取不及时会以 `ErrWorkerBackpressure` 关闭连接。持久化预占由服务端恢复,不无限积压内存。
- 取消通知另有 32 条缓冲,满时同样关闭连接;NextCancellation 的等待 context 取消不会断开连接。重连后仍要通过 Resume/Renew 核对取消意图,通知不代替持久化状态。完整契约与先升级服务端的要求见 [运行中取消](../api/cancellation.md)。
- 控制操作最长 10 秒,包含排队、发送与等待 ACK;调用方 context 可以缩短。已发送后取消/超时会关闭连接,操作是否提交需用原身份核对。 - 控制操作最长 10 秒,包含排队、发送与等待 ACK;调用方 context 可以缩短。已发送后取消/超时会关闭连接,操作是否提交需用原身份核对。
- `NextAssignment` 的 context 取消只停止等待,不关闭连接。已关闭连接不会继续交付缓冲中的派发。 - `NextAssignment` 的 context 取消只停止等待,不关闭连接。已关闭连接不会继续交付缓冲中的派发。
- 入站单帧最多 512 KiB;读空闲期限 60 秒。需要调用方持续心跳,不能用此期限代替默认 45 秒会话期限。 - 入站单帧最多 512 KiB;读空闲期限 60 秒。需要调用方持续心跳,不能用此期限代替默认 45 秒会话期限。

View File

@@ -84,8 +84,11 @@ func TestIntegrationSchemaAndConstraints(t *testing.T) {
taskA, taskB := id(), id() taskA, taskB := id(), id()
exec(taskSQL, nsA, taskA, queueA) exec(taskSQL, nsA, taskA, queueA)
exec(taskSQL, nsA, taskB, queueA) exec(taskSQL, nsA, taskB, queueA)
t.Run("namespace_isolation_and_safe_task_states", func(t *testing.T) { t.Run("no_foreign_keys_and_safe_task_states", func(t *testing.T) {
reject(1452, taskSQL, nsB, id(), queueA) var count int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&count); err != nil || count != 0 {
t.Fatalf("foreign keys must be absent: %d %v", count, err)
}
reject(3819, "UPDATE pulse_tasks SET state='RUNNING' WHERE namespace_id=? AND id=?", nsA, taskA) reject(3819, "UPDATE pulse_tasks SET state='RUNNING' WHERE namespace_id=? AND id=?", nsA, taskA)
reject(3819, "UPDATE pulse_tasks SET state='UNRECOGNIZED' WHERE namespace_id=? AND id=?", nsA, taskA) reject(3819, "UPDATE pulse_tasks SET state='UNRECOGNIZED' WHERE namespace_id=? AND id=?", nsA, taskA)
reject(3819, "UPDATE pulse_tasks SET state='SUCCEEDED' WHERE namespace_id=? AND id=?", nsA, taskA) reject(3819, "UPDATE pulse_tasks SET state='SUCCEEDED' WHERE namespace_id=? AND id=?", nsA, taskA)

View File

@@ -183,8 +183,7 @@ func Status(ctx context.Context, db *sql.DB) ([]MigrationStatus, error) {
return result, rows.Err() return result, rows.Err()
} }
// CheckSchema rejects missing, modified, dirty and unexpected versions. It does // CheckSchema 校验迁移记录并禁止外键,不执行 DDL;不代表已核验其他手工结构漂移。
// not execute DDL. A matched migration ledger is not proof against manual DDL drift.
func CheckSchema(ctx context.Context, db *sql.DB) error { func CheckSchema(ctx context.Context, db *sql.DB) error {
wanted, err := Migrations() wanted, err := Migrations()
if err != nil { if err != nil {
@@ -203,5 +202,12 @@ func CheckSchema(ctx context.Context, db *sql.DB) error {
return errors.New("schema is dirty or does not match this binary") return errors.New("schema is dirty or does not match this binary")
} }
} }
var foreignKeys int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND LEFT(TABLE_NAME,6) IN ('orbit_','pulse_','weave_')`).Scan(&foreignKeys); err != nil {
return err
}
if foreignKeys != 0 {
return errors.New("foreign keys are forbidden on Orbit tables; apply the supported schema before starting")
}
return nil return nil
} }

View File

@@ -0,0 +1,4 @@
ALTER TABLE pulse_attempt_evidence
DROP CHECK ck_attempt_evidence_outcome,
MODIFY COLUMN outcome VARCHAR(16) NOT NULL COMMENT 'Worker 声明的结果:SUCCEEDED、FAILED、UNKNOWN 或已确认停止且本次无遗留效果的 CANCELLED',
ADD CONSTRAINT ck_attempt_evidence_outcome CHECK(outcome IN ('SUCCEEDED','FAILED','UNKNOWN','CANCELLED'));

View File

@@ -0,0 +1,103 @@
-- 移除全部历史外键,保留现有索引、唯一约束、状态约束和数据。
-- 资源存在性及归属由服务端按命名空间在事务内校验,禁止级联删除。
ALTER TABLE orbit_namespaces
DROP FOREIGN KEY fk_orbit_namespaces_0;
ALTER TABLE orbit_credentials
DROP FOREIGN KEY fk_orbit_credentials_0;
ALTER TABLE orbit_idempotency_keys
DROP FOREIGN KEY fk_orbit_idempotency_keys_0;
ALTER TABLE orbit_events
DROP FOREIGN KEY fk_orbit_events_0;
ALTER TABLE orbit_subscriptions
DROP FOREIGN KEY fk_orbit_subscriptions_0,
DROP FOREIGN KEY fk_orbit_subscriptions_1;
ALTER TABLE orbit_deliveries
DROP FOREIGN KEY fk_orbit_deliveries_0,
DROP FOREIGN KEY fk_orbit_deliveries_1,
DROP FOREIGN KEY fk_orbit_deliveries_2;
ALTER TABLE orbit_audit_events
DROP FOREIGN KEY fk_orbit_audit_events_0;
ALTER TABLE pulse_queues
DROP FOREIGN KEY fk_pulse_queues_0;
ALTER TABLE pulse_execution_gates
DROP FOREIGN KEY fk_pulse_execution_gates_0;
ALTER TABLE pulse_worker_sessions
DROP FOREIGN KEY fk_pulse_worker_sessions_0,
DROP FOREIGN KEY fk_pulse_worker_sessions_1,
DROP FOREIGN KEY fk_worker_session_instance;
ALTER TABLE pulse_tasks
DROP FOREIGN KEY fk_pulse_tasks_0,
DROP FOREIGN KEY fk_pulse_tasks_1,
DROP FOREIGN KEY fk_pulse_tasks_2,
DROP FOREIGN KEY fk_pulse_tasks_3,
DROP FOREIGN KEY fk_pulse_tasks_4;
ALTER TABLE pulse_attempts
DROP FOREIGN KEY fk_pulse_attempts_0,
DROP FOREIGN KEY fk_pulse_attempts_1,
DROP FOREIGN KEY fk_pulse_attempts_2;
ALTER TABLE pulse_attempt_receipts
DROP FOREIGN KEY fk_pulse_attempt_receipts_0,
DROP FOREIGN KEY fk_pulse_attempt_receipts_1;
ALTER TABLE pulse_concurrency_limits
DROP FOREIGN KEY fk_pulse_concurrency_limits_0;
ALTER TABLE pulse_permits
DROP FOREIGN KEY fk_pulse_permits_0,
DROP FOREIGN KEY fk_pulse_permits_1,
DROP FOREIGN KEY fk_pulse_permits_2;
ALTER TABLE pulse_schedules
DROP FOREIGN KEY fk_pulse_schedules_0,
DROP FOREIGN KEY fk_schedule_queue;
ALTER TABLE pulse_schedule_versions
DROP FOREIGN KEY fk_pulse_schedule_versions_0,
DROP FOREIGN KEY fk_pulse_schedule_versions_1;
ALTER TABLE pulse_schedule_occurrences
DROP FOREIGN KEY fk_pulse_schedule_occurrences_0,
DROP FOREIGN KEY fk_pulse_schedule_occurrences_1,
DROP FOREIGN KEY fk_pulse_schedule_occurrences_2;
ALTER TABLE weave_definitions
DROP FOREIGN KEY fk_weave_definitions_0;
ALTER TABLE weave_definition_versions
DROP FOREIGN KEY fk_weave_definition_versions_0,
DROP FOREIGN KEY fk_weave_definition_versions_1;
ALTER TABLE weave_instances
DROP FOREIGN KEY fk_weave_instances_0,
DROP FOREIGN KEY fk_weave_instances_1,
DROP FOREIGN KEY fk_weave_instances_2,
DROP FOREIGN KEY fk_weave_instances_3;
ALTER TABLE weave_steps
DROP FOREIGN KEY fk_weave_steps_0,
DROP FOREIGN KEY fk_weave_steps_1,
DROP FOREIGN KEY fk_weave_steps_2;
ALTER TABLE weave_activations
DROP FOREIGN KEY fk_weave_activations_0,
DROP FOREIGN KEY fk_weave_activations_1,
DROP FOREIGN KEY fk_weave_activations_2;
ALTER TABLE weave_waits
DROP FOREIGN KEY fk_weave_waits_0,
DROP FOREIGN KEY fk_weave_waits_1,
DROP FOREIGN KEY fk_weave_waits_2;
ALTER TABLE weave_signals
DROP FOREIGN KEY fk_weave_signals_0,
DROP FOREIGN KEY fk_weave_signals_1,
DROP FOREIGN KEY fk_weave_signals_2;
ALTER TABLE weave_poll_states
DROP FOREIGN KEY fk_weave_poll_states_0,
DROP FOREIGN KEY fk_weave_poll_states_1,
DROP FOREIGN KEY fk_weave_poll_states_2;
ALTER TABLE weave_poll_rounds
DROP FOREIGN KEY fk_weave_poll_rounds_0,
DROP FOREIGN KEY fk_weave_poll_rounds_1,
DROP FOREIGN KEY fk_weave_poll_rounds_2;
ALTER TABLE weave_inbox
DROP FOREIGN KEY fk_weave_inbox_0,
DROP FOREIGN KEY fk_weave_inbox_1,
DROP FOREIGN KEY fk_weave_inbox_2;
ALTER TABLE weave_pending_advances
DROP FOREIGN KEY fk_weave_pending_advances_0,
DROP FOREIGN KEY fk_weave_pending_advances_1;
ALTER TABLE pulse_worker_instances
DROP FOREIGN KEY fk_worker_instance_credential,
DROP FOREIGN KEY fk_worker_instance_queue,
DROP FOREIGN KEY fk_worker_instance_session;
ALTER TABLE pulse_attempt_evidence
DROP FOREIGN KEY fk_attempt_evidence_attempt;

View File

@@ -0,0 +1,224 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"os"
"reflect"
"strings"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/pulse/schedule"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
mysqldriver "github.com/go-sql-driver/mysql"
)
// 重建历史 v7,验证正式升级删除所有外键且保留索引、唯一约束和已有任务。
func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, stop := context.WithTimeout(context.Background(), 60*time.Second)
defer stop()
admin, err := Open(ctx, dsn, 2, 1)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
name := "orbit_no_fk_upgrade_" + string(executionID(t))
if _, err := admin.ExecContext(ctx, "CREATE DATABASE `"+name+"`"); err != nil {
t.Fatal(err)
}
defer func() {
cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := admin.ExecContext(cleanup, "DROP DATABASE `"+name+"`"); err != nil {
t.Error(err)
}
}()
cfg, err := mysqldriver.ParseDSN(dsn)
if err != nil {
t.Fatal("invalid test DSN")
}
cfg.DBName = name
db, err := Open(ctx, cfg.FormatDSN(), 4, 2)
if err != nil {
t.Fatal(err)
}
defer db.Close()
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.ExecContext(ctx, q, args...); err != nil {
t.Fatal(err)
}
}
exec(`CREATE TABLE orbit_schema_migrations (version BIGINT UNSIGNED PRIMARY KEY,name VARCHAR(191) NOT NULL,checksum CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,dirty BOOLEAN NOT NULL,applied_at DATETIME(6) NULL) ENGINE=InnoDB`)
migrations, err := Migrations()
if err != nil {
t.Fatal(err)
}
for _, m := range migrations {
if m.Version > 7 {
break
}
statements, err := splitSQL(m.SQL)
if err != nil {
t.Fatal(err)
}
for _, statement := range statements {
exec(statement)
}
exec(`INSERT INTO orbit_schema_migrations(version,name,checksum,dirty,applied_at) VALUES (?,?,?,FALSE,UTC_TIMESTAMP(6))`, m.Version, m.Name, m.Checksum)
}
foreignKeys := func() int {
t.Helper()
var count int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&count); err != nil {
t.Fatal(err)
}
return count
}
if got := foreignKeys(); got != 70 {
t.Fatalf("v7 foreign keys=%d, want 70", got)
}
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`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var result []string
for rows.Next() {
var value string
if err := rows.Scan(&value); err != nil {
t.Fatal(err)
}
result = append(result, value)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return result
}
beforeIndexes := indexes()
app, ns, queue, task := executionID(t), executionID(t), executionID(t), executionID(t)
exec(`INSERT INTO orbit_applications(id,name) VALUES (?,?)`, app, string(app))
exec(`INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,'test')`, ns, app, string(ns))
exec(`INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')`, ns, queue, string(queue))
exec(`INSERT INTO pulse_tasks(namespace_id,id,queue_id,handler,handler_version,payload_version,payload,policy_snapshot,available_at,deadline_at,attempt_timeout_ms,max_attempts) VALUES (?,?,?,'object.prepare','v1','v1','{"n":9007199254740993}','{}',UTC_TIMESTAMP(6),DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR),5000,3)`, ns, task, queue)
taskSnapshot := func() []string {
t.Helper()
rows, err := db.QueryContext(ctx, `SELECT * FROM pulse_tasks WHERE namespace_id=? AND id=?`, ns, task)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
t.Fatal(err)
}
if !rows.Next() {
t.Fatal("seeded task missing")
}
values, scan := make([]sql.NullString, len(cols)), make([]any, len(cols))
for i := range values {
scan[i] = &values[i]
}
if err := rows.Scan(scan...); err != nil {
t.Fatal(err)
}
result := make([]string, len(cols))
for i, value := range values {
if value.Valid {
result[i] = cols[i] + "=" + value.String
} else {
result[i] = cols[i] + "=<NULL>"
}
}
if rows.Next() || rows.Err() != nil {
t.Fatal("unexpected task rows")
}
return result
}
beforeTask := taskSnapshot()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
if got := foreignKeys(); got != 0 {
t.Fatalf("remaining foreign keys=%d", got)
}
if !reflect.DeepEqual(beforeIndexes, indexes()) {
t.Fatal("upgrade changed index definitions")
}
if !reflect.DeepEqual(beforeTask, taskSnapshot()) {
t.Fatal("upgrade changed existing task")
}
if err := CheckSchema(ctx, db); err != nil {
t.Fatal(err)
}
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
// 仅在一次性测试库注入违规 DDL,验证启动检查能发现迁移账本以外的外键。
exec(`ALTER TABLE pulse_queues ADD CONSTRAINT test_forbidden_fk FOREIGN KEY (namespace_id) REFERENCES orbit_namespaces(id)`)
if err := CheckSchema(ctx, db); err == nil || !strings.Contains(err.Error(), "foreign keys are forbidden") {
t.Fatalf("foreign key drift not rejected: %v", err)
}
exec(`ALTER TABLE pulse_queues DROP FOREIGN KEY test_forbidden_fk`)
if err := CheckSchema(ctx, db); err != nil {
t.Fatal(err)
}
t.Logf("v7 foreign keys=70 -> current=0; %d index components unchanged; task preserved; drift rejected", len(beforeIndexes))
}
func TestIntegrationRelationsWithoutForeignKeys(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, stop := context.WithTimeout(context.Background(), 30*time.Second)
defer stop()
db, err := Open(ctx, dsn, 8, 2)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
a := newScheduleFixture(t, newExecutionFixture(t, ctx, db))
b := newExecutionFixture(t, ctx, db)
for name, queue := range map[string]core.ID{"missing_queue": executionID(t), "other_namespace_queue": b.resources.QueueID} {
t.Run(name, func(t *testing.T) {
_, err := a.tasks.Submit(ctx, a.token, string(executionID(t)), service.SubmitInput{QueueID: queue, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)})
expectWorkerCode(t, err, "NOT_FOUND")
hello := a.hello
hello.InstanceID, hello.QueueID = executionID(t), queue
_, _, err = a.a.Connect(ctx, a.token, hello)
expectWorkerCode(t, err, "NOT_FOUND")
definition := a.definition()
definition.Task.QueueID = queue
_, err = a.api.Create(ctx, a.token, string(executionID(t)), schedule.CreateInput{Name: name, Definition: definition})
expectWorkerCode(t, err, "NOT_FOUND")
for _, table := range []string{"pulse_tasks", "pulse_schedules", "orbit_idempotency_keys"} {
if a.count(table, "1=1") != 0 {
t.Fatalf("rejected relation left %s records", table)
}
}
if a.count("pulse_worker_instances", "id=?", hello.InstanceID) != 0 || a.count("pulse_worker_sessions", "worker_instance_id=?", hello.InstanceID) != 0 {
t.Fatal("rejected relation left worker records")
}
})
}
t.Run("session_relation_must_match_process", func(t *testing.T) {
a.exec(`UPDATE pulse_worker_sessions SET worker_instance_id=? WHERE namespace_id=? AND id=?`, executionID(t), a.resources.NamespaceID, a.binding.SessionID)
_, err := a.a.Capacity(ctx, a.token, a.binding, worker.CapacityInput{Sequence: 1, Capacity: 4})
expectWorkerCode(t, err, "STALE_SESSION")
})
}

View File

@@ -0,0 +1,121 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
)
// CancelTask 将取消意图与开始授权串行化;已开始的尝试仍须确认结果或到期核查。
func (s *TaskStore) CancelTask(ctx context.Context, token auth.Token, id core.ID, key string, in service.CancelInput) (service.CancelResult, error) {
var out service.CancelResult
err := s.transaction(ctx, token, "tasks:cancel", func(tx *sql.Tx, p auth.Permissions) error {
view, err := readTask(ctx, tx, token.NamespaceID, id, false)
if err != nil {
return err
}
if !p.AllowsQueue(view.QueueID) {
return fault.NotFound
}
identity, cached, err := reserveIdentity(ctx, tx, token.NamespaceID, "task.cancel", string(id), key, "task", id, in)
if err != nil {
return err
}
if cached != nil {
if err := json.Unmarshal(cached, &out); err != nil {
return err
}
out.Deduplicated = true
return nil
}
// 队列 → 任务 → 尝试 → 许可;取消不获取 Worker 会话锁。
if _, _, err := lockQueue(ctx, tx, token.NamespaceID, view.QueueID); err != nil {
return err
}
task, err := readExecutionTask(ctx, tx, token.NamespaceID, id)
if err != nil {
return err
}
view = task.view
if view.Revision != in.ExpectedRevision || view.State.Terminal() || view.CancelRequestedAt != nil {
return fault.Conflict
}
var owner sql.NullString
if err := tx.QueryRowContext(ctx, "SELECT owner_kind FROM pulse_tasks WHERE namespace_id=? AND id=?", token.NamespaceID, id).Scan(&owner); err != nil {
return err
}
if owner.Valid && owner.String != "schedule" {
return fault.Conflict
}
var attempt *pulse.Attempt
if view.State == pulse.TaskLeased || view.State == pulse.TaskRunning {
if task.current == nil {
return errors.New("active task lacks attempt")
}
a, err := readAttempt(ctx, tx, token.NamespaceID, *task.current)
if err != nil {
return err
}
if a.TaskID != id || a.Epoch != task.epoch || string(a.State) != string(view.State) {
return errors.New("task attempt identity or state mismatch")
}
attempt = &a
}
now, err := dbNow(ctx, tx)
if err != nil {
return err
}
next := view.State
lease := task.lease
var completed *time.Time
if view.State == pulse.TaskWaiting || view.State == pulse.TaskLeased {
next = pulse.TaskCancelled
completed = &now
lease = nil
if view.EffectUncertain {
next, completed = pulse.TaskNeedsReview, nil
}
if attempt != nil {
if attempt.AuthorizedAt != nil {
return errors.New("leased attempt already authorized")
}
result, err := tx.ExecContext(ctx, `UPDATE pulse_attempts SET state='CANCELLED',ended_at=?,error_code='CANCELLED_BEFORE_START',revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, now, token.NamespaceID, attempt.ID, attempt.Revision)
if err := exactlyOne(result, err); err != nil {
return err
}
if err := releasePermits(ctx, tx, token.NamespaceID, attempt.ID, now); err != nil {
return err
}
}
}
result, err := tx.ExecContext(ctx, `UPDATE pulse_tasks SET state=?,cancel_requested_at=?,lease_until=?,completed_at=?,updated_at=?,revision=revision+1 WHERE namespace_id=? AND id=? AND revision=?`, next, now, lease, completed, now, token.NamespaceID, id, in.ExpectedRevision)
if err := exactlyOne(result, err); err != nil {
return err
}
out.Task, err = readTask(ctx, tx, token.NamespaceID, id, false)
if err != nil {
return err
}
event := resultEvent(next)
if next == view.State {
event = "task.cancel_requested"
}
if err := emitEvent(ctx, tx, token.NamespaceID, id, out.Task.Revision, "task", event, map[string]any{"task_id": id, "state": next, "reason": "CANCEL_REQUESTED", "effect_uncertain": view.EffectUncertain}); err != nil {
return err
}
if err := appendAudit(ctx, tx, token, identity, id, "task", "task.cancel", in.Reason, map[string]any{"state": view.State, "revision": fmt.Sprint(view.Revision)}, map[string]any{"state": next, "revision": fmt.Sprint(out.Task.Revision), "cancel_requested_at": now}); err != nil {
return err
}
return saveIdentity(ctx, tx, token.NamespaceID, identity, out)
})
return out, err
}

View File

@@ -8,7 +8,6 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"time" "time"
"git.yuntuo.hk/k2safe/orbit/internal/fault" "git.yuntuo.hk/k2safe/orbit/internal/fault"
@@ -318,80 +317,6 @@ func (s *TaskStore) ListTasks(ctx context.Context, token auth.Token, in service.
}) })
return out, err return out, err
} }
func (s *TaskStore) CancelTask(ctx context.Context, token auth.Token, id core.ID, key string, in service.CancelInput) (service.CancelResult, error) {
var out service.CancelResult
err := s.transaction(ctx, token, "tasks:cancel", func(tx *sql.Tx, p auth.Permissions) error {
// 先以查询权限范围核对队列,再锁幂等记录,统一所有写操作的锁定顺序。
task, err := readTask(ctx, tx, token.NamespaceID, id, false)
if err != nil {
return err
}
if !p.AllowsQueue(task.QueueID) {
return fault.NotFound
}
identity, cached, err := reserveIdentity(ctx, tx, token.NamespaceID, "task.cancel", string(id), key, "task", id, in)
if err != nil {
return err
}
if cached != nil {
if err := json.Unmarshal(cached, &out); err != nil {
return err
}
out.Deduplicated = true
return nil
}
task, err = readTask(ctx, tx, token.NamespaceID, id, true)
if err != nil {
return err
}
if task.Revision != in.ExpectedRevision {
return fault.Conflict
}
if task.State != pulse.TaskWaiting {
return fault.Conflict
}
var owner sql.NullString
if err := tx.QueryRowContext(ctx, "SELECT owner_kind FROM pulse_tasks WHERE namespace_id=? AND id=?", token.NamespaceID, id).Scan(&owner); err != nil {
return err
}
if owner.Valid && owner.String != "schedule" {
return fault.Conflict
}
now, err := dbNow(ctx, tx)
if err != nil {
return err
}
next := pulse.TaskCancelled
var completed *time.Time = &now
if task.EffectUncertain {
next = pulse.TaskNeedsReview
completed = nil
}
result, err := tx.ExecContext(ctx, `UPDATE pulse_tasks SET state=?,cancel_requested_at=?,completed_at=?,updated_at=?,revision=revision+1 WHERE namespace_id=? AND id=? AND revision=? AND state='WAITING'`, next, now, completed, now, token.NamespaceID, id, in.ExpectedRevision)
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n != 1 {
return fault.Conflict
}
out.Task, err = readTask(ctx, tx, token.NamespaceID, id, false)
if err != nil {
return err
}
if err := emitEvent(ctx, tx, token.NamespaceID, id, out.Task.Revision, "task", resultEvent(out.Task.State), map[string]any{"task_id": id, "state": out.Task.State, "reason": "CANCEL_REQUESTED", "effect_uncertain": out.Task.EffectUncertain}); err != nil {
return err
}
if err := appendAudit(ctx, tx, token, identity, id, "task", "task.cancel", in.Reason, map[string]any{"state": task.State, "revision": fmt.Sprint(task.Revision)}, map[string]any{"state": out.Task.State, "revision": fmt.Sprint(out.Task.Revision)}); err != nil {
return err
}
return saveIdentity(ctx, tx, token.NamespaceID, identity, out)
})
return out, err
}
func (s *TaskStore) CreateQueue(ctx context.Context, token auth.Token, key string, in service.QueueInput) (service.QueueResult, error) { func (s *TaskStore) CreateQueue(ctx context.Context, token auth.Token, key string, in service.QueueInput) (service.QueueResult, error) {
var out service.QueueResult var out service.QueueResult
err := s.transaction(ctx, token, "queues:manage", func(tx *sql.Tx, p auth.Permissions) error { err := s.transaction(ctx, token, "queues:manage", func(tx *sql.Tx, p auth.Permissions) error {

View File

@@ -0,0 +1,207 @@
package mysql
import (
"context"
"encoding/json"
"os"
"sync"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
)
func TestIntegrationWorkerCancellation(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, stop := context.WithTimeout(context.Background(), 45*time.Second)
defer stop()
db, err := Open(ctx, dsn, 32, 8)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
fixture := func(t *testing.T) *executionFixture { return newExecutionFixture(t, ctx, db) }
cancel := func(f *executionFixture, task service.TaskView) service.CancelResult {
t.Helper()
out, err := f.tasks.CancelTask(ctx, f.token, task.ID, string(executionID(t)), service.CancelInput{ExpectedRevision: task.Revision, Reason: "stop controlled test"})
if err != nil {
t.Fatal(err)
}
return out
}
t.Run("leased_cancel_prevents_start_and_releases_once", func(t *testing.T) {
f := fixture(t)
f.submit(3)
a, ref := f.claim()
out := cancel(f, a.Task)
if out.Task.State != pulse.TaskCancelled || out.Task.CompletedAt == nil || f.used() != 0 {
t.Fatal("leased cancel did not settle")
}
_, err := f.a.Start(ctx, f.token, f.binding, executionID(t), ref)
expectWorkerCode(t, err, "LEASE_LOST")
late := retryFinish(t, f, ref, "SUCCEEDED", false)
if late.Receipt.Disposition != "EVIDENCE_ONLY" || f.get(a.Task.ID).State != pulse.TaskCancelled || f.used() != 0 {
t.Fatal("late result changed cancelled task")
}
})
for _, outcome := range []string{"SUCCEEDED", "FAILED", "UNKNOWN", "CANCELLED"} {
t.Run("running_"+outcome, func(t *testing.T) {
f := fixture(t)
f.submit(3)
_, ref := f.claim()
startID := executionID(t)
started, err := f.a.Start(ctx, f.token, f.binding, startID, ref)
if err != nil {
t.Fatal(err)
}
before := f.get(ref.TaskID)
key := string(executionID(t))
input := service.CancelInput{ExpectedRevision: before.Revision, Reason: "cancel running test"}
out, err := f.tasks.CancelTask(ctx, f.token, ref.TaskID, key, input)
if err != nil {
t.Fatal(err)
}
if out.Task.State != pulse.TaskRunning || out.Task.CancelRequestedAt == nil || out.Task.CompletedAt != nil || f.used() != 1 {
t.Fatal("intent prematurely settled execution")
}
replayed, err := f.a.Start(ctx, f.token, f.binding, startID, ref)
if err != nil || !replayed.Deduplicated || replayed.Lease.CancelRequestedAt == nil || !replayed.Lease.Active {
t.Fatal("old start ACK lost current cancellation")
}
renew, err := f.a.Renew(ctx, f.token, f.binding, executionID(t), ref)
if err != nil || !renew.Lease.LeaseUntil.Equal(started.Lease.LeaseUntil) || renew.Lease.CancelRequestedAt == nil {
t.Fatal("cancelled lease extended")
}
notices, err := f.a.Cancellations(ctx, f.token, f.binding)
if err != nil || len(notices) != 1 || notices[0].AttemptID != ref.AttemptID {
t.Fatal("missing persisted notice")
}
// 新会话隔离旧连接,Resume 后仍能取得取消意图并确认原尝试。
binding, _, err := f.b.Connect(ctx, f.token, f.hello)
if err != nil {
t.Fatal(err)
}
_, err = f.a.Cancellations(ctx, f.token, f.binding)
expectWorkerCode(t, err, "STALE_SESSION")
lease, err := f.b.Resume(ctx, f.token, binding, ref)
if err != nil || lease.CancelRequestedAt == nil || !lease.Active {
t.Fatal("reconnect lost intent")
}
f.a, f.binding = f.b, binding
finished := retryFinish(t, f, ref, outcome, outcome == "FAILED")
want := pulse.TaskState(outcome)
if outcome == "UNKNOWN" {
want = pulse.TaskNeedsReview
}
if finished.Receipt.TaskState != want || f.used() != 0 || f.get(ref.TaskID).AttemptCount != 1 {
t.Fatal("wrong cancellation resolution or retry")
}
again, err := f.tasks.CancelTask(ctx, f.token, ref.TaskID, key, input)
if err != nil || !again.Deduplicated || again.Task.State != pulse.TaskRunning || again.Task.Revision != out.Task.Revision {
t.Fatal("cancel receipt changed after completion")
}
if lenMust := f.count("orbit_events", "aggregate_id=? AND type='task.cancel_requested'", ref.TaskID); lenMust != 1 {
t.Fatal("duplicate intent event")
}
})
}
t.Run("prior_unknown_not_erased_and_late_cancel_is_evidence", func(t *testing.T) {
f := fixture(t)
task := retryTask(t, f, 3, pulse.RecoveryIdempotent)
first := retryStart(t, f)
retryFinish(t, f, first, "UNKNOWN", false)
pending := f.get(task.ID)
timer := time.NewTimer(time.Until(pending.AvailableAt.Add(10 * time.Millisecond)))
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
t.Fatal(ctx.Err())
}
second := retryStart(t, f)
cancel(f, f.get(task.ID))
retryFinish(t, f, second, "CANCELLED", false)
got := f.get(task.ID)
if got.State != pulse.TaskNeedsReview || !got.EffectUncertain || got.CompletedAt != nil || got.AttemptCount != 2 {
t.Fatal("current cancellation erased earlier unknown effect")
}
})
t.Run("unrequested_cancel_is_rejected", func(t *testing.T) {
f := fixture(t)
f.submit(1)
ref := retryStart(t, f)
_, err := f.a.Finish(ctx, f.token, f.binding, executionID(t), worker.FinishInput{Ref: ref, Outcome: "CANCELLED", ErrorCode: "CANCEL_CONFIRMED", Result: json.RawMessage(`{}`)})
expectWorkerCode(t, err, "STATE_CONFLICT")
if f.get(ref.TaskID).State != pulse.TaskRunning || f.used() != 1 {
t.Fatal("unrequested cancellation changed execution")
}
})
t.Run("intent_rollback_and_expiry_no_retry", func(t *testing.T) {
f := fixture(t)
f.submit(3)
ref := retryStart(t, f)
before := f.get(ref.TaskID)
key := string(executionID(t))
in := service.CancelInput{ExpectedRevision: before.Revision, Reason: "rollback test"}
f.exec(`CREATE TRIGGER orbit_test_cancel BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='controlled cancel failure'`)
_, err := f.tasks.CancelTask(ctx, f.token, ref.TaskID, key, in)
f.exec(`DROP TRIGGER orbit_test_cancel`)
if err == nil || f.get(ref.TaskID).CancelRequestedAt != nil || f.get(ref.TaskID).Revision != before.Revision {
t.Fatal("partial cancel transaction")
}
if _, err := f.tasks.CancelTask(ctx, f.token, ref.TaskID, key, in); err != nil {
t.Fatal(err)
}
f.expire(ref)
if _, err := f.storeB.Recover(ctx, 100); err != nil {
t.Fatal(err)
}
if f.get(ref.TaskID).State != pulse.TaskNeedsReview || f.used() != 0 {
t.Fatal("expired running cancel became false cancellation")
}
late := retryFinish(t, f, ref, "CANCELLED", false)
if late.Receipt.Disposition != "EVIDENCE_ONLY" || f.get(ref.TaskID).State != pulse.TaskNeedsReview {
t.Fatal("late cancellation overrode unknown")
}
})
t.Run("start_and_cancel_race", func(t *testing.T) {
for i := 0; i < 12; i++ {
f := fixture(t)
f.submit(1)
a, ref := f.claim()
var startErr, cancelErr error
var out service.CancelResult
gate := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); <-gate; _, startErr = f.a.Start(ctx, f.token, f.binding, executionID(t), ref) }()
go func() {
defer wg.Done()
<-gate
out, cancelErr = f.tasks.CancelTask(ctx, f.token, ref.TaskID, string(executionID(t)), service.CancelInput{ExpectedRevision: a.Task.Revision, Reason: "race"})
}()
close(gate)
wg.Wait()
if cancelErr == nil {
if fault.Code(startErr) != "LEASE_LOST" || out.Task.State != pulse.TaskCancelled || f.used() != 0 {
t.Fatalf("cancel winner: start=%v task=%s used=%d", startErr, out.Task.State, f.used())
}
} else {
if fault.Code(cancelErr) != "STATE_CONFLICT" || startErr != nil || f.get(ref.TaskID).State != pulse.TaskRunning {
t.Fatal("invalid race resolution")
}
cancel(f, f.get(ref.TaskID))
retryFinish(t, f, ref, "CANCELLED", false)
}
}
})
}

View File

@@ -0,0 +1,35 @@
package mysql
import (
"context"
"database/sql"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
)
// Cancellations 读取当前有效会话持有的取消意图;断线后由新会话 Resume 和扫描重新交付。
// 通知允许重复或迟到,不能代替任务结果;256 对应单 Worker 最大持久化容量。
func (s *ExecutionStore) Cancellations(ctx context.Context, token auth.Token, b worker.Binding) ([]worker.Cancellation, error) {
out := []worker.Cancellation{}
err := s.transaction(ctx, token, "workers:consume", func(tx *sql.Tx, p auth.Permissions) error {
state, err := s.lockSession(ctx, tx, token, b, p)
if err != nil {
return err
}
rows, err := tx.QueryContext(ctx, `SELECT t.id,a.id,a.epoch,t.cancel_requested_at FROM pulse_attempts a JOIN pulse_tasks t ON t.namespace_id=a.namespace_id AND t.id=a.task_id AND t.current_attempt_id=a.id WHERE a.namespace_id=? AND a.session_id=? AND a.state='RUNNING' AND t.state='RUNNING' AND t.queue_id=? AND t.cancel_requested_at IS NOT NULL ORDER BY a.id LIMIT 256`, token.NamespaceID, b.SessionID, state.queue)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var c worker.Cancellation
if err := rows.Scan(&c.TaskID, &c.AttemptID, &c.Epoch, &c.RequestedAt); err != nil {
return err
}
out = append(out, c)
}
return rows.Err()
})
return out, err
}

View File

@@ -181,7 +181,7 @@ func (s *ExecutionStore) Claim(ctx context.Context, token auth.Token, b worker.B
if !state.expires.After(now) { if !state.expires.After(now) {
return worker.StaleSession return worker.StaleSession
} }
if task.view.State != pulse.TaskWaiting || task.view.AvailableAt.After(now) || !task.view.DeadlineAt.After(now) || task.view.AttemptCount >= task.maxAttempts || (task.view.StartDeadlineAt != nil && !task.view.StartDeadlineAt.After(now)) { if task.view.State != pulse.TaskWaiting || task.view.CancelRequestedAt != nil || task.view.AvailableAt.After(now) || !task.view.DeadlineAt.After(now) || task.view.AttemptCount >= task.maxAttempts || (task.view.StartDeadlineAt != nil && !task.view.StartDeadlineAt.After(now)) {
continue continue
} }
if task.epoch == ^uint64(0) { if task.epoch == ^uint64(0) {
@@ -327,7 +327,7 @@ func leaseStatus(rows executionRows, b worker.Binding, now time.Time) worker.Lea
active = active && deadline.After(now) active = active && deadline.After(now)
until = minimumTime(until, deadline) until = minimumTime(until, deadline)
} }
return worker.LeaseStatus{Active: active, State: a.State, LeaseUntil: until, ServerTime: now} return worker.LeaseStatus{CancelRequestedAt: t.view.CancelRequestedAt, Active: active, State: a.State, LeaseUntil: until, ServerTime: now}
} }
func attemptIdentity(ref worker.AttemptRef) any { func attemptIdentity(ref worker.AttemptRef) any {
return struct { return struct {
@@ -453,6 +453,9 @@ func (s *ExecutionStore) executionCommand(ctx context.Context, token auth.Token,
task, a := rows.task, rows.attempt task, a := rows.task, rows.attempt
authorized := a.AuthorizedAt authorized := a.AuthorizedAt
if operation == "start" { if operation == "start" {
if task.view.CancelRequestedAt != nil {
return worker.LeaseLost
}
if a.State != pulse.AttemptLeased || task.view.State != pulse.TaskLeased { if a.State != pulse.AttemptLeased || task.view.State != pulse.TaskLeased {
return fault.Conflict return fault.Conflict
} }
@@ -466,6 +469,15 @@ func (s *ExecutionStore) executionCommand(ctx context.Context, token auth.Token,
} else if a.State != pulse.AttemptRunning || authorized == nil { } else if a.State != pulse.AttemptRunning || authorized == nil {
return fault.Conflict return fault.Conflict
} }
if task.view.CancelRequestedAt != nil {
// 取消后不延长执行窗口;当前权威结果仍可在原租约内提交。
receipt := worker.Receipt{Operation: operation, TaskState: task.view.State, AttemptState: a.State, TaskRevision: task.view.Revision, LeaseUntil: status.LeaseUntil, Disposition: "APPLIED"}
if err := saveReceipt(ctx, tx, token.NamespaceID, a.ID, message, operation, digest, receipt); err != nil {
return err
}
out = worker.CommandResult{Receipt: receipt, Lease: status}
return nil
}
until := minimumTime(now.Add(s.options.LeaseDuration), authorized.Add(time.Duration(task.attemptTimeout)*time.Millisecond), task.view.DeadlineAt, rows.session.expires) until := minimumTime(now.Add(s.options.LeaseDuration), authorized.Add(time.Duration(task.attemptTimeout)*time.Millisecond), task.view.DeadlineAt, rows.session.expires)
result, err := tx.ExecContext(ctx, `UPDATE pulse_attempts SET state='RUNNING',authorized_at=?,lease_until=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, authorized, until, now, token.NamespaceID, a.ID, a.Revision) result, err := tx.ExecContext(ctx, `UPDATE pulse_attempts SET state='RUNNING',authorized_at=?,lease_until=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, authorized, until, now, token.NamespaceID, a.ID, a.Revision)
if err := exactlyOne(result, err); err != nil { if err := exactlyOne(result, err); err != nil {

View File

@@ -59,7 +59,7 @@ func (s *ExecutionStore) Recover(ctx context.Context, limit int) (worker.Recover
out.Examined++ out.Examined++
var changed pulse.TaskState var changed pulse.TaskState
err := WithTx(ctx, s.db, func(tx *sql.Tx) error { err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
// 与 Worker 命令一致先锁队列;有其他节点正在处理时跳过,本批不占用等待线程。 // 队列 → 任务 → 尝试 → 许可;忙碌资源留给下一批。
var queue core.ID 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) 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) { if errors.Is(err, sql.ErrNoRows) {

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"time" "time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model" core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth" "git.yuntuo.hk/k2safe/orbit/platform/auth"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model" pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
@@ -62,6 +63,9 @@ func (s *ExecutionStore) Finish(ctx context.Context, token auth.Token, b worker.
out = worker.CommandResult{Receipt: receipt, Lease: status, Deduplicated: true} out = worker.CommandResult{Receipt: receipt, Lease: status, Deduplicated: true}
return nil return nil
} }
if in.Outcome == "CANCELLED" && task.view.CancelRequestedAt == nil {
return fault.Conflict
}
reason := "" reason := ""
if a.ResultDigest != nil { if a.ResultDigest != nil {
reason = "RESULT_CONFLICT" reason = "RESULT_CONFLICT"

View File

@@ -98,6 +98,15 @@ func (s *ExecutionStore) Connect(ctx context.Context, token auth.Token, in worke
if registration != storedRegistration || epoch == ^uint64(0) { if registration != storedRegistration || epoch == ^uint64(0) {
return fault.Conflict return fault.Conflict
} }
// 不依赖数据库外键:创建会话前验证队列存在且属于当前命名空间。
// 进程头在队列之前加锁,与执行命令保持一致;失败时回滚新建的进程头。
var queue core.ID
if err := tx.QueryRowContext(ctx, `SELECT id FROM pulse_queues WHERE namespace_id=? AND id=? FOR SHARE`, token.NamespaceID, in.QueueID).Scan(&queue); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fault.NotFound
}
return err
}
now, err := dbNow(ctx, tx) now, err := dbNow(ctx, tx)
if err != nil { if err != nil {
return err return err
@@ -160,9 +169,13 @@ func (s *ExecutionStore) lockSession(ctx context.Context, tx *sql.Tx, token auth
if !p.AllowsQueue(state.queue) { if !p.AllowsQueue(state.queue) {
return state, fault.Forbidden return state, fault.Forbidden
} }
var node core.ID var node, instance, sessionCredential core.ID
var sessionEpoch uint64
var raw []byte var raw []byte
err = tx.QueryRowContext(ctx, `SELECT node_id,capacity,capacity_sequence,capabilities,expires_at FROM pulse_worker_sessions WHERE namespace_id=? AND id=? FOR UPDATE`, token.NamespaceID, b.SessionID).Scan(&node, &state.capacity, &state.sequence, &raw, &state.expires) err = tx.QueryRowContext(ctx, `SELECT node_id,worker_instance_id,credential_id,session_epoch,capacity,capacity_sequence,capabilities,expires_at FROM pulse_worker_sessions WHERE namespace_id=? AND id=? FOR UPDATE`, token.NamespaceID, b.SessionID).Scan(&node, &instance, &sessionCredential, &sessionEpoch, &state.capacity, &state.sequence, &raw, &state.expires)
if errors.Is(err, sql.ErrNoRows) {
return state, worker.StaleSession
}
if err != nil { if err != nil {
return state, err return state, err
} }
@@ -170,7 +183,7 @@ func (s *ExecutionStore) lockSession(ctx context.Context, tx *sql.Tx, token auth
if err != nil { if err != nil {
return state, err return state, err
} }
if node != s.node || !state.expires.After(now) { if node != s.node || instance != b.InstanceID || sessionCredential != token.CredentialID || sessionEpoch != b.Epoch || !state.expires.After(now) {
return state, worker.StaleSession return state, worker.StaleSession
} }
if err := json.Unmarshal(raw, &state.capabilities); err != nil { if err := json.Unmarshal(raw, &state.capabilities); err != nil {

View File

@@ -78,7 +78,11 @@ func registerTasks(mux *http.ServeMux, s *service.Service) {
return nil, 0, err return nil, 0, err
} }
out, err := s.CancelTask(r.Context(), t, core.ID(strings.TrimSuffix(command, ":cancel")), key, in) out, err := s.CancelTask(r.Context(), t, core.ID(strings.TrimSuffix(command, ":cancel")), key, in)
return out, 200, err status := 200
if out.Task.State == pulse.TaskRunning {
status = 202
}
return out, status, err
})) }))
mux.HandleFunc("POST /v1/queues", wrap(func(r *http.Request, t auth.Token) (any, int, error) { mux.HandleFunc("POST /v1/queues", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
var in service.QueueInput var in service.QueueInput

View File

@@ -248,6 +248,7 @@ func (h *WorkerHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
draining := welcome.Draining draining := welcome.Draining
eligible := false eligible := false
leases := map[core.ID]time.Time{} leases := map[core.ID]time.Time{}
cancelSent := map[core.ID]bool{}
ticker := time.NewTicker(h.poll) ticker := time.NewTicker(h.poll)
defer ticker.Stop() defer ticker.Stop()
for { for {
@@ -332,6 +333,36 @@ func (h *WorkerHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
case <-ticker.C: case <-ticker.C:
// 取消与领取容量、排空无关;通知丢失后仍可由 Resume/Renew 核对持久化意图。
if input.CancellationNotifications && len(leases) > 0 {
call, stop := context.WithTimeout(ctx, 8*time.Second)
items, err := h.service.Cancellations(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
}
} else {
active := make(map[core.ID]bool, len(items))
for _, item := range items {
active[item.AttemptID] = true
if cancelSent[item.AttemptID] || len(outgoing) >= 4 {
continue
}
raw, err := jsonwire.Marshal(item)
if err != nil || !send(workerFrame{Type: "task.cancel", Body: raw}, false) {
return
}
cancelSent[item.AttemptID] = true
}
for id := range cancelSent {
if !active[id] {
delete(cancelSent, id)
}
}
}
}
// 本地记录仅减少空轮询;SQL 仍原子验证总容量、队列额度和有效会话。 // 本地记录仅减少空轮询;SQL 仍原子验证总容量、队列额度和有效会话。
now := time.Now() now := time.Now()
for attempt, until := range leases { for attempt, until := range leases {

View File

@@ -97,6 +97,8 @@ func TestWorkerPublishedContract(t *testing.T) {
secret := strings.Repeat("A", 43) secret := strings.Repeat("A", 43)
ref := worker.AttemptRef{TaskID: id, AttemptID: id, Epoch: 1, LeaseSecret: secret} ref := worker.AttemptRef{TaskID: id, AttemptID: id, Epoch: 1, LeaseSecret: secret}
hello := worker.ConnectInput{InstanceID: id, InstanceSecret: secret, QueueID: id, Capacity: 1, Capabilities: []worker.Capability{{Handler: "text.normalize", HandlerVersion: "v1", PayloadVersion: "v1"}}} hello := worker.ConnectInput{InstanceID: id, InstanceSecret: secret, QueueID: id, Capacity: 1, Capabilities: []worker.Capability{{Handler: "text.normalize", HandlerVersion: "v1", PayloadVersion: "v1"}}}
helloWithCancellation := hello
helloWithCancellation.CancellationNotifications = true
cases := []struct { cases := []struct {
kind string kind string
body any body any
@@ -108,6 +110,8 @@ func TestWorkerPublishedContract(t *testing.T) {
{"task.finish", worker.FinishInput{Ref: ref, Outcome: "FAILED", ErrorCode: "OBJECT_REJECTED", Result: json.RawMessage(`{}`)}}, {"task.finish", worker.FinishInput{Ref: ref, Outcome: "FAILED", ErrorCode: "OBJECT_REJECTED", Result: json.RawMessage(`{}`)}},
{"task.finish", worker.FinishInput{Ref: ref, Outcome: "UNKNOWN", ErrorCode: "LEASE_LOST", Result: json.RawMessage(`{}`)}}, {"task.finish", worker.FinishInput{Ref: ref, Outcome: "UNKNOWN", ErrorCode: "LEASE_LOST", Result: json.RawMessage(`{}`)}},
{"task.finish", worker.FinishInput{Ref: ref, Outcome: "FAILED", Retryable: true, ErrorCode: "TEMPORARY_REJECTION", Result: json.RawMessage(`{}`)}}, {"task.finish", worker.FinishInput{Ref: ref, Outcome: "FAILED", Retryable: true, ErrorCode: "TEMPORARY_REJECTION", Result: json.RawMessage(`{}`)}},
{"task.finish", worker.FinishInput{Ref: ref, Outcome: "CANCELLED", ErrorCode: "CANCEL_CONFIRMED", Result: json.RawMessage(`{}`)}},
{"worker.hello", helloWithCancellation},
} }
var valid [][]byte var valid [][]byte
for _, tc := range cases { for _, tc := range cases {
@@ -147,6 +151,7 @@ func TestWorkerPublishedContract(t *testing.T) {
{"failure_missing_code", strings.Replace(string(valid[6]), `,"error_code":"OBJECT_REJECTED"`, ``, 1)}, {"failure_missing_code", strings.Replace(string(valid[6]), `,"error_code":"OBJECT_REJECTED"`, ``, 1)},
{"success_with_code", strings.Replace(string(valid[6]), `"FAILED"`, `"SUCCEEDED"`, 1)}, {"success_with_code", strings.Replace(string(valid[6]), `"FAILED"`, `"SUCCEEDED"`, 1)},
{"unknown_retry_flag", strings.Replace(string(valid[8]), `"FAILED"`, `"UNKNOWN"`, 1)}, {"unknown_retry_flag", strings.Replace(string(valid[8]), `"FAILED"`, `"UNKNOWN"`, 1)},
{"cancelled_retry_flag", strings.Replace(string(valid[8]), `"FAILED"`, `"CANCELLED"`, 1)},
} }
for _, tc := range invalid { for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -180,7 +185,7 @@ func TestWorkerPublishedContract(t *testing.T) {
now := time.Date(2026, 9, 17, 8, 0, 0, 0, time.UTC) now := time.Date(2026, 9, 17, 8, 0, 0, 0, time.UTC)
welcome := worker.Welcome{SessionID: id, Epoch: 1, ServerTime: now, ExpiresAt: now.Add(45 * time.Second), LeaseDurationMS: 30000} welcome := worker.Welcome{SessionID: id, Epoch: 1, ServerTime: now, ExpiresAt: now.Add(45 * time.Second), LeaseDurationMS: 30000}
lease := worker.LeaseStatus{Active: true, State: pulse.AttemptRunning, LeaseUntil: now.Add(30 * time.Second), ServerTime: now} lease := worker.LeaseStatus{Active: true, State: pulse.AttemptRunning, LeaseUntil: now.Add(30 * time.Second), ServerTime: now, CancelRequestedAt: &now}
receipt := worker.Receipt{Operation: "start", TaskState: pulse.TaskRunning, AttemptState: pulse.AttemptRunning, TaskRevision: 3, LeaseUntil: lease.LeaseUntil, Disposition: "APPLIED"} receipt := worker.Receipt{Operation: "start", TaskState: pulse.TaskRunning, AttemptState: pulse.AttemptRunning, TaskRevision: 3, LeaseUntil: lease.LeaseUntil, Disposition: "APPLIED"}
task := service.TaskView{ID: id, NamespaceID: id, QueueID: id, State: pulse.TaskLeased, Revision: 2, Handler: "text.normalize", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`), AvailableAt: now, DeadlineAt: now.Add(time.Hour), RecoverySafety: pulse.RecoveryReconcileOnly, Retry: pulse.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 30000}, AttemptCount: 1, CreatedAt: now, UpdatedAt: now} task := service.TaskView{ID: id, NamespaceID: id, QueueID: id, State: pulse.TaskLeased, Revision: 2, Handler: "text.normalize", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`), AvailableAt: now, DeadlineAt: now.Add(time.Hour), RecoverySafety: pulse.RecoveryReconcileOnly, Retry: pulse.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 30000}, AttemptCount: 1, CreatedAt: now, UpdatedAt: now}
assignment := worker.Assignment{Task: task, AttemptID: id, Epoch: 1, LeaseSecret: secret, LeaseUntil: lease.LeaseUntil, ServerTime: now} assignment := worker.Assignment{Task: task, AttemptID: id, Epoch: 1, LeaseSecret: secret, LeaseUntil: lease.LeaseUntil, ServerTime: now}
@@ -190,10 +195,11 @@ func TestWorkerPublishedContract(t *testing.T) {
}{ }{
{"worker.welcome", welcome}, {"ack", welcome}, {"ack", lease}, {"worker.welcome", welcome}, {"ack", welcome}, {"ack", lease},
{"ack", worker.CommandResult{Receipt: receipt, Lease: lease, Deduplicated: true}}, {"task.assign", assignment}, {"ack", worker.CommandResult{Receipt: receipt, Lease: lease, Deduplicated: true}}, {"task.assign", assignment},
{"task.cancel", worker.Cancellation{TaskID: id, AttemptID: id, Epoch: 1, RequestedAt: now}},
} { } {
raw, _ := jsonwire.Marshal(tc.body) raw, _ := jsonwire.Marshal(tc.body)
f := workerFrame{Version: "1", Type: tc.kind, MessageID: id, SessionID: id, ReplyTo: id, Body: raw} f := workerFrame{Version: "1", Type: tc.kind, MessageID: id, SessionID: id, ReplyTo: id, Body: raw}
if tc.kind == "task.assign" { if tc.kind == "task.assign" || tc.kind == "task.cancel" {
f.ReplyTo = "" f.ReplyTo = ""
} }
frame, _ := jsonwire.Marshal(f) frame, _ := jsonwire.Marshal(f)

View File

@@ -115,6 +115,9 @@
"type": "integer", "type": "integer",
"minimum": 1, "minimum": 1,
"maximum": 256 "maximum": 256
},
"cancellation_notifications": {
"type": "boolean"
} }
}, },
"required": [ "required": [
@@ -184,7 +187,8 @@
"enum": [ "enum": [
"SUCCEEDED", "SUCCEEDED",
"FAILED", "FAILED",
"UNKNOWN" "UNKNOWN",
"CANCELLED"
] ]
}, },
"result": { "result": {
@@ -242,7 +246,8 @@
"outcome": { "outcome": {
"enum": [ "enum": [
"SUCCEEDED", "SUCCEEDED",
"UNKNOWN" "UNKNOWN",
"CANCELLED"
] ]
} }
} }
@@ -305,6 +310,9 @@
}, },
"server_time": { "server_time": {
"$ref": "#/$defs/time" "$ref": "#/$defs/time"
},
"cancel_requested_at": {
"$ref": "#/$defs/time"
} }
}, },
"required": [ "required": [
@@ -920,8 +928,63 @@
}, },
{ {
"$ref": "#/$defs/errorFrame" "$ref": "#/$defs/errorFrame"
},
{
"$ref": "#/$defs/cancellationFrame"
} }
] ]
},
"cancellation": {
"type": "object",
"properties": {
"task_id": {
"$ref": "#/$defs/id"
},
"attempt_id": {
"$ref": "#/$defs/id"
},
"epoch": {
"$ref": "#/$defs/uint64"
},
"requested_at": {
"$ref": "#/$defs/time"
}
},
"required": [
"task_id",
"attempt_id",
"epoch",
"requested_at"
],
"additionalProperties": false
},
"cancellationFrame": {
"type": "object",
"properties": {
"protocol_version": {
"const": "1"
},
"type": {
"const": "task.cancel"
},
"message_id": {
"$ref": "#/$defs/id"
},
"session_id": {
"$ref": "#/$defs/id"
},
"body": {
"$ref": "#/$defs/cancellation"
}
},
"required": [
"protocol_version",
"type",
"message_id",
"session_id",
"body"
],
"additionalProperties": false
} }
} }
} }

View File

@@ -16,7 +16,7 @@ type Input struct {
Policy pulse.RetryPolicy // Policy 受理时冻结的有限策略。 Policy pulse.RetryPolicy // Policy 受理时冻结的有限策略。
Safety pulse.RecoverySafety // Safety 仅 IDEMPOTENT 允许未知效果自动恢复。 Safety pulse.RecoverySafety // Safety 仅 IDEMPOTENT 允许未知效果自动恢复。
Consumed uint32 // Consumed 已领取次数,包含本次及未获批开始的尝试。 Consumed uint32 // Consumed 已领取次数,包含本次及未获批开始的尝试。
Outcome string // Outcome SUCCEEDED、FAILED、UNKNOWN 或 NOT_STARTED。 Outcome string // Outcome SUCCEEDED、FAILED、UNKNOWN、CANCELLED 或 NOT_STARTED。
Retryable bool // Retryable 仅对明确失败有效,Worker 声明可安全重试。 Retryable bool // Retryable 仅对明确失败有效,Worker 声明可安全重试。
Uncertain bool // Uncertain 之前仍未核实的外部效果,失败或超时不能抹掉。 Uncertain bool // Uncertain 之前仍未核实的外部效果,失败或超时不能抹掉。
CancelRequested bool // CancelRequested 已保存取消意图时禁止后续尝试。 CancelRequested bool // CancelRequested 已保存取消意图时禁止后续尝试。
@@ -47,6 +47,15 @@ func Decide(in Input) (Decision, error) {
switch in.Outcome { switch in.Outcome {
case "SUCCEEDED": case "SUCCEEDED":
return Decision{State: pulse.TaskSucceeded, Reason: "SUCCEEDED"}, nil return Decision{State: pulse.TaskSucceeded, Reason: "SUCCEEDED"}, nil
case "CANCELLED":
if !in.CancelRequested {
return out, errors.New("cancelled result requires persisted intent")
}
out.State, out.Reason = pulse.TaskCancelled, "CANCEL_CONFIRMED"
if in.Uncertain {
out.State, out.Reason = pulse.TaskNeedsReview, "RECONCILIATION_REQUIRED"
}
return out, nil
case "FAILED": case "FAILED":
canRetry = in.Retryable canRetry = in.Retryable
if canRetry { if canRetry {

View File

@@ -117,7 +117,7 @@ func (s *Service) Finish(ctx context.Context, t auth.Token, b Binding, message c
if err := validCommand(message, in.Ref); err != nil { if err := validCommand(message, in.Ref); err != nil {
return CommandResult{}, err return CommandResult{}, err
} }
if in.Outcome != "SUCCEEDED" && in.Outcome != "FAILED" && in.Outcome != "UNKNOWN" { if in.Outcome != "SUCCEEDED" && in.Outcome != "FAILED" && in.Outcome != "UNKNOWN" && in.Outcome != "CANCELLED" {
return CommandResult{}, fault.Invalid("invalid result outcome") return CommandResult{}, fault.Invalid("invalid result outcome")
} }
if in.Retryable && in.Outcome != "FAILED" { if in.Retryable && in.Outcome != "FAILED" {
@@ -140,3 +140,7 @@ func (s *Service) Resume(ctx context.Context, t auth.Token, b Binding, ref Attem
} }
return s.store.Resume(ctx, t, b, ref) return s.store.Resume(ctx, t, b, ref)
} }
func (s *Service) Cancellations(ctx context.Context, t auth.Token, b Binding) ([]Cancellation, error) {
return s.store.Cancellations(ctx, t, b)
}

View File

@@ -21,6 +21,7 @@ type Capability struct {
// ConnectInput 在每次连接时提交;同一进程恢复时保留实例和秘密。 // ConnectInput 在每次连接时提交;同一进程恢复时保留实例和秘密。
type ConnectInput struct { type ConnectInput struct {
CancellationNotifications bool `json:"cancellation_notifications,omitempty"` // CancellationNotifications 显式接受 task.cancel 提示;事实仍从持久化租约核对。
InstanceID core.ID `json:"instance_id"` // InstanceID 进程标识,重启换新值。 InstanceID core.ID `json:"instance_id"` // InstanceID 进程标识,重启换新值。
InstanceSecret string `json:"instance_secret"` // InstanceSecret 32 字节随机秘密,只保存摘要。 InstanceSecret string `json:"instance_secret"` // InstanceSecret 32 字节随机秘密,只保存摘要。
QueueID core.ID `json:"queue_id"` // QueueID 当前进程绑定的消费队列。 QueueID core.ID `json:"queue_id"` // QueueID 当前进程绑定的消费队列。
@@ -74,7 +75,7 @@ type AttemptRef struct {
// FinishInput 区分明确结果与未知效果,未知不能被解释为可以盲目重跑。 // FinishInput 区分明确结果与未知效果,未知不能被解释为可以盲目重跑。
type FinishInput struct { type FinishInput struct {
Ref AttemptRef `json:"ref"` // Ref 当前尝试授权证明。 Ref AttemptRef `json:"ref"` // Ref 当前尝试授权证明。
Outcome string `json:"outcome"` // Outcome 为 SUCCEEDED、FAILED 或 UNKNOWN。 Outcome string `json:"outcome"` // Outcome 为 SUCCEEDED、FAILED、UNKNOWN 或确认本次停止且无遗留效果的 CANCELLED。
Result json.RawMessage `json:"result"` // Result 非 null JSON 结果或证据引用,最多 256 KiB。 Result json.RawMessage `json:"result"` // Result 非 null JSON 结果或证据引用,最多 256 KiB。
ErrorCode string `json:"error_code,omitempty"` // ErrorCode FAILED/UNKNOWN 必填稳定分类,不包含原始异常正文。 ErrorCode string `json:"error_code,omitempty"` // ErrorCode FAILED/UNKNOWN 必填稳定分类,不包含原始异常正文。
Retryable bool `json:"retryable,omitempty"` // Retryable 仅 FAILED 可为真,声明该明确失败可安全重试;默认假。 Retryable bool `json:"retryable,omitempty"` // Retryable 仅 FAILED 可为真,声明该明确失败可安全重试;默认假。
@@ -93,6 +94,7 @@ type Receipt struct {
// LeaseStatus 每次响应重新计算;处理器仅在 Active 为真、State 为 RUNNING 且期限内推进。 // LeaseStatus 每次响应重新计算;处理器仅在 Active 为真、State 为 RUNNING 且期限内推进。
// LEASED 的 Active 只代表预占仍有效,不能代替开始授权。 // LEASED 的 Active 只代表预占仍有效,不能代替开始授权。
type LeaseStatus struct { type LeaseStatus struct {
CancelRequestedAt *time.Time `json:"cancel_requested_at,omitempty"` // CancelRequestedAt 已保存的取消时间;非空禁止继续业务处理,有效期内仍能确认结果。
Active bool `json:"active"` // Active 当前会话仍持有未过期的有效尝试。 Active bool `json:"active"` // Active 当前会话仍持有未过期的有效尝试。
State pulse.AttemptState `json:"state"` // State 当前尝试状态。 State pulse.AttemptState `json:"state"` // State 当前尝试状态。
LeaseUntil time.Time `json:"lease_until"` // LeaseUntil 当前租约,不因重放旧回执自动延长。 LeaseUntil time.Time `json:"lease_until"` // LeaseUntil 当前租约,不因重放旧回执自动延长。
@@ -113,7 +115,17 @@ type RecoveryStats struct {
Failed int Failed int
NeedsReview int NeedsReview int
} }
// Cancellation 是当前会话拥有的运行尝试的持久化取消提示,不是结果确认。
type Cancellation struct {
TaskID core.ID `json:"task_id"` // TaskID 已授权任务。
AttemptID core.ID `json:"attempt_id"` // AttemptID 目标尝试,旧通知不得影响新尝试。
Epoch uint64 `json:"epoch,string"` // Epoch 任务执行代次。
RequestedAt time.Time `json:"requested_at"` // RequestedAt 首次取消意图的数据库时间。
}
type Store interface { type Store interface {
Cancellations(context.Context, auth.Token, Binding) ([]Cancellation, error)
Authorize(context.Context, auth.Token) error Authorize(context.Context, auth.Token) error
Connect(context.Context, auth.Token, ConnectInput) (Binding, Welcome, error) Connect(context.Context, auth.Token, ConnectInput) (Binding, Welcome, error)
Capacity(context.Context, auth.Token, Binding, CapacityInput) (Welcome, error) Capacity(context.Context, auth.Token, Binding, CapacityInput) (Welcome, error)

View File

@@ -56,6 +56,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/history) (cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/history)
(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/retry) (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/retry)
(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/schedules) (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/schedules)
(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)
# 停止原服务后再做进程故障演练,避免其他恢复扫描器掩盖接管验证。 # 停止原服务后再做进程故障演练,避免其他恢复扫描器掩盖接管验证。
kill "$TASK_SERVER_PID" kill "$TASK_SERVER_PID"

View File

@@ -0,0 +1,133 @@
// cancellation 演示运行中取消、处理器确认停止、结果查询和有界排空。
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, queue := os.Getenv("ORBIT_TOKEN"), os.Getenv("ORBIT_QUEUE_ID")
if path := os.Getenv("ORBIT_CREDENTIALS_FILE"); path != "" {
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read private credentials: %w", err)
}
var c struct {
Token string `json:"token"`
QueueID string `json:"queue_id"`
}
if json.Unmarshal(raw, &c) != nil {
return errors.New("invalid credentials file")
}
if token == "" {
token = c.Token
}
if queue == "" {
queue = c.QueueID
}
}
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
if err != nil {
return err
}
operation, err := orbit.NewWorkerMessageID()
if err != nil {
return err
}
name := "object.cancel." + operation
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
entered := make(chan struct{})
w, err := orbit.NewWorker(client, orbit.WorkerOptions{QueueID: queue, Capacity: 1, ShutdownTimeout: 5 * time.Second, Handlers: []orbit.WorkerHandler{{Capability: orbit.WorkerCapability{Handler: name, HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: func(work context.Context, e orbit.WorkerExecution) (json.RawMessage, error) {
close(entered)
<-work.Done()
if !errors.Is(context.Cause(work), orbit.ErrTaskCancellationRequested) {
return nil, work.Err()
}
// 此示例没有外部副作用,因此可以确认安全停止;真实接入需完成自己的核实。
return nil, &orbit.WorkerCancellationConfirmed{Details: json.RawMessage(`{"evidence_ref":"objects/no-external-work"}`)}
}}}})
if err != nil {
return err
}
task, err := client.SubmitTask(ctx, "submit-"+operation, orbit.SubmitTaskRequest{QueueID: queue, Handler: name, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"object_ref":"objects/example"}`), TaskTimeoutMS: 20000, Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 15000}})
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
}
}()
select {
case <-entered:
case err := <-done:
waited = true
return fmt.Errorf("Worker stopped before handler: %v", err)
case <-ctx.Done():
return ctx.Err()
}
for {
current, err := client.GetTask(ctx, task.Task.ID)
if err != nil {
return err
}
key, err := orbit.NewWorkerMessageID()
if err != nil {
return err
}
out, err := client.CancelTask(ctx, current.ID, key, orbit.CancelTaskRequest{ExpectedRevision: current.Revision, Reason: "finish cancellation example"})
if err == nil {
fmt.Printf("task=%s cancel_requested=%t accepted_state=%s\n", out.Task.ID, out.Task.CancelRequestedAt != nil, out.Task.State)
break
}
var api *orbit.APIError
if !errors.As(err, &api) || api.Code != "STATE_CONFLICT" {
return err
}
}
for {
current, err := client.GetTask(ctx, task.Task.ID)
if err != nil {
return err
}
if current.State == orbit.TaskCancelled {
fmt.Printf("final_state=%s attempts=%d\n", current.State, current.AttemptCount)
break
}
if current.State == orbit.TaskNeedsReview || current.State == orbit.TaskFailed || current.State == orbit.TaskSucceeded {
return fmt.Errorf("unexpected result: %s", current.State)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
stop()
err = <-done
waited = true
if err != nil {
return err
}
fmt.Printf("worker_drained=true running=%d\n", w.Stats().Running)
return nil
}

View File

@@ -0,0 +1,156 @@
package orbit
import (
"context"
"encoding/json"
"errors"
"sync/atomic"
"testing"
"time"
)
func cancelRuntimeTask(t *testing.T, ctx context.Context, c *Client, id string) CancelTaskResult {
t.Helper()
for {
current, err := c.GetTask(ctx, id)
if err != nil {
t.Fatal(err)
}
out, err := c.CancelTask(ctx, id, "cancel-"+workerTestID(t), CancelTaskRequest{ExpectedRevision: current.Revision, Reason: "controlled cancellation regression"})
if err == nil {
return out
}
var api *APIError
if !errors.As(err, &api) || api.Code != "STATE_CONFLICT" {
t.Fatal(err)
}
if ctx.Err() != nil {
t.Fatal(ctx.Err())
}
}
}
func TestIntegrationWorkerRuntimeCancellation(t *testing.T) {
api, queue, token := integrationWorkerClient(t)
for _, mode := range []string{"confirmed", "unknown", "success", "lost_notice", "before_handler", "uncooperative"} {
t.Run(mode, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second)
defer cancel()
g := newWorkerFaultGateway(t, api.baseURL)
client, err := NewClient(g.server.URL, Options{Token: token, HTTPClient: g.server.Client()})
if err != nil {
t.Fatal(err)
}
name := "cancel." + mode + "." + workerTestID(t)
entered := make(chan struct{})
observed := make(chan error, 1)
release := make(chan struct{})
var calls atomic.Int32
w, err := NewWorker(client, WorkerOptions{QueueID: queue, Capacity: 1, ShutdownTimeout: 2 * time.Second, Handlers: []WorkerHandler{runtimeHandler(name, func(work context.Context, e WorkerExecution) (json.RawMessage, error) {
calls.Add(1)
close(entered)
if mode == "uncooperative" {
<-release
return json.RawMessage(`{"late":true}`), nil
}
<-work.Done()
observed <- context.Cause(work)
switch mode {
case "unknown":
return nil, work.Err()
case "success":
return json.RawMessage(`{"sequence":9007199254740993}`), nil
default:
return nil, &WorkerCancellationConfirmed{Details: json.RawMessage(`{"evidence_ref":"objects/stopped","sequence":9007199254740993}`)}
}
})}})
if err != nil {
t.Fatal(err)
}
timeout := uint64(12000)
if mode == "uncooperative" {
timeout = 2000
}
task := runtimeTask(t, ctx, api, queue, name, timeout)
if mode == "before_handler" {
g.dropOffline.Store(true)
g.drop.Store("task.start")
}
run, stop := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- w.Run(run) }()
defer stop()
defer close(release)
if mode == "before_handler" {
g.expectDrop(t, "task.start")
} else {
select {
case <-entered:
case err := <-done:
t.Fatalf("Worker stopped: %v", err)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
if mode == "lost_notice" {
g.dropCancel.Store(true)
}
if mode == "confirmed" {
g.drop.Store("task.finish")
}
out := cancelRuntimeTask(t, ctx, api, task.ID)
if out.Task.State != TaskRunning || out.Task.CancelRequestedAt == nil || out.Task.CompletedAt != nil {
t.Fatal("cancel intent reported completion")
}
if mode == "before_handler" {
g.offline.Store(false)
}
if mode == "lost_notice" {
g.expectDrop(t, "task.cancel")
}
if mode != "uncooperative" && mode != "before_handler" {
select {
case cause := <-observed:
if !errors.Is(cause, ErrTaskCancellationRequested) {
t.Fatalf("wrong handler cancellation cause: %v", cause)
}
case <-ctx.Done():
t.Fatal("handler missed cancellation")
}
}
want := TaskCancelled
if mode == "unknown" || mode == "uncooperative" {
want = TaskNeedsReview
}
if mode == "success" {
want = TaskSucceeded
}
finished := waitRuntimeState(t, ctx, api, task.ID, want)
if finished.AttemptCount != 1 {
t.Fatal("cancel request created retry")
}
if mode == "success" && string(finished.Result) != `{"sequence":9007199254740993}` {
t.Fatal("cancel overwrote success")
}
if mode == "before_handler" && calls.Load() != 0 {
t.Fatal("handler started after cancellation observed")
}
if mode == "confirmed" {
g.expectDrop(t, "task.finish")
}
stop()
select {
case err := <-done:
if mode == "uncooperative" {
if !errors.Is(err, ErrWorkerShutdownTimeout) {
t.Fatalf("uncooperative handler falsely drained: %v", err)
}
} else if err != nil {
t.Fatal(err)
}
case <-ctx.Done():
t.Fatal("Worker did not drain")
}
})
}
}

View File

@@ -0,0 +1,42 @@
package orbit
import (
"context"
"errors"
"testing"
"time"
"github.com/coder/websocket"
)
func TestCancellationNotificationAndExecutionBoundary(t *testing.T) {
r := workerTestRegistration(t)
r.CancellationNotifications = true
_, c := workerMock(t, r, func(ctx context.Context, c *websocket.Conn) {
body := workerTestBody(t, WorkerCancellation{TaskID: workerTestTask, AttemptID: workerTestAttempt, Epoch: 1, RequestedAt: time.Now().UTC()})
workerTestWrite(t, ctx, c, workerFrame{Type: "task.cancel", Body: body})
<-ctx.Done()
})
ctx, stop := context.WithTimeout(context.Background(), time.Second)
defer stop()
notice, err := c.NextCancellation(ctx)
if err != nil || notice.AttemptID != workerTestAttempt || notice.Epoch != 1 {
t.Fatalf("notice: %v", err)
}
cancelled, cancel := context.WithCancel(ctx)
cancel()
if _, err := c.NextCancellation(cancelled); !errors.Is(err, context.Canceled) {
t.Fatal("cancelled read did not end")
}
if c.Err() != nil {
t.Fatal("cancelled wait closed connection")
}
now := time.Now()
lease := WorkerLease{Active: true, State: "RUNNING", ServerTime: now, LeaseUntil: now.Add(time.Second), CancelRequestedAt: &now}
if err := lease.confirm(now); err != nil {
t.Fatal(err)
}
if _, ok := lease.ExecutionDeadline(); ok {
t.Fatal("cancel intent still granted execution")
}
}

View File

@@ -53,6 +53,7 @@ type WorkerConnection struct {
welcome WorkerWelcome welcome WorkerWelcome
registration WorkerRegistration registration WorkerRegistration
assignments chan WorkerAssignment assignments chan WorkerAssignment
cancellations chan WorkerCancellation
done chan struct{} done chan struct{}
mu sync.Mutex mu sync.Mutex
err error err error
@@ -116,7 +117,7 @@ func (c *Client) DialWorker(ctx context.Context, registration WorkerRegistration
return nil, ErrWorkerProtocol return nil, ErrWorkerProtocol
} }
lifetime, cancel := context.WithCancel(context.Background()) lifetime, cancel := context.WithCancel(context.Background())
w := &WorkerConnection{conn: conn, ctx: lifetime, cancel: cancel, welcome: welcome, registration: r, assignments: make(chan WorkerAssignment, 8), done: make(chan struct{}), pending: make(map[string]chan workerFrame)} w := &WorkerConnection{conn: conn, ctx: lifetime, cancel: cancel, welcome: welcome, registration: r, assignments: make(chan WorkerAssignment, 8), cancellations: make(chan WorkerCancellation, 32), done: make(chan struct{}), pending: make(map[string]chan workerFrame)}
ok = true ok = true
go w.readLoop() go w.readLoop()
return w, nil return w, nil
@@ -175,6 +176,20 @@ func (w *WorkerConnection) readLoop() {
} }
continue continue
} }
if f.Type == "task.cancel" {
var c WorkerCancellation
if !w.registration.CancellationNotifications || f.ReplyTo != "" || json.Unmarshal(f.Body, &c) != nil || !validID(c.TaskID) || !validID(c.AttemptID) || c.Epoch == 0 || c.RequestedAt.IsZero() {
w.fail(ErrWorkerProtocol)
return
}
select {
case w.cancellations <- c:
default:
w.fail(ErrWorkerBackpressure)
return
}
continue
}
if f.Type != "ack" && f.Type != "error" { if f.Type != "ack" && f.Type != "error" {
w.fail(ErrWorkerProtocol) w.fail(ErrWorkerProtocol)
return return
@@ -222,6 +237,28 @@ func (w *WorkerConnection) NextAssignment(ctx context.Context) (WorkerAssignment
} }
} }
// NextCancellation 等待特定尝试的停止提示;需注册 CancellationNotifications 并持续读取。
// 提示允许迟到或重复;只取消匹配 TaskID/AttemptID/Epoch 的处理器,结果仍通过 Finish 确认。
func (w *WorkerConnection) NextCancellation(ctx context.Context) (WorkerCancellation, error) {
if !w.registration.CancellationNotifications {
return WorkerCancellation{}, errors.New("cancellation notifications not enabled")
}
if err := ctx.Err(); err != nil {
return WorkerCancellation{}, err
}
select {
case <-ctx.Done():
return WorkerCancellation{}, ctx.Err()
case <-w.ctx.Done():
return WorkerCancellation{}, w.Err()
case c := <-w.cancellations:
if err := w.Err(); err != nil {
return WorkerCancellation{}, err
}
return c, nil
}
}
func (w *WorkerConnection) call(ctx context.Context, kind, id string, in, out any) (time.Time, error) { func (w *WorkerConnection) call(ctx context.Context, kind, id string, in, out any) (time.Time, error) {
started := time.Now() started := time.Now()
if !validID(id) { if !validID(id) {
@@ -335,7 +372,7 @@ func (w *WorkerConnection) Finish(ctx context.Context, id string, in WorkerFinis
if in.Retryable && in.Outcome != WorkerFailed { if in.Retryable && in.Outcome != WorkerFailed {
return out, errors.New("retryable is only valid for known failure") return out, errors.New("retryable is only valid for known failure")
} }
if (in.Outcome != WorkerSucceeded && in.Outcome != WorkerFailed && in.Outcome != WorkerUnknown) || len(in.Result) > 256<<10 || bytes.Equal(bytes.TrimSpace(in.Result), []byte("null")) || validateWorkerJSON(in.Result) != nil { if (in.Outcome != WorkerSucceeded && in.Outcome != WorkerFailed && in.Outcome != WorkerUnknown && in.Outcome != WorkerCancelled) || len(in.Result) > 256<<10 || bytes.Equal(bytes.TrimSpace(in.Result), []byte("null")) || validateWorkerJSON(in.Result) != nil {
return out, errors.New("invalid Worker result outcome or JSON") return out, errors.New("invalid Worker result outcome or JSON")
} }
if in.Outcome == WorkerSucceeded && in.ErrorCode != "" || in.Outcome != WorkerSucceeded && !workerName(in.ErrorCode, 64) { if in.Outcome == WorkerSucceeded && in.ErrorCode != "" || in.Outcome != WorkerSucceeded && !workerName(in.ErrorCode, 64) {

View File

@@ -32,6 +32,9 @@ func (w *Worker) invoke(ctx context.Context, handler WorkerHandler, execution Wo
}() }()
if ctx.Err() != nil { if ctx.Err() != nil {
out = unknownHandlerResult(ref, "LEASE_LOST") out = unknownHandlerResult(ref, "LEASE_LOST")
if errors.Is(context.Cause(ctx), ErrTaskCancellationRequested) {
out = cancelledHandlerResult(ref, nil)
}
completed = true completed = true
return return
} }
@@ -46,7 +49,10 @@ func (w *Worker) invoke(ctx context.Context, handler WorkerHandler, execution Wo
} }
} else { } else {
var known *WorkerFailure var known *WorkerFailure
if errors.As(err, &known) && known != nil && workerName(known.Code, 64) { var cancelled *WorkerCancellationConfirmed
if errors.As(err, &cancelled) && cancelled != nil && errors.Is(context.Cause(ctx), ErrTaskCancellationRequested) {
out = cancelledHandlerResult(ref, cancelled.Details)
} else if errors.As(err, &known) && known != nil && workerName(known.Code, 64) {
details := append(json.RawMessage(nil), known.Details...) details := append(json.RawMessage(nil), known.Details...)
if len(details) == 0 { if len(details) == 0 {
details = json.RawMessage(`{}`) details = json.RawMessage(`{}`)
@@ -64,6 +70,17 @@ func (w *Worker) invoke(ctx context.Context, handler WorkerHandler, execution Wo
} }
completed = true completed = true
} }
func cancelledHandlerResult(ref WorkerAttempt, details json.RawMessage) WorkerFinish {
details = append(json.RawMessage(nil), details...)
if len(details) == 0 {
details = json.RawMessage(`{}`)
}
if !validHandlerResult(details) {
return unknownHandlerResult(ref, "INVALID_HANDLER_RESULT")
}
return WorkerFinish{Ref: ref, Outcome: WorkerCancelled, ErrorCode: "CANCEL_CONFIRMED", Result: details}
}
func unknownHandlerResult(ref WorkerAttempt, code string) WorkerFinish { func unknownHandlerResult(ref WorkerAttempt, code string) WorkerFinish {
return WorkerFinish{Ref: ref, Outcome: WorkerUnknown, ErrorCode: code, Result: json.RawMessage(`{}`)} return WorkerFinish{Ref: ref, Outcome: WorkerUnknown, ErrorCode: code, Result: json.RawMessage(`{}`)}
} }

View File

@@ -51,6 +51,7 @@ type workerFaultGateway struct {
drop atomic.Value drop atomic.Value
target atomic.Value target atomic.Value
dropOffline atomic.Bool dropOffline atomic.Bool
dropCancel atomic.Bool
offline atomic.Bool offline atomic.Bool
mu sync.Mutex mu sync.Mutex
active map[*websocket.Conn]struct{} active map[*websocket.Conn]struct{}
@@ -137,6 +138,10 @@ func newWorkerFaultGateway(t *testing.T, upstream string) *workerFaultGateway {
operation := operations[f.ReplyTo] operation := operations[f.ReplyTo]
delete(operations, f.ReplyTo) delete(operations, f.ReplyTo)
mu.Unlock() mu.Unlock()
if f.Type == "task.cancel" && g.dropCancel.CompareAndSwap(true, false) {
g.dropped <- "task.cancel"
return
}
if f.Type == "ack" && operation != "" && g.drop.CompareAndSwap(operation, "") { if f.Type == "ack" && operation != "" && g.drop.CompareAndSwap(operation, "") {
if g.dropOffline.Load() { if g.dropOffline.Load() {
g.offline.Store(true) g.offline.Store(true)

View File

@@ -14,6 +14,7 @@ type runtimeAttempt struct {
startID, renewID, finishID string startID, renewID, finishID string
guard *executionGuard guard *executionGuard
invoked, returned, lost bool invoked, returned, lost bool
cancelRequested bool
result WorkerFinish result WorkerFinish
nextTry, nextRenew time.Time nextTry, nextRenew time.Time
retryDelay time.Duration retryDelay time.Duration
@@ -152,9 +153,11 @@ func (w *Worker) Run(parent context.Context) error {
} }
} }
var assignments <-chan WorkerAssignment var assignments <-chan WorkerAssignment
var cancellations <-chan WorkerCancellation
var closed <-chan struct{} var closed <-chan struct{}
if r.conn != nil { if r.conn != nil {
assignments = r.conn.assignments assignments = r.conn.assignments
cancellations = r.conn.cancellations
closed = r.conn.Done() closed = r.conn.Done()
} }
select { select {
@@ -175,6 +178,10 @@ func (w *Worker) Run(parent context.Context) error {
j.result = done.result j.result = done.result
j.nextTry = time.Time{} j.nextTry = time.Time{}
} }
case c := <-cancellations:
if j := r.jobs[c.AttemptID]; j != nil && j.assignment.Task.ID == c.TaskID && j.assignment.Epoch == c.Epoch {
r.cancelAttempt(j)
}
case reply := <-r.replies: case reply := <-r.replies:
if err := r.receive(reply); err != nil { if err := r.receive(reply); err != nil {
return r.failure(err) return r.failure(err)
@@ -232,19 +239,30 @@ func (r *workerRuntime) remove(j *runtimeAttempt) {
} }
func (r *workerRuntime) observeExpired() { func (r *workerRuntime) observeExpired() {
for _, j := range r.jobs { for _, j := range r.jobs {
if j.guard != nil && j.guard.ctx.Err() != nil && !j.lost { if j.guard != nil && j.guard.ctx.Err() != nil && !j.lost && !j.cancelRequested {
j.lost = true j.lost = true
r.event(j, "lease_lost", "LEASE_LOST") r.event(j, "lease_lost", "LEASE_LOST")
} }
} }
} }
func (r *workerRuntime) cancelAttempt(j *runtimeAttempt) {
if j.cancelRequested {
return
}
j.cancelRequested = true
if j.guard != nil {
j.guard.stop(ErrTaskCancellationRequested)
}
r.event(j, "cancel_requested", "CANCEL_REQUESTED")
}
func (r *workerRuntime) desiredCapacity() uint32 { func (r *workerRuntime) desiredCapacity() uint32 {
if r.draining { if r.draining {
return 0 return 0
} }
ghosts := uint32(0) ghosts := uint32(0)
for _, j := range r.jobs { for _, j := range r.jobs {
if j.returned || j.lost { if j.returned || j.lost || j.cancelRequested {
ghosts++ ghosts++
} }
} }
@@ -320,7 +338,10 @@ func (r *workerRuntime) establish(c runtimeConnected) error {
if j == nil { if j == nil {
continue continue
} }
if j.guard != nil { if lease.CancelRequestedAt != nil {
r.cancelAttempt(j)
}
if j.guard != nil && !j.cancelRequested {
if !j.guard.update(lease) { if !j.guard.update(lease) {
j.lost = true j.lost = true
r.event(j, "lease_lost", "LEASE_LOST") r.event(j, "lease_lost", "LEASE_LOST")
@@ -398,7 +419,7 @@ func (r *workerRuntime) dispatch() error {
} }
id = &j.finishID id = &j.finishID
case "renew": case "renew":
if !j.invoked || j.lost || j.returned || now.Before(j.nextRenew) { if !j.invoked || j.lost || j.cancelRequested || j.returned || now.Before(j.nextRenew) {
continue continue
} }
id = &j.renewID id = &j.renewID
@@ -509,7 +530,17 @@ func (r *workerRuntime) receive(reply runtimeReply) error {
r.remove(j) r.remove(j)
return nil return nil
} }
if reply.result.Lease.CancelRequestedAt != nil {
r.cancelAttempt(j)
}
if command.kind == "start" { if command.kind == "start" {
if j.cancelRequested {
// 开始确认已收到,但本进程未调用处理器;可以明确证明本次没有副作用。
j.invoked, j.returned = true, true
j.result = cancelledHandlerResult(j.assignment.Attempt(), nil)
j.nextTry = time.Time{}
return nil
}
guard, ok := newExecutionGuard(r.ctx, reply.result.Lease) guard, ok := newExecutionGuard(r.ctx, reply.result.Lease)
if !ok { if !ok {
r.event(j, "lease_lost", "START_NOT_AUTHORIZED") r.event(j, "lease_lost", "START_NOT_AUTHORIZED")
@@ -529,14 +560,14 @@ func (r *workerRuntime) receive(reply runtimeReply) error {
} else { } else {
r.worker.renewals.Add(1) r.worker.renewals.Add(1)
j.renewID = "" j.renewID = ""
if !j.guard.update(reply.result.Lease) { if !j.cancelRequested && !j.guard.update(reply.result.Lease) {
j.lost = true j.lost = true
r.event(j, "lease_lost", "LEASE_LOST") r.event(j, "lease_lost", "LEASE_LOST")
} }
} }
j.nextTry = time.Time{} j.nextTry = time.Time{}
j.retryDelay = 0 j.retryDelay = 0
if !j.lost { if !j.lost && !j.cancelRequested {
j.nextRenew = time.Now().Add(max(20*time.Millisecond, time.Until(j.guard.until())/3)) j.nextRenew = time.Now().Add(max(20*time.Millisecond, time.Until(j.guard.until())/3))
} }
return nil return nil

View File

@@ -10,6 +10,7 @@ import (
) )
var ErrExecutionLeaseLost = errors.New("Worker execution lease expired or lost") var ErrExecutionLeaseLost = errors.New("Worker execution lease expired or lost")
var ErrTaskCancellationRequested = errors.New("Orbit task cancellation requested")
var ErrWorkerShutdownTimeout = errors.New("Worker shutdown grace period expired") var ErrWorkerShutdownTimeout = errors.New("Worker shutdown grace period expired")
var ErrWorkerAlreadyRun = errors.New("Worker instance may only Run once") var ErrWorkerAlreadyRun = errors.New("Worker instance may only Run once")
@@ -41,6 +42,16 @@ type WorkerFailure struct {
func (e *WorkerFailure) Error() string { return "Worker handler reported a known failure" } func (e *WorkerFailure) Error() string { return "Worker handler reported a known failure" }
// WorkerCancellationConfirmed 仅在收到平台取消后,确认已停止且本次尝试没有未结清的外部效果时返回。
// 普通 ctx.Err()、网络超时或无法确认外部效果仍是 UNKNOWN;它不证明先前尝试的效果已核实。
type WorkerCancellationConfirmed struct {
Details json.RawMessage // Details 可选证据引用,非 null JSON,最多 256 KiB;空值使用空对象。
}
func (e *WorkerCancellationConfirmed) Error() string {
return "Worker handler confirmed safe cancellation"
}
// WorkerOptions 配置自动执行器。一个实例绑定一个队列,只能 Run 一次。 // WorkerOptions 配置自动执行器。一个实例绑定一个队列,只能 Run 一次。
type WorkerOptions struct { type WorkerOptions struct {
QueueID string // QueueID 凭据有消费权限的队列。 QueueID string // QueueID 凭据有消费权限的队列。
@@ -131,6 +142,7 @@ func NewWorker(client *Client, options WorkerOptions) (*Worker, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
r.CancellationNotifications = true
options.Handlers = append([]WorkerHandler(nil), options.Handlers...) options.Handlers = append([]WorkerHandler(nil), options.Handlers...)
return &Worker{client: client, options: options, registration: r, handlers: handlers, events: make(chan WorkerEvent, 128)}, nil return &Worker{client: client, options: options, registration: r, handlers: handlers, events: make(chan WorkerEvent, 128)}, nil
} }

View File

@@ -21,6 +21,7 @@ type WorkerCapability struct {
// WorkerRegistration 是同一进程重连时必须复用的身份;进程重启使用新身份。 // WorkerRegistration 是同一进程重连时必须复用的身份;进程重启使用新身份。
// 包含秘密,禁止以 JSON 等形式写入日志;SDK 的 String/GoString 会隐藏秘密。 // 包含秘密,禁止以 JSON 等形式写入日志;SDK 的 String/GoString 会隐藏秘密。
type WorkerRegistration struct { type WorkerRegistration struct {
CancellationNotifications bool `json:"cancellation_notifications,omitempty"` // CancellationNotifications 接受 task.cancel 提示;旧服务不支持,须先升级服务端。
InstanceID string `json:"instance_id"` // InstanceID 本进程随机标识,32 位小写十六进制。 InstanceID string `json:"instance_id"` // InstanceID 本进程随机标识,32 位小写十六进制。
InstanceSecret string `json:"instance_secret"` // InstanceSecret 32 字节随机秘密的 base64url 编码,重连保留。 InstanceSecret string `json:"instance_secret"` // InstanceSecret 32 字节随机秘密的 base64url 编码,重连保留。
QueueID string `json:"queue_id"` // QueueID 绑定队列,重连不可更换。 QueueID string `json:"queue_id"` // QueueID 绑定队列,重连不可更换。
@@ -107,6 +108,7 @@ const (
WorkerSucceeded WorkerOutcome = "SUCCEEDED" WorkerSucceeded WorkerOutcome = "SUCCEEDED"
WorkerFailed WorkerOutcome = "FAILED" WorkerFailed WorkerOutcome = "FAILED"
WorkerUnknown WorkerOutcome = "UNKNOWN" WorkerUnknown WorkerOutcome = "UNKNOWN"
WorkerCancelled WorkerOutcome = "CANCELLED" // 已停止且确认本次尝试无遗留效果,必须已有平台取消意图。
) )
// WorkerReceipt 是不可变历史回执,不能作为当前执行权。 // WorkerReceipt 是不可变历史回执,不能作为当前执行权。
@@ -121,6 +123,7 @@ type WorkerReceipt struct {
// WorkerLease 是本次响应重新核验的授权;必须调用 ExecutionDeadline 判断可否执行。 // WorkerLease 是本次响应重新核验的授权;必须调用 ExecutionDeadline 判断可否执行。
type WorkerLease struct { type WorkerLease struct {
CancelRequestedAt *time.Time `json:"cancel_requested_at,omitempty"` // CancelRequestedAt 非空表示应停止业务处理;仍可在原租约内确认结果。
Active bool `json:"active"` // Active 当前会话是否仍拥有有效尝试;LEASED 仅代表预占。 Active bool `json:"active"` // Active 当前会话是否仍拥有有效尝试;LEASED 仅代表预占。
State string `json:"state"` // State 当前尝试状态,只有 RUNNING 可执行。 State string `json:"state"` // State 当前尝试状态,只有 RUNNING 可执行。
LeaseUntil time.Time `json:"lease_until"` // LeaseUntil 当前服务端期限,不直接和客户端墙钟比较。 LeaseUntil time.Time `json:"lease_until"` // LeaseUntil 当前服务端期限,不直接和客户端墙钟比较。
@@ -131,7 +134,15 @@ type WorkerLease struct {
// ExecutionDeadline 返回保守的本地执行截止时间。false 时不能运行或继续处理器。 // ExecutionDeadline 返回保守的本地执行截止时间。false 时不能运行或继续处理器。
// 仅 Start/Renew/Resume 的 SDK 响应具有本地期限;自行 JSON 解码的值不会授权执行。 // 仅 Start/Renew/Resume 的 SDK 响应具有本地期限;自行 JSON 解码的值不会授权执行。
func (l WorkerLease) ExecutionDeadline() (time.Time, bool) { func (l WorkerLease) ExecutionDeadline() (time.Time, bool) {
return l.deadline, l.Active && l.State == "RUNNING" && !l.deadline.IsZero() && time.Now().Before(l.deadline) return l.deadline, l.Active && l.State == "RUNNING" && l.CancelRequestedAt == nil && !l.deadline.IsZero() && time.Now().Before(l.deadline)
}
// WorkerCancellation 通知特定尝试停止推进,不能表示副作用已撤销或任务已终结。
type WorkerCancellation struct {
TaskID string `json:"task_id"` // TaskID 任务标识。
AttemptID string `json:"attempt_id"` // AttemptID 目标尝试。
Epoch uint64 `json:"epoch,string"` // Epoch 原执行代次,过时通知不得影响新尝试。
RequestedAt time.Time `json:"requested_at"` // RequestedAt 首次取消意图的数据库时间。
} }
// WorkerCommandResult 把历史回执与当前权限分开;Deduplicated 不表示可再次执行。 // WorkerCommandResult 把历史回执与当前权限分开;Deduplicated 不表示可再次执行。