Signed-off-by: Feng Ruohang <rh@vonng.com>
31 KiB
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-278ensureBucketMetadataCreatedcmd/bucket-metadata-sys.go:181-185(六类分支入口,失败即return err)cmd/erasure-server-pool.go:2280-2285——GetBucketInfo无条件用缓存meta.Created覆盖物理卷 Createdcmd/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)
最短触发链:
- 桶存在,但
.minio.sys/buckets/<b>/.metadata.bin不存在且无 legacy 配置文件 →loadBucketMetadataParse走errConfigNotFound分支并返回 nil error 且Created == 0(cmd/bucket-metadata.go:230-297;注意defaultTimestamps()只在err == nil时调用)。 - 启动时
concurrentLoad(cmd/bucket-metadata-sys.go:737-763)把这个Created == 0的 meta 写进metadataMap。 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")。- 客户端收到错误。基线版本此处成功(直接赋值 +
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作用于每个数组,含顶层Statementcmd/bucket-metadata-replication.go:124-161canonicalBucketPolicycmd/site-replication.go:3796isBktPolicyReplicated→prev.Equals(*p)(silo-pkg .../policy/bucket-policy.go:190-194按Statements[i]下标逐一比较,顺序敏感)cmd/site-replication-metadata.go:174vs:179—— 本地 heal 写源站原始字节,远端 heal 经PeerBucketPolicyHandler→canonicalBucketPolicy写规范字节
最短反例:
- 站点 A 存在升级前写入的桶策略,statement 顺序不是规范字节序(极常见,例如 Deny 在 Allow 之前)。
- 加入新站点 B:
syncToAllPeers→initialBucketConfigReplicationEvent发送 A 的原始字节,UpdatedAt = A.PolicyConfigUpdatedAt。 - B 的 admin 入口解析后调
PeerBucketPolicyHandler→canonicalBucketPolicy→ 存排序后字节。 - 此后 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)
- 第二个循环对
!state.valid的目标打indeterminate / unusable peer。valid要求对端CreatedAt != 0—— 而对端还没有这个桶时CreatedAt就是 0(cmd/site-replication.go:3096-3103给缺桶站点填零值SRBucketInfo)。于是"桶刚建、尚未传播"这种完全正常的瞬态,对每个桶产生 6 条 ERROR。 - 第一个循环在对端
RemoteTargetConnectionErr(cmd/site-replication.go:3020-3026填空 ID)时,对每个本地桶 × 6 字段打missing peer。一个站点掉线 ≈ 每小时 6×桶数 条 ERROR。 - 四种完全不同的情况(缺 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)
- 空 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 明确写了"需要写入兼容说明",文档里没有。 - 零 Quota (
{}) 在对端从"被删除"变为"保留 live 文档":cmd/admin-bucket-handlers.go:79-96删除了出站bucketMeta.Quota = nil改写。这是正确的对齐(本地原本就保留{}),但对端GetBucketQuotaConfig的结果会变。 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 — 证据不足项
- F2 对双站点实验不可见:
twosite/main.go:141-144的所有策略都经SRPeerReplicateBucketMeta→canonicalBucketPolicy写入,两侧都是规范字节,states()(:160-164)的字节比较自然通过。从未构造过"升级前旧编码字节"的策略。 - F1 被 stub 掩盖:
cmd/site-replication-metadata-gate_test.go:151-159自定义GetBucketInfo,在opts.NoMetadata时直接返回物理 Created,绕过了erasureServerPools.GetBucketInfo的缓存覆盖。"physical-created"子用例证明的是一个生产中不会发生的行为。 - "稳态 0 metadata RPC"的边界:
twosite/main.go:182-190只统计event.Bucket == bucket的单桶、双站点、gate=on。结论有效但窄;gate=off 的稳态不为零(文档已声明),多桶/多站点未观测。 - 二进制身份:
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复现即可闭环。 - 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),键即归一后的文档,避免"保存阶段隐式改写"造成的空转。✔
- Policy:递归排序的规范 JSON +
- 空策略:
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判为"已提供",再交既有解析器(Policynull⇒ 语义空 ⇒ 删除;Quotanull⇒ 零值文档 ⇒ 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. 新增设计记录应准确保留的内容
关键决策
- baseline / live / tombstone 三态由
(Created, 字段时间, 载荷空否)推导,不新增 schema 字段;at == Created定义为 baseline。代价:必须知道 Created(⇒ F1 的根因)。 - 全序:
valid > real > at > 同时间墓碑胜 > 稳定内容键。deployment ID 不参与比较,也不落盘。 - 比较键是"已解析配置的纯函数":Policy 走递归排序的规范 JSON,Quota 走解析后
json.Marshal,XML 走有效文档字节,Versioning 先按本站点 Object Lock 归一。 - Versioning / Object Lock 为 update-only:空事件恒为 no-op,空值不是候选。
- 读-比较-写整体在既有
metadata.lock内;saveMetadata收指针以便调用方拿到提交后快照,保证"落盘状态 / 出站事件 / 源时间"三元组一致。 - 新增删除信息的导出用启动开关 gate,默认 off;开关不探测远端能力,启用条件是全站点全节点已升级且旧请求排空。
- 专用 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. 建议处置顺序
- 判定 F1 的条件(支持矩阵里有没有
Created == 0/ 无.metadata.bin的桶)。有 ⇒ 先修再合。 - 修 F2(推荐:不排序顶层
Statement,或让isBktPolicyReplicated改用规范键)。 - 修 F3(ERROR→Warning、缺桶不报、reason 拆分)。
- 补 F4 的三条文档。
- 补测试:F10 的接管用例、F8-1 的 legacy 策略字节双站点用例、F8-2 去 stub 的 Created 用例、F8-4 的二进制身份记录。
- F5 / F6 由作者判断,可留作后续。