多文件保存:Manifest 最后写不等于真正原子

自动关联目录:多文件保存:Manifest 最后写不等于真正原子

核心判断:把 Manifest 放在最后写,是正确的发布屏障;但多个文件逐个替换仍不是文件系统事务。进程崩溃、回滚失败和外部修改都需要 generation 级恢复。

一、业务问题:保存成功必须表示一个完整版本

多关卡内容一次保存会更新公共片段、当前关卡片段、根投影、可选二进制和 Manifest。任意文件来自不同版本,项目都可能“每个文件可读,但组合后不可用”。

单文件采用临时文件加 rename 只能保证该文件不半写,不能保证五个路径一起切换。真正的提交语义必须回答:Reader 依据哪个事实判断新版本已经完整可见?

二、实际实现:Manifest 最后发布,并保留回滚快照

工程当前方案有完整的事务意识:

  1. 校验所有目标路径都归项目所有;
  2. 抓取旧文件字节快照;
  3. 逐个原子写入并读回校验;
  4. 在写 Manifest 前重新验证候选 Catalog 与当前内容;
  5. 最后替换 Manifest,只有它发布新 generation;
  6. 任一步失败则逆序恢复旧字节,回滚失败进入致命一致性状态;
  7. 提交后再生成可恢复 generation 快照。

这比“保存几个文件后返回 true”强很多。尤其是 Manifest 必须最后、每步读回验证、回滚失败不继续编辑,都是应该保留的正确性设计。

三、结构性缺陷:原地改写制造崩溃窗口

如果进程在公共片段已替换、Manifest 尚未替换时直接终止,内存中的回滚逻辑没有机会执行。下次打开只能发现“旧 Manifest 的摘要与新片段不一致”,再依赖恢复快照修复。外部程序在事务中修改文件,也会让回滚覆盖用户的新内容。

此外,旧文件快照把保存成本放大为“读取旧版本 + 写新版本 + 可能再写旧版本”;大内容下这会拉长主路径。

四、目标设计:不可变 generation + 单指针提交

next = root/generations/<new-id>/
write(next/common)
write(next/level)
write(next/projection)
write(next/binary)
write(next/manifest)
verifyAll(next)

atomicReplace(root/current, <new-id>)

Reader 只从 current 指向的不可变目录读取。崩溃发生在指针替换前,新目录只是未引用候选;发生在替换后,新目录已经完整验证。旧 generation 保留到引用释放,可直接回滚,也无需逐文件恢复。

代价是增加磁盘占用、垃圾回收和并发 Writer 仲裁。若暂时沿用原地事务,至少要保留提交 journal、上一代完整快照、启动时一致性扫描,并禁止自动把当前混合字节重新标记为健康基线。

五、验证结论

故障注入要覆盖每个文件替换前后、Manifest 替换前后、回滚中断、磁盘满、文件被占用和外部修改。验收不是“保存 API 返回成功”,而是任意中断点重启后只能观察到完整旧 generation 或完整新 generation。