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

170 lines
7.8 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-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 外,必须精确声明:
```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/<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 健康失败隐式授权;
- `changed``recovery_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 分别执行:
```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
碎片与低配安装成本。