mirror of
https://github.com/pgsty/minio.git
synced 2026-11-09 17:21:51 +03:00
45b1e4bc00
The unreleased native-restore fixes after RELEASE.2026-10-26T00-00-00Z let the lock decide who owns a restore. Sincefa42a0982a single-pool worker holds the object lock for the whole tier download, and since7f38bf628a busy lock means "a restore runs".7f38bf628also 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 to69fcf50cd. 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 onf85d4a5fa, 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 from7f38bf628are dropped, and so is TestNativeRestoreConcurrentCompletion fromfa42a0982, 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>
859 lines
33 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|