From f321f7f1c7196b60709fe293ea2be81b6392ab33 Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Wed, 4 Nov 2026 16:37:42 +0800 Subject: [PATCH] fix(ilm): do not announce batch expiries the recheck rejected The expiry worker now asks DeleteObjects to recheck ILM against all pools. A rejected entry keeps its version and returns errLifecycleNotApplicable, but the worker still wrote an ILMExpiry audit record, an ILM trace and an s3:ObjectRemoved:Delete event for it. Event delivery drops the error element, so consumers saw an ordinary removal, repeated on every scan while the versions stay split across pools. Skip rejected entries; eligible entries of the same batch are still reported. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Feng Ruohang --- ...rasure-server-pool-lifecycle-batch_test.go | 56 +++++++++++++++++++ cmd/object-handlers-common.go | 6 ++ 2 files changed, 62 insertions(+) diff --git a/cmd/erasure-server-pool-lifecycle-batch_test.go b/cmd/erasure-server-pool-lifecycle-batch_test.go index b2e838078..363a8768a 100644 --- a/cmd/erasure-server-pool-lifecycle-batch_test.go +++ b/cmd/erasure-server-pool-lifecycle-batch_test.go @@ -23,9 +23,12 @@ import ( "net/http" "testing" + "github.com/minio/madmin-go/v3" "github.com/minio/minio/internal/bucket/lifecycle" "github.com/minio/minio/internal/bucket/replication" "github.com/minio/minio/internal/bucket/versioning" + "github.com/minio/minio/internal/event" + "github.com/minio/minio/internal/pubsub" ) // Reuse the author's scanner/worker fixture. A second configured pool is @@ -212,3 +215,56 @@ func TestPoolsLifecycleTieredBatchIdentity(t *testing.T) { }) } } + +// A batch entry whose expiry the all-pools recheck rejects is kept. The worker +// must not report it as removed, by event, ILM trace or audit, while eligible +// entries of the same batch are still reported. +func TestPoolsLifecycleBatchRejectionNotAnnounced(t *testing.T) { + s := newSplitILM(t, versioning.Enabled, splitILMRules) + const key = "nnv-announce" + for _, v := range []splitVersion{splitTiered("v1", 5, 0), splitTiered("v2", 4, 0), splitTiered("v3", 3, 0), splitAt("v4", 2, 0)} { + s.write(key, v) + } + eligible, retained := s.ids[key+" v1"], s.ids[key+" v3"] + done := make(chan struct{}) + defer close(done) + events := make(chan event.Event, 8) + if err := globalHTTPListen.Subscribe(pubsub.MaskFromMaskable(event.ObjectRemovedDelete), events, done, nil); err != nil { + t.Fatal(err) + } + traces := make(chan madmin.TraceInfo, 8) + if err := globalTrace.Subscribe(madmin.TraceILM, traces, done, nil); err != nil { + t.Fatal(err) + } + result := &lifecycleBatchResult{ObjectLayer: s.z} + deleteObjectVersions(t.Context(), result, s.bucket, []ObjectToDelete{ + {ObjectV: ObjectV{ObjectName: key, VersionID: eligible}}, + {ObjectV: ObjectV{ObjectName: key, VersionID: retained}}, + }, []lifecycle.Event{{Action: lifecycle.DeleteVersionAction}, {Action: lifecycle.DeleteVersionAction}}) + if result.errs[0] != nil || !errors.Is(result.errs[1], errLifecycleNotApplicable) { + t.Fatalf("fixture results: eligible=%v retained=%v", result.errs[0], result.errs[1]) + } + if _, err := s.z.GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: retained, Versioned: true}); err != nil { + t.Fatalf("rejected version must remain: %v", err) + } + // Both publishers deliver synchronously, so everything sent is buffered. + var notified, traced []string + for drained := false; !drained; { + select { + case ev := <-events: + notified = append(notified, ev.S3.Object.VersionID) + case tr := <-traces: + if tr.FuncName == ILMExpiry { + traced = append(traced, tr.Custom["version-id"]) + } + default: + drained = true + } + } + if len(notified) != 1 || notified[0] != eligible { + t.Errorf("removal events for %v, want only eligible %s (retained %s)", notified, eligible, retained) + } + if len(traced) != 1 || traced[0] != eligible { + t.Errorf("ILM expiry traces for %v, want only eligible %s (retained %s)", traced, eligible, retained) + } +} diff --git a/cmd/object-handlers-common.go b/cmd/object-handlers-common.go index acd7ebcb2..903e82731 100644 --- a/cmd/object-handlers-common.go +++ b/cmd/object-handlers-common.go @@ -19,6 +19,7 @@ package cmd import ( "context" + "errors" "fmt" "net/http" "regexp" @@ -471,6 +472,11 @@ func deleteObjectVersions(ctx context.Context, o ObjectLayer, bucket string, toD }) for i, dobj := range deletedObjs { + if errors.Is(errs[i], errLifecycleNotApplicable) { + // The recheck kept this version. Do not audit or announce a + // removal that did not happen; the next scan re-evaluates it. + continue + } oi := ObjectInfo{ Bucket: bucket, Name: dobj.ObjectName,