diff --git a/cmd/erasure-server-pool-free-version_test.go b/cmd/erasure-server-pool-free-version_test.go new file mode 100644 index 000000000..345fc7082 --- /dev/null +++ b/cmd/erasure-server-pool-free-version_test.go @@ -0,0 +1,256 @@ +// 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 . + +package cmd + +import ( + "context" + "errors" + "slices" + "testing" + "time" + + "github.com/minio/minio/internal/bucket/lifecycle" + objectlock "github.com/minio/minio/internal/bucket/object/lock" +) + +// Deleting a version whose data is in a remote tier leaves a free version in +// its place: a record of the remote object, kept until the scanner removes +// both. A free version is not a version of the object. There is no delete +// marker, and the version below it is the latest one; reads already skip it. + +// freeVersionNoncurrent expires versions one day after they become noncurrent. +const freeVersionNoncurrent = "noncurrentEnabled" + + "1" + +// freeVersionTier is a remote tier held in memory whose removals can fail. +type freeVersionTier struct { + *tierExpiryTestBackend + removeErr error +} + +func (b *freeVersionTier) Remove(ctx context.Context, object string, rv remoteVersionID) error { + if b.removeErr != nil { + return b.removeErr + } + return b.tierExpiryTestBackend.Remove(ctx, object, rv) +} + +type freeVersionTest struct { + t *testing.T + z *erasureServerPools + bucket string + tier *freeVersionTier + labels map[string]string // version ID -> label +} + +// newFreeVersionTest returns two pools with a versioned bucket under rules and +// a remote tier WARM. In pool 0, obj-01 has v1 and v2 and a tiered v3, written +// five, four and three days ago; the client deleted v3. obj-02 had only a +// tiered v1, deleted as well. +func newFreeVersionTest(t *testing.T, rules string) *freeVersionTest { + t.Helper() + z, _ := migrationTestPools(t) + ctx := t.Context() + f := &freeVersionTest{t: t, z: z, bucket: "free-version", labels: map[string]string{}} + if err := z.MakeBucket(ctx, f.bucket, MakeBucketOptions{VersioningEnabled: true}); err != nil { + t.Fatal(err) + } + if rules != "" { + meta, err := globalBucketMetadataSys.Get(f.bucket) + if err != nil { + t.Fatal(err) + } + meta.LifecycleConfigXML = []byte("" + rules + "") + if err := meta.Save(ctx, z); err != nil { + t.Fatal(err) + } + globalBucketMetadataSys.Set(f.bucket, meta) + } + f.tier = &freeVersionTier{tierExpiryTestBackend: &tierExpiryTestBackend{objects: map[string][]byte{}}} + prevTier := globalTierConfigMgr + globalTierConfigMgr = NewTierConfigMgr() + globalTierConfigMgr.drivercache["WARM"] = f.tier + t.Cleanup(func() { globalTierConfigMgr = prevTier }) + + now := UTCNow() + f.put("obj-01", "v1", now.AddDate(0, 0, -5)) + f.put("obj-01", "v2", now.AddDate(0, 0, -4)) + f.deleteVersion(f.tiered("obj-01", "v3", now.AddDate(0, 0, -3))) + f.deleteVersion(f.tiered("obj-02", "v1", now.AddDate(0, 0, -3))) + return f +} + +// put writes a version of object to pool 0. +func (f *freeVersionTest) put(object, label string, mtime time.Time) ObjectInfo { + f.t.Helper() + oi := putConsistencyObject(f.t, f.z, f.bucket, object, 0, object+" "+label, ObjectOptions{Versioned: true, MTime: mtime}) + f.labels[oi.VersionID] = label + return oi +} + +// tiered writes a version of object to pool 0 whose data is in the tier. +func (f *freeVersionTest) tiered(object, label string, mtime time.Time) ObjectInfo { + f.t.Helper() + oi := f.put(object, label, mtime) + infos, errs := readAllFileInfo(f.t.Context(), f.z.serverPools[0].getHashedSet(object).getDisks(), "", f.bucket, object, oi.VersionID, false, false) + if errs[0] != nil { + f.t.Fatal(errs[0]) + } + fi := infos[0] + fi.TransitionStatus, fi.TransitionTier, fi.TransitionedObjName = lifecycle.TransitionComplete, "WARM", f.remote(object, label) + f.tier.objects[fi.TransitionedObjName] = []byte(object + " " + label) + if err := f.z.serverPools[0].DecomTieredObject(f.t.Context(), f.bucket, object, fi, ObjectOptions{}); err != nil { + f.t.Fatal(err) + } + return oi +} + +// remote is the name of the remote object of a tiered version. +func (f *freeVersionTest) remote(object, label string) string { + return f.bucket + "/" + object + "/" + label +} + +// deleteVersion deletes a tiered version by its ID, as a client does, which +// leaves a free version in its place. +func (f *freeVersionTest) deleteVersion(oi ObjectInfo) { + f.t.Helper() + if _, err := f.z.DeleteObject(f.t.Context(), f.bucket, oi.Name, ObjectOptions{Versioned: true, VersionID: oi.VersionID}); err != nil { + f.t.Fatal(err) + } + if got := f.stored(0, oi.Name); len(got) == 0 || got[0] != "free" { + f.t.Fatalf("fixture: %s holds %v, want a free version on top", oi.Name, got) + } +} + +func (f *freeVersionTest) label(fi FileInfo) string { + switch { + case fi.TierFreeVersion(): + return "free" + case fi.Deleted: + return "DM" + case f.labels[fi.VersionID] != "": + return f.labels[fi.VersionID] + } + return "?" +} + +// xlMeta reads the metadata of object in pool from its first drive, nil if +// the pool has none. +func (f *freeVersionTest) xlMeta(pool int, object string) []byte { + f.t.Helper() + disk := f.z.serverPools[pool].getHashedSet(object).getDisks()[0] + buf, err := disk.ReadAll(f.t.Context(), f.bucket, pathJoin(object, xlStorageFormatFile)) + if errors.Is(err, errFileNotFound) { + return nil + } + if err != nil { + f.t.Fatal(err) + } + return buf +} + +// stored returns what pool holds of object, newest first, free versions +// included. +func (f *freeVersionTest) stored(pool int, object string) []string { + f.t.Helper() + buf := f.xlMeta(pool, object) + if buf == nil { + return nil + } + fivs, err := getFileInfoVersions(buf, f.bucket, object, true) + if err != nil { + f.t.Fatal(err) + } + var got []string + for _, fi := range fivs.Versions { + got = append(got, f.label(fi)) + } + return got +} + +// expiryQueue replaces the expiry workers with a queue for the test. +func (f *freeVersionTest) expiryQueue() chan expiryOp { + queue := make(chan expiryOp, 100) + es := &expiryState{ctx: f.t.Context(), objAPI: f.z} + workers := []chan expiryOp{queue} + es.workers.Store(&workers) + prev := globalExpiryState + globalExpiryState = es + f.t.Cleanup(func() { globalExpiryState = prev }) + return queue +} + +// expired returns the labels of the versions queued for expiry. +func (f *freeVersionTest) expired(queue chan expiryOp) []string { + var got []string + for len(queue) > 0 { + switch task := (<-queue).(type) { + case expiryTask: + got = append(got, f.labels[task.objInfo.VersionID]) + case noncurrentVersionsTask: + for _, v := range task.versions { + got = append(got, f.labels[v.VersionID]) + } + } + } + return got +} + +// The scanner reads the versions of obj-01 without the free version. v2 is the +// latest version, and a noncurrent expiry must not count it as noncurrent +// since the deleted v3 was written. Otherwise the scanner deletes it. +func TestFreeVersionScannerLatestVersion(t *testing.T) { + f := newFreeVersionTest(t, freeVersionNoncurrent) + fivs, err := getFileInfoVersions(f.xlMeta(0, "obj-01"), f.bucket, "obj-01", false) + if err != nil { + t.Fatal(err) + } + var got []string + for _, fi := range fivs.Versions { + got = append(got, f.label(fi)) + } + if want := []string{"v2", "v1"}; !slices.Equal(got, want) || len(fivs.FreeVersions) != 1 { + t.Fatalf("scanner versions %v with %d free versions, want %v with 1", got, len(fivs.FreeVersions), want) + } + v2, v1 := fivs.Versions[0], fivs.Versions[1] + if !v2.IsLatest || !v2.SuccessorModTime.IsZero() { + t.Errorf("v2: latest %v, successor mod time %v; want latest without a successor", v2.IsLatest, v2.SuccessorModTime) + } + if v1.IsLatest || !v1.SuccessorModTime.Equal(v2.ModTime) { + t.Errorf("v1: latest %v, successor mod time %v; want noncurrent since v2 at %v", v1.IsLatest, v1.SuccessorModTime, v2.ModTime) + } + if !fivs.LatestModTime.Equal(v2.ModTime) { + t.Errorf("latest mod time %v, want that of v2 %v", fivs.LatestModTime, v2.ModTime) + } + + // Apply the rules to these versions as the scanner does. + lc, err := globalLifecycleSys.Get(f.bucket) + if err != nil { + t.Fatal(err) + } + objInfos := make([]ObjectInfo, len(fivs.Versions)) + for i, fi := range fivs.Versions { + objInfos[i] = fi.ToObjectInfo(f.bucket, "obj-01", true) + } + queue := f.expiryQueue() + item := scannerItem{Path: "obj-01", bucket: f.bucket, objectName: "obj-01", lifeCycle: lc} + item.applyActions(t.Context(), f.z, objInfos, objectlock.Retention{}, &sizeSummary{}, nil) + if expired, want := f.expired(queue), []string{"v1"}; !slices.Equal(expired, want) { + t.Errorf("scanner queued %v for expiry, want %v", expired, want) + } +} diff --git a/cmd/xl-storage-format-utils.go b/cmd/xl-storage-format-utils.go index 13c6836e6..feeefc52b 100644 --- a/cmd/xl-storage-format-utils.go +++ b/cmd/xl-storage-format-utils.go @@ -19,6 +19,7 @@ package cmd import ( "errors" + "time" "github.com/zeebo/xxh3" ) @@ -28,6 +29,8 @@ import ( // - fivs.FreeVersions has all the free versions // // if inclFreeVersions is true all the versions are in fivs.Versions, free and non-free versions alike. +// Otherwise IsLatest, SuccessorModTime and LatestModTime are those of the +// non-free versions. // // Note: Only the scanner requires fivs.Versions to have exclusively non-free versions. This is used while enforcing NewerNoncurrentVersions lifecycle element. func getFileInfoVersions(xlMetaBuf []byte, volume, path string, inclFreeVersions bool) (FileInfoVersions, error) { @@ -56,6 +59,19 @@ func getFileInfoVersions(xlMetaBuf []byte, volume, path string, inclFreeVersions } if !inclFreeVersions { fivs.Versions = fivs.Versions[:n] + // A free version is not a version of the object. The latest + // version, and the successor of each other version, is among + // the rest. + for i := range fivs.Versions { + fivs.Versions[i].IsLatest = i == 0 + fivs.Versions[i].SuccessorModTime = time.Time{} + if i > 0 { + fivs.Versions[i].SuccessorModTime = fivs.Versions[i-1].ModTime + } + } + if n > 0 { + fivs.LatestModTime = fivs.Versions[0].ModTime + } } // Update numversions for i := range fivs.Versions {