diff --git a/cmd/data-scanner.go b/cmd/data-scanner.go index 329772df9..77d265367 100644 --- a/cmd/data-scanner.go +++ b/cmd/data-scanner.go @@ -1204,7 +1204,7 @@ func applyTransitionRule(event lifecycle.Event, src lcEventSrc, obj ObjectInfo) func applyExpiryOnTransitionedObject(ctx context.Context, objLayer ObjectLayer, obj ObjectInfo, lcEvent lifecycle.Event, src lcEventSrc) (ok bool) { timeILM := globalScannerMetrics.timeILM(lcEvent.Action) if err := expireTransitionedObject(ctx, objLayer, &obj, lcEvent, src); err != nil { - if isErrObjectNotFound(err) || isErrVersionNotFound(err) { + if errors.Is(err, errLifecycleNotApplicable) || isErrObjectNotFound(err) || isErrVersionNotFound(err) { return false } ilmLogIf(ctx, fmt.Errorf("expireTransitionedObject(%s, %s): %w", obj.Bucket, obj.Name, err)) @@ -1271,7 +1271,7 @@ func applyExpiryOnNonTransitionedObjects(ctx context.Context, objLayer ObjectLay dobj, err = objLayer.DeleteObject(ctx, obj.Bucket, encodeDirObject(obj.Name), opts) if err != nil { - if isErrObjectNotFound(err) || isErrVersionNotFound(err) { + if errors.Is(err, errLifecycleNotApplicable) || isErrObjectNotFound(err) || isErrVersionNotFound(err) { traceFn(ILMExpiry, nil, nil) return false } diff --git a/cmd/erasure-object.go b/cmd/erasure-object.go index 8ceae79d4..dee11cc8c 100644 --- a/cmd/erasure-object.go +++ b/cmd/erasure-object.go @@ -2045,6 +2045,9 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string // expiration attempted on a bucket with no lifecycle // rules shall be rejected. if lc == nil && opts.Expiration.Expire { + if !opts.Transition.ExpireRestored { + return objInfo, errLifecycleNotApplicable + } if opts.VersionID != "" { return objInfo, VersionNotFound{ Bucket: bucket, @@ -2089,6 +2092,9 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string isErr = true } if isErr { + if !opts.Transition.ExpireRestored { + return goi, errLifecycleNotApplicable + } if goi.VersionID != "" { return goi, VersionNotFound{ Bucket: bucket, @@ -2162,6 +2168,9 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string isErr = true } if isErr { + if !opts.Transition.ExpireRestored { + return goi, errLifecycleNotApplicable + } if goi.VersionID != "" { return goi, VersionNotFound{ Bucket: bucket, diff --git a/cmd/erasure-server-pool-consistency.go b/cmd/erasure-server-pool-consistency.go index 44f3dfed6..320fb57af 100644 --- a/cmd/erasure-server-pool-consistency.go +++ b/cmd/erasure-server-pool-consistency.go @@ -19,6 +19,7 @@ package cmd import ( "context" + "errors" "maps" "sort" "strings" @@ -769,6 +770,11 @@ func (z *erasureServerPools) deleteObjectsReconciled(ctx context.Context, bucket deleteOpts.Versioned = opts.PrefixEnabledFn(obj.ObjectName) } _, err := z.deleteObjectReconciled(ctx, bucket, obj.ObjectName, deleteOpts) + if errors.Is(err, errLifecycleNotApplicable) { + // A later lifecycle rejection did not commit a local purge. Do + // not let the worker replicate the request's pending decision. + deleted[i].ReplicationState = ReplicationState{} + } if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) { errs[i] = err } else { diff --git a/cmd/erasure-server-pool-lifecycle-batch_test.go b/cmd/erasure-server-pool-lifecycle-batch_test.go index bfb5c63cc..b2e838078 100644 --- a/cmd/erasure-server-pool-lifecycle-batch_test.go +++ b/cmd/erasure-server-pool-lifecycle-batch_test.go @@ -19,6 +19,7 @@ package cmd import ( "context" + "errors" "net/http" "testing" @@ -118,7 +119,7 @@ func TestPoolsLifecycleTieredBatchReplication(t *testing.T) { if result.errs[0] != nil { t.Errorf("eligible delete: %v", result.errs[0]) } - if !isErrVersionNotFound(result.errs[1]) { + if !errors.Is(result.errs[1], errLifecycleNotApplicable) { t.Errorf("retained version result: %v", result.errs[1]) } if result.deleted[1].VersionPurgeStatus() != "" || result.deleted[1].ReplicationState.ReplicateDecisionStr != "" { @@ -192,7 +193,11 @@ func TestPoolsLifecycleTieredBatchIdentity(t *testing.T) { } vc, _ := globalBucketVersioningSys.Get(s.bucket) _, errs := s.z.DeleteObjects(t.Context(), s.bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: key, VersionID: vid}}}, ObjectOptions{PrefixEnabledFn: vc.PrefixEnabled, Expiration: ExpirationOptions{Expire: tc.expire}}) - if errs[0] != nil { + if tc.expire && !tc.wantGone { + if !errors.Is(errs[0], errLifecycleNotApplicable) { + t.Fatalf("ineligible expiry: %v", errs[0]) + } + } else if errs[0] != nil { t.Fatalf("delete: %v", errs[0]) } query := vid diff --git a/cmd/erasure-server-pool-lifecycle-safety_test.go b/cmd/erasure-server-pool-lifecycle-safety_test.go new file mode 100644 index 000000000..27791813f --- /dev/null +++ b/cmd/erasure-server-pool-lifecycle-safety_test.go @@ -0,0 +1,353 @@ +// Copyright (c) 2026 Feng Ruohang +// +// This file is part of Silo Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "context" + "errors" + "maps" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/minio/minio/internal/bucket/lifecycle" + "github.com/minio/minio/internal/bucket/replication" + "github.com/minio/minio/internal/bucket/versioning" + xhttp "github.com/minio/minio/internal/http" +) + +// A partial metadata update can leave the newest lock/tag revision on a copy +// other than the one selected by content ModTime. Keep the data identity intact. +func setLifecycleCopyMetadata(t *testing.T, s *splitILM, key, id string, pool int, metadata map[string]string, advance bool) { + t.Helper() + oi, err := s.z.serverPools[pool].GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: id}) + if err != nil { + t.Fatal(err) + } + opts := ObjectOptions{VersionID: id, Versioned: true, MTime: oi.ModTime} + if advance { + opts.MTime = opts.MTime.Add(time.Hour) + } + opts.EvalMetadataFn = func(oi *ObjectInfo, _ error) (ReplicateDecision, error) { + maps.Copy(oi.UserDefined, metadata) + return ReplicateDecision{}, nil + } + if _, err := s.z.serverPools[pool].PutObjectMetadata(t.Context(), s.bucket, key, opts); err != nil { + t.Fatal(err) + } +} + +func TestPoolsLifecycleDuplicateLockMetadata(t *testing.T) { + old := UTCNow().Add(-2 * time.Hour).Format(time.RFC3339Nano) + recent := UTCNow().Add(-time.Hour).Format(time.RFC3339Nano) + for _, tc := range []struct { + name string + copies [2]map[string]string + mode, hold string + delete bool + newerPool1 bool + }{ + {name: "hold-on", copies: [2]map[string]string{poolLockMetadata("", "OFF", old, old), poolLockMetadata("", "ON", old, recent)}, hold: "ON"}, + {name: "held-earlier-pool", copies: [2]map[string]string{poolLockMetadata("", "ON", old, recent), poolLockMetadata("", "OFF", old, old)}, hold: "ON", newerPool1: true}, + {name: "retention-on", copies: [2]map[string]string{poolLockMetadata("", "OFF", old, old), poolLockMetadata("GOVERNANCE", "OFF", recent, old)}, mode: "GOVERNANCE", hold: "OFF"}, + {name: "independent-fields", copies: [2]map[string]string{poolLockMetadata("GOVERNANCE", "OFF", recent, old), poolLockMetadata("", "ON", old, recent)}, mode: "GOVERNANCE", hold: "ON"}, + {name: "hold-released", copies: [2]map[string]string{poolLockMetadata("", "ON", old, old), poolLockMetadata("", "OFF", old, recent)}, hold: "OFF", delete: true}, + {name: "retention-released", copies: [2]map[string]string{poolLockMetadata("GOVERNANCE", "OFF", old, old), poolLockMetadata("", "OFF", recent, old)}, hold: "OFF", delete: true}, + } { + for _, batch := range []bool{false, true} { + path := "single" + if batch { + path = "batch" + } + t.Run(tc.name+"/"+path, func(t *testing.T) { + s := &splitILM{migrationILM: newMigrationILM(t, versioning.Enabled, true, splitILMRules), ids: map[string]string{}, bodies: map[string]string{}} + const key = "ncv-copy-lock" + v := splitAt("old", 5, 0, 1) + v.tiered = batch + s.write(key, v) + s.write(key, splitAt("new", 3, 0, 1)) + id := s.ids[key+" old"] + for pool, metadata := range tc.copies { + metadata = maps.Clone(metadata) + if metadata["x-amz-object-lock-mode"] != "" { + metadata["x-amz-object-lock-retain-until-date"] = UTCNow().AddDate(1, 0, 0).Format(time.RFC3339) + } else { + metadata["x-amz-object-lock-retain-until-date"] = "" + } + setLifecycleCopyMetadata(t, s, key, id, pool, metadata, tc.newerPool1 && pool == 1) + } + versions, events, err := s.z.lifecycleEventsAllPools(t.Context(), s.bucket, key) + if err != nil { + t.Fatal(err) + } + if len(versions) != 2 || versions[1].VersionID != id || versions[1].NumVersions != 2 { + t.Fatalf("wrong version history: %+v", versions) + } + state := storedObjectLockState(versions[1].UserDefined) + if state.mode != tc.mode || state.legalHold != tc.hold { + t.Errorf("merged state=%+v, want mode=%s hold=%s", state, tc.mode, tc.hold) + } + want := lifecycle.NoneAction + if tc.delete { + want = lifecycle.DeleteVersionAction + } + // Read-side expiry rechecks consume the same event producer. + if events[1].Action != want { + t.Errorf("expiry event=%s want=%s", events[1].Action, want) + } + opts := ObjectOptions{Versioned: true, Expiration: ExpirationOptions{Expire: true}} + if batch { + _, errs := s.z.DeleteObjects(t.Context(), s.bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: key, VersionID: id}}}, opts) + err = errs[0] + } else { + opts.VersionID = id + _, err = s.z.DeleteObject(t.Context(), s.bucket, key, opts) + } + if tc.delete && err != nil || !tc.delete && !errors.Is(err, errLifecycleNotApplicable) { + t.Errorf("delete error=%v, wantDelete=%t", err, tc.delete) + } + for pool := range s.z.serverPools { + _, err := s.z.serverPools[pool].GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: id}) + // Single DeleteObject retains its existing first-copy dispatch. + gone := tc.delete && (batch || pool == 0) + if gone && !isErrVersionNotFound(err) || !gone && err != nil { + t.Errorf("pool=%d err=%v wantGone=%t", pool, err, gone) + } + } + }) + } + } +} + +func TestPoolsLifecycleDuplicateTagMetadata(t *testing.T) { + const rule = `taggedEnabledexpireyes1` + for _, tc := range []struct { + name, stale, fresh string + expire bool + }{ + {"removed", "expire=yes", "", false}, + {"changed", "expire=yes", "expire=no", false}, + {"enabled", "expire=no", "expire=yes", true}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newSplitILM(t, versioning.Enabled, rule) + const key = "tagged-copy" + s.write(key, splitTiered("old", 5, 0, 1)) + s.write(key, splitAt("new", 3, 0, 1)) + id := s.ids[key+" old"] + old := UTCNow().Add(-2 * time.Hour).Format(time.RFC3339Nano) + recent := UTCNow().Add(-time.Hour).Format(time.RFC3339Nano) + for pool, tags := range []string{tc.stale, tc.fresh} { + stamp := old + if pool == 1 { + stamp = recent + } + setLifecycleCopyMetadata(t, s, key, id, pool, map[string]string{xhttp.AmzObjectTagging: tags, ReservedMetadataPrefixLower + TaggingTimestamp: stamp}, false) + } + versions, events, err := s.z.lifecycleEventsAllPools(t.Context(), s.bucket, key) + if err != nil { + t.Fatal(err) + } + if len(versions) != 2 || versions[1].UserTags != tc.fresh || versions[1].UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] != recent { + t.Fatalf("lost newest tag revision: %+v", versions) + } + want := lifecycle.NoneAction + if tc.expire { + want = lifecycle.DeleteVersionAction + } + if events[1].Action != want { + t.Errorf("shared read/delete expiry event=%s want=%s", events[1].Action, want) + } + _, errs := s.z.DeleteObjects(t.Context(), s.bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: key, VersionID: id}}}, ObjectOptions{Versioned: true, Expiration: ExpirationOptions{Expire: true}}) + if tc.expire && errs[0] != nil || !tc.expire && !errors.Is(errs[0], errLifecycleNotApplicable) { + t.Errorf("delete error=%v", errs[0]) + } + for pool := range s.z.serverPools { + _, err := s.z.serverPools[pool].GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: id}) + if tc.expire && !isErrVersionNotFound(err) || !tc.expire && err != nil { + t.Errorf("pool=%d err=%v wantGone=%t", pool, err, tc.expire) + } + } + }) + } +} + +// All-pool validation uses ReadXL. Run once at the first addressed read in +// batch dispatch, after validation and before the set's lifecycle check. +type lifecycleDispatchDisk struct { + StorageAPI + bucket, key string + once *sync.Once + scans *atomic.Int64 + beforeRead func() +} + +func (d lifecycleDispatchDisk) ReadXL(ctx context.Context, bucket, key string, data bool) (RawFileInfo, error) { + if bucket == d.bucket && key == d.key { + d.scans.Add(1) + } + return d.StorageAPI.ReadXL(ctx, bucket, key, data) +} + +func (d lifecycleDispatchDisk) ReadVersion(ctx context.Context, orig, bucket, key, id string, opts ReadOptions) (FileInfo, error) { + if bucket == d.bucket && key == d.key { + d.once.Do(d.beforeRead) + } + return d.StorageAPI.ReadVersion(ctx, orig, bucket, key, id, opts) +} + +func TestPoolsLifecycleBatchRejectionReplication(t *testing.T) { + for _, mode := range []string{"withdraw-before-validation", "withdraw-after-validation", "already-absent", "normal"} { + t.Run(mode, func(t *testing.T) { + s := newSplitILM(t, versioning.Enabled, splitILMRules) + const key = "ncv-dispatch-recheck" + s.write(key, splitTiered("old", 5, 0)) + s.write(key, splitAt("new", 3, 0)) + id := s.ids[key+" old"] + target := newILMTestTarget(t) + registerILMTestTarget(t, s.z, s.bucket, target, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || r.URL.Query().Get("versionId") != id { + t.Errorf("unexpected purge: %s %s", r.Method, r.URL) + w.WriteHeader(500) + return + } + target.deletes.Add(1) + opts := ObjectOptions{VersionID: r.URL.Query().Get("versionId"), Versioned: true, ReplicationRequest: true} + opts.SetReplicaStatus(replication.Replica) + if _, err := s.z.DeleteObject(r.Context(), target.bucket, key, opts); err != nil && !isErrVersionNotFound(err) && !isErrObjectNotFound(err) { + t.Error(err) + w.WriteHeader(500) + return + } + w.WriteHeader(http.StatusNoContent) + }) + setILMTestReplicationConfig(t, s.bucket, target.arn, target.bucket) + putConsistencyObject(t, s.z, target.bucket, key, 0, "replica", ObjectOptions{Versioned: true, VersionID: id}) + stats := NewReplicationStats(t.Context(), nil) + previous := globalReplicationStats.Swap(stats) + t.Cleanup(func() { globalReplicationStats.Store(previous) }) + restore, _ := newILMTestReplicationPool(t.Context(), s.z, stats) + t.Cleanup(restore) + oi, err := s.z.GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: id}) + if err != nil { + t.Fatal(err) + } + entry := ilmPurgeToDelete(t.Context(), s.bucket, oi, true) + if entry.VersionPurgeStatus != replication.VersionPurgePending { + t.Fatal("fixture lacks pending purge") + } + withdraw := func() { + meta, err := globalBucketMetadataSys.Get(s.bucket) + if err != nil { + t.Error(err) + return + } + meta.lifecycleConfig = nil + meta.LifecycleConfigXML = nil + globalBucketMetadataSys.Set(s.bucket, meta) + } + if mode == "withdraw-before-validation" { + withdraw() + } + originals := s.z.serverPools[0].getHashedSet(key).getDisks() + var once sync.Once + var scans atomic.Int64 + var dispatched atomic.Bool + for _, pool := range s.z.serverPools { + pool.erasureDisksMu.Lock() + prior := pool.erasureDisks + wrapped := make([][]StorageAPI, len(prior)) + for i, disks := range prior { + wrapped[i] = append([]StorageAPI(nil), disks...) + for j, disk := range disks { + wrapped[i][j] = lifecycleDispatchDisk{StorageAPI: disk, bucket: s.bucket, key: key, once: &once, scans: &scans, beforeRead: func() { + dispatched.Store(true) + if mode != "withdraw-before-validation" && scans.Load() == 0 { + t.Error("dispatch injection ran before all-pool validation") + } + switch mode { + case "withdraw-after-validation": + withdraw() + case "already-absent": + fi := FileInfo{VersionID: id} + fi.SetTierFreeVersionID(mustGetUUID()) + for _, disk := range originals { + if err := disk.DeleteVersion(t.Context(), s.bucket, key, fi, false, DeleteOptions{}); err != nil { + t.Error(err) + } + } + } + }} + } + } + pool.erasureDisks = wrapped + pool.erasureDisksMu.Unlock() + t.Cleanup(func() { pool.erasureDisksMu.Lock(); pool.erasureDisks = prior; pool.erasureDisksMu.Unlock() }) + } + result := &lifecycleBatchResult{ObjectLayer: s.z} + deleteObjectVersions(t.Context(), result, s.bucket, []ObjectToDelete{entry}, []lifecycle.Event{{Action: lifecycle.DeleteVersionAction}}) + if mode != "withdraw-before-validation" && !dispatched.Load() { + t.Fatal("dispatch injection did not run") + } + queue := globalReplicationPool.Get().workers[0] + reject := mode == "withdraw-before-validation" || mode == "withdraw-after-validation" + if reject { + if !errors.Is(result.errs[0], errLifecycleNotApplicable) || result.deleted[0].found || result.deleted[0].VersionPurgeStatus() != "" || result.deleted[0].ReplicationState.ReplicateDecisionStr != "" { + t.Errorf("rejection acknowledged a purge: %+v, %v", result.deleted[0], result.errs[0]) + } + if len(queue) != 0 { + t.Fatalf("rejected delete queued %d remote purges", len(queue)) + } + for _, bucket := range []string{s.bucket, target.bucket} { + oi, err := s.z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: id}) + if err != nil || oi.VersionPurgeStatus != "" { + t.Errorf("rejection changed %s: purge=%s err=%v", bucket, oi.VersionPurgeStatus, err) + } + } + if target.deletes.Load() != 0 { + t.Fatal("rejected delete reached remote target") + } + return + } + if result.errs[0] != nil || len(queue) != 1 { + t.Fatalf("valid/absent purge: error=%v queued=%d", result.errs[0], len(queue)) + } + source, err := s.z.GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: id}) + if mode == "normal" && (!isErrMethodNotAllowed(err) || source.VersionPurgeStatus != replication.VersionPurgePending) { + t.Fatalf("purge not committed locally: %+v %v", source, err) + } + if mode == "already-absent" && !isErrVersionNotFound(err) { + t.Fatalf("source must actually be absent: %v", err) + } + deletion := (<-queue).(DeletedObjectReplicationInfo) + if result := replicateDelete(t.Context(), deletion, s.z); result.VersionPurgeStatus() != replication.VersionPurgeComplete { + t.Fatalf("purge failed: %+v", result) + } + if target.deletes.Load() != 1 { + t.Fatalf("remote deletes=%d", target.deletes.Load()) + } + for _, bucket := range []string{s.bucket, target.bucket} { + if _, err := s.z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: id}); !isErrVersionNotFound(err) && !isErrObjectNotFound(err) { + t.Errorf("purged version remains in %s: %v", bucket, err) + } + } + }) + } +} diff --git a/cmd/erasure-server-pool-lifecycle.go b/cmd/erasure-server-pool-lifecycle.go index 16079e534..024c48c8a 100644 --- a/cmd/erasure-server-pool-lifecycle.go +++ b/cmd/erasure-server-pool-lifecycle.go @@ -27,6 +27,10 @@ import ( "github.com/minio/minio/internal/bucket/lifecycle" ) +// An ordinary ILM rejection is not evidence that the version is absent. In +// particular it must not acknowledge a pending replication purge. +var errLifecycleNotApplicable = errors.New("lifecycle expiry no longer applies") + // ILM decides on the versions of an object that one pool holds: the scanner // and the drive heal read the drives of one set, decommission and rebalance // walk one pool, and a GET or HEAD of a version reads the pool that holds it. @@ -113,7 +117,7 @@ func (z *erasureServerPools) lifecycleEventsAllPools(ctx context.Context, bucket lc, err := globalLifecycleSys.Get(bucket) if err != nil { if errors.Is(err, BucketLifecycleNotFound{Bucket: bucket}) { - return nil, nil, nil + return nil, nil, errLifecycleNotApplicable } return nil, nil, err } @@ -143,37 +147,45 @@ func (z *erasureServerPools) lifecycleEventsAllPools(ctx context.Context, bucket } } - var all []FileInfo - for _, versions := range poolVersions { - all = append(all, versions...) + vc, _ := globalBucketVersioningSys.Get(bucket) + versioned := vc != nil && vc.Versioned(decodeDirObject(object)) + var all []PoolObjInfo + for pool, versions := range poolVersions { + for _, fi := range versions { + all = append(all, PoolObjInfo{Index: pool, ObjInfo: fi.ToObjectInfo(bucket, object, versioned)}) + } } // Each pool lists its versions newest first. On equal times the lower // pool comes first, as reads choose it. - sort.SliceStable(all, func(i, j int) bool { return all[i].ModTime.After(all[j].ModTime) }) - seen := make(map[string]struct{}, len(all)) - versions := all[:0] - for _, fi := range all { - if _, ok := seen[fi.VersionID]; ok { + sort.SliceStable(all, func(i, j int) bool { return all[i].ObjInfo.ModTime.After(all[j].ObjInfo.ModTime) }) + seen := make(map[string]int, len(all)) + var versions [][]PoolObjInfo + for _, copy := range all { + if i, ok := seen[copy.ObjInfo.VersionID]; ok { + versions[i] = append(versions[i], copy) continue } - seen[fi.VersionID] = struct{}{} - versions = append(versions, fi) + seen[copy.ObjInfo.VersionID] = len(versions) + versions = append(versions, []PoolObjInfo{copy}) } - vc, _ := globalBucketVersioningSys.Get(bucket) - versioned := vc != nil && vc.Versioned(decodeDirObject(object)) objInfos := make([]ObjectInfo, len(versions)) objOpts := make([]lifecycle.ObjectOpts, len(versions)) - for i := range versions { - fi := versions[i] - fi.IsLatest = i == 0 - fi.NumVersions = len(versions) - fi.SuccessorModTime = time.Time{} - if i > 0 { - fi.SuccessorModTime = versions[i-1].ModTime + for i, copies := range versions { + oi := copies[0].ObjInfo + if len(copies) > 1 { + // Count the version once, but retain independently ordered lock + // and tag revisions, including explicit removals, from every copy. + oi = mergedPoolObjectInfo(copies) } - objInfos[i] = fi.ToObjectInfo(bucket, object, versioned) - objOpts[i] = objInfos[i].ToLifecycleOpts() + oi.IsLatest = i == 0 + oi.NumVersions = len(versions) + oi.SuccessorModTime = time.Time{} + if i > 0 { + oi.SuccessorModTime = objInfos[i-1].ModTime + } + objInfos[i] = oi + objOpts[i] = oi.ToLifecycleOpts() } events, err := lifecycle.NewEvaluator(*lc).WithLockRetention(&lr).WithReplicationConfig(rcfg).Eval(objOpts) if err != nil { @@ -200,8 +212,7 @@ func (z *erasureServerPools) checkExpiryAllPools(ctx context.Context, bucket, ob } // expiryApplies returns nil when the ILM expiry that opts describe applies -// to the versions, and otherwise the error a set returns for an expiry that -// no longer applies: +// to the versions. A missing version and a rejected expiry remain distinct: // - an expiry of all versions (DeletePrefix) needs the latest version to // expire with all versions; // - a delete marker (no VersionID) hides the latest version, which has to @@ -231,11 +242,14 @@ func (all lifecycleAllPools) expiryApplies(bucket, object string, opts ObjectOpt if all.events[i].Action == lifecycle.DeleteVersionAction { return nil } - break + return errLifecycleNotApplicable } } return VersionNotFound{Bucket: bucket, Object: name, VersionID: opts.VersionID} } + if len(all.versions) > 0 { + return errLifecycleNotApplicable + } return ObjectNotFound{Bucket: bucket, Object: name} }