// Copyright (c) 2026 Feng Ruohang // // This file is part of Silo Object Storage. // // 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 ( "bytes" "context" "io" "os" "path/filepath" "strconv" "strings" "sync" "testing" "time" "github.com/minio/minio/internal/cachevalue" "github.com/minio/minio/internal/config/drive" xhttp "github.com/minio/minio/internal/http" xioutil "github.com/minio/minio/internal/ioutil" ) func mutationPools(t testing.TB) (*erasureServerPools, string) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) dirs, err := getRandomDisks(8) if err != nil { t.Fatal(err) } endpoints := mustGetPoolEndpoints(0, dirs[:8]...) obj, _, err := initObjectLayer(ctx, endpoints) if err != nil { cancel() removeRoots(dirs) t.Fatal(err) } z := obj.(*erasureServerPools) old := newObjectLayerFn() setObjectLayer(z) t.Cleanup(func() { cancel(); z.Shutdown(context.Background()); removeRoots(dirs); setObjectLayer(old) }) bucket := "scope-probe" if err := z.MakeBucket(ctx, bucket, MakeBucketOptions{}); err != nil { t.Fatal(err) } return z, bucket } func mutationPut(t testing.TB, z *erasureServerPools, bucket, obj string, pool int, body string, when time.Time) { t.Helper() _, err := z.serverPools[pool].PutObject(context.Background(), bucket, obj, mustGetPutObjReader(t, bytes.NewBufferString(body), int64(len(body)), "", ""), ObjectOptions{MTime: when}) if err != nil { t.Fatal(err) } } type mutationAmbiguousRename struct { StorageAPI index int entered chan<- struct{} release <-chan struct{} } func (d mutationAmbiguousRename) RenameData(ctx context.Context, sv, sp string, fi FileInfo, dv, dp string, o RenameOptions) (RenameDataResp, error) { if d.index >= 5 { return RenameDataResp{}, errDiskNotFound } r, e := d.StorageAPI.RenameData(ctx, sv, sp, fi, dv, dp, o) d.entered <- struct{}{} <-d.release if d.index == 4 && e == nil { return r, context.DeadlineExceeded } return r, e } // A physical fifth commit loses its reply. B successfully updates tags on all // five copies of A. A then undoes four known successes. B's tag update retains // the DataDir and ModTime, so the proposed ownership predicate cannot see it. func TestMutationRollbackPreservesAcknowledgedTags(t *testing.T) { z, bucket := mutationPools(t) const object = "tag-undo" base := time.Now().Add(-time.Hour) mutationPut(t, z, bucket, object, 0, "initial", base) set := z.serverPools[0].getHashedSet(object) disks, swap := installListingDiskSwitch(set) defer swap(disks) entered, release := make(chan struct{}, 5), make(chan struct{}) var once sync.Once unblock := func() { once.Do(func() { close(release) }) } defer unblock() wrapped := append([]StorageAPI(nil), disks...) for i, d := range disks { wrapped[i] = mutationAmbiguousRename{d, i, entered, release} } swap(wrapped) aDone := make(chan error, 1) go func() { body := "pending-A" _, e := set.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, strings.NewReader(body), int64(len(body)), "", ""), ObjectOptions{NoLock: true, MTime: base.Add(time.Minute)}) aDone <- e }() for range 5 { select { case <-entered: case <-time.After(5 * time.Second): t.Fatal("A did not finish physical commits") } } swap(disks) const tags = "owner=acknowledged-B" if _, err := set.PutObjectTags(t.Context(), bucket, object, tags, ObjectOptions{NoLock: true}); err != nil { t.Fatalf("B tags failed: %v", err) } count := func() int { n := 0 for _, d := range disks { fi, e := d.ReadVersion(t.Context(), "", bucket, object, "", ReadOptions{}) if e == nil && fi.Metadata[xhttp.AmzObjectTagging] == tags { n++ } } return n } if n := count(); n != 5 { t.Fatalf("B did not update all five A copies: %d", n) } t.Log("B tag update acknowledged on five physical copies; fifth A reply will report timeout") unblock() select { case e := <-aDone: if e == nil { t.Fatal("A unexpectedly succeeded") } t.Logf("A result=%v", e) case <-time.After(5 * time.Second): t.Fatal("A did not finish") } if n := count(); n != 5 { t.Errorf("acknowledged tags lost physical copies: got %d, want 5", n) } oi, e := set.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}) if e != nil { t.Fatal(e) } if oi.UserTags != tags { t.Fatalf("version-scoped rollback lost acknowledged tag update: tags=%q", oi.UserTags) } } type delayedRenameResult struct { StorageAPI fail bool entered chan<- struct{} release <-chan struct{} } func (d delayedRenameResult) RenameData(ctx context.Context, sv, sp string, fi FileInfo, dv, dp string, opts RenameOptions) (RenameDataResp, error) { if d.fail { return RenameDataResp{}, errDiskNotFound } r, err := d.StorageAPI.RenameData(ctx, sv, sp, fi, dv, dp, opts) d.entered <- struct{}{} <-d.release // Delay the reply, after the physical mutation and local unlock. return r, err } // One pool, eight real disk directories, no edited object metadata. A commits // on four disks (below write quorum five); its replies are delayed. Model a lost // namespace lease by letting B execute with NoLock, like the previous lease // fixture. B adds an explicit version and succeeds on seven disks. A then // fails quorum and rolls back. A must not erase the separately acknowledged B. func TestMutationRollbackPreservesConcurrentVersion(t *testing.T) { z, bucket := mutationPools(t) const object = "rollback-and-version" size, _ := strconv.Atoi(os.Getenv("SILO_TEST_MUTATION_BYTES")) payload := func(v string) string { if size > len(v) { return v + strings.Repeat(".", size-len(v)) } return v } base := time.Now().Add(-time.Hour) mutationPut(t, z, bucket, object, 0, payload("initial-null"), base) set := z.serverPools[0].getHashedSet(object) disks, swap := installListingDiskSwitch(set) defer swap(disks) entered, release := make(chan struct{}, 4), make(chan struct{}) var once sync.Once unblock := func() { once.Do(func() { close(release) }) } defer unblock() aDisks := append([]StorageAPI(nil), disks...) for i, d := range disks { aDisks[i] = delayedRenameResult{d, i >= 4, entered, release} } swap(aDisks) aDone := make(chan error, 1) go func() { body := payload("pending-null-A") _, err := z.serverPools[0].PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, strings.NewReader(body), int64(len(body)), "", ""), ObjectOptions{NoLock: true, MTime: base.Add(time.Minute)}) aDone <- err }() for range 4 { select { case <-entered: case err := <-aDone: t.Fatalf("A returned early: %v", err) case <-time.After(5 * time.Second): t.Fatal("A did not reach the reply barrier") } } t.Log("A physically committed on four disks; replies held; other four RenameData calls failed") bDisks := append([]StorageAPI(nil), disks...) bDisks[7] = nil swap(bDisks) bVersion := mustGetUUID() body := payload("acknowledged-version-B") _, err := z.serverPools[0].PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, strings.NewReader(body), int64(len(body)), "", ""), ObjectOptions{NoLock: true, Versioned: true, VersionID: bVersion, MTime: base.Add(2 * time.Minute)}) if err != nil { t.Fatalf("B failed: %v", err) } countB := func() int { n := 0 for _, d := range disks { if _, e := d.ReadVersion(t.Context(), "", bucket, object, bVersion, ReadOptions{}); e == nil { n++ } } return n } if n := countB(); n != 7 { t.Fatalf("B was not committed on the expected seven drives: %d", n) } t.Log("B succeeded on seven drives and its version is present on all seven") unblock() select { case err := <-aDone: if err == nil { t.Fatal("A unexpectedly succeeded") } t.Logf("A failed and rolled back: %v", err) case <-time.After(5 * time.Second): t.Fatal("A did not finish") } swap(disks) t.Logf("B metadata survives on %d of eight drives after A's rollback", countB()) if n := countB(); n != 7 { t.Fatalf("unrelated rollback removed B's acknowledged copies: got %d, want 7", n) } r, err := z.GetObjectNInfo(t.Context(), bucket, object, nil, nil, ObjectOptions{VersionID: bVersion}) if err != nil { t.Fatalf("acknowledged B became unreadable after unrelated rollback: %v", err) } defer r.Close() got, err := io.ReadAll(r) if err != nil || !bytes.Equal(got, []byte(body)) { t.Fatalf("B payload changed: readErr=%v length=%d", err, len(got)) } } func TestMutationDeleteMarkerDoesNotReenter(t *testing.T) { disk, _, err := newXLStorageTestSetup(t) if err != nil { t.Fatal(err) } defer disk.Close() if err := disk.MakeVol(t.Context(), "bucket"); err != nil { t.Fatal(err) } fi := FileInfo{Volume: "bucket", Name: "absent", VersionID: mustGetUUID(), Deleted: true, ModTime: UTCNow()} done := make(chan error, 1) go func() { done <- disk.storage.DeleteVersion(t.Context(), "bucket", "absent", fi, true, DeleteOptions{}) }() select { case err := <-done: if err != nil { t.Fatal(err) } case <-time.After(2 * time.Second): t.Fatal("DeleteVersion(forceDelMarker) blocked acquiring the same guard again through WriteMetadata") } } // Cleanup can arrive after an RPC reply is lost while the real disk commit is // still running. Both single and bulk cleanup must retain its staged metadata. func TestMutationStagingCleanupWaitsForCommit(t *testing.T) { for _, bulk := range []bool{false, true} { t.Run(strconv.FormatBool(bulk), func(t *testing.T) { disk, root, err := newXLStorageTestSetup(t) if err != nil { t.Fatal(err) } defer disk.Close() if err := disk.MakeVol(t.Context(), "bucket"); err != nil { t.Fatal(err) } var lastSuccess int64 gate := &stagedRenameContext{ Context: context.Background(), meta: filepath.Join(root, minioMetaTmpBucket, "staged", xlStorageFormatFile), entered: make(chan struct{}), release: make(chan struct{}), tracker: healthDiskCtxValue{lastSuccess: &lastSuccess}, } unblock := sync.OnceFunc(func() { close(gate.release) }) defer unblock() fi := newFileInfo("object", 2, 2) fi.DataDir, fi.ModTime, fi.Erasure.Index = mustGetUUID(), UTCNow(), 1 fi.Fresh = true committed := make(chan error, 1) go func() { _, err := disk.RenameData(gate, minioMetaTmpBucket, "staged", fi, "bucket", "object", RenameOptions{}) committed <- err }() select { case <-gate.entered: case <-time.After(5 * time.Second): t.Fatal("commit did not reach staging barrier") } cleaned := make(chan error, 1) go func() { if bulk { cleaned <- disk.DeleteBulk(t.Context(), minioMetaTmpBucket, pathJoin("staged", xlStorageFormatFile)) } else { cleaned <- disk.Delete(t.Context(), minioMetaTmpBucket, "staged", DeleteOptions{Recursive: true}) } }() early := false select { case err := <-cleaned: early = true t.Errorf("cleanup overtook admitted disk commit: %v", err) case <-time.After(100 * time.Millisecond): } unblock() select { case err := <-committed: if err != nil { t.Fatal(err) } case <-time.After(5 * time.Second): t.Fatal("commit did not finish") } if !early { select { case <-cleaned: case <-time.After(5 * time.Second): t.Fatal("cleanup did not finish") } } got, err := disk.ReadVersion(t.Context(), "", "bucket", "object", "", ReadOptions{}) if err != nil || got.DataDir != fi.DataDir || !got.ModTime.Equal(fi.ModTime) { t.Fatalf("cleanup lost committed metadata: got=%+v err=%v", got, err) } }) } } type reviewReadGate struct { StorageAPI ready chan<- struct{} release <-chan struct{} } func (d reviewReadGate) ReadXL(ctx context.Context, volume, path string, data bool) (RawFileInfo, error) { r, err := d.StorageAPI.ReadXL(ctx, volume, path, data) d.ready <- struct{}{} if d.release != nil { <-d.release } return r, err } func TestReviewFastReadAlreadyWaitsForSlowDisk(t *testing.T) { z, bucket := mutationPools(t) const object = "healthy-slow-read" mutationPut(t, z, bucket, object, 0, "healthy-inline", time.Now()) set := z.serverPools[0].getHashedSet(object) disks, swap := installListingDiskSwitch(set) defer swap(disks) ready, release := make(chan struct{}, len(disks)), make(chan struct{}) var once sync.Once unblock := func() { once.Do(func() { close(release) }) } defer unblock() wrapped := append([]StorageAPI(nil), disks...) for i, d := range disks { g := reviewReadGate{StorageAPI: d, ready: ready} if i == len(disks)-1 { g.release = release } wrapped[i] = g } swap(wrapped) done := make(chan error, 1) go func() { _, _, _, err := set.getObjectFileInfo(t.Context(), bucket, object, ObjectOptions{FastGetObjInfo: true}, true) done <- err }() for range disks { select { case <-ready: case <-time.After(5 * time.Second): t.Fatal("reads did not reach barrier") } } select { case err := <-done: t.Fatalf("read returned before the final disk: %v", err) case <-time.After(100 * time.Millisecond): t.Log("seven healthy replies are insufficient to return; final unbuffered mrfCheck send is still waiting") } unblock() select { case err := <-done: if err != nil { t.Fatal(err) } case <-time.After(5 * time.Second): t.Fatal("read did not finish after slow disk release") } } // Pause the real trash worker at its disk-space callback, after moving the // external shards but before DeleteVersion can replace xl.meta. An outer join // must also contain this nested worker, and later mutations must remain excluded. func TestMutationDeleteJoinsNestedWorker(t *testing.T) { z, bucket := mutationPools(t) const object = "nested-timeout" mutationPut(t, z, bucket, object, 0, strings.Repeat("x", 2<<20), time.Now()) disk := z.serverPools[0].getHashedSet(object).getDisks()[0].(*xlStorageDiskIDCheck) fi, err := disk.ReadVersion(t.Context(), "", bucket, object, "", ReadOptions{}) if err != nil { t.Fatal(err) } entered, release := make(chan struct{}), make(chan struct{}) var once sync.Once unblock := func() { once.Do(func() { close(release) }) } defer unblock() oldCache := disk.storage.diskInfoCache disk.storage.diskInfoCache = cachevalue.NewFromFunc(time.Hour, cachevalue.Opts{}, func(context.Context) (DiskInfo, error) { close(entered) <-release return DiskInfo{Used: 100, Free: 100, UsedInodes: 100, FreeInodes: 100}, nil }) defer func() { disk.storage.diskInfoCache = oldCache }() oldTimeout := globalDriveConfig.GetMaxTimeout() globalDriveConfig.Update(drive.Config{MaxTimeout: 1200 * time.Millisecond}) defer globalDriveConfig.Update(drive.Config{MaxTimeout: oldTimeout}) done := make(chan error, 1) go func() { _, e := xioutil.WithDeadlineAndWait(context.Background(), globalDriveConfig.GetMaxTimeout(), func(ctx context.Context) (struct{}, error) { return struct{}{}, disk.storage.DeleteVersion(ctx, bucket, object, fi, false, DeleteOptions{UndoWrite: true}) }) done <- e }() select { case <-entered: case <-time.After(5 * time.Second): t.Fatal("trash callback not reached") } early := false select { case e := <-done: early = true t.Errorf("outer join returned before nested disk worker completed: %v", e) case <-time.After(1500 * time.Millisecond): } updated := make(chan error, 1) go func() { updated <- disk.storage.UpdateMetadata(context.Background(), bucket, object, FileInfo{VersionID: fi.VersionID, Metadata: map[string]string{"after-timeout": "yes"}}, UpdateMetadataOpts{}) }() writerEarly := false select { case e := <-updated: writerEarly = true t.Errorf("later metadata writer passed unfinished deletion: %v", e) case <-time.After(100 * time.Millisecond): } unblock() if !early { select { case <-done: case <-time.After(5 * time.Second): t.Fatal("delete did not finish") } } if !writerEarly { select { case <-updated: case <-time.After(5 * time.Second): t.Fatal("later writer did not finish") } } } // Even an update that leaves all public fields unchanged is a later successful // operation. An old writer must not regain undo ownership through an ABA cycle. func TestMutationNoopUpdateRevokesUndo(t *testing.T) { for _, method := range []string{"update", "write", "aba"} { t.Run(method, func(t *testing.T) { z, bucket := mutationPools(t) const object = "no-op-update" mutationPut(t, z, bucket, object, 0, "acknowledged", time.Now()) disks := z.serverPools[0].getHashedSet(object).getDisks() for _, disk := range disks { fi, err := disk.ReadVersion(t.Context(), "", bucket, object, "", ReadOptions{ReadData: true}) if err != nil { t.Fatal(err) } switch method { case "update": err = disk.UpdateMetadata(t.Context(), bucket, object, fi, UpdateMetadataOpts{}) case "write": err = disk.WriteMetadata(t.Context(), "", bucket, object, fi) case "aba": for _, etag := range []string{"temporary", fi.Metadata["etag"]} { err = disk.UpdateMetadata(t.Context(), bucket, object, FileInfo{Metadata: map[string]string{"etag": etag}}, UpdateMetadataOpts{}) if err != nil { t.Fatal(err) } } } if err != nil { t.Fatal(err) } if err := disk.DeleteVersion(t.Context(), bucket, object, fi, false, DeleteOptions{UndoWrite: true}); err != errFileVersionNotFound { t.Fatalf("old writer retained undo after %s: %v", method, err) } } r, err := z.GetObjectNInfo(t.Context(), bucket, object, nil, nil, ObjectOptions{}) if err != nil { t.Fatal(err) } defer r.Close() body, err := io.ReadAll(r) if err != nil || string(body) != "acknowledged" { t.Fatalf("later acknowledged state lost: %q, %v", body, err) } }) } } func TestMutationCanceledSuccessfulCommitIsNotUndone(t *testing.T) { z, bucket := mutationPools(t) const object = "cancel-after-commit" mutationPut(t, z, bucket, object, 0, "initial", time.Now().Add(-time.Hour)) set := z.serverPools[0].getHashedSet(object) disks, swap := installListingDiskSwitch(set) defer swap(disks) entered, release := make(chan struct{}, len(disks)), make(chan struct{}) var once sync.Once unblock := func() { once.Do(func() { close(release) }) } defer unblock() wrapped := append([]StorageAPI(nil), disks...) for i, disk := range disks { wrapped[i] = delayedRenameResult{disk, false, entered, release} } swap(wrapped) ctx, cancel := context.WithCancel(t.Context()) defer cancel() done := make(chan error, 1) go func() { _, err := set.PutObject(ctx, bucket, object, mustGetPutObjReader(t, strings.NewReader("committed"), 9, "", ""), ObjectOptions{}) done <- err }() for range disks { select { case <-entered: case <-time.After(5 * time.Second): t.Fatal("physical commit barrier not reached") } } cancel() unblock() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("commit did not finish") } swap(disks) r, err := set.GetObjectNInfo(t.Context(), bucket, object, nil, nil, ObjectOptions{}) if err != nil { t.Fatal(err) } defer r.Close() body, err := io.ReadAll(r) if err != nil || string(body) != "committed" { t.Fatalf("cancellation rolled back an actual successful quorum: %q, %v", body, err) } } // Partial no-op updates may revoke undo on only some disks. That internal // capability must not split the quorum of otherwise identical object contents. func TestMutationPartialNoopPreservesReadability(t *testing.T) { z, bucket := mutationPools(t) const object = "partial-noop" set := z.serverPools[0].getHashedSet(object) const body = "acknowledged-data" const tags = "owner=unchanged" _, err := set.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, strings.NewReader(body), int64(len(body)), "", ""), ObjectOptions{UserDefined: map[string]string{xhttp.AmzObjectTagging: tags}}) if err != nil { t.Fatal(err) } disks, swap := installListingDiskSwitch(set) defer swap(disks) partial := append([]StorageAPI(nil), disks...) for i := 4; i < len(partial); i++ { partial[i] = nil } swap(partial) if _, err = set.PutObjectTags(t.Context(), bucket, object, tags, ObjectOptions{}); err == nil { t.Fatal("four drives unexpectedly gave write quorum") } swap(disks) for _, version := range []string{"", nullVersionID} { r, err := set.GetObjectNInfo(t.Context(), bucket, object, nil, nil, ObjectOptions{VersionID: version}) if err != nil { t.Fatalf("unchanged acknowledged object became unreadable after no-op partial update: %v", err) } got, err := io.ReadAll(r) r.Close() if err != nil || string(got) != body || r.ObjInfo.UserTags != tags { t.Fatalf("body/tags changed: %q %v %+v", got, err, r.ObjInfo) } } }