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

293 lines
31 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.
# 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 由作者判断,可留作后续。