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>
15 KiB
Write is disabled in this session, so the review is delivered here in full.
Independent review — R5 repair plan v1
Reviewer model: claude-opus-5 (Opus 5), effort max. I read docs/investigations/r5/plan-v1.md completely, docs/investigations/r5/baseline.md, both raw logs, baseline_test.go, and then independently inspected the sender/receiver/storage chain at 9ebe81c1b.
VERDICT: REQUEST_CHANGES for plan v1 sha256 fd6051527ebf19f624125bd3238da2f938420917226387c0f9373f1a98e87993. Five blocking corrections (R1–R5). No consensus is claimed.
Binding caveat, stated exactly: this session has read-only tools, so I did not execute sha256sum. I reviewed the file content at that path at the baseline SHA and cite the plan's own line numbers so you can bind findings to the hash.
1. Claims I confirmed from source (necessity, not inference)
| Plan | Claim | Evidence | Verdict |
|---|---|---|---|
| L14 | PUT tagging stamps only when replication is selected; DELETE never stamps | cmd/object-handlers.go:3762-3768, :3865-3870 |
correct (and PUT uses two separate UTCNow() calls, 3765/3767) |
| L14 | both write under the existing object lock; multi-pool updates the addressed version in each copy | cmd/erasure-object.go:2272-2337; cmd/erasure-server-pool.go:3033-3075 |
correct |
| L15 | putReplicationOpts stamps only inside nonempty UserTags; multipart clears SourceMTime |
cmd/bucket-replication.go:849-863, :1776 |
correct |
| L16 | metadata COPY carries an explicit empty tag; ModTime default only for nonempty | :747, :1692-1700; SDK copyObjectDo:262-264 writes the map verbatim |
correct |
| L18 | PUT / multipart initiation parse but never persist the source stamp | cmd/object-api-options.go:473; no writer anywhere in cmd/ |
correct (matches baseline-extended.log:19-26) |
| L18 | multipart completion already rechecks the persisted upload under the object lock | cmd/erasure-multipart.go:1161-1190 |
correct |
| L19 | getReplicationAction compares values/counts, not ordering time |
cmd/bucket-replication.go:1000-1005 |
correct |
| L20 | checkPreconditionsPUT skips matching version/ETag for non-SSE-C replicas |
cmd/object-handlers-common.go:233-247 |
correct; and senders treat 412 as delivered (:1466; multipart :1786-1788 returns nil) |
| L21 | ACK callback copies stale ri.UserTags |
cmd/bucket-replication.go:1272-1274 |
correct, and worse than stated |
| L23 | reconcileStoredObjectTags gates as described |
cmd/erasure-server-pool-consistency.go:232-243 |
correct |
Two amplifiers the plan does not name, both strengthening it:
- The ACK callback writes stale tags without a timestamp. The revived tag set therefore inherits the deletion's newer revision and propagates downstream as authoritative. Removal is the right fix and is sufficient:
er.PutObjectMetadatapreservesfi.Metadata's tag key (cmd/erasure-object.go:2260) andupdatePoolMetadatafalls back to mergedUserTags(cmd/erasure-server-pool-consistency.go:194-214). - The
encMetadatamerge atcmd/object-handlers.go:1903restores every reserved key snapshotted at:1655-1659; the guard at:1855-1864covers only the two Object Lock stamps. Tag revision is genuinely exposed, so L47 is justified.
2. Blocking disagreements
R1 — Do not synthesize a ModTime revision for objects with no tags and no revision
Where: L35 ("otherwise object ModTime (also for empty legacy objects)") composed with L49 ("persist a nonzero parsed trusted source timestamp").
Evidence: PutObjectOptions.Header() emits the header whenever TaggingTimestamp is non-zero (SDK api-put-object.go:236-238). If L35 moves selection outside the nonempty branch and defaults to ModTime, every replicated object — including every object that has never carried a tag — ships a non-zero stamp, and L49 persists it. Every object on the destination then owns a tag revision. Composed with L39 (recorded revision ⇒ force metadata replication), every object at the next hop always selects metadata replication. It also contradicts L10 ("not a reason to change the storage format") and L57 ("we do not invent historical deletion times").
Smallest correction: send a stamp only when objInfo.UserTags != "" or a recorded revision exists. That keeps the tombstone case (empty + revision — the entire point), keeps the existing nonempty ModTime fallback, and drops only empty + no-revision, which L57 already declares unrecoverable. This makes §B consistent with §D.
R2 — Bound the forced metadata replication in getReplicationAction
Where: L39.
Evidence: the destination's revision is invisible to HEAD, so the condition never becomes false. Any object carrying a revision never returns replicateNone again: every heal, MRF retry and ExistingObjectReplicationType resync re-COPIES its metadata, rewriting xl.meta on the destination (and, multi-pool, running retireReplicaCopies) each pass. L39's "extra COPY only for already-scheduled work" understates a permanent non-convergence. Existing tests won't catch it — newMatchingReplicationPair (cmd/bucket-replication_test.go:716-739) carries no revision.
Smallest correction: fire only when oi1.UserTags == "" and a revision is recorded — exactly the empty-to-empty tombstone L19 names and TestReviewR5SameEmptyTagsMustTransferTimestamp asserts. Nonempty states are already caught by the existing value/count comparison at :1003. Then state the residual: tag-deleted objects still never converge to replicateNone.
R3 — The production metadata COPY does not send x-amz-metadata-directive: REPLACE
Where: L17.
Evidence: getCopyObjMetadata sets x-amz-tagging-directive: REPLACE (:748) but never the metadata directive, so getCpObjMetadataFromHeader takes the defaultMeta branch (cmd/object-handlers.go:1143,1165-1170). Therefore:
- "its REPLACE metadata map also loses the previous timestamp before comparison" is false on the production path —
defaultMetapreserves the stored revision. It is true only for a peer that does send REPLACE. - The empty tombstone is dropped for a different reason than the plan gives:
defaultMetacarries the storedX-Amz-Taggingforward and theobjTags != ""gate at:1817skips the overwrite. (NoteX-Amz-Taggingis insupportedHeaders,cmd/handler-utils.go:84, so on the REPLACE path the empty value does arrive in the map — only the stamp is missing.) - The reproduction sends
x-amz-metadata-directive: REPLACE(baseline_test.go:134), so it does not pin the production request shape. The conclusion still holds (with a stale stored stamp the delayed COPY wins either way), but the evidence chain as written is not the one production executes.
Smallest correction: fix L17, and add a sender-shaped COPY case asserting against getCopyObjMetadata output rather than a hand-built header map.
R4 — Missing monotonic guard on the local revision at commit
Where: L31 explicitly asks the reviewer to decide. My answer: commit-time generation is not required; a commit-time monotonic guard is.
Evidence: er.PutObjectTags writes fi.Metadata[x-amz-tagging] and copies opts.UserDefined with no ordering check (cmd/erasure-object.go:2328-2330), and the handler mints the stamp before the namespace lock. R5 newly makes DELETE mint a revision, so a DELETE→PUT pair can invert — via lock queueing (globalOperationTimeout waits) or clock skew between the two nodes serving the two requests. Result: source holds tags=X @ t_old, replica holds the tombstone @ t_new. Every retransmit is then rejected by reconcileStoredObjectTags (stamp.Before(incoming) false), the sender still records Completed, and — with R2's rule — re-sends forever. Permanent, silent divergence: precisely the failure class R5 exists to remove, newly broadened by change A.
Smallest correction: in er.PutObjectTags, under the lock, if the incoming revision is not strictly after the stored one, advance it to stored + 1ns. Multi-pool is safe without a second site of change: z.PutObjectTags writes identical tags to all copies, so any per-pool stamp differences still merge to a consistent (tags, newest stamp) pair through mergedPoolObjectInfo (cmd/erasure-server-pool-consistency.go:124-131).
R5 — Under-specified duplicate-suppression comparison
Where: L51, "a valid newer source tag timestamp makes matching version/ETag insufficient".
Evidence: read naively as "non-zero source stamp ⇒ bypass", this disables the duplicate guard for every tagged replica write; for multipart it re-uploads all parts, since 412 at initiation is currently the cheap exit (:1786-1788). TestReviewR5NewerTagsMustBypassContentDuplicate already encodes the correct comparison (source stamp vs. oi's stored stamp), but the prose does not.
Smallest correction: one sentence — "strictly newer than the destination's stored tag revision" — plus the cost note that even correctly scoped, this re-PUTs object data to deliver a tag-only change.
3. Accepted / non-blocking (state them; do not necessarily fix)
- §A local generation semantics (L29): accepted as sufficient, subject to R4. Use one
UTCNow()for both stamps as proposed. - §B ACK removal (L41): necessary and sufficient; preservation verified on both the single-pool and pooled write-back paths.
- §C
encMetadatareconciliation (L47): accepted. Today's observable effect is fail-closed (update dropped) whenReplicaLockReconcileis on, and a mismatched(new tags, old stamp)pair whenVersionID == ""— worth one sentence. - Equal timestamps: adopting
reconcileStoredObjectTagsin the handler silently flips the non-versioned COPY path from "incoming wins on equal" (cmd/object-handlers.go:1824,!ondiskTimestamp.After(srcTimestamp)) to "stored wins on equal". This is the right direction and removes a real handler/storage inconsistency, but it is a compat-visible change and belongs in §D. - Missing/invalid timestamps: the gate asymmetry is correct as the plan describes — invalid stored ⇒ incoming wins; invalid incoming with valid stored ⇒ stored wins. Note the metadata COPY sender swallows parse errors (
:1696-1699,if err == nil); L35's fail-loud rule should cover that call site too. - Trust boundary: sound. Stamp honored only under
trustedReplication(cmd/object-api-options.go:390-396); headers stripped otherwise (cmd/replication-trust.go:96-112,139-143); client-supplied reserved headers rejected wholesale (cmd/generic-handlers.go:75-85). One asymmetry to resolve deliberately: the tag decision keys ondstOpts.ReplicationRequest(trusted marker) while Object Lock keys onreplicaTrusted(marker + REPLICA). Production sets both. - SSE-KMS / R4 boundary — more precise than the plan: the KMS early return (
cmd/object-api-options.go:431-461) dropsReplicationSourceTaggingTimestamp, so R5's PUT/multipart persistence is a silent no-op for KMS-header requests until R4 lands. The metadata-COPY leg is not affected: the public SSE header is synthesized only in responses (cmd/api-response.go:525-533), sogetCopyObjMetadatanever forwards it and tag deletions still order correctly for SSE-KMS objects via COPY. Say this instead of only "KMS combined tests after R4". - Ordinary PUT/multipart with tags writes no revision, so §A's invariant has a hole. Mostly masked by versioning; exposed on null/suspended versions, where
ReplicaLockReconcileis off anyway (:1847,:2432). Either stamp there too (~3 lines) or document. - Tag-filtered replication rules never see a deletion:
FilterTargetArnsruns on the post-deletionUserTags(cmd/bucket-replication.go:1189-1193;scheduleReplicationis called with the post-writeoiatcmd/object-handlers.go:3879), so a rule with a tag filter yields no target. Pre-existing, but it bounds any completeness claim. - Sender retries are otherwise clean:
replicateAllre-reads current state (:1517), so retries carry the current revision; staleness was confined to the ACK callback. No new HEAD protocol — agreed. - UUID / null / versioned / multi-pool:
metadataPoolInfos,mergedPoolObjectInfo,retireReplicaCopiesand thenullVersionIDnormalization (:136-138,:1162-1168) do carry tag state correctly; I found no additional loss path there beyond R4's clock inversion.
4. Can the proposed coverage establish the scope?
Can: per-hop handler + storage behavior on real erasure disks (ErasureSD and 16-drive), multi-pool merge/retire through consistencyPools and the existing tag suite (cmd/erasure-server-pool-tags_test.go), sender option construction, and the ACK race with a deterministic interleave.
Cannot: cross-site convergence, real clock skew, real lock contention. Both ExecObjectLayerAPITest instances run in one process against one clock. L73's "Not production multi-site acceptance" should be sharpened to say the suite establishes per-hop correctness only.
Must add: (a) an untagged-object regression asserting no revision is synthesized (R1); (b) the sender-shaped metadata COPY (R3); (c) a DELETE→PUT inversion, by passing an older stamp directly at the storage call (R4); (d) equal-timestamp parity across the versioned and null-version COPY paths; (e) an SSE-KMS metadata COPY case marked blocked-on-R4. Also keep the three assertions inside TestReviewR5DeleteThenDelayedTagUpdate separable — once change A lands, the first passes and the later two silently depend on it.
5. Summary
- Accepted: §12.1/.2/.3/.5/.6/.7/.8 defect claims; §A generation model; §B ACK removal; §C
encMetadatareconciliation, storage-recheck reuse, multipart persistence; §D R4 ownership and the no-migration position. - Non-blocking: equal-timestamp behavior change, trust asymmetry, ordinary-PUT revision hole, tag-filtered rules, swallowed parse error in the COPY sender.
- Required (blocking): R1 ModTime fallback scope; R2 forced-replication scope; R3 metadata-directive correction + sender-shaped test; R4 monotonic guard at commit; R5 duplicate-suppression comparison wording.
Answering L75 directly: §A's existing local timestamp semantics are not sufficient without R4. §B's forced metadata synchronization is necessary but wrongly scoped (R1, R2); the stale ACK removal is necessary and correctly scoped. §C's duplicate exception is defensible but under-specified (R5). And yes — a path can still lose or revive the deletion revision: the DELETE→PUT local inversion (R4), plus the documented SSE-KMS PUT leg and tag-filtered-rule gaps.
I recommend a v2 addressing R1–R5, then re-review against the new hash. I made no edits.