Signed-off-by: Feng Ruohang <rh@vonng.com>
15 KiB
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-99if 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-60Quota == 0时IsValid()恒真({}是合法 live 文档),且该版本只有SetBucketQuota,没有删除端点 —— "清除配额"只能走这条零值 PUT;cmd/site-replication.go:4090-4094导出端仅在len(QuotaConfigJSON) > 0时带出时间,删除时间被隐藏。[源码]
计划条目:已确定契约"合法的空 quota 对象 {}…不能仅因配额为零就改写为 nil";§3"同时间真实删除大于 live";提交 3 tombstone 导出;完成条件"四类 DELETE"、T7。
触发步骤
- A 站点执行等价于
mc quota clear(零值 SetBucketQuota)。 - A 落盘 live
{...}@T;发出的事件是Quota=nil@T。 - B 按计划落盘 tombstone@T。
- off(默认):B 的 tombstone 时间不导出 → B 候选被当 baseline 过滤 → 选源永远是 A 的 live@T → 推给 B → B 按"同时间删除优先"拒绝 → 每 30 秒(
site-replication.go:4651)一次 RPC + 一次拒绝日志,永不收敛。 - 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-399parseAllConfigs在objectLockConfig != nil且 versioning 文档解析失败 /!Enabled()/PrefixesExcluded()时改写VersioningConfigXML为常量,不改VersioningConfigUpdatedAt;cmd/bucket-metadata.go:581-603Save每次保存前都调parseAllConfigs→ 归一化发生在每一次saveMetadata;cmd/bucket-metadata-sys.go:232-243saveMetadata(…, 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-84S3 PUT 只在本地 LockEnabled 时拒绝 prefix-excluded;- 现有回归
cmd/site-replication-object-lock_test.go:149-167已把该机制固化为断言。[源码][复现-通过]
计划条目:§3"live/live 按稳定载荷字节序比较""apply 和 heal 使用同一比较函数";§6"无状态变化时不保存、不通知";提交 1 完成条件"导入的持久化时间与实际发出的事件一致"。
为什么阻断
- §6 只能在
saveMetadata之前比较,而真正落盘的是归一化之后的结构——规则按字面无法实现,"无变化"的判定可能与磁盘不符。 - 计划把到达时间换成源时间后,同一时间戳 + 不同载荷成为正常可达状态(Object Lock 在站点间短暂不对称时必然出现);旧代码靠"到达时间总在前进"意外掩盖了它。
- §3 没写明大者胜还是小者胜。以
…<Status>Enabled</Status><ExcludedPrefixes>对…<Status>Enabled</Status></VersioningConfiguration>为例,分歧字节是E(0x45) 与/(0x2F):方向选错,heal 每轮把未归一化文档推给已归一化站点,对方再归一化 → 重复写 + 全节点LoadBucketMetadata广播;方向选对则一轮收敛。让正确性取决于未写明的字节序方向不可接受。 - 导入路径违反提交 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-1697bulk 一律按!= 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)会再次取锁。
实施前必须修订的条目
- R1 零值配额的发送端/本地写一致性 + "落盘状态 == 发出状态"不变量。
- R2 §6 比较基准改为归一化后的落盘表示;
saveMetadata回写已持久化结构;导入 hook 用落盘字节;§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。