Files
minio/cmd/erasure-server-pool-lifecycle_test.go
T
Feng Ruohang 1bd0b1b04d fix(ilm): do not replicate deletes that lifecycle makes
The migration follow-up for #265 (PR #276) made lifecycle-created
deletes replication-aware: the scanner, the expiry of a transitioned
version and the bulk noncurrent purge evaluated the delete replication
decision and queued a delete marker or a version purge for the targets.
That is the content of the still-open PR #240. It changes behavior for
every bucket whose replication rule replicates deletes or delete
markers, the mc default: a target with longer retention loses versions
on the source's schedule, and a purge keeps its space as a pending
tombstone until the target confirms. SILO, like S3, does not replicate
lifecycle deletes; an opt-in can come later. The release did not
replicate them.

Remove exactly the replication parts: the decision callbacks and the
post-delete scheduling in applyExpiryOnNonTransitionedObjects and
expireTransitionedObject, the scheduling in deleteObjectVersions, and
ilmPurgeToDelete; the scanner builds noncurrent entries as before. The
rest of #276 stays: the hot-tier delete before the remote one, the
free-version retry record, the lifecycle-change fence and the migration
completion checks. API deletes still replicate, and the replication heal
still handles persisted pending markers and purges. As in the release,
the replication heal also resyncs an empty-state marker when
existing-object replication applies to it.

The delete-all variant of the scheduling, which queued a task with an
empty decision, goes with it.

Tests: the scanner's noncurrent purge, run with replication configured,
deletes the version at once and queues nothing; an ILM marker carries
no replication state and the heal leaves it unless existing-object
replication applies, in which case it is resynced as before; an API
version delete still queues a purge. The cross-pool batch, dispatch and
decommission tests now assert no replication, and a decommission whose
expiry used to wait for the target completes in one run. Adding back the
scanner's decision makes the first two fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-06 18:53:27 +08:00

504 lines
20 KiB
Go

