事务与恢复

三类操作日志、两级锁模型、固定的提交顺序,以及写命令被中途杀掉时会发生什么。

仓库工具翻车通常是两种难堪的姿势之一:留下一份指向已不存在的包的索引,或者把自己的数据库搞坏、只能从头重建。SOW 的设计目标是让这两件事都不可能发生,本页讲清楚支撑这一点的机制。

不变式

沿协议指针读取的客户端,永远只会读到完整的旧视图或完整的新视图。没有第三种可能 —— 包括掉电之后。

下面所有内容都是为了守住这条线:元数据在任何公开变更之前完整 stage 并校验,指针切换就是提交决策,每个操作都留下足够的持久证据,让下一条命令能把它做完或撤销,而不需要猜。

也要注意它没有声称什么。dirty 不是指索引写了一半 —— 它表示期望状态领先于已构建代,而旧的 Built 视图仍在正确服务。另外 SOW 不承诺两个不同 Dist 在同一瞬间翻代;它承诺的是每个协议视图在任何瞬间都自洽,以及命令返回时全部目标处于同一 Built Generation。

三类操作日志

仓库生命周期的不同阶段需要不同的持久化载体,所以有三类,各自作用域很窄:

日志位置覆盖范围由谁恢复
Plain 文件 journal目标目录下的 .sow-plain-operation.json一次 sow create对该目录的下一次 sow create
Workspace 文件 journal.sow/workspace-ops/active.jsoninitrepo newrepo rm下一条工作区生命周期命令
Repository 操作日志该仓库的 SQLitedist new/rmaddrmbuildlog prune该仓库的下一条写命令

这个划分不是随意的。工作区生命周期操作发生在目标仓库数据库尚不存在、或即将被删除的时候,因此不能用它。Plain 模式按设计根本没有数据库。其余情况都有可用的仓库数据库,就用它。

Plain journal 绑定解析后的输入、完整有序的动作计划,以及每个将被替换文件的持久 pre-image —— 旧哈希、mode、UID、GID,以及它在同文件系统 recovery trash 中的位置。细节与 --pigsty 的顺序见 Plain 平面仓库

Workspace journal 保存操作类型、随机 64 位十六进制 id、仓库名,以及新旧 sow.yml 的原始字节与各自 SHA-256。工作区锁保证同时只有一条 active operation。sow.yml 的原子 rename 就是提交决策:如果当前 config 仍然哈希为旧值,就清理 planned journal 并回滚;如果哈希为新值,就幂等地补齐仓库外壳,或把自有对象移入 recovery。两边都不匹配则拒绝猜测。

Repository 操作日志在任何公开文件副作用之前先向 SQLite 提交一条 planned Operation,随后记录每次状态迁移。它的 payload 绑定仓库、config SHA-256、精确的选中 Dist 集合、精确的 build_dists--skip 决策,以及一个 manifest 哈希 —— 后者覆盖新对象事实、完整期望集、逐 Dist 策略结果、RPM 公钥证书快照与目标 Generation。

这不是 SQLite 的 WAL。WAL 负责 SQLite 自己的页面事务,它无法原子地协调 pool、staging 区与 dists/。跨数据库记录与 POSIX 文件动作的,是这套应用级操作日志。

操作生命周期

planned → staged → applied → built → done
                       └──────────────→ done_dirty
   任一非终态 → recovering → built / rolled_back
   apply 前出错 → failed
状态已耐久的东西
planned命令、参数、目标与预期动作
staged新包与元数据已写入私有 staging 区并校验
applied期望状态与所需私有 pending payload 已提交;公开树可能仍是旧一代
built完整静态 Generation 已切换
done / done_dirty终态;作为审计记录保留

sow log <OPERATION> 展示带时间戳的状态迁移:

"events":[
  {"sequence":0,"state":"planned","occurred_at":"2026-08-04T04:06:32.907704Z"},
  {"sequence":1,"state":"staged","occurred_at":"2026-08-04T04:06:33.067824Z"},
  {"sequence":2,"state":"applied","occurred_at":"2026-08-04T04:06:33.253073Z"},
  {"sequence":3,"state":"built","occurred_at":"2026-08-04T04:06:34.074916Z"},
  {"sequence":4,"state":"done","occurred_at":"2026-08-04T04:06:34.077441Z"}
]

