Integrate R1 adversarial review repairs

Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
Feng Ruohang
2026-11-03 22:45:14 +08:00
6 changed files with 416 additions and 29 deletions
+2 -2
View File
@@ -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) { func applyExpiryOnTransitionedObject(ctx context.Context, objLayer ObjectLayer, obj ObjectInfo, lcEvent lifecycle.Event, src lcEventSrc) (ok bool) {
timeILM := globalScannerMetrics.timeILM(lcEvent.Action) timeILM := globalScannerMetrics.timeILM(lcEvent.Action)
if err := expireTransitionedObject(ctx, objLayer, &obj, lcEvent, src); err != nil { 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 return false
} }
ilmLogIf(ctx, fmt.Errorf("expireTransitionedObject(%s, %s): %w", obj.Bucket, obj.Name, err)) 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) dobj, err = objLayer.DeleteObject(ctx, obj.Bucket, encodeDirObject(obj.Name), opts)
if err != nil { if err != nil {
if isErrObjectNotFound(err) || isErrVersionNotFound(err) { if errors.Is(err, errLifecycleNotApplicable) || isErrObjectNotFound(err) || isErrVersionNotFound(err) {
traceFn(ILMExpiry, nil, nil) traceFn(ILMExpiry, nil, nil)
return false return false
} }
+9
View File
@@ -2045,6 +2045,9 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
// expiration attempted on a bucket with no lifecycle // expiration attempted on a bucket with no lifecycle
// rules shall be rejected. // rules shall be rejected.
if lc == nil && opts.Expiration.Expire { if lc == nil && opts.Expiration.Expire {
if !opts.Transition.ExpireRestored {
return objInfo, errLifecycleNotApplicable
}
if opts.VersionID != "" { if opts.VersionID != "" {
return objInfo, VersionNotFound{ return objInfo, VersionNotFound{
Bucket: bucket, Bucket: bucket,
@@ -2089,6 +2092,9 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
isErr = true isErr = true
} }
if isErr { if isErr {
if !opts.Transition.ExpireRestored {
return goi, errLifecycleNotApplicable
}
if goi.VersionID != "" { if goi.VersionID != "" {
return goi, VersionNotFound{ return goi, VersionNotFound{
Bucket: bucket, Bucket: bucket,
@@ -2164,6 +2170,9 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
isErr = true isErr = true
} }
if isErr { if isErr {
if !opts.Transition.ExpireRestored {
return goi, errLifecycleNotApplicable
}
if goi.VersionID != "" { if goi.VersionID != "" {
return goi, VersionNotFound{ return goi, VersionNotFound{
Bucket: bucket, Bucket: bucket,
+6
View File
@@ -19,6 +19,7 @@ package cmd
import ( import (
"context" "context"
"errors"
"maps" "maps"
"sort" "sort"
"strings" "strings"
@@ -769,6 +770,11 @@ func (z *erasureServerPools) deleteObjectsReconciled(ctx context.Context, bucket
deleteOpts.Versioned = opts.PrefixEnabledFn(obj.ObjectName) deleteOpts.Versioned = opts.PrefixEnabledFn(obj.ObjectName)
} }
_, err := z.deleteObjectReconciled(ctx, bucket, obj.ObjectName, deleteOpts) _, 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) { if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
errs[i] = err errs[i] = err
} else { } else {
@@ -19,6 +19,7 @@ package cmd
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"testing" "testing"
@@ -118,7 +119,7 @@ func TestPoolsLifecycleTieredBatchReplication(t *testing.T) {
if result.errs[0] != nil { if result.errs[0] != nil {
t.Errorf("eligible delete: %v", result.errs[0]) 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]) t.Errorf("retained version result: %v", result.errs[1])
} }
if result.deleted[1].VersionPurgeStatus() != "" || result.deleted[1].ReplicationState.ReplicateDecisionStr != "" { if result.deleted[1].VersionPurgeStatus() != "" || result.deleted[1].ReplicationState.ReplicateDecisionStr != "" {
@@ -192,7 +193,11 @@ func TestPoolsLifecycleTieredBatchIdentity(t *testing.T) {
} }
vc, _ := globalBucketVersioningSys.Get(s.bucket) 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}}) _, 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]) t.Fatalf("delete: %v", errs[0])
} }
query := vid query := vid
@@ -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 <http://www.gnu.org/licenses/>.
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 = `<Rule><ID>tagged</ID><Status>Enabled</Status><Filter><Tag><Key>expire</Key><Value>yes</Value></Tag></Filter><NoncurrentVersionExpiration><NoncurrentDays>1</NoncurrentDays></NoncurrentVersionExpiration></Rule>`
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)
}
}
})
}
}
+39 -25
View File
@@ -27,6 +27,10 @@ import (
"github.com/minio/minio/internal/bucket/lifecycle" "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 // 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 // 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. // 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) lc, err := globalLifecycleSys.Get(bucket)
if err != nil { if err != nil {
if errors.Is(err, BucketLifecycleNotFound{Bucket: bucket}) { if errors.Is(err, BucketLifecycleNotFound{Bucket: bucket}) {
return nil, nil, nil return nil, nil, errLifecycleNotApplicable
} }
return nil, nil, err return nil, nil, err
} }
@@ -143,37 +147,45 @@ func (z *erasureServerPools) lifecycleEventsAllPools(ctx context.Context, bucket
} }
} }
var all []FileInfo vc, _ := globalBucketVersioningSys.Get(bucket)
for _, versions := range poolVersions { versioned := vc != nil && vc.Versioned(decodeDirObject(object))
all = append(all, versions...) 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 // Each pool lists its versions newest first. On equal times the lower
// pool comes first, as reads choose it. // pool comes first, as reads choose it.
sort.SliceStable(all, func(i, j int) bool { return all[i].ModTime.After(all[j].ModTime) }) sort.SliceStable(all, func(i, j int) bool { return all[i].ObjInfo.ModTime.After(all[j].ObjInfo.ModTime) })
seen := make(map[string]struct{}, len(all)) seen := make(map[string]int, len(all))
versions := all[:0] var versions [][]PoolObjInfo
for _, fi := range all { for _, copy := range all {
if _, ok := seen[fi.VersionID]; ok { if i, ok := seen[copy.ObjInfo.VersionID]; ok {
versions[i] = append(versions[i], copy)
continue continue
} }
seen[fi.VersionID] = struct{}{} seen[copy.ObjInfo.VersionID] = len(versions)
versions = append(versions, fi) versions = append(versions, []PoolObjInfo{copy})
} }
vc, _ := globalBucketVersioningSys.Get(bucket)
versioned := vc != nil && vc.Versioned(decodeDirObject(object))
objInfos := make([]ObjectInfo, len(versions)) objInfos := make([]ObjectInfo, len(versions))
objOpts := make([]lifecycle.ObjectOpts, len(versions)) objOpts := make([]lifecycle.ObjectOpts, len(versions))
for i := range versions { for i, copies := range versions {
fi := versions[i] oi := copies[0].ObjInfo
fi.IsLatest = i == 0 if len(copies) > 1 {
fi.NumVersions = len(versions) // Count the version once, but retain independently ordered lock
fi.SuccessorModTime = time.Time{} // and tag revisions, including explicit removals, from every copy.
if i > 0 { oi = mergedPoolObjectInfo(copies)
fi.SuccessorModTime = versions[i-1].ModTime
} }
objInfos[i] = fi.ToObjectInfo(bucket, object, versioned) oi.IsLatest = i == 0
objOpts[i] = objInfos[i].ToLifecycleOpts() 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) events, err := lifecycle.NewEvaluator(*lc).WithLockRetention(&lr).WithReplicationConfig(rcfg).Eval(objOpts)
if err != nil { 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 // 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 // to the versions. A missing version and a rejected expiry remain distinct:
// no longer applies:
// - an expiry of all versions (DeletePrefix) needs the latest version to // - an expiry of all versions (DeletePrefix) needs the latest version to
// expire with all versions; // expire with all versions;
// - a delete marker (no VersionID) hides the latest version, which has to // - 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 { if all.events[i].Action == lifecycle.DeleteVersionAction {
return nil return nil
} }
break return errLifecycleNotApplicable
} }
} }
return VersionNotFound{Bucket: bucket, Object: name, VersionID: opts.VersionID} return VersionNotFound{Bucket: bucket, Object: name, VersionID: opts.VersionID}
} }
if len(all.versions) > 0 {
return errLifecycleNotApplicable
}
return ObjectNotFound{Bucket: bucket, Object: name} return ObjectNotFound{Bucket: bucket, Object: name}
} }