// Copyright (c) 2026 Denis Babchuk
//
// 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 (
"fmt"
"io"
"slices"
"strings"
"testing"
"time"
"github.com/minio/minio/internal/bucket/lifecycle"
"github.com/minio/minio/internal/bucket/versioning"
)
// The scanner and the walks of decommission and rebalance evaluate ILM on the
// versions of a key in one pool: IsLatest, NumVersions and SuccessorModTime
// count only these. A key can have versions in several pools: two concurrent
// first writes, overwrites that went to another pool. The expiry they decide
// runs in every pool.
// splitILM lays out the versions of keys in the two pools of migrationILM.
type splitILM struct {
*migrationILM
ids map[string]string // object and label -> version ID
bodies map[string]string // body -> label
// replication is the replication configuration the scanner runs with.
replication replicationConfig
}
func newSplitILM(t *testing.T, status versioning.State, rules string) *splitILM {
t.Helper()
return &splitILM{migrationILM: newMigrationILM(t, status, false, rules), ids: map[string]string{}, bodies: map[string]string{}}
}
// splitVersion is a version of a key in a test layout.
type splitVersion struct {
pools []int // the pools that hold a copy of it
label string
days float64 // its age
marker bool
tiered bool // its data is in a remote tier
null bool // a null version (unversioned or suspended bucket)
size int // its size, at least the size of its label
}
func (s *splitILM) write(object string, v splitVersion) {
s.t.Helper()
ctx := s.t.Context()
mtime := UTCNow().Add(-time.Duration(v.days * float64(24*time.Hour)))
versionID := mustGetUUID()
if v.null {
versionID = ""
}
for _, pool := range v.pools {
if v.marker {
_, err := s.z.serverPools[pool].DeleteObject(ctx, s.bucket, object,
ObjectOptions{Versioned: true, VersionID: versionID, DeleteMarker: true, MTime: mtime})
if err != nil {
s.t.Fatal(err)
}
continue
}
opts := ObjectOptions{Versioned: !v.null, VersionID: versionID, MTime: mtime}
if v.null && s.status() == versioning.Suspended {
opts = ObjectOptions{VersionSuspended: true, MTime: mtime}
}
body := object + " " + v.label
if len(body) < v.size {
body += strings.Repeat(".", v.size-len(body))
}
putConsistencyObject(s.t, s.z, s.bucket, object, pool, body, opts)
s.bodies[body] = v.label
if v.tiered {
vid := versionID
if vid == "" {
vid = nullVersionID
}
infos, errs := readAllFileInfo(ctx, s.z.serverPools[pool].getHashedSet(object).getDisks(), "", s.bucket, object, vid, false, false)
if errs[0] != nil {
s.t.Fatal(errs[0])
}
fi := infos[0]
fi.TransitionStatus, fi.TransitionTier, fi.TransitionedObjName = lifecycle.TransitionComplete, "WARM", "remote-"+v.label
if err := s.z.serverPools[pool].DecomTieredObject(ctx, s.bucket, object, fi, ObjectOptions{}); err != nil {
s.t.Fatal(err)
}
}
}
if v.marker {
s.markers[s.key(object, versionID)] = v.label
} else {
s.labels[s.key(object, versionID)] = v.label
}
s.ids[object+" "+v.label] = versionID
s.track(object)
}
func (s *splitILM) status() versioning.State {
vc, _ := globalBucketVersioningSys.Get(s.bucket)
if vc == nil {
return ""
}
return vc.Status
}
// scan applies ILM to every object in a pool as the scanner of a drive of
// that pool does, from the xl.meta of that drive, and runs the queued
// expiries as an expiry worker does.
func (s *splitILM) scan(pool int) {
s.t.Helper()
ctx := s.t.Context()
lc, err := globalLifecycleSys.Get(s.bucket)
if err != nil {
s.t.Fatal(err)
}
lr, err := globalBucketObjectLockSys.Get(s.bucket)
if err != nil {
s.t.Fatal(err)
}
vc, _ := globalBucketVersioningSys.Get(s.bucket)
for _, object := range s.objects {
disk := s.z.serverPools[pool].getHashedSet(object).getDisks()[0]
rf, err := disk.ReadXL(ctx, s.bucket, object, false)
if err != nil {
continue // not in this pool
}
fivs, err := getFileInfoVersions(rf.Buf, s.bucket, object, false)
if err != nil {
s.t.Fatal(err)
}
objInfos := make([]ObjectInfo, len(fivs.Versions))
for i, fi := range fivs.Versions {
objInfos[i] = fi.ToObjectInfo(s.bucket, object, vc != nil && vc.Versioned(object))
}
item := scannerItem{bucket: s.bucket, objectName: object, lifeCycle: lc, replication: s.replication}
item.applyActions(ctx, s.z, objInfos, lr, &sizeSummary{}, nil)
}
s.runQueue()
}
// head applies ILM to a version as a HEAD or GET with its versionId does.
func (s *splitILM) head(object, label string) lifecycle.Action {
s.t.Helper()
ctx := s.t.Context()
oi, err := s.z.GetObjectInfo(ctx, s.bucket, object, ObjectOptions{VersionID: s.ids[object+" "+label]})
if err != nil {
s.t.Fatal(err)
}
lc, err := globalLifecycleSys.Get(s.bucket)
if err != nil {
s.t.Fatal(err)
}
lr, err := globalBucketObjectLockSys.Get(s.bucket)
if err != nil {
s.t.Fatal(err)
}
evt := evalActionFromLifecycle(ctx, *lc, lr, nil, oi)
if evt.Action.Delete() {
applyExpiryRule(evt, lcEventSrc_s3HeadObject, oi)
}
s.runQueue()
return evt.Action
}
// runQueue runs the queued expiries with an expiry worker.
func (s *splitILM) runQueue() {
done := make(chan struct{})
go func() {
defer close(done)
globalExpiryState.Worker(s.queue)
}()
s.queue <- nil // the worker returns once it ran every expiry queued before
<-done
}
// get returns the label of the version a GET of object reads, or "-" when it
// reads as deleted.
func (s *splitILM) get(object string) string {
s.t.Helper()
ctx := s.t.Context()
oi, err := s.z.GetObjectInfo(ctx, s.bucket, object, ObjectOptions{})
if err != nil {
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isErrMethodNotAllowed(err) {
return "-"
}
return fmt.Sprintf("<%v>", err)
}
if oi.VersionID != "" && oi.VersionID != nullVersionID {
return s.label(object, oi.VersionID, false)
}
// Copies of a null version in two pools are told apart by their data.
gr, err := s.z.GetObjectNInfo(ctx, s.bucket, object, nil, nil, ObjectOptions{})
if err != nil {
return fmt.Sprintf("<%v>", err)
}
defer gr.Close()
data, err := io.ReadAll(gr)
if err != nil {
s.t.Fatal(err)
}
if l, ok := s.bodies[string(data)]; ok {
return l
}
return "?" + string(data)
}
// views returns the versions of each object in each pool, newest first.
func (s *splitILM) views() []map[string][]string {
views := make([]map[string][]string, len(s.z.serverPools))
for pool := range s.z.serverPools {
views[pool] = s.view(pool)
}
return views
}
type splitKey struct {
name string
versions []splitVersion
before string // what a GET reads before ILM ran
after string // what a GET reads once ILM ran
want [][]string // versions in each pool once ILM ran, nil to skip
}
// run lays out keys, runs two scanner cycles over all pools and checks that
// a GET of each key never reads anything but before or after, and the
// versions each pool holds at the end. The second cycle must not change
// anything.
func (s *splitILM) run(keys []splitKey) {
s.t.Helper()
for _, k := range keys {
for _, v := range k.versions {
s.write(k.name, v)
}
if got := s.get(k.name); got != k.before {
s.t.Fatalf("%s: fixture reads %s, want %s", k.name, got, k.before)
}
}
var first []map[string][]string
for cycle := 1; cycle <= 2; cycle++ {
for pool := range s.z.serverPools {
s.scan(pool)
for _, k := range keys {
if got := s.get(k.name); got != k.before && got != k.after {
s.t.Errorf("%s: reads %s after the scanner of pool %d (cycle %d), want %s", k.name, got, pool, cycle, k.after)
}
}
}
if cycle == 1 {
first = s.views()
}
}
last := s.views()
for _, k := range keys {
if got := s.get(k.name); got != k.after {
s.t.Errorf("%s: reads %s once ILM ran, want %s", k.name, got, k.after)
}
for pool := range last {
if !slices.Equal(first[pool][k.name], last[pool][k.name]) {
s.t.Errorf("%s: the second scanner cycle changed pool %d from %v to %v", k.name, pool, first[pool][k.name], last[pool][k.name])
}
if k.want != nil && !slices.Equal(last[pool][k.name], k.want[pool]) {
s.t.Errorf("%s: pool %d holds %v, want %v", k.name, pool, last[pool][k.name], k.want[pool])
}
}
}
}
const splitILMRules = "<Rule><ID>exp</ID><Status>Enabled</Status><Filter><Prefix>exp-</Prefix></Filter>" +
"<Expiration><Days>1</Days></Expiration></Rule>" +
"<Rule><ID>eav</ID><Status>Enabled</Status><Filter><Prefix>eav-</Prefix></Filter>" +
"<Expiration><Days>1</Days><ExpiredObjectAllVersions>true</ExpiredObjectAllVersions></Expiration></Rule>" +
"<Rule><ID>edm</ID><Status>Enabled</Status><Filter><Prefix>edm-</Prefix></Filter>" +
"<Expiration><ExpiredObjectDeleteMarker>true</ExpiredObjectDeleteMarker></Expiration></Rule>" +
"<Rule><ID>dme</ID><Status>Enabled</Status><Filter><Prefix>dme-</Prefix></Filter>" +
"<DelMarkerExpiration><Days>1</Days></DelMarkerExpiration></Rule>" +
"<Rule><ID>ncv</ID><Status>Enabled</Status><Filter><Prefix>ncv-</Prefix></Filter>" +
"<NoncurrentVersionExpiration><NoncurrentDays>1</NoncurrentDays></NoncurrentVersionExpiration></Rule>" +
"<Rule><ID>exs</ID><Status>Enabled</Status><Filter><And><Prefix>exs-</Prefix><ObjectSizeGreaterThan>100</ObjectSizeGreaterThan></And></Filter>" +
"<Expiration><Days>1</Days></Expiration></Rule>" +
"<Rule><ID>nnv</ID><Status>Enabled</Status><Filter><Prefix>nnv-</Prefix></Filter>" +
"<NoncurrentVersionExpiration><NoncurrentDays>1</NoncurrentDays><NewerNoncurrentVersions>1</NewerNoncurrentVersions></NoncurrentVersionExpiration></Rule>"
func splitAt(label string, days float64, pools ...int) splitVersion {
return splitVersion{pools: pools, label: label, days: days}
}
func splitMarker(label string, days float64, pools ...int) splitVersion {
return splitVersion{pools: pools, label: label, days: days, marker: true}
}
func splitTiered(label string, days float64, pools ...int) splitVersion {
return splitVersion{pools: pools, label: label, days: days, tiered: true}
}
func splitNull(label string, days float64, pools ...int) splitVersion {
return splitVersion{pools: pools, label: label, days: days, null: true}
}
// splitILMOnePool are keys in one pool. ILM applies to them as usual.
func splitILMOnePool(pool int) []splitKey {
in := func(vs ...string) [][]string {
want := [][]string{nil, nil}
want[pool] = vs
return want
}
return []splitKey{
{"exp-one", []splitVersion{splitAt("v1", 5, pool), splitAt("v2", 4, pool)}, "v2", "-", in("DM", "v2", "v1")},
{"exp-one-tiered", []splitVersion{splitAt("v1", 5, pool), splitTiered("v2", 4, pool)}, "v2", "-", in("DM", "v2", "v1")},
{"eav-one", []splitVersion{splitAt("v1", 5, pool), splitAt("v2", 4, pool)}, "v2", "-", in()},
{"edm-one", []splitVersion{splitMarker("dm", 3, pool)}, "-", "-", in()},
{"dme-one", []splitVersion{splitAt("v1", 5, pool), splitMarker("dm", 3, pool)}, "-", "-", in()},
{"ncv-one", []splitVersion{splitAt("v1", 5, pool), splitAt("v2", 4, pool), splitAt("v3", 0.01, pool)}, "v3", "v3", in("v3", "v2")},
{"nnv-one", []splitVersion{splitAt("v1", 5, pool), splitAt("v2", 4, pool), splitAt("v3", 3, pool), splitAt("v4", 2, pool)}, "v4", "v4", in("v4", "v3")},
}
}
func TestPoolsLifecycleSplitObject(t *testing.T) {
t.Run("versioned", func(t *testing.T) {
s := newSplitILM(t, versioning.Enabled, splitILMRules)
s.run([]splitKey{
// Expiration with Days applies to the current version v2 only.
{"exp-old-in-0", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"v1"}, {"v2"}}},
{"exp-old-in-1", []splitVersion{splitAt("v2", 0.01, 0), splitAt("v1", 5, 1)}, "v2", "v2", [][]string{{"v2"}, {"v1"}}},
{"exp-tiered-old-in-0", []splitVersion{splitTiered("v1", 5, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"v1"}, {"v2"}}},
// v2 is old enough: one delete marker hides the key.
{"exp-both-old", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 4, 1)}, "v2", "-", [][]string{{"DM", "v1"}, {"v2"}}},
// The rule applies to v2 only by its size.
{"exs-big-in-1", []splitVersion{splitAt("v1", 5, 0), {pools: []int{1}, label: "v2", days: 4, size: 200}}, "v2", "-", [][]string{{"DM", "v1"}, {"v2"}}},
// The marker hides v1 and is not a marker without noncurrent
// versions; v1 is not the current version.
{"exp-marker-in-0", []splitVersion{splitAt("v1", 5, 1), splitMarker("dm", 3, 0)}, "-", "-", [][]string{{"dm"}, {"v1"}}},
// ExpiredObjectAllVersions applies to the current version v2 only.
{"eav-old-in-0", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"v1"}, {"v2"}}},
{"eav-old-in-1", []splitVersion{splitAt("v2", 0.01, 0), splitAt("v1", 5, 1)}, "v2", "v2", [][]string{{"v2"}, {"v1"}}},
{"eav-tiered-old-in-0", []splitVersion{splitTiered("v1", 5, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"v1"}, {"v2"}}},
{"eav-both-old", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 4, 1)}, "v2", "-", [][]string{nil, nil}},
// A copy of v1 in both pools, as an interrupted move leaves it.
{"eav-copies", []splitVersion{splitAt("v1", 5, 0, 1)}, "v1", "-", [][]string{nil, nil}},
// The marker hides v1 in the other pool.
{"edm-marker-in-0", []splitVersion{splitAt("v1", 5, 1), splitMarker("dm", 3, 0)}, "-", "-", [][]string{{"dm"}, {"v1"}}},
{"edm-marker-in-1", []splitVersion{splitAt("v1", 5, 0), splitMarker("dm", 3, 1)}, "-", "-", [][]string{{"v1"}, {"dm"}}},
{"edm-copies", []splitVersion{splitMarker("dm", 3, 0, 1)}, "-", "-", [][]string{nil, nil}},
// v2 in the other pool is the current version, the marker does
// not expire.
{"dme-new-in-1", []splitVersion{splitMarker("dm", 3, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"dm"}, {"v2"}}},
{"dme-new-in-0", []splitVersion{splitAt("v2", 0.01, 0), splitMarker("dm", 3, 1)}, "v2", "v2", [][]string{{"v2"}, {"dm"}}},
// The marker is the current version and expires with v1.
{"dme-old-in-0", []splitVersion{splitAt("v1", 5, 0), splitMarker("dm", 3, 1)}, "-", "-", [][]string{nil, nil}},
// v1 is noncurrent since v2 was written, v2 since v3 was.
{"ncv-split", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 4, 1), splitAt("v3", 2, 0)}, "v3", "v3", [][]string{{"v3"}, {"v2"}}},
})
})
t.Run("versioned/one-pool", func(t *testing.T) {
for pool := range 2 {
s := newSplitILM(t, versioning.Enabled, splitILMRules)
s.run(splitILMOnePool(pool))
}
})
t.Run("versioned/head-version", func(t *testing.T) {
s := newSplitILM(t, versioning.Enabled, splitILMRules)
keys := []splitKey{
{"exp-old-in-0", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"v1"}, {"v2"}}},
{"eav-old-in-0", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"v1"}, {"v2"}}},
}
for _, k := range keys {
for _, v := range k.versions {
s.write(k.name, v)
}
act := s.head(k.name, "v1")
t.Logf("%s: HEAD of v1 evaluates %s", k.name, act)
if got := s.get(k.name); got != k.after {
t.Errorf("%s: reads %s after a HEAD of v1, want %s", k.name, got, k.after)
}
views := s.views()
for pool := range views {
if !slices.Equal(views[pool][k.name], k.want[pool]) {
t.Errorf("%s: pool %d holds %v, want %v", k.name, pool, views[pool][k.name], k.want[pool])
}
}
}
})
t.Run("suspended", func(t *testing.T) {
s := newSplitILM(t, versioning.Suspended, splitILMRules)
s.run([]splitKey{
{"exp-null-old-in-0", []splitVersion{splitNull("v1", 5, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"v1"}, {"v2"}}},
})
})
t.Run("unversioned", func(t *testing.T) {
s := newSplitILM(t, "", splitILMRules)
s.run([]splitKey{
{"eav-old-in-0", []splitVersion{splitNull("old", 5, 0), splitNull("new", 0.01, 1)}, "new", "new", nil},
{"exp-old-in-0", []splitVersion{splitNull("old", 5, 0), splitNull("new", 0.01, 1)}, "new", "new", nil},
})
views := s.views()
for object, want := range map[string]int{"eav-old-in-0": 1, "exp-old-in-0": 0} {
if len(views[0][object]) != want || len(views[1][object]) != 1 {
t.Errorf("%s: pools hold %v and %v, want %d and 1 copies", object, views[0][object], views[1][object], want)
}
}
})
}
// walk moves pool 0 out with a decommission or a rebalance pass, as move
// does, but reports a decommission that did not complete instead of stopping
// the test, so the reads are still checked.
func (s *splitILM) walk(kind string) {
s.t.Helper()
ctx := s.t.Context()
if kind == "rebalance" {
migrationTestWalk(s.t, s.z, s.bucket, 0, kind)
s.z.rebalMu.Lock()
s.z.rebalMeta.PoolStats[0].Info.Status = rebalCompleted
s.z.rebalMu.Unlock()
return
}
if err := s.z.StartDecommission(ctx, 0); err != nil {
s.t.Fatal(err)
}
s.z.doDecommissionInRoutine(ctx, 0)
s.z.poolMetaMutex.RLock()
info := *s.z.poolMeta.Pools[0].Decommission
s.z.poolMetaMutex.RUnlock()
if !info.Complete || info.Failed || info.ItemsDecommissionFailed > 0 {
s.t.Errorf("decommission did not complete: complete=%t failed=%t itemsFailed=%d; pool 0 holds %v",
info.Complete, info.Failed, info.ItemsDecommissionFailed, s.view(0))
}
}
// Rules that expire a whole object (DelMarkerExpiration,
// ExpiredObjectAllVersions) or a delete marker without noncurrent versions
// (ExpiredObjectDeleteMarker) depend on every version of the object. A walk
// sees the versions in its own pool only.
func TestPoolsMigrationLifecycleWholeObject(t *testing.T) {
recent := 1.0 / 24
objects := []struct {
name string
versions []splitVersion // oldest first
before string // what a GET reads before the walk
after string // what a GET reads once ILM caught up
want []string // versions in pool 1 once ILM caught up, newest first
}{
// The marker expires and takes v1 with it.
{"dme-one-pool", []splitVersion{splitAt("v1", 5, 0), splitMarker("dm", 3, 0)}, "-", "-", nil},
{"dme-older-in-pool-1", []splitVersion{splitAt("v1", 5, 1), splitMarker("dm", 3, 0)}, "-", "-", nil},
// v2 is the current version, the marker does not expire.
{"dme-newer-in-pool-1", []splitVersion{splitMarker("dm", 3, 0), splitAt("v2", recent, 1)}, "v2", "v2", []string{"v2", "dm"}},
// v2 expires and takes v1 with it.
{"eav-one-pool", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 4, 0)}, "v2", "-", nil},
{"eav-older-in-pool-1", []splitVersion{splitAt("v1", 5, 1), splitAt("v2", 4, 0)}, "v2", "-", nil},
// v2 is the current version and does not expire.
{"eav-newer-in-pool-1", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", recent, 1)}, "v2", "v2", []string{"v2", "v1"}},
// The marker hides v1, it is not a marker without noncurrent versions.
{"edm-older-in-pool-1", []splitVersion{splitAt("v1", 5, 1), splitMarker("dm", 3, 0)}, "-", "-", []string{"dm", "v1"}},
{"keep-marker", []splitVersion{splitAt("v1", 5, 0), splitMarker("dm", 3, 0)}, "-", "-", []string{"dm", "v1"}},
{"keep-versions", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 4, 0)}, "v2", "v2", []string{"v2", "v1"}},
}
for _, kind := range []string{"decommission", "rebalance"} {
t.Run(kind, func(t *testing.T) {
s := newSplitILM(t, versioning.Enabled, splitILMRules)
for _, o := range objects {
for _, v := range o.versions {
s.write(o.name, v)
}
if got := s.get(o.name); got != o.before {
t.Fatalf("%s: fixture reads %s, want %s", o.name, got, o.before)
}
}
s.walk(kind)
for _, o := range objects {
if got := s.get(o.name); got != o.before && got != o.after {
t.Errorf("%s: reads %s after the %s, before ILM caught up; want %s or %s", o.name, got, kind, o.before, o.after)
}
}
s.runQueue()
s.scan(1)
want := map[string][]string{}
for _, o := range objects {
want[o.name] = o.want
if got := s.get(o.name); got != o.after {
t.Errorf("%s: reads %s once ILM caught up, want %s", o.name, got, o.after)
}
}
s.check(want)
})
}
}