Files
qinglong/docs/adr/ADR-0200-local-sqlite-rollout-write-contract-and-snapshot.md
T

7.8 KiB
Raw Blame History

ADR-0200:本机 SQLite Rollout 写契约与升级前快照

  • 状态:Accepted
  • 日期:2026-07-29
  • 关联 RFCQL-RFC-0001 D-184、D-188、D-189、D-190
  • 关联 ADRADR-0194、ADR-0197、ADR-0198、ADR-0199

背景

ADR-0199 能在 Compose 候选失败时恢复旧 image,但“旧 image 能启动”不等于“旧 image 能安全写当前 SQLite”。候选可能已提交数据,旧 image 的读取窗口也不能证明 它采用相同写语义。Docker selection、SQLite 文件和 rollout receipt 之间不存在 跨介质事务,断电、磁盘满或响应丢失还会留下不同的中间状态。

自动用升级前数据库覆盖当前数据库同样不安全:候选写入可能是有效的用户事实, blind restore 会把一次容器回退扩大成数据回退。系统需要同时保留升级前恢复点、 观察候选是否写过,并让 image rollback 与 data restore 成为两个显式决策。

决策

1. 读窗口与写契约分开声明

本机 image OCI config 除 SQLite contract min/max 外,必须精确声明:

io.qinglong.local.sqlite-write-contract=35

min/max 表示 image 可以读取的 durable contract 区间;write contract 表示它会写回 的语义版本。当前 v3 候选固定为 read 35..35、write 35。Compose preflight 直接读取 Docker daemon 对 exact RepoDigest 的 config,要求当前数据库 contract 同时落在读取窗口内且精确等于 write contract。缺失、非整数或不相等均在任何 container mutation 前失败关闭。

该 label 同时受 Dockerfile 静态审计、原生镜像检查、OCI 双平台 config 审计和 release workflow 约束,不能由 command file、Compose override 或调用方声明。

2. Generation 2 起先创建在线一致快照

每个存在 previous generation 的 apply,在 preflight 成功后、任何 docker compose up 前调用 Node 24 node:sqlite backup 创建在线一致快照:

service/rollout-backups/<rolloutId>.sqlite

实现固定:

  • source 与 backup 都必须位于 canonical、当前 UID、0700 私有目录;
  • source/final/stage 都拒绝 symlinkSQLite 文件必须为 0600、单 hard link
  • stage 名由 rollout ID 确定,Edge 每批 16 pagesStandalone 每批 64 pages
  • 完成后复核完整 SQLite readiness、contract、page count/page size、bytes 与 SHA-256,再 fsync file、no-replace rename、fsync directory
  • 不完整或 ENOSPC stage 会删除并在同一 rollout 重试;final 已存在时只做严格 exact inspection,不覆盖;
  • 目录最多保留 8 个 final snapshot。达到上限且本次没有既存快照时失败关闭, 不由运行时自动删除恢复证据。

Generation 1 没有可替代历史,不创建 rollout snapshot;首次失败仍按 ADR-0199 显式 stop。

3. 候选写入使用连接级 data_version 观察

apply 在启动候选前保持一个只读 SQLite 连接,记录初始 PRAGMA data_version; 候选 active/失败观察结束后在同一连接读取一次:

  • unchanged:观察窗口内没有其他连接提交;
  • changed:候选窗口内观察到外部提交;
  • recovery_unknown:进程在记录观察结果前丢失响应,或不健康候选的观察连接已 无法可靠读取 data_version;恢复只能证明快照存在。

这是保守写入观察,不声称识别具体写入者,也不作为自动 restore 的授权。它只让 operator 明确知道 image rollback 与 data rollback 是否可能产生不同后果。 健康候选若无法读取观察结果仍失败关闭,不发布 active receipt;只有已经缺少 active evidence 的候选才允许把观察错误降级为 recovery_unknown 并继续建立 rollback generation。

4. Image rollback 不自动 restore SQLite

候选失败后仍按 ADR-0199 创建单调递增的 rollback generation,但旧 image 必须对 “当前”数据库重新执行完整 preflight,并满足 exact write contract。控制器不会把 升级前快照复制回 source database。

