mirror of
https://github.com/pgsty/minio.git
synced 2026-09-12 13:34:05 +03:00
docs: archive issue 77 design, adversarial reviews and acceptance evidence
Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
# Issue #77 当前核验与最小修复建议
|
||||
|
||||
> 历史快照(2026-09-12,实施前)。其中的 Issue 状态、待办和方案约束只描述当时情况;最终实现、修正与验收以 [归档总记录](issue-77.md) 为准。
|
||||
|
||||
核验日期:2026-09-12。对象:[SILO #77](https://github.com/pgsty/silo/issues/77)。代码基准:远端 `main` 的 `5c576581631561c446f30ae5b566f0aa793adc1c`,在独立 detached worktree 中运行测试。用户工作目录仍位于 `12f631b50`;两者差异为 #179 的 federation 修复,不涉及本次复制元数据代码。
|
||||
|
||||
**结论:问题真实,当前主干仍未修完,应保持打开。原方案的 A/B 核心必要,但不能照搬 8 月的实施清单;已有基础设施可以复用,同时必须补上批量复制入口和相同内容的时间戳同步。**
|
||||
|
||||
本次是分析与复现,没有修改产品代码、提交、发布或修改 GitHub Issue。下述测试是本地 ObjectLayer 与进程内 HTTP/RPC 复现,不是线上多站点验收。
|
||||
|
||||
## 1. 当前状态
|
||||
|
||||
GitHub API 实时返回 #77 为 `OPEN`,未分配负责人或 milestone,最后更新为 `2026-09-08T14:45:10Z`。核验时仓库没有打开的 PR。
|
||||
|
||||
| 范围 | 当前事实 | 是否仍属遗留项 |
|
||||
| --- | --- | --- |
|
||||
| #77-C,各站点统计 | [#91](https://github.com/pgsty/silo/pull/91) 于 8 月 29 日合并;本次现有统计回归测试通过 | 否,不应重复实现 |
|
||||
| #77-A,heal 选源与输入 | 仍先用 map 首项初始化,再跳过创建默认值;Tag 远端 heal 仍不携带时间戳 | 是 |
|
||||
| #77-B,源时间与删除状态 | 六类专用处理函数仍调用到达时间写入路径;部分删除状态不导出 | 是 |
|
||||
| #77-D,持续不一致诊断 | 已有损坏配置日志;旧事件静默跳过、默认状态无法选源等诊断未完成 | 是 |
|
||||
| Object Lock 错误 wire 字段、接管桶覆盖已有配置 | [#76](https://github.com/pgsty/silo/issues/76)、[#78](https://github.com/pgsty/silo/issues/78) 已关闭,对应 #89、#90 已合并;相关测试本次通过 | 否 |
|
||||
| 元数据整记录并发写、删除后重建 | [#103](https://github.com/pgsty/silo/pull/103)、[#156](https://github.com/pgsty/silo/pull/156) 已提供桶锁和保存前物理桶检查;删除后排队写测试本次通过 | 基础已具备,但不能代替同字段时间排序 |
|
||||
| 评论提及的 MRF 丢弃观测、delete-marker purge | [#152](https://github.com/pgsty/silo/issues/152)、[#153](https://github.com/pgsty/silo/issues/153) 已于 9 月 9 日随 [#162](https://github.com/pgsty/silo/pull/162) 关闭 | 不再列为 #77 的待实现项 |
|
||||
|
||||
#162 明确保留“405 表示 marker 仍存在”的语义,没有采用报告人建议的“405 一律判定永久删除完成”;它也未声称复现外部报告的请求量或持续 405 风暴。本次只核验其合并/关闭状态与提交说明,没有重跑那组故障实验。
|
||||
|
||||
最新公开 Release 仍是 `RELEASE.2026-09-03T13-18-01Z`。#77 的状态不能用“已有统计修复”或“已有桶锁”推导为已修复,也不能用较晚 PR 的合并推导为已发布。
|
||||
|
||||
## 2. 已复现的真实问题
|
||||
|
||||
最直接的例子:源端 10:00 PUT 配置,10:01 DELETE;目标端积压到 10:05 才接收 PUT,并把配置时间写成 10:05。随后接收源时间为 10:01 的 DELETE,判断它比本地旧,返回 HTTP 200,却保留配置。这个例子不需要机器时钟偏差,只需要事件延迟。
|
||||
|
||||
根因在 [updateAndParse](https://github.com/pgsty/silo/blob/5c576581631561c446f30ae5b566f0aa793adc1c/cmd/bucket-metadata-sys.go#L129):它在锁内统一使用 `UTCNow()`;专用 peer handler 在调用它之前,用 getter 返回的时间做比较。
|
||||
|
||||
| 配置类型 | peer PUT 保留源时间 | 较新源 DELETE | 本地删除后旧 PUT | 删除时间在元数据导出中可见 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Policy | 否 | HTTP 200,但未删除 | 会复活 | 是 |
|
||||
| Tags | 否 | HTTP 200,但未删除 | 会复活 | 否 |
|
||||
| SSE | 否 | HTTP 200,但未删除 | 会复活 | 否 |
|
||||
| Quota | 否 | HTTP 200,但未删除 | 本次未复活,getter 保留删除时间 | 否 |
|
||||
| Versioning | 否 | nil 正确地不修改配置 | 不适用 | 不应套用可删除配置规则 |
|
||||
| Object Lock | 否 | nil 正确地不修改配置 | 不适用 | 不应套用可删除配置规则 |
|
||||
|
||||
矩阵每项均在 `ErasureSD` 与 16 盘 `Erasure` 两个本地后端执行。六类专用事件通过实际签名 admin 路由调用,检查落盘状态;导出调用实际 `SiteReplicationMetaInfo`。不能把 Quota 与 Policy/Tags/SSE 的 getter 行为写成完全相同。
|
||||
|
||||
另外复现了以下路径:
|
||||
|
||||
1. **检查与写入不在同一临界区。** 阻塞旧 Tag 事件的锁获取,在锁内提交较新 Tag,再释放旧事件;旧事件仍覆盖新内容。#103 解决跨字段丢更新,没有解决 handler 的 getter → Update 竞态。
|
||||
2. **批量元数据入口绕过逐字段排序。** 已持久化较新 Tag 后,发送较旧 bulk 事件,实际 admin 路由返回 HTTP 200,Tag 内容和时间都倒退。该入口被 [桶元数据导入](https://github.com/pgsty/silo/blob/5c576581631561c446f30ae5b566f0aa793adc1c/cmd/admin-bucket-handlers.go#L1106) 使用;[PeerBucketMetadataUpdateHandler](https://github.com/pgsty/silo/blob/5c576581631561c446f30ae5b566f0aa793adc1c/cmd/site-replication.go#L1608) 只检查桶创建时间,非 CORS 字段缺少与已有字段时间的比较。这是旧实施清单漏掉的入口。
|
||||
3. **默认值能够成为错误的 heal 来源。** 两站点状态中,一个有真实 Policy,另一个只有更晚的 `CreatedAt == PolicyUpdatedAt` 默认值。最后一次复现分别有 5/32、2/32 轮错误删除有效 Policy,取决于 Go map 遍历顺序。该计数只是复现样本,不能推断生产发生率。
|
||||
4. **远端 Tag heal 丢失源时间。** 实际 HTTP 捕获的 `UpdatedAt` 为零,见 [发送字段](https://github.com/pgsty/silo/blob/5c576581631561c446f30ae5b566f0aa793adc1c/cmd/site-replication.go#L5011)。
|
||||
5. **同内容、不同时间戳没有同步。** 即便强制 `TagMismatch=true`,内容比较仍使 heal 跳过,较旧排序时间保持不变。两站点看似内容相同,之后却可能对同一个延迟事件做出不同决定。旧方案只统一写入时间、保留全部 heal 跳过条件,仍不完整。
|
||||
6. **Quota 的 nil heal 留下缓存。** 向现有 heal 传入显式较新删除状态,磁盘 `QuotaConfigJSON` 被清空,但 `GetQuotaConfig` 仍返回 1024 字节的旧硬配额。这里显式构造了删除状态,因为当前 exporter 正在隐藏它;这是新增 tombstone 导出不能直接交给旧 heal 的具体证据。
|
||||
|
||||
从当前 `git blame` 和基线比对看,核心错误继承自 MinIO:heal 初始化逻辑来自 2022 年 `3a64580663`,专用 handler 的旧事件检查来自 2022 年 `7cc9286e0f`,bulk 入口来自 2023 年 `0cde37be50`;0806 基线已包含相关逻辑。此次没有发现其由 SILO 最近的 CORS 或统计修改引入。
|
||||
|
||||
对于使用 site replication 的部署,建议按 P1 正确性问题处理:撤销的桶策略可能保留或复活,默认加密和配额也可能与源端不一致。没有启用站点复制的正常单站点请求不触发这些复制路径;本次没有证明对象数据本体丢失。
|
||||
|
||||
## 3. 最小且必要的修复范围
|
||||
|
||||
**第一部分:收束 A 的选源和输入修复。** 提取一个小的内部选择函数,在初始化候选之前排除零时间和创建默认值;无可信候选时不写入。六类 heal 使用它,补齐 Tag 的源 `UpdatedAt`。Versioning/Object Lock 遇到 nil 保持 no-op,Quota 删除使用正确的删除路径。不能重新做 #76/#78 或把 Lifecycle、CORS 一并重构。
|
||||
|
||||
**第二部分:收束 B 的状态更新,包含本次发现的入口遗漏。** 在既有 `metadata.lock` 内完成读取原始字段时间、判旧、写入源时间和保存;普通本地写入保留现有契约。复用 `updateAndParse` 的类型分支及现有 `saveMetadata`,不再从公开 getter 获取删除时间,也不改变 getter 的 S3 错误语义。peer 更新和删除保留各自解析规则,内部返回是否应用,重复且相同的状态不重复保存。
|
||||
|
||||
本地 heal 必须使用同一排序路径,不能选好源后调用普通 `Update/Delete` 再生成到达时间。批量复制入口也必须在其现有整记录锁内逐字段判旧;bulk 的 nil 继续表示“未提供此字段”,不能改成批量删除。heal 需要同步较新的时间戳,即便内容已经一致;这不要求重写 #91 的计数或增加公开 API。
|
||||
|
||||
当前 `saveMetadata` 已在锁内检查物理桶存在,桶删除也使用同一把锁。8 月方案里另加一套 `peer-require-existing` 防幽灵桶机制已没有必要,应保留并复用现有保护。确有 `Created == 0` 的历史桶如何补齐创建时间仍应覆盖,但不能退化当前所有写入都执行的存在性检查。
|
||||
|
||||
**第三部分:单独启用删除时间导出,补足轻量诊断。** Policy 已导出时间;实际新增的是 Tags/SSE/Quota 的 nil payload 时间。先使接收与 heal 能正确处理删除,再开放这些状态。混合旧版 SILO 的风险已经有 Quota 缓存复现支撑;这是产品自身的滚动升级问题,不是要求兼容未经修改的上游 MinIO。
|
||||
|
||||
可以使用仅针对这项导出的明确 opt-in:默认关闭新增导出,全站点升级到具备 A/B 修复的版本后启用。若项目选择自动能力协商,可后续单独实现;不必为了它阻塞没有新增 wire 字段的选源与接收端修复,也不需要为本 Issue 建一个通用能力框架。具体开关尚未实现,不应把这里的建议当成现有配置。
|
||||
|
||||
诊断只需解释“过旧事件”“早于桶创建”“只有默认/未知候选”等跳过原因,使用现有有界去重/限频设施,保持 RPC 的既有成功语义。不需要另建重试队列、每轮日志或大型监控系统。
|
||||
|
||||
有三项边界必须在实现中明确:
|
||||
|
||||
- 旧版 Tag heal 会发送零时间。新实现不能无说明地全部拒绝;可保留明确的兼容降级路径,在旧节点存在时不承诺完整排序收敛。
|
||||
- 相同时间戳、不同内容的冲突:只给 heal 增加 deployment ID 平局规则,并不能让到达顺序不同的 peer apply 本身确定。若承诺此类冲突也收敛,apply/heal 应共用一个类型内比较规则;可参考 CORS 的删除优先和载荷排序,不需要新增持久化源站 ID。若不纳入本轮,必须列为剩余边界,不能称全量收敛审计完成。
|
||||
- 旧版本已经写坏的到达时间无法从现有记录还原。升级不会自动恢复历史操作顺序;应由操作者确认权威状态,并在升级和时钟检查后重新提交相关配置/删除,再验证各站点。
|
||||
|
||||
因此,建议交付为有限的选源修复、统一源时间更新、删除导出与诊断三个可审核部分。已有锁、物理桶保护和 C 的计数修复直接复用。预期产品修改集中在 Server 的 site-replication 与 bucket-metadata 路径,无须为核心修复升级 Console/mcli/silo-pkg、改存储格式或重写复制架构。
|
||||
|
||||
## 4. 验证证据与关闭条件
|
||||
|
||||
本次最后一轮执行:
|
||||
|
||||
```text
|
||||
GOWORK=off go test -tags kqueue,dev ./cmd \
|
||||
-run 'TestIssue77Current|TestSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig|TestPeerBucketObjectLockMetadata|TestPeerBucketAdoption|TestQueuedMetadataUpdateAfterDelete' \
|
||||
-count=1 -timeout=5m -v
|
||||
```
|
||||
|
||||
四组审计复现测试包含 24 个后端/场景子测试,均暴露预期的现存缺陷;八个现有回归测试通过。总命令退出码为 1,原因是上述针对期望正确行为的断言失败,不代表修复验收通过。未运行全量测试、race suite、真实多站点中断/重启或滚动升级实验。
|
||||
|
||||
保留的证据:
|
||||
|
||||
- [完整运行日志](issue-77/current-tests.log)
|
||||
- [源时间、删除和 bulk 审计测试](issue-77/issue77_review_test.go.txt)
|
||||
- [heal 与锁竞态审计测试](issue-77/issue77_heal_review_test.go.txt)
|
||||
|
||||
测试以 `.go.txt` 保存,避免将故意失败的审计用例加入正常 Go 测试集。可在上述 SHA 的独立 worktree 中复制为 `cmd/issue77_review_test.go` 和 `cmd/issue77_heal_review_test.go` 后重跑。创建默认值测试使用重复 map 遍历来观测缺陷,正式回归应在抽出选择函数后改成确定性用例。
|
||||
|
||||
关闭 #77 前需要:上述失败场景转为正确行为;分别覆盖六类配置、落盘重载、漏发/重复/乱序、同字段竞态、同内容新时间、批量导入入口、legacy 桶、删除后的桶及混合版本。全站点升级后的实际断线重连/重启验证应保留独立证据。当前结论是“缺陷与实施范围已核实”,不是“修复完成”。
|
||||
@@ -0,0 +1,123 @@
|
||||
# Issue #77 最小充分修复计划
|
||||
|
||||
> 历史快照(2026-09-12,实施前)。其中的 Issue 状态、待办和方案约束只描述当时情况;最终实现、修正与验收以 [归档总记录](issue-77.md) 为准。
|
||||
|
||||
版本:v4 定稿,2026-09-12。状态:作者复核完成,Claude Code `claude-opus-5 --effort max` 四轮实际评审后,最终结论为 **GO_WITH_NONBLOCKING_NOTES,实施前阻断 0**。非阻断说明已落实到本计划,见 [最终评审汇总](issue-77/review/final-review.md)。
|
||||
|
||||
基准:`pgsty/silo` main `5c576581631561c446f30ae5b566f0aa793adc1c`。本轮交付是方案与评审,不表示已经实现、合并或发布。问题证据见 [当前核验](issue-77-current.md),意见处置见 [首轮处置](issue-77/review/decisions-v2.md)、[第二轮处置](issue-77/review/decisions-v3.md)、[接管边界补查](issue-77/review/decisions-v4.md)。
|
||||
|
||||
## 目标与边界
|
||||
|
||||
修复 Policy、Tags、SSE、Quota、Versioning、Object Lock 六类桶配置的源时间丢失、锁外判旧、heal 错选源和删除传播不完整。覆盖普通本地写入、专用 peer 事件、bulk/import、local/remote heal、元数据导出与初次同步。
|
||||
|
||||
在修复 PGSTY 栈、同一已知桶世代、合法带源时间事件的范围内,使重复、乱序和漏发后的状态能够确定性收敛。开关关闭期间新增删除信息不可见,不承诺完整删除收敛;无时间旧事件、旧版污染时间和桶创建世代冲突需要单独解释,不能自动推断历史真相。
|
||||
|
||||
不重做 #91 的计数、#76 的 Object Lock wire 修复、#78 的桶接管、#103/#156 的锁与删除保护。不改变 CORS、Lifecycle/expiry、notification、对象复制、MRF、resync、IAM 的语义;不改存储 schema、SDK、Console、mcli 或 silo-pkg,不新建能力协商、复制框架、锁或重试系统。
|
||||
|
||||
## 行为契约
|
||||
|
||||
### 1. 明确事件、缺省值与删除
|
||||
|
||||
| 类型 | 专用事件 | bulk 字段未提供 | bulk 字段明确提供 |
|
||||
| --- | --- | --- | --- |
|
||||
| Policy | nil 为删除;沿用现有解析器 `IsEmpty()` 为删除 | 保留 | 非空 RawMessage 按现有解析器处理;语义空策略归一为删除 |
|
||||
| Quota | nil 为删除;非 nil 按现有 quota 解析器处理 | 保留 | 非空 RawMessage 按现有解析器处理,零 quota 仍是 live 文档 |
|
||||
| Tags / SSE | nil 或 base64 解码后空内容为删除 | 保留 | 空字符串为删除,其他内容按原规则解码、校验 |
|
||||
| Versioning / Object Lock | nil/空内容为 no-op | 保留 | nil/空内容仍为 no-op,不能清空配置 |
|
||||
|
||||
“未提供”必须依据真实 wire 类型判定:Policy/Quota 的 `json.RawMessage` 为 nil 或空切片时经 `omitempty` 省略;**显式 JSON `null` 解码后是非 nil 的 `[]byte("null")`**,不能与缺省混淆。按现有解析器,Policy `null` 是语义空策略,Quota `null` 是零值 quota 文档。`*string` 类型的 JSON `null` 则解码为 nil。不得使用统一的 `len(payload)==0 => 删除` 来处理 bulk。
|
||||
|
||||
合法 `{}`/零值 quota 保持 live;取消普通 quota PUT 出站时“零配额改写为 nil”的逻辑,保存与发送同一个语义状态。Policy 保留现有专用 peer 的“空策略=删除”解释,本地 PUT、导入和 bulk 统一归一为同样的删除状态;这是需要写入兼容说明的小范围变化:空策略本地立即按删除处理,GET 返回既有 NotFound 行为,不再先保留空文档、等复制后才被清除。
|
||||
|
||||
**同一次操作的落盘状态与出站事件,经同一归一规则后,必须具有相同的 `(kind, payload key, source time)`。** JSON 的无意义编码次序不要求字节相同;Object Lock 改写后的有效 Versioning 文档必须来自提交结果。
|
||||
|
||||
### 2. 统一状态与排序
|
||||
|
||||
内部仅需一个小状态表示:baseline / live / tombstone,以及比较键和字段源时间。baseline 可携带历史有效配置,但没有真实修改时间;它绝不能被当成删除。复用 CORS 已有设计思路,不改 CORS 本身或扩展为通用框架。
|
||||
|
||||
- 使用 `BucketMetadata` 原始字段时间;字段时间为零时在比较视图中补为 Created,与 `defaultTimestamps()` 一致,不借用会隐藏墓碑时间的 getter 或整记录 `lastUpdate()`。
|
||||
- 在已知 Created 下,零字段时间回退后等于 Created 的状态是 baseline:有有效非空配置的 baseline-live 可作为初始化候选;空 baseline 只是缺省值,不能作为删除或 heal 来源。真正严格早于自身 Created 的字段不是候选;Versioning/Object Lock 的空候选无论时间如何都不参与选源。真实 live/tombstone 的时间必须大于 Created。
|
||||
- 专用 peer 的零时间保留兼容例外:锁内按本地新操作分配时间并限频记录 legacy-zero;不受删除传播开关影响,不在源时间排序保证之内。bulk 零时间仍按现状拒绝。
|
||||
- 非零事件**严格早于**目标桶 Created 才成功/no-op 并记录 before-created,heal 对这种目标跳过。等于目标 Created 的 live 事件可更新仍是 baseline 的目标字段,时间仍保存为源 Created,不盖上到达时间;同时间的空/nil 只算空 baseline,不能删除配置。不同桶世代不能自动合并,需要运维处理,不纳入收敛承诺。
|
||||
- 排序先比较是否为真实状态:任何真实 live/tombstone 都胜 baseline,不能让较晚创建的默认值压过较早的真实修改。真实状态之间再比较源时间,同时间 tombstone 胜 live、live/live 的**稳定比较键字节序较大者胜**。baseline-live 只胜空 baseline,或在 baseline-live 之间按同一稳定键较大者胜;永远不能覆盖真实 live/tombstone。所有候选都是 baseline-live 时仍可确定性初始化并收敛;全部为空 baseline 才安静 found=false。键、状态级别和源时间相同为 no-op。deployment ID 不参与上述比较,也不作为新字段保存。
|
||||
- Quota 使用现有解析结果的 JSON 编码作为比较键。Policy 在 Server 内生成确定性比较表示:既有解析器校验/去重后,对其**完整 JSON 树**的对象键和集合数组递归排序,统一覆盖 Statement、Action/NotAction、Resource/NotResource、Principal、Condition;保留数字类型和精度。比较键必须是已解析策略的纯函数,同一配置从两个站点分别解析也必须得到相同键。普通 `json.Marshal(BucketPolicy)` 不稳定,不能直接作键;不增加依赖不支持的 NotPrincipal 语法,不改 wire/schema。新状态比较仅使用这一套键:不把忽略 Sid 且对 Statement 顺序敏感的 `BucketPolicy.Equals` 再叠加为另一套判等规则,既有公开统计对 Equals 的使用保持不变。
|
||||
- XML 使用有效文档字节,保留大小写和实际内容;Versioning 先应用下述现有 Object Lock 约束。比较器不能依靠字节序方向来补偿保存阶段的隐式改写。
|
||||
|
||||
### 3. 先得到有效状态,再比较与提交
|
||||
|
||||
原始读取、类型处理、比较、修改及保存均在现有 `metadata.lock` 内。内部入口必须传递现有 lock context,避免 legacy migration 再次取锁。
|
||||
|
||||
- 提取并复用 `parseAllConfigs` 已有的 Object Lock→Enabled Versioning 归一规则,使比较视图和真正保存一致;不得扩大 suspend、prefix exclusion、retention 的限制。bulk 先确定实际接受的 Object Lock,再比较该约束下的 Versioning,并在最终提交前应用同一规则。
|
||||
- 以归一化后的有效 `(kind, key, time)` 判定变化;更新了时间也算变化。完全重复不保存、不通知;一次 bulk 校验失败不保存部分结果;成功至多保存一次,解锁后通知。
|
||||
- 可删除字段清空必须基于现有 parse=false 的新加载对象,不能在已经解析且仍持有旧 quota 的对象上执行 Update(nil)。bulk 保留原始读取再解析保存的方式。不要顺手修改 Quota getter 或所有 `parseAllConfigs` 空分支。
|
||||
- 保存函数必须让需要发送 hook 的调用方拿到**本次提交的最终快照**,不能先解锁再读取“最新”状态拼接旧时间。最小做法是让内部 `saveMetadata` 接收元数据指针并回写 Save 的归一化结果,机械更新现有少量调用;对外 `Update/Delete` 签名不变,新增内部提交结果仅供需要该快照的本地 handler/import 使用。不得原地修改已发布到缓存的引用字段。
|
||||
- 保留现有物理桶存在检查、删除锁序、迁移、后台通知上下文。真实历史桶 Created 为零时仅走少见的物理桶 Created 补齐路径;物理桶缺失返回现有错误;创建时间仍未知则不伪造到达时间,报告 indeterminate。
|
||||
|
||||
## 三个实现提交
|
||||
|
||||
### 提交 1:原子 apply、发送一致性与本地时间
|
||||
|
||||
主要文件:`cmd/bucket-metadata-sys.go`、`cmd/bucket-metadata.go`、`cmd/site-replication.go`、`cmd/admin-bucket-handlers.go`,以及实际需要提交快照的本地配置 handler。
|
||||
|
||||
1. 在现有 update/delete 内部路径增加源时间、状态比较和提交结果;公开签名不变。六个 peer handler 移除锁外 getter 判旧,锁内持久化原始源时间。保留 Object Lock 的 legacy Tags 字段载荷回退。
|
||||
2. bulk 对明确提供的六类字段在已有锁内逐字段比较,再原子保存;未提供字段不动,不能循环调用会重入锁的公开 handler。保留 CORS 独立分支与既有行为。
|
||||
3. 六类本地实际写入在锁内分配 `max(UTCNow(), Created+1ns, 当前字段时间+1ns)`。其他类型不变。`enablePeerBucketVersioning` 的实际变更也使用它,只有缺失配置的创建 bootstrap 继续 Created 默认值。
|
||||
接管已有桶时保留原 Created,再执行现有 `SetCreatedAt`;如果 Created 改变,仅将这六类中原本为零或等于原 Created 的默认字段时间调整到新 Created,随后再做既有 versioning/lock bootstrap。原本晚于旧 Created 的真实修改/删除时间及其配置保持不变。不能仅凭 payload 为 nil 判断默认值。该小分支防止 Created 前移时默认值变成假墓碑、后移时历史初值被误判为无效,复用已有接管锁,不重做 #78 的配置保护。代码注释明确限定六类的原因;本轮不改新比较器未读取的其它配置时间。
|
||||
4. quota 本地 PUT 保留零值文档并原样表示该语义;Policy 空策略本地与 peer 一致走删除。需要归一化的本地 handler 从本次提交快照生成 hook;其他内容不发生归一变化的路径可保留既有编码,但必须满足三元组一致性。
|
||||
5. 导入在每桶最终提交锁内,为本次涉及的六类字段生成共同 commitAt,严格大于 Created 和这些字段当前时间且不早于锁内现在。该时间同时用于落盘和 bulk hook,不能沿用 ZIP 开始时间。bulk hook 从最终提交快照构建;若导入的空 Policy 已归一成删除,另外使用现有专用 Policy nil 事件表达它,不能因 `omitempty` 漏发。未导入字段不改,Object Lock 的既有派生 Versioning 修正保留原时间语义;CORS 继续独立时间/事件,其他字段不参与该上界。
|
||||
|
||||
完成条件:六类源时间落盘;旧事件不能越过锁覆盖新状态;四类删除不会被旧 PUT 复活;真实 wire、落盘和出站状态一致;重复无写入;bulk 与 import 没有绕过排序或静默遗漏删除。
|
||||
|
||||
### 提交 2:heal 选源与应用同规则
|
||||
|
||||
主要文件:`cmd/site-replication.go`。
|
||||
|
||||
1. 先过滤空 baseline、无效来源、严格早于自身 Created 的字段和 update-only 空配置,再按上述强弱排序选最大状态;无候选必须显式返回 found=false。保留历史 baseline-live 的初次同步和 heal 能力,消除六处“先 seed map 首项,再过滤默认值”的写法。
|
||||
2. 选源和目标遍历都跳过 `info.Sites` 中不存在的 deployment ID,包括不可达站点的空 ID 占位项;单一 peer 失败记录后继续其它目标,不因 map 顺序放弃健康站点。不改变状态计数或新建重试机制。
|
||||
3. 本地 heal 使用提交 1 的源时间 update/delete;远端仍用原有逐类型 RPC,全部携带源时间,补齐 Tag 的 UpdatedAt。
|
||||
4. 比较完整有效状态,去掉公开 mismatch/payload-only 对写入的门控;同内容较旧时间也同步。对已归一且相同的目标不写入、不发 RPC。Versioning 比较使用与该站点 Object Lock 一致的有效文档;全站点 Lock 状态补齐后不再因旧原始文档产生空转。
|
||||
5. 保留 #91 的计数与公开字段;创建世代冲突、无可用来源通过有限诊断解释。已知 baseline 且各站点无实质差异时安静 no-op。
|
||||
|
||||
完成条件:map 顺序不影响结果;默认空值不再删真配置;历史 baseline-live 可以初始化新站点、不能覆盖真实状态;同内容不同时间、同时间冲突最终一致;Tag 时间完整;Quota 删除后磁盘、缓存、重载一致;不可达占位项不阻断健康目标;完整状态已可见且稳定时,第二轮 heal 无写入/广播。
|
||||
|
||||
### 提交 3:新增删除传播与有界诊断
|
||||
|
||||
只增加一个启动开关,暂定 `MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off/on`,默认 off;实现沿用现有 env 开关写法,不做能力协商。
|
||||
|
||||
| 行为 | off:升级阶段 | on:所有参与节点修复后 |
|
||||
| --- | --- | --- |
|
||||
| 带时间 peer apply、锁内排序与 heal | 使用提交 1/2 | 同左 |
|
||||
| 专用事件零时间 | 兼容应用并记录 legacy-zero | 同左,不新增协议拒绝 |
|
||||
| Tags/SSE/Quota nil payload 时间导出 | 保留旧版条件导出 | 导出 `time > Created` 的真实删除时间 |
|
||||
| Policy 时间导出 | 保留已有行为 | 保留已有行为 |
|
||||
| 初次同步的真实删除 | 保留已有行为 | Policy/Tags/SSE/Quota 都发送专用 nil + source time 事件 |
|
||||
|
||||
开关只控制**新增**删除信息的导出/初次发送,普通本地删除事件照常复制。**off 不等于禁止删除传播:Policy 墓碑原本已导出,修复后在 off 下也照常参与 heal;Tags/SSE/Quota 的新增墓碑信息才被门控。** off 期间这些字段的隐藏墓碑会使 heal 继续尝试过时 RPC,由接收端排序拒绝;这是状态不可见时的已知代价,不承诺第二轮零 RPC,也不为这种正常拒绝增加每轮日志。
|
||||
|
||||
开关不检测或证明远端能力。启用条件是所有参与站点的全部节点已经修复,同一站点配置一致,旧请求排空;旧节点仍在线时保持 off。此隔离有实证依据:旧版接到新增 Quota heal 墓碑会留下已解析缓存残留。
|
||||
|
||||
日志仅保留三个实际原因:legacy-zero、before-created、indeterminate(未知创建时间、缺失/不可达来源或有实际差异却无可用候选)。精确重复、正常旧事件和成功裁决的同时间冲突不记警告。复用 `LogOnceIf`,以稳定的桶/字段/原因作为 key,**错误正文也必须稳定**;变化的时间与 peer 详情放入日志 ReqInfo,沿用现有每小时清理,不新增限流框架、不输出完整策略。
|
||||
|
||||
Server 文档解释启用顺序和回滚:降级前所有修复节点先关开关,然后滚动降级;旧软件缺陷会恢复。点名旧版 Tag heal 无 UpdatedAt 的来源。旧版到达时间污染、legacy-zero 产生的新本地时间以及创建世代分歧无法自动还原;操作者查看状态后在权威站点重新提交需要纠正的配置/删除。历史世代冲突先处理桶身份,不能靠任意站点强刷绕过创建保护。
|
||||
|
||||
## 最小验收矩阵
|
||||
|
||||
| 组 | 必须覆盖 | 证据方式 |
|
||||
| --- | --- | --- |
|
||||
| T1 | 六类 PUT 源时间;四类 DELETE;旧事件不回退;重复无写入;零 quota 三元组一致;带合法 Version 的空 Policy PUT 成功、GET NotFound、专用删除事件与落盘一致 | 真实 admin/S3 路由、ErasureSD/Erasure16、磁盘重载、RPC 捕获 |
|
||||
| T2 | 同时间两种到达顺序结果相同、删除优先;Policy 多集合及 NotAction/NotResource/Condition 反复编码与排列后,两站点独立解析得到相同键;Sid 差异及大整数不被错误合并 | 确定性比较器测试与代表性真实 handler |
|
||||
| T3 | Versioning/Object Lock nil/空 no-op;legacy Tags 载荷回退;Object Lock + prefix exclusion/ExcludeFolders 在普通写、peer、bulk/import 后有效状态一致,第二轮 heal 无额外写入 | 原有 #76/#78 回归加针对性用例 |
|
||||
| T4 | 锁前旧事件排队、较新写先提交后旧事件不得覆盖;不同字段并发均保留 | 复用已有 `lockBucketMetadataAcquireHook` / RMW 屏障、两种 ObjectLayer、目标 race |
|
||||
| T5 | bulk 新/旧/缺省字段混合;真实 JSON 编解码的 nil、空 RawMessage、显式 null、空字符串、空策略、零 quota;非法字段不部分保存;只导入 tags 不修改 Policy/Quota | 真实 bulk 路由、缓存与磁盘 |
|
||||
| T6 | 本地时间胜过已有未来时间;相邻提交不倒序;ZIP 导入期间插入写入,最终落盘和发出事件的状态与时间一致,空 Policy 删除不漏发 | 本地 API、import 路由与 RPC 捕获 |
|
||||
| T7 | map 排列、空 baseline/全无候选;baseline-live 初始化与同级冲突收敛,但不能覆盖真实 live/tombstone;nil@Created 不删除配置;空 update-only;Tag 时间、Quota 清缓存、空 ID、世代冲突 | 确定性 heal 本地/远端用例 |
|
||||
| T8 | 墓碑经保存/缓存失效/重启仍有效;缺桶、排队写入、零 Created;历史字段时间等于 Created 的桶经初次同步、一轮 heal 后一致,第二轮无写入/广播;接管 Created 前移/后移/不变时默认时间仍是 baseline,真实 PUT/DELETE 时间不变;on/off 与 legacy-zero,off 下 Policy 仍 heal、其它隐藏墓碑允许被拒 RPC 但无每轮警告 | ObjectLayer 与进程内旧版 wire/SRInfo 模拟;复用既有删除/迁移回归 |
|
||||
| T9 | 修复版双站点短暂断线、漏发/重复/乱序后六类合法配置收敛;删除传播启用后第二轮稳定无写入;日志确实有界 | 隔离双站点实验,不把未知桶世代或零时间事件算成通过 |
|
||||
|
||||
固定旧版与修复版混合进程仅作一次性升级冒烟,不新增为长期提交门槛。将已有审计用例改成正式确定性回归,不能把遍历 map 的概率复现直接提交。先记录未修复失败,再验证通过;复用原有 Object Lock、adoption、metadata-lock、计数、CORS 回归,并在实现后运行目标 race、仓库必需检查和完整 Go CI。方案审查、局部测试、双站点结果、合并与发布是不同证据。
|
||||
|
||||
## 作者复核结论
|
||||
|
||||
三个提交分别处理写入正确性、heal 收敛和新增删除信息的升级边界;每项对应已证实缺陷或本次修复直接触及的实际路径。缩减了拒绝零时间的新协议行为、混合版本长期测试门槛和新锁屏障;保留已有持久化、通知与缓存语义。
|
||||
|
||||
Opus 第二轮已确认首轮 R1/R2/R3 实质关闭,第三轮确认历史桶初始化边界,第四轮确认接管默认时间修正必要、充分且未扩大范围;最后两轮均为 0 阻断。四轮原文、版本快照、逐项处置与模型调用证据均保留。方案可以进入实现;当前只完成方案和诊断,仍须通过上述实现期验证,不能据此认定问题已修复或可关闭。
|
||||
@@ -0,0 +1,85 @@
|
||||
# #77:桶配置复制修复与验收归档
|
||||
|
||||
归档日期:2026-09-12。对象为 [SILO #77](https://github.com/pgsty/silo/issues/77)。问题真实;来源时间、删除状态和 heal 选源共同决定是否收敛,不能只补一个删除分支。最终实际 Claude Code Opus 5 Max 实现审查结论为 **GO_WITH_NONBLOCKING_NOTES,阻断 0**,完整 cmd 和最终 lint 随后通过。
|
||||
|
||||
本文固定研究与验收时的事实。合并状态以 Issue 关联 PR 为准;主干包含代码不等于镜像、软件包或生产部署已经更新。研究叙述见伴生站的[中文设计记录](https://github.com/pgsty/silo.pgsty.com/blob/main/content/blog/design/bucket-metadata-convergence.zh.md)和[英文设计记录](https://github.com/pgsty/silo.pgsty.com/blob/main/content/blog/design/bucket-metadata-convergence.md),操作契约见 [Server site-replication README](../site-replication/README.md)。
|
||||
|
||||
## 实现为什么最小、必要且足够
|
||||
|
||||
源端 10:00 PUT、10:01 DELETE,目标到 10:05 才收到 PUT。旧实现把字段时间写成 10:05,随后把源时间为 10:01 的删除当作旧事件跳过,返回成功但保留配置。删除后没有导出时间的字段,在漏发后还会被对端旧值恢复。另有锁外判旧、批量入口绕过排序、默认值抢占来源、Tags heal 丢失时间和同内容不更新时间等独立入口。
|
||||
|
||||
| 必要改动 | 少了它会发生什么 | 复用的边界 |
|
||||
| --- | --- | --- |
|
||||
| 在现有整桶锁内读取、比较并保存来源状态 | 锁外判旧仍可覆盖并发的新状态;到达时间继续污染排序 | 既有 `.metadata.bin`、`metadata.lock` 和物理桶存在检查 |
|
||||
| Policy、Tags、SSE、Quota、Versioning、Object Lock 共用确定性比较 | 等时冲突仍依赖到达顺序;heal 与接收端可能选出不同结果 | 既有载荷、字段时间及 Created,不增加 wire 或持久化字段 |
|
||||
| 专用事件、bulk、导入、本地写和 heal 都使用提交后的状态 | 只改一个 handler 会留下旁路;归一化后的 Versioning/Quota 与出站事件可能不同 | 既有解析、保存与复制钩子,bulk 仍是一次原子保存 |
|
||||
| 删除导出开关默认关闭 | 直接对旧版节点导出新增删除状态有已复现的 Quota 缓存风险 | 全部参与节点升级后统一启用,不引入能力协商框架 |
|
||||
| 物理 Created 恢复与初次同步传播 | 历史无创建时间的真实桶会失去六类配置写入能力 | 现有物理探测;目录 mtime 只是近似值,不推断真实桶世代 |
|
||||
| Policy 状态键与 heal 一致;异常按原因去重 | 永久假 mismatch 无法修复;正常空基线噪声或无来源异常静默 | 既有每站点统计与 logger,不增加后台协调系统 |
|
||||
|
||||
真实状态优先于创建基线;真实状态按来源时间、等时删除优先、规范内容键排序。历史 `live@Created` 可以初始化空目标,但空基线不能删除真实配置。本地纠正时间在锁内取 `max(now, Created+1ns, fieldTime+1ns)`;带时间 peer 事件保留来源时间。Versioning/Object Lock 的 nil 保持 no-op,Quota `null`/`{}` 保持零配额配置,空 Policy 归一为删除。
|
||||
|
||||
Policy 编码器保留在 GET/export/peer 是必要的:既有解析器可以保存的负集合策略必须能读回和复制。PUT/import 继续使用同一编码器,避免额外表示分支。没有为了本轮重写 CORS、Lifecycle、对象复制、IAM 或 #91 已完成的计数,也没有修改 SDK、模块依赖或升级协议。
|
||||
|
||||
充分性限定在同一桶世代、有效且可排序的来源状态、全部参与节点升级并开启删除导出的范围内。历史时间污染、零时间兼容事件和桶身份冲突不属于自动恢复承诺。
|
||||
|
||||
## 计划与实际对抗审查
|
||||
|
||||
- [实施前核验与失败复现](issue-77-current.md)、[最终 v4 计划](issue-77-plan.md)。这些文件保留历史状态,不能当成当前待办。
|
||||
- [四轮计划审查与意见处置](issue-77/review/final-review.md):前两轮 NO_GO,后两轮零阻断;保留各版计划、完整最终意见、调用元数据和必要探针。
|
||||
- 三轮实现审查均使用实际 Claude Code `claude-opus-5 --effort max`。模型身份取自实际 assistant 消息,effort 取自显式调用参数。源码在独立快照中审查,审查者读代码和执行结果,没有代为运行这些验收。
|
||||
|
||||
| 实现审查 | 固定源码 | 结论与处置 |
|
||||
| --- | --- | --- |
|
||||
| [首轮完整意见](issue-77/implementation-review/opus5-max-review.md) / [调用记录](issue-77/implementation-review/session.json) | `4089113e3` | GO_WITH_NONBLOCKING_NOTES,条件性阻断 F1;物理 Created、Policy 假 mismatch、诊断和证据缺口随后修复 |
|
||||
| [第二轮意见](issue-77/implementation-review/round-2/opus5-max-review.md) / [调用记录](issue-77/implementation-review/round-2/session.json) | `62cf066ff` | 条件性和无条件阻断均为 0;修正首轮对 Policy 编码器的过宽质疑,进一步补齐无来源诊断和初次同步回归 |
|
||||
| [最终定向意见](issue-77/implementation-review/round-3/opus5-max-review.md) / [调用记录](issue-77/implementation-review/round-3/session.json) | `fcbb93e89` | 阻断 0,确认后续 `461e9a721` 的测试改写等价;读取时尚在运行的 cmd/lint 后续均退出 0 |
|
||||
|
||||
最初实现审查 SHA `4089113e3` 补签 DCO 后对应 `1ee64a8d8`,两者树完全相同。生产代码最终固定在 `fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025`;验收源码 HEAD 为 `461e9a721047c63e1a95f54ad4b533a6b89def30`,只在两个测试文件有格式和等价条件改写,见 [tree 对照](issue-77/implementation-review/round-3/final-tree-equivalence.json)与 [patch](issue-77/implementation-review/round-3/test-style.diff)。本次归档不改变生产代码或正式回归测试。
|
||||
|
||||
审查原文保留当时的判断,不将后续作者修正倒写成评审者已经观察到的结果。首轮误读 README 中既有的空 Policy / 零 Quota 说明,第二轮修正编码器必要性的判断;最终处置以本文和伴生站的逐项表为准。
|
||||
|
||||
## 验收证据
|
||||
|
||||
| 检查 | 执行对象与结果 |
|
||||
| --- | --- |
|
||||
| [完整 cmd](issue-77/implementation-review/round-3/final-cmd.log) / [命令及退出码](issue-77/implementation-review/round-3/final-cmd.json) | `fcbb93e89`,CGO=0,全部通过,包测试 492.776 秒 |
|
||||
| [最终目标 race](issue-77/implementation-review/round-3/final-target-race.log) / [命令](issue-77/implementation-review/round-3/final-target-race.json) | `fcbb93e89`,真实创建时间、诊断、初次同步、接管、Policy 状态和全部 CORS 命名用例通过 |
|
||||
| [测试改写后的 race](issue-77/implementation-review/round-3/final-style-target-race.log) | `461e9a721`,受影响用例通过 |
|
||||
| [build](issue-77/implementation-review/round-3/final-build.json)、[vet](issue-77/implementation-review/round-3/final-vet.json)、[lint](issue-77/implementation-review/round-3/final-lint.json) | 最终生产树 build/vet 通过,`461e9a721` lint 零问题;可选 typos 未安装,按 Makefile 跳过 |
|
||||
| internal、S3 Select race、生成文件、兼容检查、六平台编译 | `62cf066ff` 的 `ci-*-after.log` 作为较早阶段的补充证据,不冒充最终树在全部平台运行通过;归档中的空生成日志本身不证明退出状态 |
|
||||
| [两个真实站点进程](issue-77/implementation-review/round-3/final-runtime.log) / [执行元数据](issue-77/implementation-review/round-3/final-runtime.json) | 干净 `fcbb93e89` 构建:六类历史配置、真实漏发恢复、乱序、四类删除跨重启、两次各 65 秒零 metadata RPC、异常去重、gate=off 与固定旧版的 PUT/DELETE 冒烟均通过 |
|
||||
| [伴生站构建](issue-77/implementation-review/round-3/docs-check.log) | 提交为 `9fa6248` 的文章内容通过 Hugo 严格构建和站内链接检查,EN 1207 / ZH 1219 页;后续合并状态文案单独检查 |
|
||||
|
||||
[归档清单](issue-77/archive-manifest.json)保存每份材料的原始和归档后 SHA-256,以及全部改动源码的 SHA-256。[二进制身份](issue-77/implementation-review/round-3/binary-identity.json)保留实际版本、Go 构建身份和摘要:最终运行二进制来自干净 `fcbb93e89`,SHA-256 为 `4825a801ce0ac48d636d9428ce4cd5c17a20b6cfec0b569de70a124c9c005049`;旧版来自干净基线 `5c5765816`。第三轮审查的 cmd/lint 条件由上述已完成的 JSON 关闭。
|
||||
|
||||
### 反向复现与排除的证据
|
||||
|
||||
- [confirmed-before.log](issue-77/implementation-review/round-2/confirmed-before.log):正式测试叠加 `1ee64a8d8` 的 `erasure-server-pool.go` / `site-replication.go`,真实 ObjectLayer 创建时间恢复及旧 Policy 顺序失败;[修复后](issue-77/implementation-review/round-2/confirmed-after.log)和 [race](issue-77/implementation-review/round-2/confirmed-after-race.log)通过。
|
||||
- [confirmed-diagnostics-before.log](issue-77/implementation-review/round-2/confirmed-diagnostics-before.log):叠加旧诊断路径,普通空基线误报。第一处断言已经终止测试,不能声称它同时证明后面的 Warning 级别和去重断言在旧码失败。
|
||||
- [no-source-before.log](issue-77/implementation-review/round-2/no-source-before.log):直接运行 `62cf066ff`,三种已有无效状态在没有有效来源时缺少诊断;这一次不是旧码 overlay。
|
||||
- [initial-sync-before.log](issue-77/implementation-review/round-2/initial-sync-before.log):保留物理恢复修复,仅叠加 `1ee64a8d8` 的 `site-replication.go`,首次同步出站仍传零 Created。该单测的 peer 只确认 RPC,不证明真实对端或本地 peer 分支已经落盘。
|
||||
|
||||
`findings-before.log` / `findings-after-1.log` 是早期夹具失败,不能证明产品缺陷;过渡阶段的旧 helper 遗留引用和测试格式失败也不能当成通过记录。这些草稿不进入本归档的验收证据。另一次 Codex 调用因使用限额失败,不计入完成审查次数。
|
||||
|
||||
### 重跑真实双站点验收
|
||||
|
||||
[独立 Go 驱动](issue-77/runtime/main.go)与原执行内容相同,仅添加 `ignore` 构建标记及说明,避免进入普通包测试。它只启动回环地址上的一次性实验实例,每站四个数据目录,使用文件内声明的专用实验凭据。给它一个全新目录和自行从固定提交构建的两个二进制:
|
||||
|
||||
```sh
|
||||
go run docs/investigations/issue-77/runtime/main.go \
|
||||
/absolute/new-lab-directory /absolute/silo-fixed /absolute/silo-before
|
||||
```
|
||||
|
||||
固定构建分别为 `fcbb93e89` 和 `5c5765816`,可在独立干净 worktree 使用 `CGO_ENABLED=0 go build -trimpath`;记录 `--version`、`go version -m` 和 SHA-256。省略旧版二进制参数会跳过混合版本冒烟,不能将其报告为执行过。最终删除收敛快照见 [converged.json](issue-77/runtime/converged.json),数组顺序由驱动的 `states` 函数定义。
|
||||
|
||||
原执行环境为 `go1.27.1 darwin/arm64`。因宿主盘可用空间比例触发存储保留阈值,完整 cmd 和进程实验使用独立 16 GiB APFS 测试卷,没有降低生产阈值。临时卷、审查 worktree 和实例进程均已清理。上述是本地可复核的执行证据,不替代 GitHub Actions、Linux 多节点集群、发布制品或生产验证。
|
||||
|
||||
## 剩余边界
|
||||
|
||||
1. `MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off` 是默认值。全部站点全部节点升级、配置一致并排空旧请求后,统一开启并重启,才具有新增 Tags/SSE/Quota 删除的漏发自愈能力。普通删除事件与原有 Policy 删除导出仍保留。
|
||||
2. 到达时间污染、零时间旧事件和桶世代冲突无法凭现有数据重建。物理 Created 只是目录 mtime 的近似值;较早事件仍跳过,由操作者核对权威状态后重新提交纠正。
|
||||
3. 日志按每桶/字段/原因去重,仍可能随桶和有值字段数量增长。本轮不增加全站日志调度。
|
||||
4. 最终审查保留三项非阻断改进:解码失败诊断中的时间可能为零;初次同步单测没有证明本地 peer 分支落盘;未来并发后台日志可能需要更强的测试隔离。生产路径已复核,本轮不为这些建议新增 helper 或 hook。
|
||||
|
||||
归档保留最终报告与调用身份,不发布原始模型推理流、二进制、实验数据卷或无效夹具日志。工作站路径、历史文档链接与非必要 JSON 字段经过整理;哈希清单区分原始产物与归档副本,不能将整理后的报告哈希冒充原文件哈希。
|
||||
@@ -0,0 +1,437 @@
|
||||
{
|
||||
"archived_at": "2026-09-12",
|
||||
"issue": "https://github.com/pgsty/silo/issues/77",
|
||||
"base": "5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"tested_source_head": "461e9a721047c63e1a95f54ad4b533a6b89def30",
|
||||
"reviewed_production_head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"source_sha256": {
|
||||
"buildscripts/rebrand-guard/compat-baseline.json": "7ea6485008dd998373144ebded82483fe2c33ae60712abcf2fba973a7506b3e1",
|
||||
"cmd/admin-bucket-handlers.go": "3aa8fc80cb8f2c4725acb8fbec026d47cd71a60ad58d06f905aa013540b30db7",
|
||||
"cmd/bucket-cors-site-replication_test.go": "2cff52d3857e912526aaae40736b077e6b8bd640e098934b8af0ec76354f3747",
|
||||
"cmd/bucket-metadata-replication.go": "07730f6a19b78a9b51dd9d56696812299b3aedce1a779b23178643e2684ba686",
|
||||
"cmd/bucket-metadata-sys.go": "6d0936f537f104bdae1c460554c5ef6f34e6f057d8eb16569a23f012a9498d60",
|
||||
"cmd/bucket-metadata.go": "2b692e42df36373fb98a74792098e1251ae886f5e2759add91a00909b596d04c",
|
||||
"cmd/bucket-policy-handlers.go": "1ac487ef97f63ab19358212f1d582659b4d6fbd17888f5bb21cd5e5028e783e9",
|
||||
"cmd/bucket-versioning-handler.go": "23ec85d45bacf687ada30ea6a06cb5e6d5d5dd32a81865a24aa52a030759e3ce",
|
||||
"cmd/common-main.go": "c06df6c3051ceb264f7ddbdddea33ebe38a59e0c113b5c4c44906c1c2e08a01b",
|
||||
"cmd/erasure-server-pool.go": "1ebb616ac971a22bd736d1b381845c0011cbc95b18dc273e4a946eec8244c80d",
|
||||
"cmd/site-replication-metadata-gate_test.go": "1f170b84d2d25d67a4386451ce26597a752f96c9215155f3156ac1fd140ead86",
|
||||
"cmd/site-replication-metadata-heal_test.go": "37191261f643dae88fbe5be44da8f08b2c52455245d5c61a2df564ecb7c8bffd",
|
||||
"cmd/site-replication-metadata.go": "5756a5495723a2d9456b1c3ed7875cec789298eefb8230ea57126f9de83c4287",
|
||||
"cmd/site-replication-metadata_test.go": "2898c88d48d9693d1c974ee7fd6846b86fe354f378caa630eac2ad0d9b9edfe8",
|
||||
"cmd/site-replication.go": "5015550411bd4d82119cdb96b7f663fd082e4906baee259b30aedbaa616354c8",
|
||||
"cmd/site-replication_test.go": "d85dd2aa991a6cce1fccfa17205e4a478f4ced00e0585fbe2aee2dbf97884b8a",
|
||||
"docs/site-replication/README.md": "abe39529bf5a864dc7c945fd368b647f3fa2121fb324cea1cf63373eff9683a7"
|
||||
},
|
||||
"original_review_head": "4089113e3edbd21a29be8e6af74662462bcc22af",
|
||||
"equivalent_signed_commit": "1ee64a8d895f0876580d5e27d0ce6facf41c347e",
|
||||
"reviewed_tree": "0d18c940c6dec380dffc977cb5720b4e2384e46a",
|
||||
"environment": "go version go1.27.1 darwin/arm64",
|
||||
"scope": "Historical analysis and plan, final review reports and invocation metadata, selected executed test logs, source/binary identity, and rerunnable loopback lab. Excludes model reasoning streams, binaries, temporary volumes and invalid fixture logs. Paths, historical document links and trailing console whitespace were normalized; JSON billing fields and build-info dependency lists were omitted. Original and archived hashes distinguish these editorial transformations.",
|
||||
"final_verdict": "GO_WITH_NONBLOCKING_NOTES",
|
||||
"blocking_findings": 0,
|
||||
"records": [
|
||||
{
|
||||
"file": "issue-77-current.md",
|
||||
"source_record": "issue-77-current.md",
|
||||
"original_sha256": "702f9fe93703c8d4b79a3e2c4709e7f49614dc20211ed5221f3adbd5e3809e2d",
|
||||
"archived_sha256": "b22b50e584de73cfe2f101d35bb912d314feb7f0e0ce48018e1b4a48dc4cf5c5"
|
||||
},
|
||||
{
|
||||
"file": "issue-77-plan.md",
|
||||
"source_record": "issue-77-plan.md",
|
||||
"original_sha256": "2ee214694f16e5949ed58290364770149aee3748faee3614122fee189ef83add",
|
||||
"archived_sha256": "36ae4f99bbc22827cc9a1a437b413713e61f442a568347eeef836b4245c67081"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/current-tests.log",
|
||||
"source_record": "issue-77/current-tests.log",
|
||||
"original_sha256": "d6ed0ef373a37f8254d387a0757a431c2785b6b9a30c4e06d0034688ca3a41e8",
|
||||
"archived_sha256": "d6ed0ef373a37f8254d387a0757a431c2785b6b9a30c4e06d0034688ca3a41e8"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/issue77_heal_review_test.go.txt",
|
||||
"source_record": "issue-77/issue77_heal_review_test.go.txt",
|
||||
"original_sha256": "f1b164afff783282253660b8fa5c131b0de946a078d370cdedd95c338fa73810",
|
||||
"archived_sha256": "f1b164afff783282253660b8fa5c131b0de946a078d370cdedd95c338fa73810"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/issue77_review_test.go.txt",
|
||||
"source_record": "issue-77/issue77_review_test.go.txt",
|
||||
"original_sha256": "77e557bd098fad1345085b6bca27ff8a733984092c68c6b26d0dc74e5fca8caa",
|
||||
"archived_sha256": "77e557bd098fad1345085b6bca27ff8a733984092c68c6b26d0dc74e5fca8caa"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/adoption-baseline-probe.go.txt",
|
||||
"source_record": "issue-77/review/adoption-baseline-probe.go.txt",
|
||||
"original_sha256": "4cf7aae6763f1ae736af087244dae01e32af1c546923fa782891759967981574",
|
||||
"archived_sha256": "4cf7aae6763f1ae736af087244dae01e32af1c546923fa782891759967981574"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/adoption-baseline-probe.log",
|
||||
"source_record": "issue-77/review/adoption-baseline-probe.log",
|
||||
"original_sha256": "d5ad1bfe7d4faafc7bfc6f55800dc47a7d60b7ec7124cc31ec9ed11b0154bde7",
|
||||
"archived_sha256": "d5ad1bfe7d4faafc7bfc6f55800dc47a7d60b7ec7124cc31ec9ed11b0154bde7"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/adoption-baseline-probe.metadata.json",
|
||||
"source_record": "issue-77/review/adoption-baseline-probe.metadata.json",
|
||||
"original_sha256": "618e81cb5852b08fbc3029d812647b9d2dbe3560349ee2e44b860356e12cffa9",
|
||||
"archived_sha256": "a462ad6b10cb7e2737330eed5d5e0a5f98ac0d664aa6fcf40a1f5f2f073709f6"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/author-followup-findings.md",
|
||||
"source_record": "issue-77/review/author-followup-findings.md",
|
||||
"original_sha256": "b6d60d813702db300313430325046aa89e45071d3cdc20083d82f061b75ab2c4",
|
||||
"archived_sha256": "b6d60d813702db300313430325046aa89e45071d3cdc20083d82f061b75ab2c4"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/decisions-v2.md",
|
||||
"source_record": "issue-77/review/decisions-v2.md",
|
||||
"original_sha256": "52c85f0b45073471e96abe86aa5a182858ff00a5dfe00f290f570f2aad86c5a1",
|
||||
"archived_sha256": "52c85f0b45073471e96abe86aa5a182858ff00a5dfe00f290f570f2aad86c5a1"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/decisions-v3.md",
|
||||
"source_record": "issue-77/review/decisions-v3.md",
|
||||
"original_sha256": "7325864c0a3e93689989612da0427b88f890ef8baf197f609c570636b16a2b85",
|
||||
"archived_sha256": "7325864c0a3e93689989612da0427b88f890ef8baf197f609c570636b16a2b85"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/decisions-v4.md",
|
||||
"source_record": "issue-77/review/decisions-v4.md",
|
||||
"original_sha256": "982b2e802aed883f2b5e211f0e1677388571ccde8e32af878a35b4192c1f4d16",
|
||||
"archived_sha256": "982b2e802aed883f2b5e211f0e1677388571ccde8e32af878a35b4192c1f4d16"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/final-review.md",
|
||||
"source_record": "issue-77/review/final-review.md",
|
||||
"original_sha256": "2410f7b92e2c74b235d54bc155b822675c6b5f9ab0b7226fccca42dc38b0a7ea",
|
||||
"archived_sha256": "2410f7b92e2c74b235d54bc155b822675c6b5f9ab0b7226fccca42dc38b0a7ea"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v1.md",
|
||||
"source_record": "issue-77/review/opus5-max-v1.md",
|
||||
"original_sha256": "8a3b750a261ef520acbe658395d3303cea0835b07184b96022781a2a5d0d4dcf",
|
||||
"archived_sha256": "8a3b750a261ef520acbe658395d3303cea0835b07184b96022781a2a5d0d4dcf"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v1.metadata.json",
|
||||
"source_record": "issue-77/review/opus5-max-v1.metadata.json",
|
||||
"original_sha256": "959a9eac3a8449ef27027fbc65aca55a32b1f46063175d7187922296ecd0a957",
|
||||
"archived_sha256": "d5e3be5c5377adeb88fdcb444ad5b6ee338af91d095027e1c9502298eda51d6d"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v2.md",
|
||||
"source_record": "issue-77/review/opus5-max-v2.md",
|
||||
"original_sha256": "848a476f18ce88208b72d8bf6cb49a2316b2b14dcba64ad66e636fe0a3d70595",
|
||||
"archived_sha256": "848a476f18ce88208b72d8bf6cb49a2316b2b14dcba64ad66e636fe0a3d70595"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v2.metadata.json",
|
||||
"source_record": "issue-77/review/opus5-max-v2.metadata.json",
|
||||
"original_sha256": "ff63843a993830ea48e1a44987390b94613dea99a796453bc91bebca2f4e119f",
|
||||
"archived_sha256": "3cd83d43f461fb1e13bbc4f4163769fa6daf4d151d8442d8615a3e813da38853"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v3.md",
|
||||
"source_record": "issue-77/review/opus5-max-v3.md",
|
||||
"original_sha256": "557ef70288a9a3939842a2f76d63694e2c103a8bfcaa99b7c161ee89e1f58061",
|
||||
"archived_sha256": "557ef70288a9a3939842a2f76d63694e2c103a8bfcaa99b7c161ee89e1f58061"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v3.metadata.json",
|
||||
"source_record": "issue-77/review/opus5-max-v3.metadata.json",
|
||||
"original_sha256": "ad488156f42e33e47ed5d0a267ee69ee82efff69881d0e6400e88cf88a09ef60",
|
||||
"archived_sha256": "1c84da615bccc2f1d7a2aa917ef7af751b0943cc7e413b39f59164a65acc2c98"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v4.md",
|
||||
"source_record": "issue-77/review/opus5-max-v4.md",
|
||||
"original_sha256": "cf10b9d7963ff2b7d4c0fbb1fe744a0e853549eaf7ce675a94cc000f74fe6682",
|
||||
"archived_sha256": "cf10b9d7963ff2b7d4c0fbb1fe744a0e853549eaf7ce675a94cc000f74fe6682"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v4.metadata.json",
|
||||
"source_record": "issue-77/review/opus5-max-v4.metadata.json",
|
||||
"original_sha256": "95bae4427322fc42974a9d94ed5dd6d272a46b6eb669f95669a2fe1ef6ee4d42",
|
||||
"archived_sha256": "60d68b2db11557ee09d60b67748844a56d7b1f323803163ef03d420ad1cc8c42"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/plan-v1.md",
|
||||
"source_record": "issue-77/review/plan-v1.md",
|
||||
"original_sha256": "0218a2ca59750acd6a24dc5ece1d55a19762a863f1a2de8edc6ffc9b4ab072e0",
|
||||
"archived_sha256": "97d88bbd010cbd19744c2e2fee0fed8d3713ca553b5d0f06161299772ea8104b"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/plan-v2.md",
|
||||
"source_record": "issue-77/review/plan-v2.md",
|
||||
"original_sha256": "2b2c712b17f757ca1a8501714c6c2472bd442a6be50b29d92f2d87e23c412ba8",
|
||||
"archived_sha256": "b193ee6e47b24576f3f4d06608f1bcd0f7f2b0c27ef782ea4f74ee3706f7aa4d"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/plan-v3.md",
|
||||
"source_record": "issue-77/review/plan-v3.md",
|
||||
"original_sha256": "8c4ab51a397246a07bb1cc967df3377d26ac084b7928191eb8fecacf6e7c3aed",
|
||||
"archived_sha256": "fb3b37ab9dd2f4ab12b8050293603ed589645a730f1e23c81e2f7162ca46f7d2"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/plan-v4.md",
|
||||
"source_record": "issue-77/review/plan-v4.md",
|
||||
"original_sha256": "fbfd37498ab32a589d06254f32182869d5d49fc97722416c46c10b41e5f77816",
|
||||
"archived_sha256": "8624e9b86d3cb6daa497d43ac8cb50318ebabdd7eb4bb7c605e0720522a73bd9"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/policy-encoding-probe.go.txt",
|
||||
"source_record": "issue-77/review/policy-encoding-probe.go.txt",
|
||||
"original_sha256": "4e07cba70bdc329a0003ca0b6711886a6de22323293e8de3625cdc838ccaf537",
|
||||
"archived_sha256": "4e07cba70bdc329a0003ca0b6711886a6de22323293e8de3625cdc838ccaf537"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/wire-state-probe.go.txt",
|
||||
"source_record": "issue-77/review/wire-state-probe.go.txt",
|
||||
"original_sha256": "cafc6e2d482cc8e178ae3e17651410e3070049cd60009f0edd30fbac29e1309c",
|
||||
"archived_sha256": "cafc6e2d482cc8e178ae3e17651410e3070049cd60009f0edd30fbac29e1309c"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/wire-state-probe.log",
|
||||
"source_record": "issue-77/review/wire-state-probe.log",
|
||||
"original_sha256": "5077cd264a50cc05fe9dd628a6e98a3b0aaf94e04b65b712563191e11e4ca9f1",
|
||||
"archived_sha256": "5077cd264a50cc05fe9dd628a6e98a3b0aaf94e04b65b712563191e11e4ca9f1"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/opus5-max-review.md",
|
||||
"source_record": "implementation-review/opus5-max-review.md",
|
||||
"original_sha256": "e40dad6e1969898e35c82b7fd24517a5ee714d603a54fe3598b9e69c20922118",
|
||||
"archived_sha256": "e40dad6e1969898e35c82b7fd24517a5ee714d603a54fe3598b9e69c20922118"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/session.json",
|
||||
"source_record": "implementation-review/session.json",
|
||||
"original_sha256": "c347259a0a501242eb787319d43fe16c117d021ea4e418ce74b09b6c9c33ad08",
|
||||
"archived_sha256": "d26e9c3a7f3d0ba3d34de54e95c64e5939b8d5da1d190bb94d80423b6ca79205"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/opus5-max-review.md",
|
||||
"source_record": "implementation-review/round-2/opus5-max-review.md",
|
||||
"original_sha256": "306a856010f7f9bd1cbec39aba83236978f5a811c98f7fc3f8a08217f72de111",
|
||||
"archived_sha256": "306a856010f7f9bd1cbec39aba83236978f5a811c98f7fc3f8a08217f72de111"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/session.json",
|
||||
"source_record": "implementation-review/round-2/session.json",
|
||||
"original_sha256": "64113634f4e8d0525ed17dd74d092c74419d172aa94b501187daae8ebc27e279",
|
||||
"archived_sha256": "4f4dfe9c50b43b64f15fad830c39c116a296c302655d2c718475249521a68f19"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/opus5-max-review.md",
|
||||
"source_record": "implementation-review/round-3/opus5-max-review.md",
|
||||
"original_sha256": "9106938a7a34cfbc6a11d372dd5ffb71ae7417a77a56d10bbed178f539de8fba",
|
||||
"archived_sha256": "9106938a7a34cfbc6a11d372dd5ffb71ae7417a77a56d10bbed178f539de8fba"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/session.json",
|
||||
"source_record": "implementation-review/round-3/session.json",
|
||||
"original_sha256": "28944efc7a7b2c5451beedf0a05202ffc23b29fad2d9bd04cc89fad3d72184b9",
|
||||
"archived_sha256": "bf31d2dbd8009f0a966c0e820dfdbd5a2820e8b36e71e615f783932fd4ecc9de"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/confirmed-before.log",
|
||||
"source_record": "implementation-review/round-2/confirmed-before.log",
|
||||
"original_sha256": "af2df1c296c3377edb6aae45caa9fafd5e620ffb5a09438c15ca50e3d2cb1c10",
|
||||
"archived_sha256": "af2df1c296c3377edb6aae45caa9fafd5e620ffb5a09438c15ca50e3d2cb1c10"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/confirmed-after.log",
|
||||
"source_record": "implementation-review/round-2/confirmed-after.log",
|
||||
"original_sha256": "0c8be7325817049afe2bd2d3ed668e61cbde2887a443b988b8defbf12304b5bc",
|
||||
"archived_sha256": "0c8be7325817049afe2bd2d3ed668e61cbde2887a443b988b8defbf12304b5bc"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/confirmed-after-race.log",
|
||||
"source_record": "implementation-review/round-2/confirmed-after-race.log",
|
||||
"original_sha256": "da2725133b6c1b41a50a92700ff6adaf6dae7e9ac9d6b5812844715fd2b70e55",
|
||||
"archived_sha256": "da2725133b6c1b41a50a92700ff6adaf6dae7e9ac9d6b5812844715fd2b70e55"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/confirmed-diagnostics-before.log",
|
||||
"source_record": "implementation-review/round-2/confirmed-diagnostics-before.log",
|
||||
"original_sha256": "3d2c1325f1612c9ce79172b148d1ae02cf5443087270e9caeb46046fec1ea2c8",
|
||||
"archived_sha256": "3d2c1325f1612c9ce79172b148d1ae02cf5443087270e9caeb46046fec1ea2c8"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/no-source-before.log",
|
||||
"source_record": "implementation-review/round-2/no-source-before.log",
|
||||
"original_sha256": "e6bac0d117eb195b0ff67ef55eaff302c5f7ee36abf236fdcb69486330b31185",
|
||||
"archived_sha256": "e6bac0d117eb195b0ff67ef55eaff302c5f7ee36abf236fdcb69486330b31185"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/initial-sync-before.log",
|
||||
"source_record": "implementation-review/round-2/initial-sync-before.log",
|
||||
"original_sha256": "f200f9b961baf20e48f22b50cebd82525dd4647ea42203179d140b5474a28427",
|
||||
"archived_sha256": "f200f9b961baf20e48f22b50cebd82525dd4647ea42203179d140b5474a28427"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/docs-check.json",
|
||||
"source_record": "implementation-review/round-3/docs-check.json",
|
||||
"original_sha256": "76747f4d197d250e6bd3aaa1eba5a7013e56b655f10d143b984faed9d22b503b",
|
||||
"archived_sha256": "322f206ca48816c415ba29f9c67935c0eba038823eddfac8fb7e298ff50bda4a"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/docs-check.log",
|
||||
"source_record": "implementation-review/round-3/docs-check.log",
|
||||
"original_sha256": "af8752f3d8a072954dc6de2b8c72a4054cffbe3583aeae225a8d6623fc95e9fa",
|
||||
"archived_sha256": "a1d67bf392e45da04af9d819a5d76cec98006d1fc78c1a4cce486eb305bdc3ac"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-build.json",
|
||||
"source_record": "implementation-review/round-3/final-build.json",
|
||||
"original_sha256": "924df2a034af81acfe8961489664fa213791096db637e962ced6b78ba7ea8388",
|
||||
"archived_sha256": "c5de13eceee16b23886290fbe351a5269735a549723ed400ddb3e5f0bf7c88e5"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-build.log",
|
||||
"source_record": "implementation-review/round-3/final-build.log",
|
||||
"original_sha256": "d5d78e4d1a5b0dc5bc6042ae94ad64aaf2c500ff3e9091ea1dc9dbdfff04b263",
|
||||
"archived_sha256": "b212590ec72fc2489ea00c36458b3ce900b5b23543c7b141d211ac7b4b06f07b"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-cmd.json",
|
||||
"source_record": "implementation-review/round-3/final-cmd.json",
|
||||
"original_sha256": "431f663b748e81b9c311fa1b931483fe0b1ed09dec796b0894bb5d4a37f5f6b9",
|
||||
"archived_sha256": "39a02d590c9615721fbded8e95cc3b04ebacb32ba5c943d8e9a6f55d912c80c7"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-cmd.log",
|
||||
"source_record": "implementation-review/round-3/final-cmd.log",
|
||||
"original_sha256": "681576adac6d627140a791a22085fc520b61f4a6e04172bc7fb5d0f3967e9c40",
|
||||
"archived_sha256": "67723bedd3472d22a2e3f043e48fdab9bc5e8a212d932c513d44d61cc13eb09d"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-lint.json",
|
||||
"source_record": "implementation-review/round-3/final-lint.json",
|
||||
"original_sha256": "1dc2b38de9c4f37983e45e11ad5f416810f0c96e2cfeeff64b25846544918751",
|
||||
"archived_sha256": "35229aa6e418c54072abf141b4296be491a43cdba87c2f56f853076d78d66559"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-lint.log",
|
||||
"source_record": "implementation-review/round-3/final-lint.log",
|
||||
"original_sha256": "40eb5ec64ae64b73489fc7b6e145aabfea6554e0c73fa22be7b4a8201bb63d18",
|
||||
"archived_sha256": "d4b9455691ff7bcce76a0d886f603eda2179e7fa4e408b8e3bb987d693987c3a"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-runtime.json",
|
||||
"source_record": "implementation-review/round-3/final-runtime.json",
|
||||
"original_sha256": "6f76d83dbf9749f9dde0d6386f0b8a6b52cda6677c17ef9d739f05ea9379c52f",
|
||||
"archived_sha256": "b57c593e1023a0226e7950c142d5bd9a231832a9e7aefc7d9d584075b70b9aca"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-runtime.log",
|
||||
"source_record": "implementation-review/round-3/final-runtime.log",
|
||||
"original_sha256": "4c0137bbe554426026e1d4c8baf48e28368e2450e886c506e0055e0591cd5e5e",
|
||||
"archived_sha256": "2b6228f3c6003e896193be17a7c94970992c1fbcbaba013b1984348bf772bc1f"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-style-target-race.json",
|
||||
"source_record": "implementation-review/round-3/final-style-target-race.json",
|
||||
"original_sha256": "09b77aeb0d720de59ea5b1c31f017e8bf32e89c8dc0d4ecc3f35704f21bc0863",
|
||||
"archived_sha256": "075c8d7393164e5b43364d25c163f5da8675f5b69c67e7523a579943a710aaca"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-style-target-race.log",
|
||||
"source_record": "implementation-review/round-3/final-style-target-race.log",
|
||||
"original_sha256": "3089ad9a1c7283a23853a137311728598a7e1ae3bdbbf16e5f000e1149a7e12f",
|
||||
"archived_sha256": "a2609e9a889bcc68828e58c15967bb04820a92462c3f94de86ea584e33a6660a"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-target-race.json",
|
||||
"source_record": "implementation-review/round-3/final-target-race.json",
|
||||
"original_sha256": "68dfcad5fc63d5981065f86fb00482cc466c46f275c4c340095c26ad719f079c",
|
||||
"archived_sha256": "5fb25fa150e66478b2d170faeed3374d71325311f7e625996fe1653cfc957b66"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-target-race.log",
|
||||
"source_record": "implementation-review/round-3/final-target-race.log",
|
||||
"original_sha256": "b437b206ebd12fff304f9cdb59b39f25fd0ecf54e9f510aca98c444b0b516c89",
|
||||
"archived_sha256": "a5b6b6337ebbcf0eca02c2ca15bb1fa562d39ab6fe29f41cd304dd2979976f59"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-tree-equivalence.json",
|
||||
"source_record": "implementation-review/round-3/final-tree-equivalence.json",
|
||||
"original_sha256": "f7d1563d2f675ae282b4bec0bcedcc9809b0a0c985332d862c61ff4b8c75a59f",
|
||||
"archived_sha256": "f7d1563d2f675ae282b4bec0bcedcc9809b0a0c985332d862c61ff4b8c75a59f"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-vet.json",
|
||||
"source_record": "implementation-review/round-3/final-vet.json",
|
||||
"original_sha256": "934ae2812aef8b8635517f838a07cc81dc4780603d621770f9a150bf00d43561",
|
||||
"archived_sha256": "be1b653811da4f6503f329f7e7c8b321b3426107548f0079c8560b177bc0c37f"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-vet.log",
|
||||
"source_record": "implementation-review/round-3/final-vet.log",
|
||||
"original_sha256": "f8166088e218c7c8afa25988c02cee77ec58b552f531121a07959b31bd97ebbb",
|
||||
"archived_sha256": "012d45737551ed5ffdccc3b3535ce0be152a9e9f869f50779d5da04e965a9936"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/test-style.diff",
|
||||
"source_record": "implementation-review/round-3/test-style.diff",
|
||||
"original_sha256": "69a7f7efbb17d8dcdd2a0d7f91098569a48d7f1aeeecb6d2aefe04b6047a9025",
|
||||
"archived_sha256": "69a7f7efbb17d8dcdd2a0d7f91098569a48d7f1aeeecb6d2aefe04b6047a9025"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/ci-crosscompile-after.log",
|
||||
"source_record": "implementation-review/ci-crosscompile-after.log",
|
||||
"original_sha256": "68ef16136fabb16babd8fa26d2e3fcf6ffddac30002f874f7b9dfdd61961e82f",
|
||||
"archived_sha256": "68ef16136fabb16babd8fa26d2e3fcf6ffddac30002f874f7b9dfdd61961e82f"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/ci-gen-after.log",
|
||||
"source_record": "implementation-review/ci-gen-after.log",
|
||||
"original_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"archived_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/ci-internal-after.log",
|
||||
"source_record": "implementation-review/ci-internal-after.log",
|
||||
"original_sha256": "775e5b2b35fda828ffd69f47902e065f58aa2e2ecd915ace9e50f4085aa2d0c4",
|
||||
"archived_sha256": "775e5b2b35fda828ffd69f47902e065f58aa2e2ecd915ace9e50f4085aa2d0c4"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/ci-lint-after.log",
|
||||
"source_record": "implementation-review/ci-lint-after.log",
|
||||
"original_sha256": "cd29668fa61e94079dda874e10920623279083c491798fa9cbdb903ebeee4d82",
|
||||
"archived_sha256": "cd29668fa61e94079dda874e10920623279083c491798fa9cbdb903ebeee4d82"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/ci-s3select-after.log",
|
||||
"source_record": "implementation-review/ci-s3select-after.log",
|
||||
"original_sha256": "30fc4888ba394ef0daa6c76fa497c28086b7307ec30d195fe4f2b725badc59c9",
|
||||
"archived_sha256": "30fc4888ba394ef0daa6c76fa497c28086b7307ec30d195fe4f2b725badc59c9"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/ci-verify-after.log",
|
||||
"source_record": "implementation-review/ci-verify-after.log",
|
||||
"original_sha256": "2b6860f20bdfd453bcc299f8b57a117cd698bab77d6a474913e31794c3937bd3",
|
||||
"archived_sha256": "2b6860f20bdfd453bcc299f8b57a117cd698bab77d6a474913e31794c3937bd3"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/binary-identity.json",
|
||||
"source_record": "implementation-review/round-3/binary-identity.json",
|
||||
"original_sha256": "ffdb768a1931388f174eaca0980cc840015a8e0995b5509ef68718d10044f5e5",
|
||||
"archived_sha256": "d4ea7abc1f419e834746820b5b2d2f1b3264e42114161e7c6803f4e952fcf17d"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/runtime/converged.json",
|
||||
"source_record": "runtime-reviewed/converged.json",
|
||||
"original_sha256": "d6d97bc01a12fd624af37432e9558356255b5cd9d4333eaa616e39998c2ce94f",
|
||||
"archived_sha256": "d6d97bc01a12fd624af37432e9558356255b5cd9d4333eaa616e39998c2ce94f"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/runtime/main.go",
|
||||
"source_record": "twosite/main.go",
|
||||
"original_sha256": "ec63a5e2553907a826727af416145057215adabfb9fa2839861f0c05b7cc2a8f",
|
||||
"archived_sha256": "023459a3873753d5a373c2ba6c078d690c9a2a18c31efa98ecd5f4976ecaba4a"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
=== RUN TestQueuedMetadataUpdateAfterDelete
|
||||
=== RUN TestQueuedMetadataUpdateAfterDelete/expiry=false
|
||||
=== RUN TestQueuedMetadataUpdateAfterDelete/expiry=true
|
||||
--- PASS: TestQueuedMetadataUpdateAfterDelete (0.43s)
|
||||
--- PASS: TestQueuedMetadataUpdateAfterDelete/expiry=false (0.22s)
|
||||
--- PASS: TestQueuedMetadataUpdateAfterDelete/expiry=true (0.21s)
|
||||
=== RUN TestIssue77CurrentHealInputs
|
||||
=== RUN TestIssue77CurrentHealInputs/ErasureSD/quota-tombstone-cache
|
||||
issue77_heal_review_test.go:58: QUOTA_CACHE: disk has no quota, cache still enforces 1024
|
||||
=== RUN TestIssue77CurrentHealInputs/ErasureSD/same-payload-time-barrier
|
||||
issue77_heal_review_test.go:79: BARRIER_NOT_HEALED: same payload remains at 2026-09-12 06:59:55.50105 +0800 CST, latest is 2026-09-11 23:00:55.50105 +0000 UTC
|
||||
=== RUN TestIssue77CurrentHealInputs/ErasureSD/creation-default-selection
|
||||
issue77_heal_review_test.go:105: DEFAULT_SELECTED: creation-default erased valid policy in 5/32 heal rounds
|
||||
=== RUN TestIssue77CurrentHealInputs/ErasureSD/tag-remote-source-time
|
||||
issue77_heal_review_test.go:144: TAG_WIRE_TIME: sent 0001-01-01 00:00:00 +0000 UTC, want 2026-09-11 23:00:55.50105 +0000 UTC
|
||||
=== RUN TestIssue77CurrentHealInputs/Erasure/quota-tombstone-cache
|
||||
issue77_heal_review_test.go:58: QUOTA_CACHE: disk has no quota, cache still enforces 1024
|
||||
=== RUN TestIssue77CurrentHealInputs/Erasure/same-payload-time-barrier
|
||||
issue77_heal_review_test.go:79: BARRIER_NOT_HEALED: same payload remains at 2026-09-12 06:59:55.71847 +0800 CST, latest is 2026-09-11 23:00:55.71847 +0000 UTC
|
||||
=== RUN TestIssue77CurrentHealInputs/Erasure/creation-default-selection
|
||||
issue77_heal_review_test.go:105: DEFAULT_SELECTED: creation-default erased valid policy in 2/32 heal rounds
|
||||
=== RUN TestIssue77CurrentHealInputs/Erasure/tag-remote-source-time
|
||||
issue77_heal_review_test.go:144: TAG_WIRE_TIME: sent 0001-01-01 00:00:00 +0000 UTC, want 2026-09-11 23:00:55.71847 +0000 UTC
|
||||
--- FAIL: TestIssue77CurrentHealInputs (1.21s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/ErasureSD/quota-tombstone-cache (0.01s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/ErasureSD/same-payload-time-barrier (0.00s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/ErasureSD/creation-default-selection (0.08s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/ErasureSD/tag-remote-source-time (0.00s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/Erasure/quota-tombstone-cache (0.04s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/Erasure/same-payload-time-barrier (0.02s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/Erasure/creation-default-selection (0.83s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/Erasure/tag-remote-source-time (0.02s)
|
||||
=== RUN TestIssue77CurrentPeerCheckBeforeLock
|
||||
=== RUN TestIssue77CurrentPeerCheckBeforeLock/ErasureSD
|
||||
issue77_heal_review_test.go:209: CHECK_OUTSIDE_LOCK: queued older event replaced newer committed tags with "<Tagging><TagSet><Tag><Key>key</Key><Value>older</Value></Tag></TagSet></Tagging>"
|
||||
=== RUN TestIssue77CurrentPeerCheckBeforeLock/Erasure
|
||||
issue77_heal_review_test.go:209: CHECK_OUTSIDE_LOCK: queued older event replaced newer committed tags with "<Tagging><TagSet><Tag><Key>key</Key><Value>older</Value></Tag></TagSet></Tagging>"
|
||||
--- FAIL: TestIssue77CurrentPeerCheckBeforeLock (0.30s)
|
||||
--- FAIL: TestIssue77CurrentPeerCheckBeforeLock/ErasureSD (0.01s)
|
||||
--- FAIL: TestIssue77CurrentPeerCheckBeforeLock/Erasure (0.07s)
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/policy
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.017159 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/tags
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.029759 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.03401 +0800 CST
|
||||
issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/sse
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.04395 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.048028 +0800 CST
|
||||
issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/quota
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.057746 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.061453 +0800 CST
|
||||
issue77_review_test.go:112: older source PUT did not resurrect config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/versioning
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.069105 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
|
||||
issue77_review_test.go:87: nil payload is correctly a no-op
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/objectlock
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.077601 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
|
||||
issue77_review_test.go:87: nil payload is correctly a no-op
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/policy
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.247526 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/tags
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.369557 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.394147 +0800 CST
|
||||
issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/sse
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.466111 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.489771 +0800 CST
|
||||
issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/quota
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.562629 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.590096 +0800 CST
|
||||
issue77_review_test.go:112: older source PUT did not resurrect config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/versioning
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.640941 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
|
||||
issue77_review_test.go:87: nil payload is correctly a no-op
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/objectlock
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.690024 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
|
||||
issue77_review_test.go:87: nil payload is correctly a no-op
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion (0.78s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/policy (0.01s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/tags (0.01s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/sse (0.01s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/quota (0.01s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/versioning (0.01s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/objectlock (0.01s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/policy (0.12s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/tags (0.10s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/sse (0.10s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/quota (0.08s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/versioning (0.05s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/objectlock (0.05s)
|
||||
=== RUN TestIssue77CurrentBulkApplyOrdering
|
||||
=== RUN TestIssue77CurrentBulkApplyOrdering/ErasureSD
|
||||
issue77_review_test.go:143: BULK_STALE_OVERWRITE: older bulk event overwrote newer config, value="<Tagging><TagSet><Tag><Key>key</Key><Value>old</Value></Tag></TagSet></Tagging>" time=2026-09-12 08:48:57.792608 +0800 CST
|
||||
=== RUN TestIssue77CurrentBulkApplyOrdering/Erasure
|
||||
issue77_review_test.go:143: BULK_STALE_OVERWRITE: older bulk event overwrote newer config, value="<Tagging><TagSet><Tag><Key>key</Key><Value>old</Value></Tag></TagSet></Tagging>" time=2026-09-12 08:48:57.880379 +0800 CST
|
||||
--- FAIL: TestIssue77CurrentBulkApplyOrdering (0.27s)
|
||||
--- FAIL: TestIssue77CurrentBulkApplyOrdering/ErasureSD (0.01s)
|
||||
--- FAIL: TestIssue77CurrentBulkApplyOrdering/Erasure (0.05s)
|
||||
=== RUN TestPeerBucketAdoptionPreservesLockAndVersioningConfigs
|
||||
--- PASS: TestPeerBucketAdoptionPreservesLockAndVersioningConfigs (0.30s)
|
||||
=== RUN TestPeerBucketAdoptionBootstrapsMissingConfigs
|
||||
--- PASS: TestPeerBucketAdoptionBootstrapsMissingConfigs (0.24s)
|
||||
=== RUN TestPeerBucketAdoptionNormalizesVersioningWhenEnablingLock
|
||||
--- PASS: TestPeerBucketAdoptionNormalizesVersioningWhenEnablingLock (0.28s)
|
||||
=== RUN TestPeerBucketAdoptionEnablesSuspendedVersioning
|
||||
--- PASS: TestPeerBucketAdoptionEnablesSuspendedVersioning (0.27s)
|
||||
=== RUN TestPeerBucketObjectLockMetadataCurrentAndLegacyPayloads
|
||||
--- PASS: TestPeerBucketObjectLockMetadataCurrentAndLegacyPayloads (0.30s)
|
||||
=== RUN TestPeerBucketObjectLockMetadataWithoutLockEnabled
|
||||
--- PASS: TestPeerBucketObjectLockMetadataWithoutLockEnabled (0.24s)
|
||||
=== RUN TestSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig
|
||||
--- PASS: TestSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig (0.52s)
|
||||
FAIL
|
||||
FAIL github.com/minio/minio/cmd 7.167s
|
||||
FAIL
|
||||
@@ -0,0 +1,7 @@
|
||||
Testing builds for OS/Arch: linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64
|
||||
--> linux/amd64:github.com/minio/minio
|
||||
--> linux/arm64:github.com/minio/minio
|
||||
--> darwin/amd64:github.com/minio/minio
|
||||
--> darwin/arm64:github.com/minio/minio
|
||||
--> windows/amd64:github.com/minio/minio
|
||||
--> windows/arm64:github.com/minio/minio
|
||||
@@ -0,0 +1,78 @@
|
||||
ok github.com/minio/minio/internal/amztime 5.149s
|
||||
ok github.com/minio/minio/internal/arn 13.830s
|
||||
ok github.com/minio/minio/internal/auth 5.472s
|
||||
ok github.com/minio/minio/internal/bpool 13.244s
|
||||
ok github.com/minio/minio/internal/bucket/bandwidth 12.671s
|
||||
ok github.com/minio/minio/internal/bucket/cors 2.705s
|
||||
ok github.com/minio/minio/internal/bucket/encryption 12.228s
|
||||
ok github.com/minio/minio/internal/bucket/lifecycle 3.223s
|
||||
ok github.com/minio/minio/internal/bucket/object/lock 11.599s
|
||||
ok github.com/minio/minio/internal/bucket/replication 12.817s
|
||||
ok github.com/minio/minio/internal/bucket/versioning 13.157s
|
||||
ok github.com/minio/minio/internal/cachevalue 18.526s
|
||||
? github.com/minio/minio/internal/color [no test files]
|
||||
ok github.com/minio/minio/internal/config 14.365s
|
||||
? github.com/minio/minio/internal/config/api [no test files]
|
||||
? github.com/minio/minio/internal/config/batch [no test files]
|
||||
? github.com/minio/minio/internal/config/browser [no test files]
|
||||
? github.com/minio/minio/internal/config/callhome [no test files]
|
||||
ok github.com/minio/minio/internal/config/compress 15.754s
|
||||
ok github.com/minio/minio/internal/config/dns 17.351s
|
||||
? github.com/minio/minio/internal/config/drive [no test files]
|
||||
ok github.com/minio/minio/internal/config/etcd 18.460s
|
||||
? github.com/minio/minio/internal/config/heal [no test files]
|
||||
ok github.com/minio/minio/internal/config/identity/ldap 19.132s
|
||||
ok github.com/minio/minio/internal/config/identity/openid 19.829s
|
||||
? github.com/minio/minio/internal/config/identity/openid/provider [no test files]
|
||||
? github.com/minio/minio/internal/config/identity/plugin [no test files]
|
||||
? github.com/minio/minio/internal/config/identity/tls [no test files]
|
||||
ok github.com/minio/minio/internal/config/ilm 19.256s
|
||||
? github.com/minio/minio/internal/config/lambda [no test files]
|
||||
ok github.com/minio/minio/internal/config/lambda/event 18.225s
|
||||
? github.com/minio/minio/internal/config/lambda/target [no test files]
|
||||
ok github.com/minio/minio/internal/config/notify 17.465s
|
||||
? github.com/minio/minio/internal/config/policy/opa [no test files]
|
||||
? github.com/minio/minio/internal/config/policy/plugin [no test files]
|
||||
? github.com/minio/minio/internal/config/scanner [no test files]
|
||||
ok github.com/minio/minio/internal/config/storageclass 17.289s
|
||||
ok github.com/minio/minio/internal/config/subnet 17.168s
|
||||
ok github.com/minio/minio/internal/crypto 18.081s
|
||||
ok github.com/minio/minio/internal/deadlineconn 21.703s
|
||||
ok github.com/minio/minio/internal/disk 17.514s
|
||||
ok github.com/minio/minio/internal/dsync 154.956s
|
||||
ok github.com/minio/minio/internal/etag 17.755s
|
||||
ok github.com/minio/minio/internal/event 17.598s
|
||||
ok github.com/minio/minio/internal/event/target 16.108s
|
||||
ok github.com/minio/minio/internal/grid 22.027s
|
||||
ok github.com/minio/minio/internal/handlers 14.030s
|
||||
ok github.com/minio/minio/internal/hash 14.016s
|
||||
? github.com/minio/minio/internal/hash/sha256 [no test files]
|
||||
ok github.com/minio/minio/internal/http 13.661s
|
||||
? github.com/minio/minio/internal/init [no test files]
|
||||
ok github.com/minio/minio/internal/ioutil 14.637s
|
||||
ok github.com/minio/minio/internal/jwt 13.120s
|
||||
ok github.com/minio/minio/internal/kms 12.536s
|
||||
ok github.com/minio/minio/internal/lock 13.029s
|
||||
ok github.com/minio/minio/internal/logger 12.231s
|
||||
? github.com/minio/minio/internal/logger/message/audit [no test files]
|
||||
? github.com/minio/minio/internal/logger/target/console [no test files]
|
||||
? github.com/minio/minio/internal/logger/target/http [no test files]
|
||||
? github.com/minio/minio/internal/logger/target/kafka [no test files]
|
||||
? github.com/minio/minio/internal/logger/target/loggertypes [no test files]
|
||||
? github.com/minio/minio/internal/logger/target/testlogger [no test files]
|
||||
ok github.com/minio/minio/internal/lsync 22.092s
|
||||
? github.com/minio/minio/internal/mcontext [no test files]
|
||||
? github.com/minio/minio/internal/mountinfo [no test files]
|
||||
? github.com/minio/minio/internal/net [no test files]
|
||||
? github.com/minio/minio/internal/once [no test files]
|
||||
ok github.com/minio/minio/internal/pubsub 11.507s
|
||||
ok github.com/minio/minio/internal/rest 10.318s
|
||||
ok github.com/minio/minio/internal/ringbuffer 10.927s
|
||||
ok github.com/minio/minio/internal/s3select 8.665s
|
||||
ok github.com/minio/minio/internal/s3select/csv 8.862s
|
||||
ok github.com/minio/minio/internal/s3select/json 8.547s
|
||||
ok github.com/minio/minio/internal/s3select/jstream 8.936s
|
||||
? github.com/minio/minio/internal/s3select/parquet [no test files]
|
||||
? github.com/minio/minio/internal/s3select/simdj [no test files]
|
||||
ok github.com/minio/minio/internal/s3select/sql 8.637s
|
||||
ok github.com/minio/minio/internal/store 9.594s
|
||||
@@ -0,0 +1,13 @@
|
||||
Installing golangci-lint v2.13.1
|
||||
golangci/golangci-lint info checking GitHub for tag 'v2.13.1'
|
||||
golangci/golangci-lint info found version: 2.13.1 for v2.13.1/darwin/arm64
|
||||
golangci/golangci-lint info installed .bin/golangci/v2.13.1/golangci-lint
|
||||
Running lint check
|
||||
0 issues.
|
||||
typos binary is not found.. skipping..
|
||||
LINT_EXIT=0
|
||||
compatibility manifest: imports=119 env=438 metrics=19 headers=87 routes=223 roots=1 grid=3 storage=15 policy=58 brand=181 sha256=db0d00c26360412a5388be1eb87c2dd763633ae243c558015f7b3ae86c80baaf
|
||||
Silo rebrand compatibility baseline is unchanged
|
||||
Silo delivery and runtime rebrand checks passed
|
||||
docker entrypoint argv compatibility tests passed
|
||||
REBRAND_EXIT=0
|
||||
@@ -0,0 +1,7 @@
|
||||
ok github.com/minio/minio/internal/s3select 2.150s
|
||||
ok github.com/minio/minio/internal/s3select/csv 3.612s
|
||||
ok github.com/minio/minio/internal/s3select/json 2.105s
|
||||
ok github.com/minio/minio/internal/s3select/jstream 2.937s
|
||||
? github.com/minio/minio/internal/s3select/parquet [no test files]
|
||||
? github.com/minio/minio/internal/s3select/simdj [no test files]
|
||||
ok github.com/minio/minio/internal/s3select/sql 2.620s
|
||||
@@ -0,0 +1,2 @@
|
||||
Silo delivery and runtime rebrand checks passed
|
||||
docker entrypoint argv compatibility tests passed
|
||||
@@ -0,0 +1,292 @@
|
||||
# SILO #77 实现深度对抗性代码审查报告
|
||||
|
||||
审查对象 `4089113e3`(基线 `5c5765816`)。只读 worktree,全部结论来自直接阅读 `cmd/` 生产代码、`silo-pkg`/`madmin-go` 依赖源码与调用链,并与证据目录交叉核对。测试 PASS 仅当作"已覆盖的观察",不作正确性证明。
|
||||
|
||||
> 说明:本会话的 Write/ExitPlanMode 工具不可用,报告直接输出于此,未落盘。
|
||||
|
||||
---
|
||||
|
||||
## 0. Verdict
|
||||
|
||||
**GO_WITH_NONBLOCKING_NOTES**
|
||||
|
||||
- **无条件阻断项:0**
|
||||
- **条件性阻断项:1**(F1)——条件:支持矩阵中存在 `.metadata.bin` 缺失或 `Created == 0` 的桶
|
||||
- 其余:F2 (S2)、F3 (S3)、F4 (S3)、F5(最小性)、F6(nit)、F7(范围说明)、F8(证据不足)、F9/F10(残留边界与覆盖缺口)
|
||||
|
||||
我逐条读了六类配置在全部入口的生产路径,**没有**找到在"同一桶世代、Created 已知、合法带源时间事件"范围内会发生回退、删除复活、锁外判旧或重复广播的反例。核心机制是正确的。缺陷集中在两处边缘:零 Created 历史桶被新代码判为不可写(F1,且其自带测试用 stub 掩盖了生产行为),以及 Policy 规范编码排序与既有公开 mismatch 统计口径冲突(F2)。
|
||||
|
||||
---
|
||||
|
||||
## 1. Findings
|
||||
|
||||
### F1 — 零 Created / 无 metadata.bin 的桶:六类配置写入全部硬失败;设计中的"物理桶 Created 补齐"在生产中不可达;配套测试用 stub 掩盖
|
||||
|
||||
**严重度 S1(条件性阻断)/ 否则 S2 · 已证实(源码级链路完整),需运行定量确认**
|
||||
|
||||
位置:
|
||||
- `cmd/bucket-metadata-replication.go:265-278` `ensureBucketMetadataCreated`
|
||||
- `cmd/bucket-metadata-sys.go:181-185`(六类分支入口,失败即 `return err`)
|
||||
- `cmd/erasure-server-pool.go:2280-2285` —— **`GetBucketInfo` 无条件用缓存 `meta.Created` 覆盖物理卷 Created**
|
||||
- `cmd/admin-bucket-handlers.go:1092`(import)、`cmd/site-replication.go:1671`(bulk peer apply)
|
||||
- `cmd/site-replication.go:2156-2166` + `cmd/site-replication-metadata.go:45-55`(初次同步静默跳过)
|
||||
- `cmd/site-replication-metadata-gate_test.go:145-159`(stub)
|
||||
|
||||
最短触发链:
|
||||
1. 桶存在,但 `.minio.sys/buckets/<b>/.metadata.bin` 不存在且无 legacy 配置文件 → `loadBucketMetadataParse` 走 `errConfigNotFound` 分支并**返回 nil error 且 `Created == 0`**(`cmd/bucket-metadata.go:230-297`;注意 `defaultTimestamps()` 只在 `err == nil` 时调用)。
|
||||
2. 启动时 `concurrentLoad`(`cmd/bucket-metadata-sys.go:737-763`)把这个 `Created == 0` 的 meta 写进 `metadataMap`。
|
||||
3. `PUT ?tagging`(或 policy/sse/quota/versioning/object-lock)→ `updateAndParseMetadata` → `ensureBucketMetadataCreated` → `objAPI.GetBucketInfo(..., NoMetadata:true)` → `erasureServerPools.GetBucketInfo` 先取到物理卷时间,**随后被缓存里的 0 覆盖** → `info.Created.IsZero()` → `errors.New("bucket metadata creation time is unknown")`。
|
||||
4. 客户端收到错误。基线版本此处成功(直接赋值 + `saveMetadata`)。
|
||||
|
||||
注:`getAllLegacyConfigs` 在 `cmd/bucket-metadata.go:468` 会用 `info.ModTime` 填 `Created`,所以**有** legacy 配置文件的桶反而没事;**从未设置过任何桶配置**的老桶才是高发场景。
|
||||
|
||||
用户影响:
|
||||
- 六类配置 PUT/DELETE 全部返回错误,**无运维恢复路径**;`mc admin bucket import` 对该桶整体失败(`rpt.SetStatus(bucket, "", err)`);peer bulk apply 返回错误导致该桶复制持续报错。
|
||||
- `syncToAllPeers` 对这类桶**静默跳过全部五类配置**(`state.candidate()` 要求 `valid`,而 `valid = !created.IsZero() && ...`),且**无任何诊断日志** —— 基线版本会发送。
|
||||
|
||||
最小修复(推荐 a):
|
||||
- **(a)** `cmd/erasure-server-pool.go:2282` 改为 `if !meta.Created.IsZero() { bucketInfo.Created = meta.Created }`。一行;恢复计划中"物理桶 Created 补齐路径"的本意;顺带修掉"无 metadata.bin 的桶在 `mc ls` 里创建时间为零"这一既有瑕疵。需复核 `bucketExists`、`hasBucket = !bi.CreatedAt.IsZero()` 等消费点 —— Created 由零变非零对它们都是变好。
|
||||
- **(b)** 另外在 `syncToAllPeers`(`cmd/site-replication.go:2138-2156`)把已在手的 `bucketInfo.Created` 兜底进 `meta.Created`,或在"有内容但 send==false"时打一条 indeterminate,避免静默漏发。
|
||||
|
||||
应补验证:
|
||||
- 把 `TestPeerBucketMetadataUnknownCreated` 的 `"physical-created"` 子用例**去掉 stub**:用真实 ObjectLayer,把 `newBucketMetadata(bucket)`(Created 为零)存盘并让缓存持有它,断言 `globalBucketMetadataSys.Update(ctx, bucket, bucketTaggingConfig, tagXML)` 成功且 `Created` 被补齐为物理时间。**当前实现会失败。**
|
||||
- 一个 `syncToAllPeers` 用例:`Created == 0` 且 Tags 非空的桶,断言初次同步仍发 Tags 事件(或至少有诊断)。
|
||||
|
||||
---
|
||||
|
||||
### F2 — Policy 规范编码对 `Statement` 数组排序,与既有公开统计的顺序敏感 `Equals` 冲突:永久假 mismatch,heal 永不修复
|
||||
|
||||
**严重度 S2(公开状态/可观测性,非数据损失)· 已证实(源码级),需运行确认**
|
||||
|
||||
位置:
|
||||
- `cmd/bucket-metadata-replication.go:110` —— `sort.Slice` 作用于**每个**数组,含顶层 `Statement`
|
||||
- `cmd/bucket-metadata-replication.go:124-161` `canonicalBucketPolicy`
|
||||
- `cmd/site-replication.go:3796` `isBktPolicyReplicated` → `prev.Equals(*p)`(`silo-pkg .../policy/bucket-policy.go:190-194` 按 `Statements[i]` **下标**逐一比较,顺序敏感)
|
||||
- `cmd/site-replication-metadata.go:174` vs `:179` —— 本地 heal 写**源站原始字节**,远端 heal 经 `PeerBucketPolicyHandler` → `canonicalBucketPolicy` 写**规范字节**
|
||||
|
||||
最短反例:
|
||||
1. 站点 A 存在升级前写入的桶策略,statement 顺序不是规范字节序(极常见,例如 Deny 在 Allow 之前)。
|
||||
2. 加入新站点 B:`syncToAllPeers` → `initialBucketConfigReplicationEvent` 发送 **A 的原始字节**,`UpdatedAt = A.PolicyConfigUpdatedAt`。
|
||||
3. B 的 admin 入口解析后调 `PeerBucketPolicyHandler` → `canonicalBucketPolicy` → 存**排序后**字节。
|
||||
4. 此后 A/B 的比较键(canonical)完全相同、时间相同 → `compareBucketConfigStates == 0` → heal 永不写、永不发 RPC;而 `Equals` 因 statement 顺序不同返回 false → `mc admin replicate status` **永久**报 bucket policy mismatch,`ReplicatedBucketPolicies` 少计。
|
||||
|
||||
同机制第二条路径:同一轮 heal 中本地目标拿原始字节、远端目标拿规范字节,三站点集群会出现持久分叉。
|
||||
|
||||
为何是新问题:基线 `PeerBucketPolicyHandler` 用 `json.Marshal(policy)`,集合(Action/Resource/Principal)顺序随机但 **statement 切片顺序被 marshal/unmarshal 保持**,`Equals` 一直成立。
|
||||
|
||||
最小修复(三选一,推荐 a 或 b):
|
||||
- (a) `canonicalBucketPolicyJSON` 不对顶层 `Statement` 数组排序(只排集合数组)——顺序在全链路被保留,两站点独立解析同一文档仍得相同键。
|
||||
- (b) `isBktPolicyReplicated` 改用 `canonicalBucketPolicy` 的键比较,而不是 `Equals`。
|
||||
- (c) `healBucketConfig` 本地分支改写 `incoming.data`(与远端同一编码)——只修本地/远端分叉,**不**修 A 与新站点的分叉。
|
||||
|
||||
应补验证:双站点用例——A 侧直接把 legacy 顺序的策略字节写盘(绕过 canonical 编码器),join B,跑两轮 heal,断言 `SiteReplicationStatus` 不报 policy mismatch。
|
||||
|
||||
---
|
||||
|
||||
### F3 — heal 诊断以 ERROR 级、按"桶 × 字段"输出;正常瞬态也报警,且单一 key 会吞掉真实 RPC 失败
|
||||
|
||||
**严重度 S3 · 已证实**
|
||||
|
||||
位置:`cmd/site-replication-metadata.go:134-145`(两个诊断循环)、`:182-186`(peer 错误)、`cmd/logging.go:19-21`(`replLogOnceIf` 无 errKind ⇒ **ErrorKind**)
|
||||
|
||||
1. 第二个循环对 `!state.valid` 的目标打 `indeterminate / unusable peer`。`valid` 要求对端 `CreatedAt != 0` —— 而**对端还没有这个桶**时 `CreatedAt` 就是 0(`cmd/site-replication.go:3096-3103` 给缺桶站点填零值 `SRBucketInfo`)。于是"桶刚建、尚未传播"这种完全正常的瞬态,对每个桶产生 6 条 **ERROR**。
|
||||
2. 第一个循环在对端 `RemoteTargetConnectionErr`(`cmd/site-replication.go:3020-3026` 填空 ID)时,对**每个本地桶 × 6 字段**打 `missing peer`。一个站点掉线 ≈ 每小时 6×桶数 条 ERROR。
|
||||
3. 四种完全不同的情况(缺 peer / peer 不可用 / peer RPC 失败 / Created 未知)共用同一个 key `"bucket-metadata/<bucket>/<file>/indeterminate"` **且错误正文相同**,而 `logOnceIf` 只按 key+正文去重(`internal/logger/logonce.go:100-119`)。结果:**真正的 heal RPC 失败可能被同桶同字段的"缺 peer"消息顶掉而完全不打印**;基线版本这些失败走 `replLogIf`,必然打印。
|
||||
|
||||
最小修复:`target.CreatedAt.IsZero()`(桶不在对端)与 `found == false` 时不打诊断;把 4 种情况拆成不同 reason(`unreachable`/`peer-error`/`unknown-created`)避免 key 撞车;给 `logBucketConfigReplication` 传 `logger.WarningKind`。
|
||||
|
||||
---
|
||||
|
||||
### F4 — 三处用户可见语义变更未写入文档
|
||||
|
||||
**严重度 S3(文档/兼容)· 已证实(逐条比对 `docs/site-replication/README.md:65-118`)**
|
||||
|
||||
1. **空 Bucket Policy PUT 现在立即按删除处理**:`cmd/bucket-policy-handlers.go:102` + `cmd/bucket-metadata-replication.go:176-180` → `canonicalBucketPolicy` 对 `IsEmpty()` 返回 nil → 落盘 nil → `GetBucketPolicy` 从"200 + 空策略文档"变为 **404 NotFound**。计划 §1 明确写了"需要写入兼容说明",文档里没有。
|
||||
2. **零 Quota (`{}`) 在对端从"被删除"变为"保留 live 文档"**:`cmd/admin-bucket-handlers.go:79-96` 删除了出站 `bucketMeta.Quota = nil` 改写。这是正确的对齐(本地原本就保留 `{}`),但对端 `GetBucketQuotaConfig` 的结果会变。
|
||||
3. **`GET ?policy` 与 `mc admin bucket export` 的 JSON 形态改变**:`cmd/bucket-policy-handlers.go:202`、`cmd/admin-bucket-handlers.go:439` 改用 `canonicalBucketPolicy` → 对象键按字母序、集合数组与 **Statement 数组被排序**。语义等价(S3 策略求值与 statement 顺序无关),但字节级比对输出的工具会看到变化。
|
||||
|
||||
---
|
||||
|
||||
### F5 — 最小性:Policy 规范编码器接入 PUT / GET / export / peer 落盘,并非 #77 不变量所必需
|
||||
|
||||
**非缺陷,最小性判断 · 已证实**
|
||||
|
||||
比较键在 `bucketConfigPayload`(`cmd/bucket-metadata-replication.go:172-180`)里**从已解析策略现算**,与落盘字节无关。即使 PUT/peer 仍用 `json.Marshal`(字节不确定),两站点的键依旧相同,收敛性完全不受影响。
|
||||
|
||||
因此这部分改动带来的是两件**额外**的事:(a) 让原本被 `ActionSet.MarshalJSON`(`silo-pkg .../policy/actionset.go:144-148`,空集合报错)挡掉的 `NotAction`/`NotResource` 策略首次可以写入 —— 这是**功能新增**;(b) 直接导致 F2。
|
||||
|
||||
判断:如果作者**有意**支持负集合策略,应作为独立特性声明并单独记录/测试(目前只有 `site-replication-metadata_test.go:530-541` 一个用例);如果只为 #77,最小做法是 `canonicalBucketPolicy` 只用于比较键。我**不**主张必须删除——它确实修掉了"能存不能读"的潜在坑——但必须承认这是范围外的行为扩张,且未在文档中声明。
|
||||
|
||||
其它可删复杂度(都很小,不影响不变量):
|
||||
- `cmd/bucket-metadata-sys.go:151` 在零值结构体上用 `replicatedBucketConfig(&result.meta, configFile)` 做"是不是这六类"的判断,语义晦涩;一个 `isReplicatedBucketConfig(file) bool` 更清楚。
|
||||
- 修掉 F3 后,`healBucketConfig` 的第二个诊断循环(`:139-145`)可并入主循环。
|
||||
|
||||
---
|
||||
|
||||
### F6 — Nit
|
||||
|
||||
- `isBucketMetadataEqual`(`cmd/site-replication.go:5164`)现在只被测试引用,生产死代码。
|
||||
- `cmd/bucket-metadata-replication.go:36-38` 注释"Object Lock is applied before Versioning"对 bulk apply 循环成立,但 `healBuckets`(`cmd/site-replication.go:4745-4746`)先 heal Versioning 再 heal Object Lock。我推演过仍收敛(最多 2 个周期、无写入环),但注释与 heal 顺序不一致,建议补一句。
|
||||
|
||||
---
|
||||
|
||||
### F7 — 范围说明(非缺陷,但必须进入决策)
|
||||
|
||||
**默认 `MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off` 时,Tags / SSE / Quota 的"漏发删除"不会通过 heal 收敛。** 只有 Policy 墓碑默认导出(`cmd/site-replication.go:3953-3954` 无条件导出;另外三类在 `:3960 / :3980 / :3988` 被 gate 挡住)。
|
||||
|
||||
我完整推演了 off 模式,结论与文档一致:本地真实墓碑在锁内比较时**不会**被旧 PUT 复活(`applyBucketConfig` 用真实落盘状态比较);但持有旧数据的对端会每 30 秒发一次过期 RPC 被拒绝,**状态永不收敛**,直到运维在权威站点重新提交删除,或全站点升级后统一开启开关。
|
||||
|
||||
也就是说:**默认配置下交付的是"顺序正确 + 不复活 + Policy 删除可 heal",不是"四类删除都能自愈"。** 这是 v4 计划的既定取舍(计划 §提交 3 表格),实现与文档都如实写了;我在此只是确保批准时看到这一点。
|
||||
|
||||
---
|
||||
|
||||
### F8 — 证据不足项
|
||||
|
||||
1. **F2 对双站点实验不可见**:`twosite/main.go:141-144` 的所有策略都经 `SRPeerReplicateBucketMeta` → `canonicalBucketPolicy` 写入,两侧都是规范字节,`states()`(`:160-164`)的字节比较自然通过。**从未构造过"升级前旧编码字节"的策略。**
|
||||
2. **F1 被 stub 掩盖**:`cmd/site-replication-metadata-gate_test.go:151-159` 自定义 `GetBucketInfo`,在 `opts.NoMetadata` 时直接返回物理 Created,绕过了 `erasureServerPools.GetBucketInfo` 的缓存覆盖。`"physical-created"` 子用例证明的是一个**生产中不会发生**的行为。
|
||||
3. **"稳态 0 metadata RPC"的边界**:`twosite/main.go:182-190` 只统计 `event.Bucket == bucket` 的单桶、双站点、**gate=on**。结论有效但窄;gate=off 的稳态不为零(文档已声明),多桶/多站点未观测。
|
||||
4. **二进制身份**:`manifest.json` 给了 `silo-final` 的 sha256,但目录里没有 `--version`/build-info 输出(驱动用 `--quiet --json` 启动),`runtime-final.log` 也没有版本行。验收自述"编译信息为 `c8f264f79 + dirty`"是**诚实的**,且日志里的 gate 行为(`gate=on/off`、`off exporter exposed new tombstone`)只能来自第三个提交的生产代码,所以**没有夸大**;但也**不能从证据目录内独立复核**。补一份 `silo-final --version` 或 `go build` 复现即可闭环。
|
||||
5. **baseline.log 行号与最终测试文件不一致**(日志 93/105/117 vs 现文件 96/97/109/121):说明"修复前复现"跑的是测试文件的早期版本。可接受,但严格讲不是同一份用例。
|
||||
|
||||
---
|
||||
|
||||
### F9 — 残留边界:桶世代冲突(计划内已声明,非本轮回归)
|
||||
|
||||
各站点 `Created` 不同时,A 的 baseline-live(`at == A.Created`)在 B 上会被重算为 `real`(`newBucketConfigState` 用**目标**的 `created` 判定,`cmd/bucket-metadata-replication.go:215-219`),可能压过 B 的真实墓碑。计划明确排除在收敛承诺外,且 `AddPeerClusters`(`cmd/site-replication.go:458-466`,"only one cluster may have data")封死了最常见入口。剩余入口:分区期间两站点各自建同名桶,或 #78 接管。基线版本在同场景下是**不确定**的(seed 首个 map 项),所以不算回归。
|
||||
|
||||
---
|
||||
|
||||
### F10 — 覆盖缺口:接管时 `Created` 前移越过真实修改时间
|
||||
|
||||
`rebaseBucketConfigDefaults`(`cmd/bucket-metadata-replication.go:306-318`)只调整"零/等于旧 Created"的默认时间。若 `opts.CreatedAt` 晚于某个**真实**字段时间,该字段变成 `at < Created` ⇒ `valid == false`:作为 heal 源 `candidate()` 为 false;作为 heal 目标 `incoming.valid > current.valid` ⇒ **必被覆盖**,本地真实配置被丢弃。
|
||||
|
||||
在"远端世代胜出"的语义下可以论证这是对的,但 `TestPeerBucketAdoptionRebasesOnlyDefaults`(`cmd/site-replication-metadata_test.go:394-425`)用的 shift 是 ±1h 而真实时间在 `created+2h`,**恰好没有覆盖这一情形**。建议加一个 `shift = +3h` 的子用例,把期望行为固定下来。
|
||||
|
||||
---
|
||||
|
||||
## 2. 六类配置 × 各入口 覆盖判断
|
||||
|
||||
| 配置 | 本地写 | typed peer | bulk | import | initial sync | heal | 接管 | 结论 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| Policy | ✅ `bucket-policy-handlers.go:108/152`,hook 用 `result.meta`+`result.updatedAt` | ✅ `site-replication.go:1716-1730` | ✅ `len(item.Policy)!=0` 判"已提供" | ✅ 共同 `commitAt` + 空策略另发专用 nil 事件(`admin-bucket-handlers.go:1150-1155`) | ✅ gate 控墓碑 | ✅ | ✅ rebase | 通过(F2/F4 为附带) |
|
||||
| Tags | ✅ `bucket-handlers.go:1940/2021` | ✅ `:1733-1747` | ✅ `*string != nil` | ✅ | ✅ gate | ✅ 补齐 `UpdatedAt`(旧版 heal 缺此字段) | ✅ | 通过 |
|
||||
| SSE | ✅ `bucket-encryption-handlers.go:106/199` | ✅ `:1787-1801` | ✅ | ✅ | ✅ gate | ✅ | ✅ | 通过 |
|
||||
| Quota | ✅ `admin-bucket-handlers.go:85-99`(删掉零值改写) | ✅ `:2032-2047` | ✅ `len(item.Quota)!=0` | ✅ | ✅ gate | ✅ 含缓存清除(`parse=false` 修复) | ✅ | 通过 |
|
||||
| Versioning | ✅ `bucket-versioning-handler.go:100-116`,广播用归一后的 `result.meta` | ✅ 空=no-op | ✅ 空=no-op | ✅ 归一后落盘并广播 | ⛔ 不发(由 MakeBucketHook bootstrap + heal 对齐,与基线一致) | ✅ 按**目标** Lock 状态归一后比较 | ✅ `enablePeerBucketVersioning` 用 `localBucketConfigUpdatedAt` | 通过 |
|
||||
| Object Lock | ✅ `bucket-handlers.go:1841-1852` | ✅ 空=no-op,保留 `item.Tags` legacy 回退 | ✅ 空=no-op | ✅ | ✅ | ✅ | ✅ | 通过 |
|
||||
|
||||
逐条核对结论:
|
||||
|
||||
- **来源时间不会变成本地 now**:六个 typed handler 全部删除了锁外 `GetXConfig()` 判旧,统一走 `updateAndParseMetadata(..., &updatedAt)`;非零 `sourceTime` 直接 `sourceTime.UTC()`(`bucket-metadata-sys.go:191-193`)。只有 `sourceTime == nil`(本地写)或为零(legacy 兼容)才分配本地单调时间。✔
|
||||
- **本地写严格单调**:`localBucketConfigUpdatedAt`(`:255-263`)保证 `> Created` 且 `> 当前字段时间`,涵盖"已有未来时间"。✔
|
||||
- **重复/乱序不保存不广播**:`applyBucketConfig` 在 `compare <= 0` 返回 `changed=false`,据此跳过 `saveMetadata` 与 `LoadBucketMetadata`;bulk 用 `changed` 汇总后一次保存。✔
|
||||
我特别核对了所有使用 `result.meta`/`result.updatedAt` 的本地 handler:在这些路径上 `changed` 恒为 true(`localBucketConfigUpdatedAt` 使 `incoming.at` 严格更大,且 `incoming.candidate()` 恒真),所以**不会**出现"零时间 + 空载荷"被当成删除广播出去。这是一个隐式依赖,建议加一行注释或断言固定住。
|
||||
- **整桶 `.metadata.bin` 读-比较-写全在既有分布式锁内**:`updateAndParseMetadata`、`PeerBucketMetadataUpdateHandler`、import 最终提交、`PeerBucketMakeWithVersioningHandler`、CORS 路径,我逐个确认锁的获取在读之前、释放在 `saveMetadata` 之后、fan-out 在释放之后。✔
|
||||
- **无关字段 / CORS / lifecycle 不被覆盖**:所有写路径都是锁内**重新加载**后只改目标字段;import 用 `applyImportedBucketMetadata` 只按 `fields` 拷贝并 `bytes.Clone`。✔
|
||||
- **缓存快照正确**:`saveMetadata` 改收 `*BucketMetadata`,`meta.Save` 内部 `parseAllConfigs` 回写归一化结果,`sys.Set(name, *meta)` 发布的是提交后快照;调用方拿到的 `result.meta` 是同一份值拷贝,不会原地修改已发布引用。✔
|
||||
- **`parse` 被强制为 false 对缓存无害**:`Save()` 先 `parseAllConfigs`,发布到 `metadataMap` 的解析字段是新的;而"删除后 quota 解析残留"正是靠 `parse=false` 的新加载对象修掉的(`parseAllConfigs` 对空 `QuotaConfigJSON` **不会**把 `quotaConfig` 置 nil,`bucket-metadata.go:400-405`)。✔
|
||||
- **锁/返回值/错误处理**:`unlock()` + `locked=false` 模式保留;`updateAndParse`、`Update`、`Delete` 对外签名不变。✔
|
||||
|
||||
---
|
||||
|
||||
## 3. 六字段 state 比较:传递性、交换序无关、幂等收敛
|
||||
|
||||
`compareBucketConfigStates`(`bucket-metadata-replication.go:227-253`)实际是按 `(valid, real, real?at:—, real?isTombstone:—, key)` 的字典序全序。
|
||||
|
||||
- **传递 / 反对称**:是。`real == false` 时不比较 `at`(只比 key),但空 baseline 的 key 为 nil,`bytes.Compare` 使其恒最小,效果等同"空 baseline 永不获胜"。✔
|
||||
- **交换顺序无关**:`latestBucketConfig` 取全序最大值,与 map 遍历顺序无关;`applyBucketConfig` 同样只依赖全序。`TestLatestBucketConfigCandidates` 跑了全部 6 种排列。✔
|
||||
- **幂等收敛**:写入后目标状态等于源状态 ⇒ 下一轮 `compare == 0` ⇒ 不写不发。我另外手工推演了 Object Lock/Versioning 的"Enabled vs Suspended 同时间"场景,归一后双侧都不再写,稳定无振荡。✔
|
||||
- **baseline Created 与真修改的区分**:`real = valid && at.After(created)`,零字段时间先回填为 `created`(与 `defaultTimestamps()` 一致)。✔
|
||||
- **同时间删除胜出**:`:244-250`,仅在 `a.real` 分支内生效,空 baseline 不会借此删配置。✔
|
||||
- **等时不同载荷的稳定键**:
|
||||
- **Policy**:递归排序的规范 JSON + `json.RawMessage` 保留整数精度(测试 `:308-311` 验证 `9007199254740993` 不失真)。我对照了 `silo-pkg .../policy/bucket-policy-statement.go:27-36`,`BPStatement` 的 8 个字段(SID/Effect/Principal/Actions/NotActions/Resources/NotResources/Conditions)**全部覆盖,无字段静默丢失**;`ParseBucketPolicyConfig` 先做 `Validate` 保证 `Principal.MarshalJSON` 不会失败。✔ 但顶层 Statement 排序引出 F2。
|
||||
- **Quota**:`json.Marshal(parseBucketQuota(...))`。零 quota 仍是 live 文档(`candidate()` 靠 `real`,不靠 `len(data)`)。`{}` 与对端重编码字节不同但键相同,不产生 heal 环;`isBktQuotaCfgReplicated` 按解析值比较,不会误报。✔
|
||||
- **Tags / SSE / Object Lock**:有效文档字节,保留大小写与实际内容。✔
|
||||
- **Versioning**:先按**该站点**的 Object Lock 归一(`effectiveBucketVersioning`),键即归一后的文档,避免"保存阶段隐式改写"造成的空转。✔
|
||||
- **空策略**:`bucketConfigPayload` 对 `cfg.IsEmpty()` 返回 `(nil, nil, nil)`,与 peer 侧既有"空策略=删除"解释统一。✔(副作用见 F4-1)
|
||||
- **Object Lock 强制 Versioning 后的持久化 vs 广播**:本地只广播 Object Lock 事件,派生的 Versioning 由对端在 `Save → parseAllConfigs` 自行推导,**内容一致、时间各自保留**;heal 用归一后的有效文档比较,最多一次写入即收敛。与计划一致。✔
|
||||
|
||||
---
|
||||
|
||||
## 4. Created / 时间的异常输入
|
||||
|
||||
| 输入 | 行为 | 判断 |
|
||||
|---|---|---|
|
||||
| Created 为零 / metadata.bin 缺失 | 六类写入报错;初次同步静默跳过 | **F1,回归** |
|
||||
| 字段时间为零 | 回填为 Created ⇒ baseline | ✔ 与 `defaultTimestamps()` 一致 |
|
||||
| 事件时间 == Created | 可更新仍为 baseline 的字段,时间保存为 Created;同时间 nil/空只算空 baseline,不删除 | ✔ 有用例 |
|
||||
| 事件时间 < Created | `before-created`,跳过 + 单条日志;heal 对该目标 `continue` | ✔ |
|
||||
| 未来时间 | 接受为真实状态;后续本地写用 `max(now, at+1ns)` 压过 | ✔ 有用例 |
|
||||
| 各站点 Created 不同 | 见 F9(计划外,已声明) | 残留 |
|
||||
| 空 deployment ID | `latestBucketConfig` / `healBucketConfig` 均 `id == "" \|\| !known` 跳过 | ✔ 有用例 |
|
||||
| peer 报错 / 缺元数据 | `candidate()` 为 false ⇒ 不参与选源;作为目标 `CreatedAt.IsZero()` ⇒ `continue`。**不会被当成删除** | ✔ |
|
||||
| 单个 peer 不可达 | 记录后继续其它目标(`site-replication-metadata.go:182-186`),不因 map 顺序放弃健康站点 | ✔ 有用例(`"broken"`) |
|
||||
|
||||
**"已知历史不可恢复"与"新实现回归"的区分**:
|
||||
- 历史不可恢复(可接受):旧版到达时间污染、legacy-zero 事件产生的新本地时间、桶世代分歧。
|
||||
- 新实现回归(应修):F1 的零 Created 硬失败与初次同步静默漏发;F2 的永久假 mismatch;F3 的 ERROR 噪声与日志互相顶替。
|
||||
|
||||
---
|
||||
|
||||
## 5. 兼容路径与开关
|
||||
|
||||
- **typed 零时间兼容**:`sourceTime != nil && IsZero()` ⇒ 分配本地时间 + 一条 `legacy-zero`(`bucket-metadata-sys.go:186-190`)。不受 gate 影响,不在源时间排序保证内。✔
|
||||
- **bulk 零时间拒绝**:`PeerBucketMetadataUpdateHandler:1610-1612` ⇒ `errInvalidArgument`,与现状一致;import hook 恒带非零 `commitAt`。✔
|
||||
- **省略 vs 显式 null / 空串**:`madmin.SRBucketMeta` 全字段 `omitempty`(`madmin-go v3.0.110 cluster-commands.go:501-538`),因此
|
||||
- `json.RawMessage`(Policy/Quota):省略 ⇒ nil;显式 `null` ⇒ `[]byte("null")`,走 `len(...)!=0` 判为"已提供",再交既有解析器(Policy `null` ⇒ 语义空 ⇒ 删除;Quota `null` ⇒ 零值文档 ⇒ live)。✔ 与计划 §1 完全一致。
|
||||
- `*string`:省略/`null` ⇒ nil ⇒ 不动;空串 ⇒ 已提供且内容为空 ⇒ 四类删除、两类 no-op。✔
|
||||
- 这些由 `TestPeerBucketMetadataWireAtomicity` 通过**真实 admin 路由 + 真实 JSON 编解码**覆盖(`applySRBucketMetaViaAdmin` 走 `registerAdminRouter`),不是结构体直传。这是这批测试里质量最高的一块。
|
||||
- **direct hook 与 heal 的删除传播**:普通 DELETE 事件在 gate off 下照常复制;heal 依赖导出可见性 ⇒ 见 F7。
|
||||
- **初次同步**:保留原五类范围(不含 Versioning),gate 控制是否发送真实墓碑。✔
|
||||
- **gate 覆盖点是否遗漏**:我检查了所有导出/初次发送点 —— `SiteReplicationMetaInfo` 的 Tags/Quota/SSE 三处(`:3960/:3980/:3988`)+ `initialBucketConfigReplicationEvent:51`。Policy 墓碑**故意**不受 gate 控制(原本已导出);Versioning/Object Lock 无墓碑概念。**未发现遗漏的开关覆盖点。** ✔
|
||||
- **滚动升级/降级**:off 时新增墓碑信息不导出,旧节点不会收到它无法正确处理的 Quota heal 墓碑(文档点名了"旧版会留下已解析 quota 残留"这一实证依据)。✔ 但 off **不等于**与旧版同构:修复端的**接收**行为已经变了(锁内排序、空 baseline 不删除、重复不写),这对旧版发来的事件是更安全的方向,混版冒烟也验证了普通 PUT/DELETE 互通。降级路径文档要求先关开关再滚降;唯一可补的一句是"墓碑时间字段本来就在 schema 内,旧版只是不导出,降级不会产生解析错误"。
|
||||
|
||||
---
|
||||
|
||||
## 6. 三个独立判断
|
||||
|
||||
**最小?—— 基本是,有一处可争议的扩张。**
|
||||
生产 Go 代码净增约 37 行,六份重复的 heal / peer apply 被一套 helper 替代;没有新 schema、没有新 wire 字段、没有新锁、没有能力协商、没有迁移系统。唯一超出必要的是 **Policy 规范编码器接入写/读/导出路径**(F5)——它不是收敛所必需的,并且直接导致 F2。其余(`bucketMetadataUpdate` 提交快照、`ensureBucketMetadataCreated`、`rebaseBucketConfigDefaults`、gate)我都能各自对应到一个已证实的缺陷或本次修复直接触及的路径,**没有**可以无损删除的部分。
|
||||
|
||||
**充分?—— 对"已声明的范围"是;对"缺陷标题"不是。**
|
||||
在同一桶世代、Created 已知、gate=on 的前提下,六类配置的源时间、锁范围、删除状态参与 heal、等时冲突裁决、重复/乱序抑制都成立。但两个口子要明说:(1) 默认 gate=off ⇒ Tags/SSE/Quota 的漏发删除不收敛(F7,计划内取舍);(2) Created 未知的桶从"能写"变成"不能写"(F1,计划外回归)。
|
||||
|
||||
**必要?—— 是。**
|
||||
每一项改动都能对应到 `baseline.log` 里的实测失败(SOURCE_TIME / NEWER_DELETE / STALE_RESURRECTION / BULK_STALE_OVERWRITE / CHECK_OUTSIDE_LOCK)或计划中论证过的路径。`rebaseBucketConfigDefaults` 这种看起来最"多余"的小分支,实际是 `Created` 一旦参与 baseline/tombstone 判定后的必然补丁(不加则接管时默认值会变成假墓碑)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 新增设计记录应准确保留的内容
|
||||
|
||||
**关键决策**
|
||||
1. baseline / live / tombstone 三态由 `(Created, 字段时间, 载荷空否)` 推导,**不新增 schema 字段**;`at == Created` 定义为 baseline。代价:必须知道 Created(⇒ F1 的根因)。
|
||||
2. 全序:`valid > real > at > 同时间墓碑胜 > 稳定内容键`。deployment ID **不**参与比较,也不落盘。
|
||||
3. 比较键是"已解析配置的纯函数":Policy 走递归排序的规范 JSON,Quota 走解析后 `json.Marshal`,XML 走有效文档字节,Versioning 先按本站点 Object Lock 归一。
|
||||
4. Versioning / Object Lock 为 update-only:空事件恒为 no-op,空值不是候选。
|
||||
5. 读-比较-写整体在**既有** `metadata.lock` 内;`saveMetadata` 收指针以便调用方拿到提交后快照,保证"落盘状态 / 出站事件 / 源时间"三元组一致。
|
||||
6. 新增删除信息的**导出**用启动开关 gate,默认 off;开关不探测远端能力,启用条件是全站点全节点已升级且旧请求排空。
|
||||
7. 专用 peer 事件的零时间保留兼容例外(分配本地时间 + 限频 `legacy-zero`);bulk 零时间仍按现状拒绝。
|
||||
|
||||
**被拒绝的方案**(记录以免复议)
|
||||
- 新增 HLC / 向量时钟 / 新 wire 字段 / 能力协商 / 通用复制框架 / 新锁或重试系统。
|
||||
- 用 `len(payload)==0 ⇒ 删除` 统一处理 bulk(会把"省略"误判为删除)。
|
||||
- 把 `BucketPolicy.Equals`(忽略 Sid、对 Statement 顺序敏感)叠加为第二套判等规则。
|
||||
- 把混版长期测试作为提交门槛。
|
||||
- 把未修改上游 MinIO 的兼容性当作必需门禁(AGENTS.md:正式支持 PGSTY 栈,上游兼容为尽力而为)。
|
||||
|
||||
**限制**
|
||||
- gate=off 期间 Tags/SSE/Quota 墓碑不可见,不承诺删除收敛,且存在被拒绝的周期性 RPC。
|
||||
- 旧版到达时间污染、legacy-zero 产生的新本地时间、桶创建世代分歧**无法自动反推**,需运维在权威站点重新提交。
|
||||
- `Created` 未知的桶不在收敛承诺内(并且按当前实现直接不可写,见 F1)。
|
||||
- "比较键相同但落盘字节不同"(Quota 原始 JSON、Policy 旧编码)是允许的稳定状态,公开 mismatch 统计必须与比较键口径一致(见 F2)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 建议处置顺序
|
||||
|
||||
1. 判定 F1 的条件(支持矩阵里有没有 `Created == 0` / 无 `.metadata.bin` 的桶)。有 ⇒ 先修再合。
|
||||
2. 修 F2(推荐:不排序顶层 `Statement`,或让 `isBktPolicyReplicated` 改用规范键)。
|
||||
3. 修 F3(ERROR→Warning、缺桶不报、reason 拆分)。
|
||||
4. 补 F4 的三条文档。
|
||||
5. 补测试:F10 的接管用例、F8-1 的 legacy 策略字节双站点用例、F8-2 去 stub 的 Created 用例、F8-4 的二进制身份记录。
|
||||
6. F5 / F6 由作者判断,可留作后续。
|
||||
@@ -0,0 +1 @@
|
||||
ok github.com/minio/minio/cmd 6.864s
|
||||
@@ -0,0 +1 @@
|
||||
ok github.com/minio/minio/cmd 20.694s
|
||||
@@ -0,0 +1,57 @@
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery (0.57s)
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/object-lock.xml/missing=false (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update object-lock.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/versioning.xml/missing=false (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update versioning.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/policy.json/missing=false (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update policy.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/tagging.xml/missing=false (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update tagging.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/bucket-encryption.xml/missing=false (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update bucket-encryption.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/quota.json/missing=false (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update quota.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/object-lock.xml/missing=true (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update object-lock.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/versioning.xml/missing=true (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update versioning.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/policy.json/missing=true (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update policy.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/tagging.xml/missing=true (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update tagging.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/bucket-encryption.xml/missing=true (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update bucket-encryption.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/quota.json/missing=true (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update quota.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/object-lock.xml/missing=false (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update object-lock.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/versioning.xml/missing=false (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update versioning.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/policy.json/missing=false (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update policy.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/tagging.xml/missing=false (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update tagging.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/bucket-encryption.xml/missing=false (0.06s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update bucket-encryption.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/quota.json/missing=false (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update quota.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/object-lock.xml/missing=true (0.04s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update object-lock.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/versioning.xml/missing=true (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update versioning.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/policy.json/missing=true (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update policy.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/tagging.xml/missing=true (0.01s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update tagging.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/bucket-encryption.xml/missing=true (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update bucket-encryption.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/quota.json/missing=true (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update quota.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketPolicyReplicationStatusLegacyOrder (0.31s)
|
||||
--- FAIL: TestBucketPolicyReplicationStatusLegacyOrder/ErasureSD (0.01s)
|
||||
site-replication-metadata_test.go:348: equivalent legacy and received policy reported as permanently mismatched
|
||||
--- FAIL: TestBucketPolicyReplicationStatusLegacyOrder/Erasure (0.06s)
|
||||
site-replication-metadata_test.go:348: equivalent legacy and received policy reported as permanently mismatched
|
||||
FAIL
|
||||
FAIL github.com/minio/minio/cmd 2.940s
|
||||
FAIL
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
--- FAIL: TestHealBucketConfigDiagnostics (0.29s)
|
||||
--- FAIL: TestHealBucketConfigDiagnostics/ErasureSD (0.00s)
|
||||
site-replication-metadata-heal_test.go:80: empty baselines produced diagnostics
|
||||
--- FAIL: TestHealBucketConfigDiagnostics/Erasure (0.01s)
|
||||
site-replication-metadata-heal_test.go:80: empty baselines produced diagnostics
|
||||
FAIL
|
||||
FAIL github.com/minio/minio/cmd 2.721s
|
||||
FAIL
|
||||
@@ -0,0 +1,8 @@
|
||||
--- FAIL: TestBucketMetadataInitialSyncPhysicalCreated (0.47s)
|
||||
--- FAIL: TestBucketMetadataInitialSyncPhysicalCreated/ErasureSD (0.04s)
|
||||
site-replication-metadata-gate_test.go:289: peer creation time "0001-01-01T00:00:00Z", want physical time 2026-09-12 06:03:09 +0000 UTC
|
||||
--- FAIL: TestBucketMetadataInitialSyncPhysicalCreated/Erasure (0.08s)
|
||||
site-replication-metadata-gate_test.go:289: peer creation time "0001-01-01T00:00:00Z", want physical time 2026-09-12 06:03:09 +0000 UTC
|
||||
FAIL
|
||||
FAIL github.com/minio/minio/cmd 2.576s
|
||||
FAIL
|
||||
@@ -0,0 +1,18 @@
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics (0.36s)
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/ErasureSD (0.00s)
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/ErasureSD/unknown-created (0.00s)
|
||||
site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/ErasureSD/malformed (0.00s)
|
||||
site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/ErasureSD/before-created (0.00s)
|
||||
site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/Erasure (0.01s)
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/Erasure/unknown-created (0.00s)
|
||||
site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/Erasure/malformed (0.00s)
|
||||
site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/Erasure/before-created (0.00s)
|
||||
site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
|
||||
FAIL
|
||||
FAIL github.com/minio/minio/cmd 2.430s
|
||||
FAIL
|
||||
@@ -0,0 +1,174 @@
|
||||
# SILO #77 修正提交独立复审(固定 `62cf066ff529c7d281703daa365f555cebba717a`)
|
||||
|
||||
审查方式:只读。以 worktree 生产源码为准(不只读 diff),交叉核对 `silo-pkg v3.13.4-0.20260910091716` 依赖源码、证据目录与设计记录。上一轮报告只作为待验证的命题,不作为已确认结论。
|
||||
|
||||
---
|
||||
|
||||
## 0. 结论
|
||||
|
||||
**GO_WITH_NONBLOCKING_NOTES**
|
||||
|
||||
- **阻断项:0**(无条件 0,条件性 0)
|
||||
- 上一轮的条件性阻断 F1 与 S2 级 F2 均已在生产路径上真正关闭,并有"最终测试 + 旧生产代码覆盖"的复现证据(`round-2/confirmed-before.log`)。
|
||||
- 新增 8 项非阻断发现(NB-1 ~ NB-8),其中 3 项是行为残留(S3/S4),5 项是证据与文档表述准确性问题。
|
||||
- 一处需要纠正上一轮的**判断**(不是纠正实现):F5 中"Policy 规范编码器接入 GET/export/peer 属于超出必要的扩张"这一说法不成立——这三个调用点持有的是**已解析策略**,必须有编码器,而旧编码器对解析器已支持的负集合策略**必然报错**。真正"可选"的只有 PUT/import 两处,且因 `DisallowUnknownFields` 而无损。
|
||||
|
||||
---
|
||||
|
||||
## 1. F1–F10 关闭状态
|
||||
|
||||
| 项 | 上轮判定 | 本轮核实 | 状态 |
|
||||
|---|---|---|---|
|
||||
| F1 零 Created 桶六类配置写入硬失败、初次同步静默漏发 | S1 条件阻断 | 生产路径已修复并有真实 ObjectLayer + 旧码覆盖复现 | **关闭**(残留见 NB-1) |
|
||||
| F2 Policy 状态口径与收敛键冲突导致永久假 mismatch | S2 | `isBktPolicyReplicated` 改用同一 canonical 键,per-site presence 不变,无 SID/ID/Condition/未知值回归 | **关闭** |
|
||||
| F3 heal 诊断 ERROR 级、key 互相顶替、正常瞬态误报 | S3 | ERROR→Warning、四类原因各自持 key、缺桶不报 = 已关闭;"全无有效来源即完全静默"与"按桶×字段扇出"未处理 | **部分关闭**(NB-2、NB-3) |
|
||||
| F4 三处语义变更未文档化 | S3 | 上轮前两项系误读(原提交 README 已有,见 diff 中为 context 行);第三项已补入 `docs/site-replication/README.md:129-134` | **关闭 + 原误读已纠正** |
|
||||
| F5 Policy 规范编码器接入面 | 最小性争议 | 判断更新:GET/export/peer 非可选;PUT/import 可选但无损。建议保留现状 | **关闭(判断修正)** |
|
||||
| F6 死代码与过时注释 | nit | 注释已修(`bucket-metadata-replication.go:35-37`);`isBucketMetadataEqual` 仍为测试专用 | **关闭(保留 nit,见 NB-5)** |
|
||||
| F7 gate=off 不承诺四类删除自愈 | 范围说明 | README:75-89、设计记录 §rollout/§limits 均如实声明,未说成已自动修复 | **保持(如实)** |
|
||||
| F8 证据缺口(stub / 旧顺序策略 / 二进制身份) | 证据不足 | stub 已去除、旧顺序用例已补、二进制身份已闭环(`--version` 含 commit-id + 对照基线二进制) | **关闭**(但 NB-4:两份旧日志不可用作证据) |
|
||||
| F9 桶世代冲突 | 范围外 | 未被声称已修;设计记录 §limits-3 明确 | **保持(如实)** |
|
||||
| F10 接管 Created 越过真实字段时间 | 覆盖缺口 | 补 `shift=3h` 子用例,钉住"旧世代状态不得升级为有效来源" | **关闭(半边,见 NB-7)** |
|
||||
|
||||
---
|
||||
|
||||
## 2. 本轮重点逐项核实
|
||||
|
||||
### F1 —— 真正修好了,机制比上轮建议更完整
|
||||
|
||||
**`NoMetadata=true` 返回物理探测**(`cmd/erasure-server-pool.go:2279-2283`)。我枚举了全仓库 `NoMetadata` 的全部生产调用点,只有三处:
|
||||
|
||||
- `cmd/bucket-metadata-sys.go:272`(`saveMetadata` 存在性重查,只看 error)
|
||||
- `cmd/bucket-metadata.go:306`(迁移锁内存在性重查,只看 error)
|
||||
- `cmd/bucket-metadata-replication.go:281`(新增的 Created 恢复,正是要物理值)
|
||||
|
||||
其余全部是 `BucketOptions{}`(`NoMetadata=false`),行为不变。被跳过的 `globalBucketMetadataSys.Get()`(`bucket-metadata-sys.go:410-424`)是**纯缓存读、无副作用、不触发加载**,因此跳过它不会改变任何缓存预热或懒加载行为。`Versioning`/`ObjectLocking` 两个字段也只在非 NoMetadata 分支填,与 `ListBuckets`(`:2402/2422` 只在 `!NoMetadata` 时覆盖 Created)语义一致。**无调用者回归。**
|
||||
|
||||
**世代一致性链路完整**(这一点比上轮提出的方案更强,值得记录):
|
||||
`syncToAllPeers`(`site-replication.go:2145-2153`)恢复 `meta.Created` → 传入 `MakeBucketOptions.CreatedAt` → `MakeBucketHook`(`:821-826`)在缓存值为零时回落到 `opts.CreatedAt`(此时本地磁盘仍是 0,所以这个回落是必需的)→ **本地**分支 `PeerBucketMakeWithVersioningHandler`(`:943-966`)在 `metadata.lock` 内 `SetCreatedAt(opts.CreatedAt)` + `rebaseBucketConfigDefaults` + `saveMetadata`,**远端**分支通过 `optsMap["createdAt"]` 收到同一个值。也就是说本地并非"只在内存里补一下",而是与全部对端落盘同一个世代值。这条链路 diff 上看不出来,必须读 `MakeBucketHook` 的 `concDo` 第一个闭包才能确认。
|
||||
|
||||
四个恢复点的锁位置也确认无误:写路径(`bucket-metadata-sys.go:186`,锁内)、bulk(`site-replication.go:1674`,锁内)、import(`admin-bucket-handlers.go:1092`,锁内)、初次同步(锁外探测但不落盘,落盘由上面的钩子在锁内完成)。
|
||||
|
||||
**"无法恢复时显式错误"是否正确:正确。** 静默发明 `UTCNow()` 会造出一个不可撤销、且会向全站点传播的假世代;而显式失败保留原数据、可重试、并留一条有界 `indeterminate`。`syncToAllPeers` 因此让整个 `AddPeerClusters` 失败——这在真实文件系统上基本不可达(`StatVol` 用目录 `ModTime`,存在即非零),且 `AddPeerClusters` 是可重试的交互式管理操作,代价可接受。
|
||||
|
||||
**真实用例是否覆盖生产路径:是。** `TestBucketMetadataPhysicalCreatedRecovery`(`site-replication-metadata-gate_test.go:206-237`)通过 `globalBucketMetadataSys.save` 让**缓存持有 Created=0**,再走 `Update` → `updateAndParseMetadata` → `ensureBucketMetadataCreated` → 真实 `erasureServerPools.GetBucketInfo`;`ExecObjectLayerAPITest`(`test-utils_test.go:1766/1795`)两个后端都是真实 `erasureServerPools`,因此缓存覆盖这一段是真跑到的。`setPhysicalBucketCreated` 直接 `os.Chtimes` 每块盘的桶目录,正是 `xl-storage.go:1022-1028` 取值的来源。六类配置 × `missing=true/false` 全覆盖。
|
||||
|
||||
### F2 —— 关闭,且口径现在六类全部自洽
|
||||
|
||||
`site-replication.go:3782-3809`。状态侧的 `policies[i]` 来自 `:3436` 对**各站点原始字节的解析**,与 heal 的 `bucketConfigPayload`→`canonicalBucketPolicy`(`bucket-metadata-replication.go:191`)是同一个纯函数,因此 peer 重排 Statement 后的永久假 mismatch 被彻底关闭。`numPolicies != total` 的 per-site presence 计数原样保留(`:3783-3792`),并有断言覆盖。
|
||||
|
||||
回归面逐条查过:
|
||||
|
||||
- **SID**:旧 `BPStatement.Equals`(silo-pkg `bucket-policy-statement.go:148-171`)**不比较 SID**,新键包含 `Sid`。这是方向正确的收紧——heal 的收敛键同样含 SID,所以状态报出的 mismatch 是**真实且会被 heal 自动消除**的,不是永久噪声。用例已钉住。
|
||||
- **ID / Version**:旧 `Equals` 比较二者,新键在 `ID != ""` 时包含、`Version` 始终包含,等价。
|
||||
- **Condition**:两侧都走同一 `condition.Functions` 编码再递归排序,顺序无关。
|
||||
- **未知值**:`ParseBucketPolicyConfig` 用 `decoder.DisallowUnknownFields()`(`bucket-policy.go:173`),未知字段**根本无法通过解析**,因此不存在"键把未建模字段静默吃掉"的风险。
|
||||
- `isIAMPolicyReplicated`(`:3665-3681`)仍用 `Equals`,那是 IAM 策略、不同子系统,**不应**一并改动。正确地没动。
|
||||
|
||||
新旧行为是"收紧而非放宽",与收敛契约一致,判断合理。
|
||||
|
||||
### F3 —— 关闭了会吃日志的部分,但静默面仍偏大
|
||||
|
||||
已关闭:四种原因各自持 key(`site-replication-metadata.go:145/158/164/189`),真实 heal RPC 失败不再被"缺 peer"顶掉;`logger.WarningKind`(`:42`);对端"尚未拥有该桶"(`CreatedAt=0` 且载荷空、时间零)不再报。`siteReplicationStatus` 对不可达 peer 填 `DeploymentID=""`(`:3029`)落进 `BucketStats[bucket][""]`,而循环 1 按 `info.Sites` 的真实 ID 判缺失、循环 2 跳过 `id==""`,两条路径正交,语义正确。README:112-119 与实现完全一致。
|
||||
|
||||
未关闭的两点见 NB-2、NB-3。另外,"仅报告确实存在、但仍无法排序的状态"(`:157`)这个条件写得准确:`!current.valid && (len(data)!=0 || !at.IsZero())` 恰好把 F9/F10 的世代冲突暴露出来,这是意外的正收益。
|
||||
|
||||
### F5 —— 上一轮的最小性判断需要修正
|
||||
|
||||
`canonicalBucketPolicy` 的六个生产接入点应分成三类,而不是笼统的"超出必要":
|
||||
|
||||
| 接入点 | 性质 |
|
||||
|---|---|
|
||||
| `bucket-metadata-replication.go:191`(比较键) | **必需**,#77 的核心 |
|
||||
| `site-replication.go:3802`(状态键) | **必需**,F2 的修复本体 |
|
||||
| `bucket-policy-handlers.go:202`(GET)、`admin-bucket-handlers.go:438`(export)、`site-replication.go:1723`(peer apply) | **非可选**:这三处拿到的都是 `*policy.BucketPolicy`(`globalPolicySys.Get` / `GetBucketPolicy` / `admin-handlers-site-replication.go:228` 解析后传入),必须有编码器;而旧的 `json.Marshal` 对 `NotAction`/`NotResource` 语句**必然失败**——`BPStatement.Actions` 无 `omitempty`(`bucket-policy-statement.go:31`)+ `ActionSet.MarshalJSON` 空集报错(`actionset.go:144-148`)。bulk/import/peer 都能把这类策略落盘,所以缩回任何一处都会重新制造"写得进、读不出/复制不出" |
|
||||
| `bucket-policy-handlers.go:102`(PUT)、`admin-bucket-handlers.go:922`(import) | **可选的规范化**:比较键不依赖落盘字节。但因为解析器 `DisallowUnknownFields`,规范化**无信息丢失**(唯一差别是重复语句在盘上也被去重,语义等价),而所有读路径都已重新编码,用户观察不到"原样字节"。删掉它只换来与客户端提交字节的一致性,且会让本地写与 peer 写产生两种盘上形态 |
|
||||
|
||||
**结论:不建议缩回任何一处。** 真正可删的只有 PUT/import 两处,收益为零、改动风险非零。上一轮把这部分列为"范围外扩张"的说法应当被本轮判断取代;它是一个被同一改动暴露出来的真实读回缺陷的修复,且已在 README:129-134 声明。
|
||||
|
||||
---
|
||||
|
||||
## 3. 新发现(全部非阻断)
|
||||
|
||||
### NB-1(S3)恢复出的物理 Created 不确定,且可能晚于对端事件的来源时间
|
||||
|
||||
- **位置**:`cmd/bucket-metadata-replication.go:277-290`;取值链 `cmd/peer-s3-client.go:324-328`(取第一个不报错的 peer)→ `cmd/peer-s3-server.go:207-258`(`cloneDrives` 遍历 **Go map**,取第一个不报错的盘)→ `cmd/xl-storage.go:1022-1028`(`Created = st.ModTime()`)。
|
||||
- **最短触发**:桶无 `.metadata.bin`;其顶层目录 mtime 因近期对象写入被推进到接近 now;对端发来源时间早于该 mtime 的事件 → `bucket-metadata-sys.go:198-201` 判 `before-created` → **返回 nil、不写、不报错**,仅一条每小时去重的 warning,直到有人在源站重新提交。
|
||||
- **影响**:(a) 恢复值不是桶的稳定属性(目录 mtime 随顶层条目增删前进,且逐盘不同);(b) 两个站点各自独立恢复时会落进不同世代。二者都落在已声明的 F9 限制内,且相对基线(基线给每个站点各自 `UTCNow()`)是改善;本地侧因 `metadata.lock` 串行化 + 首写落盘,站内不会分裂。真正新的只有"首个跨站事件可能被静默跳过"。
|
||||
- **最小修复(可选)**:让 `ensureBucketMetadataCreated` 返回"本次是否为恢复值",当 `sourceTime` 非零且早于恢复值时取 `min(recovered, sourceTime)`——"在时刻 T 已持有配置的桶必然在 T 已存在",约 4 行,不新增字段。或退一步:只在 README 里把恢复值明确为"物理近似值,可能晚于真实创建"。
|
||||
- **需补证据**:一个用例——缓存 `Created=0`、`os.Chtimes` 把桶目录设为 now、通过 admin 路由投递 `UpdatedAt = now-1h` 的 peer 事件,断言期望行为(写入或明确跳过),把当前语义钉死。
|
||||
|
||||
### NB-2(S3)`!found` 时对畸形/不可排序状态完全静默
|
||||
|
||||
- **位置**:`cmd/site-replication-metadata.go:134-139`。
|
||||
- **最短触发**:全部站点的同一字段都无法成为候选(例如各站点都存着一份 base64 可解但内容非法的 XML,或全部世代未知)。`latestBucketConfig`(`:117-120`)对 `err != nil` 静默 `continue`,`healBucketConfig` 直接 `return nil`——**没有任何诊断**。而状态导出侧的 `logInvalid`(`site-replication.go:3375-3378`)只覆盖 base64 失败和 policy/quota/replication 的解析失败,XML 类内容非法不在其中,于是这种状态在两条路径上都无声。
|
||||
- **影响**:字段永久不可 heal 且无信号。范围窄(要求所有站点同时不可用),但属于"必要诊断"缺口。
|
||||
- **最小修复**:把每目标的 `bucketConfigStateFromInfo` 结果算一遍并缓存(当前每周期每目标实际解码两次:`latestBucketConfig` 一次、主循环一次),把 `currentErr != nil` 的 `indeterminate` 移到 `if !found` 之前。既补诊断又减一次解码,不引入新框架。
|
||||
- **需补证据**:`healBucketConfig` 单测——两个站点都给非法载荷,断言返回 nil 且产生一条 `indeterminate`(可用现有 `srStatusInfo` 夹具,参照 `site-replication-metadata-heal_test.go:136-143`)。
|
||||
|
||||
### NB-3(S4)`unreachable` 诊断按"桶 × 字段"扇出
|
||||
|
||||
- **位置**:`cmd/site-replication-metadata.go:143-147`,被 `cmd/site-replication.go:4750-4760` 每桶调用六次。
|
||||
- **最短触发**:一个站点掉线。每桶产生 6 个不同 key → 10k 桶集群每小时约 6 万条 warning。
|
||||
- **影响**:只是噪声(`logOnceIf` 按小时清理,仍算"有界"),但一个站点级事实被放大成桶×字段级输出。
|
||||
- **最小修复**:把循环 1 上提到 `healBuckets` 里、每桶调用一次(key 去掉 `file` 段),或干脆每周期一次。改动 <15 行。
|
||||
- **需补证据**:不需要新证据,行数/键数可直接核对。
|
||||
|
||||
### NB-4(S4,证据)两份旧日志是草稿测试产物,不能作为产品失败证据;设计记录"三个回归测试"应为两个
|
||||
|
||||
- **位置**:`findings-before.log:3`、`findings-after-1.log:3` 均为 `site-replication-metadata-gate_test.go:230: real physical creation time unavailable`。该消息在最终测试文件中**不存在**,最终断言在 **227** 行、消息是 `bucket without a recorded creation time cannot update %s: %v`。同理 `findings-before.log:52` 失败在 SID 断言(`:353`),而最终文件 SID 断言在 `:355`、`:353` 是 `t.Fatal(err)`。
|
||||
- **判断**:这两份日志跑的是早期草稿;其中 "real physical creation time unavailable" 更像**测试自身的前置检查**失败,不能用来证明产品缺陷。真正成立的是 `round-2/confirmed-before.log`:它用**最终测试**叠加 `before-erasure-server-pool.go` / `before-site-replication.go`(我已核对这两份确实是修正前版本:无 `NoMetadata` 早返回、`MakeBucketHook` 无回落、`syncToAllPeers` 仍用 `bucketInfo.Created`、`isBktPolicyReplicated` 仍是 `prev.Equals`),行号 227/348 与最终文件精确吻合,失败文本是真实产品错误 `bucket metadata creation time is unknown` 与 `equivalent legacy and received policy reported as permanently mismatched`,两后端 × 六类 × missing 双态全覆盖;`confirmed-after.log` 通过。
|
||||
- **另一项**:设计记录第 166 行"三个回归测试在未修复代码上失败、修复后通过"不准确。仓库中只有**两个**这样的测试;F3(heal 诊断)**没有任何自动化回归**(我在全部 `*_test.go` 中检索 `unreachable`/`peer-error`/`unusable peer`/`logBucketConfigReplication`,无命中)。F3 的唯一证据是 `runtime-after-review.log:20` 的驱动自述"40 exceptional events produced exactly one log per reason"。
|
||||
- **最小修复**:把该行改为"两个回归测试 + 一次运行时观察",并在证据索引里把 `findings-before.log` / `findings-after-1.log` 标注为 superseded 草稿。
|
||||
- **需补证据(可选)**:给 F3 补一个日志断言用例(注入 logger target 或计数 hook),否则 F3 的修复在回归套件里是无保护的。
|
||||
|
||||
### NB-5(S4)不要为死代码创造兼容理由
|
||||
|
||||
`isBucketMetadataEqual`(`cmd/site-replication.go:5172-5181`)仅被 `site-replication_test.go:156`、`bucket-cors-site-replication_test.go:484` 引用。代码里没有编造理由(很好),但设计记录第 186 行写"作为上游血缘保留"是一个不成立的说法。**最小修复**:改成"目前仅测试引用,暂不删除以缩小 diff",或连同两处测试一并删除。
|
||||
|
||||
### NB-6(S4)README 对恢复发生位置的描述偏保守
|
||||
|
||||
`docs/site-replication/README.md:106-110` 说"That recovery happens on the write path … 第一次配置写入(本地或复制而来)会记录物理时间"。实际上 `AddPeerClusters` 的初次同步(`site-replication.go:2145` → `MakeBucketHook` → 本地 `PeerBucketMakeWithVersioningHandler`)也会在本地落盘该时间。这是**低估**不是夸大,但会误导运维判断"何时脱离跳过状态"。**最小修复**:补半句"或在把该桶纳入站点复制时"。
|
||||
|
||||
### NB-7(S4)F10 只钉住了"来源"半边
|
||||
|
||||
`cmd/site-replication-metadata_test.go:473-483` 断言接管把 Created 调晚后,越过的真实字段**不能成为来源**(`!state.candidate()`)。但上一轮指出的另一半——该字段作为 **heal 目标**时,`current.valid=false` 使任何合法 incoming 无条件胜出(`site-replication-metadata.go:174` + `bucket-metadata-replication.go:239-245`)——没有钉住。我确认这半边行为存在且在"远端世代胜出"语义下是自洽的,本地用户 PUT 也总能覆盖回来(`localBucketConfigUpdatedAt` 保证 `> Created`),**不是缺陷**,但期望值应当被固定。**最小修复**:同一子用例再加一次 `applyBucketConfig(&got, file, <远端载荷>, created+shift+1m)`,断言返回 `changed=true`。
|
||||
|
||||
### NB-8(S4)恢复失败时的诊断丢掉了事件来源时间
|
||||
|
||||
`cmd/bucket-metadata-sys.go:187` 传 `time.Time{}` 作为 `at`,即使本次是携带 `sourceTime` 的 peer 事件,日志里也显示 `0001-01-01`。**最小修复**:`sourceTime != nil` 时传 `*sourceTime`(一行)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 证据分级
|
||||
|
||||
| 证据 | 评价 |
|
||||
|---|---|
|
||||
| `round-2/confirmed-before.log` + `confirmed-after.log` + `before-*.go` 覆盖文件 | **足够**。最终测试 × 旧生产代码,行号与失败文本可逐条核对,F1/F2 为已证实的产品失败 |
|
||||
| `binary-identity-after-review.json` | **足够**。`silo-final --version` 含 `commit-id=62cf066ff…`,并附基线 `5c5765816` 对照二进制与双方 sha256,上一轮 F8-4 的缺口已闭合 |
|
||||
| `final-cmd-after-review.log`(cmd 全包 622s, EXIT=0)、`ci-lint-after.log`(0 issues, 品牌基线未变)、`ci-verify-after.log`、`ci-gen/internal/s3select/crosscompile-after.log` | **足够**作为"未引入回归"的门禁证据(只截尾行,可接受) |
|
||||
| `findings-before.log`、`findings-after-1.log` | **不可用**。草稿测试产物,行号与失败文本均与最终测试不符(见 NB-4) |
|
||||
| `findings-after.log` | 仅"ok",无信息量,被 `confirmed-after.log` 覆盖 |
|
||||
| `runtime-after-review.log` | **作者自述为主**。日志由作者的双站点驱动产生,断言("six field states equal""zero metadata RPCs""exactly one log per reason")都由驱动自己判定。时间戳 06:01:50 与最终二进制构建时间 06-01-35Z 吻合,身份链成立;但它是端到端观察,不是独立验证。F3 目前**只有**这一条证据 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 设计记录(`bucket-metadata-convergence.zh.md`)的准确性
|
||||
|
||||
总体克制,未发现把限制说成已修复的地方:§rollout(gate=off 不承诺四类删除收敛)、§limits-1/2/3(历史污染、零时间事件、桶世代冲突)都如实写成限制;第 20 行明确"不能用来证明现有下载包或线上实例已具备这些能力";第 161 行主动声明交叉编译 ≠ 六平台运行验收。需要修正的只有三处,均已在上面列出:
|
||||
|
||||
1. 第 166 行"三个回归测试" → 两个回归测试 + 一次运行时观察(NB-4)。
|
||||
2. 第 186 行 `isBucketMetadataEqual`"上游血缘保留"是编造的理由(NB-5)。
|
||||
3. 第 181 行"与 `ListBuckets` 一致"成立(`ListBuckets` 在 `NoMetadata` 时同样不覆盖 Created),但第 185 行把 F5 记为"有意扩张,不是缺陷"仍沿用了上一轮的定性——按本轮判断,GET/export/peer 三处属于**必需**,建议改写,否则会给未来的人留下"这块可以缩回"的错误暗示。
|
||||
|
||||
---
|
||||
|
||||
## 6. 最小性 / 充分性 / 必要性(独立评价)
|
||||
|
||||
**最小性:是。** 本轮修正净增很小,且每一处都能对应到一个已复现的失败或一次被证实的误读:`NoMetadata` 早返回 5 行、`MakeBucketHook` 回落 3 行、`syncToAllPeers` 恢复 4 行、`isBktPolicyReplicated` 换键约 10 行、heal 诊断重排约 15 行、`isReplicatedBucketConfig` 具名化(消除"用一次性零值结构体探测类型"的晦涩写法)、README 两段。没有新 schema、新 wire 字段、新锁、新框架。我找不到可以无损删除的部分——唯一"可删"的 `canonicalBucketPolicy` PUT/import 接入是上一轮遗留,删掉反而制造两种盘上形态。
|
||||
|
||||
**充分性:对已声明契约充分,对两个边缘不充分且已如实声明。** 在"同一桶世代、Created 已知或可恢复、gate=on"范围内,六类配置的来源时间、锁范围、删除参与 heal、等时裁决、重复/乱序抑制、状态口径,我没有找到反例。不充分的两处都写进了文档:gate=off 下 Tags/SSE/Quota 漏发删除不收敛(F7);跨站点桶世代分歧不自动合并(F9,NB-1 属于它的子集)。F3 的静默面(NB-2)是本轮唯一一个"文档声明了、但我认为声明本身偏宽"的地方——"没有任何站点可供传播的字段就不产生诊断"对畸形配置而言不该成立。
|
||||
|
||||
**必要性:是。** F1 与 F2 都有"最终测试 + 修正前生产代码"的复现(`confirmed-before.log`),是确证的产品失败,不是理论推演;F3 的必要性来自 `logOnceIf` 按 key+正文去重这一可验证机制,虽无自动化回归但代码级推理成立;F4/F8/F10 是文档与证据补齐,成本近零。F5 的编码器接入经本轮重新论证后,从"可争议扩张"变为"其中三处必需、两处无损",必要性判断上调。
|
||||
|
||||
**处置建议(均非阻断,可合并后处理)**:NB-4 与 NB-5 是文字修正,建议合并前一并改掉(它们会影响后人对证据强度的判断);NB-2、NB-8 可合成一个约 20 行的提交;NB-1、NB-3、NB-6、NB-7 可进 backlog。
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"head": "62cf066ff529c7d281703daa365f555cebba717a",
|
||||
"prior_review_head": "4089113e3edbd21a29be8e6af74662462bcc22af",
|
||||
"started_at": "2026-09-12T08:46:55.400524+00:00",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-09-12T09:07:16.313535+00:00",
|
||||
"subtype": "success",
|
||||
"is_error": false,
|
||||
"duration_ms": 973153,
|
||||
"num_turns": 71,
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"used_tools": {
|
||||
"Read": 41,
|
||||
"Grep": 24,
|
||||
"Glob": 5
|
||||
},
|
||||
"stream_sha256": "63046ccfab9fb158178f43b09c75a9809a13ed1ff2da5a626e9e3c3e96c13d37",
|
||||
"review_sha256": "306a856010f7f9bd1cbec39aba83236978f5a811c98f7fc3f8a08217f72de111",
|
||||
"auxiliary_model_note": "CLI model_usage may include auxiliary calls. assistant_models identifies the actual review message model. max was explicitly passed in the recorded command.",
|
||||
"command": [
|
||||
"/opt/homebrew/bin/claude",
|
||||
"--print",
|
||||
"--model",
|
||||
"claude-opus-5",
|
||||
"--effort",
|
||||
"max",
|
||||
"--safe-mode",
|
||||
"--permission-mode",
|
||||
"plan",
|
||||
"--tools",
|
||||
"Read,Grep,Glob",
|
||||
"--strict-mcp-config",
|
||||
"--no-session-persistence",
|
||||
"--add-dir",
|
||||
"$LOCAL_EVIDENCE",
|
||||
"--add-dir",
|
||||
"$DOCS_WORKTREE/content/blog/design",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose"
|
||||
],
|
||||
"command_note": "Same explicit CLI model/effort/read-only options as recorded in the tool invocation; round-2 had its own pinned worktree and prompt.",
|
||||
"session_id": "8743aaa3-5c06-43db-8eba-08302d504c4e",
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"source_clean": true,
|
||||
"built_at": "2026-09-12T09:12:38.105913+00:00",
|
||||
"binaries": {
|
||||
"silo-reviewed": {
|
||||
"sha256": "4825a801ce0ac48d636d9428ce4cd5c17a20b6cfec0b569de70a124c9c005049",
|
||||
"version": "silo-reviewed version DEVELOPMENT.2026-09-12T09-09-34Z (commit-id=fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025)\nRuntime: go1.27.1 darwin/arm64\nLicense: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html\nCopyright: 2015-2025 MinIO, Inc.\nModifications: Copyright 2025-2026 PGSTY\nSource compatibility: based on MinIO technology",
|
||||
"build_info_sha256": "91778f5f2844ada1234ed6a97d97fce7d87dc6334fdd9ef839fbeb7ebdb995e3",
|
||||
"build_info_identity_lines": [
|
||||
"\tpath\tgithub.com/minio/minio",
|
||||
"\tmod\tgithub.com/minio/minio\tv0.0.0-20260912090934-fcbb93e89572\t",
|
||||
"\tbuild\t-buildmode=exe",
|
||||
"\tbuild\t-compiler=gc",
|
||||
"\tbuild\t-trimpath=true",
|
||||
"\tbuild\tCGO_ENABLED=0",
|
||||
"\tbuild\tGOARCH=arm64",
|
||||
"\tbuild\tGOOS=darwin",
|
||||
"\tbuild\tGOARM64=v8.0",
|
||||
"\tbuild\tvcs=git",
|
||||
"\tbuild\tvcs.revision=fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"\tbuild\tvcs.time=2026-09-12T09:09:34Z",
|
||||
"\tbuild\tvcs.modified=false"
|
||||
]
|
||||
},
|
||||
"silo-before": {
|
||||
"sha256": "3b07b9604f2ca9f68f33b69f7807f57e0cc9b1efb0310bbf0aa7b3997e4483b9",
|
||||
"version": "silo-before version DEVELOPMENT.2026-09-11T18-00-57Z (commit-id=5c576581631561c446f30ae5b566f0aa793adc1c)\nRuntime: go1.27.1 darwin/arm64\nLicense: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html\nCopyright: 2015-2025 MinIO, Inc.\nModifications: Copyright 2025-2026 PGSTY\nSource compatibility: based on MinIO technology",
|
||||
"build_info_sha256": "84fb4540758d7dd56367069e3843a97d1e2c3528905db11d490b9654a2d6205d",
|
||||
"build_info_identity_lines": [
|
||||
"\tpath\tgithub.com/minio/minio",
|
||||
"\tmod\tgithub.com/minio/minio\tv0.0.0-20260911180057-5c5765816315\t",
|
||||
"\tbuild\t-buildmode=exe",
|
||||
"\tbuild\t-compiler=gc",
|
||||
"\tbuild\t-tags=kqueue",
|
||||
"\tbuild\t-trimpath=true",
|
||||
"\tbuild\tCGO_ENABLED=0",
|
||||
"\tbuild\tGOARCH=arm64",
|
||||
"\tbuild\tGOOS=darwin",
|
||||
"\tbuild\tGOARM64=v8.0",
|
||||
"\tbuild\tvcs=git",
|
||||
"\tbuild\tvcs.revision=5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"\tbuild\tvcs.time=2026-09-11T18:00:57Z",
|
||||
"\tbuild\tvcs.modified=false"
|
||||
]
|
||||
}
|
||||
},
|
||||
"archive_note": "Dependency listing and machine paths omitted. Actual version, module identity, build settings and original build-info hash retained. Binaries are not included."
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"command": [
|
||||
"make",
|
||||
"check"
|
||||
],
|
||||
"cwd": "$DOCS_WORKTREE",
|
||||
"exit_code": 0,
|
||||
"checked_content_commit": "9fa6248a0323838df5aed678b42c4679a7a6f8a4",
|
||||
"committed_after_check": true,
|
||||
"recorded_at": "2026-09-12T09:30:37.223220+00:00",
|
||||
"files_sha256": {
|
||||
"content/blog/design/bucket-metadata-convergence.zh.md": "26807bf8db555c3f0feef5c933b3d5a2da48fc7fd5a65da00d2c9b93ce7b4b21",
|
||||
"content/blog/design/bucket-metadata-convergence.md": "36bc5c7f2310d96447b8130db0f34a99e900d8f7fc6f33d90630b89085769eec"
|
||||
},
|
||||
"rendered_pages": {
|
||||
"en": 1207,
|
||||
"zh": 1219
|
||||
},
|
||||
"checks": [
|
||||
"go mod verify",
|
||||
"strict Hugo build",
|
||||
"internal links"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
go mod verify
|
||||
all modules verified
|
||||
hugo build --minify --cleanDestinationDir --printPathWarnings --printI18nWarnings --panicOnWarning
|
||||
Start building sites …
|
||||
hugo v0.165.0+extended+withdeploy darwin/arm64 BuildDate=2026-08-12T14:26:28Z VendorInfo=Homebrew
|
||||
|
||||
|
||||
│ EN │ ZH
|
||||
──────────────────┼──────┼──────
|
||||
Pages │ 1207 │ 1219
|
||||
Paginator pages │ 2 │ 2
|
||||
Non-page files │ 26 │ 0
|
||||
Static files │ 740 │ 740
|
||||
Processed images │ 8 │ 0
|
||||
Aliases │ 45 │ 45
|
||||
Cleaned │ 0 │ 0
|
||||
|
||||
Total in 19619 ms
|
||||
python3 bin/check_internal_links.py public
|
||||
internal link check passed: 485471 rendered internal references across 1429 HTML files
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"command": [
|
||||
"go",
|
||||
"build",
|
||||
"./..."
|
||||
],
|
||||
"cwd": "$SILO_WORKTREE",
|
||||
"head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"started_at": "2026-09-12T09:11:30.915932+00:00",
|
||||
"environment": {
|
||||
"TMPDIR": "$TMPDIR",
|
||||
"CGO_ENABLED": "0"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"duration_seconds": 24.193
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"command": ["go", "build", "./..."], "cwd": "$SILO_WORKTREE", "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025", "started_at": "2026-09-12T09:11:30.915932+00:00", "environment": {"TMPDIR": "$TMPDIR", "CGO_ENABLED": "0"}}
|
||||
|
||||
EXIT=0
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"command": [
|
||||
"go",
|
||||
"test",
|
||||
"./cmd/",
|
||||
"-count=1",
|
||||
"-timeout",
|
||||
"30m"
|
||||
],
|
||||
"cwd": "$SILO_WORKTREE",
|
||||
"head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"started_at": "2026-09-12T09:11:30.892660+00:00",
|
||||
"environment": {
|
||||
"TMPDIR": "$LAB_VOLUME/tmp",
|
||||
"CGO_ENABLED": "0",
|
||||
"MINIO_API_REQUESTS_MAX": "10000"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"duration_seconds": 518.124
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"command": ["go", "test", "./cmd/", "-count=1", "-timeout", "30m"], "cwd": "$SILO_WORKTREE", "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025", "started_at": "2026-09-12T09:11:30.892660+00:00", "environment": {"TMPDIR": "$LAB_VOLUME/tmp", "CGO_ENABLED": "0", "MINIO_API_REQUESTS_MAX": "10000"}}
|
||||
ok github.com/minio/minio/cmd 492.776s
|
||||
|
||||
EXIT=0
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"command": [
|
||||
"make",
|
||||
"lint"
|
||||
],
|
||||
"cwd": "$SILO_WORKTREE",
|
||||
"head": "461e9a721047c63e1a95f54ad4b533a6b89def30",
|
||||
"started_at": "2026-09-12T09:17:19.334284+00:00",
|
||||
"environment": {
|
||||
"TMPDIR": "$TMPDIR"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"duration_seconds": 79.064
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{"command": ["make", "lint"], "cwd": "$SILO_WORKTREE", "head": "461e9a721047c63e1a95f54ad4b533a6b89def30", "started_at": "2026-09-12T09:17:19.334284+00:00", "environment": {"TMPDIR": "$TMPDIR"}}
|
||||
Running lint check
|
||||
0 issues.
|
||||
typos binary is not found.. skipping..
|
||||
|
||||
EXIT=0
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"command": [
|
||||
"go",
|
||||
"run",
|
||||
".issue77-evidence/twosite/main.go",
|
||||
"$LAB_VOLUME/runtime-reviewed",
|
||||
".issue77-evidence/silo-reviewed",
|
||||
".issue77-evidence/silo-before"
|
||||
],
|
||||
"cwd": "$SILO_WORKTREE",
|
||||
"head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"started_at": "2026-09-12T09:12:41.365111+00:00",
|
||||
"environment": {
|
||||
"TMPDIR": "$LAB_VOLUME/tmp"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"duration_seconds": 190.476
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{"command": ["go", "run", ".issue77-evidence/twosite/main.go", "$LAB_VOLUME/runtime-reviewed", ".issue77-evidence/silo-reviewed", ".issue77-evidence/silo-before"], "cwd": "$SILO_WORKTREE", "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025", "started_at": "2026-09-12T09:12:41.365111+00:00", "environment": {"TMPDIR": "$LAB_VOLUME/tmp"}}
|
||||
2026-09-12T09:12:42Z site-a ready gate=on
|
||||
2026-09-12T09:12:43Z site-b ready gate=on
|
||||
2026-09-12T09:12:44Z historical bucket prepared: six live fields at Created
|
||||
2026-09-12T09:12:44Z baseline-initial-sync: waiting for ordinary 30-second heal
|
||||
2026-09-12T09:12:44Z baseline-initial-sync: six field states equal
|
||||
2026-09-12T09:12:44Z initial sync preserved six historical Created timestamps
|
||||
2026-09-12T09:12:44Z real local PUT outgoing replication RPC dropped by partition
|
||||
2026-09-12T09:12:45Z live-reconnect: waiting for ordinary 30-second heal
|
||||
2026-09-12T09:13:13Z live-reconnect: six field states equal
|
||||
2026-09-12T09:13:13Z live-steady: observing two heal intervals
|
||||
2026-09-12T09:14:18Z live-steady: zero metadata RPCs across 65 seconds
|
||||
2026-09-12T09:14:18Z six duplicate/out-of-order events rejected without clock changes
|
||||
2026-09-12T09:14:19Z real local DELETE outgoing replication RPC dropped by partition
|
||||
2026-09-12T09:14:20Z site-a ready gate=on
|
||||
2026-09-12T09:14:20Z four tombstones survived process restart
|
||||
2026-09-12T09:14:20Z delete-reconnect: waiting for ordinary 30-second heal
|
||||
2026-09-12T09:14:44Z delete-reconnect: six field states equal
|
||||
2026-09-12T09:14:44Z delete-steady: observing two heal intervals
|
||||
2026-09-12T09:15:49Z delete-steady: zero metadata RPCs across 65 seconds
|
||||
2026-09-12T09:15:49Z 40 exceptional events produced exactly one log per reason
|
||||
2026-09-12T09:15:49Z starting fixed/previous-server rolling-upgrade smoke with gate off
|
||||
2026-09-12T09:15:50Z site-a ready gate=off
|
||||
2026-09-12T09:15:51Z site-b ready gate=off
|
||||
2026-09-12T09:15:51Z mixed-version ordinary PUT/DELETE and off tombstone visibility passed
|
||||
2026-09-12T09:15:51Z PASS: isolated two-site implementation acceptance
|
||||
|
||||
EXIT=0
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"command": [
|
||||
"go",
|
||||
"test",
|
||||
"-race",
|
||||
"./cmd/",
|
||||
"-run",
|
||||
"^(TestBucketMetadataInitialSyncPhysicalCreated|TestHealBucketConfigDiagnostics|TestHealBucketConfigWithoutSourceDiagnostics)$",
|
||||
"-count=1",
|
||||
"-timeout",
|
||||
"10m"
|
||||
],
|
||||
"cwd": "$SILO_WORKTREE",
|
||||
"head": "461e9a721047c63e1a95f54ad4b533a6b89def30",
|
||||
"started_at": "2026-09-12T09:18:02.407600+00:00",
|
||||
"environment": {
|
||||
"TMPDIR": "$LAB_VOLUME/tmp",
|
||||
"CGO_ENABLED": "1"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"duration_seconds": 45.681
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"command": ["go", "test", "-race", "./cmd/", "-run", "^(TestBucketMetadataInitialSyncPhysicalCreated|TestHealBucketConfigDiagnostics|TestHealBucketConfigWithoutSourceDiagnostics)$", "-count=1", "-timeout", "10m"], "cwd": "$SILO_WORKTREE", "head": "461e9a721047c63e1a95f54ad4b533a6b89def30", "started_at": "2026-09-12T09:18:02.407600+00:00", "environment": {"TMPDIR": "$LAB_VOLUME/tmp", "CGO_ENABLED": "1"}}
|
||||
ok github.com/minio/minio/cmd 4.540s
|
||||
|
||||
EXIT=0
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"command": [
|
||||
"go",
|
||||
"test",
|
||||
"-race",
|
||||
"./cmd/",
|
||||
"-run",
|
||||
"^(TestBucketMetadataInitialSyncPhysicalCreated|TestPeerBucketMetadataPhysicalCreatedBoundary|TestPeerBucketMetadataUnknownCreated|TestPeerBucketAdoptionRebasesOnlyDefaults|TestHealBucketConfigDiagnostics|TestHealBucketConfigWithoutSourceDiagnostics|TestBucketMetadataPhysicalCreatedRecovery|TestBucketPolicyReplicationStatusLegacyOrder|TestHealBucketConfigSourceAndQuiescence|TestLatestBucketConfigCandidates|Test.*Cors.*|Test.*CORS.*)$",
|
||||
"-count=1",
|
||||
"-timeout",
|
||||
"15m"
|
||||
],
|
||||
"cwd": "$SILO_WORKTREE",
|
||||
"head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"started_at": "2026-09-12T09:11:30.873027+00:00",
|
||||
"environment": {
|
||||
"TMPDIR": "$LAB_VOLUME/tmp",
|
||||
"CGO_ENABLED": "1"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"duration_seconds": 90.202
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"command": ["go", "test", "-race", "./cmd/", "-run", "^(TestBucketMetadataInitialSyncPhysicalCreated|TestPeerBucketMetadataPhysicalCreatedBoundary|TestPeerBucketMetadataUnknownCreated|TestPeerBucketAdoptionRebasesOnlyDefaults|TestHealBucketConfigDiagnostics|TestHealBucketConfigWithoutSourceDiagnostics|TestBucketMetadataPhysicalCreatedRecovery|TestBucketPolicyReplicationStatusLegacyOrder|TestHealBucketConfigSourceAndQuiescence|TestLatestBucketConfigCandidates|Test.*Cors.*|Test.*CORS.*)$", "-count=1", "-timeout", "15m"], "cwd": "$SILO_WORKTREE", "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025", "started_at": "2026-09-12T09:11:30.873027+00:00", "environment": {"TMPDIR": "$LAB_VOLUME/tmp", "CGO_ENABLED": "1"}}
|
||||
ok github.com/minio/minio/cmd 20.903s
|
||||
|
||||
EXIT=0
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"reviewed_production_head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"final_head": "461e9a721047c63e1a95f54ad4b533a6b89def30",
|
||||
"changed_files": [
|
||||
"cmd/site-replication-metadata-gate_test.go",
|
||||
"cmd/site-replication-metadata-heal_test.go"
|
||||
],
|
||||
"difference": "Test-only gofumpt formatting and equivalent if-chain to switch; no production or server documentation changes",
|
||||
"patch": "test-style.diff"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"command": [
|
||||
"go",
|
||||
"vet",
|
||||
"./..."
|
||||
],
|
||||
"cwd": "$SILO_WORKTREE",
|
||||
"head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"started_at": "2026-09-12T09:11:55.197305+00:00",
|
||||
"environment": {
|
||||
"TMPDIR": "$TMPDIR",
|
||||
"CGO_ENABLED": "0"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"duration_seconds": 4.024
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"command": ["go", "vet", "./..."], "cwd": "$SILO_WORKTREE", "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025", "started_at": "2026-09-12T09:11:55.197305+00:00", "environment": {"TMPDIR": "$TMPDIR", "CGO_ENABLED": "0"}}
|
||||
|
||||
EXIT=0
|
||||
@@ -0,0 +1,111 @@
|
||||
已完成定向核对。以下为最终报告。
|
||||
|
||||
---
|
||||
|
||||
# SILO #77 第三轮独立对抗性验收(固定 `fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025`)
|
||||
|
||||
**结论:GO_WITH_NONBLOCKING_NOTES — 阻断数 0**
|
||||
|
||||
范围限定本轮差异(`round-3/change.diff`:2 个生产文件共约 12 行、5 个测试文件、1 个 README 段落)。未重复前两轮已完成的全量审查。
|
||||
|
||||
---
|
||||
|
||||
## 一、逐条 NB 判定
|
||||
|
||||
### NB-2 — 关闭(三个边界经代码级核对成立)
|
||||
|
||||
生产改动在 `cmd/site-replication-metadata.go:134-160`。逐边界挑战结果:
|
||||
|
||||
1. **`unreachable` 仅在存在候选时输出 —— 行为字节级不变**。改前 `if !found { return nil }` 就在 unreachable 循环之前,因此该日志从来只在 `found==true` 时产生;把它包进 `if found {}`(`:138-144`)是纯重构,没有新增也没有删除任何一条 unreachable。这一点上一轮报告与提交说明都没讲清,值得记录:**这不是行为修改**。
|
||||
2. **空基线/缺桶安静 —— 由 `newBucketConfigState` 的取值规则保证**(`cmd/bucket-metadata-replication.go:224-227`):`at.IsZero() → at = created`。因此"字段时间为零但 `CreatedAt` 非零"的合法基线必然 `valid=true`,不触发 `:154`;缺桶(全零)`data` 空且 `at` 零,也不触发。只有三类会响:`created==0 且 at/data 非空`、`at < created`、解码/解析报错。与新测试四个子用例一一对应,无第四种漏网情形。
|
||||
3. **无有效来源不发 RPC、不落盘 —— 成立**。`!found` 在 `:159` 即 `continue`,位于 `:177`(本地 `updateAndParseMetadata`)与 `:182`(`SRPeerReplicateBucketMeta`)之前。测试用 `len(events()) != 0` 从对端侧反证(`heal_test.go:162-164`)。
|
||||
4. **去重 —— 成立**。`logOnceIf`(`internal/logger/logonce.go:95-120`)按 `id` **且**错误正文相同才抑制;key 为 `bucket-metadata/<bucket>/<file>/<reason>`,正文只含 reason,时间/peer 全在 ReqInfo 属性里(`site-replication-metadata.go:33-43`)。所以多 peer 同因塌缩为一条,跨因不互相顶替。测试的 `for range 2` 钉住了这一点。
|
||||
|
||||
**必要性证据成立**:`round-2/no-source-before.log` 用同一份最终测试叠加旧生产码,`unknown-created`/`malformed`/`before-created` 三个子用例在 `heal_test.go:155` 报 `got 0 diagnostics, want 1`,`empty-baseline` 两侧都安静。行号与当前文件精确吻合,是真实的前后对照,不是草稿产物。
|
||||
|
||||
**接受的边界(非阻断,已成文)**:在全站 `Created==0` 的遗留桶上(滚动升级的典型形态),每个"有值字段"现在每小时多出一条 `indeterminate`,此前完全静默。这正是 NB-2 要的可行动信号,README:123-125 与设计记录 §limits-5 都写明了。量级与 NB-3 已接受的 `unreachable` 同阶。
|
||||
|
||||
**性能挑战结果:不构成问题**。`!found` 时主循环多做一遍 `bucketConfigStateFromInfo`,但空载荷在 `site-replication-metadata.go:75` 与 `bucket-metadata-replication.go:178-180` 两处短路,连 base64 都不解——而空载荷恰好是 `!found` 的主体人群。上一轮建议的"缓存解码结果"因此确实可以不做。
|
||||
|
||||
### NB-8 — 关闭,nil/zero/local 三分支均无错误时间与 panic
|
||||
|
||||
`cmd/bucket-metadata-sys.go:186-192`:
|
||||
|
||||
- `sourceTime == nil` → `at` 为零值,打印 `0001-01-01`,与改前一致,无解引用;
|
||||
- `sourceTime != nil && IsZero()` → 同上,不会把零值当作有效来源时间;
|
||||
- `sourceTime` 非零且为本地时区 → `logBucketConfigReplication:38` 统一 `at.UTC().Format(...)`,不产生偏移时间;
|
||||
- `meta.Created`:该分支下必为零(`ensureBucketMetadataCreated:278-280` 在非零时提前返回 nil),所以 `created` 属性如实;
|
||||
- 去重不受影响:错误正文未变(见上)。
|
||||
|
||||
**一处同类残留(本轮之前既有,非本轮引入)**:`site-replication-metadata.go:155` 在 `currentErr != nil` 时 `current` 是零值结构体,于是"解析失败"的日志同样丢掉了对端上报的真实 `at`。最小修复需要一个不经解码就取 `(bucket,file) → at` 的取值函数,约 10 行。可选,不必本轮做。
|
||||
|
||||
### NB-4 / NB-5 — 关闭;全仓无漏引用,编译成立;有一项必须如实指出的门禁事实
|
||||
|
||||
- **漏引用**:`isBucketMetadataEqual` 在 Go 代码中零引用,仅 `docs/site-replication/CORS-LWW-DESIGN.md:84` 提及,且位于 "Confirmed Failures in the **Pre-Fix** Candidate" 小节,是对既往缺陷的历史陈述,不是对现有代码的断言,无需改。
|
||||
- **编译**:两个被删用例所在文件的 `encoding/base64` 导入仍在被使用(`site-replication_test.go:21` → `:80/:106`;`bucket-cors-site-replication_test.go:22` → 20 余处),不存在第二次漏删。`round-3/final-build.log`、`final-vet.log` 在本轮固定 SHA 上 `go build ./...` / `go vet ./...` 均 `EXIT=0`。**当前树完整编译成立**。
|
||||
- **真实 CORS 路径保留**:被删的是纯 helper 单测;同一性质(base64 必须按字节严格比较)现由 `TestPeerBucketCorsRejectsNonCanonicalBase64`(`bucket-cors-site-replication_test.go:782-815`)经真实 `PeerBucketCorsConfigHandler` 与 legacy bulk 两条生产路径覆盖,并断言被拒后元数据未变;heal 侧用 `corsReplicationStateFromInfo`/`equalCORSReplicationStates`(`site-replication.go:4903-4904`)而非被删函数。**无覆盖损失。**
|
||||
- **logger 全局状态与 race**:`testlogger.T` 是进程级单例但用 atomic(`testlogger.go:49,78-85`),本身无 race;`logger.DisableLog` 是普通全局 bool(`logger.go:407` 读、`test-utils_test.go:105` 默认置 true),两个新用例都 `defer` 还原。`round-3/final-target-race.log` 在本轮固定 SHA 上对包含全部新用例的集合 `-race` 跑通(20.9s,EXIT=0)。**已验证,非阻断。**
|
||||
- **两项潜在脆弱点(无阻断、目前未触发,仅备案)**:(a) `DisableLog=false` 期间全局 sink 会捕获进程内**任何**系统日志,而 `heal_test.go:97` 对任何非预期行直接 `Fatalf`——若窗口内有后台 goroutine 输出日志即失败。最小加固:分类前先 `strings.Contains(line, bucket)` 过滤外来日志。(b) `logOnce.IDMap` 是进程级、每小时才清(`logonce.go:123-131`),这两个用例之所以安全,是因为 `ExecObjectLayerAPITest` 对两种后端各自 `getRandomBucketName()`(`test-utils_test.go:1556`,分别在 `:1771`/`:1801` 调用)使日志 key 天然唯一——这是隐式依赖,值得在注释里点一句。
|
||||
- **证据精度**:`round-2/confirmed-diagnostics-before.log` 在 `heal_test.go:80`(**第一条**断言)就 Fatal,因此它只证明了 F3 的"空基线必须安静"这一半;WARNING 级别与按因去重(`:99-105`)在旧码上并未被执行到,**没有**对应的失败演示。应如实这么写,不能说成"F3 已被完整回归保护"。
|
||||
|
||||
- **必须指出的门禁事实**:`round-3/pre-style-lint.log` 显示本轮固定提交 `fcbb93e8` 的 `make lint` **失败(EXIT=2,3 issues)**,全部落在本轮新增的两个测试文件:`cmd/site-replication-metadata-heal_test.go:92:5`(gocritic ifElseChain)、`cmd/site-replication-metadata-gate_test.go:282:1` 与 `heal_test.go:138:1`(gofumpt)。我通读了修复补丁 `test-style.diff`:switch 分支顺序与函数体同 if-else 完全一致,另两处仅为结构体字面量换行,**语义等价、仅测试文件**,已在后续提交 `461e9a72` 修掉(该提交的 lint 复跑在证据中仍在进行)。结论:不影响行为验收,但"`fcbb93e8` 本身通过全部仓库门禁"这句话不成立,不应这么写。
|
||||
|
||||
### NB-6 — 关闭为**出站 wire 证明**;不得称作双站点证明(已如实区分)
|
||||
|
||||
`TestBucketMetadataInitialSyncPhysicalCreated`(`gate_test.go:245-303`)确实驱动真实源 ObjectLayer 走完整出站序列:`syncToAllPeers`(`site-replication.go:2141-2153`)→ `ensureBucketMetadataCreated` 从真实盘目录 mtime 恢复 → `MakeBucketHook`(`:821-826` 缓存零值回落)→ wire 上的 `createdAt` 参数;再经 `initialBucketConfigReplicationEvent` 发出 Tags 事件。断言 `createdAt == physical`、Tags 载荷为 base64(XML) 且 `UpdatedAt == physical`(历史字段以 Created 作源时间)。前后对照成立:`round-2/initial-sync-before.log` 在 `gate_test.go:289` 报 `"0001-01-01T00:00:00Z"`,行号与最终文件精确吻合。
|
||||
|
||||
**必须如实标注的范围限制(我独立核出,不在提交说明里)**:该用例的 `c.state.Peers` 只含 `"initial-peer"`(`gate_test.go:282-283`),而 `concDo`(`site-replication.go:2497-2515`)只对 `depID == globalDeploymentID()` 的条目执行 `selfActionFn`。该条目不存在,因此真正把 `Created` 落到**本地盘**的 `PeerBucketMakeWithVersioningHandler`(`site-replication.go:954-966` 的 `SetCreatedAt` + `saveMetadata`)**在本用例中根本没有执行**。也就是说:
|
||||
|
||||
- README:110-114 "Recovery happens … during initial site sync … records the physical time" 在生产路径上成立(经 `concDo` 自身分支),但**不被这个测试覆盖**;
|
||||
- 本用例证明的是出站内容,不是任何一侧的落盘,更不是双站点收敛。
|
||||
|
||||
设计记录 §205 已经把这个区分写对了("它证明出站内容…二者不能混称为同一验收"),README 也没有声称测试覆盖,因此**不构成不实陈述**。若要补齐本地落盘那一半:在该 Peers map 中加入 `globalDeploymentID(): {}` 并在末尾加一次 `readBucketMetadata` 断言 `Created==physical`,约 3 行。
|
||||
|
||||
### NB-7 — 关闭,两半都钉住了
|
||||
|
||||
`site-replication-metadata_test.go:482-490`。沿 `applyBucketConfig`(`bucket-metadata-replication.go:294-316`)+ `compareBucketConfigStates`(`:239-245`)核对:`incoming.valid=true` 对 `local.valid=false` 直接返回 1,因此 `changed=true`,随后断言指针别名上的 `*at == incomingAt`、`*data == incoming`——载荷与时间都断言了。对 `bucketTaggingConfig` 这是"活标签覆盖被保留的旧世代删除记录",语义与注释一致;对 `bucketSSEConfig` 载荷恰好与原值相同,那里真正承载证明的是时间戳断言。两点说明:断言层级是 `applyBucketConfig`(即写路径 `bucket-metadata-sys.go:206` 调用的同一决策函数),属单元级钉桩而非端到端,这是可接受的取舍。
|
||||
|
||||
### NB-1 — 判定为**如实保留边界**,本轮不存在必须修的回归
|
||||
|
||||
本轮对 NB-1 **没有任何生产代码改动**(仅 README:106-114 + 新用例),因此按构造不可能引入回归。我独立复核了被保留的行为与被拒方案:
|
||||
|
||||
- 保留跳过:`bucket-metadata-sys.go:202-205` 在 `updatedAt.Before(meta.Created)` 时记 `before-created` 并 `return nil`(不写、不报错);heal 侧同因在 `site-replication-metadata.go:162-164`。
|
||||
- 拒绝 `min(recovered, sourceTime)` 的理由我认为**成立**:单个事件的时间戳无法区分"本世代的较早事件"与"上一个已删除世代的事件",凭一个事件下调 `Created` 会让删除前的状态复活。这里选安全而非活性是对的,且具备自愈性(下一次 ≥ physical 的写入即确立身份)与可观测性(有界 warning)。
|
||||
- 新用例 `TestPeerBucketMetadataPhysicalCreatedBoundary`(`gate_test.go:305-335`)用真实盘 mtime 同时钉住两侧:`physical-1h` 的 peer 事件 `err==nil` 且 `bucketConfigWriteCounter` 计数为 **0**(是跳过不是失败);随后本地 `Update` 使盘上 `Created==physical` 且 `at > physical`。断言选得准确。
|
||||
- 残留(已成文):唯一持有者的源时间早于恢复值的字段,不经人工介入不会收敛;且跳过路径**不落盘恢复值**(`:204` 早于 `saveMetadata`),每次事件都重新探测 mtime——README:112-113 用 "first **successful** configuration write" 措辞正确地表达了这一点。
|
||||
|
||||
### NB-3 — 接受现状,不要求扩展
|
||||
|
||||
`site-replication-metadata.go:138-144` 仍按桶×字段×原因输出,`healBuckets` 每桶调用六次(`site-replication.go:4753-4760` → `:4851-4935`)。设计记录 §limits-5 已明确声明该量级会随桶与有值字段增长。未构成阻断,也未破坏任何既定契约,按约定不要求做站点级聚合/限流改造。
|
||||
|
||||
**一处文档精度小瑕**:§limits-5 把"日志量随桶与字段增长"这句只挂在"不可达站点"上;本轮之后,遗留零 `Created` 集群里的 `indeterminate` 同样按桶×字段增长。加半句即可,非阻断。
|
||||
|
||||
### F5 — 确认无外溢
|
||||
|
||||
本轮 diff 中没有任何 `canonicalBucketPolicy` 接入点的增删;只有设计记录 F5 一行表述更新(`bucket-metadata-convergence.md:182` / `.zh.md:185`),与"上轮已修正原判断、不再改代码"一致。
|
||||
|
||||
---
|
||||
|
||||
## 二、确证问题清单(均非阻断)
|
||||
|
||||
| # | 位置 | 触发条件 | 最小修复 |
|
||||
|---|---|---|---|
|
||||
| 1 | `cmd/site-replication-metadata-heal_test.go:92`、`:138`;`cmd/site-replication-metadata-gate_test.go:282` | 在固定提交 `fcbb93e8` 上执行 `make lint` | 已在 `461e9a72` 修掉(语义等价、仅测试文件);本轮 SHA 需注明 lint 未过 |
|
||||
| 2 | `cmd/site-replication-metadata.go:155` | heal 遇到解码/解析失败的对端状态 | 日志属性中 `sourceTime` 恒为 `0001-01-01`;需一个免解码的 `(bucket,file)→at` 取值函数,约 10 行。可选 |
|
||||
| 3 | `cmd/site-replication-metadata-heal_test.go:97` | 日志窗口内任一后台 goroutine 输出日志 | 分类前加 `strings.Contains(line, bucket)` 过滤,1 行 |
|
||||
| 4 | `cmd/site-replication-metadata-gate_test.go:282-283` | —(覆盖缺口,非缺陷) | 若要覆盖 README 的"初次同步落盘 Created"一半:Peers 加入 `globalDeploymentID()` 并补一次 `readBucketMetadata` 断言,约 3 行 |
|
||||
| 5 | 设计记录 §limits-5 | — | "随桶与字段增长"补上 `indeterminate` 这一类,半句 |
|
||||
|
||||
**证据状态如实说明**:`round-3/final-cmd.log` 只有头行,**完整 `go test ./cmd/` 尚未产生结果**,不可称作已通过;已完成且可复核的是 `final-build`/`final-vet`(EXIT=0)、`final-target-race`(EXIT=0)、`pre-style-lint`(EXIT=2,见上)。`461e9a72` 的 `make lint` 复跑同样在进行中。
|
||||
|
||||
---
|
||||
|
||||
## 三、最小性 / 充分性 / 必要性(独立评价)
|
||||
|
||||
**最小性:是,且是本系列迄今最克制的一轮。** 生产侧净变化只有两处共约 12 行:`found` 条件位移(未引入解码缓存、未新增结构体、未新增日志原因),以及诊断里一个已有指针的解引用。没有新 schema、新 wire 字段、新锁、新框架。我找不到可以无损删除的部分:删掉 `!found` 分支就退回 NB-2 的静默,删掉 `at` 就退回 NB-8。测试删除项(`isBucketMetadataEqual` 及其两个专属用例)经全仓核对确认无生产调用者、无覆盖损失。
|
||||
|
||||
**充分性:对本轮声称的目标充分;两处覆盖如实低于文字。** 新增三个用例都有真实前后对照或真实后端驱动,`-race` 通过。低于文字的两处均已在上文点名:F3 的级别/去重断言在旧码上未被演示失败;初次同步的本地落盘半边未被测试覆盖(生产路径本身成立)。二者都是证据强度问题,不是行为问题,且设计记录没有把它们说过头。
|
||||
|
||||
**必要性:是。** NB-2 与 NB-6 各有"最终测试 + 旧生产码"的复现日志且行号可逐条核对(`no-source-before.log`、`initial-sync-before.log`);NB-8 是一行可读性缺陷的直接消除;NB-5 是删除无调用者代码;NB-7 是补一侧期望值;NB-1/NB-3 明确不改代码,只做文档与钉桩。没有一项属于"顺手扩张"。
|
||||
|
||||
**放行建议**:以 `461e9a72`(lint 修复后的等价树)为合并基准,并在合入前等 `go test ./cmd/` 与 `make lint` 两条复跑各自出结果;上表 5 项可全部进 backlog。
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"base": "62cf066ff529c7d281703daa365f555cebba717a",
|
||||
"status": "completed",
|
||||
"started_at": "2026-09-12T09:10:48.496423+00:00",
|
||||
"command": [
|
||||
"/opt/homebrew/bin/claude",
|
||||
"--print",
|
||||
"--model",
|
||||
"claude-opus-5",
|
||||
"--effort",
|
||||
"max",
|
||||
"--safe-mode",
|
||||
"--permission-mode",
|
||||
"plan",
|
||||
"--tools",
|
||||
"Read,Grep,Glob",
|
||||
"--strict-mcp-config",
|
||||
"--no-session-persistence",
|
||||
"--add-dir",
|
||||
"$LOCAL_EVIDENCE",
|
||||
"--add-dir",
|
||||
"$DOCS_WORKTREE/content/blog/design",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose"
|
||||
],
|
||||
"completed_at": "2026-09-12T09:25:59.617894+00:00",
|
||||
"subtype": "success",
|
||||
"is_error": false,
|
||||
"duration_ms": 777403,
|
||||
"num_turns": 61,
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"used_tools": {
|
||||
"Read": 33,
|
||||
"Grep": 23,
|
||||
"Glob": 4
|
||||
},
|
||||
"stream_sha256": "a9a8784005abe5f02c1847e6d2a42890a309dbf34604a3756c456f1fc29c612a",
|
||||
"review_sha256": "9106938a7a34cfbc6a11d372dd5ffb71ae7417a77a56d10bbed178f539de8fba",
|
||||
"auxiliary_model_note": "CLI model_usage may include auxiliary calls. assistant_models identifies the actual review message model. max was explicitly passed in the recorded command.",
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json.",
|
||||
"session_id": "3b2ce46c-226b-4c52-b004-c727303c4a9b",
|
||||
"session_id_source": "system init event in the stream identified by stream_sha256"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
diff --git a/cmd/site-replication-metadata-gate_test.go b/cmd/site-replication-metadata-gate_test.go
|
||||
index 49818b548..de9a37bb6 100644
|
||||
--- a/cmd/site-replication-metadata-gate_test.go
|
||||
+++ b/cmd/site-replication-metadata-gate_test.go
|
||||
@@ -279,8 +279,10 @@ func TestBucketMetadataInitialSyncPhysicalCreated(t *testing.T) {
|
||||
defer peer.Close()
|
||||
// Exercise the complete outgoing sync sequence with real source
|
||||
// storage. This peer acknowledges RPCs; it is not a second ObjectLayer.
|
||||
- c := &SiteReplicationSys{enabled: true, state: srState{ServiceAccountAccessKey: serviceCred.AccessKey,
|
||||
- Peers: map[string]madmin.PeerInfo{"initial-peer": {DeploymentID: "initial-peer", Endpoint: peer.URL}}}}
|
||||
+ c := &SiteReplicationSys{enabled: true, state: srState{
|
||||
+ ServiceAccountAccessKey: serviceCred.AccessKey,
|
||||
+ Peers: map[string]madmin.PeerInfo{"initial-peer": {DeploymentID: "initial-peer", Endpoint: peer.URL}},
|
||||
+ }}
|
||||
if err := c.syncToAllPeers(ctx, madmin.SRAddOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diff --git a/cmd/site-replication-metadata-heal_test.go b/cmd/site-replication-metadata-heal_test.go
|
||||
index bedb3ea5e..6c00fb8c1 100644
|
||||
--- a/cmd/site-replication-metadata-heal_test.go
|
||||
+++ b/cmd/site-replication-metadata-heal_test.go
|
||||
@@ -89,11 +89,12 @@ func TestHealBucketConfigDiagnostics(t *testing.T) {
|
||||
defer capture.mu.Unlock()
|
||||
var unreachable, peerError int
|
||||
for _, line := range capture.lines {
|
||||
- if strings.Contains(line, "bucket metadata replication: unreachable") {
|
||||
+ switch {
|
||||
+ case strings.Contains(line, "bucket metadata replication: unreachable"):
|
||||
unreachable++
|
||||
- } else if strings.Contains(line, "bucket metadata replication: peer-error") {
|
||||
+ case strings.Contains(line, "bucket metadata replication: peer-error"):
|
||||
peerError++
|
||||
- } else {
|
||||
+ default:
|
||||
t.Fatalf("unexpected diagnostic: %s", line)
|
||||
}
|
||||
if !strings.HasPrefix(line, "WARNING:") {
|
||||
@@ -135,11 +136,13 @@ func TestHealBucketConfigWithoutSourceDiagnostics(t *testing.T) {
|
||||
// and neither local storage nor the recording peer may be written.
|
||||
name := bucket + "-" + tc.name
|
||||
local := globalDeploymentID()
|
||||
- info := srStatusInfo{Sites: map[string]madmin.PeerInfo{local: {}, "metadata-peer": {}, "unreachable": {}},
|
||||
+ info := srStatusInfo{
|
||||
+ Sites: map[string]madmin.PeerInfo{local: {}, "metadata-peer": {}, "unreachable": {}},
|
||||
BucketStats: map[string]map[string]srBucketStatsSummary{name: {
|
||||
local: bucketConfigTestInfo(name, bucketTaggingConfig, tc.data, tc.at, tc.created),
|
||||
"metadata-peer": {},
|
||||
- }}}
|
||||
+ }},
|
||||
+ }
|
||||
for range 2 {
|
||||
if err := globalSiteReplicationSys.healBucketConfig(t.Context(), name, bucketTaggingConfig, info); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"cli_version": "2.1.258",
|
||||
"base": "5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"head": "4089113e3edbd21a29be8e6af74662462bcc22af",
|
||||
"started_at": "2026-09-12T04:02:26.329601+00:00",
|
||||
"status": "completed",
|
||||
"observed_model": "claude-opus-5",
|
||||
"session_id": "86133939-ee59-4e4f-ad98-61f825c91899",
|
||||
"permission_mode": "plan",
|
||||
"tools": [
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read"
|
||||
],
|
||||
"command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --add-dir $LOCAL_EVIDENCE --output-format stream-json --verbose",
|
||||
"prompt_sha256": "09cea2c1356a2261e8cb9b0d4f2c947d0b683d00386267ebb340d487bdc66b4d",
|
||||
"diff_sha256": "58703d70978fa25ea6b23eb6735ec94a6d369df3a776e5169a13c847f0d6b0eb",
|
||||
"completed_at": "2026-09-12T04:35:09.936197+00:00",
|
||||
"subtype": "success",
|
||||
"is_error": false,
|
||||
"duration_ms": 1841224,
|
||||
"num_turns": 93,
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"used_tools": {
|
||||
"Read": 55,
|
||||
"Grep": 31,
|
||||
"Glob": 5,
|
||||
"Write": 1
|
||||
},
|
||||
"stream_sha256": "90a262f5cc189575b2e8b458c8e5cd17274fb493261c35135d199bee4895de24",
|
||||
"review_sha256": "e40dad6e1969898e35c82b7fd24517a5ee714d603a54fe3598b9e69c20922118",
|
||||
"auxiliary_model_note": "CLI model_usage may include auxiliary calls. assistant_models identifies the actual review message model. max was explicitly passed in the recorded command.",
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json.",
|
||||
"source_tree_equivalent_commit": "1ee64a8d8",
|
||||
"tool_scope_note": "Configured Read/Grep/Glob. The CLI also attempted a Write to its own plan file; no production source was edited by the reviewer."
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
)
|
||||
|
||||
func TestIssue77CurrentHealInputs(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: review77HealInputs})
|
||||
}
|
||||
|
||||
func review77HealInputs(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
ctx := t.Context()
|
||||
localID, remoteID := globalDeploymentID(), "issue77-remote"
|
||||
created := UTCNow().Add(-time.Hour)
|
||||
at := created.Add(10 * time.Minute)
|
||||
newerAt := at.Add(time.Minute)
|
||||
enc := func(b []byte) *string { s := base64.StdEncoding.EncodeToString(b); return &s }
|
||||
base := newBucketMetadata(bucket)
|
||||
base.SetCreatedAt(created)
|
||||
base.defaultTimestamps()
|
||||
c := &SiteReplicationSys{enabled: true}
|
||||
status := func(local, remote madmin.SRBucketInfo, mismatch madmin.SRBucketStatsSummary) srStatusInfo {
|
||||
local.Bucket, remote.Bucket = bucket, bucket
|
||||
return srStatusInfo{Sites: map[string]madmin.PeerInfo{localID: {Name: "local"}, remoteID: {Name: "remote"}}, BucketStats: map[string]map[string]srBucketStatsSummary{bucket: {localID: {SRBucketStatsSummary: mismatch, meta: srBucketMetaInfo{SRBucketInfo: local, DeploymentID: localID}}, remoteID: {meta: srBucketMetaInfo{SRBucketInfo: remote, DeploymentID: remoteID}}}}}
|
||||
}
|
||||
t.Run(backend+"/quota-tombstone-cache", func(t *testing.T) {
|
||||
quota, _ := json.Marshal(madmin.BucketQuota{Quota: 1024, Type: madmin.HardQuota})
|
||||
meta := base
|
||||
meta.QuotaConfigJSON, meta.QuotaConfigUpdatedAt = quota, at
|
||||
if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := status(madmin.SRBucketInfo{CreatedAt: created, QuotaConfig: enc(quota), QuotaConfigUpdatedAt: at}, madmin.SRBucketInfo{CreatedAt: created, QuotaConfigUpdatedAt: newerAt}, madmin.SRBucketStatsSummary{QuotaCfgMismatch: true})
|
||||
if err := c.healBucketQuotaConfig(ctx, obj, bucket, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cached, _, err := globalBucketMetadataSys.GetQuotaConfig(ctx, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disk, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(disk.QuotaConfigJSON) != 0 {
|
||||
t.Fatal("quota tombstone not written to disk")
|
||||
}
|
||||
if cached != nil && cached.Quota != 0 {
|
||||
t.Errorf("QUOTA_CACHE: disk has no quota, cache still enforces %d", cached.Quota)
|
||||
}
|
||||
})
|
||||
t.Run(backend+"/same-payload-time-barrier", func(t *testing.T) {
|
||||
xml := []byte(`<Tagging><TagSet><Tag><Key>key</Key><Value>same</Value></Tag></TagSet></Tagging>`)
|
||||
meta := base
|
||||
meta.TaggingConfigXML, meta.TaggingConfigUpdatedAt = xml, at
|
||||
if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Even forcing mismatch=true cannot make the existing heal advance a
|
||||
// timestamp when the live payload bytes already match.
|
||||
s := status(madmin.SRBucketInfo{CreatedAt: created, Tags: enc(xml), TagConfigUpdatedAt: at}, madmin.SRBucketInfo{CreatedAt: created, Tags: enc(xml), TagConfigUpdatedAt: newerAt}, madmin.SRBucketStatsSummary{TagMismatch: true})
|
||||
if err := c.healTagMetadata(ctx, obj, bucket, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !after.TaggingConfigUpdatedAt.Equal(newerAt) {
|
||||
t.Errorf("BARRIER_NOT_HEALED: same payload remains at %s, latest is %s", after.TaggingConfigUpdatedAt, newerAt)
|
||||
}
|
||||
})
|
||||
t.Run(backend+"/creation-default-selection", func(t *testing.T) {
|
||||
policy := []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"}]}`, bucket))
|
||||
meta := base
|
||||
meta.PolicyConfigJSON, meta.PolicyConfigUpdatedAt = policy, at
|
||||
baselineAt := newerAt.Add(time.Hour)
|
||||
s := status(madmin.SRBucketInfo{CreatedAt: created, Policy: policy, PolicyUpdatedAt: at}, madmin.SRBucketInfo{CreatedAt: baselineAt, PolicyUpdatedAt: baselineAt}, madmin.SRBucketStatsSummary{PolicyMismatch: true})
|
||||
wrong := 0
|
||||
for i := 0; i < 32; i++ {
|
||||
if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.healBucketPolicies(ctx, obj, bucket, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after.PolicyConfigJSON) == 0 {
|
||||
wrong++
|
||||
}
|
||||
}
|
||||
if wrong > 0 {
|
||||
t.Errorf("DEFAULT_SELECTED: creation-default erased valid policy in %d/32 heal rounds", wrong)
|
||||
}
|
||||
})
|
||||
t.Run(backend+"/tag-remote-source-time", func(t *testing.T) {
|
||||
var got []madmin.SRBucketMeta
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var item madmin.SRBucketMeta
|
||||
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
|
||||
t.Error(err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
got = append(got, item)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer remote.Close()
|
||||
svc, err := auth.CreateCredentials("issue77-heal-service", "issue77-heal-service-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.ParentUser = cred.AccessKey
|
||||
if _, err = globalIAMSys.store.AddServiceAccount(ctx, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer globalIAMSys.DeleteServiceAccount(ctx, svc.AccessKey, false)
|
||||
peers := map[string]madmin.PeerInfo{localID: {Name: "local", DeploymentID: localID}, remoteID: {Name: "remote", DeploymentID: remoteID, Endpoint: remote.URL}}
|
||||
remoteClient := &SiteReplicationSys{enabled: true, state: srState{ServiceAccountAccessKey: svc.AccessKey, Peers: peers}}
|
||||
xml := []byte(`<Tagging><TagSet><Tag><Key>key</Key><Value>same</Value></Tag></TagSet></Tagging>`)
|
||||
s := status(madmin.SRBucketInfo{CreatedAt: created, Tags: enc(xml), TagConfigUpdatedAt: newerAt}, madmin.SRBucketInfo{CreatedAt: created}, madmin.SRBucketStatsSummary{})
|
||||
v := s.BucketStats[bucket][remoteID]
|
||||
v.TagMismatch = true
|
||||
s.BucketStats[bucket][remoteID] = v
|
||||
if err := remoteClient.healTagMetadata(ctx, obj, bucket, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("remote received %d events", len(got))
|
||||
}
|
||||
if !got[0].UpdatedAt.Equal(newerAt) {
|
||||
t.Errorf("TAG_WIRE_TIME: sent %s, want %s", got[0].UpdatedAt, newerAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssue77CurrentPeerCheckBeforeLock(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
meta, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldAt, newAt := meta.Created.Add(time.Hour), meta.Created.Add(2*time.Hour)
|
||||
oldXML := base64.StdEncoding.EncodeToString([]byte(`<Tagging><TagSet><Tag><Key>key</Key><Value>older</Value></Tag></TagSet></Tagging>`))
|
||||
newXML := []byte(`<Tagging><TagSet><Tag><Key>key</Key><Value>newest</Value></Tag></TagSet></Tagging>`)
|
||||
lockCtx, unlock, err := lockBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
locked := true
|
||||
defer func() {
|
||||
if locked {
|
||||
unlock()
|
||||
}
|
||||
}()
|
||||
ready := make(chan struct{}, 1)
|
||||
hook := func(name string) {
|
||||
if name == bucket {
|
||||
select {
|
||||
case ready <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
lockBucketMetadataAcquireHook.Store(&hook)
|
||||
defer lockBucketMetadataAcquireHook.Store(nil)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- globalSiteReplicationSys.PeerBucketTaggingHandler(ctx, bucket, &oldXML, oldAt) }()
|
||||
select {
|
||||
case <-ready:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("older event never reached metadata lock")
|
||||
}
|
||||
// A newer writer commits while holding the existing metadata lock.
|
||||
// The old peer event has already checked the pre-commit cache.
|
||||
meta.TaggingConfigXML, meta.TaggingConfigUpdatedAt = newXML, newAt
|
||||
if err := globalBucketMetadataSys.saveMetadata(lockCtx, obj, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unlock()
|
||||
locked = false
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("older event did not finish")
|
||||
}
|
||||
after, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(after.TaggingConfigXML) != string(newXML) {
|
||||
t.Errorf("CHECK_OUTSIDE_LOCK: queued older event replaced newer committed tags with %q", after.TaggingConfigXML)
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package cmd
|
||||
|
||||
// Review-only tests against the unchanged 2026-09-12 main. Failures are evidence,
|
||||
// not a production regression-test submission.
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
)
|
||||
|
||||
func TestIssue77CurrentSourceTimeAndDeletion(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: review77SourceTimeAndDeletion})
|
||||
}
|
||||
|
||||
func review77SourceTimeAndDeletion(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
ctx := t.Context()
|
||||
created := UTCNow().Add(-time.Hour)
|
||||
putAt := created.Add(10 * time.Minute)
|
||||
delAt := putAt.Add(time.Minute)
|
||||
enc := func(s string) *string { v := base64.StdEncoding.EncodeToString([]byte(s)); return &v }
|
||||
base := newBucketMetadata(bucket)
|
||||
base.SetCreatedAt(created)
|
||||
base.defaultTimestamps()
|
||||
quotaJSON, err := json.Marshal(madmin.BucketQuota{Quota: 1024, Type: madmin.HardQuota})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
name, file string
|
||||
put madmin.SRBucketMeta
|
||||
value func(BucketMetadata) []byte
|
||||
stamp func(BucketMetadata) time.Time
|
||||
exported func(madmin.SRBucketInfo) time.Time
|
||||
deletable bool
|
||||
}{
|
||||
{"policy", bucketPolicyConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypePolicy, Policy: []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"}]}`, bucket))}, func(m BucketMetadata) []byte { return m.PolicyConfigJSON }, func(m BucketMetadata) time.Time { return m.PolicyConfigUpdatedAt }, func(m madmin.SRBucketInfo) time.Time { return m.PolicyUpdatedAt }, true},
|
||||
{"tags", bucketTaggingConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeTags, Tags: enc(`<Tagging><TagSet><Tag><Key>key</Key><Value>old</Value></Tag></TagSet></Tagging>`)}, func(m BucketMetadata) []byte { return m.TaggingConfigXML }, func(m BucketMetadata) time.Time { return m.TaggingConfigUpdatedAt }, func(m madmin.SRBucketInfo) time.Time { return m.TagConfigUpdatedAt }, true},
|
||||
{"sse", bucketSSEConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeSSEConfig, SSEConfig: enc(`<ServerSideEncryptionConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>`)}, func(m BucketMetadata) []byte { return m.EncryptionConfigXML }, func(m BucketMetadata) time.Time { return m.EncryptionConfigUpdatedAt }, func(m madmin.SRBucketInfo) time.Time { return m.SSEConfigUpdatedAt }, true},
|
||||
{"quota", bucketQuotaConfigFile, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeQuotaConfig, Quota: quotaJSON}, func(m BucketMetadata) []byte { return m.QuotaConfigJSON }, func(m BucketMetadata) time.Time { return m.QuotaConfigUpdatedAt }, func(m madmin.SRBucketInfo) time.Time { return m.QuotaConfigUpdatedAt }, true},
|
||||
{"versioning", bucketVersioningConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeVersionConfig, Versioning: enc(`<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>`)}, func(m BucketMetadata) []byte { return m.VersioningConfigXML }, func(m BucketMetadata) time.Time { return m.VersioningConfigUpdatedAt }, nil, false},
|
||||
{"objectlock", objectLockConfig, newSRBucketObjectLockMeta(bucket, enc(`<ObjectLockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>30</Days></DefaultRetention></Rule></ObjectLockConfiguration>`), putAt), func(m BucketMetadata) []byte { return m.ObjectLockConfigXML }, func(m BucketMetadata) time.Time { return m.ObjectLockConfigUpdatedAt }, nil, false},
|
||||
}
|
||||
exporter := &SiteReplicationSys{enabled: true}
|
||||
for _, tc := range cases {
|
||||
t.Run(backend+"/"+tc.name, func(t *testing.T) {
|
||||
if err := globalBucketMetadataSys.save(ctx, base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := tc.put
|
||||
item.Bucket, item.UpdatedAt = bucket, putAt
|
||||
apply := func(item madmin.SRBucketMeta) {
|
||||
t.Helper()
|
||||
rec := applySRBucketMetaViaAdmin(t, cred, item)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("admin apply returned %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
read := func() BucketMetadata {
|
||||
t.Helper()
|
||||
m, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
apply(item)
|
||||
put := read()
|
||||
if len(tc.value(put)) == 0 {
|
||||
t.Fatal("PUT did not establish live config")
|
||||
}
|
||||
if !tc.stamp(put).Equal(putAt) {
|
||||
t.Errorf("SOURCE_TIME: persisted %s, want source %s", tc.stamp(put), putAt)
|
||||
}
|
||||
apply(madmin.SRBucketMeta{Type: item.Type, Bucket: bucket, UpdatedAt: delAt})
|
||||
after := read()
|
||||
if !tc.deletable {
|
||||
if !bytes.Equal(tc.value(put), tc.value(after)) || !tc.stamp(put).Equal(tc.stamp(after)) {
|
||||
t.Error("NIL_NOOP: update-only config changed")
|
||||
}
|
||||
t.Log("nil payload is correctly a no-op")
|
||||
return
|
||||
}
|
||||
if len(tc.value(after)) != 0 {
|
||||
t.Error("NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE")
|
||||
}
|
||||
if _, err := globalBucketMetadataSys.Delete(ctx, bucket, tc.file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tombstone := read()
|
||||
if len(tc.value(tombstone)) != 0 {
|
||||
t.Fatal("local DELETE failed to establish tombstone")
|
||||
}
|
||||
info, err := exporter.SiteReplicationMetaInfo(ctx, obj, madmin.SRStatusOptions{Entity: madmin.SRBucketEntity, EntityValue: bucket})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tc.exported(info.Buckets[bucket]).Equal(tc.stamp(tombstone)) {
|
||||
t.Errorf("TOMBSTONE_EXPORT: got %s, want %s", tc.exported(info.Buckets[bucket]), tc.stamp(tombstone))
|
||||
}
|
||||
apply(item)
|
||||
after = read()
|
||||
if len(tc.value(after)) != 0 {
|
||||
t.Error("STALE_RESURRECTION: older source PUT resurrected a locally deleted config")
|
||||
} else {
|
||||
t.Log("older source PUT did not resurrect config")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue77CurrentBulkApplyOrdering(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
meta, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newAt := meta.Created.Add(time.Hour)
|
||||
newXML := `<Tagging><TagSet><Tag><Key>key</Key><Value>new</Value></Tag></TagSet></Tagging>`
|
||||
meta.TaggingConfigXML, meta.TaggingConfigUpdatedAt = []byte(newXML), newAt
|
||||
if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldXML := base64.StdEncoding.EncodeToString([]byte(`<Tagging><TagSet><Tag><Key>key</Key><Value>old</Value></Tag></TagSet></Tagging>`))
|
||||
item := madmin.SRBucketMeta{Bucket: bucket, Tags: &oldXML, UpdatedAt: newAt.Add(-time.Minute)}
|
||||
rec := applySRBucketMetaViaAdmin(t, cred, item)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("bulk apply returned %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
after, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(after.TaggingConfigXML, []byte(newXML)) || !after.TaggingConfigUpdatedAt.Equal(newAt) {
|
||||
t.Errorf("BULK_STALE_OVERWRITE: older bulk event overwrote newer config, value=%q time=%s", after.TaggingConfigXML, after.TaggingConfigUpdatedAt)
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cmd
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
)
|
||||
func TestIssue77PlanAdoptionBaseline(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t:t,objAPITest:func(_ ObjectLayer, instanceType,bucket string,_ http.Handler,_ auth.Credentials,t *testing.T){
|
||||
before,err:=globalBucketMetadataSys.GetConfigFromDisk(t.Context(),bucket);if err!=nil{t.Fatal(err)}
|
||||
if len(before.PolicyConfigJSON)!=0 || !before.PolicyConfigUpdatedAt.Equal(before.Created) {t.Fatalf("unexpected initial state: %#v",before)}
|
||||
olderCreated:=before.Created.Add(-time.Hour)
|
||||
if err:=globalSiteReplicationSys.PeerBucketMakeWithVersioningHandler(t.Context(),bucket,MakeBucketOptions{CreatedAt:olderCreated});err!=nil{t.Fatal(err)}
|
||||
after,err:=globalBucketMetadataSys.GetConfigFromDisk(t.Context(),bucket);if err!=nil{t.Fatal(err)}
|
||||
falseTombstone:=len(after.PolicyConfigJSON)==0 && after.PolicyConfigUpdatedAt.After(after.Created)
|
||||
t.Logf("%s ADOPTION_BASELINE: previous Created=%s field=%s; after Created=%s field=%s; nil with field>Created=%v",instanceType,before.Created.Format(time.RFC3339Nano),before.PolicyConfigUpdatedAt.Format(time.RFC3339Nano),after.Created.Format(time.RFC3339Nano),after.PolicyConfigUpdatedAt.Format(time.RFC3339Nano),falseTombstone)
|
||||
if !falseTombstone {t.Fatal("expected to reproduce default timestamp becoming an apparent tombstone")}
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
=== RUN TestIssue77PlanAdoptionBaseline
|
||||
issue77_plan_adoption_test.go:17: ErasureSD ADOPTION_BASELINE: previous Created=2026-09-12T09:49:46.959131+08:00 field=2026-09-12T09:49:46.959131+08:00; after Created=2026-09-12T08:49:46.959131+08:00 field=2026-09-12T09:49:46.959131+08:00; nil with field>Created=true
|
||||
issue77_plan_adoption_test.go:17: Erasure ADOPTION_BASELINE: previous Created=2026-09-12T09:49:47.012956+08:00 field=2026-09-12T09:49:47.012956+08:00; after Created=2026-09-12T08:49:47.012956+08:00 field=2026-09-12T09:49:47.012956+08:00; nil with field>Created=true
|
||||
--- PASS: TestIssue77PlanAdoptionBaseline (0.13s)
|
||||
PASS
|
||||
ok github.com/minio/minio/cmd 1.458s
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"worktree": "$TMPDIR",
|
||||
"source_commit": "5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"scope": "diagnostic of existing code only; no product implementation",
|
||||
"command": "GOWORK=off go test ./cmd -run '^TestIssue77PlanAdoptionBaseline$' -count=1 -v",
|
||||
"exit_code": 0,
|
||||
"outcome": "Both ObjectLayers reproduced a nil default state becoming an apparent tombstone after Created moved earlier; no product fix implemented.",
|
||||
"temporary_worktree_removed": true,
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# 作者补充核验
|
||||
|
||||
S1:`internal/logger/logonce.go:95` 按错误正文而非只按 key 去重。动态时间进入正文会绕过去重。修订要求:稳定的原因错误,动态详情放 logger ReqInfo,沿用现有每小时清理机制;不新增日志/限频框架。
|
||||
|
||||
S2:当前 silo-pkg 的 ActionSet.MarshalJSON 与 ResourceSet.MarshalJSON 直接枚举 map。实测同一个已解析 BucketPolicy 连续 json.Marshal 100 次,得到 4 种字节序列。源代码探针为 policy-encoding-probe.go.txt;使用项目 go.mod 选择的 pgsty/silo-pkg/v3 v3.13.4-0.20260910091716-2d8fd3cbbf07,命令为 GOWORK=off go run <探针临时路径>。
|
||||
|
||||
因此普通 json.Marshal 不能当作稳定排序键。v2 需要一个仅用于 Server 桶策略比较的确定性表示,对已通过现有解析器校验的策略排序集合成员;不能为了这一点修改依赖或持久化 wire 格式。
|
||||
|
||||
以上为作者在外部 v1 评审期间做的独立补查,不冒充 Claude 的评审发现,也不代表已经实现修复。
|
||||
@@ -0,0 +1,32 @@
|
||||
# v1 对抗评审意见与 v2 处置
|
||||
|
||||
评审者:真实 Claude Code 2.1.258,审查消息模型为 `claude-opus-5`,CLI 明确使用 `--effort max`。首轮对 main `5c576581631561c446f30ae5b566f0aa793adc1c` 的冻结 v1 给出 **NO_GO**,耗时约 24 分钟。完整输出见 [首轮评审](opus5-max-v1.md),调用证据见 [元数据](opus5-max-v1.metadata.json)。
|
||||
|
||||
本文件记录作者核对源码后的处置,不能代替评审者的后续结论。v2 尚未实现。
|
||||
|
||||
| ID | 处置 | v2 中的具体变化与理由 |
|
||||
| --- | --- | --- |
|
||||
| R1 / P1 | 接受 | 选择其方案 b:保留合法零 quota 文档,取消普通 quota PUT 的出站 nil 改写。补充落盘/事件状态三元组一致性。Policy 保留既有 peer 空策略=删除语义,本地/导入/bulk 也归一成删除;说明空策略本地 GET 行为变化。导入空 Policy 用现有专用 nil 事件,避免 bulk omitempty 漏发。 |
|
||||
| R2 / P1 | 接受,缩小实现方式 | 比较有效归一状态,复用已有 Object Lock→Versioning 规则;bulk 按最终接受的 Object Lock 处理 Versioning。saveMetadata 用指针让调用方保留 Save 后快照;公共 Update/Delete 签名保持不变,需要发送归一载荷的本地路径使用内部结果。写明同时间 live 键较大者胜,导入 hook 来自提交快照。 |
|
||||
| R3 / P1 | 接受“不得缺省即删除”的要求;纠正部分依据 | RawMessage 的 nil/空切片确实被 omitempty 省略,但显式 JSON null 解码为非 nil 的字节 `null`;非空 JSON 的空策略也可明确表达。实际 madmin-go/Go 编解码探针已验证。因此不采纳“bulk Policy 不可能表达语义删除、只有 Tags/SSE 可以”的绝对结论。v2 列出真实 wire 判定:未提供保留、Policy 提供但语义空则删除、Quota 提供的零值/null 仍为 live,Tags/SSE 显式空字符串删除;T5 经真实 marshal/unmarshal 验证。 |
|
||||
| R4 / P2 | 接受 | 选源和 apply 都使用 <= Created 的 baseline 判定;候选不晚于目标 Created 时逐目标跳过并限频诊断。独立创建世代冲突需运维,不纳入 T9 自动收敛。 |
|
||||
| R5 / P2 | 接受但保留兼容例外 | 三种内部状态明确化,非零 baseline 事件不覆盖真实配置。零时间专用事件依 O1 保留兼容应用,它是明确例外;修复后的正常发送端不得产生该状态。 |
|
||||
| R6 / P2 | 接受 | 比较视图统一把零字段时间回退到 Created,bulk 原始读取与 load/defaultTimestamps 不再得到不同裁决。 |
|
||||
| R7 / P2 | 接受 | 六类 heal 的源/目标循环跳过 unknown/空 deployment ID;单个 peer 失败继续健康目标,覆盖可达站点不被占位项阻断。 |
|
||||
| R8 / P2 | 接受 | 明确四类清空走现有 parse=false 新对象;bulk 原始读取后保存。不改 Quota getter 或无关解析分支;保留磁盘/缓存/重载回归。 |
|
||||
| R9 / P2 | 接受 | 显式保留 Object Lock 载荷在 legacy Tags 字段时的回退与排序测试。 |
|
||||
| R10 / P2 | 接受 | 升级文档点名旧 Tag heal 无 UpdatedAt,说明其到达时间/legacy-zero 污染不能自动修复。 |
|
||||
| O1 / 缩减 | 接受 | 删除 on 模式拒绝零时间的新行为;两种模式统一兼容。开关改名 METADATA_TOMBSTONES,只控制新增墓碑导出/初次同步。 |
|
||||
| O2 / 缩减 | 接受 | 沿用现有 env.Get 的 on/off 写法,不加配置框架。 |
|
||||
| O3 / 缩减 | 接受 | 旧/新真实进程只作一次性升级冒烟;长期回归用真实 wire/SRInfo 模拟。保留修复版双站点实验以验证实际最终收敛。 |
|
||||
| O4 / 缩减 | 接受 | 明确复用现有 lockBucketMetadataAcquireHook / RMW 屏障,不新增产品测试钩子。 |
|
||||
| O5 / 缩减 | 接受 | 删除无行为意义的 deployment ID 日志裁决要求;它不参与持久化排序。 |
|
||||
| O6 / 缩减 | 部分接受 | 删除不必要的 stale-conflict 警告,剩三类。保留 indeterminate 并明确真实触发条件:未知 Created、不可用来源、有实质差异却无合法候选;它不等于 before-created。 |
|
||||
|
||||
## 作者补充核验
|
||||
|
||||
- **S1:日志去重。** `internal/logger/logonce.go:95-123` 同时依赖稳定 key 和错误正文,变化时间放正文会导致每轮记录。v2 使用固定原因错误,动态详情放 ReqInfo,不新增框架。
|
||||
- **S2:Policy JSON 编码。** 当前 silo-pkg 的 ActionSet/ResourceSet MarshalJSON 枚举 map。同一个已解析策略编码 100 次得到 4 种结果,因此 v1 的普通 JSON 比较键不稳定。v2 增加 Server 内的策略比较键,对已校验策略的集合数组/对象键排序,不改变 wire 或依赖。探针见 [policy-encoding-probe.go.txt](policy-encoding-probe.go.txt)。
|
||||
- **S3:R3 的实际 wire 边界。** 使用项目选定的 madmin-go v3.0.110 与 silo-pkg 执行真实编解码,确认显式 null 与 omitted 不同,带 Version 的空 Statement 策略通过现有解析且 IsEmpty=true。源码和输出见 [wire-state-probe.go.txt](wire-state-probe.go.txt)、[wire-state-probe.log](wire-state-probe.log)。`cmd/bucket-quota.go:99-110` 证实 Quota null 由现有解析器接受为零值对象;不把它改成删除。
|
||||
|
||||
以上均为方案核查和小型诊断。没有修改产品源文件,没有把首轮 NO_GO 改写为通过;后续复审针对冻结 v2 独立给出结论。
|
||||
@@ -0,0 +1,31 @@
|
||||
# v2 对抗复审意见与 v3 处置
|
||||
|
||||
第二轮由相同 Claude Code / `claude-opus-5 --effort max` 执行,仅有 Read/Grep/Glob 工具,耗时约 11 分钟。结论 **NO_GO,1 个新增阻断**;明确确认首轮 R1/R2/R3 已全部关闭,并承认首轮 R3 关于 RawMessage 的部分依据错误。见 [第二轮原文](opus5-max-v2.md)、[调用证据](opus5-max-v2.metadata.json)。
|
||||
|
||||
## P1-1:历史桶字段时间等于 Created
|
||||
|
||||
接受阻断。源码核对确认历史配置可合法带 `UpdatedAt == Created`,MakeBucketHook 还会把同一 Created 传给新站点。v2 把等号也拒绝,会阻断初次同步,并因 heal 过滤 baseline 而无法补救。
|
||||
|
||||
对评审中的小处事实作校正:当前 `applyLegacyConfigs` 末尾已经调用 `defaultTimestamps()`,因此“迁移后磁盘字段仍为零”不普遍成立;**字段等于 Created 的反例成立,不影响阻断判断**。
|
||||
|
||||
v3 的最小修订是保留 baseline 的弱初值语义,不引入新 wire 字段或重盖到达时间:
|
||||
|
||||
| 输入/目标 | v3 行为 |
|
||||
| --- | --- |
|
||||
| 历史 live@Created → 空 baseline | 接受初始化,保存相同源时间;初次同步和 heal 一致 |
|
||||
| baseline-live → baseline-live | 稳定比较键较大者胜;全 baseline-live 也能收敛 |
|
||||
| baseline-live → 真实 live/tombstone | 不覆盖;真实状态优先级先于时间比较 |
|
||||
| 空/nil@Created → 任意已有配置 | 不能作为删除,不清空 |
|
||||
| 非零时间严格早于目标 Created | 原有创建保护,成功/no-op 与有界诊断 |
|
||||
| 全为空 baseline | found=false,安静不写入 |
|
||||
|
||||
T7/T8 新增上述用例,特别是历史桶从初次同步到完整一轮 heal 的验收;六类类型约束、删除 parse=false、旧事件不能复活 tombstone 均保留。
|
||||
|
||||
## 四条 P2
|
||||
|
||||
1. **Policy 稳定键:接受目标,不叠加第二种判等。** v3 明确对已解析策略完整 JSON 树做递归排序,覆盖 NotAction/NotResource 与 Condition,保留数字精度,并由两站点独立解析验证同键。未采纳 Equals 优先再用另一种键的建议:当前 `BucketPolicy.Equals` 对 Statement 次序敏感,而 `BPStatement.Equals` 忽略 Sid,两套判等混用可能使同时间冲突的比较不一致。公开统计现用 Equals 不变。当前 BPStatement 不支持 NotPrincipal,不为此新增语法或改 silo-pkg。
|
||||
2. **off 并非禁止删除:接受。** 直写 Policy 原有墓碑在 off 仍参与 heal;普通删除事件一直复制;只有新增 Tags/SSE/Quota 墓碑导出与初次发送受开关控制。
|
||||
3. **off 期间过时 RPC:接受。** 隐藏墓碑使部分旧 RPC 仍被尝试、在接收端被排序拒绝;这是升级阶段明确代价,不要求零 RPC,不加每轮日志。稳定无额外 RPC 的验收要求明确放在完整状态可见之后。
|
||||
4. **空策略 API 行为:接受。** T1 明确带合法 Version 的空策略 PUT 成功,随后 GET NotFound,落盘与专用删除事件三元组一致;不能误改为 4xx。
|
||||
|
||||
三个实现提交没有增加,也没有新增协议、框架、依赖或长期集成门槛。v3 只需对本次 baseline 规则与以上说明做最后针对性复审;已关闭的首轮问题不重开全仓库审查。当前仍是设计文件,没有实现或执行实现期测试。
|
||||
@@ -0,0 +1,19 @@
|
||||
# v3 通过后的作者实测与 v4 最小补充
|
||||
|
||||
Opus 第三轮已经给出 **GO_WITH_NONBLOCKING_NOTES,实施前阻断 0**,确认历史 baseline-live 初始化规则正确。见 [第三轮意见](opus5-max-v3.md)。其两条可执行非阻断说明(历史初值第二轮不再广播、deployment ID 不参与比较/持久化)已明确写入 v4。
|
||||
|
||||
## S4:接管改变 Created 会把默认时间变成假墓碑
|
||||
|
||||
这是作者补查发现,不冒称为 Opus 结论。
|
||||
|
||||
当前 `PeerBucketMakeWithVersioningHandler`(`cmd/site-replication.go:946-961`)先加载并补齐默认字段时间,再调用 SetCreatedAt 改写 Created。若桶被接管到较早的共同 Created,原本 `PolicyUpdatedAt == oldCreated` 且 policy=nil 的缺省状态,变成 `PolicyUpdatedAt > newCreated`,新排序/导出规则便会把它当成真实删除。
|
||||
|
||||
已在固定 main 上的独立 worktree 执行 `GOWORK=off go test ./cmd -run '^TestIssue77PlanAdoptionBaseline$' -count=1 -v`:ErasureSD 与 Erasure(默认多盘)都复现了该条件。Created 前移一小时,nil Policy 的字段时间留在原 Created。测试 PASS 的含义是**旧代码确实出现该现象**,不是修复通过;详见 [测试源码](adoption-baseline-probe.go.txt)、[实际输出](adoption-baseline-probe.log)、[元数据](adoption-baseline-probe.metadata.json)。测试只新增在临时 worktree,没有改产品代码。
|
||||
|
||||
## 最小修订
|
||||
|
||||
在现有接管锁内记住 oldCreated,执行现有 SetCreatedAt 得到 newCreated;仅当 Created 改变时,将本轮六类字段中原本为 0 或 oldCreated 的默认时间调整为 newCreated,再执行既有 bootstrap。所有真实字段时间(原本 > oldCreated)及载荷不动。绝不能仅以 nil 载荷识别默认状态,否则会丢失真正删除时间。
|
||||
|
||||
这不是另做 #78 的配置接管修复,而是让 #77 新比较器需要的 baseline 不变量跨越已有 SetCreatedAt 写入路径仍成立。只在提交 1 加小分支,T8 增加默认时间维持 baseline、真实 PUT/DELETE 时间保留的断言;没有增加实现提交、存储字段、协议、锁或依赖。
|
||||
|
||||
v4 其余行为与已通过的 v3 相同;第四轮只核查这个接管增量。
|
||||
@@ -0,0 +1,32 @@
|
||||
# Issue #77 最终方案评审记录
|
||||
|
||||
2026-09-12,最终计划为 [issue-77-plan.md](../../issue-77-plan.md)。固定代码基准为 main `5c576581631561c446f30ae5b566f0aa793adc1c`;当日重新查询 #77 仍为 OPEN。
|
||||
|
||||
**结论:GO_WITH_NONBLOCKING_NOTES,实施前阻断 0。** 作者已把末轮非阻断说明落实到定稿,可进入实现;本轮没有修改产品代码、提交 PR、关闭 Issue 或发布制品。
|
||||
|
||||
## 实际执行的外部评审
|
||||
|
||||
使用本机 Claude Code 2.1.258,每轮均明确传入 `--model claude-opus-5 --effort max`,在固定 main 的隔离 worktree 中执行,仅提供 Read/Grep/Glob。所有记录到的评审 assistant 消息模型均为 `claude-opus-5`。CLI 的用量信息另含少量辅助模型调用,已如实保留在 metadata;这不是用其它模型代替 Opus 评审。
|
||||
|
||||
| 轮次 | 待审快照 | 结果 | 处置 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1,全面方案审查 | [v1](plan-v1.md) | [NO_GO,3 个 P1](opus5-max-v1.md) | 修正零 quota 保存/发送不一致、Object Lock 归一后的提交快照、bulk wire 删除规则;缩减开关及验收范围 |
|
||||
| 2,修订复审 | [v2](plan-v2.md) | [NO_GO,新增 1 个 P1](opus5-max-v2.md) | 首轮 3 项确认关闭;修正历史 live@Created 的初始化回归 |
|
||||
| 3,历史初值增量 | [v3](plan-v3.md) | [GO_WITH_NONBLOCKING_NOTES,0 阻断](opus5-max-v3.md) | baseline-live 可初始化、不能覆盖真实状态;默认空值不能删除 |
|
||||
| 4,接管增量 | [v4](plan-v4.md) | [GO_WITH_NONBLOCKING_NOTES,0 阻断](opus5-max-v4.md) | 作者实测假墓碑后新增最小时间归一分支,经 Opus 确认必要性与充分性 |
|
||||
|
||||
逐项记录:[首轮处置](decisions-v2.md)、[第二轮处置](decisions-v3.md)、[接管增量](decisions-v4.md)。每轮相邻的 `opus5-max-vN.metadata.json` 保存命令、模型、工具、耗时、源 SHA、待审计划哈希及结果哈希。
|
||||
|
||||
## 末轮非阻断说明的落实
|
||||
|
||||
- T8 已明确 Created 前移、后移与不变三种情况;历史 baseline-live 第一轮补齐,第二轮无额外写入/广播。
|
||||
- 保持只调整 #77 六类字段,在实现注释中说明边界。不采纳扩大到其它配置时间的可选建议:那些时间不是新比较器的输入,没有必要同批改变其行为。
|
||||
- Policy 稳定键与公开计数的判等职责明确;Sid 差异可以触发一次状态同步,但不重做 #91 的既有统计语义。递归规范化和数字精度由 T2 验证。
|
||||
|
||||
定稿相对于第四轮冻结快照只补充上述验收/注释说明和评审状态,没有新增实现范围。
|
||||
|
||||
## 证据边界
|
||||
|
||||
作者实测使用了实际 Go 编解码、当前 silo-pkg 的 Policy 编码,以及 ErasureSD/默认多盘 ObjectLayer 的接管路径。接管诊断 PASS 表示在未修复 main 上成功观察到假墓碑条件。既有问题复现记录见 [当前核验](../../issue-77-current.md)。
|
||||
|
||||
外部 reviewer 做的是源代码与方案核查,没有运行新实现测试;现在不存在本轮产品实现。局部回归、race、完整 CI、修复版双站点收敛、一次性升级冒烟均是后续实现阶段的验收,不得把本次评审结论当成这些验证已经通过。
|
||||
@@ -0,0 +1,124 @@
|
||||
Write 工具在本会话被禁用,无法落盘评审文件,因此评审报告直接输出如下。
|
||||
|
||||
# SILO #77 修复计划 v1 对抗评审
|
||||
|
||||
基准:worktree = main `5c576581631561c446f30ae5b566f0aa793adc1c`。本次只做方案评审:未改产品代码、未运行测试、未写 GitHub、未调用其他代理。
|
||||
|
||||
**证据分级**:**[源码]** 本次在该 SHA 直接确认(给文件:行)/**[复现]** `issue-77/current-tests.log` 已记录/**[推论]** 由源码事实推导、未执行/**[待验]** 需实现期测试。
|
||||
|
||||
---
|
||||
|
||||
## 结论:NO_GO
|
||||
|
||||
方向正确,但 §3 / §5 / §6 三处**契约文字**按字面实现会产生新的不收敛与潜在错删。计划约 85% 的内容经源码核对成立,不需要推翻结构——**修订 R1、R2、R3 后复审即可进入实现**。
|
||||
|
||||
---
|
||||
|
||||
## P1 阻断项
|
||||
|
||||
### R1 零配额在发送端被改写成删除,本地却保留 live 文档 → off(默认)永不收敛,on 主动删除本地合法文档
|
||||
|
||||
**源码位置**
|
||||
- `cmd/admin-bucket-handlers.go:85` 本地写入用的是**原始请求体** `data`;
|
||||
- `cmd/admin-bucket-handlers.go:97-99` `if quotaConfig.Size == 0 && quotaConfig.Quota == 0 { bucketMeta.Quota = nil }` —— 发出的事件被改写成**删除**;
|
||||
- `cmd/site-replication.go:2088-2107` 对端 `quota == nil` 走 `Delete`;
|
||||
- `madmin-go v3.0.110 quota-commands.go:54-60` `Quota == 0` 时 `IsValid()` 恒真(`{}` 是合法 live 文档),且该版本**只有 `SetBucketQuota`,没有删除端点** —— "清除配额"只能走这条零值 PUT;
|
||||
- `cmd/site-replication.go:4090-4094` 导出端仅在 `len(QuotaConfigJSON) > 0` 时带出时间,删除时间被隐藏。**[源码]**
|
||||
|
||||
**计划条目**:已确定契约"合法的空 quota 对象 `{}`…不能仅因配额为零就改写为 nil";§3"同时间真实删除大于 live";提交 3 tombstone 导出;完成条件"四类 DELETE"、T7。
|
||||
|
||||
**触发步骤**
|
||||
1. A 站点执行等价于 `mc quota clear`(零值 SetBucketQuota)。
|
||||
2. A 落盘 live `{...}`@T;发出的事件是 `Quota=nil`@T。
|
||||
3. B 按计划落盘 tombstone@T。
|
||||
4. **off(默认)**:B 的 tombstone 时间不导出 → B 候选被当 baseline 过滤 → 选源永远是 A 的 live@T → 推给 B → B 按"同时间删除优先"拒绝 → **每 30 秒(`site-replication.go:4651`)一次 RPC + 一次拒绝日志,永不收敛**。
|
||||
5. **on**:B 导出 tombstone@T → 同时间删除胜 → heal 反过来**删除 A 上合法的 live 文档**。
|
||||
|
||||
**影响**:这是清除配额的常规路径。主干今天同样不收敛(到达时间更大导致拒绝),所以**不是回归,但计划声称会修而按 v1 修不了**,T1/T7 不可达成。**[源码]+[推论]**
|
||||
|
||||
**同类第二处**:`cmd/bucket-policy-handlers.go:87-109` 本地存 live 的"语义为空"策略,`cmd/admin-handlers-site-replication.go:233-237` 在接收端判为删除。Policy 仍能收敛,只因 `site-replication.go:4069-4070` **无条件**导出 `PolicyUpdatedAt`。可见根因是"发送端语义 ≠ 本地落盘语义",不是导出策略。
|
||||
|
||||
**最小修正(二选一,必须成对,不能只改一侧)**
|
||||
- (a) 解析后 quota 为零值结构时本地也走 `Delete`(parse=false),事件仍为 nil;或
|
||||
- (b) 取消 `Quota = nil` 改写,事件携带真实文档。
|
||||
|
||||
并把"同一次本地操作,落盘状态与发出事件状态必须是同一状态"写成契约不变量;T1 增加三元组 (kind, payload, time) 一致性断言。
|
||||
|
||||
---
|
||||
|
||||
### R2 `saveMetadata` 内部静默归一化:落盘载荷 ≠ 比较载荷 ≠ 事件载荷;§6"无状态变化不保存"按字面不可实现;§3 未定义字节序方向
|
||||
|
||||
**源码位置**
|
||||
- `cmd/bucket-metadata.go:391-399` `parseAllConfigs` 在 `objectLockConfig != nil` 且 versioning 文档解析失败 / `!Enabled()` / `PrefixesExcluded()` 时改写 `VersioningConfigXML` 为常量,**不改 `VersioningConfigUpdatedAt`**;
|
||||
- `cmd/bucket-metadata.go:581-603` `Save` 每次保存前都调 `parseAllConfigs` → 归一化发生在**每一次** `saveMetadata`;
|
||||
- `cmd/bucket-metadata-sys.go:232-243` `saveMetadata(…, meta BucketMetadata)` **按值接收**,归一化只作用于被落盘/入缓存的副本,调用方手里的结构仍未归一化;
|
||||
- `cmd/admin-bucket-handlers.go:1094-1116` 导入把未归一化的 `merged` 赋回 `*meta`,随后 `hook.Versioning = enc(meta.VersioningConfigXML)` —— **发出的载荷与落盘载荷不同,时间戳却相同**;
|
||||
- `cmd/admin-bucket-handlers.go:823-835` 导入只拒绝 `Suspended`,**不拒绝 `PrefixesExcluded()`**(`internal/bucket/versioning/versioning.go:153-155`:`ExcludedPrefixes` 或 `ExcludeFolders` 均算);
|
||||
- `cmd/bucket-versioning-handler.go:68-84` S3 PUT 只在**本地** LockEnabled 时拒绝 prefix-excluded;
|
||||
- 现有回归 `cmd/site-replication-object-lock_test.go:149-167` 已把该机制固化为断言。**[源码][复现-通过]**
|
||||
|
||||
**计划条目**:§3"live/live 按稳定载荷字节序比较""apply 和 heal 使用同一比较函数";§6"无状态变化时不保存、不通知";提交 1 完成条件"导入的持久化时间与实际发出的事件一致"。
|
||||
|
||||
**为什么阻断**
|
||||
1. §6 只能在 `saveMetadata` 之前比较,而真正落盘的是归一化之后的结构——**规则按字面无法实现**,"无变化"的判定可能与磁盘不符。
|
||||
2. 计划把到达时间换成源时间后,**同一时间戳 + 不同载荷成为正常可达状态**(Object Lock 在站点间短暂不对称时必然出现);旧代码靠"到达时间总在前进"意外掩盖了它。
|
||||
3. §3 **没写明大者胜还是小者胜**。以 `…<Status>Enabled</Status><ExcludedPrefixes>` 对 `…<Status>Enabled</Status></VersioningConfiguration>` 为例,分歧字节是 `E`(0x45) 与 `/`(0x2F):方向选错,heal 每轮把未归一化文档推给已归一化站点,对方再归一化 → 重复写 + 全节点 `LoadBucketMetadata` 广播;方向选对则一轮收敛。**让正确性取决于未写明的字节序方向不可接受。**
|
||||
4. 导入路径违反提交 1 完成条件的实质:时间一致、状态不一致。
|
||||
|
||||
**诚实边界**:我**没有**证明这会无限发散。Object Lock 不可删除,`healOLockConfigMetadata`(`5379-5447`)通常 1 轮内补齐,之后两侧归一化结果相同并收敛。实际损害是"窗口期重复写/广播 + 实现定义的裁决"。**[源码]+[推论],永久性未证实。**
|
||||
|
||||
**最小修正**:比较器对 Versioning 先做与 `parseAllConfigs` 相同的归一化再比较;`saveMetadata` 回写/返回已持久化结构,导入与所有 hook 用**落盘后的字节**;§6 改为"以归一化后的落盘表示判定状态变化";§3 写明字节序方向并注明它只用于真并发冲突,不得用来消化序列化差异。
|
||||
|
||||
---
|
||||
|
||||
### R3 §5 的"bulk 中显式提供且为空 = 删除"对 Policy/Quota 在线路上不可表达;按字面实现存在"缺省即删除"的错删路径
|
||||
|
||||
**源码位置**
|
||||
- `madmin-go v3.0.110 cluster-commands.go:501-530`:`Policy`/`Quota` 是 `json.RawMessage` + `omitempty` —— **nil 与空切片都会被省略**,解码端永远拿到 nil;而 `Tags/SSEConfig/Versioning/ObjectLockConfig` 是 `*string`,`ptr("")` 可表达"提供且为空";
|
||||
- `cmd/site-replication.go:1653-1697` bulk 一律按 `!= nil` 判"是否提供";
|
||||
- `cmd/admin-bucket-handlers.go:1103-1131` 导入发出的正是 `Type` 为空的 bulk 事件,且只填本次导入涉及的字段。**[源码]**
|
||||
|
||||
**风险**:对 Policy/Quota,这条规则要么是走不到的死代码,要么诱导实现者写 `len(item.Policy) == 0 ⇒ 删除` —— 那样**每个只导入 tags 的 bulk 事件都会删掉对端 bucket policy**,正是 #77 要消灭的错删类。这是规范缺陷,不是当前代码缺陷。**[源码]+[推论]**
|
||||
|
||||
**最小修正**:§5 改为——bulk 只有 Tags/SSE 能表达删除(显式空 base64 串);Policy/Quota 删除只走专用事件;bulk 缺省字段任何情况下不改变该字段。T5 补两条断言。
|
||||
|
||||
---
|
||||
|
||||
## P2(建议同批修订)
|
||||
|
||||
- **R4 before-created 扩大到六类,但选源端不做同一判定。** `site-replication.go:1646-1651`(bulk 已有)、`2035-2040`(CORS 先例)、`bucket-metadata.go:190-197`(`SetCreatedAt` 无条件覆盖)、`site-replication.go:5486-5512`(`healBucket` 不修复两站点 Created 分歧)。§提交 2 只过滤 `==0` 与 `==CreatedAt`,**不过滤"早于自身 Created"**:分区期间两站点各自建过同名桶后,较老站点的真实配置被永久拒绝,heal 每轮仍选中它并挡住其它候选(今天会收敛)。**最小修正**:选源与 apply 用同一可用性判定(`time <= 自身 CreatedAt` 视为 baseline);对 `目标 CreatedAt > 候选时间` 按目标跳过并记一次 `before-created`;计划中明确这是**终态不收敛、需运维介入**,不计入 T9。
|
||||
- **R5 比较器缺第三种 kind(baseline)。** `site-replication.go:1851-1898` 的 `corsReplicationState` 已有三值模型。§3 只定义"删除 vs live",把"创建默认/未知"放在选源侧 → 任何异常发送端发出的 `nil + 创建默认时间` 在 apply 侧会被当 tombstone,同时间即可删掉对端 live。**最小修正**:六类复用 CORS 三值模型(baseline = 时间为零或不晚于 Created),apply 对 baseline 一律 no-op;顺带获得 `latestCORSConfig`(`5315-5329`)的稳定选择语义。
|
||||
- **R6 两个入口读到的"当前字段时间"来源不同。** `site-replication.go:1641` 用 `readBucketMetadata`(**不调** `defaultTimestamps`),`bucket-metadata.go:230-238` 才补默认值 → 同一记录在两条路径上分别是 `0` 与 `Created`,事件时间恰等于 `Created` 时裁决不同。**最小修正**:比较器统一 `if fieldTime.IsZero() { fieldTime = meta.Created }`。
|
||||
- **R7 heal 中不可达站点占位项会中途终止本轮修复。** `3136-3142` 把不可达站点填 `SRInfo{}`,`3219` 以空 DeploymentID 进入 `BucketStats`;`5006-5009`、`5070-5073`、`5145-5148` 等处 `getAdminClient("")` 失败即 `return`,**放弃该类型剩余站点**,且是否提前退出取决于 map 顺序。与 T9 承诺冲突。**最小修正**:两处循环都跳过 `info.Sites` 中不存在的 dID 并计入 `indeterminate`。
|
||||
- **R8 parse=true 置空字段留下已解析缓存。** `bucket-metadata.go:401-413`(versioning、quota)与 `334-338`(notification)在载荷为空时**无 else 分支**(即 [复现] `QUOTA_CACHE`)。§6 的"删除走 parse=false"规避有效且最小,但应写成显式不变量并在 T7 加断言。**不建议**本轮补 else —— 会改变 `GetQuotaConfig` 返回语义(`bucket-metadata-sys.go:572-581` 不做 nil 判断),超出范围。
|
||||
- **R9 Object Lock 的 legacy `Tags` 载荷回退必须保留。** `site-replication.go:1787-1796` + 回归 `site-replication-object-lock_test.go:132-138`。提交 1 的重构容易丢掉;T3 应显式覆盖"Type=ObjectLockConfig、载荷在 Tags 字段"的排序行为。
|
||||
- **R10 off 模式下 legacy-zero 事件盖上本地新时间后永久胜出。** `site-replication.go:5011-5015` 至今不带 `UpdatedAt`([复现] `TAG_WIRE_TIME`)。计划已有"升级后由操作者重新提交"的兜底,建议在文档条目里点名这个具体来源。
|
||||
|
||||
---
|
||||
|
||||
## 过度设计 / 可缩减
|
||||
|
||||
- **O1 开关承担三件事,只有一件有证据。** 证据(quota 缓存复现)只支持"tombstone 导出需等全站点升级"。"on 模式拒绝零源时间事件"没有对应缺陷,且计划已修复树内唯一的零时间发送端;保留它只会在误开时让 heal 每 30 秒报错。**建议把开关收缩为单一含义**:是否导出/发送真实删除状态(含初次同步);零时间事件两种模式统一按 legacy-zero 兼容应用 + 限频记录。
|
||||
- **O2 开关实现不需要新框架。** `cmd/common-main.go:902` 已有 `env.Get(name, config.EnableOff) == config.EnableOn` 的两行先例,照抄即可。
|
||||
- **O3 T8 的"固定旧版与修复版两个 SILO 进程"不必作为提交门槛。** 旧版对线路的可观测差异只有两点(heal 事件不带 `UpdatedAt`;导出不含 nil 载荷时间),进程内构造 `SRInfo`/`SRBucketMeta` 即可完整覆盖;降级为一次性人工冒烟。
|
||||
- **O4 T4 不需要新建"确定性锁屏障"。** 已有 `lockBucketMetadataAcquireHook`(`bucket-metadata-sys.go:249-255`)与 `bucket-metadata-lock_test.go` 的 `runBucketMetadataRMWConflict` 模式可直接复用。
|
||||
- **O5 §3 的 deployment ID 规则可删。** "仅在状态完全相同时用于日志来源"无可观测行为;复用 `latestCORSConfig`(`5322` 的 `cmp == 0 && dID > latestID`)即自动获得稳定性。
|
||||
- **O6 `indeterminate` 缺少明确产生条件。** 若采纳 R4,`before-created` 与 `indeterminate` 会合并,应减为三类,不要为对称保留空类别。
|
||||
|
||||
---
|
||||
|
||||
## 经核对成立、不应削减
|
||||
|
||||
锁外 getter→Update 竞态([复现] `TestIssue77CurrentPeerCheckBeforeLock` 失败);bulk 绕过逐字段排序(`1653-1697` 无字段时间比较,[复现] 失败);选源前过滤创建默认值([复现] 5/32、2/32 错删 Policy;六处重复写法 `4966-4982`、`5038-5054`、`5104-5120`、`5178-5194`、`5253-5269`、`5393-5409` 抽函数是净减法);Tag 远端 heal 缺 `UpdatedAt`(`5011-5015`);同内容不同时间不同步([复现] `BARRIER_NOT_HEALED`);复用 `metadata.lock` 与 `saveMetadata` 的物理桶检查(`232-243`)、放弃 `peer-require-existing`;导入 `updatedAt := UTCNow()`(`723`)在 ZIP 开始时取一次且可能早于随后创建的桶 `Created`,per-bucket `commitAt` 修正必要。另:`lockBucketMetadataWithTimeout` 不可重入,重入会挂到 `globalOperationTimeout`(非永久死锁但同样不可接受),"不循环调用公开 handler"正确;实现内部 apply 时须沿用 `updateAndParse` 传递 lock-ctx,否则 `loadBucketMetadataParse` 的迁移分支(`bucket-metadata.go:252`、`282`)会再次取锁。
|
||||
|
||||
---
|
||||
|
||||
## 实施前必须修订的条目
|
||||
|
||||
1. **R1** 零值配额的发送端/本地写一致性 + "落盘状态 == 发出状态"不变量。
|
||||
2. **R2** §6 比较基准改为归一化后的落盘表示;`saveMetadata` 回写已持久化结构;导入 hook 用落盘字节;§3 写明同时间载荷序方向及适用边界。
|
||||
3. **R3** §5 重写 bulk 删除语义(仅 Tags/SSE 可表达;缺省永不改变字段)+ 对应断言。
|
||||
|
||||
建议一并吸收 R4、R5 措辞修正与 O1、O3、O4 缩减,再做一次短复审即可进入实现。
|
||||
|
||||
**实现阶段需要运行(本次未运行)**:把两份审计用例改为确定性回归并先记录未修复失败;R1 零配额两侧三元组一致 + off/on 各一轮 heal 无重复 RPC;R2 Object Lock 桶 + prefix-excluded/ExcludeFolders versioning 走导入与 peer 两条路径,断言"发出载荷 == 落盘载荷"且第二轮 heal 无写入;R3 bulk 缺省/空串两类断言;既有回归 `TestPeerBucketObjectLockMetadata*`、`TestPeerBucketAdoption*`、`TestQueuedMetadataUpdateAfterDelete`、`TestSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig`、CORS 站点复制用例,加目标 race 与完整 Go CI。
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"reviewer": "Claude Code",
|
||||
"cli_version": "2.1.258",
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"source_commit": "5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"permission_mode": "plan",
|
||||
"tools": [
|
||||
"Read",
|
||||
"Grep",
|
||||
"Glob"
|
||||
],
|
||||
"round": 1,
|
||||
"plan_snapshot": "plan-v1.md",
|
||||
"command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --output-format stream-json --verbose",
|
||||
"status": "completed",
|
||||
"model_configuration_reference": "https://code.claude.com/docs/en/model-config",
|
||||
"observed_model": "claude-opus-5",
|
||||
"session_id": "9fb76061-6bcc-490c-a96b-9a1d4f9daa84",
|
||||
"observed_tools": [
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read"
|
||||
],
|
||||
"verdict": "NO_GO",
|
||||
"subtype": "success",
|
||||
"is_error": false,
|
||||
"duration_ms": 1437147,
|
||||
"num_turns": 57,
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"plan_sha256": "0218a2ca59750acd6a24dc5ece1d55a19762a863f1a2de8edc6ffc9b4ab072e0",
|
||||
"auxiliary_model_note": "CLI may report small auxiliary model calls in model_usage; assistant_models lists the actual recorded review message models. max was explicitly supplied in the CLI command.",
|
||||
"collected_at_utc": "2026-09-12T01:57:46.010985+00:00",
|
||||
"stream_sha256": "a145cd75ebf2ab3b722c7e1764036fcac8e3245ed2de3c2390dc0b5826e63d16",
|
||||
"review_sha256": "8a3b750a261ef520acbe658395d3303cea0835b07184b96022781a2a5d0d4dcf",
|
||||
"used_tools": [
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read",
|
||||
"Write"
|
||||
],
|
||||
"temporary_review_worktree_removed": true,
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# SILO #77 修复计划 v2 对抗复审(Claude Code Opus 5,只读)
|
||||
|
||||
基准 worktree = main `5c576581631561c446f30ae5b566f0aa793adc1c`。本轮只针对 v1→v2 的修订差异做源码核验:未改产品代码、未运行测试、未调用其他代理。
|
||||
|
||||
## 首轮三个阻断的关闭判断
|
||||
|
||||
**R1(零配额发送端改写 / 落盘≠出站):已关闭。** v2 取消 `admin-bucket-handlers.go:97-99` 的 `Quota=nil` 改写后,`mc quota clear` 两侧都落 live 零值文档:`bucket-quota.go:99-110` 对 `{}`/`null` 均解析为零值且 `IsValid()` 为真,`isBktQuotaCfgReplicated`(`site-replication.go:3850-3888`)此时 `numquotaCfgs==total`、Type 相同 → 判为已复制,`quotaCfgSet`(`3635`)仍为 false,#91 计数不受影响。接收端 `PeerBucketQuotaConfigHandler:2089` 会重新 `json.Marshal` 导致字节不同,但 v2 §2 规定 Quota 比较键取"解析结果的 JSON 编码",`madmin.BucketQuota` 是定长结构体、编码确定,不会因此空转。Policy 侧改成本地/导入/bulk 统一按 `IsEmpty()` 删除,与既有 `admin-handlers-site-replication.go:233-237` 一致,三元组不变量成立。
|
||||
|
||||
**R2(saveMetadata 静默归一 / §6 不可实现 / 字节序方向未定):已关闭。** `saveMetadata`(`bucket-metadata-sys.go:232`)全部 6 个调用点(`site-replication.go:961/1708/2049`、`bucket-metadata-sys.go:203/222/354`、`admin-bucket-handlers.go:1095`)传入的都是调用方自有值,改指针回写无别名风险;导入 hook 改用提交快照后,`1110-1127` 与落盘文档不再分叉;比较改用 `parseAllConfigs:391-399` 同一条 Object Lock→Enabled 归一规则,方向也已写明"键较大者胜"且禁止用字节序补偿隐式改写。
|
||||
|
||||
**R3(bulk 删除语义):已关闭,且首轮依据确属错误。** 复核确认:`json.RawMessage` 显式 `null` 解码为非 nil 的 `[]byte("null")`(探针 log 第 3-4 行),`policy/bucket-policy.go:78` 允许 `Version==""`,故 `null` 与 `{"Version":...,"Statement":[]}` 都能通过 `ParseBucketPolicyConfig` 且 `IsEmpty()` 为真;`*string` 的 `null` 才退化成 nil。v2 的逐字段规则与 `PeerBucketMetadataUpdateHandler:1653-1697` 的 `!=nil` 约定一致,也堵住了"`len==0 ⇒ 删除"的错删诱导。唯一 bulk 生产者(导入 `1103-1131`)在 `omitempty` 下漏发空 Policy 的问题,v2 用专用 nil 事件补齐,处置正确。
|
||||
|
||||
## P1(新增阻断,1 项)
|
||||
|
||||
**P1-1:`fieldTime <= Created` 一律判 baseline,会让历史桶的配置在初次同步中被静默丢弃且 heal 永不修复。**
|
||||
|
||||
反例(全部为已修复版本、同一桶世代、合法带时间事件,属计划承诺范围):
|
||||
|
||||
1. A 站点有 2020 年前迁移来的桶。`applyLegacyConfigs`(`bucket-metadata.go:482-513`)不写任何 `*UpdatedAt`,`convertLegacyConfigs` 保存后磁盘上这些字段仍是零值;每次 `loadBucketMetadataParse:236-238` → `defaultTimestamps:534-561` 把 `PolicyConfigUpdatedAt` 补成 **正好等于 Created**。
|
||||
2. 运维执行 `AddPeerClusters`。`MakeBucketHook:821-823` 把 `createdAt` 传给对端,`PeerBucketMakeWithVersioningHandler:950` 的 `SetCreatedAt` 使 B 的 `Created` 与 A **完全相等**。
|
||||
3. `syncToAllPeers:2217-2228` 发出 Policy@Created(非零,因此不走 legacy-zero 例外)。
|
||||
4. 按 v2 §3"非零事件**不晚于**目标桶 Created 时成功/no-op,记录 before-created" → B 直接 no-op,policy 落不下去。
|
||||
5. 按 v2 §2/提交 2.1,A 的候选是 baseline → 过滤 → "无候选,显式 found=false" → 永远不 heal,只产出 indeterminate。
|
||||
|
||||
主干今天这条路径是成功的(`PeerBucketPolicyHandler:1718-1746` 无 before-created 检查,直接应用),bulk 侧 `1646` 也只拒绝 **严格早于** Created。因此这是回归,不是遗留缺陷;Tags/SSE/Quota 同理,Object Lock 因 `955-959` 自举侥幸掩盖。T1–T9 没有"字段时间等于 Created 的历史桶初次同步"用例,实现期不会被发现。
|
||||
|
||||
最小修正(两句契约 + 一行验收):①把 apply 侧边界改成**严格早于** Created 才 before-created;`fieldTime == Created` 保留 baseline 的"弱"语义。②baseline 只是排序上低于任何非 baseline 候选,不是"不可用":目标该字段自身也是 baseline/缺失时,baseline-live 仍可确立初值,仅在存在非 baseline 候选时被压制;全 baseline 且内容不同时按既定键排序取胜者,而不是报 indeterminate。③T7/T8 增加一行"全部字段时间等于 Created 的历史桶,初次同步与一轮 heal 后各站点一致"。
|
||||
|
||||
## P2(仍需改动)
|
||||
|
||||
- **Policy 比较键的构造方式。** 递归排序自造规范 JSON 需要覆盖 `Condition` 的 `map[string]map[string]ValueSet`、`NotAction/NotResource/NotPrincipal`,一旦两站点对同一策略算出不同键,同时间平局会各自选出不同胜者 → 每轮互推、不收敛。建议判等直接复用现成的 `BucketPolicy.Equals`(`site-replication.go:3912` 已在用),规范串只用于同时间平局排序,并复用 `ActionSet.String()`(silo-pkg `policy/actionset.go:151-158`,已排序)。同时把"键必须是**已解析策略**的纯函数、跨站点必须一致"写成不变量,T2 补一条"两站点各自字节 → 相同键"断言。
|
||||
- **开关语义与 Policy 的实际不对称需写进文档。** `site-replication.go:4069-4070` 无条件导出 `PolicyUpdatedAt`,因此提交 1/2 落地后,**开关 off 时 Policy 删除照样会经 heal 传播**,只有 Tags/SSE/Quota 被门控。v2 表格里"初次同步的真实删除:off 保留已有行为"容易被读成"off 完全不传播删除",运维会据此做错误的升级判断。
|
||||
- **off 模式下的稳定态 RPC 要写进验收预期。** 持有 Tags/SSE/Quota 墓碑的站点在 off 下导出不带时间 → 每轮仍被选为待修复目标 → 每 30s(`4651`)一次必然被拒的 RPC。提交 2 的"对已归一且相同的目标不写入、不发 RPC"不覆盖该情形。请明确这是 off 期间的预期代价,并禁止为它加每轮日志(会与 §3 的三类有界日志冲突)。
|
||||
- **空策略 PUT 的对外行为补一条断言。** v2 让本地 `PutBucketPolicy` 对 `{"Version":"2012-10-17","Statement":[]}`(`bucket-policy-handlers.go:98-101` 只拒空 Version,该文档合法)改走删除。T1 请断言"PUT 返回成功、GET 返回 NotFound、出站专用事件与落盘一致",避免实现时误改成 4xx。
|
||||
|
||||
## 作者补查核验
|
||||
|
||||
**S1 成立。** `internal/logger/logonce.go:109` 仅在 `prev.Err.Error() == nerr.Error()` 时抑制;正文含变化时间会每轮重新打印(且分支不更新 map,等于完全失效)。固定正文 + ReqInfo 的做法正确。
|
||||
|
||||
**S2 成立。** `policy/actionset.go:144-168` 的 `MarshalJSON → ToSlice` 直接遍历 map 不排序,`json.Marshal(BucketPolicy)` 确实不稳定,v1 的字节比较键不可用;v2 另建比较键的结论正确(构造方式见上 P2)。
|
||||
|
||||
---
|
||||
|
||||
**结论:NO_GO。实施前阻断数:1(P1-1)。** 首轮 R1/R2/R3 三项均已实质关闭,R4–R10、O1–O6 的处置未引入新的明确阻断;余下 4 条 P2 建议与 P1-1 一并修订即可进入实现,不需要再做全量复审。
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"reviewer": "Claude Code",
|
||||
"cli_version": "2.1.258",
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"source_commit": "5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"permission_mode": "plan",
|
||||
"tools": [
|
||||
"Read",
|
||||
"Grep",
|
||||
"Glob"
|
||||
],
|
||||
"round": 2,
|
||||
"plan_snapshot": "plan-v2.md",
|
||||
"plan_sha256": "2b2c712b17f757ca1a8501714c6c2472bd442a6be50b29d92f2d87e23c412ba8",
|
||||
"command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --output-format stream-json --verbose",
|
||||
"status": "completed",
|
||||
"observed_model": "claude-opus-5",
|
||||
"session_id": "e40ce534-ece8-430f-841d-99add4fab13d",
|
||||
"observed_tools": [
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read"
|
||||
],
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"subtype": "success",
|
||||
"is_error": false,
|
||||
"duration_ms": 666254,
|
||||
"num_turns": 41,
|
||||
"collected_at_utc": "2026-09-12T01:40:02.942542+00:00",
|
||||
"stream_sha256": "7b3aa8de142e77b4ea1bca468685a04203261021c7978c3a97a27d1febacacab",
|
||||
"review_sha256": "848a476f18ce88208b72d8bf6cb49a2316b2b14dcba64ad66e636fe0a3d70595",
|
||||
"used_tools": [
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read"
|
||||
],
|
||||
"auxiliary_model_note": "CLI may report small auxiliary model calls in model_usage; assistant_models lists the actual recorded review message models. max was explicitly supplied in the CLI command.",
|
||||
"verdict": "NO_GO",
|
||||
"blocking_findings": 1,
|
||||
"closed_previous_blockers": [
|
||||
"R1",
|
||||
"R2",
|
||||
"R3"
|
||||
],
|
||||
"temporary_review_worktree_removed": true,
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
## P1-1:已关闭
|
||||
|
||||
v2 的阻断链条有三个环节,v3 逐个断开:
|
||||
|
||||
1. **apply 侧边界**:计划 §2 第 39 行改为「非零事件**严格早于**目标桶 Created 才成功/no-op 并记录 before-created」,并明写「等于目标 Created 的 live 事件可更新仍是 baseline 的目标字段,时间仍保存为源 Created」。v2 反例第 4 步(`==` 被判 before-created)不再成立。
|
||||
2. **heal 候选**:第 37 行只把「空 baseline」排除出源,baseline-live 是初始化候选;提交 2.1(第 72 行)过滤条件同样是「严格早于**自身** Created」,等于 Created 不被滤掉。v2 反例第 5 步(found=false 永不修复)不再成立。
|
||||
3. **验收**:T8 第 111 行有「历史字段时间全部等于 Created 的桶经初次同步和一轮 heal 后一致」,T7 第 110 行有 baseline-live 初始化与同级收敛。
|
||||
|
||||
## 四条反例核验
|
||||
|
||||
1. **历史 live@Created → 空字段**:初次同步走 apply 分支(baseline-live 胜空 baseline,时间存原值 Created);漏发时 heal 也能选到该候选。写入后两侧同为 baseline-live@Created、键相等,按第 40 行「键、状态级别和源时间相同为 no-op」+ 提交 2.4 稳定。✅
|
||||
2. **较晚空默认值**:第 40 行排序先比状态级别,「任何真实 live/tombstone 都胜 baseline」,且空 baseline 既不是源也不是删除(37/39 行、decisions 表第 18 行)。时间更晚的空默认值无法压过较早真实修改。✅
|
||||
3. **不复活真实删除 / 同级收敛**:baseline-live「永远不能覆盖真实 live/tombstone」在 heal 选源与 apply 两侧同一套规则(提交 2 标题即「选源与应用同规则」),因此 off 模式下墓碑隐藏时,被选中的 baseline-live 推到持有墓碑的站点仍被接收端拒绝——是过时 RPC,不是复活。全 baseline-live 不同载荷按同一稳定键较大者胜,两侧独立计算同键(第 41 行纯函数不变量),单轮收敛且不回摆。✅
|
||||
4. **四条 P2**:均落到契约或验收行(41 行取消双判等、92 行 off≠禁止删除且不加每轮日志、T1 空策略 PUT 成功/GET NotFound)。剩余风险是 Policy 键的实现细节(递归排序与大整数精度),已转为 T2 断言,属实现期验证,非方案阻断。✅
|
||||
|
||||
## 非阻断注记
|
||||
|
||||
- 比较键含 Sid,而公开统计仍用忽略 Sid 的 `Equals`:仅 Sid 差异时 heal 会写一次、状态接口报已复制;收敛后二者一致,不会持续分歧。
|
||||
- 建议把 T8 历史桶那行的「一轮 heal」补一句第二轮无写入/广播(与第 78 行完成条件同口径),可选。
|
||||
- 第 40 行末「同状态来源不参与持久化冲突裁决」措辞含糊,实现前口头澄清即可。
|
||||
|
||||
**结论:GO_WITH_NONBLOCKING_NOTES。实施前阻断数:0。** 未发现新反例;已关闭范围(R1/R2/R3、六类 handler、导入、计数、调用图)未重审。
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"reviewer": "Claude Code",
|
||||
"cli_version": "2.1.258",
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"source_commit": "5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"permission_mode": "plan",
|
||||
"tools": [
|
||||
"Read",
|
||||
"Grep",
|
||||
"Glob"
|
||||
],
|
||||
"round": 3,
|
||||
"plan_snapshot": "plan-v3.md",
|
||||
"plan_sha256": "8c4ab51a397246a07bb1cc967df3377d26ac084b7928191eb8fecacf6e7c3aed",
|
||||
"command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --output-format stream-json --verbose",
|
||||
"status": "completed",
|
||||
"observed_model": "claude-opus-5",
|
||||
"session_id": "3ae0c14b-58cf-415d-8d2d-3a388d322534",
|
||||
"observed_tools": [
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read"
|
||||
],
|
||||
"subtype": "success",
|
||||
"is_error": false,
|
||||
"duration_ms": 181195,
|
||||
"num_turns": 4,
|
||||
"collected_at_utc": "2026-09-12T01:50:13.403953+00:00",
|
||||
"stream_sha256": "728542385eb75e02050c17a389b49d2b4dfacdf1c54801fc6792694f91f08cff",
|
||||
"review_sha256": "557ef70288a9a3939842a2f76d63694e2c103a8bfcaa99b7c161ee89e1f58061",
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"used_tools": [
|
||||
"Read"
|
||||
],
|
||||
"auxiliary_model_note": "CLI may report small auxiliary model calls in model_usage; assistant_models lists the actual recorded review message models. max was explicitly supplied in the CLI command.",
|
||||
"verdict": "GO_WITH_NONBLOCKING_NOTES",
|
||||
"blocking_findings": 0,
|
||||
"temporary_review_worktree_removed": true,
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
## 复核结论(第四轮,仅针对接管增量)
|
||||
|
||||
**必要性成立,证据链闭合。** `loadBucketMetadataParse` 内 `defaultTimestamps`(`cmd/bucket-metadata.go:534`)先把字段时间补成 oldCreated,随后 `SetCreatedAt`(`:190-197`)无条件改写 `Created`,两者之间无补偿;probe 日志两个 ObjectLayer 均得 `nil with field>Created=true`,与 v4 新比较器「时间 > Created 即真实状态、nil 载荷即 tombstone」直接相撞,会伪造删除并向外广播。补丁点选在 `cmd/site-replication.go:946-950` 现有 `lockBucketMetadata` 内,位置正确。
|
||||
|
||||
**充分性成立。** 用「为 0 或 == oldCreated」判定默认值,与计划 §39「等于 Created 仍属 baseline」同一口径,重映射不改变任何状态级别;真实(> oldCreated)时间与全部载荷不动,故不覆盖真修改/真删除。不触 payload、不新增写入或广播路径,#78 配置保护不受影响;baseline-live 之间按稳定键而非时间决胜,平移时间不改变胜负。
|
||||
|
||||
另补一条支持性事实:`enablePeerBucketVersioning:901` 与 handler `:957` 的 `IsZero` 守卫在此路径上已被 `defaultTimestamps` 提前失效,bootstrap 分支本身无法把时间修正到 newCreated——归一化是唯一补救,必要性比 decisions-v4 描述的更强。
|
||||
|
||||
**两点非阻断修正:**
|
||||
|
||||
1. `SetCreatedAt` 对方向无约束,newCreated 也可能晚于 oldCreated;此时旧默认时间变成「严格早于自身 Created」,按 §37 被当作无效候选过滤,历史 baseline-live 静默丧失初始化能力。计划正文写的是「如果 Created 改变」(双向,正确),但 decisions-v4 §S4 与 T8 只写「前移」。T8 应补后移方向断言。
|
||||
2. `defaultTimestamps` 覆盖 11 个字段,补丁只归一六类,其余五类接管后遗留 != Created 的伪真实时间。#77 比较器不读它们,不阻断;实现时请在代码注释写明限定理由,或直接对该函数的全集做同样归一(同等成本,不扩大范围)。
|
||||
|
||||
未发现范围外扩、未证实假设或与已关闭项的冲突;独立桶世代合并仍在既定边界外,正确。
|
||||
|
||||
**GO_WITH_NONBLOCKING_NOTES;实施前阻断数 0。**
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"reviewer": "Claude Code",
|
||||
"cli_version": "2.1.258",
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"source_commit": "5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"permission_mode": "plan",
|
||||
"tools": [
|
||||
"Read",
|
||||
"Grep",
|
||||
"Glob"
|
||||
],
|
||||
"round": 4,
|
||||
"plan_snapshot": "plan-v4.md",
|
||||
"plan_sha256": "fbfd37498ab32a589d06254f32182869d5d49fc97722416c46c10b41e5f77816",
|
||||
"command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --output-format stream-json --verbose",
|
||||
"status": "completed",
|
||||
"observed_model": "claude-opus-5",
|
||||
"session_id": "72c12e7b-d830-4f3a-9066-5fa829de7a4f",
|
||||
"observed_tools": [
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read"
|
||||
],
|
||||
"subtype": "success",
|
||||
"is_error": false,
|
||||
"duration_ms": 112227,
|
||||
"num_turns": 10,
|
||||
"collected_at_utc": "2026-09-12T01:55:02.590226+00:00",
|
||||
"stream_sha256": "d8f3509c695749cb5fae9e2f7142b887e48a950a5d96818f73a05529feab2bea",
|
||||
"review_sha256": "cf10b9d7963ff2b7d4c0fbb1fe744a0e853549eaf7ce675a94cc000f74fe6682",
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"used_tools": [
|
||||
"Grep",
|
||||
"Read"
|
||||
],
|
||||
"auxiliary_model_note": "CLI may report small auxiliary model calls in model_usage; assistant_models lists the actual recorded review message models. max was explicitly supplied in the CLI command.",
|
||||
"verdict": "GO_WITH_NONBLOCKING_NOTES",
|
||||
"blocking_findings": 0,
|
||||
"nonblocking_notes_disposition": "Final plan adds Created earlier/later/unchanged acceptance and a comment limiting timestamp normalization to the six fields. No implementation scope change.",
|
||||
"final_plan_sha256": "2ee214694f16e5949ed58290364770149aee3748faee3614122fee189ef83add",
|
||||
"temporary_review_worktree_removed": true,
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
# Issue #77 最小充分修复计划
|
||||
|
||||
版本:v1,2026-09-12。状态:作者自审完成,待 Claude Code Opus 5 Max 对抗评审。
|
||||
|
||||
基准:`pgsty/silo` main `5c576581631561c446f30ae5b566f0aa793adc1c`。本计划只授权设计与评审,不表示已实现、合并或发布。复现材料见 [当前核验](../../issue-77-current.md)。
|
||||
|
||||
## 目标与范围
|
||||
|
||||
让修复版本间的 Policy、Tags、SSE、Quota、Versioning、Object Lock 桶配置,在合法带源时间的 peer 事件被延迟、重复、乱序或漏发后,能按同一规则应用并由 heal 最终收敛;删除状态必须落盘、能传播,不能被较旧配置复活。桶元数据批量导入的复制入口属于同一范围。
|
||||
|
||||
本轮不重做 #91 的计数、#76 的 Object Lock wire 字段、#78 的桶接管、#103/#156 的整记录锁和删除保护。CORS、Lifecycle/expiry、notification、对象数据复制、MRF、resync、IAM 均不改变其语义。支持与验收对象是协调的 PGSTY 栈;不引入为了上游 MinIO 兼容而修改依赖的工作。
|
||||
|
||||
## 已确定的行为契约
|
||||
|
||||
| 类型 | 专用事件 nil 或解码后空内容 | bulk 字段未提供/null | 真实删除状态导出 |
|
||||
| --- | --- | --- | --- |
|
||||
| Policy / Tags / SSE / Quota | 删除;保留源时间,删除走现有 parse=false 路径 | 不修改该字段 | Policy 保留现状;另三类受下述单一开关控制 |
|
||||
| Versioning / Object Lock | no-op,不能通过 heal 删除配置 | 不修改该字段 | 不新增删除语义 |
|
||||
|
||||
合法的空 quota 对象 `{}` 仍按现有 quota 解析语义处理,不能仅因配额为零就改写为 nil。Policy 空策略继续使用现有专用 admin handler 的判空行为。所有既有字段编码、公开 getter 的 NotFound 行为、RPC 返回结构及权限要求保持不变。
|
||||
|
||||
1. **读取原始字段时间。** 排序使用 `BucketMetadata` 的原始 `*UpdatedAt` 字段,不能用会隐藏删除时间的公开 getter,也不能用整记录 `lastUpdate()` 代替字段时间。
|
||||
2. **创建边界与未知值。** `UpdatedAt == 0` 或 `UpdatedAt == CreatedAt` 的记录不能作为 heal 的权威候选。后者沿用既有创建默认值约定,包括无法恢复真实修改时间的旧记录;全部为此类状态时不写入并给出有界诊断。peer 事件早于当前桶 CreatedAt 时不应用。新产生的实际配置写入时间必须严格大于 CreatedAt。
|
||||
3. **带时间事件的确定性次序。** 先比较源时间;相同时间下,真实删除大于 live,live/live 按稳定载荷字节序比较;内容和时间均相同则 no-op。Policy/Quota 使用既有解析后 JSON 编码作为比较表示;XML 使用解码后的实际文档字节。apply 和 heal 使用同一个比较函数。deployment ID 只在完全相同状态下用于选取日志中的来源,不能作为未持久化的冲突元数据。
|
||||
4. **本地新操作不倒退。** 对上述六类的实际本地写入,锁内分配 `max(UTCNow(), CreatedAt+1ns, 当前字段时间+1ns)`。其他配置类型保持原有 UTCNow 行为。保留现有 Object Lock 强制 Enabled versioning 的规则,不改变 suspend、prefix exclusion、retention 的合法性。
|
||||
5. **nil 与删除区分。** 只有可删除类型的专用事件,或 bulk 中明确提供且按既有规则解析为空的可删除字段,表示删除。bulk 的 nil 必须继续表示未提供。Versioning/Object Lock 的 nil/空输入在任何路径均不能清空配置。
|
||||
6. **写入原子性。** 原始读取、比较、修改及保存全部在现有桶 `metadata.lock` 内。更新用现有 parse=true 路径,删除用 parse=false 路径;bulk 保留当前原始读取、最后统一解析保存的方式,避免 Quota 已解析缓存残留。任何字段校验失败不得保存部分 bulk。无状态变化时不保存、不通知;有变化时成功保存一次,解锁后再通知。
|
||||
|
||||
## 实施顺序
|
||||
|
||||
### 提交 1:原子排序与本地事件时间
|
||||
|
||||
改动主要在 `cmd/bucket-metadata-sys.go`、`cmd/site-replication.go` 和 `cmd/admin-bucket-handlers.go`,只增加六类字段的内部访问/比较/更新时间能力,不引入注册表、接口插件或复制框架。
|
||||
|
||||
- 复用现有 `updateAndParse` 的加载、类型设置、保存和通知边界,增加内部 peer 源时间输入及是否应用的结果。公开 `Update/Delete` 的签名保持不变;专用 peer handler 的公开 error-only 签名也不变。
|
||||
- 六个 peer handler 删除锁外 getter 判旧,使用内部源时间更新/删除路径。对非零源时间执行上述比较,持久化原始源时间而非到达时间;重复和被拒绝的旧事件不落盘。
|
||||
- 批量 `PeerBucketMetadataUpdateHandler` 对六类已提供字段在其已有整记录锁内执行同一比较。不能循环调用会再次取得桶锁的公开 handler。保留 bulk 中未提供字段、既有 CORS 分支以及一次保存语义。
|
||||
- 普通本地六类写入用字段内单调时间。`enablePeerBucketVersioning` 在实际修改现有配置时也用该时间函数;仅缺失字段的创建 bootstrap 保留 CreatedAt 默认值,不给默认配置伪造一次新修改。
|
||||
- **导入生成端一并修复。** 在每桶最终提交锁内,对本次导入涉及的六类字段分配一个共同 `commitAt`:严格大于 CreatedAt 和这些字段现存时间,并且不早于锁内当前时刻。覆盖导入暂存对象中这些字段的时间后,再调用现有 `applyImportedBucketMetadata`。发送 bulk hook 的 UpdatedAt 必须是同一个 commitAt,不能再用整个 ZIP 请求开始时的 updatedAt。未导入字段不改;CORS 继续自己的时间函数和独立事件,Lifecycle/notification 不参与这个时间上界。
|
||||
- 原样保留 `saveMetadata` 的物理桶存在检查、桶删除锁序、后台通知上下文和加载/迁移路径。不得另加一套 peer-require-existing 或新锁。
|
||||
- 对真实历史桶 `Created == 0`,仅在该少见路径取得现有物理桶 Created 并补齐,再按现有默认时间规则加载;物理桶不存在就返回现有错误。若物理创建时间也未知,不能用本次到达时间伪造桶世代:不应用并报告 indeterminate。
|
||||
|
||||
提交 1 的完成条件:六类 peer PUT 落盘保留源时间;四类 DELETE 后旧 PUT 不复活;相同内容重复事件不保存;getter→Update 竞态和 bulk 旧事件覆盖被挡住;本地后续写入可以胜过已有未来源时间;导入的持久化时间与实际发出的事件一致。
|
||||
|
||||
### 提交 2:heal 统一选源与状态同步
|
||||
|
||||
改动主要在 `cmd/site-replication.go`。
|
||||
|
||||
- 先过滤未知/创建默认状态和 update-only 的空候选,再从剩余候选中按相同比较器选最新;必须显式返回 found=false。取消“先用 map 首项初始化,再判断是否默认”的写法。
|
||||
- 为 Policy、Tags、SSE、Quota、Versioning、Object Lock 六类 heal 替换对应选源循环。不触碰 CORS、Lifecycle 或对象数据修复路径。
|
||||
- 本地 heal 通过提交 1 的源时间更新/删除路径;远端 heal 保留对应事件类型并携带选中状态的源时间,补齐 Tag 的 UpdatedAt。
|
||||
- heal 是否写入由完整状态比较决定,不再由公开 mismatch 计数或仅 payload 相等决定。即使内容相同,较旧时间也必须同步;完全相同的状态不保存、不发送。
|
||||
- 保留 #91 的 payload/数量统计语义和字段,不为实现时间戳同步更改 madmin API。持续 indeterminate 或因桶创建边界拒绝而留下的状态差异通过提交 3 的日志解释。
|
||||
|
||||
提交 2 的完成条件:map 顺序不影响选源;默认状态不能删除真配置;全默认/未知状态不修改磁盘;nil 不清空 Versioning/Object Lock;Quota 删除后磁盘和缓存一致;远端 Tag 时间完整;同内容不同时间、同时间不同内容都能确定性收敛。
|
||||
|
||||
### 提交 3:删除传播与有界诊断
|
||||
|
||||
使用一个启动配置开关,暂定名称 `MINIO_SITE_REPLICATION_METADATA_CONVERGENCE`,`off` 为默认,`on` 为显式启用。不加入 madmin/silo-pkg 字段、不新增 RPC、不构建通用能力协商。
|
||||
|
||||
| 行为 | off:兼容升级阶段 | on:全站点修复后 |
|
||||
| --- | --- | --- |
|
||||
| 有源时间的 peer apply、锁内排序、heal | 使用提交 1/2 的修复 | 同左 |
|
||||
| 专用事件零源时间 | 保留旧版兼容:锁内按本地新操作时间应用,限频记录兼容降级;此类事件不在排序收敛保证内 | 拒绝为无效事件,不写入 |
|
||||
| bulk 零源时间 | 维持当前拒绝行为 | 同左 |
|
||||
| Tags/SSE/Quota nil payload 时间导出 | 维持旧版 payload 条件导出 | 导出真实删除时间;零/创建默认仍不是删除事件 |
|
||||
| Policy 时间导出 | 保持已有行为 | 保持已有行为 |
|
||||
| 初次同步的删除状态 | 保持已有行为 | Policy/Tags/SSE/Quota 的真实删除也发送专用 nil + source time 事件 |
|
||||
|
||||
这里的开关只隔离新增的删除传播和不再接受无时间事件的行为。它不证明远端能力;启用的明确条件是所有参与站点、每站点全部节点都已部署包含提交 1/2/3 的修复版本,旧请求已排空。配置同一站点内必须一致。任何旧节点仍在线时保持 off。
|
||||
|
||||
- exporter 只把可区分的真实删除状态作为新增导出对象,不把新桶默认配置改成删除。Policy 已有导出保留,选择端过滤默认值。
|
||||
- `syncToAllPeers` 也使用同一状态判断发送真实删除;不能只修定期元数据查询而遗漏初次同步。保持现有逐类型 RPC 和编码。
|
||||
- 复用已有日志去重/限频设施,固定为有限的原因集合:legacy-zero、stale-conflict、before-created、indeterminate。精确重复属于正常 no-op,不记警告;普通顺序已知的旧事件不制造每轮噪音;仅实际错误/持续无法收敛的原因记录桶、配置类型、当前/源时间,不输出完整策略或配置。
|
||||
- stale 与 before-created 保持 RPC 成功/no-op;缺桶、非法 payload 和 on 模式下的零时间仍返回相应错误。不开新重试循环。
|
||||
- 更新 Server 的 site-replication 文档,写清 off/on、全节点升级条件、零时间兼容降级和回滚限制。回滚/降级前先在所有修复节点关闭该开关,再滚动降级;旧软件原有缺陷会恢复,不宣称回滚后仍具备收敛保证。
|
||||
- 旧版本已经记录成到达时间的历史值不能推导回真实源时间。升级完成后,操作者在看到各站点状态后选择权威配置,重新提交需要纠正的配置/删除;不得自动把时间归零或选任意站点强制覆盖。
|
||||
|
||||
## 最小验收矩阵
|
||||
|
||||
以下均为缺陷或上述修复引入的行为边界;不把发布制品、外部上游服务或不相关产品作为本计划硬门槛。
|
||||
|
||||
| 组 | 必须验证 | 验证层 |
|
||||
| --- | --- | --- |
|
||||
| T1 | 六类 PUT 原样源时间;四类 DELETE;较旧 PUT/DELETE 不回退;精确重复无写入 | 真实 admin 路由 + 两种 ObjectLayer + 磁盘重载 |
|
||||
| T2 | 同时间不同 live 状态两种到达顺序结果相同;可删除类型 PUT/DELETE 同时间删除获胜 | 比较器确定性测试 + 代表性真实 handler |
|
||||
| T3 | Versioning/Object Lock nil/空不删除;保留 lock→Enabled versioning 约束;空 quota 对象语义 | 类型回归 + 现有 #76/#78 测试 |
|
||||
| T4 | 旧 peer 在锁前通过检查,较新写入先提交后旧 peer 再落锁,不能覆盖;并发不同字段均保留 | 确定性锁屏障 + 两种 ObjectLayer,针对性 race |
|
||||
| T5 | bulk 有旧字段/新字段/未提供字段混合,分别忽略/应用/保留;非法字段不部分保存;不能借 bulk 复活墓碑 | admin bulk 路由 + 磁盘/缓存 |
|
||||
| T6 | 本地操作的时间大于收到的未来时间;两个相邻本地提交不倒序;ZIP 导入期间插入写入,最终提交字段与发出的 bulk 时间完全一致 | 本地 API、import 路由、捕获 RPC |
|
||||
| T7 | 多站点排序排列、默认/零时间、全部无候选、空 update-only;同 payload 新时间、同时间冲突;Tag 出站源时间;Quota nil 后缓存清空 | heal + 本地/远端路径 |
|
||||
| T8 | 保存/缓存失效/进程重启后墓碑仍有效;缺桶/排队写入/真实零 Created 历史桶;on/off 与新旧发送端混合;初次同步携带真实删除 | ObjectLayer、现有删除测试、固定旧版与修复版两个 SILO 进程 |
|
||||
| T9 | 短暂断线、事件漏发/重复/乱序后重新连通,六类允许状态收敛;开关 on 后旧零时间事件失败且无修改;日志有界 | 隔离的双站点/多节点实验 |
|
||||
|
||||
实现期间先把已有审计用例改成确定性正式回归:不能将反复遍历 map 的概率复现直接提交为生产测试。先记录未修复失败,再验证修复后通过。完成局部修复后运行相关现有回归、目标 race、仓库必需的格式/静态检查与完整 Go CI;同一基准通过后不因形式要求反复全量测试。
|
||||
|
||||
## 作者自审结论
|
||||
|
||||
- 必要:六类共享的是字段排序与锁内写入;删除、编码、bulk 未提供字段和 Object Lock 约束仍按类型处理。
|
||||
- 充分:普通本地写、专用 peer、bulk/import、local/remote heal、导出、初次同步、重载均在清单内。没有继续保留绕过排序的实际写入入口。
|
||||
- 最小:复用一把已有锁、现有持久化和通知、一套小的字段比较逻辑;只加一个针对实际混合版本风险的开关。没有存储 schema、SDK、Console、客户端或新协议依赖。
|
||||
- 明确限制:off 模式、无时间旧事件、任意未升级节点和已污染的历史时间不承诺自动收敛;on 模式的验收仅针对修复 PGSTY 栈的合法状态与已知桶世代。
|
||||
- 当前只批准进入外部方案评审。任何对抗评审发现的具体遗漏或多余工作,应先修订本文件并复审,再决定进入实现。
|
||||
@@ -0,0 +1,118 @@
|
||||
# Issue #77 最小充分修复计划
|
||||
|
||||
版本:v2,2026-09-12。状态:已处理首轮对抗评审并完成作者复核,待 Claude Code Opus 5 Max 复审。
|
||||
|
||||
基准:`pgsty/silo` main `5c576581631561c446f30ae5b566f0aa793adc1c`。本轮交付是方案与评审,不表示已经实现、合并或发布。问题证据见 [当前核验](../../issue-77-current.md),首轮意见及处置见 [评审处置](decisions-v2.md)。
|
||||
|
||||
## 目标与边界
|
||||
|
||||
修复 Policy、Tags、SSE、Quota、Versioning、Object Lock 六类桶配置的源时间丢失、锁外判旧、heal 错选源和删除传播不完整。覆盖普通本地写入、专用 peer 事件、bulk/import、local/remote heal、元数据导出与初次同步。
|
||||
|
||||
在修复 PGSTY 栈、同一已知桶世代、合法带源时间事件的范围内,使重复、乱序和漏发后的状态能够确定性收敛。开关关闭期间新增删除信息不可见,不承诺完整删除收敛;无时间旧事件、旧版污染时间和桶创建世代冲突需要单独解释,不能自动推断历史真相。
|
||||
|
||||
不重做 #91 的计数、#76 的 Object Lock wire 修复、#78 的桶接管、#103/#156 的锁与删除保护。不改变 CORS、Lifecycle/expiry、notification、对象复制、MRF、resync、IAM 的语义;不改存储 schema、SDK、Console、mcli 或 silo-pkg,不新建能力协商、复制框架、锁或重试系统。
|
||||
|
||||
## 行为契约
|
||||
|
||||
### 1. 明确事件、缺省值与删除
|
||||
|
||||
| 类型 | 专用事件 | bulk 字段未提供 | bulk 字段明确提供 |
|
||||
| --- | --- | --- | --- |
|
||||
| Policy | nil 为删除;沿用现有解析器 `IsEmpty()` 为删除 | 保留 | 非空 RawMessage 按现有解析器处理;语义空策略归一为删除 |
|
||||
| Quota | nil 为删除;非 nil 按现有 quota 解析器处理 | 保留 | 非空 RawMessage 按现有解析器处理,零 quota 仍是 live 文档 |
|
||||
| Tags / SSE | nil 或 base64 解码后空内容为删除 | 保留 | 空字符串为删除,其他内容按原规则解码、校验 |
|
||||
| Versioning / Object Lock | nil/空内容为 no-op | 保留 | nil/空内容仍为 no-op,不能清空配置 |
|
||||
|
||||
“未提供”必须依据真实 wire 类型判定:Policy/Quota 的 `json.RawMessage` 为 nil 或空切片时经 `omitempty` 省略;**显式 JSON `null` 解码后是非 nil 的 `[]byte("null")`**,不能与缺省混淆。按现有解析器,Policy `null` 是语义空策略,Quota `null` 是零值 quota 文档。`*string` 类型的 JSON `null` 则解码为 nil。不得使用统一的 `len(payload)==0 => 删除` 来处理 bulk。
|
||||
|
||||
合法 `{}`/零值 quota 保持 live;取消普通 quota PUT 出站时“零配额改写为 nil”的逻辑,保存与发送同一个语义状态。Policy 保留现有专用 peer 的“空策略=删除”解释,本地 PUT、导入和 bulk 统一归一为同样的删除状态;这是需要写入兼容说明的小范围变化:空策略本地立即按删除处理,GET 返回既有 NotFound 行为,不再先保留空文档、等复制后才被清除。
|
||||
|
||||
**同一次操作的落盘状态与出站事件,经同一归一规则后,必须具有相同的 `(kind, payload key, source time)`。** JSON 的无意义编码次序不要求字节相同;Object Lock 改写后的有效 Versioning 文档必须来自提交结果。
|
||||
|
||||
### 2. 统一状态与排序
|
||||
|
||||
内部仅需一个小状态表示:baseline / live / tombstone,以及比较键和字段源时间。复用 CORS 已有设计思路,不改 CORS 本身或扩展为通用框架。
|
||||
|
||||
- 使用 `BucketMetadata` 原始字段时间;字段时间为零时在比较视图中补为 Created,与 `defaultTimestamps()` 一致,不借用会隐藏墓碑时间的 getter 或整记录 `lastUpdate()`。
|
||||
- 在已知 Created 下,零时间或 `fieldTime <= Created` 是 baseline;它不能成为 heal 权威源,也不能作为带时间 peer 更新覆盖实际状态。Versioning/Object Lock 的空候选无论时间如何都不参与选源。
|
||||
- 专用 peer 的零时间保留兼容例外:锁内按本地新操作分配时间并限频记录 legacy-zero;不受删除传播开关影响,不在源时间排序保证之内。bulk 零时间仍按现状拒绝。
|
||||
- 非零事件不晚于目标桶 Created 时成功/no-op,记录 before-created。heal 同时过滤源端 baseline;对候选时间不晚于目标 Created 的目标跳过,不能每轮继续发送必然拒绝的 RPC。不同桶世代不能自动合并,需要运维处理,不纳入收敛承诺。
|
||||
- 有效状态先按源时间排序;同时间 tombstone 胜 live;同时间 live/live 的**稳定比较键字节序较大者胜**。键与时间相同为 no-op。同状态来源按现有稳定遍历/选源方式取即可,deployment ID 不参与持久化冲突裁决。
|
||||
- Quota 使用现有解析结果的 JSON 编码作为比较键。Policy 在 Server 内生成确定性比较表示:既有解析器校验/去重后,对 JSON 对象键和集合数组递归排序,包括 Statement、Action、Resource、Principal、Condition 值。普通 `json.Marshal(BucketPolicy)` 不稳定,不能直接作键;不为此修改 silo-pkg 或 wire/schema。
|
||||
- XML 使用有效文档字节,保留大小写和实际内容;Versioning 先应用下述现有 Object Lock 约束。比较器不能依靠字节序方向来补偿保存阶段的隐式改写。
|
||||
|
||||
### 3. 先得到有效状态,再比较与提交
|
||||
|
||||
原始读取、类型处理、比较、修改及保存均在现有 `metadata.lock` 内。内部入口必须传递现有 lock context,避免 legacy migration 再次取锁。
|
||||
|
||||
- 提取并复用 `parseAllConfigs` 已有的 Object Lock→Enabled Versioning 归一规则,使比较视图和真正保存一致;不得扩大 suspend、prefix exclusion、retention 的限制。bulk 先确定实际接受的 Object Lock,再比较该约束下的 Versioning,并在最终提交前应用同一规则。
|
||||
- 以归一化后的有效 `(kind, key, time)` 判定变化;更新了时间也算变化。完全重复不保存、不通知;一次 bulk 校验失败不保存部分结果;成功至多保存一次,解锁后通知。
|
||||
- 可删除字段清空必须基于现有 parse=false 的新加载对象,不能在已经解析且仍持有旧 quota 的对象上执行 Update(nil)。bulk 保留原始读取再解析保存的方式。不要顺手修改 Quota getter 或所有 `parseAllConfigs` 空分支。
|
||||
- 保存函数必须让需要发送 hook 的调用方拿到**本次提交的最终快照**,不能先解锁再读取“最新”状态拼接旧时间。最小做法是让内部 `saveMetadata` 接收元数据指针并回写 Save 的归一化结果,机械更新现有少量调用;对外 `Update/Delete` 签名不变,新增内部提交结果仅供需要该快照的本地 handler/import 使用。不得原地修改已发布到缓存的引用字段。
|
||||
- 保留现有物理桶存在检查、删除锁序、迁移、后台通知上下文。真实历史桶 Created 为零时仅走少见的物理桶 Created 补齐路径;物理桶缺失返回现有错误;创建时间仍未知则不伪造到达时间,报告 indeterminate。
|
||||
|
||||
## 三个实现提交
|
||||
|
||||
### 提交 1:原子 apply、发送一致性与本地时间
|
||||
|
||||
主要文件:`cmd/bucket-metadata-sys.go`、`cmd/bucket-metadata.go`、`cmd/site-replication.go`、`cmd/admin-bucket-handlers.go`,以及实际需要提交快照的本地配置 handler。
|
||||
|
||||
1. 在现有 update/delete 内部路径增加源时间、状态比较和提交结果;公开签名不变。六个 peer handler 移除锁外 getter 判旧,锁内持久化原始源时间。保留 Object Lock 的 legacy Tags 字段载荷回退。
|
||||
2. bulk 对明确提供的六类字段在已有锁内逐字段比较,再原子保存;未提供字段不动,不能循环调用会重入锁的公开 handler。保留 CORS 独立分支与既有行为。
|
||||
3. 六类本地实际写入在锁内分配 `max(UTCNow(), Created+1ns, 当前字段时间+1ns)`。其他类型不变。`enablePeerBucketVersioning` 的实际变更也使用它,只有缺失配置的创建 bootstrap 继续 Created 默认值。
|
||||
4. quota 本地 PUT 保留零值文档并原样表示该语义;Policy 空策略本地与 peer 一致走删除。需要归一化的本地 handler 从本次提交快照生成 hook;其他内容不发生归一变化的路径可保留既有编码,但必须满足三元组一致性。
|
||||
5. 导入在每桶最终提交锁内,为本次涉及的六类字段生成共同 commitAt,严格大于 Created 和这些字段当前时间且不早于锁内现在。该时间同时用于落盘和 bulk hook,不能沿用 ZIP 开始时间。bulk hook 从最终提交快照构建;若导入的空 Policy 已归一成删除,另外使用现有专用 Policy nil 事件表达它,不能因 `omitempty` 漏发。未导入字段不改,Object Lock 的既有派生 Versioning 修正保留原时间语义;CORS 继续独立时间/事件,其他字段不参与该上界。
|
||||
|
||||
完成条件:六类源时间落盘;旧事件不能越过锁覆盖新状态;四类删除不会被旧 PUT 复活;真实 wire、落盘和出站状态一致;重复无写入;bulk 与 import 没有绕过排序或静默遗漏删除。
|
||||
|
||||
### 提交 2:heal 选源与应用同规则
|
||||
|
||||
主要文件:`cmd/site-replication.go`。
|
||||
|
||||
1. 先过滤 baseline、无效来源和 update-only 空配置,再选最大状态;无候选必须显式返回 found=false。消除六处“先 seed map 首项,再过滤默认值”的写法。
|
||||
2. 选源和目标遍历都跳过 `info.Sites` 中不存在的 deployment ID,包括不可达站点的空 ID 占位项;单一 peer 失败记录后继续其它目标,不因 map 顺序放弃健康站点。不改变状态计数或新建重试机制。
|
||||
3. 本地 heal 使用提交 1 的源时间 update/delete;远端仍用原有逐类型 RPC,全部携带源时间,补齐 Tag 的 UpdatedAt。
|
||||
4. 比较完整有效状态,去掉公开 mismatch/payload-only 对写入的门控;同内容较旧时间也同步。对已归一且相同的目标不写入、不发 RPC。Versioning 比较使用与该站点 Object Lock 一致的有效文档;全站点 Lock 状态补齐后不再因旧原始文档产生空转。
|
||||
5. 保留 #91 的计数与公开字段;创建世代冲突、无可用来源通过有限诊断解释。已知 baseline 且各站点无实质差异时安静 no-op。
|
||||
|
||||
完成条件:map 顺序不影响结果;默认值不再删真配置;同内容不同时间、同时间冲突最终一致;Tag 时间完整;Quota 删除后磁盘、缓存、重载一致;不可达占位项不阻断健康目标;第二轮稳定 heal 无写入/广播。
|
||||
|
||||
### 提交 3:新增删除传播与有界诊断
|
||||
|
||||
只增加一个启动开关,暂定 `MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off/on`,默认 off;实现沿用现有 env 开关写法,不做能力协商。
|
||||
|
||||
| 行为 | off:升级阶段 | on:所有参与节点修复后 |
|
||||
| --- | --- | --- |
|
||||
| 带时间 peer apply、锁内排序与 heal | 使用提交 1/2 | 同左 |
|
||||
| 专用事件零时间 | 兼容应用并记录 legacy-zero | 同左,不新增协议拒绝 |
|
||||
| Tags/SSE/Quota nil payload 时间导出 | 保留旧版条件导出 | 导出 `time > Created` 的真实删除时间 |
|
||||
| Policy 时间导出 | 保留已有行为 | 保留已有行为 |
|
||||
| 初次同步的真实删除 | 保留已有行为 | Policy/Tags/SSE/Quota 都发送专用 nil + source time 事件 |
|
||||
|
||||
开关只控制**新增**删除信息的导出/初次发送,普通本地删除事件照常复制。它不检测或证明远端能力。启用条件是所有参与站点的全部节点已经修复,同一站点配置一致,旧请求排空;旧节点仍在线时保持 off。此隔离有实证依据:旧版接到新增 Quota heal 墓碑会留下已解析缓存残留。
|
||||
|
||||
日志仅保留三个实际原因:legacy-zero、before-created、indeterminate(未知创建时间、缺失/不可达来源或有实际差异却无可用候选)。精确重复、正常旧事件和成功裁决的同时间冲突不记警告。复用 `LogOnceIf`,以稳定的桶/字段/原因作为 key,**错误正文也必须稳定**;变化的时间与 peer 详情放入日志 ReqInfo,沿用现有每小时清理,不新增限流框架、不输出完整策略。
|
||||
|
||||
Server 文档解释启用顺序和回滚:降级前所有修复节点先关开关,然后滚动降级;旧软件缺陷会恢复。点名旧版 Tag heal 无 UpdatedAt 的来源。旧版到达时间污染、legacy-zero 产生的新本地时间以及创建世代分歧无法自动还原;操作者查看状态后在权威站点重新提交需要纠正的配置/删除。历史世代冲突先处理桶身份,不能靠任意站点强刷绕过创建保护。
|
||||
|
||||
## 最小验收矩阵
|
||||
|
||||
| 组 | 必须覆盖 | 证据方式 |
|
||||
| --- | --- | --- |
|
||||
| T1 | 六类 PUT 源时间;四类 DELETE;旧事件不回退;重复无写入;零 quota、空 Policy 的落盘/出站三元组一致 | 真实 admin/S3 路由、ErasureSD/Erasure16、磁盘重载、RPC 捕获 |
|
||||
| T2 | 同时间两种到达顺序结果相同、删除优先;Policy 多 Action/Resource/Principal/Condition 集合反复编码与排列后键稳定 | 确定性比较器测试与代表性真实 handler |
|
||||
| T3 | Versioning/Object Lock nil/空 no-op;legacy Tags 载荷回退;Object Lock + prefix exclusion/ExcludeFolders 在普通写、peer、bulk/import 后有效状态一致,第二轮 heal 无额外写入 | 原有 #76/#78 回归加针对性用例 |
|
||||
| T4 | 锁前旧事件排队、较新写先提交后旧事件不得覆盖;不同字段并发均保留 | 复用已有 `lockBucketMetadataAcquireHook` / RMW 屏障、两种 ObjectLayer、目标 race |
|
||||
| T5 | bulk 新/旧/缺省字段混合;真实 JSON 编解码的 nil、空 RawMessage、显式 null、空字符串、空策略、零 quota;非法字段不部分保存;只导入 tags 不修改 Policy/Quota | 真实 bulk 路由、缓存与磁盘 |
|
||||
| T6 | 本地时间胜过已有未来时间;相邻提交不倒序;ZIP 导入期间插入写入,最终落盘和发出事件的状态与时间一致,空 Policy 删除不漏发 | 本地 API、import 路由与 RPC 捕获 |
|
||||
| T7 | 多站点排序排列、baseline/全无候选、空 update-only;同内容新时间;Tag 源时间;Quota 删除清缓存;空 deployment ID 不阻断其它目标;世代冲突跳过且不反复发 RPC | 确定性 heal 本地/远端用例 |
|
||||
| T8 | 墓碑经保存/缓存失效/重启仍有效;缺桶、排队写入、零 Created 历史桶;开关 on/off、legacy-zero 与初次同步删除 | ObjectLayer 与进程内旧版 wire/SRInfo 模拟;复用既有删除/迁移回归 |
|
||||
| T9 | 修复版双站点短暂断线、漏发/重复/乱序后六类合法配置收敛;删除传播启用后第二轮稳定无写入;日志确实有界 | 隔离双站点实验,不把未知桶世代或零时间事件算成通过 |
|
||||
|
||||
固定旧版与修复版混合进程仅作一次性升级冒烟,不新增为长期提交门槛。将已有审计用例改成正式确定性回归,不能把遍历 map 的概率复现直接提交。先记录未修复失败,再验证通过;复用原有 Object Lock、adoption、metadata-lock、计数、CORS 回归,并在实现后运行目标 race、仓库必需检查和完整 Go CI。方案审查、局部测试、双站点结果、合并与发布是不同证据。
|
||||
|
||||
## 作者复核结论
|
||||
|
||||
三个提交分别处理写入正确性、heal 收敛和新增删除信息的升级边界;每项对应已证实缺陷或本次修复直接触及的实际路径。缩减了拒绝零时间的新协议行为、混合版本长期测试门槛和新锁屏障;保留已有持久化、通知与缓存语义。
|
||||
|
||||
首轮三个 P1 意见均已有明确处置:R1 修发送/保存一致性;R2 使用有效状态和提交快照;R3 给出经过实际编解码验证的逐字段规则,而非照搬其不准确的 RawMessage 推断。作者另补充了 Policy 非确定性编码与日志去重问题。外部复审未返回前不标记为可进入实现;后续仍须用实现期测试证明,而非把本计划当作修复完成。
|
||||
@@ -0,0 +1,120 @@
|
||||
# Issue #77 最小充分修复计划
|
||||
|
||||
版本:v3,2026-09-12。状态:首轮三个阻断已被 Opus 复审确认关闭;已修订第二轮发现的历史桶初始化边界,待最后的针对性复审。
|
||||
|
||||
基准:`pgsty/silo` main `5c576581631561c446f30ae5b566f0aa793adc1c`。本轮交付是方案与评审,不表示已经实现、合并或发布。问题证据见 [当前核验](../../issue-77-current.md),意见处置见 [首轮处置](decisions-v2.md)、[第二轮处置](decisions-v3.md)。
|
||||
|
||||
## 目标与边界
|
||||
|
||||
修复 Policy、Tags、SSE、Quota、Versioning、Object Lock 六类桶配置的源时间丢失、锁外判旧、heal 错选源和删除传播不完整。覆盖普通本地写入、专用 peer 事件、bulk/import、local/remote heal、元数据导出与初次同步。
|
||||
|
||||
在修复 PGSTY 栈、同一已知桶世代、合法带源时间事件的范围内,使重复、乱序和漏发后的状态能够确定性收敛。开关关闭期间新增删除信息不可见,不承诺完整删除收敛;无时间旧事件、旧版污染时间和桶创建世代冲突需要单独解释,不能自动推断历史真相。
|
||||
|
||||
不重做 #91 的计数、#76 的 Object Lock wire 修复、#78 的桶接管、#103/#156 的锁与删除保护。不改变 CORS、Lifecycle/expiry、notification、对象复制、MRF、resync、IAM 的语义;不改存储 schema、SDK、Console、mcli 或 silo-pkg,不新建能力协商、复制框架、锁或重试系统。
|
||||
|
||||
## 行为契约
|
||||
|
||||
### 1. 明确事件、缺省值与删除
|
||||
|
||||
| 类型 | 专用事件 | bulk 字段未提供 | bulk 字段明确提供 |
|
||||
| --- | --- | --- | --- |
|
||||
| Policy | nil 为删除;沿用现有解析器 `IsEmpty()` 为删除 | 保留 | 非空 RawMessage 按现有解析器处理;语义空策略归一为删除 |
|
||||
| Quota | nil 为删除;非 nil 按现有 quota 解析器处理 | 保留 | 非空 RawMessage 按现有解析器处理,零 quota 仍是 live 文档 |
|
||||
| Tags / SSE | nil 或 base64 解码后空内容为删除 | 保留 | 空字符串为删除,其他内容按原规则解码、校验 |
|
||||
| Versioning / Object Lock | nil/空内容为 no-op | 保留 | nil/空内容仍为 no-op,不能清空配置 |
|
||||
|
||||
“未提供”必须依据真实 wire 类型判定:Policy/Quota 的 `json.RawMessage` 为 nil 或空切片时经 `omitempty` 省略;**显式 JSON `null` 解码后是非 nil 的 `[]byte("null")`**,不能与缺省混淆。按现有解析器,Policy `null` 是语义空策略,Quota `null` 是零值 quota 文档。`*string` 类型的 JSON `null` 则解码为 nil。不得使用统一的 `len(payload)==0 => 删除` 来处理 bulk。
|
||||
|
||||
合法 `{}`/零值 quota 保持 live;取消普通 quota PUT 出站时“零配额改写为 nil”的逻辑,保存与发送同一个语义状态。Policy 保留现有专用 peer 的“空策略=删除”解释,本地 PUT、导入和 bulk 统一归一为同样的删除状态;这是需要写入兼容说明的小范围变化:空策略本地立即按删除处理,GET 返回既有 NotFound 行为,不再先保留空文档、等复制后才被清除。
|
||||
|
||||
**同一次操作的落盘状态与出站事件,经同一归一规则后,必须具有相同的 `(kind, payload key, source time)`。** JSON 的无意义编码次序不要求字节相同;Object Lock 改写后的有效 Versioning 文档必须来自提交结果。
|
||||
|
||||
### 2. 统一状态与排序
|
||||
|
||||
内部仅需一个小状态表示:baseline / live / tombstone,以及比较键和字段源时间。baseline 可携带历史有效配置,但没有真实修改时间;它绝不能被当成删除。复用 CORS 已有设计思路,不改 CORS 本身或扩展为通用框架。
|
||||
|
||||
- 使用 `BucketMetadata` 原始字段时间;字段时间为零时在比较视图中补为 Created,与 `defaultTimestamps()` 一致,不借用会隐藏墓碑时间的 getter 或整记录 `lastUpdate()`。
|
||||
- 在已知 Created 下,零字段时间回退后等于 Created 的状态是 baseline:有有效非空配置的 baseline-live 可作为初始化候选;空 baseline 只是缺省值,不能作为删除或 heal 来源。真正严格早于自身 Created 的字段不是候选;Versioning/Object Lock 的空候选无论时间如何都不参与选源。真实 live/tombstone 的时间必须大于 Created。
|
||||
- 专用 peer 的零时间保留兼容例外:锁内按本地新操作分配时间并限频记录 legacy-zero;不受删除传播开关影响,不在源时间排序保证之内。bulk 零时间仍按现状拒绝。
|
||||
- 非零事件**严格早于**目标桶 Created 才成功/no-op 并记录 before-created,heal 对这种目标跳过。等于目标 Created 的 live 事件可更新仍是 baseline 的目标字段,时间仍保存为源 Created,不盖上到达时间;同时间的空/nil 只算空 baseline,不能删除配置。不同桶世代不能自动合并,需要运维处理,不纳入收敛承诺。
|
||||
- 排序先比较是否为真实状态:任何真实 live/tombstone 都胜 baseline,不能让较晚创建的默认值压过较早的真实修改。真实状态之间再比较源时间,同时间 tombstone 胜 live、live/live 的**稳定比较键字节序较大者胜**。baseline-live 只胜空 baseline,或在 baseline-live 之间按同一稳定键较大者胜;永远不能覆盖真实 live/tombstone。所有候选都是 baseline-live 时仍可确定性初始化并收敛;全部为空 baseline 才安静 found=false。键、状态级别和源时间相同为 no-op。同状态来源不参与持久化冲突裁决。
|
||||
- Quota 使用现有解析结果的 JSON 编码作为比较键。Policy 在 Server 内生成确定性比较表示:既有解析器校验/去重后,对其**完整 JSON 树**的对象键和集合数组递归排序,统一覆盖 Statement、Action/NotAction、Resource/NotResource、Principal、Condition;保留数字类型和精度。比较键必须是已解析策略的纯函数,同一配置从两个站点分别解析也必须得到相同键。普通 `json.Marshal(BucketPolicy)` 不稳定,不能直接作键;不增加依赖不支持的 NotPrincipal 语法,不改 wire/schema。新状态比较仅使用这一套键:不把忽略 Sid 且对 Statement 顺序敏感的 `BucketPolicy.Equals` 再叠加为另一套判等规则,既有公开统计对 Equals 的使用保持不变。
|
||||
- XML 使用有效文档字节,保留大小写和实际内容;Versioning 先应用下述现有 Object Lock 约束。比较器不能依靠字节序方向来补偿保存阶段的隐式改写。
|
||||
|
||||
### 3. 先得到有效状态,再比较与提交
|
||||
|
||||
原始读取、类型处理、比较、修改及保存均在现有 `metadata.lock` 内。内部入口必须传递现有 lock context,避免 legacy migration 再次取锁。
|
||||
|
||||
- 提取并复用 `parseAllConfigs` 已有的 Object Lock→Enabled Versioning 归一规则,使比较视图和真正保存一致;不得扩大 suspend、prefix exclusion、retention 的限制。bulk 先确定实际接受的 Object Lock,再比较该约束下的 Versioning,并在最终提交前应用同一规则。
|
||||
- 以归一化后的有效 `(kind, key, time)` 判定变化;更新了时间也算变化。完全重复不保存、不通知;一次 bulk 校验失败不保存部分结果;成功至多保存一次,解锁后通知。
|
||||
- 可删除字段清空必须基于现有 parse=false 的新加载对象,不能在已经解析且仍持有旧 quota 的对象上执行 Update(nil)。bulk 保留原始读取再解析保存的方式。不要顺手修改 Quota getter 或所有 `parseAllConfigs` 空分支。
|
||||
- 保存函数必须让需要发送 hook 的调用方拿到**本次提交的最终快照**,不能先解锁再读取“最新”状态拼接旧时间。最小做法是让内部 `saveMetadata` 接收元数据指针并回写 Save 的归一化结果,机械更新现有少量调用;对外 `Update/Delete` 签名不变,新增内部提交结果仅供需要该快照的本地 handler/import 使用。不得原地修改已发布到缓存的引用字段。
|
||||
- 保留现有物理桶存在检查、删除锁序、迁移、后台通知上下文。真实历史桶 Created 为零时仅走少见的物理桶 Created 补齐路径;物理桶缺失返回现有错误;创建时间仍未知则不伪造到达时间,报告 indeterminate。
|
||||
|
||||
## 三个实现提交
|
||||
|
||||
### 提交 1:原子 apply、发送一致性与本地时间
|
||||
|
||||
主要文件:`cmd/bucket-metadata-sys.go`、`cmd/bucket-metadata.go`、`cmd/site-replication.go`、`cmd/admin-bucket-handlers.go`,以及实际需要提交快照的本地配置 handler。
|
||||
|
||||
1. 在现有 update/delete 内部路径增加源时间、状态比较和提交结果;公开签名不变。六个 peer handler 移除锁外 getter 判旧,锁内持久化原始源时间。保留 Object Lock 的 legacy Tags 字段载荷回退。
|
||||
2. bulk 对明确提供的六类字段在已有锁内逐字段比较,再原子保存;未提供字段不动,不能循环调用会重入锁的公开 handler。保留 CORS 独立分支与既有行为。
|
||||
3. 六类本地实际写入在锁内分配 `max(UTCNow(), Created+1ns, 当前字段时间+1ns)`。其他类型不变。`enablePeerBucketVersioning` 的实际变更也使用它,只有缺失配置的创建 bootstrap 继续 Created 默认值。
|
||||
4. quota 本地 PUT 保留零值文档并原样表示该语义;Policy 空策略本地与 peer 一致走删除。需要归一化的本地 handler 从本次提交快照生成 hook;其他内容不发生归一变化的路径可保留既有编码,但必须满足三元组一致性。
|
||||
5. 导入在每桶最终提交锁内,为本次涉及的六类字段生成共同 commitAt,严格大于 Created 和这些字段当前时间且不早于锁内现在。该时间同时用于落盘和 bulk hook,不能沿用 ZIP 开始时间。bulk hook 从最终提交快照构建;若导入的空 Policy 已归一成删除,另外使用现有专用 Policy nil 事件表达它,不能因 `omitempty` 漏发。未导入字段不改,Object Lock 的既有派生 Versioning 修正保留原时间语义;CORS 继续独立时间/事件,其他字段不参与该上界。
|
||||
|
||||
完成条件:六类源时间落盘;旧事件不能越过锁覆盖新状态;四类删除不会被旧 PUT 复活;真实 wire、落盘和出站状态一致;重复无写入;bulk 与 import 没有绕过排序或静默遗漏删除。
|
||||
|
||||
### 提交 2:heal 选源与应用同规则
|
||||
|
||||
主要文件:`cmd/site-replication.go`。
|
||||
|
||||
1. 先过滤空 baseline、无效来源、严格早于自身 Created 的字段和 update-only 空配置,再按上述强弱排序选最大状态;无候选必须显式返回 found=false。保留历史 baseline-live 的初次同步和 heal 能力,消除六处“先 seed map 首项,再过滤默认值”的写法。
|
||||
2. 选源和目标遍历都跳过 `info.Sites` 中不存在的 deployment ID,包括不可达站点的空 ID 占位项;单一 peer 失败记录后继续其它目标,不因 map 顺序放弃健康站点。不改变状态计数或新建重试机制。
|
||||
3. 本地 heal 使用提交 1 的源时间 update/delete;远端仍用原有逐类型 RPC,全部携带源时间,补齐 Tag 的 UpdatedAt。
|
||||
4. 比较完整有效状态,去掉公开 mismatch/payload-only 对写入的门控;同内容较旧时间也同步。对已归一且相同的目标不写入、不发 RPC。Versioning 比较使用与该站点 Object Lock 一致的有效文档;全站点 Lock 状态补齐后不再因旧原始文档产生空转。
|
||||
5. 保留 #91 的计数与公开字段;创建世代冲突、无可用来源通过有限诊断解释。已知 baseline 且各站点无实质差异时安静 no-op。
|
||||
|
||||
完成条件:map 顺序不影响结果;默认空值不再删真配置;历史 baseline-live 可以初始化新站点、不能覆盖真实状态;同内容不同时间、同时间冲突最终一致;Tag 时间完整;Quota 删除后磁盘、缓存、重载一致;不可达占位项不阻断健康目标;完整状态已可见且稳定时,第二轮 heal 无写入/广播。
|
||||
|
||||
### 提交 3:新增删除传播与有界诊断
|
||||
|
||||
只增加一个启动开关,暂定 `MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off/on`,默认 off;实现沿用现有 env 开关写法,不做能力协商。
|
||||
|
||||
| 行为 | off:升级阶段 | on:所有参与节点修复后 |
|
||||
| --- | --- | --- |
|
||||
| 带时间 peer apply、锁内排序与 heal | 使用提交 1/2 | 同左 |
|
||||
| 专用事件零时间 | 兼容应用并记录 legacy-zero | 同左,不新增协议拒绝 |
|
||||
| Tags/SSE/Quota nil payload 时间导出 | 保留旧版条件导出 | 导出 `time > Created` 的真实删除时间 |
|
||||
| Policy 时间导出 | 保留已有行为 | 保留已有行为 |
|
||||
| 初次同步的真实删除 | 保留已有行为 | Policy/Tags/SSE/Quota 都发送专用 nil + source time 事件 |
|
||||
|
||||
开关只控制**新增**删除信息的导出/初次发送,普通本地删除事件照常复制。**off 不等于禁止删除传播:Policy 墓碑原本已导出,修复后在 off 下也照常参与 heal;Tags/SSE/Quota 的新增墓碑信息才被门控。** off 期间这些字段的隐藏墓碑会使 heal 继续尝试过时 RPC,由接收端排序拒绝;这是状态不可见时的已知代价,不承诺第二轮零 RPC,也不为这种正常拒绝增加每轮日志。
|
||||
|
||||
开关不检测或证明远端能力。启用条件是所有参与站点的全部节点已经修复,同一站点配置一致,旧请求排空;旧节点仍在线时保持 off。此隔离有实证依据:旧版接到新增 Quota heal 墓碑会留下已解析缓存残留。
|
||||
|
||||
日志仅保留三个实际原因:legacy-zero、before-created、indeterminate(未知创建时间、缺失/不可达来源或有实际差异却无可用候选)。精确重复、正常旧事件和成功裁决的同时间冲突不记警告。复用 `LogOnceIf`,以稳定的桶/字段/原因作为 key,**错误正文也必须稳定**;变化的时间与 peer 详情放入日志 ReqInfo,沿用现有每小时清理,不新增限流框架、不输出完整策略。
|
||||
|
||||
Server 文档解释启用顺序和回滚:降级前所有修复节点先关开关,然后滚动降级;旧软件缺陷会恢复。点名旧版 Tag heal 无 UpdatedAt 的来源。旧版到达时间污染、legacy-zero 产生的新本地时间以及创建世代分歧无法自动还原;操作者查看状态后在权威站点重新提交需要纠正的配置/删除。历史世代冲突先处理桶身份,不能靠任意站点强刷绕过创建保护。
|
||||
|
||||
## 最小验收矩阵
|
||||
|
||||
| 组 | 必须覆盖 | 证据方式 |
|
||||
| --- | --- | --- |
|
||||
| T1 | 六类 PUT 源时间;四类 DELETE;旧事件不回退;重复无写入;零 quota 三元组一致;带合法 Version 的空 Policy PUT 成功、GET NotFound、专用删除事件与落盘一致 | 真实 admin/S3 路由、ErasureSD/Erasure16、磁盘重载、RPC 捕获 |
|
||||
| T2 | 同时间两种到达顺序结果相同、删除优先;Policy 多集合及 NotAction/NotResource/Condition 反复编码与排列后,两站点独立解析得到相同键;Sid 差异及大整数不被错误合并 | 确定性比较器测试与代表性真实 handler |
|
||||
| T3 | Versioning/Object Lock nil/空 no-op;legacy Tags 载荷回退;Object Lock + prefix exclusion/ExcludeFolders 在普通写、peer、bulk/import 后有效状态一致,第二轮 heal 无额外写入 | 原有 #76/#78 回归加针对性用例 |
|
||||
| T4 | 锁前旧事件排队、较新写先提交后旧事件不得覆盖;不同字段并发均保留 | 复用已有 `lockBucketMetadataAcquireHook` / RMW 屏障、两种 ObjectLayer、目标 race |
|
||||
| T5 | bulk 新/旧/缺省字段混合;真实 JSON 编解码的 nil、空 RawMessage、显式 null、空字符串、空策略、零 quota;非法字段不部分保存;只导入 tags 不修改 Policy/Quota | 真实 bulk 路由、缓存与磁盘 |
|
||||
| T6 | 本地时间胜过已有未来时间;相邻提交不倒序;ZIP 导入期间插入写入,最终落盘和发出事件的状态与时间一致,空 Policy 删除不漏发 | 本地 API、import 路由与 RPC 捕获 |
|
||||
| T7 | map 排列、空 baseline/全无候选;baseline-live 初始化与同级冲突收敛,但不能覆盖真实 live/tombstone;nil@Created 不删除配置;空 update-only;Tag 时间、Quota 清缓存、空 ID、世代冲突 | 确定性 heal 本地/远端用例 |
|
||||
| T8 | 墓碑经保存/缓存失效/重启仍有效;缺桶、排队写入、零 Created;历史字段时间全部等于 Created 的桶经初次同步和一轮 heal 后一致;on/off 与 legacy-zero,off 下 Policy 仍 heal、其它隐藏墓碑允许被拒 RPC 但无每轮警告 | ObjectLayer 与进程内旧版 wire/SRInfo 模拟;复用既有删除/迁移回归 |
|
||||
| T9 | 修复版双站点短暂断线、漏发/重复/乱序后六类合法配置收敛;删除传播启用后第二轮稳定无写入;日志确实有界 | 隔离双站点实验,不把未知桶世代或零时间事件算成通过 |
|
||||
|
||||
固定旧版与修复版混合进程仅作一次性升级冒烟,不新增为长期提交门槛。将已有审计用例改成正式确定性回归,不能把遍历 map 的概率复现直接提交。先记录未修复失败,再验证通过;复用原有 Object Lock、adoption、metadata-lock、计数、CORS 回归,并在实现后运行目标 race、仓库必需检查和完整 Go CI。方案审查、局部测试、双站点结果、合并与发布是不同证据。
|
||||
|
||||
## 作者复核结论
|
||||
|
||||
三个提交分别处理写入正确性、heal 收敛和新增删除信息的升级边界;每项对应已证实缺陷或本次修复直接触及的实际路径。缩减了拒绝零时间的新协议行为、混合版本长期测试门槛和新锁屏障;保留已有持久化、通知与缓存语义。
|
||||
|
||||
Opus 第二轮已确认首轮 R1/R2/R3 实质关闭,并确认作者的 Policy 编码和日志去重补查。v3 修正第二轮新增的历史桶 baseline-live 初始化回归,补齐相关验收与 off 模式说明,保留三个实现提交。最后针对性复审返回前不标记为可进入实现;后续仍须用实现期测试证明,而非把本计划当作修复完成。
|
||||
@@ -0,0 +1,121 @@
|
||||
# Issue #77 最小充分修复计划
|
||||
|
||||
版本:v4,2026-09-12。状态:Opus 已对 v3 给出 GO_WITH_NONBLOCKING_NOTES、0 阻断;作者追加实测发现接管时默认时间需随 Created 保持 baseline,补入提交 1 后待该增量复核。
|
||||
|
||||
基准:`pgsty/silo` main `5c576581631561c446f30ae5b566f0aa793adc1c`。本轮交付是方案与评审,不表示已经实现、合并或发布。问题证据见 [当前核验](../../issue-77-current.md),意见处置见 [首轮处置](decisions-v2.md)、[第二轮处置](decisions-v3.md)、[接管边界补查](decisions-v4.md)。
|
||||
|
||||
## 目标与边界
|
||||
|
||||
修复 Policy、Tags、SSE、Quota、Versioning、Object Lock 六类桶配置的源时间丢失、锁外判旧、heal 错选源和删除传播不完整。覆盖普通本地写入、专用 peer 事件、bulk/import、local/remote heal、元数据导出与初次同步。
|
||||
|
||||
在修复 PGSTY 栈、同一已知桶世代、合法带源时间事件的范围内,使重复、乱序和漏发后的状态能够确定性收敛。开关关闭期间新增删除信息不可见,不承诺完整删除收敛;无时间旧事件、旧版污染时间和桶创建世代冲突需要单独解释,不能自动推断历史真相。
|
||||
|
||||
不重做 #91 的计数、#76 的 Object Lock wire 修复、#78 的桶接管、#103/#156 的锁与删除保护。不改变 CORS、Lifecycle/expiry、notification、对象复制、MRF、resync、IAM 的语义;不改存储 schema、SDK、Console、mcli 或 silo-pkg,不新建能力协商、复制框架、锁或重试系统。
|
||||
|
||||
## 行为契约
|
||||
|
||||
### 1. 明确事件、缺省值与删除
|
||||
|
||||
| 类型 | 专用事件 | bulk 字段未提供 | bulk 字段明确提供 |
|
||||
| --- | --- | --- | --- |
|
||||
| Policy | nil 为删除;沿用现有解析器 `IsEmpty()` 为删除 | 保留 | 非空 RawMessage 按现有解析器处理;语义空策略归一为删除 |
|
||||
| Quota | nil 为删除;非 nil 按现有 quota 解析器处理 | 保留 | 非空 RawMessage 按现有解析器处理,零 quota 仍是 live 文档 |
|
||||
| Tags / SSE | nil 或 base64 解码后空内容为删除 | 保留 | 空字符串为删除,其他内容按原规则解码、校验 |
|
||||
| Versioning / Object Lock | nil/空内容为 no-op | 保留 | nil/空内容仍为 no-op,不能清空配置 |
|
||||
|
||||
“未提供”必须依据真实 wire 类型判定:Policy/Quota 的 `json.RawMessage` 为 nil 或空切片时经 `omitempty` 省略;**显式 JSON `null` 解码后是非 nil 的 `[]byte("null")`**,不能与缺省混淆。按现有解析器,Policy `null` 是语义空策略,Quota `null` 是零值 quota 文档。`*string` 类型的 JSON `null` 则解码为 nil。不得使用统一的 `len(payload)==0 => 删除` 来处理 bulk。
|
||||
|
||||
合法 `{}`/零值 quota 保持 live;取消普通 quota PUT 出站时“零配额改写为 nil”的逻辑,保存与发送同一个语义状态。Policy 保留现有专用 peer 的“空策略=删除”解释,本地 PUT、导入和 bulk 统一归一为同样的删除状态;这是需要写入兼容说明的小范围变化:空策略本地立即按删除处理,GET 返回既有 NotFound 行为,不再先保留空文档、等复制后才被清除。
|
||||
|
||||
**同一次操作的落盘状态与出站事件,经同一归一规则后,必须具有相同的 `(kind, payload key, source time)`。** JSON 的无意义编码次序不要求字节相同;Object Lock 改写后的有效 Versioning 文档必须来自提交结果。
|
||||
|
||||
### 2. 统一状态与排序
|
||||
|
||||
内部仅需一个小状态表示:baseline / live / tombstone,以及比较键和字段源时间。baseline 可携带历史有效配置,但没有真实修改时间;它绝不能被当成删除。复用 CORS 已有设计思路,不改 CORS 本身或扩展为通用框架。
|
||||
|
||||
- 使用 `BucketMetadata` 原始字段时间;字段时间为零时在比较视图中补为 Created,与 `defaultTimestamps()` 一致,不借用会隐藏墓碑时间的 getter 或整记录 `lastUpdate()`。
|
||||
- 在已知 Created 下,零字段时间回退后等于 Created 的状态是 baseline:有有效非空配置的 baseline-live 可作为初始化候选;空 baseline 只是缺省值,不能作为删除或 heal 来源。真正严格早于自身 Created 的字段不是候选;Versioning/Object Lock 的空候选无论时间如何都不参与选源。真实 live/tombstone 的时间必须大于 Created。
|
||||
- 专用 peer 的零时间保留兼容例外:锁内按本地新操作分配时间并限频记录 legacy-zero;不受删除传播开关影响,不在源时间排序保证之内。bulk 零时间仍按现状拒绝。
|
||||
- 非零事件**严格早于**目标桶 Created 才成功/no-op 并记录 before-created,heal 对这种目标跳过。等于目标 Created 的 live 事件可更新仍是 baseline 的目标字段,时间仍保存为源 Created,不盖上到达时间;同时间的空/nil 只算空 baseline,不能删除配置。不同桶世代不能自动合并,需要运维处理,不纳入收敛承诺。
|
||||
- 排序先比较是否为真实状态:任何真实 live/tombstone 都胜 baseline,不能让较晚创建的默认值压过较早的真实修改。真实状态之间再比较源时间,同时间 tombstone 胜 live、live/live 的**稳定比较键字节序较大者胜**。baseline-live 只胜空 baseline,或在 baseline-live 之间按同一稳定键较大者胜;永远不能覆盖真实 live/tombstone。所有候选都是 baseline-live 时仍可确定性初始化并收敛;全部为空 baseline 才安静 found=false。键、状态级别和源时间相同为 no-op。deployment ID 不参与上述比较,也不作为新字段保存。
|
||||
- Quota 使用现有解析结果的 JSON 编码作为比较键。Policy 在 Server 内生成确定性比较表示:既有解析器校验/去重后,对其**完整 JSON 树**的对象键和集合数组递归排序,统一覆盖 Statement、Action/NotAction、Resource/NotResource、Principal、Condition;保留数字类型和精度。比较键必须是已解析策略的纯函数,同一配置从两个站点分别解析也必须得到相同键。普通 `json.Marshal(BucketPolicy)` 不稳定,不能直接作键;不增加依赖不支持的 NotPrincipal 语法,不改 wire/schema。新状态比较仅使用这一套键:不把忽略 Sid 且对 Statement 顺序敏感的 `BucketPolicy.Equals` 再叠加为另一套判等规则,既有公开统计对 Equals 的使用保持不变。
|
||||
- XML 使用有效文档字节,保留大小写和实际内容;Versioning 先应用下述现有 Object Lock 约束。比较器不能依靠字节序方向来补偿保存阶段的隐式改写。
|
||||
|
||||
### 3. 先得到有效状态,再比较与提交
|
||||
|
||||
原始读取、类型处理、比较、修改及保存均在现有 `metadata.lock` 内。内部入口必须传递现有 lock context,避免 legacy migration 再次取锁。
|
||||
|
||||
- 提取并复用 `parseAllConfigs` 已有的 Object Lock→Enabled Versioning 归一规则,使比较视图和真正保存一致;不得扩大 suspend、prefix exclusion、retention 的限制。bulk 先确定实际接受的 Object Lock,再比较该约束下的 Versioning,并在最终提交前应用同一规则。
|
||||
- 以归一化后的有效 `(kind, key, time)` 判定变化;更新了时间也算变化。完全重复不保存、不通知;一次 bulk 校验失败不保存部分结果;成功至多保存一次,解锁后通知。
|
||||
- 可删除字段清空必须基于现有 parse=false 的新加载对象,不能在已经解析且仍持有旧 quota 的对象上执行 Update(nil)。bulk 保留原始读取再解析保存的方式。不要顺手修改 Quota getter 或所有 `parseAllConfigs` 空分支。
|
||||
- 保存函数必须让需要发送 hook 的调用方拿到**本次提交的最终快照**,不能先解锁再读取“最新”状态拼接旧时间。最小做法是让内部 `saveMetadata` 接收元数据指针并回写 Save 的归一化结果,机械更新现有少量调用;对外 `Update/Delete` 签名不变,新增内部提交结果仅供需要该快照的本地 handler/import 使用。不得原地修改已发布到缓存的引用字段。
|
||||
- 保留现有物理桶存在检查、删除锁序、迁移、后台通知上下文。真实历史桶 Created 为零时仅走少见的物理桶 Created 补齐路径;物理桶缺失返回现有错误;创建时间仍未知则不伪造到达时间,报告 indeterminate。
|
||||
|
||||
## 三个实现提交
|
||||
|
||||
### 提交 1:原子 apply、发送一致性与本地时间
|
||||
|
||||
主要文件:`cmd/bucket-metadata-sys.go`、`cmd/bucket-metadata.go`、`cmd/site-replication.go`、`cmd/admin-bucket-handlers.go`,以及实际需要提交快照的本地配置 handler。
|
||||
|
||||
1. 在现有 update/delete 内部路径增加源时间、状态比较和提交结果;公开签名不变。六个 peer handler 移除锁外 getter 判旧,锁内持久化原始源时间。保留 Object Lock 的 legacy Tags 字段载荷回退。
|
||||
2. bulk 对明确提供的六类字段在已有锁内逐字段比较,再原子保存;未提供字段不动,不能循环调用会重入锁的公开 handler。保留 CORS 独立分支与既有行为。
|
||||
3. 六类本地实际写入在锁内分配 `max(UTCNow(), Created+1ns, 当前字段时间+1ns)`。其他类型不变。`enablePeerBucketVersioning` 的实际变更也使用它,只有缺失配置的创建 bootstrap 继续 Created 默认值。
|
||||
接管已有桶时保留原 Created,再执行现有 `SetCreatedAt`;如果 Created 改变,仅将这六类中原本为零或等于原 Created 的默认字段时间调整到新 Created,随后再做既有 versioning/lock bootstrap。原本晚于旧 Created 的真实修改/删除时间及其配置保持不变。不能仅凭 payload 为 nil 判断默认值。该小分支用于防止原默认时间因 Created 前移被误识别为墓碑,复用已有接管锁,不重做 #78 的配置保护。
|
||||
4. quota 本地 PUT 保留零值文档并原样表示该语义;Policy 空策略本地与 peer 一致走删除。需要归一化的本地 handler 从本次提交快照生成 hook;其他内容不发生归一变化的路径可保留既有编码,但必须满足三元组一致性。
|
||||
5. 导入在每桶最终提交锁内,为本次涉及的六类字段生成共同 commitAt,严格大于 Created 和这些字段当前时间且不早于锁内现在。该时间同时用于落盘和 bulk hook,不能沿用 ZIP 开始时间。bulk hook 从最终提交快照构建;若导入的空 Policy 已归一成删除,另外使用现有专用 Policy nil 事件表达它,不能因 `omitempty` 漏发。未导入字段不改,Object Lock 的既有派生 Versioning 修正保留原时间语义;CORS 继续独立时间/事件,其他字段不参与该上界。
|
||||
|
||||
完成条件:六类源时间落盘;旧事件不能越过锁覆盖新状态;四类删除不会被旧 PUT 复活;真实 wire、落盘和出站状态一致;重复无写入;bulk 与 import 没有绕过排序或静默遗漏删除。
|
||||
|
||||
### 提交 2:heal 选源与应用同规则
|
||||
|
||||
主要文件:`cmd/site-replication.go`。
|
||||
|
||||
1. 先过滤空 baseline、无效来源、严格早于自身 Created 的字段和 update-only 空配置,再按上述强弱排序选最大状态;无候选必须显式返回 found=false。保留历史 baseline-live 的初次同步和 heal 能力,消除六处“先 seed map 首项,再过滤默认值”的写法。
|
||||
2. 选源和目标遍历都跳过 `info.Sites` 中不存在的 deployment ID,包括不可达站点的空 ID 占位项;单一 peer 失败记录后继续其它目标,不因 map 顺序放弃健康站点。不改变状态计数或新建重试机制。
|
||||
3. 本地 heal 使用提交 1 的源时间 update/delete;远端仍用原有逐类型 RPC,全部携带源时间,补齐 Tag 的 UpdatedAt。
|
||||
4. 比较完整有效状态,去掉公开 mismatch/payload-only 对写入的门控;同内容较旧时间也同步。对已归一且相同的目标不写入、不发 RPC。Versioning 比较使用与该站点 Object Lock 一致的有效文档;全站点 Lock 状态补齐后不再因旧原始文档产生空转。
|
||||
5. 保留 #91 的计数与公开字段;创建世代冲突、无可用来源通过有限诊断解释。已知 baseline 且各站点无实质差异时安静 no-op。
|
||||
|
||||
完成条件:map 顺序不影响结果;默认空值不再删真配置;历史 baseline-live 可以初始化新站点、不能覆盖真实状态;同内容不同时间、同时间冲突最终一致;Tag 时间完整;Quota 删除后磁盘、缓存、重载一致;不可达占位项不阻断健康目标;完整状态已可见且稳定时,第二轮 heal 无写入/广播。
|
||||
|
||||
### 提交 3:新增删除传播与有界诊断
|
||||
|
||||
只增加一个启动开关,暂定 `MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off/on`,默认 off;实现沿用现有 env 开关写法,不做能力协商。
|
||||
|
||||
| 行为 | off:升级阶段 | on:所有参与节点修复后 |
|
||||
| --- | --- | --- |
|
||||
| 带时间 peer apply、锁内排序与 heal | 使用提交 1/2 | 同左 |
|
||||
| 专用事件零时间 | 兼容应用并记录 legacy-zero | 同左,不新增协议拒绝 |
|
||||
| Tags/SSE/Quota nil payload 时间导出 | 保留旧版条件导出 | 导出 `time > Created` 的真实删除时间 |
|
||||
| Policy 时间导出 | 保留已有行为 | 保留已有行为 |
|
||||
| 初次同步的真实删除 | 保留已有行为 | Policy/Tags/SSE/Quota 都发送专用 nil + source time 事件 |
|
||||
|
||||
开关只控制**新增**删除信息的导出/初次发送,普通本地删除事件照常复制。**off 不等于禁止删除传播:Policy 墓碑原本已导出,修复后在 off 下也照常参与 heal;Tags/SSE/Quota 的新增墓碑信息才被门控。** off 期间这些字段的隐藏墓碑会使 heal 继续尝试过时 RPC,由接收端排序拒绝;这是状态不可见时的已知代价,不承诺第二轮零 RPC,也不为这种正常拒绝增加每轮日志。
|
||||
|
||||
开关不检测或证明远端能力。启用条件是所有参与站点的全部节点已经修复,同一站点配置一致,旧请求排空;旧节点仍在线时保持 off。此隔离有实证依据:旧版接到新增 Quota heal 墓碑会留下已解析缓存残留。
|
||||
|
||||
日志仅保留三个实际原因:legacy-zero、before-created、indeterminate(未知创建时间、缺失/不可达来源或有实际差异却无可用候选)。精确重复、正常旧事件和成功裁决的同时间冲突不记警告。复用 `LogOnceIf`,以稳定的桶/字段/原因作为 key,**错误正文也必须稳定**;变化的时间与 peer 详情放入日志 ReqInfo,沿用现有每小时清理,不新增限流框架、不输出完整策略。
|
||||
|
||||
Server 文档解释启用顺序和回滚:降级前所有修复节点先关开关,然后滚动降级;旧软件缺陷会恢复。点名旧版 Tag heal 无 UpdatedAt 的来源。旧版到达时间污染、legacy-zero 产生的新本地时间以及创建世代分歧无法自动还原;操作者查看状态后在权威站点重新提交需要纠正的配置/删除。历史世代冲突先处理桶身份,不能靠任意站点强刷绕过创建保护。
|
||||
|
||||
## 最小验收矩阵
|
||||
|
||||
| 组 | 必须覆盖 | 证据方式 |
|
||||
| --- | --- | --- |
|
||||
| T1 | 六类 PUT 源时间;四类 DELETE;旧事件不回退;重复无写入;零 quota 三元组一致;带合法 Version 的空 Policy PUT 成功、GET NotFound、专用删除事件与落盘一致 | 真实 admin/S3 路由、ErasureSD/Erasure16、磁盘重载、RPC 捕获 |
|
||||
| T2 | 同时间两种到达顺序结果相同、删除优先;Policy 多集合及 NotAction/NotResource/Condition 反复编码与排列后,两站点独立解析得到相同键;Sid 差异及大整数不被错误合并 | 确定性比较器测试与代表性真实 handler |
|
||||
| T3 | Versioning/Object Lock nil/空 no-op;legacy Tags 载荷回退;Object Lock + prefix exclusion/ExcludeFolders 在普通写、peer、bulk/import 后有效状态一致,第二轮 heal 无额外写入 | 原有 #76/#78 回归加针对性用例 |
|
||||
| T4 | 锁前旧事件排队、较新写先提交后旧事件不得覆盖;不同字段并发均保留 | 复用已有 `lockBucketMetadataAcquireHook` / RMW 屏障、两种 ObjectLayer、目标 race |
|
||||
| T5 | bulk 新/旧/缺省字段混合;真实 JSON 编解码的 nil、空 RawMessage、显式 null、空字符串、空策略、零 quota;非法字段不部分保存;只导入 tags 不修改 Policy/Quota | 真实 bulk 路由、缓存与磁盘 |
|
||||
| T6 | 本地时间胜过已有未来时间;相邻提交不倒序;ZIP 导入期间插入写入,最终落盘和发出事件的状态与时间一致,空 Policy 删除不漏发 | 本地 API、import 路由与 RPC 捕获 |
|
||||
| T7 | map 排列、空 baseline/全无候选;baseline-live 初始化与同级冲突收敛,但不能覆盖真实 live/tombstone;nil@Created 不删除配置;空 update-only;Tag 时间、Quota 清缓存、空 ID、世代冲突 | 确定性 heal 本地/远端用例 |
|
||||
| T8 | 墓碑经保存/缓存失效/重启仍有效;缺桶、排队写入、零 Created;历史字段时间等于 Created 的桶经初次同步、一轮 heal 后一致,第二轮无写入/广播;接管前移 Created 后默认时间仍是 baseline,真实 PUT/DELETE 时间不变;on/off 与 legacy-zero,off 下 Policy 仍 heal、其它隐藏墓碑允许被拒 RPC 但无每轮警告 | ObjectLayer 与进程内旧版 wire/SRInfo 模拟;复用既有删除/迁移回归 |
|
||||
| T9 | 修复版双站点短暂断线、漏发/重复/乱序后六类合法配置收敛;删除传播启用后第二轮稳定无写入;日志确实有界 | 隔离双站点实验,不把未知桶世代或零时间事件算成通过 |
|
||||
|
||||
固定旧版与修复版混合进程仅作一次性升级冒烟,不新增为长期提交门槛。将已有审计用例改成正式确定性回归,不能把遍历 map 的概率复现直接提交。先记录未修复失败,再验证通过;复用原有 Object Lock、adoption、metadata-lock、计数、CORS 回归,并在实现后运行目标 race、仓库必需检查和完整 Go CI。方案审查、局部测试、双站点结果、合并与发布是不同证据。
|
||||
|
||||
## 作者复核结论
|
||||
|
||||
三个提交分别处理写入正确性、heal 收敛和新增删除信息的升级边界;每项对应已证实缺陷或本次修复直接触及的实际路径。缩减了拒绝零时间的新协议行为、混合版本长期测试门槛和新锁屏障;保留已有持久化、通知与缓存语义。
|
||||
|
||||
Opus 第二轮已确认首轮 R1/R2/R3 实质关闭,第三轮确认 v3 的历史桶初始化边界修正,给出 0 阻断。作者随后在两个真实 ObjectLayer 上补查到接管改变 Created 会把旧默认时间变成假墓碑,v4 仅补充默认时间随接管保持 baseline 的分支与回归。所有变更仍在三个实现提交内;该增量复审返回前不标记最终方案通过,后续仍须用实现期测试证明,而非把本计划当作修复完成。
|
||||
@@ -0,0 +1,14 @@
|
||||
package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
func main() {
|
||||
p,err:=policy.ParseBucketPolicyConfig(strings.NewReader(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::issue77-test/a/*","arn:aws:s3:::issue77-test/b/*"]}]}`),"issue77-test")
|
||||
if err!=nil {panic(err)}
|
||||
variants:=map[string]int{}
|
||||
for i:=0;i<100;i++ {b,err:=json.Marshal(p);if err!=nil{panic(err)};variants[string(b)]++}
|
||||
fmt.Printf("100 encodings of the same parsed BucketPolicy produced %d distinct byte sequences\n",len(variants))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
func main() {
|
||||
for _, raw := range []string{`{"bucket":"b"}`, `{"bucket":"b","policy":null,"quota":null}`, `{"bucket":"b","policy":{"Version":"2012-10-17","Statement":[]},"quota":{}}`} {
|
||||
var x madmin.SRBucketMeta
|
||||
if err:=json.Unmarshal([]byte(raw),&x);err!=nil{panic(err)}
|
||||
fmt.Printf("wire=%s\nPolicy nil=%t bytes=%q; Quota nil=%t bytes=%q\n",raw,x.Policy==nil,[]byte(x.Policy),x.Quota==nil,[]byte(x.Quota))
|
||||
if len(x.Policy)>0 && !bytes.Equal(x.Policy,[]byte("null")) {
|
||||
p,err:=policy.ParseBucketPolicyConfig(bytes.NewReader(x.Policy),"b")
|
||||
if err!=nil{panic(err)}
|
||||
fmt.Printf("Policy parses successfully, IsEmpty=%t\n",p.IsEmpty())
|
||||
}
|
||||
}
|
||||
for _, v := range []json.RawMessage{nil,{},[]byte("null")} {
|
||||
b,err:=json.Marshal(madmin.SRBucketMeta{Bucket:"b",Policy:v});if err!=nil{panic(err)}
|
||||
fmt.Printf("encoded raw=%q -> %s\n",[]byte(v),b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
wire={"bucket":"b"}
|
||||
Policy nil=true bytes=""; Quota nil=true bytes=""
|
||||
wire={"bucket":"b","policy":null,"quota":null}
|
||||
Policy nil=false bytes="null"; Quota nil=false bytes="null"
|
||||
wire={"bucket":"b","policy":{"Version":"2012-10-17","Statement":[]},"quota":{}}
|
||||
Policy nil=false bytes="{\"Version\":\"2012-10-17\",\"Statement\":[]}"; Quota nil=false bytes="{}"
|
||||
Policy parses successfully, IsEmpty=true
|
||||
encoded raw="" -> {"type":"","bucket":"b","updatedAt":"0001-01-01T00:00:00Z","expiryUpdatedAt":"0001-01-01T00:00:00Z"}
|
||||
encoded raw="" -> {"type":"","bucket":"b","updatedAt":"0001-01-01T00:00:00Z","expiryUpdatedAt":"0001-01-01T00:00:00Z"}
|
||||
encoded raw="null" -> {"type":"","bucket":"b","policy":null,"updatedAt":"0001-01-01T00:00:00Z","expiryUpdatedAt":"0001-01-01T00:00:00Z"}
|
||||
@@ -0,0 +1 @@
|
||||
[null,"2026-09-12T09:14:44.759058Z",null,"2026-09-12T09:14:44.759058Z",null,"2026-09-12T09:14:44.759058Z",null,"2026-09-12T09:14:44.759058Z","PFZlcnNpb25pbmdDb25maWd1cmF0aW9uIHhtbG5zPSJodHRwOi8vczMuYW1hem9uYXdzLmNvbS9kb2MvMjAwNi0wMy0wMS8iPjxTdGF0dXM+RW5hYmxlZDwvU3RhdHVzPjwvVmVyc2lvbmluZ0NvbmZpZ3VyYXRpb24+","2026-09-12T09:13:44.759058Z","PE9iamVjdExvY2tDb25maWd1cmF0aW9uPjxPYmplY3RMb2NrRW5hYmxlZD5FbmFibGVkPC9PYmplY3RMb2NrRW5hYmxlZD48UnVsZT48RGVmYXVsdFJldGVudGlvbj48TW9kZT5HT1ZFUk5BTkNFPC9Nb2RlPjxEYXlzPjMwPC9EYXlzPjwvRGVmYXVsdFJldGVudGlvbj48L1J1bGU+PC9PYmplY3RMb2NrQ29uZmlndXJhdGlvbj4=","2026-09-12T09:13:44.759058Z"]
|
||||
@@ -0,0 +1,333 @@
|
||||
//go:build ignore
|
||||
|
||||
// Standalone disposable loopback-lab driver; see ../../issue-77.md.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
minio "github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"github.com/minio/minio-go/v7/pkg/tags"
|
||||
)
|
||||
|
||||
const bucket = "issue77-runtime"
|
||||
const user = "issue77-lab-admin"
|
||||
const password = "issue77-disposable-lab-secret"
|
||||
|
||||
var ctx = context.Background()
|
||||
var root string
|
||||
|
||||
type site struct {
|
||||
name, address, binary, gate string
|
||||
cmd *exec.Cmd
|
||||
log *os.File
|
||||
proxy *httptest.Server
|
||||
block atomic.Bool
|
||||
calls atomic.Int64
|
||||
dropped atomic.Int64
|
||||
admin *madmin.AdminClient
|
||||
s3 *minio.Client
|
||||
}
|
||||
|
||||
func check(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
func stamp(s string) { fmt.Println(time.Now().UTC().Format(time.RFC3339), s) }
|
||||
func port() string {
|
||||
l, e := net.Listen("tcp", "127.0.0.1:0")
|
||||
check(e)
|
||||
s := l.Addr().String()
|
||||
check(l.Close())
|
||||
return s
|
||||
}
|
||||
func newSite(name, binary string) *site {
|
||||
s := &site{name: name, address: port(), binary: binary, gate: "on"}
|
||||
target, e := url.Parse("http://" + s.address)
|
||||
check(e)
|
||||
reverse := httputil.NewSingleHostReverseProxy(target)
|
||||
reverse.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) { w.WriteHeader(503) }
|
||||
s.proxy = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/site-replication/peer/bucket-meta") {
|
||||
data, e := io.ReadAll(r.Body)
|
||||
check(e)
|
||||
r.Body = io.NopCloser(bytes.NewReader(data))
|
||||
var event madmin.SRBucketMeta
|
||||
check(json.Unmarshal(data, &event))
|
||||
if event.Bucket == bucket {
|
||||
s.calls.Add(1)
|
||||
if s.block.Load() {
|
||||
s.dropped.Add(1)
|
||||
w.WriteHeader(503)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
reverse.ServeHTTP(w, r)
|
||||
}))
|
||||
s.admin, e = madmin.New(s.address, user, password, false)
|
||||
check(e)
|
||||
s.s3, e = minio.New(s.address, &minio.Options{Creds: credentials.NewStaticV4(user, password, ""), Secure: false})
|
||||
check(e)
|
||||
return s
|
||||
}
|
||||
func (s *site) start() {
|
||||
var e error
|
||||
s.log, e = os.OpenFile(filepath.Join(root, s.name+".jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
check(e)
|
||||
args := []string{"--json", "server", "--quiet", "--address", s.address, "--console-address", port()}
|
||||
for i := 0; i < 4; i++ {
|
||||
dir := filepath.Join(root, s.name, fmt.Sprintf("disk%d", i))
|
||||
check(os.MkdirAll(dir, 0700))
|
||||
args = append(args, dir)
|
||||
}
|
||||
s.cmd = exec.Command(s.binary, args...)
|
||||
s.cmd.Env = append(os.Environ(), "MINIO_ROOT_USER="+user, "MINIO_ROOT_PASSWORD="+password, "MINIO_BROWSER=off", "MINIO_CI_CD=1", "MINIO_SITE_REPLICATION_METADATA_TOMBSTONES="+s.gate)
|
||||
s.cmd.Stdout = s.log
|
||||
s.cmd.Stderr = s.log
|
||||
check(s.cmd.Start())
|
||||
client := http.Client{Timeout: time.Second}
|
||||
deadline := time.Now().Add(50 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
r, e := client.Get("http://" + s.address + "/minio/health/ready")
|
||||
if e == nil {
|
||||
r.Body.Close()
|
||||
if r.StatusCode == 200 {
|
||||
stamp(s.name + " ready gate=" + s.gate)
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
panic(s.name + " startup timed out; inspect " + s.log.Name())
|
||||
}
|
||||
func (s *site) stop() {
|
||||
if s.cmd == nil {
|
||||
return
|
||||
}
|
||||
_ = s.cmd.Process.Signal(os.Interrupt)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- s.cmd.Wait() }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
_ = s.cmd.Process.Kill()
|
||||
<-done
|
||||
}
|
||||
s.log.Close()
|
||||
s.cmd = nil
|
||||
}
|
||||
func (s *site) info() madmin.SRBucketInfo {
|
||||
i, e := s.admin.SRMetaInfo(ctx, madmin.SRStatusOptions{Buckets: true})
|
||||
check(e)
|
||||
return i.Buckets[bucket]
|
||||
}
|
||||
func (s *site) apply(e madmin.SRBucketMeta) {
|
||||
e.Bucket = bucket
|
||||
check(s.admin.SRPeerReplicateBucketMeta(ctx, e))
|
||||
}
|
||||
func enc(s string) *string { r := base64.StdEncoding.EncodeToString([]byte(s)); return &r }
|
||||
func events(at time.Time, newer bool) []madmin.SRBucketMeta {
|
||||
value, days := "old", 10
|
||||
if newer {
|
||||
value, days = "new", 30
|
||||
}
|
||||
return []madmin.SRBucketMeta{
|
||||
{Type: madmin.SRBucketMetaTypePolicy, UpdatedAt: at, Policy: []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Sid":%q,"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"}]}`, value, bucket))},
|
||||
{Type: madmin.SRBucketMetaTypeTags, UpdatedAt: at, Tags: enc(`<Tagging><TagSet><Tag><Key>key</Key><Value>` + value + `</Value></Tag></TagSet></Tagging>`)},
|
||||
{Type: madmin.SRBucketMetaTypeSSEConfig, UpdatedAt: at, SSEConfig: enc(`<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>`)},
|
||||
{Type: madmin.SRBucketMetaTypeQuotaConfig, UpdatedAt: at, Quota: []byte(`{"quota":1024,"quotatype":"hard"}`)},
|
||||
{Type: madmin.SRBucketMetaTypeVersionConfig, UpdatedAt: at, Versioning: enc(`<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>`)},
|
||||
{Type: madmin.SRBucketMetaTypeObjectLockConfig, UpdatedAt: at, ObjectLockConfig: enc(fmt.Sprintf(`<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>%d</Days></DefaultRetention></Rule></ObjectLockConfiguration>`, days))},
|
||||
}
|
||||
}
|
||||
func states(m madmin.SRBucketInfo) []byte {
|
||||
b, e := json.Marshal([]any{m.Policy, m.PolicyUpdatedAt, m.Tags, m.TagConfigUpdatedAt, m.SSEConfig, m.SSEConfigUpdatedAt, m.QuotaConfig, m.QuotaConfigUpdatedAt, m.Versioning, m.VersioningConfigUpdatedAt, m.ObjectLockConfig, m.ObjectLockConfigUpdatedAt})
|
||||
check(e)
|
||||
return b
|
||||
}
|
||||
func times(m madmin.SRBucketInfo) []time.Time {
|
||||
return []time.Time{m.PolicyUpdatedAt, m.TagConfigUpdatedAt, m.SSEConfigUpdatedAt, m.QuotaConfigUpdatedAt, m.VersioningConfigUpdatedAt, m.ObjectLockConfigUpdatedAt}
|
||||
}
|
||||
func converge(a, b *site, label string) {
|
||||
stamp(label + ": waiting for ordinary 30-second heal")
|
||||
deadline := time.Now().Add(130 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if bytes.Equal(states(a.info()), states(b.info())) {
|
||||
stamp(label + ": six field states equal")
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
check(os.WriteFile(filepath.Join(root, label+"-a.json"), states(a.info()), 0600))
|
||||
check(os.WriteFile(filepath.Join(root, label+"-b.json"), states(b.info()), 0600))
|
||||
panic(label + " convergence timed out")
|
||||
}
|
||||
func quiet(a, b *site, label string) {
|
||||
beforeA, beforeB := a.calls.Load(), b.calls.Load()
|
||||
stamp(label + ": observing two heal intervals")
|
||||
time.Sleep(65 * time.Second)
|
||||
if a.calls.Load() != beforeA || b.calls.Load() != beforeB {
|
||||
panic(fmt.Sprintf("%s not quiet: a %d -> %d, b %d -> %d", label, beforeA, a.calls.Load(), beforeB, b.calls.Load()))
|
||||
}
|
||||
stamp(label + ": zero metadata RPCs across 65 seconds")
|
||||
}
|
||||
func main() {
|
||||
root = os.Args[1]
|
||||
binary, e := filepath.Abs(os.Args[2])
|
||||
check(e)
|
||||
check(os.MkdirAll(root, 0700))
|
||||
a, b := newSite("site-a", binary), newSite("site-b", binary)
|
||||
defer a.proxy.Close()
|
||||
defer b.proxy.Close()
|
||||
defer a.stop()
|
||||
defer b.stop()
|
||||
a.start()
|
||||
b.start()
|
||||
created := time.Now().UTC().Add(-time.Hour)
|
||||
check(a.admin.SRPeerBucketOps(ctx, bucket, madmin.MakeWithVersioningBktOp, map[string]string{"createdAt": created.Format(time.RFC3339Nano), "lockEnabled": "true", "versioningEnabled": "true"}))
|
||||
for _, event := range events(created, false) {
|
||||
a.apply(event)
|
||||
}
|
||||
stamp("historical bucket prepared: six live fields at Created")
|
||||
status, e := a.admin.SiteReplicationAdd(ctx, []madmin.PeerSite{{Name: a.name, Endpoint: a.proxy.URL, AccessKey: user, SecretKey: password}, {Name: b.name, Endpoint: b.proxy.URL, AccessKey: user, SecretKey: password}}, madmin.SRAddOptions{})
|
||||
check(e)
|
||||
if !status.Success || status.InitialSyncErrorMessage != "" {
|
||||
panic(fmt.Sprintf("add sites: %+v", status))
|
||||
}
|
||||
converge(a, b, "baseline-initial-sync")
|
||||
for _, tm := range times(a.info()) {
|
||||
if !tm.Equal(created) {
|
||||
panic("initial sync reclocked historical baseline: " + tm.String())
|
||||
}
|
||||
}
|
||||
stamp("initial sync preserved six historical Created timestamps")
|
||||
a.block.Store(true)
|
||||
b.block.Store(true)
|
||||
faultTags, e := tags.NewTags(map[string]string{"fault": "outgoing-message-dropped"}, true)
|
||||
check(e)
|
||||
check(a.s3.SetBucketTagging(ctx, bucket, faultTags))
|
||||
if b.dropped.Load() == 0 {
|
||||
panic("outbound fault injection did not drop a real metadata RPC")
|
||||
}
|
||||
stamp("real local PUT outgoing replication RPC dropped by partition")
|
||||
at := time.Now().UTC().Add(time.Minute)
|
||||
newer, older := events(at, true), events(at.Add(-time.Second), false)
|
||||
for i := range newer {
|
||||
a.apply(newer[i])
|
||||
a.apply(newer[i])
|
||||
b.apply(older[i])
|
||||
a.apply(older[i])
|
||||
}
|
||||
for _, tm := range times(a.info()) {
|
||||
if !tm.Equal(at) {
|
||||
panic("duplicate/out-of-order source time changed: " + tm.String())
|
||||
}
|
||||
}
|
||||
if bytes.Equal(states(a.info()), states(b.info())) {
|
||||
panic("fault setup did not create divergence")
|
||||
}
|
||||
a.block.Store(false)
|
||||
b.block.Store(false)
|
||||
converge(a, b, "live-reconnect")
|
||||
quiet(a, b, "live-steady")
|
||||
for _, event := range older {
|
||||
b.apply(event)
|
||||
}
|
||||
if !bytes.Equal(states(a.info()), states(b.info())) {
|
||||
panic("stale event after convergence rolled back state")
|
||||
}
|
||||
stamp("six duplicate/out-of-order events rejected without clock changes")
|
||||
a.block.Store(true)
|
||||
b.block.Store(true)
|
||||
dropped := b.dropped.Load()
|
||||
check(a.s3.RemoveBucketTagging(ctx, bucket))
|
||||
if b.dropped.Load() <= dropped {
|
||||
panic("ordinary delete RPC was not dropped")
|
||||
}
|
||||
stamp("real local DELETE outgoing replication RPC dropped by partition")
|
||||
deleteAt := at.Add(time.Minute)
|
||||
for _, event := range newer[:4] {
|
||||
a.apply(madmin.SRBucketMeta{Type: event.Type, UpdatedAt: deleteAt})
|
||||
}
|
||||
beforeRestart := states(a.info())
|
||||
a.stop()
|
||||
a.start()
|
||||
if !bytes.Equal(beforeRestart, states(a.info())) {
|
||||
panic("restart lost deletion/source state")
|
||||
}
|
||||
stamp("four tombstones survived process restart")
|
||||
a.block.Store(false)
|
||||
b.block.Store(false)
|
||||
converge(a, b, "delete-reconnect")
|
||||
quiet(a, b, "delete-steady")
|
||||
final := a.info()
|
||||
if len(final.Policy) != 0 || final.Tags != nil || final.SSEConfig != nil || final.QuotaConfig != nil {
|
||||
panic("deletion did not converge")
|
||||
}
|
||||
check(os.WriteFile(filepath.Join(root, "converged.json"), states(final), 0600))
|
||||
// Deliberate legacy and generation exceptions are outside convergence proof.
|
||||
for i := 0; i < 20; i++ {
|
||||
event := newer[1]
|
||||
event.UpdatedAt = time.Time{}
|
||||
a.apply(event)
|
||||
event.UpdatedAt = final.CreatedAt.Add(-time.Duration(i+1) * time.Second)
|
||||
a.apply(event)
|
||||
}
|
||||
data, e := os.ReadFile(filepath.Join(root, "site-a.jsonl"))
|
||||
check(e)
|
||||
for _, reason := range []string{"legacy-zero", "before-created"} {
|
||||
n := strings.Count(string(data), "bucket metadata replication: "+reason)
|
||||
if n != 1 {
|
||||
panic(fmt.Sprintf("%s diagnostics count %d, want 1", reason, n))
|
||||
}
|
||||
}
|
||||
stamp("40 exceptional events produced exactly one log per reason")
|
||||
if len(os.Args) > 3 {
|
||||
stamp("starting fixed/previous-server rolling-upgrade smoke with gate off")
|
||||
a.stop()
|
||||
b.stop()
|
||||
a.gate = "off"
|
||||
b.gate = "off"
|
||||
b.binary, e = filepath.Abs(os.Args[3])
|
||||
check(e)
|
||||
a.start()
|
||||
b.start()
|
||||
tag, e := tags.NewTags(map[string]string{"upgrade": "works"}, true)
|
||||
check(e)
|
||||
check(a.s3.SetBucketTagging(ctx, bucket, tag))
|
||||
remote, e := b.s3.GetBucketTagging(ctx, bucket)
|
||||
check(e)
|
||||
if remote.ToMap()["upgrade"] != "works" {
|
||||
panic("mixed-version live event lost")
|
||||
}
|
||||
check(a.s3.RemoveBucketTagging(ctx, bucket))
|
||||
if a.info().Tags != nil || b.info().Tags != nil {
|
||||
panic("mixed-version ordinary tag delete lost")
|
||||
}
|
||||
if !a.info().TagConfigUpdatedAt.IsZero() {
|
||||
panic("off exporter exposed new tombstone")
|
||||
}
|
||||
stamp("mixed-version ordinary PUT/DELETE and off tombstone visibility passed")
|
||||
}
|
||||
stamp("PASS: isolated two-site implementation acceptance")
|
||||
}
|
||||
Reference in New Issue
Block a user