Files
minio/docs/investigations/r6/opus-v1-review.md
T
Feng Ruohang 0c61128d23 fix(replication): retry marker purges through persisted MRF
(cherry picked from commit cf381a7151ef25fc95ace5fedcd767fa19410de2)
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-09-16 00:37:55 +08:00

10 KiB

I'll start by reading AGENTS.md and the plan file, then dig into the actual source.

Let me locate the core source files and the evidence directory.

I've verified the plan against the actual source. Writing up the review now.

The Write tool is disabled in this session, so I can't create the plan file — the review is below in full.


Independent review — SILO R6 plan-v1.md

Plan hash reviewed: bcd023e2b00ad3dc709baa738bb02551d47aeebb49879b0f76f2789f4c4dff8b Source: 9ebe81c1b3611f9cc73e676b5b741c2be62c467a I did not run anything. All findings come from reading the source, the existing tests, PR #184's diff/prose, and the supplied baseline.log (read, not produced by me).

Verdict: REVISE

One blocking finding. The classification rule, the canonical-wire choice, the MRF 405 work, and the retry-budget work are technically correct and better grounded than PR #184. One explicitly prescribed detail in §A causes a silent regression on the canonical purge shape — the shape every live producer emits — and §C would not catch it. The correction is small and stays inside the plan's bounded scope.


Blocking

B1 — Pinning rinfo.ReplicationStatus = rinfo.PrevReplicationStatus for purges makes the persisted marker creation-status block get rewritten from the fan-out subset

§A prescribes this "on every exit" while also forbidding changes to getReplicationState merging. Together that is a regression, not preservation.

  1. bucket-replication-utils.go:392-399 — targetState() never sets ReplicationStatus, so a canonical purge returns "" today.
  2. bucket-replication-utils.go:92-101 — ReplicationStatusInternal() rebuilds "arn=STATUS;" only from rinfos.Targets, i.e. only fanned-out targets.
  3. bucket-replication.go:508-543 — fan-out skips !Replicate, non-matching dobj.TargetArn, and nil clients. TargetArn is really set by queueReplicateDeletesWrapper (:2400-2409).
  4. bucket-replication-utils.go:410-412 → drs, passed as DeleteReplication at bucket-replication.go:572-582.
  5. erasure-object.go:2163-2177 → xl-storage-format-v2.go:1438-1446 — for a DeleteType version, if fi.DeleteMarkerReplicationStatus() is non-empty, MetaSys[ReplicationStatus] and MetaSys[ReplicationTimestamp] are overwritten.

Today that guard never fires for a canonical purge: the string is "arn1=;", replicationStatusesMap doesn't match it (:428-439), composite over the empty map is "" (:455-459) — so the on-disk creation block is left untouched, which preserves it perfectly including non-attempted targets. Under the plan it becomes non-empty and is rewritten:

  • Status loss: "arn1=COMPLETED;arn2=COMPLETED;" with a fan-out covering only arn2 persists "arn2=COMPLETED;" — arn1 silently dropped. That is the exact failure the plan exists to fix, newly introduced on the main shape.
  • Zero timestamp: rs.ReplicationTimeStamp = rinfos.ReplicationTimeStamp (:413) is never assigned in replicateDelete; it's only rescued by bucket-replication.go:568-570 when the composite changes. A repeated FAILED→FAILED purge writes 0001-01-01T00:00:00Z.
  • Empty ReplicaStatus: under a legacy RoleArn config the composite can be REPLICA (bucket-replication-utils.go:497-503), taking xl-storage-format-v2.go:1398-1400, which writes ReplicaStatus — never populated by ObjectInfo.ReplicationState() (:569-587).

Only delete-marker versions are affected; ObjectType versions only touch VersionPurgeStatusKey (xl-storage-format-v2.go:1465-1476).

Minimal correction (strictly smaller than the plan): keep the classification and every exit fix, but for purges leave rinfo.ReplicationStatus at its zero value rather than pinning it. That is what the canonical path already does, and it preserves the on-disk block byte-for-byte including non-attempted targets, with no change to getReplicationState. Consequences that then become mandatory, not optional:

  • purge exits write only VersionPurgeStatus — never Failed, Completed, or PrevReplicationStatus;
  • the resync defer (:618-622) must be gated on the operation's own success. Under the plan as written, pinning Completed would stamp the reset marker for a failed purge on any marker whose creation was COMPLETED — the two changes are coupled and cannot land separately;
  • the stats gate at :556 must be replaced by the per-target operation-status comparison §A already calls for, otherwise purges stop being reported at all.

Add a partial-fan-out test: two ARNs persisted, one excluded from fan-out, failed purge → marker metadata unchanged. §C item 5 fans out to both targets and cannot detect this.


