Persist, send and reconcile empty tag states together with their revision across COPY, PUT and multipart replication. Advance local tag mutations under the existing locks and preserve current tags during replication ACK. Cover signed HTTP, persistent single/multiple pool state, KMS, SSE-C key rotation, ordering, retry and duplicate requests. Record real Opus plan consensus, implementation review and local verification evidence. Signed-off-by: Feng Ruohang <rh@vonng.com> (cherry picked from commit 115fe8b12329d147adbaf817faa1737392ecbf9b) Signed-off-by: Feng Ruohang <rh@vonng.com>
12 KiB
Write is disabled in this session, so I'll deliver the review directly rather than to the plan file.
R5 implementation review — tag deletion and ordered replication
Verdict: GO_WITH_NONBLOCKING_NOTES — blocking count: 0
Read-only review. No files edited, no tests run, no results simulated. Every pass I cite is quoted from the supplied log.
What I reviewed
| Item | Value |
|---|---|
| Working tree | /Users/vonng/.codex/worktrees/77ad/silo, branch codex/r5-tag-deletion-ordering |
| Baseline dependency | dbcf8dec589deb5d91e17d295cb70997635f5b55 (R4 SSE-KMS field) |
| Plan | plan-v2.md, SHA256 5a782acf…7fc7446ca (quoted) |
| Patch | implementation-v1.patch, SHA256 8f6f76ee…f88f93554c1b (quoted) |
Hash caveat: with Read/Grep/Glob I cannot compute either SHA256 — both are quoted from the manifest/task. What I did verify by reading is that every hunk in the frozen patch is present verbatim in the working tree across all seven production files. That is source identity by inspection, not by digest. I also confirmed R5 does not touch cmd/object-api-options.go; the R4 branch carrying ReplicationSourceTaggingTimestamp (object-api-options.go:449-460) is unmodified.
Per-claim findings
1. Empty values are ordered states; no fabricated legacy tombstone. Confirmed. replicationTaggingTimestamp (bucket-replication.go:786-794) returns the recorded stamp even with empty tags, falls back to ModTime only for non-empty tags, zero otherwise. Used by both putReplicationOpts (:861-870) and the metadata sender (:1702-1707). The SDK omits the header for a zero time (minio-go@…60bd07042d49/api-put-object.go:236-238, api-compose-object.go:286-288), so "no revision" really travels as absence. Malformed stamps fail both constructions.
2. Local revisions unconditional and monotonic. Confirmed. Both handlers mint one UTCNow() outside the dsc.ReplicateAny() branch (object-handlers.go:3773-3778, :3876-3881); getOpts leaves opts.UserDefined nil (object-api-options.go:110,:39), so the unconditional map replacement drops nothing. er.PutObjectTags applies the guard under the existing NS lock (erasure-object.go:2273-2282, :2330-2334); an absent stamp yields "" and preserves legacy direct-storage semantics. z.PutObjectTags folds one candidate strictly beyond every copy and clones first (erasure-server-pool.go:3054-3062) — opts is a value parameter and er.PutObjectTags never writes opts.UserDefined, so no caller map is mutated. No replica path reaches PutObjectTags (the only two production callers are the tagging handlers), so replicas keep strict source ordering via reconcileStoredObjectTags, stored-wins on ties (erasure-server-pool-consistency.go:238-242).
3. COPY receiver. Confirmed. Stored pair captured before reconstruction (object-handlers.go:1800); srcInfo.UserTags is never reassigned between the source read and the decision, so it genuinely is stored state. The decision block (:1818-1837) accepts an incoming empty value with a stamp and rechecks the captured state; all existing in-lock rechecks still run (erasure-object.go:136-138, :1312-1315; erasure-multipart.go:1161-1190; erasure-server-pool.go:1443-1450). The encMetadata fix (:1840) is safe and correctly placed — encMetadata receives reserved keys only on the SSE-C rotation path (:1655-1659), and the delete lands after rotateKey/newEncryptReader and before the merge at :1910.
4. PUT/multipart persistence and the duplicate exception. Confirmed. putOptsFromHeaders aliases opts.UserDefined = metadata in both branches (object-api-options.go:451,:464), so post-build writes reach storage (object-handlers.go:2323-2325; object-multipart-handlers.go:315-318, correctly after maps.Copy(metadata, encMetadata) at :300). The relaxation (object-handlers-common.go:243-246) sits below the explicit If-Match/If-None-Match checks, is gated on isReplicaTrusted + olderThan (zero source never wins, bucket-object-lock.go:370-376), and leaves the SSE-C exemption intact. It cannot loop: once the write lands the stamps are equal and the next attempt 412s. completeMultipartOpts sets neither PreserveETag nor a tagging timestamp (object-api-options.go:501-550), so completion needs no new exception and reconciles under the lock (object-multipart-handlers.go:1201).
5. Equal values can hide a newer revision. Confirmed and correctly scoped. The gate (bucket-replication.go:1013-1018) sits after the null-version resync exclusion and after every branch that can return replicateAll; from there only replicateMetadata/replicateNone are reachable, so it can never downgrade a needed full transfer. It is reached only from replicationActionForTarget → replicateAll (:1608), not from the object-replication fast path (:1328-1343). The Completed gate (:3775) and failures-only requeue (:1316) bound the work, and an incoming replica COPY schedules no outgoing event. Existing fixtures carry no tagging stamp (bucket-replication_test.go:716-739), so they are unaffected.
6. ACK no longer overwrites current tags. Confirmed removed (bucket-replication.go:1276-1286). Preservation holds on both write-backs: er.PutObjectMetadata copies from ObjectInfo.UserDefined, which cleanMetadata strips of x-amz-tagging (object-api-utils.go:403-407; erasure-object.go:2254-2260); updatePoolMetadata falls back to merged UserTags and rewrites the merged newest stamp (erasure-server-pool-consistency.go:194-214). Both under the object lock (erasure-object.go:2196-2205; erasure-server-pool.go:3020-3029). The sender also re-reads current state first (bucket-replication.go:1527-1550).
7. Scope. Respected — no tag-filter eligibility change, no historical tombstone invention, no clock-skew consensus, no object-api-options.go change.
Trust boundary re-checked: the reserved key cannot be injected from the wire — containsReservedMetadata rejects the whole X-Minio-Internal- class outside the SSE allowlist (generic-handlers.go:75-85), and extractMetadataFromMimeWithReplication maps only replicationToInternalHeaders (handler-utils.go:258-298).
KMS fixture inspected directly, not inferred from prior output: the multipart case now seeds a real multipart source and reuses its actual ETag/part layout (replication-tagging-order_test.go:472-489). The earlier single-PUT-ETag mismatch is gone. See L3 for what it still does not cover.
Non-blocking notes (no change required)
- N1 — on encrypted destinations the forced metadata COPY is not metadata-only. The gate at
bucket-replication.go:1013-1018yields a replica COPY; with bucket-default/auto KMS the destination applies SSE beforecopyDstOpts(object-handlers.go:1428-1433) and then clearssrcInfo.metadataOnly(:1669-1677) — so it rewrites object data. Bounded to one COPY per object entering heal and one per object per explicit resync (not a loop), but the plan's "extra metadata I/O" understates this case. Worth a sentence in the cost note. - N2 — fail-closed on a malformed stored revision is terminal for that object (
:786-794→:1702-1707/:867-870, requeued by MRF at:1316-1322). No production writer can produce such a value, so this is a defensive tail. Note the asymmetry: storage self-heals the same corruption (invalid stored ⇒ incoming wins,erasure-server-pool-consistency.go:238-242) while the sender refuses to proceed. The minimal hardening, if ever wanted, is to treat a present-but-empty value as absent — I traced no reachable path producing one, and the current behavior is what plan and consensus chose, so I am not asking for it. - N3 — trusted-marker vs REPLICA asymmetry (pre-existing).
object-handlers.go:2323/object-multipart-handlers.go:316persist onopts.ReplicationRequest, while the in-lock recheck needsisReplicaTrustedand a version ID (:2442). Production sets both; the new precondition exception uses the stricter predicate. Accepted in plan §D. - N4 — a trusted metadata COPY with no source revision now ignores the request's tag value (
object-handlers.go:1826-1833). For a peer sendingx-amz-metadata-directive: REPLACEwithout a revision, the value used to land (X-Amz-Taggingis insupportedHeaders,handler-utils.go:271-283). No MinIO sender produces that shape, and the R4 caseobject-copy-replication-tagging_test.go:89already expects stored-wins there, reaching it via the storage reconcile. Deliberate per plan §C. - N5 — ordinary COPY always writes an explicit tag value plus a fresh revision (
:1834-1837). (a)x-amz-tagging-directive: REPLACEwith no tags now genuinely clears the destination, where the default-metadata path used to carry source tags forward — an S3 conformance improvement, covered byTestAPILocalTaggingAlwaysAdvancesRevision. (b) An in-place key-rotation COPY advances the revision without changing any value, re-asserting current tags against an older in-flight remote deletion. Both follow from last-writer-wins as specified. - N6 — cosmetic. The key is read case-insensitively at
bucket-replication.go:787/:1016, exactly elsewhere.TaggingTimestampis lowercase (:74) and storage writes only lowercase, so they agree; the same mix already exists for lock timestamps in that file (:896vs:1708).
Tests and coverage limitations (material)
- L1 — the only established green result is the 12 R5 tests (
fixed-targeted-latest.log,ok … 9.161s): signed HTTP through real single-disk and 16-disk storage, null/UUID, COPY default and REPLACE, PUT/multipart, KMS, SSE-C rotation, multi-pool, sender retry and stale ACK. The widercmdpackage,-race,gofmtandgit diff --checkare ongoing, and theTestReplicationResyncpanic is unattributed. I treat that as an open verification item, not a regression and not a pass. My static read found no existing test whose expectations R5 flips — I checked thegetReplicationActionfixtures, the R4 KMS COPY table including itsmissing-timestampcase (R5 satisfies it via the handler instead of the storage reconcile), and confirmed no pre-existing test callsPutObjectTagswith a supplied revision. - L2 — multi-pool convergence after an incoming replica write is asserted weakly.
replication-tagging-order_test.go:688-698skips any pool whose version is absent and only asserts a retained copy is correct; it pins neither which pool is retained nor that retirement ran. The case that matters for §A — one revision strictly greater than every copy, in the response and every stored copy — is pinned byTestLocalTaggingCommitCannotRegressRevision. - L3 — the KMS variant's source objects are plaintext. Seeds use
obj.PutObject(:468,:477-488), bypassing handler encryption, so encryption enters only via the incoming request and the destination bucket default. Real coverage of the R4 field on the receive side, but not encrypted-source-to-encrypted-destination end to end. The trailing plaintext GET (:520-523) does establish the final object is readable. - L4 — wire shape is well pinned.
TestTaggingProductionCopyWireShapeasserts the real SDK request (metadata-directive="",tagging-directive=REPLACE) against a live peer, andr5Receive's"copy"operation covers peer metadata-REPLACE independently. Both required shapes are present.
Recommendation
The production diff is merge-eligible as written; I found no actual source defect. The single gating action before merge is closing L1 — a clean full-package run (plus -race, gofmt, git diff --check) with the TestReplicationResync panic isolated against the unpatched baseline. N1 and N2 deserve a sentence each in the plan's cost/limitations section; N3–N6 are already covered by plan §C/§D and need no action.