// Copyright (c) 2026 Feng Ruohang // // 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 ( "bytes" "context" "crypto/rand" "encoding/base64" "errors" "fmt" "maps" "net/http" "net/http/httptest" "os" "reflect" "runtime" "strings" "sync" "sync/atomic" "testing" "time" "github.com/google/uuid" "github.com/minio/madmin-go/v3" "github.com/minio/minio/internal/bucket/lifecycle" "github.com/minio/minio/internal/bucket/versioning" "github.com/minio/minio/internal/config/drive" xhttp "github.com/minio/minio/internal/http" ) // restoreGenerationDisk fails metadata writes or updates of the object, or // metadata reads of uploads, while its toggles are set. type restoreGenerationDisk struct { StorageAPI bucket, object string write, update, uploadRead *atomic.Bool } func (d restoreGenerationDisk) WriteMetadata(ctx context.Context, orig, vol, path string, fi FileInfo) error { if d.write != nil && d.write.Load() && vol == d.bucket && path == d.object { return errFaultyDisk } return d.WriteMetadata(ctx, orig, vol, path, fi) } func (d restoreGenerationDisk) UpdateMetadata(ctx context.Context, vol, path string, fi FileInfo, opts UpdateMetadataOpts) error { if d.update != nil && d.update.Load() && vol == d.bucket && path == d.object { return errFaultyDisk } return d.StorageAPI.UpdateMetadata(ctx, vol, path, fi, opts) } func (d restoreGenerationDisk) ReadVersion(ctx context.Context, orig, vol, path, version string, opts ReadOptions) (FileInfo, error) { if d.uploadRead != nil && d.uploadRead.Load() && vol == minioMetaMultipartBucket { return FileInfo{}, errFaultyDisk } return d.StorageAPI.ReadVersion(ctx, orig, vol, path, version, opts) } // restoreGenerationOffline makes a drive's object metadata unreadable and // unwritable while set, its uploads still readable. type restoreGenerationOffline struct { StorageAPI bucket, object string fail *atomic.Bool } func (d restoreGenerationOffline) ReadXL(ctx context.Context, bucket, object string, data bool) (RawFileInfo, error) { if d.fail.Load() && bucket == d.bucket && object == d.object { return RawFileInfo{}, errFaultyDisk } return d.StorageAPI.ReadXL(ctx, bucket, object, data) } func (d restoreGenerationOffline) ReadVersion(ctx context.Context, orig, bucket, object, version string, opts ReadOptions) (FileInfo, error) { if d.fail.Load() && bucket == d.bucket && object == d.object { return FileInfo{}, errFaultyDisk } return d.StorageAPI.ReadVersion(ctx, orig, bucket, object, version, opts) } func (d restoreGenerationOffline) WriteMetadata(ctx context.Context, orig, bucket, object string, fi FileInfo) error { if d.fail.Load() && bucket == d.bucket && object == d.object { return errFaultyDisk } return d.WriteMetadata(ctx, orig, bucket, object, fi) } // restoreGenerationMetadata returns the version's metadata on each drive. func restoreGenerationMetadata(t *testing.T, disks []StorageAPI, r *restoreTest, versionID string) []map[string]string { t.Helper() metas := make([]map[string]string, len(disks)) for i, d := range disks { fi, err := d.ReadVersion(t.Context(), "", r.bucket, r.object, versionID, ReadOptions{}) if err != nil { t.Fatal(err) } metas[i] = fi.Metadata } return metas } // An Object Lock update acknowledged on a quorum, which one drive missed, // survives POST restore: POST patches the restore fields onto the quorum's // generation and never writes the stale drive's metadata anywhere. func TestRestoreStatusUsesQuorumGeneration(t *testing.T) { for _, kind := range []string{"legal-hold", "retention"} { t.Run(kind, func(t *testing.T) { r := newRestoreSafetyTest(t, 1, versioning.Enabled) initConfigSubsystem(t.Context(), r.z) enableBucketObjectLock(t, r.bucket) oi, _ := nativeRestoreSeed(t, r, 0, false, false) if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err != nil { t.Fatal(err) } initial := []byte(`OFF`) updated := []byte(`ON`) key := strings.ToLower(xhttp.AmzObjectLockLegalHold) if kind == "retention" { initial = retentionBody("COMPLIANCE", UTCNow().Add(24*time.Hour)) updated = retentionBody("COMPLIANCE", UTCNow().Add(48*time.Hour)) key = strings.ToLower(xhttp.AmzObjectLockRetainUntilDate) } if rec := nativeRestoreLockRequest(t.Context(), t, r, oi.VersionID, kind, initial); rec.Code != http.StatusOK { t.Fatal(rec.Body.String()) } er := r.z.serverPools[0].getHashedSet(r.object) disks := er.getDisks() var fail atomic.Bool wrapped := append([]StorageAPI(nil), disks...) wrapped[0] = restoreGenerationDisk{StorageAPI: disks[0], bucket: r.bucket, object: r.object, update: &fail} er.getDisks = func() []StorageAPI { return wrapped } fail.Store(true) if rec := nativeRestoreLockRequest(t.Context(), t, r, oi.VersionID, kind, updated); rec.Code != http.StatusOK { t.Fatalf("protection update: %d %s", rec.Code, rec.Body.String()) } fail.Store(false) before := restoreGenerationMetadata(t, disks, r, oi.VersionID) acknowledged := before[1][key] if before[0][key] == acknowledged { t.Fatal("fixture: drive 0 did not miss the update") } for i := 1; i < len(disks); i++ { if before[i][key] != acknowledged { t.Fatal("fixture: the update did not reach the other drives") } } if rec := restoreIsolationPOST(t, r, r.z, oi.VersionID); rec.Code != http.StatusOK { t.Fatalf("POST: %d %s", rec.Code, rec.Body.String()) } after := restoreGenerationMetadata(t, disks, r, oi.VersionID) for i := 1; i < len(disks); i++ { if after[i][key] != acknowledged { t.Errorf("drive %d: POST wrote %q over the acknowledged %q", i, after[i][key], acknowledged) } if after[i][xhttp.AmzRestoreRequestDate] == before[i][xhttp.AmzRestoreRequestDate] { t.Errorf("drive %d: POST did not record its request", i) } } // The stale drive is left to heal; reads that pick it are not // changed here. if after[0][key] != before[0][key] || after[0][xhttp.AmzRestoreRequestDate] != before[0][xhttp.AmzRestoreRequestDate] { t.Error("POST wrote the stale drive instead of leaving it to heal") } }) } } // The attempt is part of a version's metadata generation. A POST whose // marker reached all drives but one is acknowledged: its restore owns the // version, and the reset of an older attempt leaves the newer marker alone. func TestRestoreAttemptGenerationFencing(t *testing.T) { for _, mode := range []string{"worker", "old-reset", "degraded-old-reset"} { t.Run(mode, func(t *testing.T) { r := newRestoreSafetyTest(t, 1, versioning.Enabled) initConfigSubsystem(t.Context(), r.z) oi, body := restoreIsolationSeed(t, r, 0, false, false) er := r.z.serverPools[0].getHashedSet(r.object) disks := er.getDisks() var fail, offline atomic.Bool wrapped := append([]StorageAPI(nil), disks...) wrapped[0] = restoreGenerationDisk{StorageAPI: disks[0], bucket: r.bucket, object: r.object, write: &fail} wrapped[1] = restoreGenerationOffline{StorageAPI: disks[1], bucket: r.bucket, object: r.object, fail: &offline} er.getDisks = func() []StorageAPI { return wrapped } captured := make(chan ObjectOptions, 2) layer := newRestoreIsolationLayer(r) layer.restore = func(_ context.Context, _, _ string, opts ObjectOptions) error { captured <- opts return nil } var older ObjectOptions if mode != "worker" { if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK { t.Fatal(rec.Body.String()) } older = <-captured layer.wait(t) } fail.Store(true) rec := restoreIsolationPOST(t, r, layer, oi.VersionID) fail.Store(false) if rec.Code != http.StatusOK { t.Fatalf("POST on all drives but one: %d %s", rec.Code, rec.Body.String()) } newer := <-captured layer.wait(t) id := newer.Transition.attempt.ID if mode == "worker" { if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, newer); err != nil { t.Fatalf("restore of the acknowledged attempt: %v", err) } if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) { t.Fatalf("restored contents: %v", err) } return } if mode == "degraded-old-reset" { // One drive of the newer attempt is unreadable: the drives // that answer no longer show which attempt is acknowledged. offline.Store(true) } if err := r.z.resetRestoreStatus(r.bucket, r.object, older); err != nil { t.Fatal(err) } offline.Store(false) after := restoreGenerationMetadata(t, disks, r, oi.VersionID) for i := 1; i < len(disks); i++ { if after[i][restoreAttemptKey] != id || !strings.Contains(after[i][xhttp.AmzRestore], "true") { t.Errorf("drive %d: the older attempt's reset cleared the newer marker: %q %q", i, after[i][restoreAttemptKey], after[i][xhttp.AmzRestore]) } } if after[0][restoreAttemptKey] != older.Transition.attempt.ID { t.Error("the reset wrote the stale drive") } // The newer attempt still owns the version and restores it. if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, newer); err != nil { t.Fatalf("restore of the newer attempt after the older reset: %v", err) } if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) { t.Fatalf("restored contents: %v", err) } }) } } // restoreGenerationUploadPath is the stored path of an upload. func restoreGenerationUploadPath(t *testing.T, r *restoreTest, id string) string { t.Helper() b, err := base64.RawURLEncoding.DecodeString(id) if err != nil { t.Fatal(err) } _, uploadUUID, ok := strings.Cut(string(b), ".") if !ok { t.Fatalf("upload ID without UUID: %q", id) } return pathJoin(r.z.serverPools[0].getHashedSet(r.object).getMultipartSHADir(r.bucket, r.object), uploadUUID) } // The restore marker is established on a majority of the drives. One drive // whose copy of the upload lacks it does not open the upload to clients, // and the restore still completes. func TestRestoreUploadMarkerQuorum(t *testing.T) { r := newRestoreSafetyTest(t, 2, versioning.Enabled) initConfigSubsystem(t.Context(), r.z) oi, body := restoreIsolationSeed(t, r, 0, false, true) tier := newRestoreIsolationGateTier(5<<20, nil) globalTierConfigMgr.drivercache["WARM"] = tier defer tier.unblock() layer := newRestoreIsolationLayer(r) if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK { t.Fatal(rec.Body.String()) } restoreIsolationEntered(t, tier) id, _ := restoreIsolationUpload(t, r, 0) disk := r.z.serverPools[0].getHashedSet(r.object).getDisks()[0] path := restoreGenerationUploadPath(t, r, id) fi, err := disk.ReadVersion(t.Context(), "", minioMetaMultipartBucket, path, "", ReadOptions{}) if err != nil { t.Fatal(err) } delete(fi.Metadata, restoreUploadKey) if err := disk.WriteMetadata(t.Context(), "", minioMetaMultipartBucket, path, fi); err != nil { t.Fatal(err) } poison := bytes.Repeat([]byte("p"), 5<<20) if _, err := r.z.PutObjectPart(t.Context(), r.bucket, r.object, id, 1, mustGetPutObjReader(t, bytes.NewReader(poison), int64(len(poison)), "", ""), ObjectOptions{}); !errors.As(err, &InvalidUploadID{}) { t.Errorf("public part with one unmarked copy of the upload: %v, want NoSuchUpload", err) } for _, op := range []func() error{ func() error { _, err := r.z.ListObjectParts(t.Context(), r.bucket, r.object, id, 0, 10, ObjectOptions{}) return err }, func() error { _, err := r.z.GetMultipartInfo(t.Context(), r.bucket, r.object, id, ObjectOptions{}) return err }, } { if err := op(); !errors.As(err, &InvalidUploadID{}) { t.Errorf("public read with one unmarked copy of the upload: %v, want NoSuchUpload", err) } } if uploads, err := r.z.ListMultipartUploads(t.Context(), r.bucket, r.object, "", "", "", 10); err != nil || len(uploads.Uploads) != 0 { t.Errorf("listing with one unmarked copy of the upload: %+v, %v", uploads.Uploads, err) } tier.unblock() if err := layer.wait(t); err != nil { t.Fatal(err) } if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) { t.Fatalf("restore contents: %v", err) } } // When the drives cannot tell an upload's marker, no client lists it, no // abort removes it and stale cleanup keeps it; the restore completes once // the drives answer again. func TestRestoreUploadUnreadableFailsClosed(t *testing.T) { for _, mode := range []string{"list", "abort", "stale"} { t.Run(mode, func(t *testing.T) { r := newRestoreSafetyTest(t, 2, versioning.Enabled) initConfigSubsystem(t.Context(), r.z) oi, body := restoreIsolationSeed(t, r, 0, false, true) er := r.z.serverPools[0].getHashedSet(r.object) disks := er.getDisks() var fail atomic.Bool // Stale cleanup with a short age also removes in-flight temporary // parts: hold that restore in completion, after its last part and // before its commit, and the others in their download, outside // the upload lock. gate := &restoreIsolationCompletionGate{entered: make(chan struct{}), release: make(chan struct{})} defer gate.unblock() tier := newRestoreIsolationGateTier(5<<20, nil) defer tier.unblock() wrapped := make([]StorageAPI, len(disks)) for i, d := range disks { disk := d if mode == "stale" { disk = restoreIsolationValidationDisk{d, gate} } wrapped[i] = restoreGenerationDisk{StorageAPI: disk, uploadRead: &fail} } er.getDisks = func() []StorageAPI { return wrapped } if mode != "stale" { globalTierConfigMgr.drivercache["WARM"] = tier } layer := newRestoreIsolationLayer(r) if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK { t.Fatal(rec.Body.String()) } if mode == "stale" { select { case <-gate.entered: case <-time.After(restoreTestWait): t.Fatal("restore never reached completion") } } else { restoreIsolationEntered(t, tier) } stored := restoreIsolationUploads(t, r, 0) if len(stored) != 1 { t.Fatalf("restore upload on disk: %v", stored) } var id string for uploadUUID := range stored { id = base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s.%s", globalDeploymentID(), uploadUUID)) } fail.Store(true) switch mode { case "list": if uploads, err := r.z.ListMultipartUploads(t.Context(), r.bucket, r.object, "", "", "", 100); len(uploads.Uploads) != 0 { t.Errorf("an upload with an unknown marker is listed: %+v, %v", uploads.Uploads, err) } case "abort": if err := r.z.AbortMultipartUpload(t.Context(), r.bucket, r.object, id, ObjectOptions{}); err == nil { t.Error("abort of an upload with an unknown marker succeeded") } case "stale": globalAPIConfig.mu.Lock() prev := globalAPIConfig.staleUploadsExpiry globalAPIConfig.staleUploadsExpiry = time.Nanosecond globalAPIConfig.mu.Unlock() for _, disk := range wrapped { er.cleanupStaleUploadsOnDisk(t.Context(), disk) } globalAPIConfig.mu.Lock() globalAPIConfig.staleUploadsExpiry = prev globalAPIConfig.mu.Unlock() } fail.Store(false) if uploads := restoreIsolationUploads(t, r, 0); len(uploads) != 1 { t.Errorf("%s removed an upload with an unknown marker: %v", mode, uploads) } gate.unblock() tier.unblock() if err := layer.wait(t); err != nil { t.Fatalf("restore after %s: %v", mode, err) } if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) { t.Fatalf("restore contents: %v", err) } }) } } // POST restores the version it resolved with that version's identity. A // replacement under the same version ID between its read and its status // write is refused, and nothing restores it. func TestRestorePOSTRejectsReplacedTarget(t *testing.T) { for _, state := range []versioning.State{"", versioning.Suspended} { t.Run(fmt.Sprintf("versioning=%q", state), func(t *testing.T) { r := newRestoreSafetyTest(t, 1, state) initConfigSubsystem(t.Context(), r.z) oi, _ := restoreIsolationSeed(t, r, 0, true, false) layer := newRestoreIsolationLayer(r) var replacement ObjectInfo layer.afterRead = func() { data := []byte("replacement after POST resolved its target") var err error replacement, err = r.z.PutObject(t.Context(), r.bucket, r.object, mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{VersionSuspended: state == versioning.Suspended}) if err != nil { t.Fatal(err) } opts := r.opts(nullVersionID) opts.MTime = replacement.ModTime opts.Transition = TransitionOptions{Tier: "WARM", ETag: replacement.ETag} if err := r.z.serverPools[0].TransitionObject(t.Context(), r.bucket, r.object, opts); err != nil { t.Fatal(err) } } if rec := restoreIsolationPOST(t, r, layer, ""); rec.Code < 400 { layer.wait(t) t.Errorf("POST accepted a replacement of its target: %d", rec.Code) } after, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(nullVersionID)) if err != nil { t.Fatal(err) } if after.ETag != replacement.ETag || after.UserDefined[xhttp.AmzRestore] != "" || after.ETag == oi.ETag { t.Errorf("replacement %q changed by POST: etag %q restore %q", replacement.ETag, after.ETag, after.UserDefined[xhttp.AmzRestore]) } }) } } // A POST on a version whose restore holds the object lock answers within // the two-second takeover bound: its first read does not wait for the lock. func TestRestorePOSTOngoingBound(t *testing.T) { for _, mode := range []string{"pools=1", "pools=2", "stale-minority", "failed-activity-read"} { t.Run(mode, func(t *testing.T) { pools := 1 if mode == "pools=2" { pools = 2 } r := newRestoreSafetyTest(t, pools, versioning.Enabled) initConfigSubsystem(t.Context(), r.z) oi, _ := restoreIsolationSeed(t, r, 0, false, false) tier := newRestoreIsolationGateTier(0, nil) globalTierConfigMgr.drivercache["WARM"] = tier defer tier.unblock() var fail atomic.Bool if mode == "stale-minority" { // The first read may pick a drive that missed the marker. er := r.z.serverPools[0].getHashedSet(r.object) disks := er.getDisks() wrapped := append([]StorageAPI(nil), disks...) wrapped[0] = restoreGenerationDisk{StorageAPI: disks[0], bucket: r.bucket, object: r.object, write: &fail} er.getDisks = func() []StorageAPI { return wrapped } fail.Store(true) } layer := newRestoreIsolationLayer(r) rec := restoreIsolationPOST(t, r, layer, oi.VersionID) fail.Store(false) if rec.Code != http.StatusOK { t.Fatal(rec.Body.String()) } restoreIsolationEntered(t, tier) var unreadable atomic.Bool second := newRestoreIsolationLayer(r) if mode == "failed-activity-read" { // Once POST resolved its target, nothing tells whether it // is restoring. er := r.z.serverPools[0].getHashedSet(r.object) disks := er.getDisks() wrapped := make([]StorageAPI, len(disks)) for i, d := range disks { wrapped[i] = restoreGenerationOffline{StorageAPI: d, bucket: r.bucket, object: r.object, fail: &unreadable} } er.getDisks = func() []StorageAPI { return wrapped } second.afterRead = func() { unreadable.Store(true) } } done := make(chan *httptest.ResponseRecorder, 1) start := time.Now() go func() { done <- restoreIsolationPOST(t, r, second, oi.VersionID) }() select { case rec := <-done: want := rec.Code == http.StatusConflict if mode == "failed-activity-read" { want = rec.Code >= 500 } if !want || time.Since(start) > restoreTakeoverWait+time.Second { t.Errorf("POST during a running restore: %d after %s, want a bounded refusal", rec.Code, time.Since(start)) } case <-time.After(restoreTakeoverWait + 2*time.Second): t.Error("POST during a running restore waited for the object lock") unreadable.Store(false) tier.unblock() <-done } unreadable.Store(false) tier.unblock() if err := layer.wait(t); err != nil { t.Fatal(err) } }) } } // A POST whose status write fails on drives that stay unwritten puts the // generation it read back, and a split that a failed put-back leaves // differs only in restore fields: the attempt's reset and a later POST both // resolve it, while every other field still needs the quorum. func TestRestorePOSTStatusWriteSplit(t *testing.T) { for _, mode := range []string{"put-back", "split-reset", "split-post"} { t.Run(mode, func(t *testing.T) { r := newRestoreSafetyTest(t, 1, versioning.Enabled) initConfigSubsystem(t.Context(), r.z) oi, body := restoreIsolationSeed(t, r, 0, false, false) er := r.z.serverPools[0].getHashedSet(r.object) disks := er.getDisks() var fail atomic.Bool wrapped := append([]StorageAPI(nil), disks...) for i := range 2 { wrapped[i] = restoreGenerationDisk{StorageAPI: disks[i], bucket: r.bucket, object: r.object, write: &fail} } er.getDisks = func() []StorageAPI { return wrapped } before := restoreGenerationMetadata(t, disks, r, oi.VersionID) var attempt *restoreAttempt if mode == "put-back" { fail.Store(true) rec := restoreIsolationPOST(t, r, r.z, oi.VersionID) fail.Store(false) if rec.Code < 400 { t.Fatalf("POST acknowledged a status write on two of four drives: %d", rec.Code) } if after := restoreGenerationMetadata(t, disks, r, oi.VersionID); !reflect.DeepEqual(before, after) { t.Error("failed status write was not put back") } } else { // A status write and its put-back both failed on two drives. attempt = newRestoreAttempt(oi) for _, d := range disks[2:] { fi, err := d.ReadVersion(t.Context(), "", r.bucket, r.object, oi.VersionID, ReadOptions{ReadData: true}) if err != nil { t.Fatal(err) } fi.Metadata[xhttp.AmzRestore] = ongoingRestoreObj().String() fi.Metadata[restoreAttemptKey] = attempt.ID if err := d.WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil { t.Fatal(err) } } if _, err := er.readRestoreGeneration(t.Context(), r.bucket, r.object, oi.VersionID); err != nil { t.Fatalf("a split in restore fields only has no generation: %v", err) } } if mode == "split-reset" { opts := nativeRestoreOptions(r, oi.VersionID) opts.Transition.attempt = attempt if err := r.z.resetRestoreStatus(r.bucket, r.object, opts); err != nil { t.Fatalf("reset of the split attempt: %v", err) } for i, meta := range restoreGenerationMetadata(t, disks, r, oi.VersionID) { if meta[xhttp.AmzRestore] != "" || meta[restoreAttemptKey] != "" { t.Errorf("drive %d keeps the split attempt: %q %q", i, meta[xhttp.AmzRestore], meta[restoreAttemptKey]) } } } layer := newRestoreIsolationLayer(r) if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK { t.Fatalf("POST after the failed status write: %d %s", rec.Code, rec.Body.String()) } if err := layer.wait(t); err != nil { t.Fatal(err) } if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) { t.Fatalf("restore contents: %v", err) } }) } } // A split in any other field is not resolved: the generation fails closed. func TestRestoreGenerationSplitNeedsQuorum(t *testing.T) { r := newRestoreSafetyTest(t, 1, versioning.Enabled) oi, _ := restoreIsolationSeed(t, r, 0, false, false) er := r.z.serverPools[0].getHashedSet(r.object) for i, d := range er.getDisks()[2:] { fi, err := d.ReadVersion(t.Context(), "", r.bucket, r.object, oi.VersionID, ReadOptions{ReadData: true}) if err != nil { t.Fatal(err) } fi.Metadata[xhttp.AmzRestore] = ongoingRestoreObj().String() fi.Metadata[strings.ToLower(xhttp.AmzObjectLockLegalHold)] = fmt.Sprint("ON", i) if err := d.WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil { t.Fatal(err) } } if _, err := er.readRestoreGeneration(t.Context(), r.bucket, r.object, oi.VersionID); !errors.Is(err, errErasureReadQuorum) { t.Fatalf("split outside the restore fields: %v, want read quorum", err) } if rec := restoreIsolationPOST(t, r, r.z, oi.VersionID); rec.Code < 400 { t.Errorf("POST over a split outside the restore fields: %d", rec.Code) } } // restoreGenerationStageDisk holds a completion that is staging: as // xlStorage.RenameData does, a drive that stages writes the committed // object metadata into the upload before it renames the upload into the // object. type restoreGenerationStageDisk struct { StorageAPI bucket, object string stage bool arrived *atomic.Int32 entered chan struct{} release chan struct{} disks int32 } func (d restoreGenerationStageDisk) RenameData(ctx context.Context, srcVolume, srcPath string, fi FileInfo, dstVolume, dstPath string, opts RenameOptions) (RenameDataResp, error) { if srcVolume == minioMetaMultipartBucket && dstVolume == d.bucket && dstPath == d.object { if d.stage { if err := d.WriteMetadata(ctx, "", srcVolume, srcPath, fi); err != nil { return RenameDataResp{}, err } } if d.arrived.Add(1) == d.disks { close(d.entered) } select { case <-d.release: case <-ctx.Done(): return RenameDataResp{}, ctx.Err() } } return d.StorageAPI.RenameData(ctx, srcVolume, srcPath, fi, dstVolume, dstPath, opts) } // While a completion stages its commit, the upload holds object metadata // without the marker on the drives that staged. Nobody lists, aborts or // removes it then, also when the copies not staged yet are old. func TestRestoreUploadStagingFailsClosed(t *testing.T) { for _, mode := range []string{"staged", "partly-staged", "stalled-commit"} { t.Run(mode, func(t *testing.T) { testRestoreUploadStaging(t, mode) }) } } func testRestoreUploadStaging(t *testing.T, mode string) { r := newRestoreSafetyTest(t, 1, versioning.Enabled) initConfigSubsystem(t.Context(), r.z) oi, body := restoreIsolationSeed(t, r, 0, false, true) er := r.z.serverPools[0].getHashedSet(r.object) disks := er.getDisks() var arrived atomic.Int32 entered, release := make(chan struct{}), make(chan struct{}) released := false defer func() { if !released { close(release) } }() wrapped := make([]StorageAPI, len(disks)) for i, d := range disks { wrapped[i] = restoreGenerationStageDisk{ StorageAPI: d, bucket: r.bucket, object: r.object, stage: mode != "partly-staged" || i == 0, arrived: &arrived, entered: entered, release: release, disks: int32(len(disks)), } } er.getDisks = func() []StorageAPI { return wrapped } layer := newRestoreIsolationLayer(r) if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK { t.Fatal(rec.Body.String()) } select { case <-entered: case <-time.After(restoreTestWait): t.Fatal("completion never staged its commit") } stored := restoreIsolationUploads(t, r, 0) var id string for uploadUUID := range stored { id = base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s.%s", globalDeploymentID(), uploadUUID)) if mode != "staged" { // partly-staged: a long restore, the copies not staged yet are // old. stalled-commit: drives that stall the running commit // past the grace make its staged copies look abandoned. old := UTCNow().Add(-48 * time.Hour) for _, d := range disks[1:] { if err := os.Chtimes(pathJoin(d.Endpoint().Path, minioMetaMultipartBucket, er.getMultipartSHADir(r.bucket, r.object), uploadUUID, xlStorageFormatFile), old, old); err != nil { t.Fatal(err) } } } } if uploads, err := r.z.ListMultipartUploads(t.Context(), r.bucket, r.object, "", "", "", 10); err != nil || len(uploads.Uploads) != 0 { t.Errorf("a staged restore upload is listed: %+v, %v", uploads.Uploads, err) } // The set's own abort: the pools layer would wait for the upload lock // the completion holds. if err := er.AbortMultipartUpload(t.Context(), r.bucket, r.object, id, ObjectOptions{}); err == nil { t.Error("abort of a staged restore upload succeeded") } globalAPIConfig.mu.Lock() prev := globalAPIConfig.staleUploadsExpiry globalAPIConfig.staleUploadsExpiry = time.Nanosecond globalAPIConfig.mu.Unlock() for _, disk := range wrapped { er.cleanupStaleUploadsOnDisk(t.Context(), disk) } globalAPIConfig.mu.Lock() globalAPIConfig.staleUploadsExpiry = prev globalAPIConfig.mu.Unlock() if after := restoreIsolationUploads(t, r, 0); !reflect.DeepEqual(after, stored) { t.Errorf("a staged restore upload was removed: %v -> %v", stored, after) } released = true close(release) if err := layer.wait(t); err != nil { t.Fatalf("restore after its staging was attacked: %v", err) } if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) { t.Fatalf("restore contents: %v", err) } } // When a drive of an acknowledged attempt becomes unreadable, the drives that // answer disagree on the restore status. That the attempt ended is then not // established, and stale cleanup keeps its upload. func TestRestoreSplitKeepsRunningUpload(t *testing.T) { r := newRestoreSafetyTest(t, 1, versioning.Enabled) initConfigSubsystem(t.Context(), r.z) oi, body := restoreIsolationSeed(t, r, 0, false, true) er := r.z.serverPools[0].getHashedSet(r.object) disks := er.getDisks() var missed, offline atomic.Bool gate := &restoreIsolationCompletionGate{entered: make(chan struct{}), release: make(chan struct{})} defer gate.unblock() wrapped := make([]StorageAPI, len(disks)) for i, d := range disks { var disk StorageAPI = restoreIsolationValidationDisk{d, gate} switch i { case 0: disk = restoreGenerationDisk{StorageAPI: disk, bucket: r.bucket, object: r.object, write: &missed} case 1: disk = restoreGenerationOffline{StorageAPI: disk, bucket: r.bucket, object: r.object, fail: &offline} } wrapped[i] = disk } er.getDisks = func() []StorageAPI { return wrapped } layer := newRestoreIsolationLayer(r) missed.Store(true) rec := restoreIsolationPOST(t, r, layer, oi.VersionID) missed.Store(false) if rec.Code != http.StatusOK { t.Fatal(rec.Body.String()) } select { case <-gate.entered: case <-time.After(restoreTestWait): t.Fatal("restore never reached completion") } stored := restoreIsolationUploads(t, r, 0) offline.Store(true) if gen, err := er.readRestoreGeneration(t.Context(), r.bucket, r.object, oi.VersionID); err != nil || !gen.split { t.Fatalf("fixture: no split generation: %v", err) } globalAPIConfig.mu.Lock() prev := globalAPIConfig.staleUploadsExpiry globalAPIConfig.staleUploadsExpiry = time.Nanosecond globalAPIConfig.mu.Unlock() for _, disk := range wrapped { er.cleanupStaleUploadsOnDisk(t.Context(), disk) } globalAPIConfig.mu.Lock() globalAPIConfig.staleUploadsExpiry = prev globalAPIConfig.mu.Unlock() offline.Store(false) if after := restoreIsolationUploads(t, r, 0); !reflect.DeepEqual(after, stored) { t.Errorf("stale cleanup removed a running restore's upload: %v -> %v", stored, after) } gate.unblock() if err := layer.wait(t); err != nil { t.Fatalf("restore after the split: %v", err) } if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) { t.Fatalf("restore contents: %v", err) } } // restoreGenerationCleanupDisk misses restored-copy cleanups and metadata // writes of the object while its toggles are set. type restoreGenerationCleanupDisk struct { StorageAPI bucket, object string cleanup, metaWrites *atomic.Bool } func (d restoreGenerationCleanupDisk) DeleteVersion(ctx context.Context, bucket, object string, fi FileInfo, force bool, opts DeleteOptions) error { if bucket == d.bucket && object == d.object && fi.ExpireRestored && d.cleanup.Load() { return errFaultyDisk } return d.StorageAPI.DeleteVersion(ctx, bucket, object, fi, force, opts) } func (d restoreGenerationCleanupDisk) WriteMetadata(ctx context.Context, orig, bucket, object string, fi FileInfo) error { if bucket == d.bucket && object == d.object && d.metaWrites.Load() { return errFaultyDisk } return d.WriteMetadata(ctx, orig, bucket, object, fi) } // Restored-copy expiry decides on the version's authoritative generation. A // drive that missed a cleanup and the POST after it keeps an expired header, // which must not clear the quorum's ongoing restore; drives split between // an expired and a renewed header authorize nothing. func TestExpireRestoredUsesQuorumGeneration(t *testing.T) { for _, pools := range []int{1, 2} { for _, mode := range []string{"after-partial-cleanup", "split-renewal"} { t.Run(fmt.Sprintf("pools=%d/%s", pools, mode), func(t *testing.T) { r := newRestoreSafetyTest(t, pools, versioning.Enabled) initConfigSubsystem(t.Context(), r.z) oi := r.put("cache", false, true) r.restore(oi) stale, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID)) if err != nil { t.Fatal(err) } er := r.z.serverPools[0].getHashedSet(r.object) disks := er.getDisks() expire := func() error { return expireTransitionedObject(t.Context(), r.z, &stale, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner) } if mode == "split-renewal" { // A renewal reached two drives; the other two keep the // expired header. dataDir := r.fileInfo(oi.VersionID).DataDir for _, d := range disks[2:] { fi, err := d.ReadVersion(t.Context(), "", r.bucket, r.object, oi.VersionID, ReadOptions{ReadData: true}) if err != nil { t.Fatal(err) } fi.Metadata[xhttp.AmzRestore] = completedRestoreObj(UTCNow().Add(48 * time.Hour)).String() if err := d.WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil { t.Fatal(err) } } if err := expire(); err == nil || r.dataDirs(dataDir) != len(disks) { t.Errorf("expiry on drives split between expired and renewed: %v, %d/%d cache dirs", err, r.dataDirs(dataDir), len(disks)) } return } var cleanup, metaWrites atomic.Bool wrapped := append([]StorageAPI(nil), disks...) wrapped[0] = restoreGenerationCleanupDisk{StorageAPI: disks[0], bucket: r.bucket, object: r.object, cleanup: &cleanup, metaWrites: &metaWrites} er.getDisks = func() []StorageAPI { return wrapped } cleanup.Store(true) err = expire() cleanup.Store(false) if err != nil { t.Fatalf("cleanup on all drives but one: %v", err) } captured := make(chan ObjectOptions, 1) layer := newRestoreIsolationLayer(r) layer.restore = func(_ context.Context, _, _ string, opts ObjectOptions) error { captured <- opts return nil } metaWrites.Store(true) rec := restoreIsolationPOST(t, r, layer, oi.VersionID) metaWrites.Store(false) if rec.Code != http.StatusOK { t.Fatalf("POST after the cleanup: %d %s", rec.Code, rec.Body.String()) } opts := <-captured layer.wait(t) // The same cleanup, queued twice, runs again. if err := expire(); err == nil { t.Error("a drive's expired header cleared the quorum's ongoing restore") } if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, opts); err != nil { t.Fatalf("restore of the acknowledged attempt: %v", err) } if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, r.bodies["cache"]) { t.Fatalf("restored contents: %v", err) } }) } } } // restoreGenerationHidden hides a drive's copy of the object version while // set, as a drive that lost it and that heal writes again. type restoreGenerationHidden struct { StorageAPI bucket, object string hidden *atomic.Bool } func (d restoreGenerationHidden) ReadVersion(ctx context.Context, orig, bucket, object, version string, opts ReadOptions) (FileInfo, error) { if d.hidden.Load() && bucket == d.bucket && object == d.object { return FileInfo{}, errFileNotFound } return d.StorageAPI.ReadVersion(ctx, orig, bucket, object, version, opts) } // Heal copies the restore fields to a drive only as a quorum of the drives // holds them. While they disagree, healing the version waits: it must not // create a quorum for an expired header over a renewal, nor for a // superseded attempt. Fields a quorum agrees on are copied. func TestRestoreHealNeedsRestoreQuorum(t *testing.T) { for _, mode := range []string{"expired-over-renewal", "superseded-attempt", "agreed"} { t.Run(mode, func(t *testing.T) { r := newRestoreSafetyTest(t, 1, versioning.Enabled) initConfigSubsystem(t.Context(), r.z) er := r.z.serverPools[0].getHashedSet(r.object) disks := er.getDisks() setRestore := func(versionID string, drives []StorageAPI, fields map[string]string) { for _, d := range drives { fi, err := d.ReadVersion(t.Context(), "", r.bucket, r.object, versionID, ReadOptions{ReadData: true}) if err != nil { t.Fatal(err) } maps.Copy(fi.Metadata, fields) if err := d.WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil { t.Fatal(err) } } } var versionID string var stale ObjectInfo var older ObjectOptions hide := 3 switch mode { case "expired-over-renewal", "agreed": oi := r.put("cache", false, true) r.restore(oi) versionID = oi.VersionID var err error if stale, err = r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(versionID)); err != nil { t.Fatal(err) } renewed := map[string]string{xhttp.AmzRestore: completedRestoreObj(UTCNow().Add(48 * time.Hour)).String()} if mode == "agreed" { // Three drives renewed, the hidden one expired. setRestore(versionID, disks[:3], renewed) } else { // No quorum: two drives expired an hour ago, one renewed, // and the hidden one expired a day ago. setRestore(versionID, disks[:2], map[string]string{xhttp.AmzRestore: completedRestoreObj(UTCNow().Add(-time.Hour)).String()}) setRestore(versionID, disks[2:3], renewed) } case "superseded-attempt": oi, _ := restoreIsolationSeed(t, r, 0, false, false) versionID = oi.VersionID captured := make(chan ObjectOptions, 1) layer := newRestoreIsolationLayer(r) layer.restore = func(_ context.Context, _, _ string, opts ObjectOptions) error { captured <- opts return nil } if rec := restoreIsolationPOST(t, r, layer, versionID); rec.Code != http.StatusOK { t.Fatal(rec.Body.String()) } older = <-captured layer.wait(t) // A newer attempt reached two drives. setRestore(versionID, disks[2:], map[string]string{xhttp.AmzRestore: ongoingRestoreObj().String(), restoreAttemptKey: newRestoreAttempt(oi).ID}) hide = 2 } before := restoreGenerationMetadata(t, disks, r, versionID) var hidden atomic.Bool wrapped := append([]StorageAPI(nil), disks...) wrapped[hide] = restoreGenerationHidden{StorageAPI: disks[hide], bucket: r.bucket, object: r.object, hidden: &hidden} er.getDisks = func() []StorageAPI { return wrapped } hidden.Store(true) _, healErr := er.HealObject(t.Context(), r.bucket, r.object, versionID, madmin.HealOpts{ScanMode: madmin.HealNormalScan}) hidden.Store(false) er.getDisks = func() []StorageAPI { return disks } after := restoreGenerationMetadata(t, disks, r, versionID) if mode == "agreed" { if healErr != nil || after[hide][xhttp.AmzRestore] != after[0][xhttp.AmzRestore] { t.Errorf("heal did not copy the restore fields a quorum holds: %v, %q", healErr, after[hide][xhttp.AmzRestore]) } return } if healErr == nil { t.Error("heal proceeded on drives that disagree on the restore fields") } if after[hide][xhttp.AmzRestore] != before[hide][xhttp.AmzRestore] || after[hide][restoreAttemptKey] != before[hide][restoreAttemptKey] { t.Errorf("heal wrote a minority restore state: %q -> %q", before[hide][xhttp.AmzRestore], after[hide][xhttp.AmzRestore]) } if mode == "expired-over-renewal" { dataDir := r.fileInfo(versionID).DataDir if err := expireTransitionedObject(t.Context(), r.z, &stale, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner); err == nil || r.dataDirs(dataDir) != len(disks) { t.Errorf("expiry after heal: %v, %d/%d cache dirs", err, r.dataDirs(dataDir), len(disks)) } return } if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, older); !errors.Is(err, errRestoreSuperseded) { t.Errorf("superseded attempt after heal: %v", err) } }) } } // restoreGenerationStageFails fails the commit before it stages. type restoreGenerationStageFails struct { StorageAPI bucket, object string } func (d restoreGenerationStageFails) RenameData(ctx context.Context, srcVolume, srcPath string, fi FileInfo, dstVolume, dstPath string, opts RenameOptions) (RenameDataResp, error) { if srcVolume == minioMetaMultipartBucket && dstVolume == d.bucket && dstPath == d.object { return RenameDataResp{}, errFaultyDisk } return d.StorageAPI.RenameData(ctx, srcVolume, srcPath, fi, dstVolume, dstPath, opts) } // restoreGenerationFailedCommit stages the committed metadata into the // upload, as RenameData does, and then fails the commit. type restoreGenerationFailedCommit struct { StorageAPI bucket, object string } func (d restoreGenerationFailedCommit) RenameData(ctx context.Context, srcVolume, srcPath string, fi FileInfo, dstVolume, dstPath string, opts RenameOptions) (RenameDataResp, error) { if srcVolume == minioMetaMultipartBucket && dstVolume == d.bucket && dstPath == d.object { if err := d.WriteMetadata(ctx, "", srcVolume, srcPath, fi); err != nil { return RenameDataResp{}, err } return RenameDataResp{}, errFaultyDisk } return d.StorageAPI.RenameData(ctx, srcVolume, srcPath, fi, dstVolume, dstPath, opts) } // restoreGenerationLeftover runs a restore whose commit fails after it // staged on every drive, or on every drive but drive 0, which fails before // it stages. It returns the upload left behind and its on-disk UUID. func restoreGenerationLeftover(t *testing.T, r *restoreTest, unstaged bool) (map[string]int, string) { t.Helper() initConfigSubsystem(t.Context(), r.z) oi, _ := restoreIsolationSeed(t, r, 0, false, true) er := r.z.serverPools[0].getHashedSet(r.object) disks := er.getDisks() wrapped := make([]StorageAPI, len(disks)) for i, d := range disks { if unstaged && i == 0 { wrapped[i] = restoreGenerationStageFails{StorageAPI: d, bucket: r.bucket, object: r.object} continue } wrapped[i] = restoreGenerationFailedCommit{StorageAPI: d, bucket: r.bucket, object: r.object} } er.getDisks = func() []StorageAPI { return wrapped } layer := newRestoreIsolationLayer(r) if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK { t.Fatal(rec.Body.String()) } if err := layer.wait(t); err == nil { t.Fatal("fixture: the failed commit succeeded") } er.getDisks = func() []StorageAPI { return disks } stored := restoreIsolationUploads(t, r, 0) if len(stored) != 1 { t.Fatalf("fixture: no leftover of the failed commit: %v", stored) } for uploadUUID := range stored { return stored, uploadUUID } return nil, "" } // restoreGenerationAgeUpload makes every drive's copy of an upload's // metadata look written d ago. func restoreGenerationAgeUpload(t *testing.T, r *restoreTest, uploadUUID string, d time.Duration) { t.Helper() er := r.z.serverPools[0].getHashedSet(r.object) old := UTCNow().Add(-d) for _, disk := range er.getDisks() { if err := os.Chtimes(pathJoin(disk.Endpoint().Path, minioMetaMultipartBucket, er.getMultipartSHADir(r.bucket, r.object), uploadUUID, xlStorageFormatFile), old, old); err != nil { t.Fatal(err) } } } // What a failed commit leaves in an upload can always be removed once every // copy is staged and older than the commit grace: abort removes it, also // when stale cleanup could not. A recent staging, a copy that cannot be read // and a copy not staged yet are refused, and nothing is removed then. func TestRestoreAbandonedCommitAbort(t *testing.T) { for _, age := range []string{"recent", "abandoned", "unreadable-copy", "unstaged-copy"} { t.Run(age, func(t *testing.T) { r := newRestoreSafetyTest(t, 1, versioning.Enabled) stored, uploadUUID := restoreGenerationLeftover(t, r, age == "unstaged-copy") if age != "recent" { restoreGenerationAgeUpload(t, r, uploadUUID, 2*restoreCommitGrace) } id := base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s.%s", globalDeploymentID(), uploadUUID)) er := r.z.serverPools[0].getHashedSet(r.object) disks := er.getDisks() var unreadable atomic.Bool if age == "unreadable-copy" { wrapped := append([]StorageAPI(nil), disks...) wrapped[0] = restoreGenerationDisk{StorageAPI: disks[0], uploadRead: &unreadable} er.getDisks = func() []StorageAPI { return wrapped } unreadable.Store(true) } err := r.z.AbortMultipartUpload(t.Context(), r.bucket, r.object, id, ObjectOptions{}) unreadable.Store(false) left := restoreIsolationUploads(t, r, 0) if age != "abandoned" { if err == nil || !reflect.DeepEqual(left, stored) { t.Errorf("abort of an upload that may still commit (%s): %v, left %v", age, err, left) } return } if err != nil || len(left) != 0 { t.Errorf("abort of an abandoned commit: %v, left %v", err, left) } }) } } // With no commit running, stale cleanup removes what a failed commit left // drive by drive: a staged copy older than the commit grace. A recent // staging and a copy that never staged stay. func TestRestoreFailedCommitStaleCleanup(t *testing.T) { for _, mode := range []string{"recent", "abandoned", "unstaged-copy"} { t.Run(mode, func(t *testing.T) { r := newRestoreSafetyTest(t, 1, versioning.Enabled) stored, uploadUUID := restoreGenerationLeftover(t, r, mode == "unstaged-copy") if mode != "recent" { // The copy that never staged is as old as the others. restoreGenerationAgeUpload(t, r, uploadUUID, 2*restoreCommitGrace) } er := r.z.serverPools[0].getHashedSet(r.object) globalAPIConfig.mu.Lock() prev := globalAPIConfig.staleUploadsExpiry globalAPIConfig.staleUploadsExpiry = time.Nanosecond globalAPIConfig.mu.Unlock() for _, disk := range er.getDisks() { er.cleanupStaleUploadsOnDisk(t.Context(), disk) } globalAPIConfig.mu.Lock() globalAPIConfig.staleUploadsExpiry = prev globalAPIConfig.mu.Unlock() want := map[string]int{} switch mode { case "recent": want = stored case "unstaged-copy": want = map[string]int{uploadUUID: 1} } if left := restoreIsolationUploads(t, r, 0); !reflect.DeepEqual(left, want) { t.Errorf("stale cleanup of a failed commit's leftover: %v -> %v, want %v", stored, left, want) } }) } } // restoreGenerationSnapshotKey marks the context of a stale cleanup whose // reads of upload metadata restoreGenerationSnapshotDisk holds. type restoreGenerationSnapshotKey struct{} // restoreGenerationSnapshotDisk reads upload metadata at once, and hands the // reply to a marked stale cleanup only on release. type restoreGenerationSnapshotDisk struct { StorageAPI reads *atomic.Int32 release chan struct{} } func (d restoreGenerationSnapshotDisk) ReadVersion(ctx context.Context, origvolume, volume, path, versionID string, opts ReadOptions) (FileInfo, error) { fi, err := d.StorageAPI.ReadVersion(ctx, origvolume, volume, path, versionID, opts) if volume == minioMetaMultipartBucket && ctx.Value(restoreGenerationSnapshotKey{}) != nil { d.reads.Add(1) select { case <-d.release: case <-ctx.Done(): return FileInfo{}, ctx.Err() } } return fi, err } // Stale cleanup decides on a removal under the commit lock. A completion // that starts while cleanup reads an upload past the stale age waits for // it, and a cleanup that comes later skips the running commit: the upload, // an ordinary one, still commits with its contents. func TestCleanupStaleUploadsSkipsRunningCommit(t *testing.T) { r := newRestoreSafetyTest(t, 1, versioning.Enabled) initConfigSubsystem(t.Context(), r.z) er := r.z.serverPools[0].getHashedSet(r.object) mp, err := r.z.NewMultipartUpload(t.Context(), r.bucket, r.object, ObjectOptions{Versioned: true, MTime: UTCNow().Add(-48 * time.Hour)}) if err != nil { t.Fatal(err) } body := []byte("an ordinary upload past the stale age") part, err := r.z.PutObjectPart(t.Context(), r.bucket, r.object, mp.UploadID, 1, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{}) if err != nil { t.Fatal(err) } disks := er.getDisks() var reads, staged atomic.Int32 readRelease := make(chan struct{}) stageEntered, stageRelease := make(chan struct{}), make(chan struct{}) var readOnce, stageOnce sync.Once defer readOnce.Do(func() { close(readRelease) }) defer stageOnce.Do(func() { close(stageRelease) }) wrapped := make([]StorageAPI, len(disks)) for i, d := range disks { stage := restoreGenerationStageDisk{ StorageAPI: d, bucket: r.bucket, object: r.object, stage: true, arrived: &staged, entered: stageEntered, release: stageRelease, disks: int32(len(disks)), } wrapped[i] = restoreGenerationSnapshotDisk{StorageAPI: stage, reads: &reads, release: readRelease} } er.getDisks = func() []StorageAPI { return wrapped } globalAPIConfig.mu.Lock() prev := globalAPIConfig.staleUploadsExpiry globalAPIConfig.staleUploadsExpiry = time.Nanosecond globalAPIConfig.mu.Unlock() defer func() { globalAPIConfig.mu.Lock() globalAPIConfig.staleUploadsExpiry = prev globalAPIConfig.mu.Unlock() }() // Every drive's cleanup at once, each reading the upload on every drive. ctx := context.WithValue(t.Context(), restoreGenerationSnapshotKey{}, true) var cleanups sync.WaitGroup for _, disk := range wrapped { cleanups.Go(func() { er.cleanupStaleUploadsOnDisk(ctx, disk) }) } cleanupDone := make(chan struct{}) go func() { cleanups.Wait(); close(cleanupDone) }() // The completion starts once every cleanup that may decide holds its // read of the upload from before the commit. for deadline := time.Now().Add(2 * time.Second); reads.Load() < int32(len(disks)*len(disks)) && time.Now().Before(deadline); { time.Sleep(10 * time.Millisecond) } completion := make(chan ObjectInfo, 1) completionErr := make(chan error, 1) go func() { oi, err := r.z.CompleteMultipartUpload(t.Context(), r.bucket, r.object, mp.UploadID, []CompletePart{{PartNumber: 1, ETag: part.ETag}}, ObjectOptions{Versioned: true}) completion <- oi completionErr <- err }() select { case <-stageEntered: case <-time.After(3 * time.Second): } readOnce.Do(func() { close(readRelease) }) select { case <-cleanupDone: case <-time.After(restoreTestWait): t.Fatal("stale cleanup never returned") } stageOnce.Do(func() { close(stageRelease) }) oi := <-completion if err := <-completionErr; err != nil { t.Fatalf("completion of an upload stale cleanup read: %v", err) } er.getDisks = func() []StorageAPI { return disks } if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) { t.Fatalf("completed contents: %q, %v", got, err) } } // restoreGenerationRand draws UUIDs from crypto/rand for the whole test // binary, installed before any goroutine runs. While a pause is set, it holds // the UUID that stale cleanup draws in its deadline-bound worker, for the // trash path right before its rename, until the pause is released. var restoreGenerationRand = &restoreGenerationRandReader{} func init() { uuid.SetRand(restoreGenerationRand) } type restoreGenerationRandReader struct { pause atomic.Pointer[restoreGenerationPause] } type restoreGenerationPause struct { entered, release chan struct{} } func (r *restoreGenerationRandReader) Read(b []byte) (int, error) { if p := r.pause.Load(); p != nil && restoreGenerationInCleanupRename() && r.pause.CompareAndSwap(p, nil) { close(p.entered) <-p.release } return rand.Read(b) } // restoreGenerationInCleanupRename reports whether the caller runs in stale // cleanup's deadline-bound worker. func restoreGenerationInCleanupRename() bool { pcs := make([]uintptr, 32) frames := runtime.CallersFrames(pcs[:runtime.Callers(3, pcs)]) cleanup, worker := false, false for { f, more := frames.Next() cleanup = cleanup || strings.Contains(f.Function, "cleanupStaleUploadsOnDisk") worker = worker || strings.Contains(f.Function, "ioutil.WithDeadline") if !more { return cleanup && worker } } } // Stale cleanup holds the commit lock until its rename ends, also when the // rename outlives the drive deadline and cleanup has moved on: a completion // does not stage while that rename can still remove its upload. func TestCleanupStaleUploadsHoldsLockThroughRename(t *testing.T) { r := newRestoreSafetyTest(t, 1, versioning.Enabled) initConfigSubsystem(t.Context(), r.z) er := r.z.serverPools[0].getHashedSet(r.object) mp, err := r.z.NewMultipartUpload(t.Context(), r.bucket, r.object, ObjectOptions{Versioned: true, MTime: UTCNow().Add(-48 * time.Hour)}) if err != nil { t.Fatal(err) } body := []byte("an upload whose stale cleanup outlives the drive deadline") part, err := r.z.PutObjectPart(t.Context(), r.bucket, r.object, mp.UploadID, 1, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{}) if err != nil { t.Fatal(err) } disks := er.getDisks() stored := restoreIsolationUploads(t, r, 0) prev := globalDriveConfig.GetMaxTimeout() // The shortest drive deadline the configuration takes. globalDriveConfig.Update(drive.Config{MaxTimeout: 1100 * time.Millisecond}) defer globalDriveConfig.Update(drive.Config{MaxTimeout: prev}) pause := &restoreGenerationPause{entered: make(chan struct{}), release: make(chan struct{})} var paused sync.Once defer paused.Do(func() { close(pause.release) }) restoreGenerationRand.pause.Store(pause) defer restoreGenerationRand.pause.CompareAndSwap(pause, nil) cleanupDone := make(chan struct{}) go func() { er.cleanupStaleUploadsOnDisk(t.Context(), disks[0]) close(cleanupDone) }() select { case <-pause.entered: case <-time.After(5 * time.Second): t.Fatal("stale cleanup never reached its rename") } select { case <-cleanupDone: case <-time.After(5 * time.Second): t.Fatal("stale cleanup did not move on after the drive deadline") } if left := restoreIsolationUploads(t, r, 0); !reflect.DeepEqual(left, stored) { t.Fatalf("fixture: the held rename already ran: %v -> %v", stored, left) } var staged atomic.Int32 stageEntered, stageRelease := make(chan struct{}), make(chan struct{}) var stageOnce sync.Once defer stageOnce.Do(func() { close(stageRelease) }) wrapped := make([]StorageAPI, len(disks)) for i, d := range disks { wrapped[i] = restoreGenerationStageDisk{ StorageAPI: d, bucket: r.bucket, object: r.object, stage: true, arrived: &staged, entered: stageEntered, release: stageRelease, disks: int32(len(disks)), } } er.getDisks = func() []StorageAPI { return wrapped } completion := make(chan ObjectInfo, 1) completionErr := make(chan error, 1) go func() { oi, err := r.z.CompleteMultipartUpload(t.Context(), r.bucket, r.object, mp.UploadID, []CompletePart{{PartNumber: 1, ETag: part.ETag}}, ObjectOptions{Versioned: true}) completion <- oi completionErr <- err }() select { case <-stageEntered: t.Error("a completion staged while stale cleanup's rename could still run") case <-time.After(2 * time.Second): } paused.Do(func() { close(pause.release) }) select { case <-stageEntered: case <-time.After(restoreTestWait): t.Fatal("completion never staged after the rename ended") } stageOnce.Do(func() { close(stageRelease) }) oi := <-completion if err := <-completionErr; err != nil { t.Fatalf("completion after the rename ended: %v", err) } er.getDisks = func() []StorageAPI { return disks } if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) { t.Fatalf("completed contents: %q, %v", got, err) } }