Conditional DELETE:为什么删除条件只能针对当前对象判断一次
本文是 SILO PR #12 的问题分析、设计讨论与修复决策归档。
截至 2026-08-26 的状态: PR #12 仍然 open,原 head 为
5b71a75e,落后最新main118 个提交。原提交没有 DCO sign-off,GitHub 上没有 check run。本文记录的改进方案已在独立本地 worktree 中实现、测试并完成两轮 review,但尚未提交、推送、合并或发布。
本轮范围: 正确实现单对象DeleteObject的If-Match;同时在收到尚未支持的DeleteObjects逐对象 ETag 时 fail closed,避免静默无条件删除。完整批量条件执行与 bucket policy 仍是独立交付。
发布边界: 本地实现、测试、review、commit、push、远端 CI、merge、tag、镜像与生产部署是相互独立的门槛。
太长不看(TL;DR)
这个问题是真的。SILO 当前会忽略 DELETE 请求的 If-Match,让客户端以为自己在执行 compare-and-delete,服务器却实际执行无条件删除。PR #12 试图补上条件检查,目标正确,也意识到必须在持锁、读取新鲜对象状态后判断。
但原实现把同一个 HTTP callback 传给每个 erasure pool。不同 pool 可能保留不同时间的对象副本,于是每个 pool 按自己的 ETag 各判各删。评审用双池测试复现了两个反例:
- 请求最终返回 412,但匹配条件的旧副本已经被删除;
- 请求返回成功,未匹配条件的旧副本保留,随后重新成为可见对象。
选定修复不增加新的条件框架。它复用 SILO 已有 GET 多池路径的模式:在外层 namespace lock 下选出当前对象,只判断一次条件,然后清除下层 callback。条件失败时任何 pool 都不能发生 mutation;条件通过后所有 pool 执行原有清理,不再重新解释客户端条件。
问题为什么成立
被忽略的条件不是无害扩展
AWS 条件删除文档 已经为通用 bucket 明确支持 DeleteObject 和 DeleteObjects:
| 请求 | 含义 | 结果与权限 |
|---|---|---|
If-Match: <ETag> |
只有当前对象仍是调用者看到的版本时才删除 | 匹配返回 204,不匹配返回 412;需要 s3:GetObject 与 s3:DeleteObject |
If-Match: * |
只有当前对象存在时才删除 | 对象存在返回 204;只需要 s3:DeleteObject |
| key 不存在 | 无法满足条件 | 返回 Not Found |
| 最新版本是 delete marker | 当前对象不存在 | If-Match: * 返回 412 |
因此,服务端收到条件后无条件执行,不是“尚未支持的 header 被忽略”这么简单。它破坏了调用者用于避免并发误删的前提。
严重性取决于调用面:不是每个客户端都会使用条件删除,所以总体出现频率未知;但一旦客户端依赖它,单次失败就可能删除另一写者刚刚提交的新对象。这属于数据正确性问题。
delete marker 的失败不是 ETag 比较问题
PR #12 复用了通用 isETagEqual:右值为 * 时直接返回 true,所以 isETagEqual("", "*") 也为真。
更根本的问题在更外层。erasureServerPools.DeleteObject 看到当前对象已经是 delete marker 时,会在原 PR 新增的内层 callback 执行前直接返回成功。评审测试观察到 callback 调用次数为零,结果却是成功。
这里要区分两种影响:
- delete-marker fast path 在复现中没有删除历史版本,也没有再创建 marker;它是绕过条件并假报成功;
- 多池反例确实能在失败请求中删除一个副本,或在成功请求后留下可重新出现的副本;这才是实际存储状态被破坏。
所以不能只把 isETagEqual("", "*") 改成 false。那既触及 GET、PUT、COPY 共用的比较器,也无法让 callback 越过外层 fast path。
原 PR 哪些思路是对的
PR 的高层算法没有错:
- Handler 发现
If-Match; - 存储层在持锁后读取新鲜
ObjectInfo; - 条件不成立则在 mutation 前返回;
- Handler 把结果编码成 S3 响应。
它避免了先单独 HEAD、再执行 DELETE 的明显 TOCTOU 窗口,也为错误 ETag、匹配 ETag和缺失对象增加了测试。普通单池、当前对象、具体错误 ETag 的路径确实能够返回 412 并保留对象。
问题不在“应该在锁内判断”,而在“哪一层的锁内、针对哪个对象判断”。
原子性边界在哪里
SILO 的删除路径分为两层:
只有 erasureServerPools.DeleteObject 同时知道:
- 哪个副本代表当前对象;
- 哪些 pool 还存在旧副本或不一致 metadata;
- 是否会走 delete-marker fast path;
- 是否要并发删除多个 pool。
所以客户端条件必须在这一层判断。单个 pool 只知道自己的副本,它没有资格重新解释“当前对象的 ETag 是否仍匹配”。
双池反例
测试构造两个 pool:pool 0 有较旧对象,pool 1 有较新对象,两者 ETag 不同。SILO 的读取选择 pool 1 作为当前对象,但非版本化删除会清理两个 pool。
条件匹配旧副本
原 PR 会在 pool 0 判定通过并删除旧副本,在 pool 1 判定失败。最终返回值取最新 pool 的 412,但失败请求已经修改了存储状态。
条件匹配当前副本
原 PR 会在 pool 1 判定通过并删除当前副本,在 pool 0 判定失败并保留旧副本。请求返回成功后,旧副本变成系统可见的最新对象,相当于对象“复活”。
callback 还捕获同一个 http.ResponseWriter;在多个 pool 并发执行时,可能并发写同一响应。即使暂时没有触发可见 race,也不应让存储副本并发决定 HTTP 结果。
降级旧池的既有局限
这里还有一个不由本补丁引入的相关旧限制:如果被选中的当前 pool 可读可写,但保存旧副本的非当前 pool 处于降级状态,现有多池删除路径可能返回当前 pool 的成功,而没有向上暴露旧 pool 的错误。残留副本在恢复后可能重新可见。
新条件检查没有制造这个行为:它先正确判断可读的当前对象,再进入无条件删除也会使用的非版本化多池清理。修复降级旧池的错误聚合与恢复语义会影响所有非版本化多池删除,不只 conditional delete,因此应单独跟踪。
选定的最小修复
1. 外层只检查一次
在 erasureServerPools.DeleteObject 已经取得 namespace write lock 后:
- 保存
opts.CheckPrecondFn; - 立即从传给下层的
opts中清除 callback; - 读取所有 pool 并选出当前
pinfo; - 如果当前对象不可可靠读取,返回 quorum 错误,callback 不运行;
- 针对
pinfo.ObjInfo调用一次 callback; - 条件通过后继续现有删除流程,所有 pool 不再重复判断。
这不是新发明。GetObjectNInfo 的多池实现已经使用同样的“外层保存 callback、清除下层 callback、选出 latest 后检查一次”模式。DELETE 复用该模式可以把修改限制在真实原子性边界。
2. * 显式判断当前 representation
DELETE 专用条件函数把 * 与具体 ETag 分开:
缺失 key 在选择当前对象时已经返回 Not Found;delete marker 则进入 callback 并返回 412。通用 isETagEqual 保持不变,避免波及其他方法。
3. 具体 ETag 增加读权限
Handler 仍先检查 s3:DeleteObject。当规范化后的条件不是裸 * 时,再检查 s3:GetObject。因此:
- Delete-only policy +
*:允许; - Delete-only policy + 具体 ETag:403,且对象不变;
- Get + Delete + 正确 ETag:允许。
这是授权前置检查,不会在拒绝后触碰存储。
4. 不为 DELETE 强制 SSE-C 解密请求
原 PR 直接复用 GET/PUT 的 DecryptObjectInfo,但 SSE-C 对象在没有 SSE-C 读取 header 时会被拒绝。DELETE 条件只需要客户端可见 ETag,不需要解密内容或尺寸。
选定实现只在具体 ETag 路径调用既有 getDecryptedETag;* 不读取 ETag。这样复用 SILO 已有的 ETag 投影逻辑,不为 DELETE 引入内容解密要求。
5. 条件针对当前版本
AWS 规定 conditional delete 评估当前版本。SILO 的外层 pool 选择本来就读取当前对象,再把显式 versionId 留给真正的版本删除。因此上移判断后,即使请求携带历史 versionId,条件 callback 看到的也是当前 ETag。
测试固定了这一点:请求删除历史版本、条件却匹配历史 ETag而不匹配当前 ETag时,返回 412,历史版本和当前版本都保持不变。
6. 在暂不支持的边缘 fail closed
两个很小的 guard 防止单对象条件被绕过:
- 空或仅含空白的
If-Match会被拒绝,不会降级成无条件删除; If-Match不能与内部递归扩展x-minio-force-delete组合,因为 prefix 删除语义无法表达单对象 ETag 条件;HTTP Handler 会拒绝,存储层也会拒绝任何内部 prefix-delete + callback 组合。
批量 XML decoder 现在也会识别逐对象 <ETag>。在原子化逐项执行完成前,只要 batch 中出现非空 ETag,服务器就在任何删除发生前以 NotImplemented 拒绝整个请求。这不是批量条件删除支持,只是防止静默丢弃条件的数据安全护栏。
被否决的方案
只修 isETagEqual
不能解决外层 delete-marker fast path,也会改变多个 API 共用的比较语义。
保留每池 callback,再聚合结果
聚合错误无法回滚已经发生的副本删除。客户端条件是对逻辑当前对象的判断,不是对每个物理副本的独立条件。
新增复杂的条件对象或事务协调器
当前只支持一个 If-Match 条件,现有 CheckPrecondFn 足以表达;GET 已经展示了正确的一次性消费模式。新增通用 DSL、状态机或跨池事务抽象没有必要。
在同一改动中完成所有 conditional delete
DeleteObjects 与 policy condition 是相关但不同的接口和仓库边界。把它们塞进本 PR 会扩大 XML、逐项响应、IAM、quiet mode 和依赖发布的审查面,降低核心删除修复的可信度。
测试与验收契约
最小充分测试覆盖:
| 层级 | 验证内容 |
|---|---|
| 条件函数 | 正确/错误/带引号 ETag、*、delete marker、非 DELETE 方法,以及无需内容解密 header 的 SSE-C 客户端可见 ETag 投影 |
| Handler | 错误 ETag 返回 412 且对象保留;正确 ETag 返回 204;缺失 key 返回 Not Found;空条件与 conditional force-delete 无 mutation 拒绝 |
| 权限 | Delete-only 的具体 ETag 返回 403且对象保留;同一策略下 * 成功 |
| 单池存储 | 正确/错误条件、缺失对象、delete marker callback 恰好一次,并拒绝 conditional prefix delete |
| Quorum | 当前对象不可可靠读取时返回 quorum 错误,callback 零次,恢复磁盘后对象仍在 |
| Versioning | 显式历史 versionId 的条件仍针对当前版本 |
| 双池 | 412 后每个 pool 都不变;204 后所有 pool 副本都消失;callback 都只运行一次 |
| Batch 安全护栏 | 尚未支持的逐对象 <ETag> 返回 NotImplemented,所有对象保持不变 |
原 PR 的 quorum 测试只把 16 块盘中的 8 块下线并断言“存在任意错误”。此时删除 write quorum 本来也不足,所以测试放在未实现 conditional delete 的主线上也会通过。新测试要求具体 quorum 错误、callback 未执行,并在恢复磁盘后确认对象仍在,避免同类假阳性。
独立对抗评审
两轮只读本地 Claude Code review 都使用 Fable 模型与 xhigh effort,检查精确的服务端差异和两篇设计记录。两轮结论都是 GO WITH NON-BLOCKING NOTES;第一轮意见落实后,不再存在 P0、P1 或 P2 finding。
第一轮发现 conditional force-delete 绕过、纯空白条件降级、batch ETag 被静默丢弃、版本化成功路径缺测试,以及降级旧 pool 的既有局限;上文的 guard、测试与限制说明都来自这些 finding。第二轮确认了外层原子性边界、错误处理、权限拆分、batch 字段影响面、response writer、双语一致性和最小复杂度。它剩余的一个可行动 P3 是内部调用者理论上仍可组合 prefix deletion 与 callback;存储层现在也会拒绝这个组合。
评审中有一句认为 SSE-C 未提供 customer-key header 时条件必然失败。直接检查既有实现表明并非如此:getDecryptedETag 无需解密对象内容,就会投影后端保存的客户端可见 ETag 后缀;新增定向回归测试已固定这一行为。其余非阻塞备注是多值 header 的规范化和刻意保留的“鉴权前返回 501”错误顺序。若要宣称超出公开文档的逐字节一致性,发布前仍值得用真实 AWS 对“当前 delete marker + 具体 ETag”以及“versionId + If-Match”做一次差分验证。
本轮刻意不做什么
DeleteObjects 的逐对象条件
AWS DeleteObjects API 允许每个 <Object> 携带 <ETag>,并在同一个 200 响应内逐项返回 <Deleted> 或 <Error>。
安全补丁只为 ObjectToDelete 增加 ETag 字段,使 Handler 能识别条件并在 mutation 前拒绝整个请求。这样关闭了原先静默无条件删除的风险,但没有实现 AWS 要求的逐对象判断和 mixed <Deleted> / <Error> 响应。
完整兼容仍是独立且高优先级的工作:每项都要在正确锁下针对逻辑当前对象判断;逐项落实具体 ETag 的权限规则;保持 quiet mode;条件失败不能阻塞其他无关项,并在响应中分别报告。
s3:if-match policy condition key
AWS 允许通过 policy 强制 conditional delete。SILO 使用的 silo-pkg 尚未定义 s3:if-match,完整实现需要:
- 在
silo-pkg增加 condition key 与 action map; - 发布新的
silo-pkg版本; - 在 server 提供单删 header 和批删逐对象 condition values;
- 升级依赖并做策略兼容测试。
这应是独立跨仓库交付,不是单对象原子性修复的前置依赖。
复杂度、收益与代价
生产修改仍然很小:一个 DELETE 条件函数、一次附加授权、外层十余行的一次性 callback 消费,以及针对畸形/递归请求和暂未支持 batch 条件的窄幅 fail-closed guard。复杂度主要在测试,因为删除路径横跨多池、版本、marker、quorum 和权限。
| 范围 | 复杂度 | 主要成本 |
|---|---|---|
| 本轮单对象修复与 batch 安全护栏 | 中等 | 删除热路径与多池/版本/权限回归 |
| Batch conditional delete | 中等偏高 | XML、逐对象判断、mixed response、quiet mode |
| Policy condition key | 中等、跨仓库 | silo-pkg 发布、server condition values、策略测试 |
收益大于代价。它消除危险的静默无条件删除,并把条件判断放回系统已经存在的全局一致性边界。相比引入新框架,复用当前 outer-lock/latest-object 模式是最小、充分且必要的实现。
合并与发布门槛
单对象修复在满足以下条件后可以合并:
- 定向条件删除、权限、versioning、quorum 与双池测试通过;
go test ./cmd、go vet ./cmd、格式与 diff 检查通过;- 独立对抗 review 没有未解决 blocker;
- 作者在最新主线上整理提交并提供有效 DCO sign-off;
- DCO、Go CI、VulnCheck 等远端 workflow 全绿;
- PR 描述明确区分完整的
DeleteObject支持与 batch fail-closed 护栏,并链接完整 batch/policy 后续项。
即使代码合并,仍不能把功能写成已发布。只有对应 release、软件包、docker.io/pgsty/minio 镜像、部署和真实 S3 客户端验证分别完成后,生产用户才能依赖它。
结论
Conditional DELETE 值得实现,PR #12 的目标和“锁内读取新鲜状态”方向也值得保留。真正需要改变的是判断边界:客户端条件属于逻辑当前对象,不能由每个物理副本分别解释。
选定方案只把 callback 上移到已经负责选择当前对象的 erasureServerPools,复用现有模式,显式处理 wildcard/delete marker,并补上具体 ETag 的读权限。它不改存储格式、不增加依赖、不引入新条件框架。batch 改动严格限于 mutation 前拒绝尚未支持的条件;完整批量执行与 policy 支持仍保持独立。
这就是本问题所需的最小、充分且必要的复杂度。