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

31 KiB
Raw Blame History

SILO #77 实现深度对抗性代码审查报告

审查对象 4089113e3(基线 5c5765816)。只读 worktree,全部结论来自直接阅读 cmd/ 生产代码、silo-pkg/madmin-go 依赖源码与调用链,并与证据目录交叉核对。测试 PASS 仅当作"已覆盖的观察",不作正确性证明。

说明:本会话的 Write/ExitPlanMode 工具不可用,报告直接输出于此,未落盘。


0. Verdict

GO_WITH_NONBLOCKING_NOTES

  • 无条件阻断项:0
  • 条件性阻断项:1F1)——条件:支持矩阵中存在 .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:1092import)、cmd/site-replication.go:1671bulk peer apply
  • cmd/site-replication.go:2156-2166 + cmd/site-replication-metadata.go:45-55(初次同步静默跳过)
  • cmd/site-replication-metadata-gate_test.go:145-159stub

最短触发链:

  1. 桶存在,但 .minio.sys/buckets/<b>/.metadata.bin 不存在且无 legacy 配置文件 → loadBucketMetadataParseerrConfigNotFound 分支并返回 nil error 且 Created == 0cmd/bucket-metadata.go:230-297;注意 defaultTimestamps() 只在 err == nil 时调用)。
  2. 启动时 concurrentLoadcmd/bucket-metadata-sys.go:737-763)把这个 Created == 0 的 meta 写进 metadataMap
  3. PUT ?tagging(或 policy/sse/quota/versioning/object-lock)→ updateAndParseMetadataensureBucketMetadataCreatedobjAPI.GetBucketInfo(..., NoMetadata:true)erasureServerPools.GetBucketInfo 先取到物理卷时间,随后被缓存里的 0 覆盖info.Created.IsZero()errors.New("bucket metadata creation time is unknown")
  4. 客户端收到错误。基线版本此处成功(直接赋值 + saveMetadata)。

注:getAllLegacyConfigscmd/bucket-metadata.go:468 会用 info.ModTimeCreated,所以 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 里创建时间为零"这一既有瑕疵。需复核 bucketExistshasBucket = !bi.CreatedAt.IsZero() 等消费点 —— Created 由零变非零对它们都是变好。
  • (b) 另外在 syncToAllPeerscmd/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 冲突:永久假 mismatchheal 永不修复

严重度 S2(公开状态/可观测性,非数据损失)· 已证实(源码级),需运行确认

位置:

  • cmd/bucket-metadata-replication.go:110 —— sort.Slice 作用于每个数组,含顶层 Statement
  • cmd/bucket-metadata-replication.go:124-161 canonicalBucketPolicy
  • cmd/site-replication.go:3796 isBktPolicyReplicatedprev.Equals(*p)silo-pkg .../policy/bucket-policy.go:190-194Statements[i] 下标逐一比较,顺序敏感)
  • cmd/site-replication-metadata.go:174 vs :179 —— 本地 heal 写源站原始字节,远端 heal 经 PeerBucketPolicyHandlercanonicalBucketPolicy规范字节

最短反例:

  1. 站点 A 存在升级前写入的桶策略,statement 顺序不是规范字节序(极常见,例如 Deny 在 Allow 之前)。
  2. 加入新站点 BsyncToAllPeersinitialBucketConfigReplicationEvent 发送 A 的原始字节UpdatedAt = A.PolicyConfigUpdatedAt
  3. B 的 admin 入口解析后调 PeerBucketPolicyHandlercanonicalBucketPolicy → 存排序后字节。
  4. 此后 A/B 的比较键(canonical)完全相同、时间相同 → compareBucketConfigStates == 0 → heal 永不写、永不发 RPC;而 Equals 因 statement 顺序不同返回 false → mc admin replicate status 永久报 bucket policy mismatchReplicatedBucketPolicies 少计。

同机制第二条路径:同一轮 heal 中本地目标拿原始字节、远端目标拿规范字节,三站点集群会出现持久分叉。

