9.3 KiB
ADR-0065:持久化本机应用切换 Supervisor 与未知结果收敛
- 状态:Superseded(ADR-0243;未接入产品的 package 实现已删除)
- 日期:2026-07-20
- 关联 RFC:QL-RFC-0001 D-05、D-17、D-37、D-40、D-42、D-63、D-64、D-65
- 关联 ADR:ADR-0004、ADR-0007、ADR-0036、ADR-0062、ADR-0063、ADR-0064、ADR-0066
ADR-0243 已删除
@qinglong/local-cutover的孤立孵化实现。本 ADR 保留为 cutover 安全语义记录,不代表当前仓库仍提供 Supervisor package。ADR-0309 已在现有ql3-local-deploy产品中恢复 Docker legacy-stop journal/commitment 与 adopted v3 启动门;ADR-0310 又恢复 Docker target start/restart barrier、崩溃后 inspect-only 与 terminalmanual_required。ADR-0313 已恢复人工 resolution,ADR-0314 已恢复 Docker target stop 和最小写后分类;其他 init controller、legacy restart 与数据域回退仍未实现。
上下文
ADR-0064 已能在数据库层证明 source 与 recovery 一致、在双库写栅栏内签发 activation,并在 adopted storage 生命周期内阻断旧 SQLite writer。但数据库锁不能证明旧 HTTP listener、timer、子进程、通知请求或其他外部副作用已经停止,也不能覆盖“停止 2.x 后进程崩溃”“target start 已发出但结果未落盘”“target 曾 active、控制进程重启后 2.x 被人工拉起”等窗口。
若把这些步骤直接写入 legacy back/app.ts,2.x 根应用会重新成为 3.0 的进程管理 authority;若只用内存布尔值或 shell 命令串联,控制进程崩溃后无法判断某个 start 是否已经产生副作用。最危险的错误恢复是同时重启 2.x 和 3.0,或者在未知 start 结果上再次 start。
决策
1. Cutover 是独立、默认关闭的部署 authority
新增 @qinglong/local-cutover,只提供 driver-neutral 的切换 contract、Supervisor 和文件 journal adapter。它没有 production dependency,不导入 legacy 根、local-admin、local-sqlite、local-adopted-profile、cluster、Worker、Express、Sequelize 或 Drizzle,也不自行注册 timer、watcher、signal handler、listener 或数据库连接。
当前所有既有 runtime/Profile package 都禁止反向导入该 authority。后续只能由独立部署入口显式组合具体 2.x process controller 与 3.0 target controller;不得把它塞回普通应用启动路径。
disabled 路径在验证 cutover ID、activation digest、journal 路径或 controller 前返回,不触碰文件和进程。
2. Process controller 必须返回绑定切换身份的证明
Supervisor 不接受一个裸 stop exit code。legacy controller 的 exact-shape evidence 必须同时绑定 cutoverId、activationDigest、controller identity、稳定 process identity、观察时间,并明确声明:
- database writer 已停止;
- application 外部副作用已 quiesced。
target start/inspect/stop evidence 同样绑定 cutover 与 activation,并包含 controller/target identity。畸形、可扩展、跨 cutover 重放或身份漂移全部 fail closed。这里的 controller 是部署信任边界;当前 package 只验证 evidence contract,不假装能从 PID 或 SQLite 锁独立推导网络、子进程和外部系统已经静默。
3. 每个状态转换先进入有界、不可覆盖的 durable journal
journal 位于操作员预先创建的绝对路径私有目录,要求真实目录、当前用户所有且 group/other 权限为零。每条记录:
- 使用连续十二位序号和
0600regular file; - 绑定 cutover/profile/activation、前一条 digest、状态、时间和低敏 evidence;
- 通过 SHA-256 形成不可变链;
- 最多 64 条记录、128 个目录项和 64 KiB 单记录;
- 先写同目录私有临时 inode 并
fsync,再用 no-overwrite hard link 原子发布,最后同步目录; - 同一 sequence 的并发 compare-and-append 只能有一个 winner。
未知文件、symlink、权限过宽、序号空洞、非法 transition、digest/evidence 漂移或资源超限均 fail closed。journal 只保存低敏 identity/digest,不持久化原始错误文本、token、数据库 URL 或 Secret。
4. 首次切换必须先证明 legacy 停止,再跨 target start barrier
正常序列固定为:
requested
-> legacy_stop_requested
-> legacy_stopped
-> target_start_requested
-> target_active
legacy_stop_requested 可在崩溃后幂等重做 stop-and-verify,因为它只收敛旧 owner。target_start_requested 是外部副作用 start barrier:同一调用在 barrier 持久化后只调用一次 startAndVerify;若控制进程在调用期间崩溃,恢复只能 inspect。只有 exact active evidence 可以补写 target_active;inactive、unknown、异常或不匹配都进入 terminal manual_required,禁止盲目再次 start,也禁止自动重启 legacy。
5. Target 重启前必须重新证明 legacy 仍静默
曾经 target_active 的 journal 不代表旧进程永远停止。若恢复时 target inspection 为 inactive,必须执行:
target_active
-> legacy_recheck_requested
-> legacy_reverified
-> target_restart_requested
-> target_active
只有新的 legacy evidence 同时证明数据库 writer 与外部副作用仍停止,才允许写 restart barrier。target_restart_requested 与首次 start barrier 相同:崩溃恢复只 inspect,不重放 start。target inspection 为 active 时还必须与已记录 controller/target identity 一致;unknown 或 identity drift 进入人工恢复。
target 正常停止使用 target_stop_requested -> stopped,stop-and-verify 可以幂等恢复。停止 target 后 Supervisor 不自动重启 2.x,因为 target 可能已经产生 3.0 新事实;回退仍遵守 ADR-0064 的 reconciliation 边界。
6. manual_required 是安全终态,不是可重试错误
legacy 停止未证明、target start/restart 结果未知、active identity 漂移、target stop 未证明或 journal fencing 冲突都必须收敛为 manual_required。记录只包含有限 reason、uncertain state 和错误摘要 digest。
同一 cutover ID 不允许从 manual_required 或 stopped 自动重新开始。操作员必须先检查进程、外部副作用、activation 和 target 数据事实,再通过后续受审恢复命令创建新的 cutover/recovery 事实;不得删除或重写旧 journal 伪造新起点。
被否决的替代方案
- 在
back/app.ts内直接 stop 旧 runtime 并启动 3.0:继续让 legacy 根成为新架构 service locator,且进程崩溃后没有独立权威,拒绝。 - 只检查 PID 不存在:PID 可复用,且不能证明子进程、listener 或远端副作用静默,拒绝。
- target start 报错就自动重试:错误可能发生在副作用之后,会形成重复 owner,拒绝。
- target 启动失败就自动重启 legacy:未知 target 可能已经 active,且 target 可能已有新事实,会造成双跑或数据丢失,拒绝。
- 用一个可覆盖 JSON 保存当前状态:断电、并发 writer 或部分写会丢失转换历史和 CAS fence,拒绝。
- target 曾 active 后直接按旧 legacy evidence 重启:旧证据可能已因人工恢复失效,拒绝;必须重新 stop-and-verify。
影响与未完成项
正向影响:
- 数据库 activation 与应用进程 cutover 成为两个清晰 authority;
- start/restart/stop 崩溃窗口有 durable barrier,不靠内存猜测;
- 并发 supervisor、journal 篡改和错误详情泄漏均 fail closed;
- edge 不安装进程管理 framework 或 cluster 依赖;
- 旧应用不会因 target 错误被自动拉起形成双 owner。
仍未完成:
- systemd、Docker/s6 与当前 QingLong master/worker 的具体 legacy controller;
- 把具体 Run/scheduler/Executor recovery、lifecycle 和 admission stack 注入 local-application,并由独立 target process controller 驱动;
manual_required的只读诊断、人工 resolution 和新 cutover ceremony;- 多资产 backup manifest、target 写后 reconciliation 和用户可见回退选择;
- Linux x64/arm64、固定路由设备、断电和只读/overlay 文件系统验证;
- 独立 supervisor CLI/镜像、权限模型、SBOM、签名与审计导出。
因此本 ADR 只把 cutover state machine 和 crash semantics 孵化为默认不可达的 authority,不表示 QingLong 3.0 已自动接管 2.x 部署。
验证
- disabled 路径不读取 journal、不调用任何 controller。
- 正常流程严格先持久化 legacy stop proof 和 target start barrier,stop 后不启动 legacy。
- legacy evidence 不完整时 target 永远不启动并进入
manual_required。 - start barrier 后的异常不保存原始错误、不重放 start;重入保持同一人工终态。
- start crash 只有 exact active inspection 能恢复,inactive/unknown 不会重试。
- 曾 active 的 target 只有在新 legacy quiescence evidence 后才能重启。
- stop crash 只幂等 stop-and-verify,不会触发 legacy start。
- 两个并发 first append 只有一个成功;篡改、宽权限和 symlink journal 均 fail closed。
- package dependency/import audit 证明 local-cutover 没有 runtime dependency,且既有 package 无法反向导入。
- supervisor、真实 target/legacy adapter、写后 reconciliation 和多架构 Gate 完成前保持默认不可达。