Files
minio/docs/investigations/issue-77/review/opus5-max-v1.md
2026-09-12 17:52:25 +08:00

125 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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。