* test(heal): add concurrency invariants for heal vs delete/overwrite races (HS-12) Audit conclusion for backlog#1874: RustFS does not need a persistent object-level healing marker (MinIO x-minio-healing) because every path that can touch the same (bucket, object) commit surface serializes on the same namespace write lock, and the heal lock guard spans the whole rename commit including the HEAL_RENAME_INCOMPLETE partial path. Lock the conclusion in with two race regression tests: - heal_racing_version_delete_never_resurrects_the_deleted_version: shard damage is injected on the doomed version so a Deep heal has real reconstruction work while a versioned DELETE runs concurrently; the deleted version must stay deleted and the survivor intact. - heal_racing_unversioned_overwrites_preserves_the_last_commit: unversioned overwrites (activating the post-commit tail that deletes the replaced data dir without the ns lock) race a Deep heal in a loop; the final current version must be exactly the last commit. Also adds docs/operations/heal-concurrency-safety-notes-zh.md with the full intersection matrix (17 intersections), lock-coverage argument, and the residual-window classification (commit tail races are fail-into-retry safe; bare prefix delete has zero production callers; admin no_lock is an explicit operator opt-in). Co-Authored-By: heihutu <heihutu@gmail.com> * test: remove redundant heal etag clone Co-Authored-By: heihutu <heihutu@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com>
8.9 KiB
Heal 并发安全说明(对象级 healing 标记对标审计结论)
对应 backlog rustfs/backlog#1874(父 #1862,HS-12)。本文回答一个问题:MinIO 在 heal
期间对对象打 x-minio-healing:true 元数据标记以防"heal 提交与并发删除/版本清理互毁"
(cmd/xl-storage.go RenameData 的 healing 分支),RustFS 是否需要同款防御。
结论:不需要。 RustFS 不存在 MinIO 用 healing 标记防御的那类竞争:所有会触达同一
(bucket, object) 提交面的路径都在同一把对象级 namespace 写锁上互斥,且 heal 的锁
guard 覆盖 rename 提交全程;MinIO 需要标记的根因(RenameData 提交内部与版本清理逻辑
交错)在 RustFS 的提交模型中不存在。RustFS 已有一个瞬态 healing 旗标用于另一目的
(见下文 §2),并有并发不变量回归测试锁定本结论(§5)。
1. 两个防御模型的对照
MinIO:heal 时对对象写 x-minio-healing:true(持久元数据标记),后续任何 RenameData
提交看到该标记就跳过版本清理/legacy purge 逻辑——防御发生在锁外,靠元数据让路。
RustFS:三层防御,全部不依赖持久对象标记:
- 锁内互斥:heal 与一切前台/后台写路径的提交点在同一把
(bucket, object)ns 写锁 上串行(分布式部署为 quorum 锁 RPC,单机为进程内锁管理器;锁粒度是对象级,version 恒为 None)。 - 提交模型隔离:rename_data 提交内没有会与 heal 交错的版本清理逻辑;被替换旧版本 的 data_dir 物理删除被移出提交临界区(commit tail),且只删已被新提交替换的 unshared 目录。
- 瞬态 healing 旗标:
FileInfo::set_healing(crates/filemeta/src/fileinfo.rs)在 heal 提交的内存 FileInfo 上打"healing"内部键,rename_data 据此允许先清空 stale 目标 data_dir 再 rename——解决 heal 复用 data_dir 做 in-place 修复时 rename(2) 无法 替换非空目录的文件系统语义冲突(EEXIST/ENOTEMPTY)。该键是瞬态的,不落盘 (is_skip_meta_key),与 MinIO 的持久标记目的不同。非 heal 提交撞上非空目标 data_dir 会显式失败,有测试锁定两个方向的行为。
2. 交点矩阵
中心路径:heal_object_with_explicit_version_regen(crates/ecstore/src/set_disk/ops/heal.rs,
下称 heal.rs)在入口取 (bucket, object) ns 写锁,guard 绑定到函数作用域末尾,覆盖
quorum 元数据读取 → EC 重建 → 逐盘 rename 提交 → tmp 清理 → HEAL_RENAME_INCOMPLETE
部分提交返回 → 孤儿 data_dir 回收的全过程。并发侧逐交点判定:
| # | 并发路径 | 并发侧锁 | 判定 | 关键证据 |
|---|---|---|---|---|
| 1 | PUT 对象提交 | put_object_commit 对象写锁,rename_data 在锁内 |
同锁串行 | ops/object.rs 提交锁段 + rename 调用点 |
| 2 | PUT 旧 data_dir tail 清理 | drop 对象锁后的 commit_rename_data_dir,无锁 |
无锁并发,语义安全(见 §3.1) | object.rs drop 后 tail 段;io_primitives.rs |
| 3 | DELETE 单对象/版本 | delete_object 对象写锁,delete_version 在锁内 |
同锁串行 | object.rs delete_object 锁段 |
| 4 | DELETE 批量 | 批量逐对象写锁(dist 走批量锁 RPC) | 同锁串行 | object.rs delete_objects 锁段 |
| 5 | CompleteMultipart | 对象写锁 + upload 路径锁双锁,rename 在锁内 | 同锁串行 | ops/multipart.rs 提交锁段 |
| 6 | CompleteMultipart tail 清理 | drop 对象锁后的旧 data_dir 删除 | 无锁并发,语义安全(见 §3.1) | multipart.rs drop 后 tail 段 |
| 7 | AbortMultipart | 仅 multipart bucket 的 upload 路径锁 | 锁 key 不相交,但资源不相交(abort 不触对象 data_dir/xl.meta)→ 无实际交点 | multipart.rs abort 锁段 |
| 8 | ILM expiry(含 DeleteAllVersions) | DeleteAllVersions 走 delete_prefix_object=true → 仍取对象锁;FreeVersionTask 显式取锁;noncurrent 批量走批量锁 |
同锁串行 | bucket_lifecycle_ops.rs 消费端链路 |
| 9 | 纯 prefix 删除(绕锁能力面) | delete_prefix-only 不取子对象锁 |
无锁并发,但生产调用方为零(见 §3.2) | object.rs delete_object 锁条件 |
| 10 | 孤儿 data_dir 回收 reclaim_orphan_data_dirs | 函数本体无锁;唯一生产调用方在 heal 锁内 | heal 流程内=锁内串行 | heal.rs 收尾调用;io_primitives.rs |
| 11 | 旧清理 receipt 对账 reconcile_old_data_cleanup_receipts | 函数本体无锁;调用点在 heal 锁内 + epoch fence 防误删 | 锁内串行 | object.rs 对账函数 |
| 12 | replication | 数据面为远端 HTTP 写(不落本地盘);本地元数据回写走对象锁 | 同锁串行 / 无交点 | replication_resyncer.rs 链路 |
| 13 | data_movement / rebalance / decommission 源清理 | 显式取对象锁 + 版本未变复核 + guard 复用(no_lock 只是复用已持锁) | 同锁串行 | data_movement/mod.rs 源清理 |
| 14 | copy_object | 目标对象锁 / 走 put 链锁 | 同锁串行 | object.rs copy_object 锁段 |
| 15 | 另一 heal 任务(跨 HealType/force_start) | dedup key 跨类型不相交 + force_start 跳过去重 → 任务级可并发 | 最终在 ns 写锁上串行 | heal/manager.rs dedup key 构成 |
| 16 | admin no_lock=true heal |
客户端可控绕锁 | 无锁并发,明示运维选项(见 §3.3) | admin/handlers/heal.rs 透传 |
| 17 | stale multipart 清理 | multipart bucket 的 upload 路径锁 | 资源不相交 → 无交点 | bucket_lifecycle_ops.rs 清理链路 |
3. 残留窗口定性
3.1 PUT/CompleteMultipart commit tail(交点 2/6)
写路径提交成功、释放对象锁之后,才 best-effort 删除被替换的旧 data_dir(注释明示有意 不阻塞下一操作)。该删除与并发 heal 对同一旧 data_dir 的读取/重建存在竞态窗口,但语义 安全:
- 删除目标是已被新提交替换的 unshared data_dir;heal 的 canonical 元数据来自 quorum 仲裁(ETag/mod_time),此时 quorum 已指向新版本,heal 不会把已替换版本当作 canonical 复活;
- 竞态最坏后果 = heal 当轮对旧版本的一次 transient 失败/空转,重试轮自然收敛;清理
residue 会上报并重新入队 heal(
report_old_data_dir_cleanup); - 换盘重建等长 heal 走 per-version 显式版本请求,quorum 元数据在锁内读取,不受 tail 影响。
3.2 纯 prefix 删除(交点 9)
delete_prefix && !delete_prefix_object 的路径不取子对象锁(对象名空间锁无法保护前缀
递归删除),与并发 heal 存在理论复活窗口(heal 在 prefix 删除进行中依据旧 quorum 元
数据重建某版本)。全仓库核对结论:该路径的生产调用方为零——所有生产 delete_prefix: true 调用点均同时设置 delete_prefix_object: true(从而取对象锁)或在测试模块内。这
是 API 能力面的暴露而非行为风险。若未来有调用方需要纯 prefix 删除,须在调用点证明与
heal/scanner 的隔离(例如 bucket 级停扫围栏)。
3.3 admin no_lock=true(交点 16)
admin heal 请求可透传客户端 nolock 参数绕过 ns 锁(与 MinIO madmin 的同名选项对齐)。
这是运维明示选项:使用即自负与并发写的竞争责任。文档化即可,不建议收紧。
4. heal 侧自身的不变量保障
- dedup key 跨 HealType 不相交(object/metadata/mrf/ecdecode/prefix 各自键面)+ admin
force_start可跳过去重 → 同对象可能同时存在多个 heal 任务,但它们的执行体全部在heal_object入口的 ns 写锁上串行(生产入口均no_lock=false); - read-repair 的本地 TTL 预留只去重自身来源,不拦截其他来源的 heal——同样由 ns 锁兜底;
- healing 旗标不落盘,故不存在"标记残留导致后续提交错误让路"的反向风险。
5. 回归测试
以下两个并发不变量测试随本审计加入 crates/ecstore/src/set_disk/ops/heal.rs 测试模块:
heal_racing_version_delete_never_resurrects_the_deleted_version:注入 doomed 版本 shard 损坏后,版本化 DELETE 与 Deep heal 真并发(同一把锁争用),断言已删除版本不被 复活、存活版本完好;heal_racing_unversioned_overwrites_preserves_the_last_commit:非版本化覆盖提交(激活 commit tail 旧 data_dir 删除)与 Deep heal 循环竞态,断言最终 current 恰为最后一次 提交(etag 级一致)。
6. 结论
MinIO 的 x-minio-healing 是锁外元数据防御,前提是其 RenameData 提交内部存在与 heal
交错的版本清理逻辑;RustFS 的提交模型把这类交错从根上消除(提交面锁内互斥 + 清理外
移到 tail + tail 只删 unshared 旧目录),因此引入持久对象级 healing 标记没有对应的竞争
可防,反而会引入 FileInfo 落盘格式变更与标记残留清理两类新成本。维持现状,本对标疑点
关闭。