8.7 KiB
ADR-0005:Durable Cancellation Dispatch、Lease 与 Fencing
- 状态:Proposed
- 日期: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。首版字段为:
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 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:
- 校验 Run 由 runtime 拥有、仍非终态、cancel request 时间一致。
- 校验 Attempt 属于该 Run 且状态为 claimed/starting/running。
- 不存在 dispatch 时创建
pending,绑定该 Attempt。 - 既有记录绑定其他 Attempt 时 fail closed,不重新绑定或选择“最新 PID”。
dispatched、blocked不再 claim;未到next_attempt_at_ms返回 not-due;未过期 lease 返回 leased。- 到期或可派发时,以 version CAS 更新为 leased,递增 version 和 dispatch_count,写入新的 owner、不可预测 token 和 expiry。
SQLite adapter 使用短 IMMEDIATE 事务串行化写竞争。PostgreSQL adapter 必须提供等价的行锁或条件更新语义;实现方式可以不同,行为契约不得改变。
2.3 发出副作用
只有 claim 成功的 Worker 可以调用对应 PersistedExecutionController。本机进程 controller 在每次 TERM/KILL 前都重新核验 durable handle:
- 平台和 handle 格式受支持。
- Attempt.pid 与 handle PID 一致。
- 当前 boot ID 与记录一致。
/proc/<pid>/statstart ticks 一致。- PID 仍为记录的 process-group leader。
任一证明缺失或不一致都不得降级为裸 PID signal。
2.4 Result 事务与 fencing
结果提交必须同时匹配 run ID、attempt ID、lease owner、lease token 和 expected dispatch version。任何一项过期都拒绝写入。
同一事务中:
- 更新 dispatch 状态并清空 lease。
- 使用当前 Run version CAS 预留下一个 event sequence。
- 追加一个低敏 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)),指数有上限。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 已实现 0005-run-cancellation-dispatch、Repository 端口、临时 Sequelize/SQLite adapter、lease expiry 接管、fencing、退避、结果事件、Dispatcher、有界 Supervisor 和默认惰性的 lifecycle runner,并覆盖双 Worker、崩溃接管、事务回滚、无重叠调度和有界 shutdown 测试。
HTTP worker 已通过默认关闭的 manual-only manifest bootstrap 接入该 Supervisor:只有 accepted 且全部 gate 通过时才启动,失败或 shutdown 时有界停止。以下工作仍未完成,因此它仍只允许显式 canary,不得扩大到默认生产流量:
- PostgreSQL adapter 与真实多连接并发压力测试。
- ADR-0007 的 completion receipt、direct-file log、CompletionService 与周期 completion supervisor(timeout lifecycle 已接入)。
- 用户可见的运行指标、blocked 诊断和处置入口。
- 固定 edge 设备的数据库写放大、RSS、时延和磁盘基准。
- 与 rollout manifest、回滚 runbook 和运维告警的最终接线。
6. 验证门禁
- 两个 Worker 竞争时只有一个 lease owner 可以调用 controller。
- lease 未过期不接管,到期后新 token/version 可以接管。
- 旧 owner/token/version 的结果被 fencing 拒绝。
- 结果 Event 追加失败时 Run version 与 dispatch 状态一起回滚。
- retry_wait 在到期前不调用 controller,到期后 dispatch_count 递增。
- terminal/blocked dispatch 不再自动调用 controller。
- controller missing、handle missing 和临时异常按持久化退避重试。
- identity/PID/process-group 不一致时零 signal。
- page、cycle 和退避均有硬上限。
- Event 与日志不包含 handle、命令、环境和 Secret。