列举不能丢掉仍有仲裁的 null 版本
发布更新,2026-09-20: null 版本仲裁修复已随 Server 20260916 发布。#218 跟踪的滚动重启列举限制仍然开放。以下调查与验证记录保留当时的日期和范围。
本文记录 2026-09-16 调查的列举漏项:未开版本控制的桶在并发覆盖写下返回 HTTP 200 的列表,却少了同一端点仍能 GET 的 key。这里说明已确认的机制、提交 8d06424b1 的窄修复、这个修复证明了什么,以及仍由 #218 跟踪的反例。
状态: 已在 main,晚于已发布的 Server 20260903。修复在实施前经过三轮外部评审,用确定性的顺序反例、签名 HTTP 列举和差分输入验证。它没有关闭列举问题;见边界。
缺陷
列举会合并被询问各盘的版本流。各盘不一致时,mergeXLV2Versions 选出一个最新版本并统计同意它的流。上游 2022 年的改动(PR #14125)让这个计票容忍 healing 留下的签名差异,这是必要的,但它的选择与计票方式对输入顺序敏感。当每块盘各有一个普通 null 版本、而更新的少数盘排在最后时,旧一代的计数会被重置。实际达到仲裁的旧一代于是被丢弃,合并返回空,解析器把这个 key 当作不存在。请求本身仍然成功,客户端看到的是一份看起来完整、其实有洞的列表。读取该 key 能成功,因为读路径自行解析仲裁。
旧代码在九种盘序下如此失败,并有完整的签名 LIST 证据,而同样内容在其他顺序下列举正确。这个缺陷在已发布的 Server 20260903 中同样存在。
修复
补做的计票只在原选择没有达到仲裁时进行,并且只针对能够精确判断的输入:每个非空盘流恰好有一个普通 null 版本,没有 free version,纠删参数全部相同,而且按裁剪前的原始输入判断。满足时按头部重新分组,只有达到原有效仲裁的组才会返回。混合历史、显式版本 ID、删除标记、free version 与混合纠删布局保持原有行为;严格模式、签名、平票与代表条目的规则不变,合并的共享调用方对所有原本成功的输入得到同样的结果。
验证:九个旧顺序与七个新顺序反例通过;5,620 个差分输入保持约定结果;完整签名 HTTP 列举、对象读取、普通与 race 运行,以及扫描器、healing、迁移这些合并的消费方都做了检查。合入 main 前,六个顶层与 23 个嵌套用例在普通与 race 模式下通过。四节点十六盘集群上并发覆盖写下的 20,000 次稳态成功 LIST 没有漏项。
仍然开放的部分
同一集群上,四个节点滚动重启叠加并发覆盖写,27,966 次成功 LIST 中有 2,624 次少了一到四个 key。这些 key 是可读的:8,192 组同端点 GET/HEAD 核查中 8,186 组返回 200,内容是最后确认的写入。最强的样本开始于最后一个节点健康检查返回后 17.6 s,漏掉的 key 最后一次成功 PUT 在 11.5 s 前已确认,之后没有新写入。原始 XML、HTTP 关联与正文哈希经独立复核,分页与解析解释不了它。
修复后的构建仍有把"无法决策"当作"不存在"而不让请求失败的出口:没有任何一代达到仲裁的合并;有效条目少于仲裁或没有缓存元数据的解析器;丢弃该条目、只在失效 reader 多于仲裁允许时才让列举失败的 partial 回调;以及元数据读取意外失败时跳过该条目的 walker。列举仲裁按被询问的盘数计算,不随中途失效的 reader 调整。样本命中了哪一个出口,从 HTTP 证据看不出来;失败请求的逐盘流、有效仲裁、补票资格与缓存状态没有采集,运行后恢复的元数据也不是失败时的输入。
下一步是对一次失败 LIST 做有界的诊断采集,再据此构造确定性回归,然后才裁决契约是否改成让请求失败或返回三态结果。在此之前,滚动重启期间不要运行会删除源端列表中缺失对象的同步工具,集群稳定后重新列举。