Files
qinglong/docs/adr/ADR-0005-durable-cancellation-dispatch.md
T

189 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-0005Durable Cancellation Dispatch、Lease 与 Fencing
- 状态:AcceptedLocal、PostgreSQL Repository、Cluster 生产交付与人工处置入口已实现)
- 日期:2026-07-18
- 决策范围:跨进程取消派发、崩溃恢复、并发 Worker、退避和审计事件
- 关联:QL-RFC-0001、ADR-0001、ADR-0003、ADR-0004
## 1. 上下文
`run.cancel_requested` 解决“用户取消意图是否已经提交”,但它本身不能证明哪个 Worker 有权调用 Executor,也不能阻止多个副本同时扫描同一个 Run 并重复发送 signal。
只使用内存锁在进程重启和多副本部署中无效;只依赖 append-only RunEvent 也无法原子 claim 一个外部副作用。另一方面,OS signal 与数据库事务之间没有通用的 exactly-once 提交协议:Worker 可能在 signal 成功后、结果落库前崩溃。
该协议必须同时适用于:
- edge/standalone:单进程或低性能设备,SQLite、低频轮询、严格资源上限。
- cluster-control/worker:多个副本并发扫描,共享 PostgreSQL,允许 Worker 故障和租约接管。
## 2. 决策
### 2.1 独立状态
取消意图仍属于 Run;取消副作用的协调状态属于独立 `CancellationDispatch`。每个 Run 最多一条 dispatch,并固定绑定一个 RunAttempt。首版字段为:
```text
run_id primary key
attempt_id not null
status pending | leased | retry_wait | dispatched | blocked
version non-negative integer
dispatch_count non-negative integer
next_attempt_at_ms nullable
lease_owner nullable
lease_token_digest nullable
lease_expires_at_ms nullable
last_result nullable
last_dispatched_at_ms nullable
created_at_ms not null
updated_at_ms not null
```
RunEvent 记录发生过的事实,CancellationDispatch 负责 claim、到期和退避查询。两者不能互相替代。
### 2.2 Claim 事务
Worker 对唯一 active Attempt 执行原子 claim
1. 校验 Run 由 runtime 拥有、仍非终态、cancel request 时间一致。
2. 校验 Attempt 属于该 Run 且状态为 claimed/starting/running。
3. 不存在 dispatch 时创建 `pending`,绑定该 Attempt。
4. 既有记录绑定其他 Attempt 时 fail closed,不重新绑定或选择“最新 PID”。
5. `dispatched``blocked` 不再 claim;未到 `next_attempt_at_ms` 返回 not-due;未过期 lease 返回 leased。
6. 到期或可派发时,以 version CAS 更新为 leased,递增 version 和 dispatch_count,写入新的 owner、domain-separated SHA-256 token digest 和 expiry;原始 token 只随成功 claim 返回给当前调用者,不进入 durable record。
Repository 是租约、到期与退避的时间 authority。调用方只提交已有 `cancel_requested_at_ms` 事实、lease duration 或 retry delay,不得提交“当前时间”、lease expiry 或绝对 retry timestamp。SQLite adapter 使用注入 clock 与短 `IMMEDIATE` 事务串行化写竞争;PostgreSQL adapter 使用 `transaction_timestamp()`,按 Run→Attempt→CancellationDispatch 顺序取行锁。实现方式可以不同,行为契约不得改变。
### 2.3 发出副作用
只有 claim 成功的 Worker 可以调用对应 `PersistedExecutionController`。本机进程 controller 在每次 TERM/KILL 前都重新核验 durable handle
- 平台和 handle 格式受支持。
- Attempt.pid 与 handle PID 一致。
- 当前 boot ID 与记录一致。
- `/proc/<pid>/stat` start ticks 一致。
- PID 仍为记录的 process-group leader。
任一证明缺失或不一致都不得降级为裸 PID signal。
### 2.4 Result 事务与 fencing
结果提交必须同时匹配 run ID、attempt ID、lease owner、原始 lease token 的 digest 和 expected dispatch version。任何一项过期都拒绝写入。
同一事务中:
1. 更新 dispatch 状态并清空 lease。
2. 使用当前 Run version CAS 预留下一个 event sequence。
3. 追加一个低敏 RunEvent。
结果分类:
| Controller/调度结果 | Dispatch 状态 | Event | 后续行为 |
| --- | --- | --- | --- |
| `termination_requested``already_exited` | dispatched | `run.cancel_dispatched` | 等待 completion/Reconciler 收敛 Run 终态 |
| `identity_mismatch``pid_mismatch``unsupported``invalid` | blocked | `run.cancel_dispatch_blocked` | 禁止自动重试,等待诊断或明确修复 |
| `controller_missing``handle_missing``dispatch_error` | retry_wait | `run.cancel_dispatch_failed` | 按有界指数退避重试 |
Event payload 只包含 Attempt ID、dispatch count 和枚举结果,不包含 PID、durable handle、命令、环境或 Secret。
### 2.5 崩溃窗口
该协议提供 at-least-once recovery,不宣称 OS signal exactly-once。允许的窗口是:Worker 已取得 lease 并发送 signal,但在提交 result 前崩溃。
lease 到期后其他 Worker 可以重新 claim。重试仍必须执行完整身份复验:
- 进程已退出时记录 `already_exited`
- 身份改变时进入 blocked。
- 只有同一可证明进程仍存在时才允许再次发送 signal。
这比永久卡住取消请求或对裸 PID 猜测更安全。lease duration 必须大于单次 controller 的正常 TERM/KILL 检查窗口,并有固定上限。
### 2.6 退避与 Supervisor
首版退避为 `min(max, base * 2^(dispatch_count-1))`,指数有上限。调用方只提交有硬上限的 `retryDelayMs`Repository 依据自身时间计算并持久化绝对 `next_attempt_at_ms`,进程重启不会清空退避。
Supervisor 一次只执行有界 cycle
- source 默认每页 32,硬上限 64。
- 单 cycle 默认 4 页,硬上限 64 页。
- attempt 查询溢出、缺失 cursor 或 cursor 不前进时 fail closed。
- Supervisor 不自行创建 timer、后台线程或 boot hook。
独立 lifecycle runner 提供显式 `start()`/`stop()`:只有调用 `start()` 才创建 unref timer;每轮完成后才安排下一轮,禁止慢设备累积重叠扫描;错误只进入受限诊断回调,不终止后续 cycle;`stop()` 立即取消待执行 timer,并对正在执行的 cycle 使用有上限的 drain 等待。超时后旧 cycle 仍不得再安排新 timer,且在它完成前拒绝重新 start。
部署 Profile 决定调用 cadence。edge 可以低频串行执行;cluster 可以由多个副本触发,但实际副作用仍受同一 lease/fencing 约束。
## 3. 不采用的方案
### 3.1 仅使用 RunEvent 去重
Event 可以审计,但不能表达 lease expiry、当前 owner 和 next retry,也不能防止两个 Worker 在写 Event 之前同时 signal,拒绝。
### 3.2 进程内 Mutex 或 active handle registry
无法跨重启和跨副本协调,只能作为同进程快速路径,拒绝作为事实源。
### 3.3 直接把 Run 标记 cancelled 再 kill
会把用户意图伪装成已经发生的进程事实,且 kill 失败后状态失真,拒绝。
### 3.4 事务内发送 signal
长事务会放大 SQLite 写锁和集群锁占用;事务回滚也无法撤销 signal,拒绝。
### 3.5 PID-only 恢复
PID 可复用,可能终止无关进程,禁止。
## 4. 影响
正面影响:
- 两个 Worker 不会在正常租约期同时派发同一取消。
- 崩溃后可以接管,重启不会丢失退避。
- stale Worker 无法覆盖新 owner 的结果。
- edge 与 cluster 共享领域协议,数据库适配器可按 Profile 优化。
代价:
- 增加一张当前状态表和每次派发的短事务。
- signal 与结果提交之间仍存在不可消除的崩溃窗口。
- blocked 状态需要指标、诊断 API 和人工处置流程。
## 5. 当前孵化边界
`next` 已实现 profile-neutral canonical contract、`0005-run-cancellation-dispatch`、legacy Sequelize/SQLite adapter、PostgreSQL `pg-0066-cancellation-dispatch`/capability v65 adapter、lease expiry 接管、fencing、退避、结果事件、Dispatcher、有界 Supervisor 和默认惰性的 lifecycle runner。PostgreSQL 结果事务按 Run→Attempt→dispatch 锁序完成 dispatch 更新、Run version CAS 与 RunEvent 追加;runtime 角色只取得新表的 SELECT/INSERT/UPDATE。Cluster 生产交付已由 ADR-0457 接入既有 caller-driven Worker lease-controlRun-level stop 必须先结算 durable dispatch 再返回,Workflow Task timeout 则保持 `untracked`,不伪造父 Run 取消。ADR-0458 又在既有隔离 Run management plane 上增加低敏 inspect 与 exact-CAS rearmviewer 只有 `run.read` 诊断权,rearm 继续要求 `run.stop`;任何接口都不暴露 lease owner、raw token 或 token digest。
ADR-0459 进一步增加 Project-scoped、caller-driven summary,以固定计数和三态 assessment 提供 blocked/availability 告警出口,不改变全局 readiness,也不新增采集器。
ADR-0460 已在现有 `ql3 run` 增加 one-shot status 产品入口:同一强认证 summary 被投影为低敏 text/JSON 状态卡,并以 `0/10/20` 区分 clear、converging 与 attention-required;没有新增轮询器、临时 command 文件或常驻 authority。
ADR-0461 已增加 Project-scoped blocked drill-down:固定 16 项、只返回 Run ID/blocked time,以数据库快照和 `(blockedAtMs,runId)` 键集显式逐页;v67 将 Project identity 固化到 dispatch 并用复合外键与 partial index 避免跨租户扫描。该入口仍是 one-shot,不自动翻页或引入后台 scanner。
HTTP worker 已通过默认关闭的 manual-only manifest bootstrap 接入 Local Supervisor:只有 accepted 且全部 gate 通过时才启动,失败或 shutdown 时有界停止。以下工作仍未完成,因此它仍只允许显式 canary,不得扩大到默认生产流量:
- Copilot Console 的可选 Project 状态卡与用户触发式 blocked drill-downProject 聚合出口、一次性产品状态卡、告警退出码、有界发现和私有 operator 处置协议已经实现。
- 固定 edge 设备的数据库写放大、RSS、时延和磁盘基准。
- 固定 Project allowlist 的外部时序指标适配;数据库事实驱动的按需 availability/blocked 汇总已经实现。
- 首次真实目标实例完整激活/回滚仪式与共享 config 多写者 authority。
## 6. 验证门禁
1. 两个 Worker 竞争时只有一个 lease owner 可以调用 controller。
2. lease 未过期不接管,到期后新 token/version 可以接管。
3. 旧 owner/token/version 的结果被 fencing 拒绝。
4. 结果 Event 追加失败时 Run version 与 dispatch 状态一起回滚。
5. retry_wait 在到期前不调用 controller,到期后 dispatch_count 递增。
6. terminal/blocked dispatch 不再自动调用 controller。
7. controller missing、handle missing 和临时异常按持久化退避重试。
8. identity/PID/process-group 不一致时零 signal。
9. page、cycle 和退避均有硬上限。
10. Event 与日志不包含 handle、命令、环境和 Secret。
11. PostgreSQL 双连接只能产生一个 claim winnerraw token 不落库,数据库时间决定 lease/retry 到期。
12. v65 事实经 WAL 到达 standby,提升为新 Primary 后仍可读取;旧 owner/token/version 继续被 fencing。
13. Cluster Worker lease-control 只在 durable dispatch 已结算或已重放时释放 Run-level stopWorkflow Task timeout 不写父 Run 取消事实。
14. 强认证 viewer 只能读取无 lease capability 的低敏诊断;只有 `run.stop` authority 可按 blocked/version/result 精确 rearm。
15. rearm、RunEvent 与 allowed audit 原子提交,数据库时间决定 retry duestale fence、授权漂移与 mutation drift 均失败关闭。
16. Project summary 的五态与 blocking-result 计数必须交叉守恒,blocked 只产生 `attention_required` 告警而不撤回全局 readiness,且响应不包含 Run/Attempt/lease identity。
17. `ql3 run status` 必须只发送一次固定 summarytext/JSON 事实一致,`0/10/20` 与三态严格映射;原 command-file 模式和 Edge/Standalone 闭包不得变化。
18. `ql3 run blocked` 每次只能读取一个固定 16 项页面;cursor 必须保持首屏数据库快照和严格键集顺序,响应只含 Run identity/blocked time,并由 Project 复合外键与 partial index 证明无跨租户扫描。