只有你显式给出 --skip 才可能走到 done_dirty。默认 add 如果在 applied 之后渲染失败,命令返回错误、旧 Built 视图继续服务、Operation 保持可恢复 —— 它不会悄悄地以 dirty 收尾。

applied 之前失败的 Operation 会成为 failed。这里有一处契约上的微妙之处:add 必须在解析包之前先记录 planned Operation,所以一个架构不被许可的包确实会留下审计记录。但除了那条终态 failed 记录之外,什么都不会被写入 —— 没有包对象、没有成员关系、没有 pending 字节、没有公开树变化、没有 Generation。既留住了审计线索,又保证无效架构不会进入任何产品投影。

锁模型

锁是本机的 POSIX advisory flock。产品契约是单机、单写、本地 POSIX、协作式锁 —— 网络文件系统既不检测也不支持。

文件谁持有
工作区锁.sow/workspace.lockinitrepo new/rmdist new/rm
仓库锁.sow/repo-locks/<repo>.lockaddrmbuilddist new/rmlog prune
Plain 目录锁目标目录及其稳定父目录sow create

两把都需要时,顺序固定:先工作区,后仓库,释放顺序相反。仓库锁的 inode 位于稳定路径,绝不随私有状态目录移动 —— 这样删除仓库时可以在别的进程还持有旧描述符的情况下撤下锁路径,而不会有第二个写者在新 inode 上形成。

sow create 同时锁住目标目录它稳定的父目录。父目录锁的作用是:阻止另一个协作写者用 rename 把目录整个换掉,再对替身取得一把独立的锁。

只读命令从不取写锁,也不接受锁参数。其中需要组合读取配置、SQLite 与 live 元数据的那几个(config checkrepo ls/showdist ls/show)会在整个快照期间持有共享锁。status 刻意更轻:它只探测仓库锁,以便在写入进行中报告 recoveringlocked,而不会被它阻塞。

两个参数控制等待行为,适用于所有取写锁的命令:

参数行为
-T, --timeout DUR最多等待 DUR;0(默认)一直等
-N, --no-wait只尝试一次,锁被占用立即失败

两条失败路径都以 4 退出。--no-wait 与非零 --timeout 同时出现是用法错误,退出码 2

