Files
minio/cmd/restore-two-phase_test.go
T
Feng Ruohang 45b1e4bc00 fix(restore): stage native restores outside the object lock
The unreleased native-restore fixes after RELEASE.2026-10-26T00-00-00Z
let the lock decide who owns a restore. Since fa42a0982 a single-pool
worker holds the object lock for the whole tier download, and since
7f38bf628 a busy lock means "a restore runs". 7f38bf628 also keeps the
restore's data in a marked multipart upload. Two regressions followed:

- While an old version restored, every GET, HEAD and write of the same
  key waited for the download. On a single pool this is new; with
  several pools the released code already held the lock that long.
- An ordinary upload whose completion failed after staging its metadata
  became InvalidUploadID for every caller, and could not be aborted for
  an hour. The marker that hid restore uploads could not tell them apart
  once a completion had rewritten the upload's xl.meta.

A restore now runs in two phases:

- The worker erasure-codes the tier copy into the tmp volume of the
  version's set, without the object lock, as heal does. A drive that
  fails any part, or misses a part when CheckParts runs before the
  commit, takes no part in the commit.
- A restore lock per version tells whether a restore runs. POST takes it
  before the object lock and hands it to the worker. It is a liveness
  hint: only the checks under the object lock decide what is written.
- The commit takes the object lock briefly, on one pool or several. It
  reads the version again and checks the attempt's ownership, the
  version identity, the tier copy, the part layout and the erasure
  layout. It then publishes with RenameData drive by drive, never
  through erasure renameData's undo, and needs the write quorum. The
  replaced data directory goes only when every published drive reports
  the same one.
- A publication that reached some drives but not the write quorum
  converges under the same lock hold, in bounded retries after it: back
  to the unrestored version on every drive that holds it, or to a
  committed restore when the copy turns out to be on a write quorum.
  Each pass first checks that every drive holds what the version held
  before the commit, the commit wrote, or a convergence wrote, and stops
  on anything else. It deletes no directory.

Multipart goes back to main: erasure-multipart.go and
bucket-object-lock.go are identical to 69fcf50cd. The commit lock, the
marker and the restore exceptions of multipart completion and of the
null-version Object Lock check are gone, and single-pool completion
again takes the upload lock first and the object lock right before it
commits. With several pools, abandoned-data cleanup now takes the pools
lock that writes take, as heal does, so it cannot remove a directory
that a commit moved in place and has not yet referenced. The restore
capability becomes protocol 2, and a queued worker checks it again.

Recovery after a coordinator crash during publication, and storage RPCs
that complete after their caller returned, stay excluded. Main has the
same exposure, and they are tracked separately.

Tests:
- New regression tests cover both problems. They fail on f85d4a5fa,
  and the multipart one also passes on main.
- Fault-injection tests cover a publication below the write quorum,
  including seven of twelve drives on 8+4; lost answers; convergence
  retries and stops; the old data directory; a stale inline marker; a
  recreated tmp volume; a published revision read across its expiry; a
  second POST; a target deleted during the download; a tier 404; and
  pools-locked abandoned cleanup.
- Thirteen tests of the upload marker from 7f38bf628 are dropped, and
  so is TestNativeRestoreConcurrentCompletion from fa42a0982, which
  completed the restore's upload from a client. Four tests are reframed
  for the new semantics, including the lock-domain restore test, which
  now gates the commit.
- Three self-recursive WriteMetadata test doubles are fixed, and the
  helpers of the dropped tests are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-06 11:30:29 +08:00

859 lines
33 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/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)
}
}
}