Nonblocking

  1. The wire version ID is load-bearing. For the legacy shape dobj.VersionID is empty, so the implementation must keep the existing versionID fallback (bucket-replication.go:609-612). A RemoveObject with empty VersionID against a versioned target creates a new marker (erasure-object.go:2126-2149) — the exact resurrection being fixed. Make §C1 assert the outgoing versionId and x-minio-source-deletemarker explicitly.
  2. Retry increment must cover all three delete-path MRF sites: :487 (lock), :565 (aggregate failure), :2427 (queue full). §B names only the first two; missing :2427 leaves an unbounded loop once marker MRF is live. Mirror ri.RetryCount++ at :1310.
  3. Stats will move. COMPLETE→COMPLETED makes ReplicationStats.Update reach its Completed case for Heal/ExistingObject deletes (bucket-replication-stats.go:184-201, replication.go:139-144). Today "COMPLETE" != "COMPLETED" so nothing is recorded. Assert expected counter deltas rather than discovering them.
  4. §B's identity gate excludes null-version markers by construction — GetObjectInfo returns ObjectNotFound, not 405, when VersionID == "" (erasure-object.go:996-999), and ToObjectInfo leaves VersionID empty when versioned is false (erasure-metadata.go:120-123). No regression, but document it instead of implying MRF healing is complete.
  5. Legacy-shape scope. I found no producer of that shape on this baseline: object-handlers.go:3225-3228, bucket-handlers.go:565-570, bucket-replication.go:3323-3327/:3775-3779 are mutually exclusive, and erasure-object.go:1752-1766 only sets DeleteMarkerVersionID when VersionID == "". DeletedObjectReplicationInfo isn't serialized, so it can't survive a restart. §A's legacy handling is upgrade/robustness work; the live value of R6 is mostly §B. Say so in the PR text so the fork doesn't inherit #184's overclaiming.
  6. §C item 3 is testable but fiddly. globalLocalDrivesMap is populated by newErasureServerPools (erasure-server-pool.go:174-181), so save/load works — but loadMRF deletes the file after reading (:4013-4015) and queueMRFHeal dispatches a detached goroutine with a 1s per-entry context (:4067-4081). Re-persist between rounds and synchronize, don't sleep.
  7. Confirm the fixtures run. The supplied baseline.log shows both TestReplicateDeleteMarkerPurge subtests aborting at replication-delete-marker_test.go:121 with "Storage reached its minimum free drive threshold" — an environment failure. §C items 2, 3, 5 all depend on those fixtures.

Independently confirmed as correct in the plan

  • Observation 1 holds at every exit: :624 (early-return without sending), :645-649/:702-706/:712-716 (field selected on dobj.VersionID), :684-688 (HEAD-not-ready overwrites creation status unconditionally), :628 (completed-purge early-out only for non-empty VersionID), :618-622 (resync stamp keyed on ReplicationStatus). The supplied probe log agrees.
  • Observation 2 is a real regression in PR #184. replicateDelete selects on dobj.VersionID != "" (:549-552), so with only the target-side fix a failed legacy purge aggregates to COMPLETED, emits ObjectReplicationComplete, and skips queueMRFSave (:563-566) — worse than baseline.
  • Task-level classification is required; #184's per-target isDMPurge is wrong. VersionPurgeStatus() needs completed == len(ri.Targets) (bucket-replication-utils.go:122-139); a target classified as a creation never sets the purge field, so the composite can never reach COMPLETE. The task-level rule is also safe here — no current producer emits a creation with a non-empty composite purge status.
  • The canonical-wire choice really does fix resurrection; #184 does not. With ReplicationDeleteMarker=true and an absent version the receiver re-creates the marker (xl-storage.go:1346-1349; xl-storage-format-v2.go:1517-1520). With false, the receiver returns VersionNotFound (erasure-object.go:2013-2027) and the handler answers 204 (object-handlers.go:3171-3192). Scope it honestly: this only changes legacy-shaped purges (canonical already sends false), and does nothing for a delayed creation after a purge — correctly filed as separate.
  • §B's 405 premise is exact: erasure-object.go:996-1002 returns a populated ObjectInfo with toObjectErr(errMethodNotAllowed,…) → MethodNotAllowed{} (object-api-errors.go:96-102), preserved multi-pool too (erasure-server-pool.go:1063-1077).
  • §B's retry gap is real: ToMRFEntry() (:1927-1937) never sets RetryCount, so the > mrfRetryLimit drop (:3883-3887) can never fire for deletes. No msgp code exists for the type, so the field is genuinely schema-free.
  • Resync accounting already handles both shapes — resyncTargetSucceeded (:3154-3162) keys on roi.VersionPurgeStatus. The plan correctly leaves it alone.
  • The pushback on #184's permanence/topology prose is correct. Canonical purge retries are not short-circuited on this baseline (:624 requires VersionID == ""; :628 exits only on VersionPurgeComplete). #184's Bugs 1 and 2 as narrated are legacy-shape-only.

Because B1 is a blocker, I am not stating consensus on this exact hash. Resolve B1 (and the coupled resync-defer/stats-gate items it forces) and the plan becomes technically acceptable in my judgement — implementation would still require the tests in §C plus the partial-fan-out regression above.