# ADR-0200:本机 SQLite Rollout 写契约与升级前快照 - 状态:Accepted - 日期:2026-07-29 - 关联 RFC:QL-RFC-0001 D-184、D-188、D-189、D-190 - 关联 ADR:ADR-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 外,必须精确声明: ```text 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` 创建在线一致快照: ```text service/rollout-backups/.sqlite ``` 实现固定: - source 与 backup 都必须位于 canonical、当前 UID、`0700` 私有目录; - source/final/stage 都拒绝 symlink,SQLite 文件必须为 `0600`、单 hard link; - stage 名由 rollout ID 确定,Edge 每批 16 pages,Standalone 每批 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 健康失败隐式授权; - `changed` 或 `recovery_unknown` 均不能被解释为“可以自动丢弃当前库”。 ### 5. Receipt 绑定数据证据 `service/rollouts/.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 部署不加载此 subpath;Cluster 的 PostgreSQL/Barman 恢复边界不复用本地 SQLite 文件协议。 ## 发布门 真实 release 门必须对同一个 pushed exact digest 分别执行: ```text 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 被观察为 `changed`,response-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 碎片与低配安装成本。