桶配置复制:来源时间、删除与确定性收敛
#77 是已复现的站点复制正确性问题。接收端把来源时间改成到达时间,可能拒绝真正较新的删除;部分配置删除后不再导出时间,断线期间遗漏的删除也无法被 heal 找回。单独补一个 DELETE 分支不能解决这两个问题。
合并状态(2026-09-12): 修复与研究归档已通过 PR #180 合入
main(48ec10312),#77 已关闭。合并前 9 项 CI 检查全部通过。
评审边界: 计划经过四轮 Claude Code Opus 5 Max 审查,最后两轮通过;实现完整审查、修正复审和最终定向验收共三轮,均为GO_WITH_NONBLOCKING_NOTES。最终阻断为零,要求等待的完整 cmd 与最终 lint 已通过。
适用范围: 下文描述已进入主干的修复。下载包与线上实例是否包含修复,需要核对其具体版本;完整删除自愈仍要求全部节点升级并统一开启删除导出。
既有工作与本轮范围
此前的发布说明、安全加固记录和 Server 兼容性说明 已记载 #77 的删除收敛限制,但没有完整记录状态模型、替代方案和验证边界。本文补齐这部分设计依据。
源码仓库正式归档保存实施前复现、各版计划、七轮审查的最终报告与调用身份、逐项处置、验收日志、源码/二进制哈希及可重跑的双站点驱动。原始模型推理流、二进制和临时实验卷不纳入仓库;整理后的工作站路径及文档链接与原始产物分别记录哈希。
相关修复各有自己的责任边界:
| 已有工作 | 已解决的问题 | 不能据此推断的能力 |
|---|---|---|
| #91 | 每站点配置计数、Policy/Quota 统计与畸形字段隔离 | 计数正确不代表配置值和来源时间已收敛 |
| #103 | 整个 .metadata.bin 的写入串行化 |
有锁不代表锁外作出的新旧判断仍有效 |
| #76、#78 | Object Lock 的复制载体和已有桶接管保护 | 不替代六类配置统一的来源状态比较 |
| CORS 复制修复 | 桶级 CORS 的独立删除记录与复制信任边界 | 不能直接推广到所有元数据类型 |
本轮只处理 Policy、Tags、SSE、Quota、Versioning、Object Lock。Lifecycle/expiry 有自己的合并时间语义;CORS 保留独立机制;notification、IAM、对象复制、MRF、resync 和公开统计不在本轮重写。对象复制可靠性另有记录。
正式支持和发布验收面向维护中的 silo、silo-console、mc、silo-pkg 组合。与未修改的上游 MinIO/MC 保持尽最大努力兼容,不以此要求维护组件降级或重建分叉依赖。
复现说明了什么
原始回归在 ErasureSD 和 16 盘 Erasure 两种 ObjectLayer 上复现:源站在过去的时刻 T 写入配置,对端落盘的却是当前到达时间;随后收到源时间为 T+1 分钟的删除,仍被当成旧事件拒绝。单看 RPC 成功无法判断最终状态是否正确。
| 配置 | 原实现保留 PUT 来源时间 | 空事件的既有语义 | 原实现导出无载荷时间 |
|---|---|---|---|
| Policy | 否 | 删除 | 是 |
| Tags | 否 | 删除 | 否 |
| SSE | 否 | 删除 | 否 |
| Quota | 否 | 删除;零值 JSON 另有语义 | 否 |
| Versioning | 否 | 不操作 | 不作为删除使用 |
| Object Lock | 否 | 不操作 | 不作为删除使用 |
另有三个必要入口问题:旧 bulk 可以覆盖新字段;请求在锁外判断后排队,拿到锁时仍使用过期结论;Tag 的远端 heal 漏传 UpdatedAt。这些都需要在实际入口修复,不能只调整 heal 的选源条件。
一个字段需要保存哪些事实
沿用已有字段载荷、字段 UpdatedAt 和桶 Created,不增加磁盘格式、SDK 字段或 deployment ID。比较时区分以下状态:
| 状态 | 条件 | 是否可以成为来源 |
|---|---|---|
| 未知或无效 | 创建时间未知、载荷非法,或字段时间早于创建时间 | 否;不能把缺少信息解释成删除 |
| 空基线 | 空载荷,时间为零或等于 Created | 否 |
| 有值基线 | 合法非空载荷,时间为零或等于 Created | 是;用于历史配置初始化 |
| 真实更新 | 合法非空载荷,时间晚于 Created | 是 |
| 真实删除 | 可删除字段的空载荷,时间晚于 Created | 是;这个带时间的空值即 tombstone(删除记录) |
Versioning 和 Object Lock 的空事件继续不操作,不能因统一 helper 而获得删除能力。零时间字段在比较时使用 Created 作为基线;这不表示把历史空值补成一次新删除。
比较规则依次为:真实状态胜过基线;真实状态之间较晚来源时间胜出;同一时间删除胜过有值;同级有值冲突以稳定内容键的字节序裁决。完全相同的有效状态不保存、不通知。有值基线之间也按内容键选择,不能让一个较晚创建的默认状态压过真实修改。
这是一种确定性冲突裁决,不是“字节序较大的配置在业务上更正确”。并发配置冲突仍需操作者选择业务上期望的值,并重新提交。
比较键必须与真实保存行为一致
Policy 的集合由 map 支撑,直接 JSON 编码会受枚举顺序影响。Server 对已校验策略的完整 JSON 树排序,包括 Statement、Action/NotAction、Resource/NotResource、Principal、Condition;保留 Sid 和数字精度。这里不新增解析器不支持的语法。
既有策略解析器接受 NotAction / NotResource,但原结构的必填 Action/Resource 编码会对空集合报错。因此使用同一份显式字段编码,并让 Policy GET/admin export 也能读取已接受的策略。这项补齐防止“写入成功、读取失败”,并非单纯格式清理。
Quota 使用既有解析结果的 JSON 作为键;{}、JSON null 或合法零配额文档仍是有值文档,不能悄悄发送成删除。空 Policy 则按既有 peer 语义统一为删除:合法空策略 PUT 成功,后续 GET 返回既有 NotFound。
XML 配置使用有效文档的实际字节作为键,没有引入通用 XML 规范化器。Versioning 是一个必要例外:先应用已有 Object Lock 约束,再比较最终会保存的有效文档。否则比较器接受一个版本配置,Save 又改写它,下一轮 heal 便会重复发送。
锁住整个决策,而不只是最后的保存
六类字段共同存储在一份 .metadata.bin 中。读取原始记录、校验、比较、修改和保存必须位于同一把已有 metadata.lock 内:先锁外比较、再锁内写入,仍会使用过期状态;改成按字段加锁,也会让整条记录的读改写互相覆盖。
本地写入在锁内分配时间:
这使一次本地纠正能超过已保存的未来字段时间。带来源时间的 peer 事件保留其原时间,不重新盖上本机时间;完整重复和较旧事件直接结束。
bulk 只处理明确提供的字段,逐项验证后至多保存一次;字段省略表示不动,显式 null 按类型处理。非法字段不会使前半份配置先落盘。导入在每桶最终提交锁内分配涉及六类字段的共同时间;磁盘状态和出站事件使用本次提交的最终快照,不能在解锁后另读“最新状态”拼接旧时间。空 Policy 另用现有专用删除事件表达,避免被 bulk 的 omitempty 丢掉。
保存函数回传归一化后的快照,公开 Update/Delete API 不变。其它元数据仍保留原来的处理路径和通知机制。
桶接管不能制造假删除
接管可能调整 Created。若把 Created 改早,却保留原本等于旧 Created 的空字段时间,空基线就会被误认成真实删除。改晚也可能使历史初值变成无效状态。
最小处理是在现有接管锁内,仅把这六类字段原本为零或等于旧 Created 的默认时间调整到新 Created。真实更新和真实删除的时间保持不变;不靠“载荷为空”猜测是否默认,也不重做已有桶的配置保护。真正不同的桶创建世代仍需人工处理。
所有入口使用同一个状态规则
| 入口 | 必要行为 |
|---|---|
| 本地 S3/Admin 写入 | 锁内单调时间;必要时由提交快照生成出站事件 |
| 专用 peer 事件 | 原始来源时间在同一锁内比较和保存;保留既有类型及旧 Object Lock 载体兼容 |
| bulk/import | 字段存在性明确、原子保存;导入时间在最终提交时确定 |
| 首次同步 | 保留历史有值基线;开关开启后补发真实删除 |
| local/remote heal | 同一比较器选源与应用,包含完整来源时间;未知 ID 和故障 peer 不阻断其它健康目标 |
| 状态导出 | 值与时间属于同一记录;新增删除时间受升级开关控制 |
首次同步保留已有五类配置发送范围;Versioning 仍通过 MakeBucketHook 初始化并由 heal 对齐。没有为了表格对称而新增第六套初始化流程。
heal 不再先取 map 的第一项作为胜者,再过滤默认时间。它先筛选有效候选,再取最大状态。公开 mismatch 计数不能作为唯一门控:内容相同、来源时间不同也需要同步;反过来,最终有效状态相同就不应再次写入或发 RPC。
为什么需要默认关闭的升级开关
新增启动变量 MINIO_SITE_REPLICATION_METADATA_TOMBSTONES 默认 off。它控制新增删除信息的可见性,不负责探测远端能力。
| 行为 | off | on |
|---|---|---|
| 来源时间排序、原子 apply | 启用 | 启用 |
| 普通删除事件 | 继续复制 | 继续复制 |
| Policy 的已有删除时间导出 | 保留 | 保留 |
| Tags/SSE/Quota 无载荷时的真实删除时间导出 | 隐藏 | 导出 |
| 初次同步补发真实删除 | 保持原有范围 | 补发四类可删除字段 |
旧实现不能安全消费全部新增删除信息,例如旧 Quota heal 会在清空载荷后留下已解析的缓存值。仅写一句“请升级”不足以隔离滚动升级窗口,因此先保持 off。
启用顺序:全部参与站点的全部节点升级到包含修复的构建,确认同站点配置一致并排空旧请求,然后统一设置 on 并重启。降级前先在全部修复节点设置 off 并重启,再滚动降级;旧软件原有缺陷会随降级恢复。
off 期间隐藏的 Tags/SSE/Quota 删除记录可能导致 heal 重复发送旧状态,由修复端拒绝。因此“第二轮零 RPC”只适用于完整状态已可见且稳定的情况,不是 off 模式的保证。
保留和拒绝了哪些方案
| 决策 | 理由 |
|---|---|
| 保留一个内部六字段 helper | 六类已复现相同来源时间问题;统一比较避免入口间规则漂移,类型删除语义仍明确区分 |
| 保留现有整桶锁、持久化字段和 heal 周期 | 分别承担原子性、删除持久性与漏发恢复,无需新增协调服务 |
| 不只把 UTCNow 替换成来源时间 | 仍会留下锁外判断、缺失删除信息、等时冲突和初次同步缺口 |
| 不只添加墓碑时间导出 | 旧接收端与旧 Quota 缓存路径仍有问题,必须控制升级窗口 |
| 不以 deployment ID 决胜,也不添加 HLC/schema | 当前范围可用已有时间和确定性内容键完成;不声称提供跨站点因果排序 |
| 不一律拒绝零时间专用事件 | 旧 Tag heal 确实遗漏时间;保留廉价协议兼容并明确限制 |
| 不把所有字段泛化为同一种删除语义 | 会错误删除 Versioning/Object Lock,或破坏 Lifecycle/CORS 独立规则 |
初次实现的生产 Go 代码新增 729 行、删除 692 行(净增 37 行),主要替换重复的应用和 heal 分支。行数不是最小性的证明。必要性要逐条对应复现;充分性要覆盖所有实际入口;最小性要判断删去某一机制后,是否会重新出现具体错误。
验证及其证据边界
环境为本机 go1.27.1 darwin/arm64。四组实施前审计用例在未修复基线上失败,修复后的回归套件在两种 ObjectLayer 上通过;核心测试入口位于 cmd/site-replication-metadata{,-heal,-gate}_test.go。原始失败与现有用例通过的完整输出见基线审计日志。
| 验证 | 观察结果 |
|---|---|
| 来源时间、四类删除、重复/乱序、锁前排队、不同字段写入 | 回归通过,目标 race 通过 |
| 等时双向到达、删除优先、Policy 键与负集合 GET、bulk/import | 边界回归及补充 race 通过 |
| 完整 cmd 包、internal/S3 Select race | 最终产品代码 fcbb93e89 的 cmd 全量通过(492.776 秒);internal/S3 Select race 已于 62cf066ff 通过 |
| 构建、vet、lint、生成文件和兼容检查 | 最终产品代码 build/vet 通过,461e9a721 lint 零问题;生成文件与兼容检查已于 62cf066ff 通过。可选 typos 未安装,按 Makefile 跳过 |
| Linux/Darwin/Windows × amd64/arm64 | 62cf066ff 六目标交叉编译通过,不等于六平台运行验收 |
| 两个真实站点进程,每站四个数据目录 | 历史六类配置初次同步保留 Created;丢弃真实 PUT 的出站 RPC 并注入乱序后,正常 30 秒 heal 恢复一致 |
| 删除遗漏及重启 | 四类删除落盘,来源进程重启后保留;恢复连接后收敛 |
| 稳态与日志 | 两次各观察 65 秒,连续两个正常 heal 周期无 metadata RPC;重复异常按每桶/字段/原因去重 |
| 修复版与固定旧版混合 | 全部关闭开关后,Tags PUT/DELETE 冒烟通过;不是完整混合版本正确性证明 |
| 评审修正:创建时间补齐、策略状态比较键、heal 诊断 | 真实 ObjectLayer 创建时间与旧顺序策略测试通过旧码覆盖确认失败;修正后通过。62cf066ff 的完整 cmd、internal、S3 Select race、lint、生成文件、品牌检查与六目标交叉编译重跑通过 |
| 最终收尾回归 | fcbb93e89 的诊断、初次同步、物理时间边界、接管与 CORS 目标 race 通过;461e9a721 仅测试写法变更后,相关 race 再次通过 |
| 双站点验收在最终二进制上重跑 | 同一套验收先在干净 62cf066ff、再在干净 fcbb93e89 构建的二进制上通过;最终 461e9a721 仅调整测试写法,产品代码相同 |
本地证据集包含基线失败、测试日志、可重跑双站点驱动、元数据快照和源码/二进制 SHA-256。首次双站点运行使用的二进制标识为 c8f264f79 + dirty,已补录实际二进制的 Go build-info 和 SHA-256。评审修正及收尾后,分别以干净 62cf066ff 和 fcbb93e89 构建的二进制重跑。实际 --version、Go 构建元数据与 SHA-256 均记录,并保留基线 5c5765816 的对照二进制身份。最终 461e9a721 与 fcbb93e89 之间只有测试格式及等价的分支写法调整,源码差异已单独保存。
上述表格记录本地测试和隔离进程观察。主干集成另有远端证据:PR #180 的 9 项检查全部通过,包括 Go CI 的六项任务、DCO、漏洞检查和发布流程验证。合并后已核对主干树与通过检查的 PR 合并树完全一致。这些证据不等同于真实 Linux 多节点集群、正式发布制品或生产部署验证。
对抗评审记录
计划审查使用实际 Claude Code claude-opus-5 --effort max,共四轮。前两轮推动修正了状态比较、保存快照、历史基线及导入等边界;后两轮结论为 GO_WITH_NONBLOCKING_NOTES,实施前阻断为零。计划通过不等于实现已经正确。
本次实现审查固定 4089113e3,由同一模型和强度独立检查全部差异、生产调用链、正式测试及运行证据,重点挑战充分性、最小性、滚动升级和证据身份。结论为 GO_WITH_NONBLOCKING_NOTES:无条件阻断为零,条件性阻断一项,另有九项发现。它确认核心收敛机制成立,在已声明的契约范围内没有找到回退、删除复活、锁外判旧或重复广播的反例。其中三项是本次改动引入的真实缺陷,已在 62cf066ff 修正。随后 fcbb93e89 补齐“没有有效来源也应诊断无效状态”的日志边界与首次同步回归。
| 发现 | 判定 | 处置 |
|---|---|---|
| F1:没有记录创建时间的桶,六类配置全部写不进去 | 确证回归,条件性阻断 | 已修。不要求元数据时,GetBucketInfo 原样返回物理探测结果,与 ListBuckets 一致;初次同步补齐该时间并传给建桶钩子 |
| F2:复制状态按 Statement 顺序比较,heal 按规范键比较 | 确证;永久假 mismatch,heal 永远修不掉 | 已修。状态改用 heal 的同一比较键;每站点存在性计数不变 |
| F3:四种 heal 情况共用一个日志 key,正常瞬态按 ERROR 输出 | 确证 | 已修。每个原因各自持有 key 并降为 Warning;空基线和缺桶保持安静,无有效来源时仍诊断实际存在的无效状态 |
| F4:三处用户可见语义变更没有写入文档 | 部分成立 | 原审查提交 README 末尾已说明空 Policy 和零 Quota,首轮漏读;补充的是 Policy GET/export 的编码顺序及负集合策略行为 |
| F5:Policy 规范编码器的接入是否多余 | 第二轮修正了首轮判断 | 比较键和状态键必须一致;GET/export/peer 编码器避免已能落盘的负集合策略读回或复制失败,必须保留。PUT/import 统一编码并非比较器所必需,但删去只会增加分支和表示差异,故保留 |
| F6:孤立的辅助函数与过时的顺序注释 | 确证的小问题 | 注释已改;没有生产调用者的 isBucketMetadataEqual 及只测试该函数的过时用例已删除 |
| F7:开关关闭时,遗漏的 Tags/SSE/Quota 删除不收敛 | 对计划取舍的正确理解 | 不改动;滚动升级一节与下文边界已声明 |
| F8:证据缺口——recovery 测试用 stub、缺少旧顺序策略用例、二进制身份不可复核 | 确证 | recovery 改用真实 ObjectLayer;补充被规范编码器重排的旧顺序策略用例;双站点验收改用干净最终树构建的二进制重跑并记录身份 |
| F9:桶创建世代冲突 | 已声明在范围外,且不是回归 | 不改动;见下文边界 |
| F10:接管将 Created 调晚并越过真实字段时间 | 覆盖缺口,不是缺陷 | 接管测试固定两面行为:保留原时间,旧世代状态不能成为来源;作为目标接受新世代的合法输入 |
其中的 stub 值得单独一提:原 recovery 测试注入的对象层在创建时间探测中直接返回期望值,于是它对一段生产中永远不会这样表现的代码判定通过。替换后的测试在每块本地盘上标记桶目录时间并驱动真实对象层,在未修复的代码上会失败。
第二轮复审固定 62cf066ff,再次由实际 claude-opus-5 --effort max 执行,结论仍为 GO_WITH_NONBLOCKING_NOTES,条件性与无条件阻断都为零。它逐项重查生产路径,并核对作者用正式测试配合旧生产代码 overlay 得到的 F1/F2 复现结果,修正了首轮对 Policy 编码器必要性的判断。审查者没有代为运行测试。
| 后续发现 | 收尾处置 |
|---|---|
| NB-1:物理 Created 只是近似值 | 保留世代边界,写明目录修改时间的局限;真实盘测试固定“较早 peer 事件跳过、本地纠正成功”,不靠单个来源时间降低桶身份 |
| NB-2、NB-8:无来源时过度静默、恢复失败日志丢失来源时间 | 已修;有实际无效状态仍诊断,恢复失败记录原事件时间。无来源不会发 RPC,空基线安静 |
| NB-3:掉线日志按桶与字段放大 | 明确接受当前每桶/字段/原因的粒度;不将站点级日志聚合框架带入正确性修复 |
| NB-4:草稿日志不能证明产品失败 | findings-before.log、findings-after-1.log 标为已被替代的夹具失败。后续用同一正式测试叠加旧生产代码,分别复现物理时间、策略顺序、初次同步以及日志问题 |
| NB-5、NB-6:无用辅助函数、恢复位置说明不完整 | 删除辅助函数及两处只测试它的用例;说明初次站点同步也会落盘 Created,保留真实 CORS 路径测试 |
| NB-7:接管只验证来源半边 | 增补目标半边:旧世代状态失效后,新世代的合法输入可以覆盖它 |
日志去重和无来源诊断已使用现有 logger target 补成正式回归,覆盖级别、不同原因、重复调用与零 RPC。首次同步用例驱动真实源 ObjectLayer 和完整出站流程,对端是确认 RPC 的测试服务;它证明出站内容,真实双站点进程实验则提供另一个层次的证据。二者不能混称为同一验收。
第三轮定向验收固定最终产品代码 fcbb93e89,结论仍为 GO_WITH_NONBLOCKING_NOTES,阻断为零;它也检查了后续 461e9a721 的纯测试写法差异,确认语义等价。审查读取日志时,完整 cmd 与 lint 尚在运行;随后两项均以退出码 0 完成。fcbb93e89 本身的测试格式曾使 lint 失败,修正后的 461e9a721 才是通过全部已要求检查的交付基准。
仍有三项非阻断的改进建议,不影响本轮范围内的行为结论:heal 遇到解码/解析失败时,诊断属性可能显示零来源时间;初次同步单测没有执行本地 peer 分支,因此不证明恢复时间在本地落盘(该生产分支经源码复核);日志测试严格捕获系统日志,未来若引入并发后台日志,可考虑进一步隔离。没有据此新增生产取值 helper 或测试 hook。反向失败日志只证明实际走到的失败断言,例如空基线误报;修复后对 Warning 级别和按原因去重的检查通过,不能把二者误写成已经分别演示过旧码失败。
仍需保留的运行边界
- 历史时间污染不能自动还原。 到达时间覆盖和无时间旧事件已经丢失来源事实;部署修复后不会凭空恢复正确历史顺序。核对各站点,在权威站点重新提交期望配置或删除。
- 零时间专用事件继续兼容。 它们使用本地单调时间,并记录
legacy-zero;bulk 的零时间约束不变。这类事件不属于带来源时间收敛保证。 - 桶身份冲突不自动合并。 先解决创建世代分歧。早于目标 Created 的事件不应用;未知创建时间只从真实物理桶补齐,仍未知或桶不存在时不写入。恢复值是桶目录修改时间这一物理近似值,可能随顶层对象变化、逐盘不同,并晚于真实创建时间;早于它的事件仍会被跳过,不能只凭一个事件降低桶身份。补齐发生在成功配置写入或初次站点同步时;在落盘前,状态仍照实报告未知时间,周期 heal 在两个方向上都跳过该桶。
- 物理时钟不是因果时钟。 已知未来字段时间后的本地纠正可前进,但不能推断所有并发写入的业务意图。
- 诊断有界,成功响应不等于应用。
legacy-zero、before-created、indeterminate、unreachable、peer-error复用现有 LogOnceIf;错误文本和 key 稳定,详情放入属性,沿用每小时清理。每个原因各自持有 key,一种情况不会把另一种顶掉;空基线以及尚未拥有该桶的对端不产生诊断。真实存在但无法排序的状态即使没有有效来源,也会输出indeterminate,且不触发 RPC。正常重复和旧事件保持安静。这是每桶/字段/原因的上限;不可达站点的unreachable和无效状态的indeterminate都可能随桶与有值字段数量增长,不是全站固定条数的上限。 - 代码、合并与发布分别验收。 Issue 状态、Server 版本、镜像、软件包、文档上线及生产配置需要各自证据;本文记录的主干合并不代表发行制品或生产环境已经完成升级。