因此:

  • 旧 image 能安全读写当前库时,可恢复服务并保留 snapshot;
  • 当前库已超出旧 image 契约时,rollback preflight 失败,rollout lock 保留;
  • 需要 data restore 时由后续显式、停服、受审的 restore operation 完成,不能 由 Compose 健康失败隐式授权;
  • changedrecovery_unknown 均不能被解释为“可以自动丢弃当前库”。

5. Receipt 绑定数据证据

service/rollouts/<rolloutId>.json 使用 qinglong/local-compose-rollout-receipt@v2,新增严格 canonical sqlite 对象, 保存 contract、write contract、write observation,以及 generation 2+ 快照的 SHA-256、bytes、page count 和 page size。重放 receipt 时必须重新检查 final snapshot 和全部证据;快照缺失、漂移或 receipt shape 漂移均失败关闭,不再次启动 容器。旧 v1 receipt 不会被静默解释成具备数据证据。

若崩溃发生在 rollback selection 已切换而 receipt 尚未发布,恢复路径必须先找到并 复核该 rollout 的既有快照,才能继续启动旧 image。没有快照的伪造恢复状态不再被 接受。

6. 保持低配设备闭包

实现只在既有 @qinglong/local-sqlite 增加 rollout-safety subpath,并由既有 local-owner-cli Compose apply 精确导入。不新增 workspace package、第三方依赖、 daemon、timer、watcher、端口或 Edge 常驻内存。无 Docker 的 systemd/OpenRC 部署不加载此 subpathCluster 的 PostgreSQL/Barman 恢复边界不复用本地 SQLite 文件协议。

发布门

真实 release 门必须对同一个 pushed exact digest 分别执行:

Edge generation 1 active
  -> 同 digest generation 2
  -> 在线 SQLite snapshot + receipt inspection
  -> active
  -> SIGTERM stopped

Standalone generation 1 active
  -> 同 digest generation 2
  -> 在线 SQLite snapshot + receipt inspection
  -> active
  -> SIGTERM stopped

使用相同 digest 是为了隔离 rollout/data protocol 本身,不把业务 image 差异混入 该门;正式升级仍使用不同 exact digest。

验收证据

  • local-sqlite 141/141
  • local-owner-cli 41/41
  • image/OCI/release 专项 36/36,静态 image audit findings=0
  • workspace 保持 22 packages,无新增生产依赖;
  • ENOSPC 注入在任何 compose up 前失败,候选外部 SQLite commit 被观察为 changedresponse-loss recovery 必须持有可复核 snapshot
  • arm64 Docker 上,Edge 128 MiB/64 PID 与 Standalone 256 MiB/256 PID 对 localhost exact manifest sha256:88c059…fe9c 均完成 generation 1→2、 write contract 35、snapshot、durable receipt、active、graceful stopped 和资源 清理;
  • PostgreSQL 18.4 arm64 physical HA 回归门同期重跑,timeline 1→2、旧主 fencing、pg_rewind 同步只读重加入及全部 domain gates 均 gates.passed=true

localhost digest 只属于本机 live evidence,不替代 GHCR 双架构 manifest、签名和 attestation。

未包含

  • operator 显式 data restore、restore dry-run 与恢复后新 generation
  • 达到 8 份后的受审 snapshot GC/归档;
  • 宿主机断电、文件系统损坏和物理路由器闪存寿命矩阵;
  • systemd/OpenRC 的进程激活与 rollback
  • 正式 GHCR release 成功记录。

被拒绝的替代方案

  • 只看 read min/max:不能证明旧 image 会以当前 durable 语义写回。
  • 候选失败就自动覆盖数据库:可能静默丢失已提交的有效事实。
  • 复制 .sqlite/-wal/-shm 三个文件:在线复制窗口不能证明一致性。
  • 把快照存在内存或临时目录:无法跨进程崩溃、response loss 和 daemon 重启。
  • 每次升级无限保留快照:不适合闪存有限的路由设备。
  • 另拆 backup package/service:没有独立交付或 authority,反而增加 package 碎片与低配安装成本。