$ sow add ./build/*.rpm -r pgsql -d el9 -N
lock unavailable

在"宁可跳过这轮、也不要堆积"的 cron 作业里用 -N;在"排一小会儿队可以、但绝不能挂死"的 CI 里用 -T 30s

提交顺序

每一代都按同样的四个阶段写入,而顺序正是不变式成立的原因:

payload  →  metadata  →  pointer  →  delete
  1. payload —— 包字节写进 pool/ 和视图别名。此时还没有任何东西引用它们。
  2. metadata —— checksum 命名的 RPM 元数据、PackagesPackages.gz,以及 by-hash 索引副本。此时仍没有指针指向它们。
  3. pointer —— 客户端入口:RPM 的 repomd.xml(配置了签名则连同 .asc);Managed APT 则在每个架构的 direct 与 by-hash 索引都就位之后,才发布 Release(连同 InReleaseRelease.gpg)。这一步就是提交。
  4. delete —— 清理已过保留窗口的旧代元数据。

正着读:包一定先于引用它的索引存在,索引一定先于指向它的指针存在。反着读:在一个不再引用某文件的指针耐久落地之前,那个文件不会被删。不存在任何一个窗口,让客户端沿活的指针走到一个不存在的文件。

这一切都通过与目标同文件系统的 staging 区完成,初始化时通过比较 st_dev 校验。挂载点或设备不同是明确失败,绝不降级为复制。文件先写入、fsync、由 SOW 自己的解析器与闭包校验器验证,之后才用原子 rename 换入。公开文件不继承你的 umask:repodata/0755,索引文件与指针是 0644

sow changes 输出的正是这四个阶段 —— 所以外部同步脚本可以按顺序消费它的输出,永远不会发布出一个破碎的中间状态。见可观测与审计

崩溃恢复

每条写命令都先恢复,再做自己的事。 没有单独的修复命令,也没有守护进程盯着陈旧状态;恢复是变更的前置条件。只要存在非终态 Operation,下一条 addrmbuilddist new/rmlog prune 就先把它做完或回滚,然后才继续。

全局恢复顺序是固定的:先在工作区锁下恢复工作区生命周期;如果那不是一次仓库删除,再按仓库名顺序、在各自稳定的仓库锁下恢复仓库 Operation。已经越过"删除仓库"提交决策的工作区操作具有支配权,并禁止任何嵌套的仓库恢复 —— 在一个正被删除的仓库内部恢复状态毫无意义。

恢复由证据驱动,不做乐观假设。每个阶段都有明确规则:

已到达的阶段恢复规则
plannedconfig 仍为旧值 → 回滚 stage;否则证据冲突,退出 5
stagedconfig 仍为旧值 → 可回滚;config 已为新值 → 只允许前滚
applied新 config 已原子换入,这就是提交决策,因此一律前滚
built指针与目录已耐久,前滚提交数据库行
done数据库、config 与树同代;清理 stage,重复恢复是空操作

这套规则的验收方式是在多个不同时机向 sow add 发送 SIGKILL。每一次,status 都报告 recovering,下一条写命令都先恢复该 Operation 再执行自身,最终 check 八层全过,公开树从未撕裂。

$ sow status
repository=pigsty status=recovering ready_to_copy=false ...

sow build 是唯一的显式前滚恢复入口:它在收敛之前,会先尝试完成或回滚任何可判定的非终态 Operation。看到 recovering 时,执行 sow build 就是标准反应。

error 专留给 journal、数据库与文件证据互相矛盾、任何自动选择都不安全的情况。此时 build 拒绝覆盖,最后完成的视图继续服务,你应当从备份恢复,再跑 checkbuild。这里刻意没有 repair --force —— 一个可能猜错的修复,比一个拒绝执行的修复更糟。

fail-closed 的路径安全

Managed 路径从不由用户提供的字符串拼装。每次创建、rename 和删除都走同一套流程:

  1. 把工作区根解析为绝对真实路径;
  2. 用固定相对片段重新构造目标,并验证相对路径不含任何逃逸分量;
  3. 对路径上每个已存在的受控组件执行 Lstat,拒绝符号链接和非预期文件类型;
  4. 只删除已经先被原子移入 .sow/.../recovery(或 Plain recovery trash)的对象;
  5. 删除前再次证明该 recovery 目标确实位于对应的私有状态目录内。

名称必须匹配 [a-z0-9][a-z0-9._-]*,....sowpooldists 及工作区保留名一律拒绝。

对文件句柄也是同样的姿态。Plain journal 通过 no-follow、绑定描述符的句柄读取,符号链接无法在检查与打开之间被换进来。SQLite 以 O_NOFOLLOW 打开并绑定普通文件 inode,连接建立后再按路径复核一次;数据库、WAL、shm 或 rollback journal 中任何一个是符号链接、非普通文件、有多个硬链接,或在打开期间被换绑,都会被拒绝。log export 拒绝覆盖已存在的文件,也拒绝父目录是符号链接的目标 —— 这就是为什么在 macOS 上往 /tmp 导出会失败:那里的 /tmp 本身是个符号链接。

各类 journal 都有大小上限:Plain 64 MiB、工作区 32 MiB、仓库 Operation payload 16 MiB,外置的 mutation manifest 与 base manifest 各 64 MiB。超限既不截断也不降级,而是在提交窗口之外直接失败 —— 这样写者永远不会产出一条"自己写得进去、恢复读者却永远读不回来"的 Operation 记录。

以上没有一条声称能抵御以同一用户身份运行、拥有无限权限的恶意进程。它抵御的是现实中的失败模式:崩溃、协作进程之间的竞态,以及在检查与使用之间形态发生变化的路径。

继续阅读

最后修改:2026-08-08: init commit (fe725aa)