fix: treat empty object lock values as absent when comparing

getReplicationAction builds its source map from oi1.UserDefined, where a
removed retention is a present key with an empty value, and its target map
from the destination's HEAD headers, which can never carry those keys because
setObjectHeaders skips empty lock values and FilterObjectLockMetadata drops
both keys when the mode is invalid. The comparison then always reports a
difference, the replicateNone fast path is dead for such versions, and an
otherwise matching version re-copies its metadata on every evaluation.

Skip an entry whose value is empty and whose key is x-amz-object-lock-mode or
x-amz-object-lock-retain-until-date, case-insensitively, in both comparison
loops, using the joined value on the target side. Normalizing only the source
would regress the case where both sides hold the empty pair.

HEAD also omits a real retention from a credential without
s3:GetObjectRetention, which the documented target policy does not grant, so
that normalization alone would read a destination hiding a retention as in
sync and drop the removal. replicationActionForTarget therefore confirms with
the destination before skipping the resend: only an explicit answer, no
retention on the version, clears it. Everything else keeps today's metadata
resend, including a denied or unreachable destination, a mode the SDK does not
recognize, and InvalidRequest, which names a bucket without Object Lock but is
also what a destination answers when its own read of that configuration fails.
The null version an existing object resync excludes is never reopened.

Tests: TestGetReplicationActionEmptyObjectLockValues (eight cases, red on 2
and 3 before this change), TestRetentionRemovedAtSource,
TestTargetRetentionConfirmedAbsent,
TestReplicationActionForTargetRetentionRemoval,
TestReplicationActionForTargetNullVersionResync and
TestEmptyRetentionValuesAreOmittedFromObjectResponseHeaders.
Compatibility: sender side only, no wire or storage change, so a fixed source
converges against any destination version.

Fixes pgsty/silo#117