为何是新问题:基线 PeerBucketPolicyHandlerjson.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-186peer 错误)、cmd/logging.go:19-21replLogOnceIf 无 errKind ⇒ ErrorKind

  1. 第二个循环对 !state.valid 的目标打 indeterminate / unusable peervalid 要求对端 CreatedAt != 0 —— 而对端还没有这个桶CreatedAt 就是 0cmd/site-replication.go:3096-3103 给缺桶站点填零值 SRBucketInfo)。于是"桶刚建、尚未传播"这种完全正常的瞬态,对每个桶产生 6 条 ERROR
  2. 第一个循环在对端 RemoteTargetConnectionErrcmd/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 撞车;给 logBucketConfigReplicationlogger.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-180canonicalBucketPolicyIsEmpty() 返回 nil → 落盘 nil → GetBucketPolicy 从"200 + 空策略文档"变为 404 NotFound。计划 §1 明确写了"需要写入兼容说明",文档里没有。
  2. 零 Quota ({}) 在对端从"被删除"变为"保留 live 文档"cmd/admin-bucket-handlers.go:79-96 删除了出站 bucketMeta.Quota = nil 改写。这是正确的对齐(本地原本就保留 {}),但对端 GetBucketQuotaConfig 的结果会变。
  3. GET ?policymc admin bucket export 的 JSON 形态改变cmd/bucket-policy-handlers.go:202cmd/admin-bucket-handlers.go:439 改用 canonicalBucketPolicy → 对象键按字母序、集合数组与 Statement 数组被排序。语义等价(S3 策略求值与 statement 顺序无关),但字节级比对输出的工具会看到变化。

F5 — 最小性:Policy 规范编码器接入 PUT / GET / export / peer 落盘,并非 #77 不变量所必需

非缺陷,最小性判断 · 已证实

比较键在 bucketConfigPayloadcmd/bucket-metadata-replication.go:172-180)里从已解析策略现算,与落盘字节无关。即使 PUT/peer 仍用 json.Marshal(字节不确定),两站点的键依旧相同,收敛性完全不受影响。

因此这部分改动带来的是两件额外的事:(a) 让原本被 ActionSet.MarshalJSONsilo-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

  • isBucketMetadataEqualcmd/site-replication.go:5164)现在只被测试引用,生产死代码。
  • cmd/bucket-metadata-replication.go:36-38 注释"Object Lock is applied before Versioning"对 bulk apply 循环成立,但 healBucketscmd/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 的所有策略都经 SRPeerReplicateBucketMetacanonicalBucketPolicy 写入,两侧都是规范字节,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/offoff exporter exposed new tombstone)只能来自第三个提交的生产代码,所以没有夸大;但也不能从证据目录内独立复核。补一份 silo-final --versiongo build 复现即可闭环。
  5. baseline.log 行号与最终测试文件不一致(日志 93/105/117 vs 现文件 96/97/109/121):说明"修复前复现"跑的是测试文件的早期版本。可接受,但严格讲不是同一份用例。

F9 — 残留边界:桶世代冲突(计划内已声明,非本轮回归)

各站点 Created 不同时,A 的 baseline-liveat == A.Created)在 B 上会被重算为 realnewBucketConfigState目标created 判定,cmd/bucket-metadata-replication.go:215-219),可能压过 B 的真实墓碑。计划明确排除在收敛承诺外,且 AddPeerClusterscmd/site-replication.go:458-466"only one cluster may have data")封死了最常见入口。剩余入口:分区期间两站点各自建同名桶,或 #78 接管。基线版本在同场景下是不确定的(seed 首个 map 项),所以不算回归。


F10 — 覆盖缺口:接管时 Created 前移越过真实修改时间

rebaseBucketConfigDefaultscmd/bucket-metadata-replication.go:306-318)只调整"零/等于旧 Created"的默认时间。若 opts.CreatedAt 晚于某个真实字段时间,该字段变成 at < Createdvalid == false:作为 heal 源 candidate() 为 false;作为 heal 目标 incoming.valid > current.valid必被覆盖,本地真实配置被丢弃。

