mirror of
https://github.com/pgsty/minio.git
synced 2026-11-09 17:21:51 +03:00
7af07298ce
The implementation review of this round's public fixes found one gap in the code and four in the tests. - A refused expiry was traced with its reason but without its error, so the trace did not say what refused it. It now carries the error, as a failed one does. - The restore MRF test replaced the live MRF channel, which the heal routines of the test servers read: a race, and a consumer could take the entry under test. addPartialOp now offers an atomic hook that receives the operation in place of the queue. Production never sets it. - Two fixtures restored the storage class with Update, which marks an uninitialized configuration initialized and so changes the default parity (-1) into an explicit 0 for later tests. setTestStandardParity restores the whole saved value under storageclass.ConfigLock. - The v3 metric test built its own descriptor map, so dropping the counter from the registered ILM collector still passed. It gathers through the collector the handler registers. - The audit test read one extra entry or waited 500 ms. It drains every entry of the bucket up to the removal audited last, and checks one failure audit, one removal audit and no refusal audit per object. Each corrected test fails with the corresponding mutation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Feng Ruohang <rh@vonng.com>
923 lines
35 KiB
Go
923 lines
35 KiB
Go
// 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 <http://www.gnu.org/licenses/>.
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/minio/madmin-go/v3"
|
|
"github.com/minio/minio/internal/bucket/versioning"
|
|
"github.com/minio/minio/internal/config/storageclass"
|
|
xhttp "github.com/minio/minio/internal/http"
|
|
)
|
|
|
|
// A native restore downloads its tier copy without the object lock: while
|
|
// the download of an old version is paused, the hot versions of the same key
|
|
// stay readable and writable. Only the short commit takes the object lock.
|
|
func TestRestoreDownloadKeepsKeyAvailable(t *testing.T) {
|
|
for _, pools := range []int{1, 2} {
|
|
for _, multipart := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("pools=%d/multipart=%v", pools, multipart), func(t *testing.T) {
|
|
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
|
|
initConfigSubsystem(t.Context(), r.z)
|
|
oi, body := nativeRestoreSeed(t, r, 0, false, multipart)
|
|
hot := putConsistencyObject(t, r.z, r.bucket, r.object, 0, "hot latest", ObjectOptions{Versioned: true, MTime: oi.ModTime.Add(time.Hour)})
|
|
putConsistencyObject(t, r.z, r.bucket, "unrelated", 0, "unrelated hot object", ObjectOptions{Versioned: true})
|
|
gate := newRestoreIsolationGateTier(1, nil)
|
|
globalTierConfigMgr.drivercache["WARM"] = gate
|
|
defer gate.unblock()
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
|
|
}()
|
|
restoreIsolationEntered(t, gate)
|
|
|
|
within := func(name string, fn func(ctx context.Context) error) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
|
defer cancel()
|
|
start := time.Now()
|
|
if err := fn(ctx); err != nil {
|
|
t.Fatalf("%s during a paused restore download: %v after %s", name, err, time.Since(start))
|
|
}
|
|
}
|
|
for _, key := range []string{"unrelated", r.object} {
|
|
within("GET "+key, func(ctx context.Context) error {
|
|
gr, err := r.z.GetObjectNInfo(ctx, r.bucket, key, nil, http.Header{}, ObjectOptions{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer gr.Close()
|
|
_, err = io.ReadAll(gr)
|
|
return err
|
|
})
|
|
}
|
|
within("HEAD hot latest", func(ctx context.Context) error {
|
|
got, err := r.z.GetObjectInfo(ctx, r.bucket, r.object, ObjectOptions{})
|
|
if err == nil && got.VersionID != hot.VersionID {
|
|
err = fmt.Errorf("latest is %s, want %s", got.VersionID, hot.VersionID)
|
|
}
|
|
return err
|
|
})
|
|
within("PutObjectTags hot latest", func(ctx context.Context) error {
|
|
_, err := r.z.PutObjectTags(ctx, r.bucket, r.object, "during=restore", ObjectOptions{VersionID: hot.VersionID, Versioned: true})
|
|
return err
|
|
})
|
|
// The download publishes nothing: no upload, public or not.
|
|
if uploads := restoreIsolationUploads(t, r, 0); len(uploads) != 0 {
|
|
t.Errorf("restore created uploads: %v", uploads)
|
|
}
|
|
within("PUT new version", func(ctx context.Context) error {
|
|
data := []byte("written during restore")
|
|
_, err := r.z.PutObject(ctx, r.bucket, r.object,
|
|
mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{Versioned: true})
|
|
return err
|
|
})
|
|
|
|
gate.unblock()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(time.Minute):
|
|
t.Fatal("restore did not complete after releasing the tier")
|
|
}
|
|
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
|
t.Fatalf("restored version mismatch: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// restoreTwoPhaseStagedCommitFailure makes a multipart completion stage its
|
|
// object metadata in the upload and then fail before moving any data.
|
|
type restoreTwoPhaseStagedCommitFailure struct {
|
|
StorageAPI
|
|
bucket, object string
|
|
}
|
|
|
|
func (d restoreTwoPhaseStagedCommitFailure) RenameData(ctx context.Context, sv, sp string, fi FileInfo, dv, dp string, opts RenameOptions) (RenameDataResp, error) {
|
|
if sv == minioMetaMultipartBucket && dv == d.bucket && dp == d.object {
|
|
if err := d.WriteMetadata(ctx, "", sv, sp, fi); err != nil {
|
|
return RenameDataResp{}, err
|
|
}
|
|
return RenameDataResp{}, errFaultyDisk
|
|
}
|
|
return d.StorageAPI.RenameData(ctx, sv, sp, fi, dv, dp, opts)
|
|
}
|
|
|
|
// An ordinary upload whose completion failed after staging stays usable as
|
|
// on main: the client re-uploads its parts and completes, or aborts.
|
|
func TestMultipartFailedCommitRetriesLikeMain(t *testing.T) {
|
|
for _, abort := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("abort=%v", abort), func(t *testing.T) {
|
|
z, bucket := consistencyPools(t)
|
|
initConfigSubsystem(t.Context(), z)
|
|
const object = "public-upload"
|
|
er := z.serverPools[0].getHashedSet(object)
|
|
ctx := t.Context()
|
|
mp, err := er.NewMultipartUpload(ctx, bucket, object, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := []byte("ordinary multipart data")
|
|
putPart := func() (PartInfo, error) {
|
|
return er.PutObjectPart(ctx, bucket, object, mp.UploadID, 1,
|
|
mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{})
|
|
}
|
|
p, err := putPart()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
disks := er.getDisks()
|
|
wrapped := make([]StorageAPI, len(disks))
|
|
for i, d := range disks {
|
|
wrapped[i] = restoreTwoPhaseStagedCommitFailure{d, bucket, object}
|
|
}
|
|
previous := er.getDisks
|
|
er.getDisks = func() []StorageAPI { return wrapped }
|
|
_, err = er.CompleteMultipartUpload(ctx, bucket, object, mp.UploadID, []CompletePart{{PartNumber: 1, ETag: p.ETag}}, ObjectOptions{})
|
|
er.getDisks = previous
|
|
if err == nil {
|
|
t.Fatal("fault injection failed")
|
|
}
|
|
if _, err := er.GetMultipartInfo(ctx, bucket, object, mp.UploadID, ObjectOptions{}); err != nil {
|
|
t.Fatalf("upload after a failed commit: %v", err)
|
|
}
|
|
if abort {
|
|
if err := er.AbortMultipartUpload(ctx, bucket, object, mp.UploadID, ObjectOptions{}); err != nil {
|
|
t.Fatalf("abort after a failed commit: %v", err)
|
|
}
|
|
if _, err := er.GetMultipartInfo(ctx, bucket, object, mp.UploadID, ObjectOptions{}); !errors.As(err, &InvalidUploadID{}) {
|
|
t.Fatalf("aborted upload still answers: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if p, err = putPart(); err != nil {
|
|
t.Fatalf("re-upload a part after a failed commit: %v", err)
|
|
}
|
|
if _, err := er.CompleteMultipartUpload(ctx, bucket, object, mp.UploadID, []CompletePart{{PartNumber: 1, ETag: p.ETag}}, ObjectOptions{}); err != nil {
|
|
t.Fatalf("complete after re-uploading: %v", err)
|
|
}
|
|
gr, err := er.GetObjectNInfo(ctx, bucket, object, nil, http.Header{}, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer gr.Close()
|
|
if got, err := io.ReadAll(gr); err != nil || !bytes.Equal(got, body) {
|
|
t.Fatalf("completed object = %q, %v", got, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// restoreTwoPhaseFaults are the faults restoreTwoPhaseDisk injects, shared by
|
|
// a test and its drives so that the test can turn them off.
|
|
type restoreTwoPhaseFaults struct {
|
|
// rename: "" passes the publication through, "fail" fails it without
|
|
// applying it, "apply-fail" applies it and then reports a failure.
|
|
rename atomic.Value
|
|
// oldDataDir, when set, replaces the old data directory a publication
|
|
// reports.
|
|
oldDataDir atomic.Pointer[string]
|
|
// writeFails fails this many metadata writes of the object.
|
|
writeFails atomic.Int32
|
|
// writes counts the metadata writes of the object.
|
|
writes atomic.Int32
|
|
// recreateTmp drops the staging directory before the second part is
|
|
// written, as a server restart moves the tmp volume away.
|
|
recreateTmp atomic.Bool
|
|
}
|
|
|
|
func (f *restoreTwoPhaseFaults) renameMode() string {
|
|
m, _ := f.rename.Load().(string)
|
|
return m
|
|
}
|
|
|
|
type restoreTwoPhaseDisk struct {
|
|
StorageAPI
|
|
bucket, object string
|
|
f *restoreTwoPhaseFaults
|
|
}
|
|
|
|
func (d restoreTwoPhaseDisk) RenameData(ctx context.Context, sv, sp string, fi FileInfo, dv, dp string, opts RenameOptions) (RenameDataResp, error) {
|
|
if sv != minioMetaTmpBucket || dv != d.bucket || dp != d.object {
|
|
return d.StorageAPI.RenameData(ctx, sv, sp, fi, dv, dp, opts)
|
|
}
|
|
switch d.f.renameMode() {
|
|
case "fail":
|
|
return RenameDataResp{}, errFaultyDisk
|
|
case "apply-fail":
|
|
if _, err := d.StorageAPI.RenameData(ctx, sv, sp, fi, dv, dp, opts); err != nil {
|
|
return RenameDataResp{}, err
|
|
}
|
|
return RenameDataResp{}, errFaultyDisk
|
|
}
|
|
resp, err := d.StorageAPI.RenameData(ctx, sv, sp, fi, dv, dp, opts)
|
|
if old := d.f.oldDataDir.Load(); old != nil {
|
|
resp.OldDataDir = *old
|
|
}
|
|
return resp, err
|
|
}
|
|
|
|
func (d restoreTwoPhaseDisk) WriteMetadata(ctx context.Context, orig, vol, path string, fi FileInfo) error {
|
|
if vol == d.bucket && path == d.object {
|
|
d.f.writes.Add(1)
|
|
if d.f.writeFails.Load() > 0 {
|
|
d.f.writeFails.Add(-1)
|
|
return errFaultyDisk
|
|
}
|
|
}
|
|
return d.StorageAPI.WriteMetadata(ctx, orig, vol, path, fi)
|
|
}
|
|
|
|
func (d restoreTwoPhaseDisk) CreateFile(ctx context.Context, origvolume, volume, path string, size int64, reader io.Reader) error {
|
|
if volume == minioMetaTmpBucket && strings.HasSuffix(path, "/part.2") && d.f.recreateTmp.Load() {
|
|
tmpID, _, _ := strings.Cut(path, "/")
|
|
if err := os.RemoveAll(filepath.Join(d.Endpoint().Path, minioMetaTmpBucket, tmpID)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return d.StorageAPI.CreateFile(ctx, origvolume, volume, path, size, reader)
|
|
}
|
|
|
|
// restoreTwoPhaseFaulty wraps the drives of the object's set in the first
|
|
// pool; faults[i] is nil for a drive left as it is. It returns the plain
|
|
// drives, for reads that see what is stored.
|
|
func restoreTwoPhaseFaulty(t *testing.T, r *restoreTest, faults ...*restoreTwoPhaseFaults) []StorageAPI {
|
|
t.Helper()
|
|
er := r.z.serverPools[0].getHashedSet(r.object)
|
|
disks := er.getDisks()
|
|
previous := er.getDisks
|
|
t.Cleanup(func() { er.getDisks = previous })
|
|
wrapped := append([]StorageAPI(nil), disks...)
|
|
for i, f := range faults {
|
|
if f != nil {
|
|
wrapped[i] = restoreTwoPhaseDisk{StorageAPI: disks[i], bucket: r.bucket, object: r.object, f: f}
|
|
}
|
|
}
|
|
er.getDisks = func() []StorageAPI { return wrapped }
|
|
return disks
|
|
}
|
|
|
|
// restoreTwoPhaseEntries reads the version from each drive.
|
|
func restoreTwoPhaseEntries(t *testing.T, r *restoreTest, disks []StorageAPI, versionID string) []FileInfo {
|
|
t.Helper()
|
|
infos, errs := readAllFileInfo(t.Context(), disks, "", r.bucket, r.object, restoreTestVersionKey(versionID), false, false)
|
|
for i, err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("drive %d: %v", i, err)
|
|
}
|
|
}
|
|
return infos
|
|
}
|
|
|
|
func restoreTwoPhaseUnrestored(fi FileInfo) bool {
|
|
return fi.Metadata[xhttp.AmzRestore] == "" && fi.Metadata[restoreAttemptKey] == "" && fi.IsRemote()
|
|
}
|
|
|
|
func restoreTwoPhaseRestored(fi FileInfo) bool {
|
|
st, err := parseRestoreObjStatus(fi.Metadata[xhttp.AmzRestore])
|
|
return err == nil && !st.Ongoing() && !fi.IsRemote() && fi.Metadata[restoreAttemptKey] == ""
|
|
}
|
|
|
|
// restoreTwoPhaseDelays shortens the convergence retries for a test.
|
|
func restoreTwoPhaseDelays(t *testing.T, delays ...time.Duration) {
|
|
previous := restorePublicationDelays
|
|
restorePublicationDelays = delays
|
|
t.Cleanup(func() { restorePublicationDelays = previous })
|
|
}
|
|
|
|
// A publication that reaches fewer drives than the write quorum is brought
|
|
// back to the unrestored version on every drive, under the same lock hold.
|
|
// No directory is deleted: the staged one is left to abandoned-data cleanup.
|
|
// The version restores again once the drives work.
|
|
func TestRestorePublicationBelowQuorumReverts(t *testing.T) {
|
|
for _, multipart := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("multipart=%v", multipart), func(t *testing.T) {
|
|
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
|
oi, body := nativeRestoreSeed(t, r, 0, false, multipart)
|
|
faults := &restoreTwoPhaseFaults{}
|
|
faults.rename.Store("fail")
|
|
disks := restoreTwoPhaseFaulty(t, r, faults, faults)
|
|
before := restoreTwoPhaseEntries(t, r, disks, oi.VersionID)
|
|
err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
|
|
if err == nil {
|
|
t.Fatal("a publication on two of four drives succeeded")
|
|
}
|
|
for i, fi := range restoreTwoPhaseEntries(t, r, disks, oi.VersionID) {
|
|
if !restoreTwoPhaseUnrestored(fi) || fi.DataDir != before[i].DataDir {
|
|
t.Errorf("drive %d after a failed publication: restore %q, remote %v, data dir %s (was %s)",
|
|
i, fi.Metadata[xhttp.AmzRestore], fi.IsRemote(), fi.DataDir, before[i].DataDir)
|
|
}
|
|
}
|
|
// The drives that published keep the staged directory.
|
|
for i := 2; i < len(disks); i++ {
|
|
entries, err := os.ReadDir(filepath.Join(disks[i].Endpoint().Path, r.bucket, r.object))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dirs := 0
|
|
for _, e := range entries {
|
|
if e.IsDir() && e.Name() != before[i].DataDir {
|
|
dirs++
|
|
}
|
|
}
|
|
if dirs == 0 {
|
|
t.Errorf("drive %d: the published data directory was deleted", i)
|
|
}
|
|
}
|
|
faults.rename.Store("")
|
|
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err != nil {
|
|
t.Fatalf("restore after the drives recovered: %v", err)
|
|
}
|
|
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
|
t.Fatalf("restored contents: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A publication whose drives applied it but did not answer is found on a
|
|
// write quorum when the worker reads the drives again: the restore stands.
|
|
func TestRestorePublicationUncertainCommits(t *testing.T) {
|
|
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
|
oi, body := nativeRestoreSeed(t, r, 0, false, true)
|
|
faults := &restoreTwoPhaseFaults{}
|
|
faults.rename.Store("apply-fail")
|
|
disks := restoreTwoPhaseFaulty(t, r, faults, faults)
|
|
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err != nil {
|
|
t.Fatalf("restore whose answers were lost: %v", err)
|
|
}
|
|
for i, fi := range restoreTwoPhaseEntries(t, r, disks, oi.VersionID) {
|
|
if !restoreTwoPhaseRestored(fi) {
|
|
t.Errorf("drive %d: not restored: %q", i, fi.Metadata[xhttp.AmzRestore])
|
|
}
|
|
}
|
|
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
|
t.Fatalf("restored contents: %v", err)
|
|
}
|
|
}
|
|
|
|
// A convergence that cannot write every drive under the commit's lock hold
|
|
// finishes in a background retry.
|
|
func TestRestoreConvergenceRetries(t *testing.T) {
|
|
restoreTwoPhaseDelays(t, 50*time.Millisecond, 50*time.Millisecond, 50*time.Millisecond)
|
|
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
|
oi, _ := nativeRestoreSeed(t, r, 0, false, false)
|
|
failing, flaky := &restoreTwoPhaseFaults{}, &restoreTwoPhaseFaults{}
|
|
failing.rename.Store("fail")
|
|
flaky.writeFails.Store(1)
|
|
disks := restoreTwoPhaseFaulty(t, r, failing, failing, flaky)
|
|
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err == nil {
|
|
t.Fatal("a publication on two of four drives succeeded")
|
|
}
|
|
deadline := time.Now().Add(restoreTestWait)
|
|
for {
|
|
settled := true
|
|
for _, fi := range restoreTwoPhaseEntries(t, r, disks, oi.VersionID) {
|
|
settled = settled && restoreTwoPhaseUnrestored(fi)
|
|
}
|
|
if settled {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("background convergence did not finish")
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// A convergence retry never overwrites what someone else wrote between two
|
|
// lock holds: a metadata update or a newer attempt's marker stops it.
|
|
func TestRestoreConvergenceStopsOnOtherWrites(t *testing.T) {
|
|
for _, other := range []string{"tags", "successor"} {
|
|
t.Run(other, func(t *testing.T) {
|
|
restoreTwoPhaseDelays(t, 300*time.Millisecond, 300*time.Millisecond, 300*time.Millisecond)
|
|
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
|
initConfigSubsystem(t.Context(), r.z)
|
|
oi, body := restoreIsolationSeed(t, r, 0, false, false)
|
|
failing, stuck := &restoreTwoPhaseFaults{}, &restoreTwoPhaseFaults{}
|
|
failing.rename.Store("fail")
|
|
stuck.writeFails.Store(1 << 20)
|
|
disks := restoreTwoPhaseFaulty(t, r, failing, failing, stuck)
|
|
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err == nil {
|
|
t.Fatal("a publication on two of four drives succeeded")
|
|
}
|
|
var successor string
|
|
release := make(chan struct{})
|
|
layer := newRestoreIsolationLayer(r)
|
|
switch other {
|
|
case "tags":
|
|
if _, err := r.z.PutObjectTags(t.Context(), r.bucket, r.object, "kept=yes", r.opts(oi.VersionID)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case "successor":
|
|
layer.restore = func(ctx context.Context, bucket, object string, opts ObjectOptions) error {
|
|
<-release
|
|
return r.z.RestoreTransitionedObject(ctx, bucket, object, opts)
|
|
}
|
|
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
|
|
close(release)
|
|
t.Fatalf("successor POST: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
successor = restoreTwoPhaseEntries(t, r, disks, oi.VersionID)[0].Metadata[restoreAttemptKey]
|
|
if successor == "" {
|
|
close(release)
|
|
t.Fatal("successor POST recorded no attempt")
|
|
}
|
|
}
|
|
writes := stuck.writes.Load()
|
|
time.Sleep(time.Second)
|
|
if got := stuck.writes.Load(); got != writes {
|
|
t.Errorf("a stopped convergence kept writing: %d -> %d writes", writes, got)
|
|
}
|
|
for i, fi := range restoreTwoPhaseEntries(t, r, disks, oi.VersionID) {
|
|
if i == 2 {
|
|
continue // the drive the convergence could not write
|
|
}
|
|
switch other {
|
|
case "tags":
|
|
if fi.Metadata[xhttp.AmzObjectTagging] != "kept=yes" {
|
|
t.Errorf("drive %d: the tags written between retries were overwritten: %q", i, fi.Metadata[xhttp.AmzObjectTagging])
|
|
}
|
|
case "successor":
|
|
if fi.Metadata[restoreAttemptKey] != successor {
|
|
t.Errorf("drive %d: the successor's marker was overwritten: %q", i, fi.Metadata[restoreAttemptKey])
|
|
}
|
|
}
|
|
}
|
|
if other == "successor" {
|
|
failing.rename.Store("")
|
|
stuck.writeFails.Store(0)
|
|
close(release)
|
|
if err := layer.wait(t); err != nil {
|
|
t.Fatalf("successor restore: %v", err)
|
|
}
|
|
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
|
t.Fatalf("restored contents: %v", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The data directory of the replaced entry goes only where every drive that
|
|
// reports it agrees on it; a drive that does not report it, as when another
|
|
// version shares the directory, keeps the directory everywhere.
|
|
func TestRestoreRemovesOldDataDirOnlyWhenUnanimous(t *testing.T) {
|
|
for _, mixed := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("mixed=%v", mixed), func(t *testing.T) {
|
|
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
|
oi, body := nativeRestoreSeed(t, r, 0, false, true)
|
|
old := r.fileInfo(oi.VersionID).DataDir
|
|
if old == "" {
|
|
t.Fatal("fixture: the transitioned version has no data directory")
|
|
}
|
|
faults := &restoreTwoPhaseFaults{}
|
|
if mixed {
|
|
none := ""
|
|
faults.oldDataDir.Store(&none)
|
|
}
|
|
restoreTwoPhaseFaulty(t, r, faults)
|
|
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
|
t.Fatalf("restored contents: %v", err)
|
|
}
|
|
n := r.dataDirs(old)
|
|
if mixed && n != 4 {
|
|
t.Errorf("drives disagreed on the old data directory, but %d of 4 kept it", n)
|
|
}
|
|
if !mixed && n != 0 {
|
|
t.Errorf("the unanimous old data directory remained on %d drives", n)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// An earlier restore of a small object may have inlined its data, and the
|
|
// expiry of that copy leaves the inline marker behind. A new restore writes
|
|
// a data directory and must not publish the marker with it.
|
|
func TestRestoreDropsStaleInlineMarker(t *testing.T) {
|
|
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
|
oi, body := nativeRestoreSeed(t, r, 0, false, false)
|
|
disks := r.z.serverPools[0].getHashedSet(r.object).getDisks()
|
|
for i, fi := range restoreTwoPhaseEntries(t, r, disks, oi.VersionID) {
|
|
fi.SetInlineData()
|
|
if err := disks[i].WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i, fi := range restoreTwoPhaseEntries(t, r, disks, oi.VersionID) {
|
|
if fi.InlineData() {
|
|
t.Errorf("drive %d: the restored copy is marked inline", i)
|
|
}
|
|
}
|
|
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
|
t.Fatalf("restored contents: %v", err)
|
|
}
|
|
}
|
|
|
|
// A drive whose staging directory was moved away and recreated by a later
|
|
// part misses the earlier parts: it is left out of the commit, and when too
|
|
// few drives are left nothing is published at all.
|
|
func TestRestoreStagingDropsRecreatedTmp(t *testing.T) {
|
|
for _, recreated := range []int{1, 2} {
|
|
t.Run(fmt.Sprintf("drives=%d", recreated), func(t *testing.T) {
|
|
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
|
oi, body := nativeRestoreSeed(t, r, 0, false, true)
|
|
faults := &restoreTwoPhaseFaults{}
|
|
faults.recreateTmp.Store(true)
|
|
wrap := make([]*restoreTwoPhaseFaults, recreated)
|
|
for i := range wrap {
|
|
wrap[i] = faults
|
|
}
|
|
disks := restoreTwoPhaseFaulty(t, r, wrap...)
|
|
err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
|
|
entries := restoreTwoPhaseEntries(t, r, disks, oi.VersionID)
|
|
if recreated == 2 {
|
|
if err == nil {
|
|
t.Fatal("a copy staged completely on two of four drives was published")
|
|
}
|
|
for i, fi := range entries {
|
|
if !restoreTwoPhaseUnrestored(fi) {
|
|
t.Errorf("drive %d after a failed staging: %q", i, fi.Metadata[xhttp.AmzRestore])
|
|
}
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if restoreTwoPhaseRestored(entries[0]) {
|
|
t.Error("the drive with missing parts got the restored copy")
|
|
}
|
|
for i := 1; i < len(entries); i++ {
|
|
if !restoreTwoPhaseRestored(entries[i]) {
|
|
t.Errorf("drive %d: not restored", i)
|
|
}
|
|
}
|
|
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
|
t.Fatalf("restored contents: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A POST while the worker of an earlier POST holds the restore lock answers
|
|
// that a restore is in progress; it starts no second worker.
|
|
func TestRestoreSecondPOSTWhileRunning(t *testing.T) {
|
|
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
|
initConfigSubsystem(t.Context(), r.z)
|
|
oi, body := restoreIsolationSeed(t, r, 0, false, true)
|
|
release := make(chan struct{})
|
|
layer := newRestoreIsolationLayer(r)
|
|
layer.restore = func(ctx context.Context, bucket, object string, opts ObjectOptions) error {
|
|
<-release
|
|
return r.z.RestoreTransitionedObject(ctx, bucket, object, opts)
|
|
}
|
|
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
|
|
t.Fatalf("first POST: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
rec := restoreIsolationPOST(t, r, layer, oi.VersionID)
|
|
close(release)
|
|
if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "RestoreAlreadyInProgress") {
|
|
t.Errorf("second POST: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if err := layer.wait(t); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case err := <-layer.done:
|
|
t.Fatalf("a second restore ran: %v", err)
|
|
default:
|
|
}
|
|
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
|
t.Fatalf("restored contents: %v", err)
|
|
}
|
|
}
|
|
|
|
// A version deleted while its tier copy downloads is not brought back.
|
|
func TestRestoreTargetDeletedDuringDownload(t *testing.T) {
|
|
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
|
initConfigSubsystem(t.Context(), r.z)
|
|
oi, _ := nativeRestoreSeed(t, r, 0, false, false)
|
|
gate := newRestoreIsolationGateTier(1, nil)
|
|
globalTierConfigMgr.drivercache["WARM"] = gate
|
|
defer gate.unblock()
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
|
|
}()
|
|
restoreIsolationEntered(t, gate)
|
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
|
_, err := r.z.DeleteObject(ctx, r.bucket, r.object, r.opts(oi.VersionID))
|
|
cancel()
|
|
if err != nil {
|
|
t.Fatalf("delete during the download: %v", err)
|
|
}
|
|
gate.unblock()
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
t.Fatal("the restore of a deleted version succeeded")
|
|
}
|
|
case <-time.After(restoreTestWait):
|
|
t.Fatal("restore did not finish")
|
|
}
|
|
if _, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID)); !isErrVersionNotFound(err) && !isErrObjectNotFound(err) {
|
|
t.Fatalf("deleted version after the restore: %v", err)
|
|
}
|
|
}
|
|
|
|
// With several pools, abandoned-data cleanup takes the pools lock that every
|
|
// write of the object takes, also where a pool's sets have lockers of their
|
|
// own: while a writer holds it, cleanup removes nothing, such as a data
|
|
// directory that a commit moved in place and has not yet referenced.
|
|
func TestCheckAbandonedPartsTakesPoolsLock(t *testing.T) {
|
|
r := newRestoreSafetyTest(t, 2, versioning.Enabled)
|
|
nativeRestoreSeed(t, r, 1, false, false)
|
|
for _, set := range r.z.serverPools[1].sets {
|
|
previous := set.nsMutex
|
|
set.nsMutex = newNSLock(false)
|
|
t.Cleanup(func() { set.nsMutex = previous })
|
|
}
|
|
disks := r.z.serverPools[1].getHashedSet(r.object).getDisks()
|
|
moved := mustGetUUID()
|
|
for _, disk := range disks {
|
|
dir := filepath.Join(disk.Endpoint().Path, r.bucket, r.object, moved)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "part.1"), []byte("staged"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
present := func() int {
|
|
n := 0
|
|
for _, disk := range disks {
|
|
if _, err := os.Stat(filepath.Join(disk.Endpoint().Path, r.bucket, r.object, moved)); err == nil {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
lk := r.z.NewObjectNSLock(r.bucket, r.object)
|
|
lkctx, err := lk.GetLock(t.Context(), globalOperationTimeout)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
|
|
r.z.CheckAbandonedParts(ctx, r.bucket, r.object, madmin.HealOpts{Remove: true})
|
|
cancel()
|
|
held := present()
|
|
lk.Unlock(lkctx)
|
|
if held != len(disks) {
|
|
t.Fatalf("abandoned-data cleanup removed a moved directory from %d drives while a writer held the object lock", len(disks)-held)
|
|
}
|
|
if err := r.z.CheckAbandonedParts(t.Context(), r.bucket, r.object, madmin.HealOpts{Remove: true}); err != nil {
|
|
t.Fatalf("abandoned-data cleanup without a writer: %v", err)
|
|
}
|
|
if n := present(); n != 0 {
|
|
t.Fatalf("abandoned-data cleanup left the unreferenced directory on %d drives", n)
|
|
}
|
|
}
|
|
|
|
// The header flag that tells whether a transitioned version uses its data
|
|
// directory follows whether its restored copy has expired when a drive writes
|
|
// the entry. A publication's own entry is recognized with either value.
|
|
func TestRestorePublishedRevisionAcrossExpiry(t *testing.T) {
|
|
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
|
oi, _ := nativeRestoreSeed(t, r, 0, false, false)
|
|
fi := r.fileInfo(oi.VersionID)
|
|
expiry := time.Now().Add(2 * time.Second).Truncate(time.Second).Add(time.Second)
|
|
st := &restoreStage{dataDir: mustGetUUID(), expiry: expiry}
|
|
published := restorePublished(fi, fi, st, 0)
|
|
keys, err := fileInfoRevisions(published)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stored := func() revisionKey {
|
|
var x xlMetaV2
|
|
if err := x.AddVersion(published); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
buf, err := x.AppendTo(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var y xlMetaV2
|
|
if err := y.Load(buf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return versionRevision(y.versions[0], y.metaV)
|
|
}
|
|
before := stored()
|
|
time.Sleep(time.Until(expiry) + 100*time.Millisecond)
|
|
after := stored()
|
|
if before == after {
|
|
t.Fatal("fixture: the stored flag did not change at the expiry")
|
|
}
|
|
for _, got := range []revisionKey{before, after} {
|
|
if got != keys[0] && got != keys[1] {
|
|
t.Errorf("a publication's own entry is not recognized: %v not in %v", got, keys)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A tier that lost the copy answers not found. That is no reason to keep the
|
|
// version's ongoing marker: the attempt clears it, and a later POST can
|
|
// restore the version again.
|
|
func TestRestoreTierNotFoundClearsMarker(t *testing.T) {
|
|
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
|
initConfigSubsystem(t.Context(), r.z)
|
|
oi, _ := restoreIsolationSeed(t, r, 0, false, false)
|
|
tier := globalTierConfigMgr.drivercache["WARM"]
|
|
globalTierConfigMgr.drivercache["WARM"] = nativeRestoreFailTier{tier, ObjectNotFound{Bucket: r.bucket, Object: r.object}}
|
|
layer := newRestoreIsolationLayer(r)
|
|
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
|
|
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if err := layer.wait(t); !isErrObjectNotFound(err) {
|
|
t.Fatalf("restore from a tier without the copy: %v", err)
|
|
}
|
|
info, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
|
|
if err != nil || info.RestoreOngoing || info.UserDefined[xhttp.AmzRestore] != "" {
|
|
t.Fatalf("version after the failed restore: %q, %v", info.UserDefined[xhttp.AmzRestore], err)
|
|
}
|
|
}
|
|
|
|
// newRestoreWideTest is newRestoreSafetyTest with one pool of twelve drives,
|
|
// whose default layout is eight data and four parity shards: a version's
|
|
// metadata reaches its read quorum on seven drives, one short of the write
|
|
// quorum of eight that its shards need.
|
|
func newRestoreWideTest(t *testing.T) *restoreTest {
|
|
t.Helper()
|
|
prevTier := globalTierConfigMgr
|
|
t.Cleanup(func() { globalTierConfigMgr = prevTier })
|
|
dirs, err := getRandomDisks(12)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { removeRoots(dirs) })
|
|
obj, _, err := initObjectLayer(t.Context(), mustGetPoolEndpoints(0, dirs...))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
z := obj.(*erasureServerPools)
|
|
t.Cleanup(func() { z.Shutdown(context.Background()) })
|
|
// Other tests leave a storage class behind; fix the layout to 8+4.
|
|
saved := globalStorageClass
|
|
globalStorageClass.Update(storageclass.Config{Standard: storageclass.StorageClass{Parity: 4}})
|
|
t.Cleanup(func() { globalStorageClass.Update(saved) })
|
|
globalTierConfigMgr = NewTierConfigMgr()
|
|
globalTierConfigMgr.drivercache["WARM"] = &restoreTestTier{tierExpiryTestBackend: &tierExpiryTestBackend{objects: map[string][]byte{}}}
|
|
r := &restoreTest{t: t, z: z, bucket: getRandomBucketName(), object: "obj", labels: map[string]string{}, bodies: map[string][]byte{}}
|
|
if err := z.MakeBucket(t.Context(), r.bucket, MakeBucketOptions{VersioningEnabled: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta, err := globalBucketMetadataSys.Get(r.bucket)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta.LifecycleConfigXML = []byte(restoreSafetyRules)
|
|
if err := meta.Save(t.Context(), z); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
globalBucketMetadataSys.Set(r.bucket, meta)
|
|
return r
|
|
}
|
|
|
|
// On twelve drives with eight data shards, a publication that reached seven
|
|
// drives has the metadata read quorum but not the eight shards a read needs.
|
|
// It is not a restore: the worker brings every drive back to the unrestored
|
|
// version, and the version reads from its tier copy.
|
|
func TestRestorePublicationBelowWriteQuorumOnWideSet(t *testing.T) {
|
|
r := newRestoreWideTest(t)
|
|
oi, body := nativeRestoreSeed(t, r, 0, false, true)
|
|
fi := r.fileInfo(oi.VersionID)
|
|
if fi.Erasure.DataBlocks != 8 || fi.Erasure.ParityBlocks != 4 {
|
|
t.Fatalf("fixture layout %d+%d, want 8+4", fi.Erasure.DataBlocks, fi.Erasure.ParityBlocks)
|
|
}
|
|
faults := &restoreTwoPhaseFaults{}
|
|
faults.rename.Store("fail")
|
|
disks := restoreTwoPhaseFaulty(t, r, faults, faults, faults, faults, faults)
|
|
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err == nil {
|
|
t.Fatal("a publication on seven of twelve drives succeeded")
|
|
}
|
|
for i, e := range restoreTwoPhaseEntries(t, r, disks, oi.VersionID) {
|
|
if !restoreTwoPhaseUnrestored(e) || e.DataDir != fi.DataDir {
|
|
t.Errorf("drive %d after a publication on seven drives: restore %q, remote %v", i, e.Metadata[xhttp.AmzRestore], e.IsRemote())
|
|
}
|
|
}
|
|
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
|
t.Fatalf("the version no longer reads from its tier copy: %v", err)
|
|
}
|
|
faults.rename.Store("")
|
|
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err != nil {
|
|
t.Fatalf("restore after the drives recovered: %v", err)
|
|
}
|
|
for i, e := range restoreTwoPhaseEntries(t, r, disks, oi.VersionID) {
|
|
if !restoreTwoPhaseRestored(e) {
|
|
t.Errorf("drive %d: not restored", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A commit that reaches a write quorum but not every drive queues a heal of
|
|
// the version it restored: for the null version, the normalized null ID that
|
|
// the convergence path queues too. That heal repairs the null version on the
|
|
// drive that missed the commit, also where the null version is noncurrent.
|
|
func TestRestorePartialCommitQueuesNullVersionHeal(t *testing.T) {
|
|
setTestStandardParity(t, 2)
|
|
r := newRestoreSafetyTest(t, 1, versioning.Suspended)
|
|
oi, body := nativeRestoreSeed(t, r, 0, true, false)
|
|
// A version written while versioning was enabled makes the null version
|
|
// noncurrent.
|
|
newer := []byte("newer")
|
|
if _, err := r.z.PutObject(t.Context(), r.bucket, r.object,
|
|
mustGetPutObjReader(t, bytes.NewReader(newer), int64(len(newer)), "", ""), ObjectOptions{Versioned: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
failing := &restoreTwoPhaseFaults{}
|
|
failing.rename.Store("fail")
|
|
disks := restoreTwoPhaseFaulty(t, r, nil, nil, nil, failing)
|
|
// Observe the MRF entries in place of the queue, which the heal routines
|
|
// of the test servers consume.
|
|
var mu sync.Mutex
|
|
var queued []PartialOperation
|
|
hook := func(op PartialOperation) {
|
|
if op.Bucket == r.bucket && op.Object == r.object {
|
|
mu.Lock()
|
|
queued = append(queued, op)
|
|
mu.Unlock()
|
|
}
|
|
}
|
|
mrfPartialOpHook.Store(&hook)
|
|
t.Cleanup(func() { mrfPartialOpHook.Store(nil) })
|
|
err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
|
|
mrfPartialOpHook.Store(nil)
|
|
if err != nil {
|
|
t.Fatalf("restore with one drive failing its commit: %v", err)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(queued) != 1 || queued[0].VersionID != nullVersionID {
|
|
t.Fatalf("queued heals %+v, want one of version %q", queued, nullVersionID)
|
|
}
|
|
if restoreTwoPhaseRestored(restoreTwoPhaseEntries(t, r, disks, oi.VersionID)[3]) {
|
|
t.Fatal("the drive that failed the commit holds the restored copy")
|
|
}
|
|
|
|
failing.rename.Store("")
|
|
if _, err := r.z.HealObject(t.Context(), r.bucket, r.object, queued[0].VersionID, madmin.HealOpts{ScanMode: madmin.HealNormalScan}); err != nil {
|
|
t.Fatalf("heal of the queued version: %v", err)
|
|
}
|
|
for i, fi := range restoreTwoPhaseEntries(t, r, disks, oi.VersionID) {
|
|
if !restoreTwoPhaseRestored(fi) {
|
|
t.Errorf("drive %d: not restored after the heal: %q", i, fi.Metadata[xhttp.AmzRestore])
|
|
}
|
|
}
|
|
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
|
t.Fatalf("restored contents: %v", err)
|
|
}
|
|
if latest, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, ObjectOptions{}); err != nil || latest.VersionID == "" || latest.VersionID == nullVersionID {
|
|
t.Fatalf("latest = %q, %v; want the newer version", latest.VersionID, err)
|
|
}
|
|
}
|