Signed-off-by: Feng Ruohang <rh@vonng.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L7qJqWwy8oFA6aCXWRzXQe
This commit is contained in:
Feng Ruohang
2026-09-05 14:52:56 +08:00
parent f0bd164b92
commit 05ab4ca475
2 changed files with 407 additions and 5 deletions
+86 -5
View File
@@ -931,11 +931,17 @@ func equals(k1 string, keys ...string) bool {
return false
}
// nullVersionExcludedFromResync reports the exclusion at the head of getReplicationAction, kept
// verbatim from upstream: an existing object resync leaves a null version alone when the source
// modification time is later than the one the target reports, without comparing anything else.
func nullVersionExcludedFromResync(oi1 ObjectInfo, oi2 minio.ObjectInfo, opType replication.Type) bool {
return opType == replication.ExistingObjectReplicationType &&
oi1.ModTime.Unix() > oi2.LastModified.Unix() && oi1.VersionID == nullVersionID
}
// returns replicationAction by comparing metadata between source and target
func getReplicationAction(oi1 ObjectInfo, oi2 minio.ObjectInfo, opType replication.Type) replicationAction {
// Avoid resyncing null versions created prior to enabling replication if target has a newer copy
if opType == replication.ExistingObjectReplicationType &&
oi1.ModTime.Unix() > oi2.LastModified.Unix() && oi1.VersionID == nullVersionID {
if nullVersionExcludedFromResync(oi1, oi2, opType) {
return replicateNone
}
sz, _ := oi1.GetActualSize()
@@ -986,9 +992,21 @@ func getReplicationAction(oi1 ObjectInfo, oi2 minio.ObjectInfo, opType replicati
"X-Amz-Meta-",
}
// An empty object lock mode or retain-until-date records a removed retention, but
// it is omitted from GET/HEAD response headers: setObjectHeaders() skips both keys
// when the value is empty, and FilterObjectLockMetadata() drops them when the mode
// is not valid. The target can therefore never report them, so treat empty and
// absent as equal rather than as a permanent difference.
emptyLockValue := func(k, v string) bool {
return v == "" && equals(k, xhttp.AmzObjectLockMode, xhttp.AmzObjectLockRetainUntilDate)
}
// compare metadata on both maps to see if meta is identical
compareMeta1 := make(map[string]string)
for k, v := range oi1.UserDefined {
if emptyLockValue(k, v) {
continue
}
var found bool
for _, prefix := range compareKeys {
if !stringsHasPrefixFold(k, prefix) {
@@ -1004,6 +1022,10 @@ func getReplicationAction(oi1 ObjectInfo, oi2 minio.ObjectInfo, opType replicati
compareMeta2 := make(map[string]string)
for k, v := range oi2.Metadata {
val := strings.Join(v, ",")
if emptyLockValue(k, val) {
continue
}
var found bool
for _, prefix := range compareKeys {
if !stringsHasPrefixFold(k, prefix) {
@@ -1013,7 +1035,7 @@ func getReplicationAction(oi1 ObjectInfo, oi2 minio.ObjectInfo, opType replicati
break
}
if found {
compareMeta2[strings.ToLower(k)] = strings.Join(v, ",")
compareMeta2[strings.ToLower(k)] = val
}
}
@@ -1024,6 +1046,65 @@ func getReplicationAction(oi1 ObjectInfo, oi2 minio.ObjectInfo, opType replicati
return replicateNone
}
// objectRetentionGetter is the part of the replication target client used to confirm whether a
// destination version still holds Object Lock retention.
type objectRetentionGetter interface {
GetObjectRetention(ctx context.Context, bucketName, objectName, versionID string) (*minio.RetentionMode, *time.Time, error)
}
// retentionRemovedAtSource reports whether oi carries the shape a removed retention leaves behind,
// an object lock key present with an empty value (cmd/object-handlers.go:3211-3215).
func retentionRemovedAtSource(oi ObjectInfo) bool {
lkMap := caseInsensitiveMap(oi.UserDefined)
for _, k := range []string{xhttp.AmzObjectLockMode, xhttp.AmzObjectLockRetainUntilDate} {
if v, ok := lkMap.Lookup(k); ok && v == "" {
return true
}
}
return false
}
// targetRetentionConfirmedAbsent reports whether the destination version is known to hold no
// retention. A HEAD response omits retention both when the version has none and when the
// replication credential lacks s3:GetObjectRetention (cmd/object-handlers.go:942-946), so the
// comparison in getReplicationAction on its own cannot tell a removal that is already in sync from
// one the destination still holds. Only NoSuchObjectLockConfiguration, the answer for a version
// that carries no retention, and a response naming no retention mode count as absent. Everything
// else is uncertainty and is treated as still present, so that the removal is resent exactly as it
// is today: a denied or unreachable destination, a mode the SDK returned without recognizing since
// it does not validate it, and InvalidRequest, which names a bucket with no Object Lock
// configuration but is also what a destination answers when its own read of that configuration
// fails (cmd/bucket-object-lock.go:39-50 returns an error with a zero Retention, discarded at
// cmd/object-handlers.go:3275).
func targetRetentionConfirmedAbsent(ctx context.Context, tgt objectRetentionGetter, bucket, object, versionID string) bool {
mode, _, err := tgt.GetObjectRetention(ctx, bucket, object, versionID)
if err != nil {
return minio.ToErrorResponse(err).Code == "NoSuchObjectLockConfiguration"
}
// An absent or empty mode is no retention. A non-empty mode is retention, whether or not this
// SDK recognizes it.
return mode == nil || *mode == ""
}
// replicationActionForTarget returns the action for a source version against a destination that
// answered HEAD. It is getReplicationAction plus the confirmation that a removed retention which
// compares as in sync really is: see targetRetentionConfirmedAbsent.
func replicationActionForTarget(ctx context.Context, oi1 ObjectInfo, oi2 minio.ObjectInfo, opType replication.Type, tgt objectRetentionGetter, bucket, object string) replicationAction {
rAction := getReplicationAction(oi1, oi2, opType)
if rAction != replicateNone || !retentionRemovedAtSource(oi1) {
return rAction
}
// A null version the resync deliberately leaves alone is not a comparison result, so it is
// not the confirmation's to reopen.
if nullVersionExcludedFromResync(oi1, oi2, opType) {
return rAction
}
if targetRetentionConfirmedAbsent(ctx, tgt, bucket, object, oi1.VersionID) {
return rAction
}
return replicateMetadata
}
// replicateObject replicates the specified version of the object to destination bucket
// The source object is then updated to reflect the replication status.
func replicateObject(ctx context.Context, ri ReplicateObjectInfo, objectAPI ObjectLayer) {
@@ -1467,7 +1548,7 @@ func (ri ReplicateObjectInfo) replicateAll(ctx context.Context, objectAPI Object
sOpts.Set(xhttp.AmzTagDirective, "ACCESS")
oi, cerr := tgt.StatObject(ctx, tgt.Bucket, object, sOpts)
if cerr == nil {
rAction = getReplicationAction(objInfo, oi, ri.OpType)
rAction = replicationActionForTarget(ctx, objInfo, oi, ri.OpType, tgt, tgt.Bucket, object)
rinfo.ReplicationStatus = replication.Completed
if rAction == replicateNone {
if ri.OpType == replication.ExistingObjectReplicationType &&