在"远端世代胜出"的语义下可以论证这是对的,但 TestPeerBucketAdoptionRebasesOnlyDefaultscmd/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/152hook 用 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 状态归一后比较 enablePeerBucketVersioninglocalBucketConfigUpdatedAt 通过
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> 当前字段时间,涵盖"已有未来时间"。✔
  • 重复/乱序不保存不广播applyBucketConfigcompare <= 0 返回 changed=false,据此跳过 saveMetadataLoadBucketMetadatabulk 用 changed 汇总后一次保存。✔ 我特别核对了所有使用 result.meta/result.updatedAt 的本地 handler:在这些路径上 changed 恒为 truelocalBucketConfigUpdatedAt 使 incoming.at 严格更大,且 incoming.candidate() 恒真),所以不会出现"零时间 + 空载荷"被当成删除广播出去。这是一个隐式依赖,建议加一行注释或断言固定住。
  • 整桶 .metadata.bin 读-比较-写全在既有分布式锁内updateAndParseMetadataPeerBucketMetadataUpdateHandler、import 最终提交、PeerBucketMakeWithVersioningHandler、CORS 路径,我逐个确认锁的获取在读之前、释放在 saveMetadata 之后、fan-out 在释放之后。✔
  • 无关字段 / CORS / lifecycle 不被覆盖:所有写路径都是锁内重新加载后只改目标字段;import 用 applyImportedBucketMetadata 只按 fields 拷贝并 bytes.Clone。✔
  • 缓存快照正确saveMetadata 改收 *BucketMetadatameta.Save 内部 parseAllConfigs 回写归一化结果,sys.Set(name, *meta) 发布的是提交后快照;调用方拿到的 result.meta 是同一份值拷贝,不会原地修改已发布引用。✔
  • parse 被强制为 false 对缓存无害Save()parseAllConfigs,发布到 metadataMap 的解析字段是新的;而"删除后 quota 解析残留"正是靠 parse=false 的新加载对象修掉的(parseAllConfigs 对空 QuotaConfigJSON 不会quotaConfig 置 nilbucket-metadata.go:400-405)。✔
  • 锁/返回值/错误处理unlock() + locked=false 模式保留;updateAndParseUpdateDelete 对外签名不变。✔

3. 六字段 state 比较:传递性、交换序无关、幂等收敛

compareBucketConfigStatesbucket-metadata-replication.go:227-253)实际是按 (valid, real, real?at:—, real?isTombstone:—, key) 的字典序全序。

  • 传递 / 反对称:是。real == false 时不比较 at(只比 key),但空 baseline 的 key 为 nilbytes.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-36BPStatement 的 8 个字段(SID/Effect/Principal/Actions/NotActions/Resources/NotResources/Conditions全部覆盖,无字段静默丢失ParseBucketPolicyConfig 先做 Validate 保证 Principal.MarshalJSON 不会失败。✔ 但顶层 Statement 排序引出 F2。
    • Quotajson.Marshal(parseBucketQuota(...))。零 quota 仍是 live 文档(candidate()real,不靠 len(data))。{} 与对端重编码字节不同但键相同,不产生 heal 环;isBktQuotaCfgReplicated 按解析值比较,不会误报。✔
    • Tags / SSE / Object Lock:有效文档字节,保留大小写与实际内容。✔
    • Versioning:先按该站点的 Object Lock 归一(effectiveBucketVersioning),键即归一后的文档,避免"保存阶段隐式改写"造成的空转。✔
  • 空策略bucketConfigPayloadcfg.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 / healBucketConfigid == "" || !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-zerobucket-metadata-sys.go:186-190)。不受 gate 影响,不在源时间排序保证内。✔
  • bulk 零时间拒绝PeerBucketMetadataUpdateHandler:1610-1612errInvalidArgument,与现状一致;import hook 恒带非零 commitAt。✔
  • 省略 vs 显式 null / 空串madmin.SRBucketMeta 全字段 omitemptymadmin-go v3.0.110 cluster-commands.go:501-538),因此
    • json.RawMessagePolicy/Quota):省略 ⇒ nil;显式 null[]byte("null"),走 len(...)!=0 判为"已提供",再交既有解析器(Policy null ⇒ 语义空 ⇒ 删除;Quota null ⇒ 零值文档 ⇒ live)。✔ 与计划 §1 完全一致。
    • *string:省略/null ⇒ nil ⇒ 不动;空串 ⇒ 已提供且内容为空 ⇒ 四类删除、两类 no-op。✔
    • 这些由 TestPeerBucketMetadataWireAtomicity 通过真实 admin 路由 + 真实 JSON 编解码覆盖(applySRBucketMetaViaAdminregisterAdminRouter),不是结构体直传。这是这批测试里质量最高的一块。
  • 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 提交快照、ensureBucketMetadataCreatedrebaseBucketConfigDefaults、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 走递归排序的规范 JSONQuota 走解析后 json.MarshalXML 走有效文档字节,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. 修 F3ERROR→Warning、缺桶不报、reason 拆分)。
  4. 补 F4 的三条文档。
  5. 补测试:F10 的接管用例、F8-1 的 legacy 策略字节双站点用例、F8-2 去 stub 的 Created 用例、F8-4 的二进制身份记录。
  6. F5 / F6 由作者判断,可留作后续。