diff --git a/cmd/erasure-server-pool-decom.go b/cmd/erasure-server-pool-decom.go
index 19e6a01e8..545602f32 100644
--- a/cmd/erasure-server-pool-decom.go
+++ b/cmd/erasure-server-pool-decom.go
@@ -799,7 +799,7 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
for setIdx, set := range pool.sets {
// filterLifecycle returns an ILM deletion that migration must handle
// before retiring the source copy, or NoneAction.
- filterLifecycle := func(bucket, object string, fi FileInfo) lifecycle.Action {
+ filterLifecycle := func(bucket, object string, fi FileInfo, history *migrationHistory) lifecycle.Action {
if lc == nil {
return lifecycle.NoneAction
}
@@ -819,7 +819,7 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
// a noncurrent version and has to be moved like any other. The
// scanner applies the expiry once it lives in another pool.
return lifecycle.NoneAction
- case evt.Action.Delete() && !z.migrationExpiryApplies(ctx, objInfo, evt):
+ case evt.Action.Delete() && !z.migrationExpiryApplies(ctx, objInfo, evt, history):
// Another pool holds versions with which the rule does not
// apply. The version moves like any other.
return lifecycle.NoneAction
@@ -848,10 +848,12 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
var decommissioned int
var expired []migrationExpiredVersion
+ // The key's history in all pools, read once for its versions.
+ var history migrationHistory
for _, version := range fivs.Versions {
stopFn := globalDecommissionMetrics.log(decomMetricDecommissionObject, idx, bi.Name, version.Name, version.VersionID)
// Apply lifecycle rules on the objects that are expired.
- if action := filterLifecycle(bi.Name, version.Name, version); action.Delete() {
+ if action := filterLifecycle(bi.Name, version.Name, version, &history); action.Delete() {
decommissioned++
if ilmDeletesVersionOnly(action) || (action.DeleteAll() && !version.IsRemote()) {
// Run the logical expiry before retiring the source.
diff --git a/cmd/erasure-server-pool-lifecycle.go b/cmd/erasure-server-pool-lifecycle.go
index 597cdf249..ab76c53c2 100644
--- a/cmd/erasure-server-pool-lifecycle.go
+++ b/cmd/erasure-server-pool-lifecycle.go
@@ -762,7 +762,13 @@ func recheckReadExpiry(ctx context.Context, objectAPI ObjectLayer, oi ObjectInfo
// of the object, and a delete marker alone in that pool can hide versions in
// another one. Such a version is moved like any other, and the scanner
// applies the rule in the pool that holds the object then.
-func (z *erasureServerPools) migrationExpiryApplies(ctx context.Context, oi ObjectInfo, evt lifecycle.Event) bool {
+//
+// The walk reads the history of a key once, into h, for the prechecks of all
+// its versions: this decides only whether a version moves or is left to the
+// expiry, which reads the history again under the object lock before it
+// deletes anything. A read error is kept, so every due version of that key
+// moves.
+func (z *erasureServerPools) migrationExpiryApplies(ctx context.Context, oi ObjectInfo, evt lifecycle.Event, h *migrationHistory) bool {
// The options the expiry deletes with, see
// applyExpiryOnNonTransitionedObjects and expireTransitionedObject.
opts := ObjectOptions{
@@ -780,6 +786,27 @@ func (z *erasureServerPools) migrationExpiryApplies(ctx context.Context, oi Obje
if !z.expiryNeedsAllPools(opts) {
return true
}
- _, err := z.checkExpiryAllPools(ctx, oi.Bucket, encodeDirObject(oi.Name), opts)
- return err == nil
+ object := encodeDirObject(oi.Name)
+ if h == nil {
+ h = &migrationHistory{}
+ }
+ if !h.loaded {
+ h.all.versions, h.all.events, h.all.err = z.lifecycleEventsAllPools(ctx, oi.Bucket, object)
+ h.loaded = true
+ if f := migrationHistoryLoaded; f != nil {
+ f(oi.Name)
+ }
+ }
+ return h.all.expiryApplies(oi.Bucket, object, opts) == nil
+}
+
+// migrationHistoryLoaded, when a test sets it, is told each time a migration
+// walk reads the history of a key.
+var migrationHistoryLoaded func(object string)
+
+// migrationHistory is the history of one key in all pools, read once by a
+// decommission or rebalance walk for the prechecks of its versions.
+type migrationHistory struct {
+ loaded bool
+ all lifecycleAllPools
}
diff --git a/cmd/erasure-server-pool-migration-precheck_test.go b/cmd/erasure-server-pool-migration-precheck_test.go
new file mode 100644
index 000000000..8c508c74f
--- /dev/null
+++ b/cmd/erasure-server-pool-migration-precheck_test.go
@@ -0,0 +1,61 @@
+// Copyright (c) 2026 PGSTY
+//
+// 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 (
+ "fmt"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/minio/minio/internal/bucket/versioning"
+)
+
+// The walk of a decommission or a rebalance prechecks every due version of a
+// key with the versions of the key in all pools. It reads that history once
+// per key, not once per version.
+func TestPoolsMigrationExpiryPrecheckReadsHistoryOnce(t *testing.T) {
+ for _, kind := range []string{"decommission", "rebalance"} {
+ t.Run(kind, func(t *testing.T) {
+ m := newMigrationILM(t, versioning.Enabled, false,
+ migrationILMRule("1"))
+ const key, versions = "obj-many", 64
+ for i := range versions {
+ m.put(key, fmt.Sprintf("v%02d", i), ObjectOptions{Versioned: true, MTime: UTCNow().AddDate(0, 0, -10).Add(-time.Duration(versions-i) * time.Minute)})
+ }
+ var mu sync.Mutex
+ loads := map[string]int{}
+ migrationHistoryLoaded = func(object string) {
+ mu.Lock()
+ loads[object]++
+ mu.Unlock()
+ }
+ t.Cleanup(func() { migrationHistoryLoaded = nil })
+
+ m.migrate(kind)
+
+ mu.Lock()
+ n := loads[key]
+ mu.Unlock()
+ if n != 1 {
+ t.Errorf("the walk read the history of %s %d times for %d due versions, want once", key, n, versions-1)
+ }
+ m.check(map[string][]string{key: {fmt.Sprintf("v%02d", versions-1)}})
+ })
+ }
+}
diff --git a/cmd/erasure-server-pool-rebalance.go b/cmd/erasure-server-pool-rebalance.go
index da1dd6a8e..49e10e584 100644
--- a/cmd/erasure-server-pool-rebalance.go
+++ b/cmd/erasure-server-pool-rebalance.go
@@ -590,7 +590,7 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
for setIdx, set := range pool.sets {
// filterLifecycle returns an ILM deletion that migration must handle
// before retiring the source copy, or NoneAction.
- filterLifecycle := func(bucket, object string, fi FileInfo) lifecycle.Action {
+ filterLifecycle := func(bucket, object string, fi FileInfo, history *migrationHistory) lifecycle.Action {
if lc == nil {
return lifecycle.NoneAction
}
@@ -605,7 +605,7 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
// a noncurrent version and has to be moved like any other. The
// scanner applies the expiry once it lives in another pool.
return lifecycle.NoneAction
- case evt.Action.Delete() && !z.migrationExpiryApplies(ctx, objInfo, evt):
+ case evt.Action.Delete() && !z.migrationExpiryApplies(ctx, objInfo, evt, history):
// Another pool holds versions with which the rule does not
// apply. The version moves like any other.
return lifecycle.NoneAction
@@ -638,6 +638,8 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
var rebalanced int
var expired []migrationExpiredVersion
+ // The key's history in all pools, read once for its versions.
+ var history migrationHistory
for _, version := range fivs.Versions {
stopFn := globalRebalanceMetrics.log(rebalanceMetricRebalanceObject, poolIdx, bucket, version.Name, version.VersionID)
@@ -648,7 +650,7 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
}
// Apply lifecycle rules on the objects that are expired.
- if action := filterLifecycle(bucket, version.Name, version); action.Delete() {
+ if action := filterLifecycle(bucket, version.Name, version, &history); action.Delete() {
// Count the version, or the source copies of the moved
// ones stay behind as duplicates. Its own source copy
// goes with them when ILM deletes it alone; any other