diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 6b7824a24..2bf27c930 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -102,7 +102,7 @@ jobs: run: go test -race ./cmd -run '^Test(Multipart|PaginateMultipartUploads|ListMultipartUploads|CleanupStaleUploads|ExtractModTime)' -count=1 -timeout=5m - name: Run listing metadata recovery, pagination and cancellation tests under race detector - run: go test -race ./cmd -run '^Test(ListFallback|ListingCache|ListingMerge|ListingRecovery|ListingPending|ListingEntry|ListingSecondLock|StreamMetadataParts|LocalLockerListing|DistributedListing|MergeXLV2LatestNullCrossParity|CountXLV2MixedECGroups|ListCrossParity)' -count=1 -timeout=5m + run: go test -race ./cmd -run '^Test(ListFallback|ListingCache|ListingMerge|ListingRecovery|ListingPending|ListingEntry|ListingSecondLock|StreamMetadataParts|LocalLockerListing|DistributedListing|MergeXLV2LatestNullCrossParity|CountXLV2MixedECGroups|ListCrossParity|ListObjectVersionsCrossParityNull|MigrationCrossParity)' -count=1 -timeout=5m - name: Run lock cancellation and late-grant cleanup tests under race detector run: go test -race ./internal/dsync ./internal/lsync -run '^Test(DRWMutex|LRWMutex)' -count=1 -timeout=5m diff --git a/cmd/data-scanner.go b/cmd/data-scanner.go index 0475c4a53..13d0b99f0 100644 --- a/cmd/data-scanner.go +++ b/cmd/data-scanner.go @@ -776,7 +776,7 @@ func (f *folderScanner) scanFolder(ctx context.Context, folder cachedFolder, int } // We got an entry which we should be able to heal. - fiv, err := entry.fileInfoVersions(bucket) + fiv, err := entry.allFileInfoVersions(bucket) if err != nil { err := bgSeq.queueHealTask(healSource{ bucket: bucket, diff --git a/cmd/erasure-server-pool-decom.go b/cmd/erasure-server-pool-decom.go index e9bd3330b..d5a557451 100644 --- a/cmd/erasure-server-pool-decom.go +++ b/cmd/erasure-server-pool-decom.go @@ -997,6 +997,9 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool if decommissioned == len(fivs.Versions) { stopFn := globalDecommissionMetrics.log(decomMetricDecommissionRemoveObject, idx, bi.Name, entry.name) err := z.removeMovedVersions(ctx, bi.Name, entry.name, idx, fivs.Versions, expired) + if err == nil { + err = z.removeFreeVersions(ctx, bi.Name, entry.name, idx, fivs.FreeVersions) + } stopFn(0, err) auditLogDecom(ctx, "DecomDeleteObject", bi.Name, entry.name, "", err) if err != nil { @@ -1123,6 +1126,45 @@ func (z *erasureServerPools) decommissionInBackground(ctx context.Context, idx i return nil } +// removeFreeVersions removes the free versions fvs of object from the pool +// idx being decommissioned, as the scanner removes free versions: first the +// remote object of each, then the free version. A free version is not moved. +// In another pool it would be a delete marker on top of the object, and once +// this pool is gone nothing would remove its remote object. A remote object +// that is already gone is not an error. +func (z *erasureServerPools) removeFreeVersions(ctx context.Context, bucket, object string, idx int, fvs []FileInfo) error { + if len(fvs) == 0 { + return nil + } + for _, fv := range fvs { + err := deleteObjectFromRemoteTier(ctx, fv.TransitionedObjName, fv.TransitionVersionID, fv.TransitionTier) + if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) { + return fmt.Errorf("unable to remove the remote object %s of a free version of %s/%s from tier %s: %w", + fv.TransitionedObjName, bucket, object, fv.TransitionTier, err) + } + } + object = encodeDirObject(object) + lk := z.NewNSLock(bucket, object) + lkctx, err := lk.GetLock(ctx, globalOperationTimeout) + if err != nil { + return err + } + ctx = lkctx.Context() + defer lk.Unlock(lkctx) + for _, fv := range fvs { + _, err := z.serverPools[idx].DeleteObject(ctx, bucket, object, ObjectOptions{ + VersionID: fv.VersionID, + InclFreeVersions: true, + NoLock: true, + NoAuditLog: true, + }) + if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) { + return err + } + } + return nil +} + func (z *erasureServerPools) checkAfterDecom(ctx context.Context, idx int) error { buckets, err := z.getBucketsToDecommission(ctx) if err != nil { @@ -1210,7 +1252,9 @@ func (z *erasureServerPools) checkAfterDecom(ctx context.Context, idx int) error } } - versionsFound += len(fivs.Versions) - ignored + // A free version left behind keeps a remote object that + // nothing removes once the pool is gone. + versionsFound += len(fivs.Versions) - ignored + len(fivs.FreeVersions) }); err != nil { return err } 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..507e56f3a --- /dev/null +++ b/cmd/erasure-server-pool-free-version_test.go @@ -0,0 +1,526 @@ +// 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" + + madmin "github.com/minio/madmin-go/v3" + "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 +} + +// versions labels the versions of a listing as "