mirror of
https://github.com/pgsty/minio.git
synced 2026-11-10 17:45:32 +03:00
Supersedes PR #279 and PR #282 and keeps their contributor commits. Native restores stage outside the object lock and commit under a short object lock (#284); ordinary multipart completion returns to main's behavior.
This commit is contained in:
@@ -96,10 +96,15 @@ jobs:
|
||||
- name: Run multi-pool delete consistency tests under race detector
|
||||
run: go test -race ./cmd -run '^Test(Pools(Delete|BulkDelete|RetiringCopyPreservesSharedTierObject)|BulkDeleteQuorumEvidence)' -count=1 -timeout=5m
|
||||
|
||||
- name: Run lifecycle expiry and native restore tests under race detector
|
||||
env:
|
||||
MINIO_API_REQUESTS_MAX: "10000"
|
||||
run: go test -race -tags kqueue,dev ./cmd -run '^Test(PoolsLifecycle|PoolsMigrationLifecycle|ExpireRestored|NativeRestore)' -count=1 -timeout=5m
|
||||
|
||||
- name: Run multipart listing, cancellation and cleanup tests under race detector
|
||||
env:
|
||||
MINIO_API_REQUESTS_MAX: "10000"
|
||||
run: go test -race ./cmd -run '^Test(Multipart|PaginateMultipartUploads|ListMultipartUploads|CleanupStaleUploads|ExtractModTime)' -count=1 -timeout=5m
|
||||
run: go test -race ./cmd -run '^Test(ObjectCompleteMultipartUpload|Multipart|PaginateMultipartUploads|ListMultipartUploads|CleanupStaleUploads|ExtractModTime)' -count=1 -timeout=5m
|
||||
|
||||
- name: Run listing metadata recovery, pagination and cancellation tests under race detector
|
||||
run: go test -race ./cmd -run '^Test(ListFallback|ListingCache|ListingMerge|ListingRecovery|ListingPending|ListingEntry|ListingSecondLock|StreamMetadataParts|LocalLockerListing|DistributedListing|MergeXLV2LatestNullCrossParity|CountXLV2MixedECGroups|ListCrossParity|ListObjectVersionsCrossParityNull|MigrationCrossParity)' -count=1 -timeout=5m
|
||||
|
||||
@@ -594,11 +594,13 @@
|
||||
"x-minio-from-peer",
|
||||
"x-minio-healing-drives",
|
||||
"x-minio-internal",
|
||||
"x-minio-internal-a",
|
||||
"x-minio-internal-actual-object-size",
|
||||
"x-minio-internal-actual-size",
|
||||
"x-minio-internal-archive-info",
|
||||
"x-minio-internal-archive-type",
|
||||
"x-minio-internal-b",
|
||||
"x-minio-internal-c",
|
||||
"x-minio-internal-compression",
|
||||
"x-minio-internal-encrypted-multipart",
|
||||
"x-minio-internal-encryptedmultipart",
|
||||
@@ -611,6 +613,8 @@
|
||||
"x-minio-internal-replication-reset-arn",
|
||||
"x-minio-internal-replication-status",
|
||||
"x-minio-internal-replication-timestamp",
|
||||
"x-minio-internal-restore-attempt",
|
||||
"x-minio-internal-restore-upload",
|
||||
"x-minio-internal-server-side-encryption",
|
||||
"x-minio-internal-server-side-encryption-context",
|
||||
"x-minio-internal-server-side-encryption-iv",
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ func (r *BatchJobKeyRotateV1) KeyRotate(ctx context.Context, api ObjectLayer, ob
|
||||
versioned := globalBucketVersioningSys.PrefixEnabled(srcBucket, srcObject)
|
||||
versionSuspended := globalBucketVersioningSys.PrefixSuspended(srcBucket, srcObject)
|
||||
|
||||
lock := api.NewNSLock(r.Bucket, objInfo.Name)
|
||||
lock := api.NewObjectNSLock(r.Bucket, objInfo.Name)
|
||||
lkctx, err := lock.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
)
|
||||
|
||||
// Read the actual metadata from every disk without object-layer read repair.
|
||||
func restoreBoundarySnapshot(t *testing.T, r *restoreTest) [][]byte {
|
||||
t.Helper()
|
||||
var result [][]byte
|
||||
for _, pool := range r.z.serverPools {
|
||||
for _, disk := range pool.getHashedSet(r.object).getDisks() {
|
||||
raw, err := disk.ReadXL(t.Context(), r.bucket, r.object, true)
|
||||
if err != nil && err != errFileNotFound {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result = append(result, bytes.Clone(raw.Buf))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestExpireRestoredKeepsReplacementDeleteMarker(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
t.Run(fmt.Sprintf("pools=%d", pools), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, pools, versioning.Suspended)
|
||||
r.put("old", false, false)
|
||||
oi := r.put("null", true, true)
|
||||
r.restore(oi)
|
||||
stale, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(nullVersionID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.z.DeleteObject(t.Context(), r.bucket, r.object, r.opts("")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !r.fileInfo(nullVersionID).Deleted {
|
||||
t.Fatal("ordinary DELETE did not replace null with a delete marker")
|
||||
}
|
||||
before := restoreBoundarySnapshot(t, r)
|
||||
err = expireTransitionedObject(t.Context(), r.z, &stale, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner)
|
||||
if !isErrVersionNotFound(err) {
|
||||
t.Errorf("stale cleanup should reject the replacement marker: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, restoreBoundarySnapshot(t, r)) {
|
||||
t.Error("stale cleanup changed stored versions or their data")
|
||||
}
|
||||
if _, _, err := r.read(""); !isErrObjectNotFound(err) {
|
||||
t.Errorf("stale cleanup exposed the old version: %v", err)
|
||||
}
|
||||
// An explicit user purge must still remove that same marker.
|
||||
if _, err := r.z.DeleteObject(t.Context(), r.bucket, r.object, r.opts(nullVersionID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if label, data, err := r.read(""); err != nil || label != "old" || !bytes.Equal(data, r.bodies["old"]) {
|
||||
t.Fatalf("ordinary marker purge: label=%s err=%v", label, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireRestoredMetadataReadKeepsMinorityVersion(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
for _, operation := range []string{"cleanup", "restore", "restore-cleanup", "lookup-cleanup", "lookup-restore", "get", "delete"} {
|
||||
t.Run(fmt.Sprintf("pools=%d/%s", pools, operation), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
|
||||
oi := r.put("target", false, true)
|
||||
r.restore(oi)
|
||||
fi := r.fileInfo(oi.VersionID)
|
||||
// A readable latest version lets pools lookup finish before the
|
||||
// set reads the specifically addressed, incomplete old version.
|
||||
r.put("newer", false, false)
|
||||
er := r.z.serverPools[0].getHashedSet(r.object)
|
||||
disks := er.getDisks()
|
||||
for _, disk := range disks[1:] {
|
||||
raw, err := disk.ReadXL(t.Context(), r.bucket, r.object, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var meta xlMetaV2
|
||||
if err := meta.Load(raw.Buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
del := FileInfo{VersionID: oi.VersionID}
|
||||
del.SetSkipTierFreeVersion()
|
||||
if _, err := meta.DeleteVersion(del); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf, err := meta.AppendTo(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Remove only this metadata entry; retain every real shard.
|
||||
if err := disk.WriteAll(t.Context(), r.bucket, pathJoin(r.object, xlStorageFormatFile), buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
before := restoreBoundarySnapshot(t, r)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
opts := r.opts(oi.VersionID)
|
||||
var err error
|
||||
switch operation {
|
||||
case "cleanup":
|
||||
err = expireTransitionedObject(ctx, r.z, &oi, lifecycle.Event{Action: lifecycle.DeleteRestoredVersionAction}, lcEventSrc_Scanner)
|
||||
case "restore":
|
||||
opts.Transition.RestoreRequest = &RestoreObjectRequest{Days: 1}
|
||||
err = r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, opts)
|
||||
case "restore-cleanup":
|
||||
// The initial read succeeded before remote download failed;
|
||||
// cleanup must not purge a now-incomplete target on reread.
|
||||
err = er.clearRestoreStatus(ctx, r.bucket, r.object, oi.VersionID, nil)
|
||||
case "lookup-cleanup", "lookup-restore":
|
||||
// Version-sensitive lookup also covers native restore routing.
|
||||
opts.MetadataChg = true
|
||||
if operation == "lookup-cleanup" {
|
||||
opts.Transition.ExpireRestored = true
|
||||
} else {
|
||||
opts.Transition.RestoreRequest = &RestoreObjectRequest{Days: 1}
|
||||
}
|
||||
_, _, err = r.z.getPoolInfoExistingWithOpts(ctx, r.bucket, r.object, opts)
|
||||
case "get":
|
||||
_, err = r.z.GetObjectInfo(ctx, r.bucket, r.object, opts)
|
||||
case "delete":
|
||||
_, err = r.z.DeleteObject(ctx, r.bucket, r.object, opts)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("%s timed out instead of returning its read error: %v", operation, err)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("incomplete target unexpectedly readable")
|
||||
}
|
||||
if operation != "get" && operation != "delete" {
|
||||
if !reflect.DeepEqual(before, restoreBoundarySnapshot(t, r)) {
|
||||
t.Error("failed restore read changed metadata, inline data or free versions")
|
||||
}
|
||||
if n := r.dataDirs(fi.DataDir); n != len(disks) {
|
||||
t.Errorf("failed restore read removed shards: %d/%d directories remain", n, len(disks))
|
||||
}
|
||||
return
|
||||
}
|
||||
// Ordinary GET/DELETE retain their existing dangling cleanup.
|
||||
raw, err := disks[0].ReadXL(t.Context(), r.bucket, r.object, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions, err := getFileInfoVersions(raw.Buf, r.bucket, r.object, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(versions.Versions) != 1 || len(versions.FreeVersions) != 1 || r.dataDirs(fi.DataDir) != len(disks)-1 {
|
||||
t.Fatal("ordinary dangling cleanup no longer removed the minority version")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeRestoreInitialMissingReadDoesNotWrite(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
r.put("live", false, false)
|
||||
before := restoreBoundarySnapshot(t, r)
|
||||
opts := r.opts(mustGetUUID())
|
||||
opts.Transition.RestoreRequest = &RestoreObjectRequest{Days: 1}
|
||||
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, opts); !isErrVersionNotFound(err) {
|
||||
t.Fatalf("missing restore target: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, restoreBoundarySnapshot(t, r)) {
|
||||
t.Fatal("initial restore failure wrote metadata without a resolved version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireRestoredStorageRejectsOtherTypes(t *testing.T) {
|
||||
for _, kind := range []string{"null-marker", "uuid-marker", "legacy", "legacy-json"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
disk, _, err := newXLStorageTestSetup(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := t.Context()
|
||||
const bucket, object = "restore-types", "obj"
|
||||
if err := disk.MakeVol(ctx, bucket); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var meta xlMetaV2
|
||||
versionID, metaName, dataDir := nullVersionID, xlStorageFormatFile, ""
|
||||
var buf []byte
|
||||
switch kind {
|
||||
case "null-marker", "uuid-marker":
|
||||
if kind == "uuid-marker" {
|
||||
versionID = mustGetUUID()
|
||||
}
|
||||
err = meta.AddVersion(FileInfo{VersionID: versionID, Deleted: true, ModTime: time.Now().UTC()})
|
||||
case "legacy", "legacy-json":
|
||||
legacy := getSampleXLMeta(1)
|
||||
legacy.DataDir, dataDir = "legacy", "legacy"
|
||||
if kind == "legacy-json" {
|
||||
metaName = xlStorageFormatFileV1
|
||||
buf, err = json.Marshal(legacy)
|
||||
disk.storage.Lock()
|
||||
disk.storage.formatLegacy = true
|
||||
disk.storage.Unlock()
|
||||
} else {
|
||||
err = meta.AddLegacy(&legacy)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buf == nil {
|
||||
buf, err = meta.AppendTo(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := disk.WriteAll(ctx, bucket, pathJoin(object, metaName), buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := []byte("sole legacy contents")
|
||||
dataPath := filepath.Join(disk.Endpoint().Path, bucket, object, dataDir, "part.1")
|
||||
if dataDir != "" {
|
||||
if err := os.MkdirAll(filepath.Dir(dataPath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dataPath, payload, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := disk.DeleteVersion(ctx, bucket, object, FileInfo{VersionID: versionID, ExpireRestored: true, TransitionStatus: lifecycle.TransitionComplete}, false, DeleteOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := disk.ReadAll(ctx, bucket, pathJoin(object, metaName))
|
||||
if err != nil || !bytes.Equal(buf, after) {
|
||||
t.Errorf("restore cleanup changed %s metadata: %v", kind, err)
|
||||
}
|
||||
if dataDir != "" {
|
||||
if data, err := os.ReadFile(dataPath); err != nil || !bytes.Equal(data, payload) {
|
||||
t.Errorf("restore cleanup removed legacy contents: %v", err)
|
||||
}
|
||||
}
|
||||
if err := disk.DeleteVersion(ctx, bucket, object, FileInfo{VersionID: versionID}, false, DeleteOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := disk.ReadAll(ctx, bucket, pathJoin(object, metaName)); err != errFileNotFound {
|
||||
t.Errorf("ordinary purge left %s: %v", kind, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// 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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
const restoreSafetyRules = `<LifecycleConfiguration><Rule><ID>tier</ID><Status>Enabled</Status><Filter><Prefix></Prefix></Filter><Transition><Days>1000</Days><StorageClass>WARM</StorageClass></Transition></Rule></LifecycleConfiguration>`
|
||||
|
||||
// Reuse the original restore-copy fixture with a small, isolated real cluster.
|
||||
func newRestoreSafetyTest(t *testing.T, pools int, state versioning.State) *restoreTest {
|
||||
t.Helper()
|
||||
prevTier := globalTierConfigMgr
|
||||
t.Cleanup(func() { globalTierConfigMgr = prevTier })
|
||||
dirs, err := getRandomDisks(4 * pools)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { removeRoots(dirs) })
|
||||
var endpoints EndpointServerPools
|
||||
for i := range pools {
|
||||
endpoints = append(endpoints, mustGetPoolEndpoints(i, dirs[4*i:4*i+4]...)...)
|
||||
}
|
||||
obj, _, err := initObjectLayer(t.Context(), endpoints)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
t.Cleanup(func() { z.Shutdown(context.Background()) })
|
||||
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: state != ""}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := globalBucketMetadataSys.Get(r.bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state == versioning.Suspended {
|
||||
meta.VersioningConfigXML = []byte(`<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>`)
|
||||
}
|
||||
meta.LifecycleConfigXML = []byte(restoreSafetyRules)
|
||||
if err := meta.Save(t.Context(), z); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalBucketMetadataSys.Set(r.bucket, meta)
|
||||
return r
|
||||
}
|
||||
|
||||
type restoreCleanupFaultDisk struct {
|
||||
consistencyReadFaultDisk
|
||||
deletes *atomic.Int64
|
||||
}
|
||||
|
||||
func (d restoreCleanupFaultDisk) DeleteVersion(ctx context.Context, bucket, object string, fi FileInfo, force bool, opts DeleteOptions) error {
|
||||
if bucket == d.bucket && object == d.object {
|
||||
d.deletes.Add(1)
|
||||
}
|
||||
return d.StorageAPI.DeleteVersion(ctx, bucket, object, fi, force, opts)
|
||||
}
|
||||
|
||||
func TestExpireRestoredRequiresFreshMetadata(t *testing.T) {
|
||||
for _, restored := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("expire-restored=%v", restored), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, "")
|
||||
oi := r.put("v1", true, true)
|
||||
r.restore(oi)
|
||||
before := r.fileInfo(oi.VersionID)
|
||||
er := r.z.serverPools[0].getHashedSet(r.object)
|
||||
original := er.getDisks
|
||||
disks := original()
|
||||
var deletes atomic.Int64
|
||||
faulty := make([]StorageAPI, len(disks))
|
||||
for i, d := range disks {
|
||||
faulty[i] = restoreCleanupFaultDisk{consistencyReadFaultDisk{d, r.bucket, r.object}, &deletes}
|
||||
}
|
||||
er.getDisks = func() []StorageAPI { return faulty }
|
||||
// Restore the callback before any fixture reads and on a fatal assertion.
|
||||
defer func() { er.getDisks = original }()
|
||||
opts := ObjectOptions{VersionID: nullVersionID, Transition: TransitionOptions{ExpireRestored: restored}, Expiration: ExpirationOptions{Expire: restored}}
|
||||
_, err := er.DeleteObject(t.Context(), r.bucket, r.object, opts)
|
||||
er.getDisks = original
|
||||
if restored {
|
||||
var quorum InsufficientWriteQuorum
|
||||
if !errors.As(err, &quorum) {
|
||||
t.Errorf("cleanup without fresh metadata: got %v, want write-quorum error", err)
|
||||
}
|
||||
if n := deletes.Load(); n != 0 {
|
||||
t.Errorf("cleanup attempted %d blind disk deletes", n)
|
||||
}
|
||||
if after := r.fileInfo(oi.VersionID); !reflect.DeepEqual(before, after) {
|
||||
t.Error("failed metadata read changed the stored version")
|
||||
}
|
||||
if n := r.dataDirs(before.DataDir); n != len(disks) {
|
||||
t.Errorf("cleanup removed data: %d/%d directories remain", n, len(disks))
|
||||
}
|
||||
} else {
|
||||
// Ordinary unversioned DELETE retains the existing tryDel behavior.
|
||||
if err != nil || deletes.Load() != int64(len(disks)) {
|
||||
t.Fatalf("ordinary DELETE: err=%v, deletes=%d", err, deletes.Load())
|
||||
}
|
||||
if _, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, ObjectOptions{}); !isErrObjectNotFound(err) {
|
||||
t.Fatalf("ordinary DELETE left the object: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireRestoredRenewedOrMissing(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
oi := r.put("v1", false, true)
|
||||
r.restore(oi)
|
||||
stale, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := r.fileInfo(oi.VersionID)
|
||||
renewed := completedRestoreObj(time.Now().Add(48 * time.Hour)).String()
|
||||
opts := r.opts(oi.VersionID)
|
||||
opts.EvalMetadataFn = func(o *ObjectInfo, _ error) (ReplicateDecision, error) {
|
||||
o.UserDefined[xhttp.AmzRestore] = renewed
|
||||
return ReplicateDecision{}, nil
|
||||
}
|
||||
if _, err := r.z.PutObjectMetadata(t.Context(), r.bucket, r.object, opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = expireTransitionedObject(t.Context(), r.z, &stale, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner)
|
||||
if !errors.Is(err, errLifecycleNotApplicable) {
|
||||
t.Errorf("stale task should be rejected, got %v", err)
|
||||
}
|
||||
if got := r.fileInfo(oi.VersionID); got.Metadata[xhttp.AmzRestore] != renewed || got.DataDir != before.DataDir {
|
||||
t.Error("stale task changed renewed restore")
|
||||
}
|
||||
if n := r.dataDirs(before.DataDir); n != 4 {
|
||||
t.Errorf("renewed copy has %d directories, want 4", n)
|
||||
}
|
||||
if _, data, err := r.read(oi.VersionID); err != nil || !bytes.Equal(data, r.bodies["v1"]) {
|
||||
t.Fatalf("renewed copy not readable: %v", err)
|
||||
}
|
||||
|
||||
for _, id := range []string{mustGetUUID(), nullVersionID} {
|
||||
opts := r.opts(id)
|
||||
opts.Expiration.Expire = true
|
||||
opts.Transition.ExpireRestored = true
|
||||
if _, err := r.z.DeleteObject(t.Context(), r.bucket, r.object, opts); !isErrVersionNotFound(err) {
|
||||
t.Errorf("missing version %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
missing := r.opts(nullVersionID)
|
||||
missing.Expiration.Expire = true
|
||||
missing.Transition.ExpireRestored = true
|
||||
if _, err := r.z.DeleteObject(t.Context(), r.bucket, "missing-object", missing); !isErrVersionNotFound(err) && !isErrObjectNotFound(err) {
|
||||
t.Errorf("missing object: %v", err)
|
||||
}
|
||||
if got := r.fileInfo(oi.VersionID); got.Metadata[xhttp.AmzRestore] != renewed {
|
||||
t.Error("missing-target cleanup changed existing restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireRestoredStorageBoundary(t *testing.T) {
|
||||
for _, status := range []string{"", lifecycle.TransitionPending, lifecycle.TransitionComplete} {
|
||||
for _, inline := range []bool{false, true} {
|
||||
for _, requestStatus := range []string{"", lifecycle.TransitionComplete} {
|
||||
t.Run(fmt.Sprintf("stored=%s/inline=%v/request=%s", status, inline, requestStatus), func(t *testing.T) {
|
||||
disk, _, err := newXLStorageTestSetup(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const bucket, object = "restore-storage", "obj"
|
||||
ctx := t.Context()
|
||||
if err := disk.MakeVol(ctx, bucket); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi := newFileInfo(object, 2, 2)
|
||||
fi.VersionID, fi.DataDir = mustGetUUID(), mustGetUUID()
|
||||
fi.ModTime, fi.Size = time.Now().UTC(), 1024
|
||||
fi.TransitionStatus = status
|
||||
fi.AddObjectPart(1, "", fi.Size, fi.Size, fi.ModTime, nil, nil)
|
||||
fi.Metadata = map[string]string{xhttp.AmzRestore: completedRestoreObj(time.Now().Add(-time.Hour)).String(), "x-amz-meta-preserve": "keep"}
|
||||
payload := []byte("sole local contents")
|
||||
if inline {
|
||||
fi.Data = payload
|
||||
fi.SetInlineData()
|
||||
}
|
||||
if err := disk.WriteMetadata(ctx, "", bucket, object, fi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join(disk.Endpoint().Path, bucket, object, fi.DataDir)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "part.1"), payload, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := disk.ReadXL(ctx, bucket, object, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var xl xlMetaV2
|
||||
if err := xl.Load(raw.Buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
del := FileInfo{VersionID: fi.VersionID, ExpireRestored: true, TransitionStatus: requestStatus}
|
||||
dataDir, err := xl.DeleteVersion(del)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDir := ""
|
||||
if status == lifecycle.TransitionComplete {
|
||||
wantDir = fi.DataDir
|
||||
}
|
||||
if dataDir != wantDir {
|
||||
t.Errorf("format authorized directory %q, want %q", dataDir, wantDir)
|
||||
}
|
||||
before, err := getFileInfo(raw.Buf, bucket, object, fi.VersionID, fileInfoOpts{Data: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := disk.DeleteVersion(ctx, bucket, object, del, false, DeleteOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterRaw, err := disk.ReadXL(ctx, bucket, object, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := getFileInfo(afterRaw.Buf, bucket, object, fi.VersionID, fileInfoOpts{Data: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, dirErr := os.ReadFile(filepath.Join(dir, "part.1"))
|
||||
if status != lifecycle.TransitionComplete {
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Error("non-complete cleanup changed metadata or inline data")
|
||||
}
|
||||
if dirErr != nil || !bytes.Equal(data, payload) {
|
||||
t.Errorf("sole local contents removed: %v", dirErr)
|
||||
}
|
||||
} else {
|
||||
if !os.IsNotExist(dirErr) {
|
||||
t.Errorf("completed restore directory still present: %v", dirErr)
|
||||
}
|
||||
if len(after.Data) != 0 || after.Metadata[xhttp.AmzRestore] != "" {
|
||||
t.Error("completed restore kept cached data or restore headers")
|
||||
}
|
||||
if after.TransitionStatus != status || after.VersionID != before.VersionID || after.Metadata["x-amz-meta-preserve"] != "keep" {
|
||||
t.Error("cleanup changed the transitioned version identity or user metadata")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
// Copyright (c) 2026 Denis Babchuk
|
||||
//
|
||||
// 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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
// restoreTestTier is a remote tier held in memory that counts its reads.
|
||||
type restoreTestTier struct {
|
||||
*tierExpiryTestBackend
|
||||
gets atomic.Int64
|
||||
}
|
||||
|
||||
func (b *restoreTestTier) Get(ctx context.Context, object string, rv remoteVersionID, opts WarmBackendGetOpts) (io.ReadCloser, error) {
|
||||
b.gets.Add(1)
|
||||
return b.tierExpiryTestBackend.Get(ctx, object, rv, opts)
|
||||
}
|
||||
|
||||
// restoreTest is a cluster with a remote tier WARM and a bucket whose
|
||||
// versions are transitioned to it and restored.
|
||||
type restoreTest struct {
|
||||
t *testing.T
|
||||
z *erasureServerPools
|
||||
bucket string
|
||||
object string
|
||||
queue chan expiryOp
|
||||
labels map[string]string // version ID ("null" for the null version) -> label
|
||||
bodies map[string][]byte // label -> data
|
||||
}
|
||||
|
||||
func restoreTestVersionKey(versionID string) string {
|
||||
if versionID == "" {
|
||||
return nullVersionID
|
||||
}
|
||||
return versionID
|
||||
}
|
||||
|
||||
// put writes a version of the object to the first pool. A restored version
|
||||
// is 1 MiB, so that its restored copy has a data directory of its own.
|
||||
func (r *restoreTest) put(label string, null, restored bool) ObjectInfo {
|
||||
r.t.Helper()
|
||||
body := []byte(r.object + " " + label)
|
||||
if restored {
|
||||
body = bytes.Repeat(body, (1<<20)/len(body)+1)
|
||||
}
|
||||
opts := ObjectOptions{Versioned: !null}
|
||||
if null {
|
||||
opts.VersionSuspended = globalBucketVersioningSys.PrefixSuspended(r.bucket, r.object)
|
||||
}
|
||||
oi, err := r.z.serverPools[0].PutObject(r.t.Context(), r.bucket, r.object,
|
||||
mustGetPutObjReader(r.t, bytes.NewReader(body), int64(len(body)), "", ""), opts)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
r.labels[restoreTestVersionKey(oi.VersionID)] = label
|
||||
r.bodies[label] = body
|
||||
return oi
|
||||
}
|
||||
|
||||
// opts returns the options of a request on a version of the object, as the
|
||||
// S3 handlers set them.
|
||||
func (r *restoreTest) opts(versionID string) ObjectOptions {
|
||||
return ObjectOptions{
|
||||
VersionID: versionID,
|
||||
Versioned: globalBucketVersioningSys.PrefixEnabled(r.bucket, r.object),
|
||||
VersionSuspended: globalBucketVersioningSys.PrefixSuspended(r.bucket, r.object),
|
||||
}
|
||||
}
|
||||
|
||||
// restore transitions a version to the tier, restores it, and moves the
|
||||
// expiry date of the restore into the past.
|
||||
func (r *restoreTest) restore(oi ObjectInfo) {
|
||||
r.t.Helper()
|
||||
ctx := r.t.Context()
|
||||
vopts := r.opts(oi.VersionID)
|
||||
topts := vopts
|
||||
topts.MTime = oi.ModTime
|
||||
topts.Transition = TransitionOptions{Tier: "WARM", ETag: oi.ETag}
|
||||
if err := r.z.TransitionObject(ctx, r.bucket, r.object, topts); err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
ropts := vopts
|
||||
ropts.Transition = TransitionOptions{RestoreRequest: &RestoreObjectRequest{Days: 1}}
|
||||
// Through the pool, as with a single pool: with more than one pool the
|
||||
// pools layer holds the object lock that the write of the restored copy
|
||||
// waits for.
|
||||
if err := r.z.serverPools[0].RestoreTransitionedObject(ctx, r.bucket, r.object, ropts); err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
expired := completedRestoreObj(time.Now().Add(-24 * time.Hour).UTC()).String()
|
||||
mopts := vopts
|
||||
mopts.MTime = oi.ModTime
|
||||
mopts.EvalMetadataFn = func(o *ObjectInfo, _ error) (ReplicateDecision, error) {
|
||||
o.UserDefined[xhttp.AmzRestore] = expired
|
||||
return ReplicateDecision{}, nil
|
||||
}
|
||||
if _, err := r.z.PutObjectMetadata(ctx, r.bucket, r.object, mopts); err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// fileInfo returns the version of the object from a drive of the first pool.
|
||||
func (r *restoreTest) fileInfo(versionID string) FileInfo {
|
||||
r.t.Helper()
|
||||
infos, errs := readAllFileInfo(r.t.Context(), r.z.serverPools[0].getHashedSet(r.object).getDisks(), "", r.bucket, r.object, restoreTestVersionKey(versionID), false, false)
|
||||
if errs[0] != nil {
|
||||
r.t.Fatal(errs[0])
|
||||
}
|
||||
return infos[0]
|
||||
}
|
||||
|
||||
// dataDirs returns how many drives of the first pool hold the data
|
||||
// directory of a version.
|
||||
func (r *restoreTest) dataDirs(dataDir string) int {
|
||||
r.t.Helper()
|
||||
n := 0
|
||||
for _, disk := range r.z.serverPools[0].getHashedSet(r.object).getDisks() {
|
||||
_, err := os.Stat(filepath.Join(disk.Endpoint().Path, r.bucket, r.object, dataDir))
|
||||
if err == nil {
|
||||
n++
|
||||
} else if !os.IsNotExist(err) {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// versions returns the versions of the object in each pool, newest first,
|
||||
// and its free versions in all pools.
|
||||
func (r *restoreTest) versions() (views []string, free int) {
|
||||
r.t.Helper()
|
||||
for _, pool := range r.z.serverPools {
|
||||
rf, err := pool.getHashedSet(r.object).getDisks()[0].ReadXL(r.t.Context(), r.bucket, r.object, false)
|
||||
if errors.Is(err, errFileNotFound) {
|
||||
views = append(views, "")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
fivs, err := getFileInfoVersions(rf.Buf, r.bucket, r.object, false)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
var labels []string
|
||||
for _, fi := range fivs.Versions {
|
||||
if fi.Deleted {
|
||||
labels = append(labels, "DM")
|
||||
} else {
|
||||
labels = append(labels, r.labels[restoreTestVersionKey(fi.VersionID)])
|
||||
}
|
||||
}
|
||||
views = append(views, strings.Join(labels, " "))
|
||||
free += len(fivs.FreeVersions)
|
||||
}
|
||||
return views, free
|
||||
}
|
||||
|
||||
// read returns the label and the data of a version, or of the latest version
|
||||
// when versionID is empty, or the error of the read.
|
||||
func (r *restoreTest) read(versionID string) (string, []byte, error) {
|
||||
r.t.Helper()
|
||||
gr, err := r.z.GetObjectNInfo(r.t.Context(), r.bucket, r.object, nil, nil, ObjectOptions{VersionID: versionID})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer gr.Close()
|
||||
data, err := io.ReadAll(gr)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return r.labels[restoreTestVersionKey(gr.ObjInfo.VersionID)], data, nil
|
||||
}
|
||||
|
||||
// runQueue runs the queued expiries with an expiry worker.
|
||||
func (r *restoreTest) runQueue() {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
globalExpiryState.Worker(r.queue)
|
||||
}()
|
||||
r.queue <- nil // the worker returns once it ran every expiry queued before
|
||||
<-done
|
||||
}
|
||||
|
||||
// objInfos returns the versions of the object as a drive of the
|
||||
// first pool holds them, as the scanner and the walks of decommission and
|
||||
// the drive heal see them.
|
||||
func (r *restoreTest) objInfos() []ObjectInfo {
|
||||
r.t.Helper()
|
||||
rf, err := r.z.serverPools[0].getHashedSet(r.object).getDisks()[0].ReadXL(r.t.Context(), r.bucket, r.object, false)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
fivs, err := getFileInfoVersions(rf.Buf, r.bucket, r.object, false)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
versioned := globalBucketVersioningSys.PrefixEnabled(r.bucket, r.object) || globalBucketVersioningSys.PrefixSuspended(r.bucket, r.object)
|
||||
objInfos := make([]ObjectInfo, len(fivs.Versions))
|
||||
for i, fi := range fivs.Versions {
|
||||
objInfos[i] = fi.ToObjectInfo(r.bucket, r.object, versioned)
|
||||
}
|
||||
return objInfos
|
||||
}
|
||||
|
||||
// The restored copy of a transitioned version expires on the expiry date of
|
||||
// its restore. The expiry removes the copy and nothing else: the version
|
||||
// stays where it is in the version stack, transitioned, and is read from the
|
||||
// tier again. Every path that applies ILM runs the expiry: the scanner and a
|
||||
// GET or HEAD queue it for an expiry worker, decommission and the drive heal
|
||||
// run it at once. The expiry of the restored copy of the latest version used
|
||||
// to delete the object instead: a versioned bucket got a delete marker that
|
||||
// hid it. With versioning suspended it went to the null version, removed its
|
||||
// data if there was one and failed if not, and the restored copy stayed.
|
||||
func TestExpireRestoredCopy(t *testing.T) {
|
||||
prevTier := globalTierConfigMgr
|
||||
t.Cleanup(func() { globalTierConfigMgr = prevTier })
|
||||
prevExpiry := globalExpiryState
|
||||
t.Cleanup(func() { globalExpiryState = prevExpiry })
|
||||
|
||||
// Nothing here is old enough to transition; the rule makes the bucket
|
||||
// one with a tier, as restored versions are in.
|
||||
const rules = `<LifecycleConfiguration><Rule><ID>tier</ID><Status>Enabled</Status><Filter><Prefix></Prefix></Filter>` +
|
||||
`<Transition><Days>1000</Days><StorageClass>WARM</StorageClass></Transition></Rule></LifecycleConfiguration>`
|
||||
|
||||
type version struct {
|
||||
label string
|
||||
null bool // the null version
|
||||
restored bool // transitioned and restored, the restore has expired
|
||||
}
|
||||
testCases := []struct {
|
||||
name string
|
||||
versioning versioning.State // "", Enabled or Suspended
|
||||
versions []version // oldest first
|
||||
}{
|
||||
{name: "versioned", versioning: versioning.Enabled, versions: []version{{label: "v1"}, {label: "v2", restored: true}}},
|
||||
{name: "versioned-noncurrent", versioning: versioning.Enabled, versions: []version{{label: "v1", restored: true}, {label: "v2"}}},
|
||||
{name: "versioned-null", versioning: versioning.Enabled, versions: []version{{label: "v1", null: true, restored: true}}},
|
||||
{name: "suspended", versioning: versioning.Suspended, versions: []version{{label: "v1", null: true}, {label: "v2", restored: true}}},
|
||||
{name: "suspended-no-null", versioning: versioning.Suspended, versions: []version{{label: "v1"}, {label: "v2", restored: true}}},
|
||||
{name: "suspended-null", versioning: versioning.Suspended, versions: []version{{label: "v1"}, {label: "v2", null: true, restored: true}}},
|
||||
{name: "suspended-null-noncurrent", versioning: versioning.Suspended, versions: []version{{label: "v1", null: true, restored: true}, {label: "v2"}}},
|
||||
{name: "unversioned", versions: []version{{label: "v1", null: true, restored: true}}},
|
||||
}
|
||||
|
||||
sources := []struct {
|
||||
name string
|
||||
expire func(r *restoreTest, oi ObjectInfo, evt lifecycle.Event)
|
||||
}{
|
||||
{"scanner", func(r *restoreTest, _ ObjectInfo, _ lifecycle.Event) {
|
||||
lc, err := globalLifecycleSys.Get(r.bucket)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
lr, err := globalBucketObjectLockSys.Get(r.bucket)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
item := scannerItem{bucket: r.bucket, objectName: r.object, lifeCycle: lc}
|
||||
item.applyActions(r.t.Context(), r.z, r.objInfos(), lr, &sizeSummary{}, nil)
|
||||
r.runQueue()
|
||||
}},
|
||||
{"head", func(r *restoreTest, oi ObjectInfo, evt lifecycle.Event) {
|
||||
applyExpiryRule(evt, lcEventSrc_s3HeadObject, oi)
|
||||
r.runQueue()
|
||||
}},
|
||||
{"list", func(r *restoreTest, _ ObjectInfo, _ lifecycle.Event) {
|
||||
// ListObjects of the key with max-keys=1 reads its latest
|
||||
// version without the versioning of the bucket, a listing of
|
||||
// versions reads them all.
|
||||
if _, err := r.z.ListObjects(r.t.Context(), r.bucket, r.object, "", "", 1); err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
if _, err := r.z.ListObjectVersions(r.t.Context(), r.bucket, r.object, "", "", "", 100); err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
r.runQueue()
|
||||
}},
|
||||
{"decom", func(r *restoreTest, oi ObjectInfo, evt lifecycle.Event) {
|
||||
for _, o := range r.objInfos() {
|
||||
if o.VersionID == oi.VersionID {
|
||||
applyExpiryOnTransitionedObject(r.t.Context(), r.z, o, evt, lcEventSrc_Decom)
|
||||
return
|
||||
}
|
||||
}
|
||||
r.t.Fatal("version not found on the drive")
|
||||
}},
|
||||
}
|
||||
|
||||
for _, pools := range []int{1, 2} {
|
||||
t.Run(fmt.Sprintf("pools=%d", pools), func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
dirs, err := getRandomDisks(4 * pools)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { removeRoots(dirs) })
|
||||
var endpoints EndpointServerPools
|
||||
for i := range pools {
|
||||
endpoints = append(endpoints, mustGetPoolEndpoints(i, dirs[4*i:4*i+4]...)...)
|
||||
}
|
||||
obj, _, err := initObjectLayer(ctx, endpoints)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
t.Cleanup(func() { z.Shutdown(context.Background()) })
|
||||
// After initObjectLayer, which sets up a new tier config.
|
||||
tier := &restoreTestTier{tierExpiryTestBackend: &tierExpiryTestBackend{objects: map[string][]byte{}}}
|
||||
globalTierConfigMgr = NewTierConfigMgr()
|
||||
globalTierConfigMgr.drivercache["WARM"] = tier
|
||||
|
||||
for _, tc := range testCases {
|
||||
for _, src := range sources {
|
||||
t.Run(tc.name+"/"+src.name, func(t *testing.T) {
|
||||
r := &restoreTest{
|
||||
t: t, z: z,
|
||||
bucket: "restore-" + tc.name + "-" + src.name, object: "obj",
|
||||
queue: make(chan expiryOp, 100),
|
||||
labels: map[string]string{}, bodies: map[string][]byte{},
|
||||
}
|
||||
if err := z.MakeBucket(ctx, r.bucket, MakeBucketOptions{VersioningEnabled: tc.versioning != ""}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := globalBucketMetadataSys.Get(r.bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.versioning == versioning.Suspended {
|
||||
meta.VersioningConfigXML = []byte("<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>")
|
||||
}
|
||||
meta.LifecycleConfigXML = []byte(rules)
|
||||
if err := meta.Save(ctx, z); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalBucketMetadataSys.Set(r.bucket, meta)
|
||||
es := &expiryState{ctx: ctx, objAPI: z}
|
||||
workers := []chan expiryOp{r.queue}
|
||||
es.workers.Store(&workers)
|
||||
globalExpiryState = es
|
||||
|
||||
var restored ObjectInfo
|
||||
for _, v := range tc.versions {
|
||||
oi := r.put(v.label, v.null, v.restored)
|
||||
if v.restored {
|
||||
r.restore(oi)
|
||||
restored = oi
|
||||
}
|
||||
}
|
||||
rlabel := r.labels[restoreTestVersionKey(restored.VersionID)]
|
||||
|
||||
// The fixture: the restored copy is on the drives,
|
||||
// its restore has expired, ILM expires it.
|
||||
fi := r.fileInfo(restored.VersionID)
|
||||
if fi.TransitionStatus != lifecycle.TransitionComplete || fi.DataDir == "" || r.dataDirs(fi.DataDir) == 0 {
|
||||
t.Fatalf("fixture: %s transition status %q, restored copy on %d drives", rlabel, fi.TransitionStatus, r.dataDirs(fi.DataDir))
|
||||
}
|
||||
oi, err := z.GetObjectInfo(ctx, r.bucket, r.object, r.opts(restored.VersionID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lc, err := globalLifecycleSys.Get(r.bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lr, err := globalBucketObjectLockSys.Get(r.bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evt := evalActionFromLifecycle(ctx, *lc, lr, nil, oi)
|
||||
if !evt.Action.DeleteRestored() || oi.RestoreExpires.IsZero() {
|
||||
t.Fatalf("fixture: %s restore expires %v, action %v", rlabel, oi.RestoreExpires, evt.Action)
|
||||
}
|
||||
before, _ := r.versions()
|
||||
for key, label := range r.labels {
|
||||
if _, data, err := r.read(key); err != nil || !bytes.Equal(data, r.bodies[label]) {
|
||||
t.Fatalf("fixture: GET %s: %v", label, err)
|
||||
}
|
||||
}
|
||||
|
||||
src.expire(r, oi, evt)
|
||||
|
||||
after, free := r.versions()
|
||||
if fmt.Sprint(after) != fmt.Sprint(before) || free != 0 {
|
||||
t.Errorf("versions in each pool: before %q, after %q with %d free versions", before, after, free)
|
||||
}
|
||||
if label, _, err := r.read(""); err != nil || label != tc.versions[len(tc.versions)-1].label {
|
||||
t.Errorf("GET reads %q, %v; want %q", label, err, tc.versions[len(tc.versions)-1].label)
|
||||
}
|
||||
if n := r.dataDirs(fi.DataDir); n != 0 {
|
||||
t.Errorf("restored copy of %s still on %d drives", rlabel, n)
|
||||
}
|
||||
oi, err = z.GetObjectInfo(ctx, r.bucket, r.object, r.opts(restored.VersionID))
|
||||
if err != nil {
|
||||
t.Fatalf("HEAD %s: %v", rlabel, err)
|
||||
}
|
||||
if oi.TransitionedObject.Status != lifecycle.TransitionComplete || oi.UserDefined[xhttp.AmzRestore] != "" || !oi.RestoreExpires.IsZero() {
|
||||
t.Errorf("HEAD %s: transition status %q, x-amz-restore %q", rlabel, oi.TransitionedObject.Status, oi.UserDefined[xhttp.AmzRestore])
|
||||
}
|
||||
for _, v := range tc.versions {
|
||||
for key, label := range r.labels {
|
||||
if label != v.label {
|
||||
continue
|
||||
}
|
||||
gets := tier.gets.Load()
|
||||
_, data, err := r.read(key)
|
||||
switch {
|
||||
case err != nil:
|
||||
t.Errorf("GET %s: %v", v.label, err)
|
||||
case !bytes.Equal(data, r.bodies[v.label]):
|
||||
t.Errorf("GET %s reads %d bytes that differ", v.label, len(data))
|
||||
case v.restored && tier.gets.Load() == gets:
|
||||
t.Errorf("GET %s does not read the tier", v.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+43
-11
@@ -37,6 +37,7 @@ import (
|
||||
"github.com/minio/minio/internal/amztime"
|
||||
sse "github.com/minio/minio/internal/bucket/encryption"
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/bucket/object/lock"
|
||||
"github.com/minio/minio/internal/bucket/replication"
|
||||
"github.com/minio/minio/internal/event"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
@@ -611,8 +612,9 @@ func enqueueTransitionImmediate(obj ObjectInfo, src lcEventSrc) {
|
||||
func expireTransitionedObject(ctx context.Context, objectAPI ObjectLayer, oi *ObjectInfo, lcEvent lifecycle.Event, src lcEventSrc) error {
|
||||
traceFn := globalLifecycleSys.trace(*oi)
|
||||
opts := ObjectOptions{
|
||||
Versioned: globalBucketVersioningSys.PrefixEnabled(oi.Bucket, oi.Name),
|
||||
Expiration: ExpirationOptions{Expire: true},
|
||||
Versioned: globalBucketVersioningSys.PrefixEnabled(oi.Bucket, oi.Name),
|
||||
VersionSuspended: globalBucketVersioningSys.PrefixSuspended(oi.Bucket, oi.Name),
|
||||
Expiration: ExpirationOptions{Expire: true},
|
||||
}
|
||||
if lcEvent.Action.DeleteVersioned() {
|
||||
opts.VersionID = oi.VersionID
|
||||
@@ -622,6 +624,17 @@ func expireTransitionedObject(ctx context.Context, objectAPI ObjectLayer, oi *Ob
|
||||
// delete locally restored copy of object or object version
|
||||
// from the source, while leaving metadata behind. The data on
|
||||
// transitioned tier lies untouched and still accessible
|
||||
//
|
||||
// The restored copy belongs to this version, also when it is the
|
||||
// latest one (DeleteRestoredAction). Without its version ID the
|
||||
// delete applies to the object: a versioned bucket gets a delete
|
||||
// marker that hides it, a suspended one strips the null version.
|
||||
// An empty version ID is the null version, as callers that read
|
||||
// it without the versioning of the bucket leave it.
|
||||
opts.VersionID = oi.VersionID
|
||||
if opts.VersionID == "" {
|
||||
opts.VersionID = nullVersionID
|
||||
}
|
||||
opts.Transition.ExpireRestored = true
|
||||
_, err := objectAPI.DeleteObject(ctx, oi.Bucket, oi.Name, opts)
|
||||
if err == nil {
|
||||
@@ -636,7 +649,6 @@ func expireTransitionedObject(ctx context.Context, objectAPI ObjectLayer, oi *Ob
|
||||
// touching the remote tier. If the lifecycle configuration changed after
|
||||
// the scanner queued this task, DeleteObject rejects the stale action while
|
||||
// the remote object is still protected.
|
||||
opts.VersionSuspended = globalBucketVersioningSys.PrefixSuspended(oi.Bucket, oi.Name)
|
||||
keepRemote := opts.VersionID == "" &&
|
||||
(opts.Versioned || (opts.VersionSuspended && oi.VersionID != "" && oi.VersionID != nullVersionID))
|
||||
if lcEvent.Action == lifecycle.DeleteAction || lcEvent.Action == lifecycle.DeleteVersionAction {
|
||||
@@ -976,8 +988,10 @@ func postRestoreOpts(ctx context.Context, r *http.Request, bucket, object string
|
||||
}, nil
|
||||
}
|
||||
|
||||
// set ObjectOptions for PUT call to restore temporary copy of transitioned data
|
||||
func putRestoreOpts(bucket, object string, rreq *RestoreObjectRequest, objInfo ObjectInfo) (putOpts ObjectOptions) {
|
||||
// set ObjectOptions for PUT call to restore temporary copy of transitioned data.
|
||||
// opts are the restore's options, with the versioning POST captured: the
|
||||
// bucket's versioning may have changed since, and must not mint a new version.
|
||||
func putRestoreOpts(rreq *RestoreObjectRequest, objInfo ObjectInfo, opts ObjectOptions) (putOpts ObjectOptions) {
|
||||
meta := make(map[string]string)
|
||||
sc := rreq.OutputLocation.S3.StorageClass
|
||||
if sc == "" {
|
||||
@@ -1000,8 +1014,8 @@ func putRestoreOpts(bucket, object string, rreq *RestoreObjectRequest, objInfo O
|
||||
meta[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionAES
|
||||
}
|
||||
return ObjectOptions{
|
||||
Versioned: globalBucketVersioningSys.PrefixEnabled(bucket, object),
|
||||
VersionSuspended: globalBucketVersioningSys.PrefixSuspended(bucket, object),
|
||||
Versioned: opts.Versioned,
|
||||
VersionSuspended: opts.VersionSuspended,
|
||||
UserDefined: meta,
|
||||
}
|
||||
}
|
||||
@@ -1009,19 +1023,37 @@ func putRestoreOpts(bucket, object string, rreq *RestoreObjectRequest, objInfo O
|
||||
if len(objInfo.UserTags) != 0 {
|
||||
meta[xhttp.AmzObjectTagging] = objInfo.UserTags
|
||||
}
|
||||
// Set restore object status
|
||||
// Set restore object status; a completed restore has no attempt.
|
||||
restoreExpiry := lifecycle.ExpectedExpiryTime(time.Now().UTC(), rreq.Days)
|
||||
meta[xhttp.AmzRestore] = completedRestoreObj(restoreExpiry).String()
|
||||
delete(meta, restoreAttemptKey)
|
||||
return ObjectOptions{
|
||||
Versioned: globalBucketVersioningSys.PrefixEnabled(bucket, object),
|
||||
VersionSuspended: globalBucketVersioningSys.PrefixSuspended(bucket, object),
|
||||
Versioned: opts.Versioned,
|
||||
VersionSuspended: opts.VersionSuspended,
|
||||
UserDefined: meta,
|
||||
VersionID: objInfo.VersionID,
|
||||
VersionID: restoreVersionID(objInfo.VersionID),
|
||||
MTime: objInfo.ModTime,
|
||||
Expires: objInfo.Expires,
|
||||
}
|
||||
}
|
||||
|
||||
// restoredCopyExpires reports whether the restored copy of oi, as read now,
|
||||
// expires by its own reason: lifecycle expires the restored copy of this
|
||||
// version, its restore deadline is valid and has passed, its transition is
|
||||
// complete and no restore is running. Another rule falling due is no reason.
|
||||
func restoredCopyExpires(ctx context.Context, lc lifecycle.Lifecycle, lr lock.Retention, rcfg *replication.Config, oi ObjectInfo) bool {
|
||||
if !evalActionFromLifecycle(ctx, lc, lr, rcfg, oi).Action.DeleteRestored() {
|
||||
return false
|
||||
}
|
||||
status, err := parseRestoreObjStatus(oi.UserDefined[xhttp.AmzRestore])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
expiry, completed := status.Expiry()
|
||||
return completed && !expiry.IsZero() && UTCNow().After(expiry) &&
|
||||
oi.TransitionedObject.Status == lifecycle.TransitionComplete && !oi.RestoreOngoing
|
||||
}
|
||||
|
||||
var errRestoreHDRMalformed = fmt.Errorf("x-amz-restore header malformed")
|
||||
|
||||
// IsRemote returns true if this object version's contents are in its remote
|
||||
|
||||
+2
-2
@@ -1204,7 +1204,7 @@ func applyTransitionRule(event lifecycle.Event, src lcEventSrc, obj ObjectInfo)
|
||||
func applyExpiryOnTransitionedObject(ctx context.Context, objLayer ObjectLayer, obj ObjectInfo, lcEvent lifecycle.Event, src lcEventSrc) (ok bool) {
|
||||
timeILM := globalScannerMetrics.timeILM(lcEvent.Action)
|
||||
if err := expireTransitionedObject(ctx, objLayer, &obj, lcEvent, src); err != nil {
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
if errors.Is(err, errLifecycleNotApplicable) || isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
return false
|
||||
}
|
||||
ilmLogIf(ctx, fmt.Errorf("expireTransitionedObject(%s, %s): %w", obj.Bucket, obj.Name, err))
|
||||
@@ -1271,7 +1271,7 @@ func applyExpiryOnNonTransitionedObjects(ctx context.Context, objLayer ObjectLay
|
||||
|
||||
dobj, err = objLayer.DeleteObject(ctx, obj.Bucket, encodeDirObject(obj.Name), opts)
|
||||
if err != nil {
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
if errors.Is(err, errLifecycleNotApplicable) || isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
traceFn(ILMExpiry, nil, nil)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -373,6 +373,13 @@ func (er *erasureObjects) healObject(ctx context.Context, bucket string, object
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
// The restore fields fence native restores and restored-copy cleanup.
|
||||
// Heal copies them only as a quorum of the drives holds them; while the
|
||||
// drives disagree, it waits rather than create a quorum for one side.
|
||||
latestMeta, err = restoreHealMeta(partsMetadata, latestMeta, max(len(storageDisks)/2+1, readQuorum))
|
||||
if err != nil {
|
||||
return result, toObjectErr(err, bucket, object, versionID)
|
||||
}
|
||||
|
||||
// No modtime quorum
|
||||
filterDisksByETag := quorumETag != ""
|
||||
|
||||
+46
-104
@@ -45,7 +45,6 @@ import (
|
||||
"github.com/minio/minio/internal/crypto"
|
||||
"github.com/minio/minio/internal/event"
|
||||
"github.com/minio/minio/internal/grid"
|
||||
"github.com/minio/minio/internal/hash"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
xioutil "github.com/minio/minio/internal/ioutil"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
@@ -509,6 +508,11 @@ func joinErrs(errs []error) string {
|
||||
}
|
||||
|
||||
func (er erasureObjects) deleteIfDangling(ctx context.Context, bucket, object string, metaArr []FileInfo, errs []error, dataErrsByPart map[int][]int, opts ObjectOptions) (FileInfo, error) {
|
||||
// Restore and restored-copy cleanup must preserve the version on read
|
||||
// failure, including lookups and failure cleanup that reread metadata.
|
||||
if opts.Transition.ExpireRestored || opts.Transition.RestoreRequest != nil {
|
||||
return FileInfo{}, errErasureReadQuorum
|
||||
}
|
||||
m, ok := isObjectDangling(metaArr, errs, dataErrsByPart)
|
||||
if !ok {
|
||||
// We only come here if we cannot figure out if the object
|
||||
@@ -2045,6 +2049,9 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
// expiration attempted on a bucket with no lifecycle
|
||||
// rules shall be rejected.
|
||||
if lc == nil && opts.Expiration.Expire {
|
||||
if !opts.Transition.ExpireRestored {
|
||||
return objInfo, errLifecycleNotApplicable
|
||||
}
|
||||
if opts.VersionID != "" {
|
||||
return objInfo, VersionNotFound{
|
||||
Bucket: bucket,
|
||||
@@ -2089,6 +2096,9 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
isErr = true
|
||||
}
|
||||
if isErr {
|
||||
if !opts.Transition.ExpireRestored {
|
||||
return goi, errLifecycleNotApplicable
|
||||
}
|
||||
if goi.VersionID != "" {
|
||||
return goi, VersionNotFound{
|
||||
Bucket: bucket,
|
||||
@@ -2107,6 +2117,11 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
return ObjectInfo{}, toObjectErr(er.deletePrefix(ctx, bucket, object), bucket, object)
|
||||
}
|
||||
|
||||
// An ILM version expiry that the pools checked with the versions in all
|
||||
// pools; the set still guards the protection it reads, see
|
||||
// checkedExpiryRefused.
|
||||
checkedExpiry := opts.Expiration.Expire && opts.Expiration.CheckedAllPools && opts.VersionID != "" && !opts.Transition.ExpireRestored
|
||||
|
||||
storageDisks := er.getDisks()
|
||||
versionFound := true
|
||||
objInfo = ObjectInfo{VersionID: opts.VersionID} // version id needed in Delete API response.
|
||||
@@ -2114,7 +2129,10 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
tryDel := false
|
||||
if gerr != nil && goi.Name == "" {
|
||||
if _, ok := gerr.(InsufficientReadQuorum); ok {
|
||||
if opts.Versioned || opts.VersionSuspended || countOnlineDisks(storageDisks) < len(storageDisks)/2+1 {
|
||||
// A restored-copy expiry needs fresh metadata to confirm its target
|
||||
// and deadline, a checked expiry to guard its protection; ordinary
|
||||
// unversioned DELETE may still try a blind delete.
|
||||
if opts.Transition.ExpireRestored || checkedExpiry || opts.Versioned || opts.VersionSuspended || countOnlineDisks(storageDisks) < len(storageDisks)/2+1 {
|
||||
// Add an MRF heal for next time.
|
||||
er.addPartial(bucket, object, opts.VersionID)
|
||||
return objInfo, InsufficientWriteQuorum{}
|
||||
@@ -2134,6 +2152,26 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
}
|
||||
}
|
||||
|
||||
// A queued null restore may now address a replacement delete marker.
|
||||
// Such errors carry ObjectInfo, but never authorize cache cleanup.
|
||||
if opts.Transition.ExpireRestored && (gerr != nil || goi.DeleteMarker) {
|
||||
return objInfo, VersionNotFound{Bucket: bucket, Object: object, VersionID: opts.VersionID}
|
||||
}
|
||||
// A restored copy expires only by its own reason, decided on the
|
||||
// version's authoritative generation: a drive that missed a renewal or a
|
||||
// later POST must not authorize removing the cache, nor drives that
|
||||
// disagree on the restore status. Another lifecycle deletion falling due,
|
||||
// or the all-pools check of an ordinary expiry, does not stand in for it.
|
||||
if opts.Transition.ExpireRestored {
|
||||
gen, err := er.readRestoreGeneration(ctx, bucket, object, opts.VersionID)
|
||||
if err != nil || gen.split || gen.fi.Deleted || lc == nil {
|
||||
return goi, errLifecycleNotApplicable
|
||||
}
|
||||
if !restoredCopyExpires(ctx, *lc, rcfg, replcfg, gen.fi.ToObjectInfo(bucket, object, opts.Versioned || opts.VersionSuspended)) {
|
||||
return goi, errLifecycleNotApplicable
|
||||
}
|
||||
}
|
||||
|
||||
if opts.EvalMetadataFn != nil {
|
||||
dsc, err := opts.EvalMetadataFn(&goi, gerr)
|
||||
if err != nil {
|
||||
@@ -2151,7 +2189,7 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Expiration.Expire {
|
||||
if opts.Expiration.Expire && !opts.Expiration.CheckedAllPools {
|
||||
if gerr == nil {
|
||||
evt := evalActionFromLifecycle(ctx, *lc, rcfg, replcfg, goi)
|
||||
var isErr bool
|
||||
@@ -2162,6 +2200,9 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
isErr = true
|
||||
}
|
||||
if isErr {
|
||||
if !opts.Transition.ExpireRestored {
|
||||
return goi, errLifecycleNotApplicable
|
||||
}
|
||||
if goi.VersionID != "" {
|
||||
return goi, VersionNotFound{
|
||||
Bucket: bucket,
|
||||
@@ -2175,6 +2216,8 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if checkedExpiry && checkedExpiryRefused(*lc, rcfg, replcfg, goi, opts.Expiration.ApprovedProtection) {
|
||||
return goi, errLifecycleNotApplicable
|
||||
}
|
||||
|
||||
// Determine whether to mark object deleted for replication
|
||||
@@ -2621,107 +2664,6 @@ func (er erasureObjects) TransitionObject(ctx context.Context, bucket, object st
|
||||
return err
|
||||
}
|
||||
|
||||
// RestoreTransitionedObject - restore transitioned object content locally on this cluster.
|
||||
// This is similar to PostObjectRestore from AWS GLACIER
|
||||
// storage class. When PostObjectRestore API is called, a temporary copy of the object
|
||||
// is restored locally to the bucket on source cluster until the restore expiry date.
|
||||
// The copy that was transitioned continues to reside in the transitioned tier.
|
||||
func (er erasureObjects) RestoreTransitionedObject(ctx context.Context, bucket, object string, opts ObjectOptions) error {
|
||||
return er.restoreTransitionedObject(ctx, bucket, object, opts)
|
||||
}
|
||||
|
||||
// update restore status header in the metadata
|
||||
func (er erasureObjects) updateRestoreMetadata(ctx context.Context, bucket, object string, objInfo ObjectInfo, opts ObjectOptions) error {
|
||||
oi := objInfo.Clone()
|
||||
oi.metadataOnly = true // Perform only metadata updates.
|
||||
|
||||
// allow retry in the case of failure to restore
|
||||
delete(oi.UserDefined, xhttp.AmzRestore)
|
||||
|
||||
if _, err := er.CopyObject(ctx, bucket, object, bucket, object, oi, ObjectOptions{
|
||||
VersionID: oi.VersionID,
|
||||
}, ObjectOptions{
|
||||
VersionID: oi.VersionID,
|
||||
}); err != nil {
|
||||
storageLogIf(ctx, fmt.Errorf("Unable to update transition restore metadata for %s/%s(%s): %s", bucket, object, oi.VersionID, err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreTransitionedObject for multipart object chunks the file stream from remote tier into the same number of parts
|
||||
// as in the xl.meta for this version and rehydrates the part.n into the fi.DataDir for this version as in the xl.meta
|
||||
func (er erasureObjects) restoreTransitionedObject(ctx context.Context, bucket string, object string, opts ObjectOptions) error {
|
||||
setRestoreHeaderFn := func(oi ObjectInfo, rerr error) error {
|
||||
if rerr == nil {
|
||||
return nil // nothing to do; restore object was successful
|
||||
}
|
||||
er.updateRestoreMetadata(ctx, bucket, object, oi, opts)
|
||||
return rerr
|
||||
}
|
||||
var oi ObjectInfo
|
||||
// get the file info on disk for transitioned object
|
||||
actualfi, _, _, err := er.getObjectFileInfo(ctx, bucket, object, opts, false)
|
||||
if err != nil {
|
||||
return setRestoreHeaderFn(oi, toObjectErr(err, bucket, object))
|
||||
}
|
||||
|
||||
oi = actualfi.ToObjectInfo(bucket, object, opts.Versioned || opts.VersionSuspended)
|
||||
ropts := putRestoreOpts(bucket, object, opts.Transition.RestoreRequest, oi)
|
||||
if len(oi.Parts) == 1 {
|
||||
var rs *HTTPRangeSpec
|
||||
gr, err := getTransitionedObjectReader(ctx, bucket, object, rs, http.Header{}, oi, opts)
|
||||
if err != nil {
|
||||
return setRestoreHeaderFn(oi, toObjectErr(err, bucket, object))
|
||||
}
|
||||
defer gr.Close()
|
||||
hashReader, err := hash.NewReader(ctx, gr, gr.ObjInfo.Size, "", "", gr.ObjInfo.Size)
|
||||
if err != nil {
|
||||
return setRestoreHeaderFn(oi, toObjectErr(err, bucket, object))
|
||||
}
|
||||
pReader := NewPutObjReader(hashReader)
|
||||
_, err = er.PutObject(ctx, bucket, object, pReader, ropts)
|
||||
return setRestoreHeaderFn(oi, toObjectErr(err, bucket, object))
|
||||
}
|
||||
|
||||
res, err := er.NewMultipartUpload(ctx, bucket, object, ropts)
|
||||
if err != nil {
|
||||
return setRestoreHeaderFn(oi, err)
|
||||
}
|
||||
|
||||
var uploadedParts []CompletePart
|
||||
var rs *HTTPRangeSpec
|
||||
// get reader from the warm backend - note that even in the case of encrypted objects, this stream is still encrypted.
|
||||
gr, err := getTransitionedObjectReader(ctx, bucket, object, rs, http.Header{}, oi, opts)
|
||||
if err != nil {
|
||||
return setRestoreHeaderFn(oi, err)
|
||||
}
|
||||
defer gr.Close()
|
||||
|
||||
// rehydrate the parts back on disk as per the original xl.meta prior to transition
|
||||
for _, partInfo := range oi.Parts {
|
||||
hr, err := hash.NewReader(ctx, io.LimitReader(gr, partInfo.Size), partInfo.Size, "", "", partInfo.Size)
|
||||
if err != nil {
|
||||
return setRestoreHeaderFn(oi, err)
|
||||
}
|
||||
pInfo, err := er.PutObjectPart(ctx, bucket, object, res.UploadID, partInfo.Number, NewPutObjReader(hr), ObjectOptions{})
|
||||
if err != nil {
|
||||
return setRestoreHeaderFn(oi, err)
|
||||
}
|
||||
if pInfo.Size != partInfo.Size {
|
||||
return setRestoreHeaderFn(oi, InvalidObjectState{Bucket: bucket, Object: object})
|
||||
}
|
||||
uploadedParts = append(uploadedParts, CompletePart{
|
||||
PartNumber: pInfo.PartNumber,
|
||||
ETag: pInfo.ETag,
|
||||
})
|
||||
}
|
||||
_, err = er.CompleteMultipartUpload(ctx, bucket, object, res.UploadID, uploadedParts, ObjectOptions{
|
||||
MTime: oi.ModTime,
|
||||
})
|
||||
return setRestoreHeaderFn(oi, err)
|
||||
}
|
||||
|
||||
// DecomTieredObject - moves tiered object to another pool during decommissioning.
|
||||
func (er erasureObjects) DecomTieredObject(ctx context.Context, bucket, object string, fi FileInfo, opts ObjectOptions) error {
|
||||
if opts.UserDefined == nil {
|
||||
|
||||
@@ -19,6 +19,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -670,7 +671,9 @@ func (z *erasureServerPools) deleteObjectReconciled(ctx context.Context, bucket,
|
||||
// deleteObjectsReconciled removes data while retaining each item's authoritative
|
||||
// copy until every other pool has completed its cleanup. The caller holds the
|
||||
// pools-layer write locks and separates marker creation from this batch.
|
||||
func (z *erasureServerPools) deleteObjectsReconciled(ctx context.Context, bucket string, objects []ObjectToDelete, opts ObjectOptions) ([]DeletedObject, []error) {
|
||||
// approved holds the Object Lock state each checked ILM expiry was approved
|
||||
// with, see ExpirationOptions.ApprovedProtection.
|
||||
func (z *erasureServerPools) deleteObjectsReconciled(ctx context.Context, bucket string, objects []ObjectToDelete, approved []objectLockState, opts ObjectOptions) ([]DeletedObject, []error) {
|
||||
deleted := make([]DeletedObject, len(objects))
|
||||
errs := make([]error, len(objects))
|
||||
primary := make([]int, len(objects))
|
||||
@@ -760,11 +763,23 @@ func (z *erasureServerPools) deleteObjectsReconciled(ctx context.Context, bucket
|
||||
}
|
||||
deleteOpts := opts
|
||||
deleteOpts.VersionID, deleteOpts.NoLock = obj.VersionID, true
|
||||
// DeleteObjects already checked ordinary ILM UUID/explicit null
|
||||
// versions against all pools. Empty IDs and user deletes have no such
|
||||
// authorization; restored-copy expiry uses the single-object path.
|
||||
deleteOpts.Expiration.CheckedAllPools = opts.Expiration.Expire && obj.VersionID != ""
|
||||
if deleteOpts.Expiration.CheckedAllPools {
|
||||
deleteOpts.Expiration.ApprovedProtection = approved[i]
|
||||
}
|
||||
deleteOpts.DeleteReplication = obj.ReplicationState()
|
||||
if opts.PrefixEnabledFn != nil {
|
||||
deleteOpts.Versioned = opts.PrefixEnabledFn(obj.ObjectName)
|
||||
}
|
||||
_, err := z.deleteObjectReconciled(ctx, bucket, obj.ObjectName, deleteOpts)
|
||||
if errors.Is(err, errLifecycleNotApplicable) {
|
||||
// A later lifecycle rejection did not commit a local purge. Do
|
||||
// not let the worker replicate the request's pending decision.
|
||||
deleted[i].ReplicationState = ReplicationState{}
|
||||
}
|
||||
if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
|
||||
errs[i] = err
|
||||
} else {
|
||||
|
||||
@@ -522,10 +522,20 @@ func TestPoolsDeleteVersionSpecialCalls(t *testing.T) {
|
||||
opts := ObjectOptions{Versioned: true, VersionID: oi.VersionID, InclFreeVersions: !expiration, Expiration: ExpirationOptions{Expire: expiration}}
|
||||
_, err := z.DeleteObject(t.Context(), bucket, object, opts)
|
||||
if expiration {
|
||||
// No lifecycle rule authorizes expiration. Preserve its existing
|
||||
// version-not-found result, even with an unrelated unreadable pool.
|
||||
if !isErrVersionNotFound(err) {
|
||||
t.Errorf("expiration changed its scanner contract: %v", err)
|
||||
// No rule authorizes expiration. Distinguish refusal from real
|
||||
// absence, even with an unrelated unreadable pool, so a worker
|
||||
// cannot acknowledge or replicate a purge that never happened.
|
||||
if !errors.Is(err, errLifecycleNotApplicable) {
|
||||
t.Errorf("expiration did not preserve its rejection: %v", err)
|
||||
}
|
||||
gr, err := z.serverPools[0].GetObjectNInfo(t.Context(), bucket, object, nil, nil, ObjectOptions{VersionID: oi.VersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(gr)
|
||||
gr.Close()
|
||||
if err != nil || string(data) != "payload" || gr.ObjInfo.VersionID != oi.VersionID {
|
||||
t.Errorf("rejected expiration changed the stored version: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("free-version cleanup was forced through all-pool resolution: %v", err)
|
||||
|
||||
@@ -819,6 +819,10 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
// a noncurrent version and has to be moved like any other. The
|
||||
// scanner applies the expiry once it lives in another pool.
|
||||
return lifecycle.NoneAction
|
||||
case evt.Action.Delete() && !z.migrationExpiryApplies(ctx, objInfo, evt):
|
||||
// Another pool holds versions with which the rule does not
|
||||
// apply. The version moves like any other.
|
||||
return lifecycle.NoneAction
|
||||
case evt.Action.Delete():
|
||||
return evt.Action
|
||||
default:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,270 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/bucket/replication"
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
"github.com/minio/minio/internal/event"
|
||||
"github.com/minio/minio/internal/pubsub"
|
||||
)
|
||||
|
||||
// Reuse the author's scanner/worker fixture. A second configured pool is
|
||||
// sufficient to trigger the tiered NNV regression; no versions need move there.
|
||||
func TestPoolsLifecycleTieredNoncurrentBatch(t *testing.T) {
|
||||
for _, tiered := range []bool{false, true} {
|
||||
name := "plain"
|
||||
if tiered {
|
||||
name = "tiered"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
s := newSplitILM(t, versioning.Enabled, splitILMRules)
|
||||
old := func(label string, days float64) splitVersion {
|
||||
v := splitAt(label, days, 0)
|
||||
v.tiered = tiered
|
||||
return v
|
||||
}
|
||||
s.run([]splitKey{
|
||||
{"nnv-all-in-0", []splitVersion{old("v1", 5), old("v2", 4), splitAt("v3", 3, 0), splitAt("v4", 2, 0)}, "v4", "v4", [][]string{{"v4", "v3"}, nil}},
|
||||
{"ncv-all-in-0", []splitVersion{old("v1", 5), old("v2", 4), splitAt("v3", 0.01, 0)}, "v3", "v3", [][]string{{"v3", "v2"}, nil}},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Observe the actual pool results consumed by the expiry worker, without
|
||||
// replacing the storage deletion or the replication enqueue path.
|
||||
type lifecycleBatchResult struct {
|
||||
ObjectLayer
|
||||
deleted []DeletedObject
|
||||
errs []error
|
||||
}
|
||||
|
||||
func (o *lifecycleBatchResult) DeleteObjects(ctx context.Context, bucket string, objects []ObjectToDelete, opts ObjectOptions) ([]DeletedObject, []error) {
|
||||
o.deleted, o.errs = o.ObjectLayer.DeleteObjects(ctx, bucket, objects, opts)
|
||||
return o.deleted, o.errs
|
||||
}
|
||||
|
||||
func TestPoolsLifecycleTieredBatchReplication(t *testing.T) {
|
||||
for _, identity := range []string{"uuid", "null"} {
|
||||
t.Run(identity, func(t *testing.T) {
|
||||
s := newSplitILM(t, versioning.Enabled, splitILMRules)
|
||||
const key = "nnv-replicated"
|
||||
oldest := splitTiered("v1", 5, 0)
|
||||
oldest.null = identity == "null"
|
||||
for _, v := range []splitVersion{oldest, splitTiered("v2", 4, 0), splitTiered("v3", 3, 0), splitAt("v4", 2, 0)} {
|
||||
s.write(key, v)
|
||||
}
|
||||
target := newILMTestTarget(t)
|
||||
registerILMTestTarget(t, s.z, s.bucket, target, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
t.Errorf("unexpected target method: %s", r.Method)
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
target.deletes.Add(1)
|
||||
opts := ObjectOptions{VersionID: r.URL.Query().Get("versionId"), Versioned: true, ReplicationRequest: true}
|
||||
opts.SetReplicaStatus(replication.Replica)
|
||||
if _, err := s.z.DeleteObject(r.Context(), target.bucket, key, opts); err != nil && !isErrVersionNotFound(err) && !isErrObjectNotFound(err) {
|
||||
t.Errorf("target purge: %v", err)
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
setILMTestReplicationConfig(t, s.bucket, target.arn, target.bucket)
|
||||
stats := NewReplicationStats(t.Context(), nil)
|
||||
previous := globalReplicationStats.Swap(stats)
|
||||
t.Cleanup(func() { globalReplicationStats.Store(previous) })
|
||||
restore, _ := newILMTestReplicationPool(t.Context(), s.z, stats)
|
||||
t.Cleanup(restore)
|
||||
var entries []ObjectToDelete
|
||||
for _, label := range []string{"v1", "v3"} {
|
||||
vid := s.ids[key+" "+label]
|
||||
if vid == "" {
|
||||
vid = nullVersionID
|
||||
}
|
||||
oi, err := s.z.GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: vid, Versioned: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := ilmPurgeToDelete(t.Context(), s.bucket, oi, true)
|
||||
if entry.VersionPurgeStatus != replication.VersionPurgePending {
|
||||
t.Fatalf("fixture lacks purge decision: %+v", entry)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
putConsistencyObject(t, s.z, target.bucket, key, 0, label, ObjectOptions{Versioned: identity != "null" || label != "v1", VersionID: vid})
|
||||
}
|
||||
result := &lifecycleBatchResult{ObjectLayer: s.z}
|
||||
deleteObjectVersions(t.Context(), result, s.bucket, entries, []lifecycle.Event{{Action: lifecycle.DeleteVersionAction}, {Action: lifecycle.DeleteVersionAction}})
|
||||
if result.errs[0] != nil {
|
||||
t.Errorf("eligible delete: %v", result.errs[0])
|
||||
}
|
||||
if !errors.Is(result.errs[1], errLifecycleNotApplicable) {
|
||||
t.Errorf("retained version result: %v", result.errs[1])
|
||||
}
|
||||
if result.deleted[1].VersionPurgeStatus() != "" || result.deleted[1].ReplicationState.ReplicateDecisionStr != "" {
|
||||
t.Errorf("rejected entry retained replication state: %+v", result.deleted[1])
|
||||
}
|
||||
source, err := s.z.GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: entries[0].VersionID, Versioned: true})
|
||||
if !isErrMethodNotAllowed(err) || source.VersionPurgeStatus != replication.VersionPurgePending {
|
||||
t.Errorf("source must be a pending purge tombstone before enqueue: status=%q err=%v", source.VersionPurgeStatus, err)
|
||||
}
|
||||
retained, err := s.z.GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: entries[1].VersionID, Versioned: true})
|
||||
if err != nil || retained.VersionPurgeStatus != "" {
|
||||
t.Errorf("rejected source changed: %+v, %v", retained, err)
|
||||
}
|
||||
queue := globalReplicationPool.Get().workers[0]
|
||||
if len(queue) != 1 {
|
||||
t.Fatalf("queued %d purges, want only eligible v1", len(queue))
|
||||
}
|
||||
deletion, ok := (<-queue).(DeletedObjectReplicationInfo)
|
||||
if !ok || deletion.VersionID != entries[0].VersionID || deletion.EventType != ReplicateILMDelete || deletion.VersionPurgeStatus() != replication.VersionPurgePending {
|
||||
t.Fatalf("wrong purge queued: %+v", deletion)
|
||||
}
|
||||
if result := replicateDelete(t.Context(), deletion, s.z); result.VersionPurgeStatus() != replication.VersionPurgeComplete {
|
||||
t.Fatalf("replicate purge: %+v", result)
|
||||
}
|
||||
if target.deletes.Load() != 1 {
|
||||
t.Errorf("target delete count=%d", target.deletes.Load())
|
||||
}
|
||||
for _, bucket := range []string{s.bucket, target.bucket} {
|
||||
if _, err := s.z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: entries[0].VersionID}); !isErrVersionNotFound(err) && !isErrObjectNotFound(err) {
|
||||
t.Errorf("purged v1 remains in %s: %v", bucket, err)
|
||||
}
|
||||
if _, err := s.z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: entries[1].VersionID}); err != nil {
|
||||
t.Errorf("retained v3 missing from %s: %v", bucket, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsLifecycleTieredBatchIdentity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
versioned bool
|
||||
id string
|
||||
expire bool
|
||||
wantGone bool
|
||||
}{
|
||||
{"uuid-ilm", true, "uuid", true, true},
|
||||
{"null-ilm", true, "null", true, true},
|
||||
{"empty-unversioned-ilm", false, "", true, false},
|
||||
{"uuid-user-delete", true, "uuid", false, true},
|
||||
{"empty-user-delete", false, "", false, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
state := versioning.State("")
|
||||
if tc.versioned {
|
||||
state = versioning.Enabled
|
||||
}
|
||||
s := newSplitILM(t, state, splitILMRules)
|
||||
const key = "nnv-identity"
|
||||
v := splitTiered("v1", 5, 0)
|
||||
v.null = tc.id != "uuid"
|
||||
s.write(key, v)
|
||||
if tc.versioned {
|
||||
s.write(key, splitAt("v2", 4, 0))
|
||||
s.write(key, splitAt("v3", 3, 0))
|
||||
}
|
||||
vid := tc.id
|
||||
if vid == "uuid" {
|
||||
vid = s.ids[key+" v1"]
|
||||
}
|
||||
vc, _ := globalBucketVersioningSys.Get(s.bucket)
|
||||
_, errs := s.z.DeleteObjects(t.Context(), s.bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: key, VersionID: vid}}}, ObjectOptions{PrefixEnabledFn: vc.PrefixEnabled, Expiration: ExpirationOptions{Expire: tc.expire}})
|
||||
if tc.expire && !tc.wantGone {
|
||||
if !errors.Is(errs[0], errLifecycleNotApplicable) {
|
||||
t.Fatalf("ineligible expiry: %v", errs[0])
|
||||
}
|
||||
} else if errs[0] != nil {
|
||||
t.Fatalf("delete: %v", errs[0])
|
||||
}
|
||||
query := vid
|
||||
if query == "" {
|
||||
query = nullVersionID
|
||||
}
|
||||
_, err := s.z.GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: query})
|
||||
gone := isErrVersionNotFound(err) || isErrObjectNotFound(err)
|
||||
if gone != tc.wantGone || (!gone && err != nil) {
|
||||
t.Fatalf("gone=%t err=%v, want gone=%t", gone, err, tc.wantGone)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A batch entry whose expiry the all-pools recheck rejects is kept. The worker
|
||||
// must not report it as removed, by event, ILM trace or audit, while eligible
|
||||
// entries of the same batch are still reported.
|
||||
func TestPoolsLifecycleBatchRejectionNotAnnounced(t *testing.T) {
|
||||
s := newSplitILM(t, versioning.Enabled, splitILMRules)
|
||||
const key = "nnv-announce"
|
||||
for _, v := range []splitVersion{splitTiered("v1", 5, 0), splitTiered("v2", 4, 0), splitTiered("v3", 3, 0), splitAt("v4", 2, 0)} {
|
||||
s.write(key, v)
|
||||
}
|
||||
eligible, retained := s.ids[key+" v1"], s.ids[key+" v3"]
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
events := make(chan event.Event, 8)
|
||||
if err := globalHTTPListen.Subscribe(pubsub.MaskFromMaskable(event.ObjectRemovedDelete), events, done, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
traces := make(chan madmin.TraceInfo, 8)
|
||||
if err := globalTrace.Subscribe(madmin.TraceILM, traces, done, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := &lifecycleBatchResult{ObjectLayer: s.z}
|
||||
deleteObjectVersions(t.Context(), result, s.bucket, []ObjectToDelete{
|
||||
{ObjectV: ObjectV{ObjectName: key, VersionID: eligible}},
|
||||
{ObjectV: ObjectV{ObjectName: key, VersionID: retained}},
|
||||
}, []lifecycle.Event{{Action: lifecycle.DeleteVersionAction}, {Action: lifecycle.DeleteVersionAction}})
|
||||
if result.errs[0] != nil || !errors.Is(result.errs[1], errLifecycleNotApplicable) {
|
||||
t.Fatalf("fixture results: eligible=%v retained=%v", result.errs[0], result.errs[1])
|
||||
}
|
||||
if _, err := s.z.GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: retained, Versioned: true}); err != nil {
|
||||
t.Fatalf("rejected version must remain: %v", err)
|
||||
}
|
||||
// Both publishers deliver synchronously, so everything sent is buffered.
|
||||
var notified, traced []string
|
||||
for drained := false; !drained; {
|
||||
select {
|
||||
case ev := <-events:
|
||||
notified = append(notified, ev.S3.Object.VersionID)
|
||||
case tr := <-traces:
|
||||
if tr.FuncName == ILMExpiry {
|
||||
traced = append(traced, tr.Custom["version-id"])
|
||||
}
|
||||
default:
|
||||
drained = true
|
||||
}
|
||||
}
|
||||
if len(notified) != 1 || notified[0] != eligible {
|
||||
t.Errorf("removal events for %v, want only eligible %s (retained %s)", notified, eligible, retained)
|
||||
}
|
||||
if len(traced) != 1 || traced[0] != eligible {
|
||||
t.Errorf("ILM expiry traces for %v, want only eligible %s (retained %s)", traced, eligible, retained)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/bucket/replication"
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
// A partial metadata update can leave the newest lock/tag revision on a copy
|
||||
// other than the one selected by content ModTime. Keep the data identity intact.
|
||||
func setLifecycleCopyMetadata(t *testing.T, s *splitILM, key, id string, pool int, metadata map[string]string, advance bool) {
|
||||
t.Helper()
|
||||
oi, err := s.z.serverPools[pool].GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts := ObjectOptions{VersionID: id, Versioned: true, MTime: oi.ModTime}
|
||||
if advance {
|
||||
opts.MTime = opts.MTime.Add(time.Hour)
|
||||
}
|
||||
opts.EvalMetadataFn = func(oi *ObjectInfo, _ error) (ReplicateDecision, error) {
|
||||
maps.Copy(oi.UserDefined, metadata)
|
||||
return ReplicateDecision{}, nil
|
||||
}
|
||||
if _, err := s.z.serverPools[pool].PutObjectMetadata(t.Context(), s.bucket, key, opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsLifecycleDuplicateLockMetadata(t *testing.T) {
|
||||
old := UTCNow().Add(-2 * time.Hour).Format(time.RFC3339Nano)
|
||||
recent := UTCNow().Add(-time.Hour).Format(time.RFC3339Nano)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
copies [2]map[string]string
|
||||
mode, hold string
|
||||
delete bool
|
||||
newerPool1 bool
|
||||
}{
|
||||
{name: "hold-on", copies: [2]map[string]string{poolLockMetadata("", "OFF", old, old), poolLockMetadata("", "ON", old, recent)}, hold: "ON"},
|
||||
{name: "held-earlier-pool", copies: [2]map[string]string{poolLockMetadata("", "ON", old, recent), poolLockMetadata("", "OFF", old, old)}, hold: "ON", newerPool1: true},
|
||||
{name: "retention-on", copies: [2]map[string]string{poolLockMetadata("", "OFF", old, old), poolLockMetadata("GOVERNANCE", "OFF", recent, old)}, mode: "GOVERNANCE", hold: "OFF"},
|
||||
{name: "independent-fields", copies: [2]map[string]string{poolLockMetadata("GOVERNANCE", "OFF", recent, old), poolLockMetadata("", "ON", old, recent)}, mode: "GOVERNANCE", hold: "ON"},
|
||||
{name: "hold-released", copies: [2]map[string]string{poolLockMetadata("", "ON", old, old), poolLockMetadata("", "OFF", old, recent)}, hold: "OFF", delete: true},
|
||||
{name: "retention-released", copies: [2]map[string]string{poolLockMetadata("GOVERNANCE", "OFF", old, old), poolLockMetadata("", "OFF", recent, old)}, hold: "OFF", delete: true},
|
||||
} {
|
||||
for _, batch := range []bool{false, true} {
|
||||
path := "single"
|
||||
if batch {
|
||||
path = "batch"
|
||||
}
|
||||
t.Run(tc.name+"/"+path, func(t *testing.T) {
|
||||
s := &splitILM{migrationILM: newMigrationILM(t, versioning.Enabled, true, splitILMRules), ids: map[string]string{}, bodies: map[string]string{}}
|
||||
const key = "ncv-copy-lock"
|
||||
v := splitAt("old", 5, 0, 1)
|
||||
v.tiered = batch
|
||||
s.write(key, v)
|
||||
s.write(key, splitAt("new", 3, 0, 1))
|
||||
id := s.ids[key+" old"]
|
||||
for pool, metadata := range tc.copies {
|
||||
metadata = maps.Clone(metadata)
|
||||
if metadata["x-amz-object-lock-mode"] != "" {
|
||||
metadata["x-amz-object-lock-retain-until-date"] = UTCNow().AddDate(1, 0, 0).Format(time.RFC3339)
|
||||
} else {
|
||||
metadata["x-amz-object-lock-retain-until-date"] = ""
|
||||
}
|
||||
setLifecycleCopyMetadata(t, s, key, id, pool, metadata, tc.newerPool1 && pool == 1)
|
||||
}
|
||||
versions, events, err := s.z.lifecycleEventsAllPools(t.Context(), s.bucket, key)
|
||||
switch {
|
||||
case tc.newerPool1:
|
||||
// Another ModTime makes the copies separate writes. The
|
||||
// held older one refuses the object instead of merging.
|
||||
if !errors.Is(err, errLifecycleNotApplicable) {
|
||||
t.Fatalf("held older write: %v", err)
|
||||
}
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
default:
|
||||
if len(versions) != 2 || versions[1].VersionID != id || versions[1].NumVersions != 2 {
|
||||
t.Fatalf("wrong version history: %+v", versions)
|
||||
}
|
||||
state := storedObjectLockState(versions[1].UserDefined)
|
||||
if state.mode != tc.mode || state.legalHold != tc.hold {
|
||||
t.Errorf("merged state=%+v, want mode=%s hold=%s", state, tc.mode, tc.hold)
|
||||
}
|
||||
want := lifecycle.NoneAction
|
||||
if tc.delete {
|
||||
want = lifecycle.DeleteVersionAction
|
||||
}
|
||||
// Read-side expiry rechecks consume the same event producer.
|
||||
if events[1].Action != want {
|
||||
t.Errorf("expiry event=%s want=%s", events[1].Action, want)
|
||||
}
|
||||
}
|
||||
opts := ObjectOptions{Versioned: true, Expiration: ExpirationOptions{Expire: true}}
|
||||
if batch {
|
||||
_, errs := s.z.DeleteObjects(t.Context(), s.bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: key, VersionID: id}}}, opts)
|
||||
err = errs[0]
|
||||
} else {
|
||||
opts.VersionID = id
|
||||
_, err = s.z.DeleteObject(t.Context(), s.bucket, key, opts)
|
||||
}
|
||||
if tc.delete && err != nil || !tc.delete && !errors.Is(err, errLifecycleNotApplicable) {
|
||||
t.Errorf("delete error=%v, wantDelete=%t", err, tc.delete)
|
||||
}
|
||||
for pool := range s.z.serverPools {
|
||||
_, err := s.z.serverPools[pool].GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: id})
|
||||
// Single DeleteObject retains its existing first-copy dispatch.
|
||||
gone := tc.delete && (batch || pool == 0)
|
||||
if gone && !isErrVersionNotFound(err) || !gone && err != nil {
|
||||
t.Errorf("pool=%d err=%v wantGone=%t", pool, err, gone)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsLifecycleDuplicateTagMetadata(t *testing.T) {
|
||||
const rule = `<Rule><ID>tagged</ID><Status>Enabled</Status><Filter><Tag><Key>expire</Key><Value>yes</Value></Tag></Filter><NoncurrentVersionExpiration><NoncurrentDays>1</NoncurrentDays></NoncurrentVersionExpiration></Rule>`
|
||||
for _, tc := range []struct {
|
||||
name, stale, fresh string
|
||||
expire bool
|
||||
}{
|
||||
{"removed", "expire=yes", "", false},
|
||||
{"changed", "expire=yes", "expire=no", false},
|
||||
{"enabled", "expire=no", "expire=yes", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := newSplitILM(t, versioning.Enabled, rule)
|
||||
const key = "tagged-copy"
|
||||
s.write(key, splitTiered("old", 5, 0, 1))
|
||||
s.write(key, splitAt("new", 3, 0, 1))
|
||||
id := s.ids[key+" old"]
|
||||
old := UTCNow().Add(-2 * time.Hour).Format(time.RFC3339Nano)
|
||||
recent := UTCNow().Add(-time.Hour).Format(time.RFC3339Nano)
|
||||
for pool, tags := range []string{tc.stale, tc.fresh} {
|
||||
stamp := old
|
||||
if pool == 1 {
|
||||
stamp = recent
|
||||
}
|
||||
setLifecycleCopyMetadata(t, s, key, id, pool, map[string]string{xhttp.AmzObjectTagging: tags, ReservedMetadataPrefixLower + TaggingTimestamp: stamp}, false)
|
||||
}
|
||||
versions, events, err := s.z.lifecycleEventsAllPools(t.Context(), s.bucket, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(versions) != 2 || versions[1].UserTags != tc.fresh || versions[1].UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] != recent {
|
||||
t.Fatalf("lost newest tag revision: %+v", versions)
|
||||
}
|
||||
want := lifecycle.NoneAction
|
||||
if tc.expire {
|
||||
want = lifecycle.DeleteVersionAction
|
||||
}
|
||||
if events[1].Action != want {
|
||||
t.Errorf("shared read/delete expiry event=%s want=%s", events[1].Action, want)
|
||||
}
|
||||
_, errs := s.z.DeleteObjects(t.Context(), s.bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: key, VersionID: id}}}, ObjectOptions{Versioned: true, Expiration: ExpirationOptions{Expire: true}})
|
||||
if tc.expire && errs[0] != nil || !tc.expire && !errors.Is(errs[0], errLifecycleNotApplicable) {
|
||||
t.Errorf("delete error=%v", errs[0])
|
||||
}
|
||||
for pool := range s.z.serverPools {
|
||||
_, err := s.z.serverPools[pool].GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: id})
|
||||
if tc.expire && !isErrVersionNotFound(err) || !tc.expire && err != nil {
|
||||
t.Errorf("pool=%d err=%v wantGone=%t", pool, err, tc.expire)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// All-pool validation uses ReadXL. Run once at the first addressed read in
|
||||
// batch dispatch, after validation and before the set's lifecycle check.
|
||||
type lifecycleDispatchDisk struct {
|
||||
StorageAPI
|
||||
bucket, key string
|
||||
once *sync.Once
|
||||
scans *atomic.Int64
|
||||
beforeRead func()
|
||||
}
|
||||
|
||||
func (d lifecycleDispatchDisk) ReadXL(ctx context.Context, bucket, key string, data bool) (RawFileInfo, error) {
|
||||
if bucket == d.bucket && key == d.key {
|
||||
d.scans.Add(1)
|
||||
}
|
||||
return d.StorageAPI.ReadXL(ctx, bucket, key, data)
|
||||
}
|
||||
|
||||
func (d lifecycleDispatchDisk) ReadVersion(ctx context.Context, orig, bucket, key, id string, opts ReadOptions) (FileInfo, error) {
|
||||
if bucket == d.bucket && key == d.key {
|
||||
d.once.Do(d.beforeRead)
|
||||
}
|
||||
return d.StorageAPI.ReadVersion(ctx, orig, bucket, key, id, opts)
|
||||
}
|
||||
|
||||
func TestPoolsLifecycleBatchRejectionReplication(t *testing.T) {
|
||||
for _, mode := range []string{"withdraw-before-validation", "withdraw-after-validation", "already-absent", "normal"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
s := newSplitILM(t, versioning.Enabled, splitILMRules)
|
||||
const key = "ncv-dispatch-recheck"
|
||||
s.write(key, splitTiered("old", 5, 0))
|
||||
s.write(key, splitAt("new", 3, 0))
|
||||
id := s.ids[key+" old"]
|
||||
target := newILMTestTarget(t)
|
||||
registerILMTestTarget(t, s.z, s.bucket, target, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete || r.URL.Query().Get("versionId") != id {
|
||||
t.Errorf("unexpected purge: %s %s", r.Method, r.URL)
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
target.deletes.Add(1)
|
||||
opts := ObjectOptions{VersionID: r.URL.Query().Get("versionId"), Versioned: true, ReplicationRequest: true}
|
||||
opts.SetReplicaStatus(replication.Replica)
|
||||
if _, err := s.z.DeleteObject(r.Context(), target.bucket, key, opts); err != nil && !isErrVersionNotFound(err) && !isErrObjectNotFound(err) {
|
||||
t.Error(err)
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
setILMTestReplicationConfig(t, s.bucket, target.arn, target.bucket)
|
||||
putConsistencyObject(t, s.z, target.bucket, key, 0, "replica", ObjectOptions{Versioned: true, VersionID: id})
|
||||
stats := NewReplicationStats(t.Context(), nil)
|
||||
previous := globalReplicationStats.Swap(stats)
|
||||
t.Cleanup(func() { globalReplicationStats.Store(previous) })
|
||||
restore, _ := newILMTestReplicationPool(t.Context(), s.z, stats)
|
||||
t.Cleanup(restore)
|
||||
oi, err := s.z.GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := ilmPurgeToDelete(t.Context(), s.bucket, oi, true)
|
||||
if entry.VersionPurgeStatus != replication.VersionPurgePending {
|
||||
t.Fatal("fixture lacks pending purge")
|
||||
}
|
||||
withdraw := func() {
|
||||
meta, err := globalBucketMetadataSys.Get(s.bucket)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
meta.lifecycleConfig = nil
|
||||
meta.LifecycleConfigXML = nil
|
||||
globalBucketMetadataSys.Set(s.bucket, meta)
|
||||
}
|
||||
if mode == "withdraw-before-validation" {
|
||||
withdraw()
|
||||
}
|
||||
originals := s.z.serverPools[0].getHashedSet(key).getDisks()
|
||||
var once sync.Once
|
||||
var scans atomic.Int64
|
||||
var dispatched atomic.Bool
|
||||
for _, pool := range s.z.serverPools {
|
||||
pool.erasureDisksMu.Lock()
|
||||
prior := pool.erasureDisks
|
||||
wrapped := make([][]StorageAPI, len(prior))
|
||||
for i, disks := range prior {
|
||||
wrapped[i] = append([]StorageAPI(nil), disks...)
|
||||
for j, disk := range disks {
|
||||
wrapped[i][j] = lifecycleDispatchDisk{StorageAPI: disk, bucket: s.bucket, key: key, once: &once, scans: &scans, beforeRead: func() {
|
||||
dispatched.Store(true)
|
||||
if mode != "withdraw-before-validation" && scans.Load() == 0 {
|
||||
t.Error("dispatch injection ran before all-pool validation")
|
||||
}
|
||||
switch mode {
|
||||
case "withdraw-after-validation":
|
||||
withdraw()
|
||||
case "already-absent":
|
||||
fi := FileInfo{VersionID: id}
|
||||
fi.SetTierFreeVersionID(mustGetUUID())
|
||||
for _, disk := range originals {
|
||||
if err := disk.DeleteVersion(t.Context(), s.bucket, key, fi, false, DeleteOptions{}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
}
|
||||
}
|
||||
pool.erasureDisks = wrapped
|
||||
pool.erasureDisksMu.Unlock()
|
||||
t.Cleanup(func() { pool.erasureDisksMu.Lock(); pool.erasureDisks = prior; pool.erasureDisksMu.Unlock() })
|
||||
}
|
||||
result := &lifecycleBatchResult{ObjectLayer: s.z}
|
||||
deleteObjectVersions(t.Context(), result, s.bucket, []ObjectToDelete{entry}, []lifecycle.Event{{Action: lifecycle.DeleteVersionAction}})
|
||||
if mode != "withdraw-before-validation" && !dispatched.Load() {
|
||||
t.Fatal("dispatch injection did not run")
|
||||
}
|
||||
queue := globalReplicationPool.Get().workers[0]
|
||||
reject := mode == "withdraw-before-validation" || mode == "withdraw-after-validation"
|
||||
if reject {
|
||||
if !errors.Is(result.errs[0], errLifecycleNotApplicable) || result.deleted[0].found || result.deleted[0].VersionPurgeStatus() != "" || result.deleted[0].ReplicationState.ReplicateDecisionStr != "" {
|
||||
t.Errorf("rejection acknowledged a purge: %+v, %v", result.deleted[0], result.errs[0])
|
||||
}
|
||||
if len(queue) != 0 {
|
||||
t.Fatalf("rejected delete queued %d remote purges", len(queue))
|
||||
}
|
||||
for _, bucket := range []string{s.bucket, target.bucket} {
|
||||
oi, err := s.z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: id})
|
||||
if err != nil || oi.VersionPurgeStatus != "" {
|
||||
t.Errorf("rejection changed %s: purge=%s err=%v", bucket, oi.VersionPurgeStatus, err)
|
||||
}
|
||||
}
|
||||
if target.deletes.Load() != 0 {
|
||||
t.Fatal("rejected delete reached remote target")
|
||||
}
|
||||
return
|
||||
}
|
||||
if result.errs[0] != nil || len(queue) != 1 {
|
||||
t.Fatalf("valid/absent purge: error=%v queued=%d", result.errs[0], len(queue))
|
||||
}
|
||||
source, err := s.z.GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: id})
|
||||
if mode == "normal" && (!isErrMethodNotAllowed(err) || source.VersionPurgeStatus != replication.VersionPurgePending) {
|
||||
t.Fatalf("purge not committed locally: %+v %v", source, err)
|
||||
}
|
||||
if mode == "already-absent" && !isErrVersionNotFound(err) {
|
||||
t.Fatalf("source must actually be absent: %v", err)
|
||||
}
|
||||
deletion := (<-queue).(DeletedObjectReplicationInfo)
|
||||
if result := replicateDelete(t.Context(), deletion, s.z); result.VersionPurgeStatus() != replication.VersionPurgeComplete {
|
||||
t.Fatalf("purge failed: %+v", result)
|
||||
}
|
||||
if target.deletes.Load() != 1 {
|
||||
t.Fatalf("remote deletes=%d", target.deletes.Load())
|
||||
}
|
||||
for _, bucket := range []string{s.bucket, target.bucket} {
|
||||
if _, err := s.z.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: id}); !isErrVersionNotFound(err) && !isErrObjectNotFound(err) {
|
||||
t.Errorf("purged version remains in %s: %v", bucket, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
// Copyright (c) 2026 Denis Babchuk
|
||||
//
|
||||
// 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 (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
objectlock "github.com/minio/minio/internal/bucket/object/lock"
|
||||
"github.com/minio/minio/internal/bucket/replication"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
// An ordinary ILM rejection is not evidence that the version is absent. In
|
||||
// particular it must not acknowledge a pending replication purge.
|
||||
var errLifecycleNotApplicable = errors.New("lifecycle expiry no longer applies")
|
||||
|
||||
// ILM decides on the versions of an object that one pool holds: the scanner
|
||||
// and the drive heal read the drives of one set, decommission and rebalance
|
||||
// walk one pool, and a GET or HEAD of a version reads the pool that holds it.
|
||||
// IsLatest, NumVersions and SuccessorModTime count only these. An object can
|
||||
// have versions in several pools: from two concurrent first writes, from
|
||||
// overwrites that went to another pool, from data movement. The expiries
|
||||
// these decisions run delete in every pool, so a version that is the latest
|
||||
// in its own pool but not of the object had a newer version hidden under a
|
||||
// delete marker or deleted with all versions, and a delete marker without
|
||||
// noncurrent versions in its pool was removed over the versions of another
|
||||
// pool. Deciding on fewer versions only delays the expiry of a noncurrent
|
||||
// version: its successor in the pool is not older than the one of the object,
|
||||
// and fewer newer versions count against NewerNoncurrentVersions.
|
||||
//
|
||||
// Before an expiry deletes in any pool, it is evaluated again with the
|
||||
// versions of the object in all pools, as the scanner would evaluate it if
|
||||
// one pool held them all. An expiry that no longer applies is not run, as the
|
||||
// recheck of a set does it: the scanner queues it again once the rule
|
||||
// applies to the object.
|
||||
|
||||
// objectVersions returns the versions of object that the drives of the set
|
||||
// hold authoritatively, newest first, without free versions. It returns no
|
||||
// versions when the set does not hold the object, and an error when the
|
||||
// drives cannot tell its complete history.
|
||||
//
|
||||
// Expiry deletes by this history, so a revision of a version, its header
|
||||
// and its metadata (versionRevision), counts only when more than half of
|
||||
// the configured drives and the read quorum of its metadata hold it. A
|
||||
// version that some drive shows without such a revision, or too many
|
||||
// unreadable drives, could hide what an expiry must not delete. Free
|
||||
// versions are tier GC records and do not count.
|
||||
func (er erasureObjects) objectVersions(ctx context.Context, bucket, object string) ([]FileInfo, error) {
|
||||
disks := er.getDisks()
|
||||
rawFileInfos, errs := readAllRawFileInfo(ctx, disks, bucket, object, false)
|
||||
majority := len(disks)/2 + 1
|
||||
|
||||
var (
|
||||
metas [][]xlMetaV2ShallowVersion
|
||||
metaVs []uint8
|
||||
metaV uint8
|
||||
unknown int
|
||||
)
|
||||
for i, rf := range rawFileInfos {
|
||||
if errs[i] != nil {
|
||||
if !errors.Is(errs[i], errFileNotFound) && !errors.Is(errs[i], errVolumeNotFound) {
|
||||
unknown++
|
||||
}
|
||||
continue
|
||||
}
|
||||
var xl xlMetaV2
|
||||
if err := xl.LoadOrConvert(rf.Buf); err != nil {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
metas = append(metas, xl.versions)
|
||||
metaVs = append(metaVs, xl.metaV)
|
||||
metaV = xl.metaV
|
||||
}
|
||||
// Without an unknown drive the history is complete, also on one drive.
|
||||
if unknown > 0 && unknown >= len(disks)/2 {
|
||||
return nil, toObjectErr(errErasureReadQuorum, bucket, object)
|
||||
}
|
||||
|
||||
quorumMetas, quorumErrs := make([]FileInfo, len(disks)), make([]error, len(disks))
|
||||
merged, err := authoritativeHistory(ctx, metas, metaVs, metaV, majority, func(ver xlMetaV2ShallowVersion, metaV uint8) int {
|
||||
return er.versionReadQuorum(ctx, bucket, object, ver, metaV, quorumMetas, quorumErrs)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, toObjectErr(err, bucket, object)
|
||||
}
|
||||
if merged == nil {
|
||||
return nil, nil // not in this set
|
||||
}
|
||||
all, err := xlMetaV2{versions: merged, metaV: metaV}.ListVersions(bucket, object, false)
|
||||
if err != nil {
|
||||
return nil, toObjectErr(err, bucket, object)
|
||||
}
|
||||
versions := all[:0]
|
||||
for _, fi := range all {
|
||||
if !fi.TierFreeVersion() {
|
||||
versions = append(versions, fi)
|
||||
}
|
||||
}
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// revisionKey identifies a revision of a version: its header and a strong
|
||||
// digest of its metadata. The header Signature is a 32-bit fold of weak
|
||||
// hashes; two revisions can share it.
|
||||
type revisionKey struct {
|
||||
header xlMetaV2VersionHeader
|
||||
digest [sha256.Size]byte
|
||||
}
|
||||
|
||||
// versionRevision returns the revision of ver, whose metadata is encoded as
|
||||
// metaV describes. As Signature does, it leaves out the fields that differ
|
||||
// between the drives of one write: the erasure index, empty part lists and,
|
||||
// for legacy versions, the release and the per-shard checksums. The digest
|
||||
// input is canonical: each map, including the part checksums of a legacy
|
||||
// version, is its own section with its entry count and sorted,
|
||||
// length-prefixed entries, since msgp encodes maps in iteration order and an
|
||||
// entry must not move between maps unnoticed. The header of a
|
||||
// legacy version carries a signature over its per-shard checksums, so the
|
||||
// key replaces it with one from the digest.
|
||||
func versionRevision(ver xlMetaV2ShallowVersion, metaV uint8) revisionKey {
|
||||
h := sha256.New()
|
||||
writeUint := func(n int) {
|
||||
var b [8]byte
|
||||
binary.LittleEndian.PutUint64(b[:], uint64(n))
|
||||
h.Write(b[:])
|
||||
}
|
||||
writeField := func(b []byte) {
|
||||
writeUint(len(b))
|
||||
h.Write(b)
|
||||
}
|
||||
section := func(name string, entries int) {
|
||||
writeField([]byte(name))
|
||||
writeUint(entries)
|
||||
}
|
||||
writeStrings := func(name string, m map[string]string) {
|
||||
section(name, len(m))
|
||||
for _, k := range slices.Sorted(maps.Keys(m)) {
|
||||
writeField([]byte(k))
|
||||
writeField([]byte(m[k]))
|
||||
}
|
||||
}
|
||||
writeBytes := func(name string, m map[string][]byte) {
|
||||
section(name, len(m))
|
||||
for _, k := range slices.Sorted(maps.Keys(m)) {
|
||||
writeField([]byte(k))
|
||||
writeField(m[k])
|
||||
}
|
||||
}
|
||||
key := revisionKey{header: ver.header}
|
||||
var v xlMetaV2Version
|
||||
if _, err := v.unmarshalV(metaV, ver.meta); err == nil {
|
||||
var (
|
||||
bts []byte
|
||||
err error = errFileCorrupt
|
||||
)
|
||||
section("type", 1)
|
||||
writeField([]byte{byte(v.Type)})
|
||||
switch {
|
||||
case v.Type == ObjectType && v.ObjectV2 != nil:
|
||||
c := *v.ObjectV2
|
||||
c.ErasureIndex = 0
|
||||
if !slices.ContainsFunc(c.PartETags, func(tag string) bool { return tag != "" }) {
|
||||
c.PartETags = nil
|
||||
}
|
||||
if len(c.PartActualSizes) == 0 {
|
||||
c.PartActualSizes = nil
|
||||
}
|
||||
writeStrings("user", c.MetaUser)
|
||||
writeBytes("sys", c.MetaSys)
|
||||
c.MetaUser, c.MetaSys = nil, nil
|
||||
bts, err = c.MarshalMsg(nil)
|
||||
case v.Type == DeleteType && v.DeleteMarker != nil:
|
||||
c := *v.DeleteMarker
|
||||
writeBytes("sys", c.MetaSys)
|
||||
c.MetaSys = nil
|
||||
bts, err = c.MarshalMsg(nil)
|
||||
case v.Type == LegacyType && v.ObjectV1 != nil:
|
||||
c := *v.ObjectV1
|
||||
c.Erasure.Index = 0
|
||||
c.Erasure.Checksums = nil
|
||||
c.Minio.Release = ""
|
||||
writeStrings("meta", c.Meta)
|
||||
c.Meta = nil
|
||||
// Part checksums are the only other map: one section per part.
|
||||
section("parts", len(c.Parts))
|
||||
c.Parts = slices.Clone(c.Parts)
|
||||
for i := range c.Parts {
|
||||
writeStrings("part-checksums", c.Parts[i].Checksums)
|
||||
c.Parts[i].Checksums = nil
|
||||
}
|
||||
bts, err = c.MarshalMsg(nil)
|
||||
}
|
||||
if err == nil {
|
||||
section("fields", 1)
|
||||
writeField(bts)
|
||||
h.Sum(key.digest[:0])
|
||||
if v.Type == LegacyType {
|
||||
copy(key.header.Signature[:], key.digest[:])
|
||||
}
|
||||
return key
|
||||
}
|
||||
h.Reset()
|
||||
}
|
||||
// Undecodable metadata only matches the same bytes.
|
||||
section("raw", 1)
|
||||
writeField(ver.meta)
|
||||
h.Sum(key.digest[:0])
|
||||
return key
|
||||
}
|
||||
|
||||
// authoritativeHistory returns the versions of metas, the version lists of
|
||||
// the drives that answered, as merged newest first, when every version
|
||||
// that is not a free version has a revision that at least majority drives
|
||||
// and its readQuorum hold. It returns no versions when none is live,
|
||||
// errErasureReadQuorum when the drives cannot tell the history, and the
|
||||
// error of ctx once it ends. metaVs are the metadata versions of metas,
|
||||
// metaV the one the merged result is decoded with.
|
||||
func authoritativeHistory(ctx context.Context, metas [][]xlMetaV2ShallowVersion, metaVs []uint8, metaV uint8, majority int, readQuorum func(xlMetaV2ShallowVersion, uint8) int) ([]xlMetaV2ShallowVersion, error) {
|
||||
// Count the drives that hold each revision; a drive counts once for each
|
||||
// version.
|
||||
type revision struct {
|
||||
ver xlMetaV2ShallowVersion
|
||||
metaV uint8
|
||||
count int
|
||||
}
|
||||
keys := make([][]revisionKey, len(metas))
|
||||
revisions := make(map[[16]byte]map[revisionKey]*revision)
|
||||
live := make(map[[16]byte]bool)
|
||||
for i, versions := range metas {
|
||||
keys[i] = make([]revisionKey, len(versions))
|
||||
seen := make(map[[16]byte]bool, len(versions))
|
||||
for j, ver := range versions {
|
||||
// Decoding and hashing every history has a cost; stop with the
|
||||
// request.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := ver.header.VersionID
|
||||
if !ver.header.FreeVersion() {
|
||||
live[id] = true
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
key := versionRevision(ver, metaVs[i])
|
||||
keys[i][j] = key
|
||||
if revisions[id] == nil {
|
||||
revisions[id] = make(map[revisionKey]*revision)
|
||||
}
|
||||
r := revisions[id][key]
|
||||
if r == nil {
|
||||
r = &revision{ver: ver, metaV: metaVs[i]}
|
||||
revisions[id][key] = r
|
||||
}
|
||||
r.count++
|
||||
}
|
||||
}
|
||||
accepted := make(map[[16]byte]revisionKey, len(live))
|
||||
for id := range live {
|
||||
var best *revision
|
||||
var bestKey revisionKey
|
||||
for key, r := range revisions[id] {
|
||||
if !key.header.FreeVersion() && (best == nil || r.count > best.count) {
|
||||
best, bestKey = r, key
|
||||
}
|
||||
}
|
||||
// Only one revision can hold more than half of the drives.
|
||||
if best == nil || best.count < majority || best.count < readQuorum(best.ver, best.metaV) {
|
||||
return nil, errErasureReadQuorum
|
||||
}
|
||||
accepted[id] = bestKey
|
||||
}
|
||||
|
||||
// The strict merge orders the history. It has to hold exactly the
|
||||
// accepted revisions. A minority revision that sorts first would reset
|
||||
// its count, and one that shares the header could stand in for the
|
||||
// accepted one, so the merge only sees the accepted revision of each
|
||||
// version and free versions as they are. It sees the headers of the
|
||||
// keys, which do not differ between the drives of a legacy version.
|
||||
filtered := make([][]xlMetaV2ShallowVersion, len(metas))
|
||||
for i, versions := range metas {
|
||||
for j, ver := range versions {
|
||||
key, ok := accepted[ver.header.VersionID]
|
||||
switch {
|
||||
case ver.header.FreeVersion():
|
||||
case ok && keys[i][j] == key:
|
||||
ver.header = key.header
|
||||
default:
|
||||
continue
|
||||
}
|
||||
filtered[i] = append(filtered[i], ver)
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
merged := mergeXLV2Versions(majority, true, 0, filtered...)
|
||||
emitted := make(map[[16]byte]bool, len(accepted))
|
||||
for _, ver := range merged {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ver.header.FreeVersion() {
|
||||
continue
|
||||
}
|
||||
id := ver.header.VersionID
|
||||
if key, ok := accepted[id]; !ok || emitted[id] || versionRevision(ver, metaV) != key {
|
||||
return nil, errErasureReadQuorum
|
||||
}
|
||||
emitted[id] = true
|
||||
}
|
||||
if len(emitted) != len(accepted) {
|
||||
return nil, errErasureReadQuorum
|
||||
}
|
||||
if len(accepted) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// versionReadQuorum returns the read quorum of the metadata of ver, as
|
||||
// objectQuorumFromMeta derives it when all drives of the set hold it, or
|
||||
// more than the drives when its metadata cannot be read. metaArr and errs
|
||||
// have one entry for each drive; errs stays nil.
|
||||
func (er erasureObjects) versionReadQuorum(ctx context.Context, bucket, object string, ver xlMetaV2ShallowVersion, metaV uint8, metaArr []FileInfo, errs []error) int {
|
||||
var v xlMetaV2Version
|
||||
if _, err := v.unmarshalV(metaV, ver.meta); err != nil {
|
||||
return len(metaArr) + 1
|
||||
}
|
||||
fi, err := v.ToFileInfo(bucket, object, false)
|
||||
if err != nil {
|
||||
return len(metaArr) + 1
|
||||
}
|
||||
for i := range metaArr {
|
||||
metaArr[i] = fi
|
||||
}
|
||||
readQuorum, _, err := objectQuorumFromMeta(ctx, metaArr, errs, er.defaultParityCount)
|
||||
if err != nil {
|
||||
return len(metaArr) + 1
|
||||
}
|
||||
return readQuorum
|
||||
}
|
||||
|
||||
// lifecycleEventsAllPools returns the versions of object in all pools,
|
||||
// newest first, and the lifecycle event of each as the scanner evaluates it
|
||||
// on them. A version with copies in several pools, as an interrupted move
|
||||
// leaves it, counts once, and so do other writes of its version ID.
|
||||
func (z *erasureServerPools) lifecycleEventsAllPools(ctx context.Context, bucket, object string) ([]ObjectInfo, []lifecycle.Event, error) {
|
||||
lc, err := globalLifecycleSys.Get(bucket)
|
||||
if err != nil {
|
||||
if errors.Is(err, BucketLifecycleNotFound{Bucket: bucket}) {
|
||||
return nil, nil, errLifecycleNotApplicable
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
lr, err := globalBucketObjectLockSys.Get(bucket)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rcfg, err := getReplicationConfig(ctx, bucket)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
poolVersions := make([][]FileInfo, len(z.serverPools))
|
||||
errs := make([]error, len(z.serverPools))
|
||||
var wg sync.WaitGroup
|
||||
for i, pool := range z.serverPools {
|
||||
wg.Add(1)
|
||||
go func(i int, set *erasureObjects) {
|
||||
defer wg.Done()
|
||||
poolVersions[i], errs[i] = set.objectVersions(ctx, bucket, object)
|
||||
}(i, pool.getHashedSet(object))
|
||||
}
|
||||
wg.Wait()
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
vc, _ := globalBucketVersioningSys.Get(bucket)
|
||||
versioned := vc != nil && vc.Versioned(decodeDirObject(object))
|
||||
var all []PoolObjInfo
|
||||
for pool, versions := range poolVersions {
|
||||
for _, fi := range versions {
|
||||
all = append(all, PoolObjInfo{Index: pool, ObjInfo: fi.ToObjectInfo(bucket, object, versioned)})
|
||||
}
|
||||
}
|
||||
// Each pool lists its versions newest first. On equal times the lower
|
||||
// pool comes first, as reads choose it.
|
||||
sort.SliceStable(all, func(i, j int) bool { return all[i].ObjInfo.ModTime.After(all[j].ObjInfo.ModTime) })
|
||||
seen := make(map[string]int, len(all))
|
||||
var versions [][]PoolObjInfo
|
||||
for _, copy := range all {
|
||||
if i, ok := seen[copy.ObjInfo.VersionID]; ok {
|
||||
versions[i] = append(versions[i], copy)
|
||||
continue
|
||||
}
|
||||
seen[copy.ObjInfo.VersionID] = len(versions)
|
||||
versions = append(versions, []PoolObjInfo{copy})
|
||||
}
|
||||
|
||||
e := lifecycle.NewEvaluator(*lc).WithLockRetention(&lr).WithReplicationConfig(rcfg)
|
||||
objInfos := make([]ObjectInfo, len(versions))
|
||||
objOpts := make([]lifecycle.ObjectOpts, len(versions))
|
||||
for i, copies := range versions {
|
||||
// Copies with another ModTime are other writes of the version ID,
|
||||
// such as null versions written in several pools, not duplicates of
|
||||
// one write. The newest is the version. Data movement writes a new
|
||||
// data dir, so only ModTime tells the duplicates of a write apart.
|
||||
// An older write that is still protected keeps the whole object.
|
||||
newest := 1
|
||||
for newest < len(copies) && copies[newest].ObjInfo.ModTime.Equal(copies[0].ObjInfo.ModTime) {
|
||||
newest++
|
||||
}
|
||||
for _, older := range copies[newest:] {
|
||||
if opts := older.ObjInfo.ToLifecycleOpts(); e.IsObjectLocked(opts) || e.IsPendingReplication(opts) {
|
||||
return nil, nil, errLifecycleNotApplicable
|
||||
}
|
||||
}
|
||||
copies = copies[:newest]
|
||||
oi := copies[0].ObjInfo
|
||||
if len(copies) > 1 {
|
||||
// The copies must tell which protection is the newest, and no
|
||||
// copy may carry replication state that the merge would drop.
|
||||
if !lockRevisionsOrdered(copies) || !replicationStatesAgree(copies) {
|
||||
return nil, nil, errLifecycleNotApplicable
|
||||
}
|
||||
// Count the version once, but retain independently ordered lock
|
||||
// and tag revisions, including explicit removals, from every copy.
|
||||
oi = mergedPoolObjectInfo(copies)
|
||||
setLifecycleLockState(oi.UserDefined, newestLockState(copies))
|
||||
}
|
||||
oi.IsLatest = i == 0
|
||||
oi.NumVersions = len(versions)
|
||||
oi.SuccessorModTime = time.Time{}
|
||||
if i > 0 {
|
||||
oi.SuccessorModTime = objInfos[i-1].ModTime
|
||||
}
|
||||
objInfos[i] = oi
|
||||
objOpts[i] = oi.ToLifecycleOpts()
|
||||
}
|
||||
events, err := e.Eval(objOpts)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return objInfos, events, nil
|
||||
}
|
||||
|
||||
// lockRevisionTime parses the timestamp of an Object Lock revision. Unlike
|
||||
// olderThan, a missing, malformed or zero timestamp orders nothing.
|
||||
func lockRevisionTime(stamp string) (time.Time, bool) {
|
||||
t, err := time.Parse(time.RFC3339Nano, stamp)
|
||||
return t, err == nil && !t.IsZero()
|
||||
}
|
||||
|
||||
// lockRevisionNewer reports whether the revision stamped newer strictly
|
||||
// supersedes the one stamped older.
|
||||
func lockRevisionNewer(newer, older string) bool {
|
||||
n, ok := lockRevisionTime(newer)
|
||||
o, ok2 := lockRevisionTime(older)
|
||||
return ok && ok2 && n.After(o)
|
||||
}
|
||||
|
||||
// lifecycleLockState returns the Object Lock state of metadata as the
|
||||
// lifecycle evaluator reads it (objectlock.GetObjectRetentionMeta and
|
||||
// GetObjectLegalHoldMeta): a lowercase key before its canonical form. The
|
||||
// stored state (storedObjectLockState) only has lowercase keys.
|
||||
func lifecycleLockState(metadata map[string]string) objectLockState {
|
||||
get := func(canonical string) string {
|
||||
if v, ok := metadata[strings.ToLower(canonical)]; ok {
|
||||
return v
|
||||
}
|
||||
return metadata[canonical]
|
||||
}
|
||||
return objectLockState{
|
||||
mode: get(objectlock.AmzObjectLockMode),
|
||||
retainUntil: get(objectlock.AmzObjectLockRetainUntilDate),
|
||||
retentionTimestamp: get(ReservedMetadataPrefix + ObjectLockRetentionTimestamp),
|
||||
legalHold: get(objectlock.AmzObjectLockLegalHold),
|
||||
legalHoldTimestamp: get(ReservedMetadataPrefix + ObjectLockLegalHoldTimestamp),
|
||||
}
|
||||
}
|
||||
|
||||
// setLifecycleLockState sets state in metadata so that the evaluator reads
|
||||
// it: lowercase keys take precedence over canonical ones.
|
||||
func setLifecycleLockState(metadata map[string]string, state objectLockState) {
|
||||
set := func(canonical, value string) {
|
||||
lower := strings.ToLower(canonical)
|
||||
_, hasLower := metadata[lower]
|
||||
_, hasCanonical := metadata[canonical]
|
||||
if value != "" || hasLower || hasCanonical {
|
||||
metadata[lower] = value
|
||||
}
|
||||
}
|
||||
set(objectlock.AmzObjectLockMode, state.mode)
|
||||
set(objectlock.AmzObjectLockRetainUntilDate, state.retainUntil)
|
||||
set(ReservedMetadataPrefix+ObjectLockRetentionTimestamp, state.retentionTimestamp)
|
||||
set(objectlock.AmzObjectLockLegalHold, state.legalHold)
|
||||
set(ReservedMetadataPrefix+ObjectLockLegalHoldTimestamp, state.legalHoldTimestamp)
|
||||
}
|
||||
|
||||
// newestLockState returns the newest hold and the newest retention of the
|
||||
// copies of a version, which lockRevisionsOrdered found equal or ordered.
|
||||
func newestLockState(copies []PoolObjInfo) objectLockState {
|
||||
state := lifecycleLockState(copies[0].ObjInfo.UserDefined)
|
||||
for _, copy := range copies[1:] {
|
||||
next := lifecycleLockState(copy.ObjInfo.UserDefined)
|
||||
if lockRevisionNewer(next.legalHoldTimestamp, state.legalHoldTimestamp) {
|
||||
state.legalHold, state.legalHoldTimestamp = next.legalHold, next.legalHoldTimestamp
|
||||
}
|
||||
if lockRevisionNewer(next.retentionTimestamp, state.retentionTimestamp) {
|
||||
state.mode, state.retainUntil, state.retentionTimestamp = next.mode, next.retainUntil, next.retentionTimestamp
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// checkedExpiryRefused reports whether a set must keep a version whose ILM
|
||||
// expiry the pools approved with the Object Lock state approved, by the
|
||||
// metadata goi the set reads under the same lock. A pending purge always
|
||||
// keeps it: its status orders nothing. The approved hold or retention only
|
||||
// replaces the one the set reads when it is strictly newer; otherwise the
|
||||
// protection the set reads stands, also when it is newer than the approval.
|
||||
func checkedExpiryRefused(lc lifecycle.Lifecycle, lr objectlock.Retention, rcfg *replication.Config, goi ObjectInfo, approved objectLockState) bool {
|
||||
e := lifecycle.NewEvaluator(lc).WithLockRetention(&lr).WithReplicationConfig(rcfg)
|
||||
obj := goi.ToLifecycleOpts()
|
||||
if e.IsPendingReplication(obj) {
|
||||
return true
|
||||
}
|
||||
local := lifecycleLockState(goi.UserDefined)
|
||||
obj.UserDefined = maps.Clone(goi.UserDefined)
|
||||
if obj.UserDefined == nil {
|
||||
obj.UserDefined = make(map[string]string)
|
||||
}
|
||||
if lockRevisionNewer(approved.legalHoldTimestamp, local.legalHoldTimestamp) {
|
||||
obj.UserDefined[strings.ToLower(xhttp.AmzObjectLockLegalHold)] = approved.legalHold
|
||||
}
|
||||
if lockRevisionNewer(approved.retentionTimestamp, local.retentionTimestamp) {
|
||||
obj.UserDefined[strings.ToLower(xhttp.AmzObjectLockMode)] = approved.mode
|
||||
obj.UserDefined[strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)] = approved.retainUntil
|
||||
}
|
||||
return e.IsObjectLocked(obj)
|
||||
}
|
||||
|
||||
// lockRevisionsOrdered reports whether the copies of a version agree on the
|
||||
// legal hold and on the retention, each on its own, or carry revisions with
|
||||
// distinct valid timestamps, so that the newest one is known.
|
||||
func lockRevisionsOrdered(copies []PoolObjInfo) bool {
|
||||
ordered := func(revision func(objectLockState) (value, stamp string)) bool {
|
||||
stamps := make(map[[2]string]string, len(copies))
|
||||
for _, copy := range copies {
|
||||
value, stamp := revision(lifecycleLockState(copy.ObjInfo.UserDefined))
|
||||
stamps[[2]string{value, stamp}] = stamp
|
||||
}
|
||||
if len(stamps) == 1 {
|
||||
return true
|
||||
}
|
||||
seen := make([]time.Time, 0, len(stamps))
|
||||
for _, stamp := range stamps {
|
||||
t, ok := lockRevisionTime(stamp)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, other := range seen {
|
||||
if t.Equal(other) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
seen = append(seen, t)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return ordered(func(s objectLockState) (string, string) {
|
||||
return s.legalHold, s.legalHoldTimestamp
|
||||
}) && ordered(func(s objectLockState) (string, string) {
|
||||
return s.mode + "\x00" + s.retainUntil, s.retentionTimestamp
|
||||
})
|
||||
}
|
||||
|
||||
// replicationStatesAgree reports whether the copies of a version carry the
|
||||
// same replication and purge state. The per-target states are compared as
|
||||
// parsed, regardless of their order.
|
||||
func replicationStatesAgree(copies []PoolObjInfo) bool {
|
||||
first := copies[0].ObjInfo
|
||||
for _, copy := range copies[1:] {
|
||||
oi := copy.ObjInfo
|
||||
if oi.ReplicationStatus != first.ReplicationStatus || oi.VersionPurgeStatus != first.VersionPurgeStatus ||
|
||||
!maps.Equal(replicationStatusesMap(oi.ReplicationStatusInternal), replicationStatusesMap(first.ReplicationStatusInternal)) ||
|
||||
!maps.Equal(versionPurgeStatusesMap(oi.VersionPurgeStatusInternal), versionPurgeStatusesMap(first.VersionPurgeStatusInternal)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// lifecycleAllPools holds the versions of an object in all pools and their
|
||||
// lifecycle events, see lifecycleEventsAllPools.
|
||||
type lifecycleAllPools struct {
|
||||
versions []ObjectInfo
|
||||
events []lifecycle.Event
|
||||
err error
|
||||
}
|
||||
|
||||
// checkExpiryAllPools returns nil when the ILM expiry that opts describe
|
||||
// still applies to object with the versions it has in all pools, see
|
||||
// lifecycleAllPools.expiryApplies, and the Object Lock state with which it
|
||||
// approved the expiry of a version.
|
||||
func (z *erasureServerPools) checkExpiryAllPools(ctx context.Context, bucket, object string, opts ObjectOptions) (objectLockState, error) {
|
||||
var all lifecycleAllPools
|
||||
all.versions, all.events, all.err = z.lifecycleEventsAllPools(ctx, bucket, object)
|
||||
if err := all.expiryApplies(bucket, object, opts); err != nil {
|
||||
return objectLockState{}, err
|
||||
}
|
||||
return all.protection(opts.VersionID), nil
|
||||
}
|
||||
|
||||
// version returns the index of the version versionID, or -1.
|
||||
func (all lifecycleAllPools) version(versionID string) int {
|
||||
for i, oi := range all.versions {
|
||||
if oi.VersionID == versionID || oi.VersionID == "" && versionID == nullVersionID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// protection returns the Object Lock state of the version versionID that
|
||||
// the evaluator decided on, to be checked against the state the set reads.
|
||||
func (all lifecycleAllPools) protection(versionID string) objectLockState {
|
||||
if versionID == "" {
|
||||
return objectLockState{}
|
||||
}
|
||||
if i := all.version(versionID); i >= 0 {
|
||||
return lifecycleLockState(all.versions[i].UserDefined)
|
||||
}
|
||||
return objectLockState{}
|
||||
}
|
||||
|
||||
// expiryApplies returns nil when the ILM expiry that opts describe applies
|
||||
// to the versions. A missing version and a rejected expiry remain distinct:
|
||||
// - an expiry of all versions (DeletePrefix) needs the latest version to
|
||||
// expire with all versions;
|
||||
// - a delete marker (no VersionID) hides the latest version, which has to
|
||||
// expire;
|
||||
// - a version (VersionID) has to expire by itself: a noncurrent version,
|
||||
// or a delete marker without noncurrent versions.
|
||||
func (all lifecycleAllPools) expiryApplies(bucket, object string, opts ObjectOptions) error {
|
||||
if all.err != nil {
|
||||
if _, ok := all.err.(InsufficientReadQuorum); ok {
|
||||
return InsufficientWriteQuorum{}
|
||||
}
|
||||
return all.err
|
||||
}
|
||||
name := decodeDirObject(object)
|
||||
switch {
|
||||
case opts.DeletePrefix:
|
||||
if len(all.events) > 0 && all.events[0].Action.DeleteAll() {
|
||||
return nil
|
||||
}
|
||||
case opts.VersionID == "":
|
||||
if len(all.events) > 0 && (all.events[0].Action == lifecycle.DeleteAction || all.events[0].Action == lifecycle.DeleteAllVersionsAction) {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
if i := all.version(opts.VersionID); i >= 0 {
|
||||
if all.events[i].Action == lifecycle.DeleteVersionAction {
|
||||
return nil
|
||||
}
|
||||
return errLifecycleNotApplicable
|
||||
}
|
||||
return VersionNotFound{Bucket: bucket, Object: name, VersionID: opts.VersionID}
|
||||
}
|
||||
if len(all.versions) > 0 {
|
||||
return errLifecycleNotApplicable
|
||||
}
|
||||
return ObjectNotFound{Bucket: bucket, Object: name}
|
||||
}
|
||||
|
||||
// expiryNeedsAllPools reports whether an ILM expiry in opts deletes or hides
|
||||
// versions by a rule that depends on the other versions of the object. A
|
||||
// restored copy expires by its own restore date. Without versions each copy
|
||||
// of an object is a write of its own, and a set rechecks the rule on its
|
||||
// copy (deleteObjectFromAllPools).
|
||||
func (z *erasureServerPools) expiryNeedsAllPools(opts ObjectOptions) bool {
|
||||
if !opts.Expiration.Expire || z.SinglePool() || opts.Transition.ExpireRestored {
|
||||
return false
|
||||
}
|
||||
return opts.DeletePrefix || opts.VersionID != "" || opts.Versioned || opts.VersionSuspended
|
||||
}
|
||||
|
||||
// recheckReadExpiry only rechecks a local ordinary-delete candidate. The
|
||||
// global latest expiring does not authorize hiding a different version that
|
||||
// GET/HEAD has already read. On an incomplete or rejected recheck, keep that
|
||||
// successful read and do not queue an expiry. Leave response metadata intact.
|
||||
func recheckReadExpiry(ctx context.Context, objectAPI ObjectLayer, oi ObjectInfo, evt lifecycle.Event) (ObjectInfo, lifecycle.Event) {
|
||||
z, ok := objectAPI.(*erasureServerPools)
|
||||
if !ok || !evt.Action.Delete() || evt.Action.DeleteRestored() || !z.expiryNeedsAllPools(ObjectOptions{
|
||||
Expiration: ExpirationOptions{Expire: true},
|
||||
VersionID: oi.VersionID,
|
||||
Versioned: globalBucketVersioningSys.PrefixEnabled(oi.Bucket, oi.Name),
|
||||
VersionSuspended: globalBucketVersioningSys.PrefixSuspended(oi.Bucket, oi.Name),
|
||||
DeletePrefix: evt.Action.DeleteAll(),
|
||||
}) {
|
||||
return oi, evt
|
||||
}
|
||||
versions, events, err := z.lifecycleEventsAllPools(ctx, oi.Bucket, encodeDirObject(oi.Name))
|
||||
if err == nil {
|
||||
versionID := oi.VersionID
|
||||
if versionID == "" {
|
||||
versionID = nullVersionID
|
||||
}
|
||||
for i, version := range versions {
|
||||
id := version.VersionID
|
||||
if id == "" {
|
||||
id = nullVersionID
|
||||
}
|
||||
if id == versionID {
|
||||
if events[i].Action.Delete() && !events[i].Action.DeleteRestored() {
|
||||
return version, events[i]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return oi, lifecycle.Event{Action: lifecycle.NoneAction}
|
||||
}
|
||||
|
||||
// migrationExpiryApplies reports whether the ILM expiry evt, which a
|
||||
// decommission or rebalance decided for oi on the versions of the pool it
|
||||
// walks, applies with the versions of the object in all pools too. Only then
|
||||
// may the walk leave the version to the expiry instead of moving it. A
|
||||
// version that is the latest in the walked pool can be a noncurrent version
|
||||
// of the object, and a delete marker alone in that pool can hide versions in
|
||||
// another one. Such a version is moved like any other, and the scanner
|
||||
// applies the rule in the pool that holds the object then.
|
||||
func (z *erasureServerPools) migrationExpiryApplies(ctx context.Context, oi ObjectInfo, evt lifecycle.Event) bool {
|
||||
// The options the expiry deletes with, see
|
||||
// applyExpiryOnNonTransitionedObjects and expireTransitionedObject.
|
||||
opts := ObjectOptions{
|
||||
Expiration: ExpirationOptions{Expire: true},
|
||||
Versioned: globalBucketVersioningSys.PrefixEnabled(oi.Bucket, oi.Name),
|
||||
VersionSuspended: globalBucketVersioningSys.PrefixSuspended(oi.Bucket, oi.Name),
|
||||
}
|
||||
if evt.Action.DeleteVersioned() {
|
||||
opts.VersionID = oi.VersionID
|
||||
}
|
||||
if evt.Action.DeleteAll() {
|
||||
opts.DeletePrefix = true
|
||||
}
|
||||
opts.Transition.ExpireRestored = evt.Action.DeleteRestored()
|
||||
if !z.expiryNeedsAllPools(opts) {
|
||||
return true
|
||||
}
|
||||
_, err := z.checkExpiryAllPools(ctx, oi.Bucket, encodeDirObject(oi.Name), opts)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
// Copyright (c) 2026 Denis Babchuk
|
||||
//
|
||||
// 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 (
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
)
|
||||
|
||||
// The scanner and the walks of decommission and rebalance evaluate ILM on the
|
||||
// versions of a key in one pool: IsLatest, NumVersions and SuccessorModTime
|
||||
// count only these. A key can have versions in several pools: two concurrent
|
||||
// first writes, overwrites that went to another pool. The expiry they decide
|
||||
// runs in every pool.
|
||||
|
||||
// splitILM lays out the versions of keys in the two pools of migrationILM.
|
||||
type splitILM struct {
|
||||
*migrationILM
|
||||
ids map[string]string // object and label -> version ID
|
||||
bodies map[string]string // body -> label
|
||||
}
|
||||
|
||||
func newSplitILM(t *testing.T, status versioning.State, rules string) *splitILM {
|
||||
t.Helper()
|
||||
return &splitILM{migrationILM: newMigrationILM(t, status, false, rules), ids: map[string]string{}, bodies: map[string]string{}}
|
||||
}
|
||||
|
||||
// splitVersion is a version of a key in a test layout.
|
||||
type splitVersion struct {
|
||||
pools []int // the pools that hold a copy of it
|
||||
label string
|
||||
days float64 // its age
|
||||
marker bool
|
||||
tiered bool // its data is in a remote tier
|
||||
null bool // a null version (unversioned or suspended bucket)
|
||||
size int // its size, at least the size of its label
|
||||
}
|
||||
|
||||
func (s *splitILM) write(object string, v splitVersion) {
|
||||
s.t.Helper()
|
||||
ctx := s.t.Context()
|
||||
mtime := UTCNow().Add(-time.Duration(v.days * float64(24*time.Hour)))
|
||||
versionID := mustGetUUID()
|
||||
if v.null {
|
||||
versionID = ""
|
||||
}
|
||||
for _, pool := range v.pools {
|
||||
if v.marker {
|
||||
_, err := s.z.serverPools[pool].DeleteObject(ctx, s.bucket, object,
|
||||
ObjectOptions{Versioned: true, VersionID: versionID, DeleteMarker: true, MTime: mtime})
|
||||
if err != nil {
|
||||
s.t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
opts := ObjectOptions{Versioned: !v.null, VersionID: versionID, MTime: mtime}
|
||||
if v.null && s.status() == versioning.Suspended {
|
||||
opts = ObjectOptions{VersionSuspended: true, MTime: mtime}
|
||||
}
|
||||
body := object + " " + v.label
|
||||
if len(body) < v.size {
|
||||
body += strings.Repeat(".", v.size-len(body))
|
||||
}
|
||||
putConsistencyObject(s.t, s.z, s.bucket, object, pool, body, opts)
|
||||
s.bodies[body] = v.label
|
||||
if v.tiered {
|
||||
vid := versionID
|
||||
if vid == "" {
|
||||
vid = nullVersionID
|
||||
}
|
||||
infos, errs := readAllFileInfo(ctx, s.z.serverPools[pool].getHashedSet(object).getDisks(), "", s.bucket, object, vid, false, false)
|
||||
if errs[0] != nil {
|
||||
s.t.Fatal(errs[0])
|
||||
}
|
||||
fi := infos[0]
|
||||
fi.TransitionStatus, fi.TransitionTier, fi.TransitionedObjName = lifecycle.TransitionComplete, "WARM", "remote-"+v.label
|
||||
if err := s.z.serverPools[pool].DecomTieredObject(ctx, s.bucket, object, fi, ObjectOptions{}); err != nil {
|
||||
s.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if v.marker {
|
||||
s.markers[s.key(object, versionID)] = v.label
|
||||
} else {
|
||||
s.labels[s.key(object, versionID)] = v.label
|
||||
}
|
||||
s.ids[object+" "+v.label] = versionID
|
||||
s.track(object)
|
||||
}
|
||||
|
||||
func (s *splitILM) status() versioning.State {
|
||||
vc, _ := globalBucketVersioningSys.Get(s.bucket)
|
||||
if vc == nil {
|
||||
return ""
|
||||
}
|
||||
return vc.Status
|
||||
}
|
||||
|
||||
// scan applies ILM to every object in a pool as the scanner of a drive of
|
||||
// that pool does, from the xl.meta of that drive, and runs the queued
|
||||
// expiries as an expiry worker does.
|
||||
func (s *splitILM) scan(pool int) {
|
||||
s.t.Helper()
|
||||
ctx := s.t.Context()
|
||||
lc, err := globalLifecycleSys.Get(s.bucket)
|
||||
if err != nil {
|
||||
s.t.Fatal(err)
|
||||
}
|
||||
lr, err := globalBucketObjectLockSys.Get(s.bucket)
|
||||
if err != nil {
|
||||
s.t.Fatal(err)
|
||||
}
|
||||
vc, _ := globalBucketVersioningSys.Get(s.bucket)
|
||||
for _, object := range s.objects {
|
||||
disk := s.z.serverPools[pool].getHashedSet(object).getDisks()[0]
|
||||
rf, err := disk.ReadXL(ctx, s.bucket, object, false)
|
||||
if err != nil {
|
||||
continue // not in this pool
|
||||
}
|
||||
fivs, err := getFileInfoVersions(rf.Buf, s.bucket, object, false)
|
||||
if err != nil {
|
||||
s.t.Fatal(err)
|
||||
}
|
||||
objInfos := make([]ObjectInfo, len(fivs.Versions))
|
||||
for i, fi := range fivs.Versions {
|
||||
objInfos[i] = fi.ToObjectInfo(s.bucket, object, vc != nil && vc.Versioned(object))
|
||||
}
|
||||
item := scannerItem{bucket: s.bucket, objectName: object, lifeCycle: lc}
|
||||
item.applyActions(ctx, s.z, objInfos, lr, &sizeSummary{}, nil)
|
||||
}
|
||||
s.runQueue()
|
||||
}
|
||||
|
||||
// head applies ILM to a version as a HEAD or GET with its versionId does.
|
||||
func (s *splitILM) head(object, label string) lifecycle.Action {
|
||||
s.t.Helper()
|
||||
ctx := s.t.Context()
|
||||
oi, err := s.z.GetObjectInfo(ctx, s.bucket, object, ObjectOptions{VersionID: s.ids[object+" "+label]})
|
||||
if err != nil {
|
||||
s.t.Fatal(err)
|
||||
}
|
||||
lc, err := globalLifecycleSys.Get(s.bucket)
|
||||
if err != nil {
|
||||
s.t.Fatal(err)
|
||||
}
|
||||
lr, err := globalBucketObjectLockSys.Get(s.bucket)
|
||||
if err != nil {
|
||||
s.t.Fatal(err)
|
||||
}
|
||||
evt := evalActionFromLifecycle(ctx, *lc, lr, nil, oi)
|
||||
if evt.Action.Delete() {
|
||||
applyExpiryRule(evt, lcEventSrc_s3HeadObject, oi)
|
||||
}
|
||||
s.runQueue()
|
||||
return evt.Action
|
||||
}
|
||||
|
||||
// runQueue runs the queued expiries with an expiry worker.
|
||||
func (s *splitILM) runQueue() {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
globalExpiryState.Worker(s.queue)
|
||||
}()
|
||||
s.queue <- nil // the worker returns once it ran every expiry queued before
|
||||
<-done
|
||||
}
|
||||
|
||||
// get returns the label of the version a GET of object reads, or "-" when it
|
||||
// reads as deleted.
|
||||
func (s *splitILM) get(object string) string {
|
||||
s.t.Helper()
|
||||
ctx := s.t.Context()
|
||||
oi, err := s.z.GetObjectInfo(ctx, s.bucket, object, ObjectOptions{})
|
||||
if err != nil {
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isErrMethodNotAllowed(err) {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprintf("<%v>", err)
|
||||
}
|
||||
if oi.VersionID != "" && oi.VersionID != nullVersionID {
|
||||
return s.label(object, oi.VersionID, false)
|
||||
}
|
||||
// Copies of a null version in two pools are told apart by their data.
|
||||
gr, err := s.z.GetObjectNInfo(ctx, s.bucket, object, nil, nil, ObjectOptions{})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("<%v>", err)
|
||||
}
|
||||
defer gr.Close()
|
||||
data, err := io.ReadAll(gr)
|
||||
if err != nil {
|
||||
s.t.Fatal(err)
|
||||
}
|
||||
if l, ok := s.bodies[string(data)]; ok {
|
||||
return l
|
||||
}
|
||||
return "?" + string(data)
|
||||
}
|
||||
|
||||
// views returns the versions of each object in each pool, newest first.
|
||||
func (s *splitILM) views() []map[string][]string {
|
||||
views := make([]map[string][]string, len(s.z.serverPools))
|
||||
for pool := range s.z.serverPools {
|
||||
views[pool] = s.view(pool)
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
type splitKey struct {
|
||||
name string
|
||||
versions []splitVersion
|
||||
before string // what a GET reads before ILM ran
|
||||
after string // what a GET reads once ILM ran
|
||||
want [][]string // versions in each pool once ILM ran, nil to skip
|
||||
}
|
||||
|
||||
// run lays out keys, runs two scanner cycles over all pools and checks that
|
||||
// a GET of each key never reads anything but before or after, and the
|
||||
// versions each pool holds at the end. The second cycle must not change
|
||||
// anything.
|
||||
func (s *splitILM) run(keys []splitKey) {
|
||||
s.t.Helper()
|
||||
for _, k := range keys {
|
||||
for _, v := range k.versions {
|
||||
s.write(k.name, v)
|
||||
}
|
||||
if got := s.get(k.name); got != k.before {
|
||||
s.t.Fatalf("%s: fixture reads %s, want %s", k.name, got, k.before)
|
||||
}
|
||||
}
|
||||
var first []map[string][]string
|
||||
for cycle := 1; cycle <= 2; cycle++ {
|
||||
for pool := range s.z.serverPools {
|
||||
s.scan(pool)
|
||||
for _, k := range keys {
|
||||
if got := s.get(k.name); got != k.before && got != k.after {
|
||||
s.t.Errorf("%s: reads %s after the scanner of pool %d (cycle %d), want %s", k.name, got, pool, cycle, k.after)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cycle == 1 {
|
||||
first = s.views()
|
||||
}
|
||||
}
|
||||
last := s.views()
|
||||
for _, k := range keys {
|
||||
if got := s.get(k.name); got != k.after {
|
||||
s.t.Errorf("%s: reads %s once ILM ran, want %s", k.name, got, k.after)
|
||||
}
|
||||
for pool := range last {
|
||||
if !slices.Equal(first[pool][k.name], last[pool][k.name]) {
|
||||
s.t.Errorf("%s: the second scanner cycle changed pool %d from %v to %v", k.name, pool, first[pool][k.name], last[pool][k.name])
|
||||
}
|
||||
if k.want != nil && !slices.Equal(last[pool][k.name], k.want[pool]) {
|
||||
s.t.Errorf("%s: pool %d holds %v, want %v", k.name, pool, last[pool][k.name], k.want[pool])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const splitILMRules = "<Rule><ID>exp</ID><Status>Enabled</Status><Filter><Prefix>exp-</Prefix></Filter>" +
|
||||
"<Expiration><Days>1</Days></Expiration></Rule>" +
|
||||
"<Rule><ID>eav</ID><Status>Enabled</Status><Filter><Prefix>eav-</Prefix></Filter>" +
|
||||
"<Expiration><Days>1</Days><ExpiredObjectAllVersions>true</ExpiredObjectAllVersions></Expiration></Rule>" +
|
||||
"<Rule><ID>edm</ID><Status>Enabled</Status><Filter><Prefix>edm-</Prefix></Filter>" +
|
||||
"<Expiration><ExpiredObjectDeleteMarker>true</ExpiredObjectDeleteMarker></Expiration></Rule>" +
|
||||
"<Rule><ID>dme</ID><Status>Enabled</Status><Filter><Prefix>dme-</Prefix></Filter>" +
|
||||
"<DelMarkerExpiration><Days>1</Days></DelMarkerExpiration></Rule>" +
|
||||
"<Rule><ID>ncv</ID><Status>Enabled</Status><Filter><Prefix>ncv-</Prefix></Filter>" +
|
||||
"<NoncurrentVersionExpiration><NoncurrentDays>1</NoncurrentDays></NoncurrentVersionExpiration></Rule>" +
|
||||
"<Rule><ID>exs</ID><Status>Enabled</Status><Filter><And><Prefix>exs-</Prefix><ObjectSizeGreaterThan>100</ObjectSizeGreaterThan></And></Filter>" +
|
||||
"<Expiration><Days>1</Days></Expiration></Rule>" +
|
||||
"<Rule><ID>nnv</ID><Status>Enabled</Status><Filter><Prefix>nnv-</Prefix></Filter>" +
|
||||
"<NoncurrentVersionExpiration><NoncurrentDays>1</NoncurrentDays><NewerNoncurrentVersions>1</NewerNoncurrentVersions></NoncurrentVersionExpiration></Rule>"
|
||||
|
||||
func splitAt(label string, days float64, pools ...int) splitVersion {
|
||||
return splitVersion{pools: pools, label: label, days: days}
|
||||
}
|
||||
|
||||
func splitMarker(label string, days float64, pools ...int) splitVersion {
|
||||
return splitVersion{pools: pools, label: label, days: days, marker: true}
|
||||
}
|
||||
|
||||
func splitTiered(label string, days float64, pools ...int) splitVersion {
|
||||
return splitVersion{pools: pools, label: label, days: days, tiered: true}
|
||||
}
|
||||
|
||||
func splitNull(label string, days float64, pools ...int) splitVersion {
|
||||
return splitVersion{pools: pools, label: label, days: days, null: true}
|
||||
}
|
||||
|
||||
// splitILMOnePool are keys in one pool. ILM applies to them as usual.
|
||||
func splitILMOnePool(pool int) []splitKey {
|
||||
in := func(vs ...string) [][]string {
|
||||
want := [][]string{nil, nil}
|
||||
want[pool] = vs
|
||||
return want
|
||||
}
|
||||
return []splitKey{
|
||||
{"exp-one", []splitVersion{splitAt("v1", 5, pool), splitAt("v2", 4, pool)}, "v2", "-", in("DM", "v2", "v1")},
|
||||
{"exp-one-tiered", []splitVersion{splitAt("v1", 5, pool), splitTiered("v2", 4, pool)}, "v2", "-", in("DM", "v2", "v1")},
|
||||
{"eav-one", []splitVersion{splitAt("v1", 5, pool), splitAt("v2", 4, pool)}, "v2", "-", in()},
|
||||
{"edm-one", []splitVersion{splitMarker("dm", 3, pool)}, "-", "-", in()},
|
||||
{"dme-one", []splitVersion{splitAt("v1", 5, pool), splitMarker("dm", 3, pool)}, "-", "-", in()},
|
||||
{"ncv-one", []splitVersion{splitAt("v1", 5, pool), splitAt("v2", 4, pool), splitAt("v3", 0.01, pool)}, "v3", "v3", in("v3", "v2")},
|
||||
{"nnv-one", []splitVersion{splitAt("v1", 5, pool), splitAt("v2", 4, pool), splitAt("v3", 3, pool), splitAt("v4", 2, pool)}, "v4", "v4", in("v4", "v3")},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsLifecycleSplitObject(t *testing.T) {
|
||||
t.Run("versioned", func(t *testing.T) {
|
||||
s := newSplitILM(t, versioning.Enabled, splitILMRules)
|
||||
s.run([]splitKey{
|
||||
// Expiration with Days applies to the current version v2 only.
|
||||
{"exp-old-in-0", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"v1"}, {"v2"}}},
|
||||
{"exp-old-in-1", []splitVersion{splitAt("v2", 0.01, 0), splitAt("v1", 5, 1)}, "v2", "v2", [][]string{{"v2"}, {"v1"}}},
|
||||
{"exp-tiered-old-in-0", []splitVersion{splitTiered("v1", 5, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"v1"}, {"v2"}}},
|
||||
// v2 is old enough: one delete marker hides the key.
|
||||
{"exp-both-old", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 4, 1)}, "v2", "-", [][]string{{"DM", "v1"}, {"v2"}}},
|
||||
// The rule applies to v2 only by its size.
|
||||
{"exs-big-in-1", []splitVersion{splitAt("v1", 5, 0), {pools: []int{1}, label: "v2", days: 4, size: 200}}, "v2", "-", [][]string{{"DM", "v1"}, {"v2"}}},
|
||||
// The marker hides v1 and is not a marker without noncurrent
|
||||
// versions; v1 is not the current version.
|
||||
{"exp-marker-in-0", []splitVersion{splitAt("v1", 5, 1), splitMarker("dm", 3, 0)}, "-", "-", [][]string{{"dm"}, {"v1"}}},
|
||||
// ExpiredObjectAllVersions applies to the current version v2 only.
|
||||
{"eav-old-in-0", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"v1"}, {"v2"}}},
|
||||
{"eav-old-in-1", []splitVersion{splitAt("v2", 0.01, 0), splitAt("v1", 5, 1)}, "v2", "v2", [][]string{{"v2"}, {"v1"}}},
|
||||
{"eav-tiered-old-in-0", []splitVersion{splitTiered("v1", 5, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"v1"}, {"v2"}}},
|
||||
{"eav-both-old", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 4, 1)}, "v2", "-", [][]string{nil, nil}},
|
||||
// A copy of v1 in both pools, as an interrupted move leaves it.
|
||||
{"eav-copies", []splitVersion{splitAt("v1", 5, 0, 1)}, "v1", "-", [][]string{nil, nil}},
|
||||
// The marker hides v1 in the other pool.
|
||||
{"edm-marker-in-0", []splitVersion{splitAt("v1", 5, 1), splitMarker("dm", 3, 0)}, "-", "-", [][]string{{"dm"}, {"v1"}}},
|
||||
{"edm-marker-in-1", []splitVersion{splitAt("v1", 5, 0), splitMarker("dm", 3, 1)}, "-", "-", [][]string{{"v1"}, {"dm"}}},
|
||||
{"edm-copies", []splitVersion{splitMarker("dm", 3, 0, 1)}, "-", "-", [][]string{nil, nil}},
|
||||
// v2 in the other pool is the current version, the marker does
|
||||
// not expire.
|
||||
{"dme-new-in-1", []splitVersion{splitMarker("dm", 3, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"dm"}, {"v2"}}},
|
||||
{"dme-new-in-0", []splitVersion{splitAt("v2", 0.01, 0), splitMarker("dm", 3, 1)}, "v2", "v2", [][]string{{"v2"}, {"dm"}}},
|
||||
// The marker is the current version and expires with v1.
|
||||
{"dme-old-in-0", []splitVersion{splitAt("v1", 5, 0), splitMarker("dm", 3, 1)}, "-", "-", [][]string{nil, nil}},
|
||||
// v1 is noncurrent since v2 was written, v2 since v3 was.
|
||||
{"ncv-split", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 4, 1), splitAt("v3", 2, 0)}, "v3", "v3", [][]string{{"v3"}, {"v2"}}},
|
||||
})
|
||||
})
|
||||
t.Run("versioned/one-pool", func(t *testing.T) {
|
||||
for pool := range 2 {
|
||||
s := newSplitILM(t, versioning.Enabled, splitILMRules)
|
||||
s.run(splitILMOnePool(pool))
|
||||
}
|
||||
})
|
||||
t.Run("versioned/head-version", func(t *testing.T) {
|
||||
s := newSplitILM(t, versioning.Enabled, splitILMRules)
|
||||
keys := []splitKey{
|
||||
{"exp-old-in-0", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"v1"}, {"v2"}}},
|
||||
{"eav-old-in-0", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"v1"}, {"v2"}}},
|
||||
}
|
||||
for _, k := range keys {
|
||||
for _, v := range k.versions {
|
||||
s.write(k.name, v)
|
||||
}
|
||||
act := s.head(k.name, "v1")
|
||||
t.Logf("%s: HEAD of v1 evaluates %s", k.name, act)
|
||||
if got := s.get(k.name); got != k.after {
|
||||
t.Errorf("%s: reads %s after a HEAD of v1, want %s", k.name, got, k.after)
|
||||
}
|
||||
views := s.views()
|
||||
for pool := range views {
|
||||
if !slices.Equal(views[pool][k.name], k.want[pool]) {
|
||||
t.Errorf("%s: pool %d holds %v, want %v", k.name, pool, views[pool][k.name], k.want[pool])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("suspended", func(t *testing.T) {
|
||||
s := newSplitILM(t, versioning.Suspended, splitILMRules)
|
||||
s.run([]splitKey{
|
||||
{"exp-null-old-in-0", []splitVersion{splitNull("v1", 5, 0), splitAt("v2", 0.01, 1)}, "v2", "v2", [][]string{{"v1"}, {"v2"}}},
|
||||
})
|
||||
})
|
||||
t.Run("unversioned", func(t *testing.T) {
|
||||
s := newSplitILM(t, "", splitILMRules)
|
||||
s.run([]splitKey{
|
||||
{"eav-old-in-0", []splitVersion{splitNull("old", 5, 0), splitNull("new", 0.01, 1)}, "new", "new", nil},
|
||||
{"exp-old-in-0", []splitVersion{splitNull("old", 5, 0), splitNull("new", 0.01, 1)}, "new", "new", nil},
|
||||
})
|
||||
views := s.views()
|
||||
for object, want := range map[string]int{"eav-old-in-0": 1, "exp-old-in-0": 0} {
|
||||
if len(views[0][object]) != want || len(views[1][object]) != 1 {
|
||||
t.Errorf("%s: pools hold %v and %v, want %d and 1 copies", object, views[0][object], views[1][object], want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// walk moves pool 0 out with a decommission or a rebalance pass, as move
|
||||
// does, but reports a decommission that did not complete instead of stopping
|
||||
// the test, so the reads are still checked.
|
||||
func (s *splitILM) walk(kind string) {
|
||||
s.t.Helper()
|
||||
ctx := s.t.Context()
|
||||
if kind == "rebalance" {
|
||||
migrationTestWalk(s.t, s.z, s.bucket, 0, kind)
|
||||
s.z.rebalMu.Lock()
|
||||
s.z.rebalMeta.PoolStats[0].Info.Status = rebalCompleted
|
||||
s.z.rebalMu.Unlock()
|
||||
return
|
||||
}
|
||||
if err := s.z.StartDecommission(ctx, 0); err != nil {
|
||||
s.t.Fatal(err)
|
||||
}
|
||||
s.z.doDecommissionInRoutine(ctx, 0)
|
||||
s.z.poolMetaMutex.RLock()
|
||||
info := *s.z.poolMeta.Pools[0].Decommission
|
||||
s.z.poolMetaMutex.RUnlock()
|
||||
if !info.Complete || info.Failed || info.ItemsDecommissionFailed > 0 {
|
||||
s.t.Errorf("decommission did not complete: complete=%t failed=%t itemsFailed=%d; pool 0 holds %v",
|
||||
info.Complete, info.Failed, info.ItemsDecommissionFailed, s.view(0))
|
||||
}
|
||||
}
|
||||
|
||||
// Rules that expire a whole object (DelMarkerExpiration,
|
||||
// ExpiredObjectAllVersions) or a delete marker without noncurrent versions
|
||||
// (ExpiredObjectDeleteMarker) depend on every version of the object. A walk
|
||||
// sees the versions in its own pool only.
|
||||
func TestPoolsMigrationLifecycleWholeObject(t *testing.T) {
|
||||
recent := 1.0 / 24
|
||||
objects := []struct {
|
||||
name string
|
||||
versions []splitVersion // oldest first
|
||||
before string // what a GET reads before the walk
|
||||
after string // what a GET reads once ILM caught up
|
||||
want []string // versions in pool 1 once ILM caught up, newest first
|
||||
}{
|
||||
// The marker expires and takes v1 with it.
|
||||
{"dme-one-pool", []splitVersion{splitAt("v1", 5, 0), splitMarker("dm", 3, 0)}, "-", "-", nil},
|
||||
{"dme-older-in-pool-1", []splitVersion{splitAt("v1", 5, 1), splitMarker("dm", 3, 0)}, "-", "-", nil},
|
||||
// v2 is the current version, the marker does not expire.
|
||||
{"dme-newer-in-pool-1", []splitVersion{splitMarker("dm", 3, 0), splitAt("v2", recent, 1)}, "v2", "v2", []string{"v2", "dm"}},
|
||||
// v2 expires and takes v1 with it.
|
||||
{"eav-one-pool", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 4, 0)}, "v2", "-", nil},
|
||||
{"eav-older-in-pool-1", []splitVersion{splitAt("v1", 5, 1), splitAt("v2", 4, 0)}, "v2", "-", nil},
|
||||
// v2 is the current version and does not expire.
|
||||
{"eav-newer-in-pool-1", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", recent, 1)}, "v2", "v2", []string{"v2", "v1"}},
|
||||
// The marker hides v1, it is not a marker without noncurrent versions.
|
||||
{"edm-older-in-pool-1", []splitVersion{splitAt("v1", 5, 1), splitMarker("dm", 3, 0)}, "-", "-", []string{"dm", "v1"}},
|
||||
{"keep-marker", []splitVersion{splitAt("v1", 5, 0), splitMarker("dm", 3, 0)}, "-", "-", []string{"dm", "v1"}},
|
||||
{"keep-versions", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 4, 0)}, "v2", "v2", []string{"v2", "v1"}},
|
||||
}
|
||||
for _, kind := range []string{"decommission", "rebalance"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
s := newSplitILM(t, versioning.Enabled, splitILMRules)
|
||||
for _, o := range objects {
|
||||
for _, v := range o.versions {
|
||||
s.write(o.name, v)
|
||||
}
|
||||
if got := s.get(o.name); got != o.before {
|
||||
t.Fatalf("%s: fixture reads %s, want %s", o.name, got, o.before)
|
||||
}
|
||||
}
|
||||
s.walk(kind)
|
||||
for _, o := range objects {
|
||||
if got := s.get(o.name); got != o.before && got != o.after {
|
||||
t.Errorf("%s: reads %s after the %s, before ILM caught up; want %s or %s", o.name, got, kind, o.before, o.after)
|
||||
}
|
||||
}
|
||||
s.runQueue()
|
||||
s.scan(1)
|
||||
want := map[string][]string{}
|
||||
for _, o := range objects {
|
||||
want[o.name] = o.want
|
||||
if got := s.get(o.name); got != o.after {
|
||||
t.Errorf("%s: reads %s once ILM caught up, want %s", o.name, got, o.after)
|
||||
}
|
||||
}
|
||||
s.check(want)
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,322 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7/pkg/set"
|
||||
)
|
||||
|
||||
// Object lock domains.
|
||||
//
|
||||
// Every erasure set has its own lockers, one per host among its drives, and a
|
||||
// distributed lock is granted by a majority of them. Two locks of one name
|
||||
// exclude each other only when they ask the same lockers. In a single pool the
|
||||
// writers (PutObject, multipart commit, PutObjectPart, tags, retention, legal
|
||||
// hold, metadata, restore, transition, heal) lock in the set an object hashes
|
||||
// to. The pools layer locks an object, and its upload IDs, in the same set, so
|
||||
// that a pool whose sets use different hosts still has one lock per object.
|
||||
// With several pools every object write takes the pools lock instead, which
|
||||
// these helpers then return unchanged.
|
||||
//
|
||||
// Generic NewNSLock keeps one stable domain for names that are not objects:
|
||||
// bucket and IAM metadata locks, format and leader coordination, and the
|
||||
// /[replicate]/ serializer.
|
||||
|
||||
// NewObjectNSLock returns the lock of one object in the lock domain that its
|
||||
// writes use: in a single pool the object's own erasure set, otherwise the
|
||||
// pools lock. Directory objects are named as storage names them.
|
||||
func (z *erasureServerPools) NewObjectNSLock(bucket, object string) RWLocker {
|
||||
if z.SinglePool() {
|
||||
object = encodeDirObject(object)
|
||||
}
|
||||
return z.objectNSLock(bucket, object, object)
|
||||
}
|
||||
|
||||
// uploadIDNSLock returns the guard of one upload ID. It is chosen by the
|
||||
// upload's object, as PutObjectPart locks it in that object's set; the name
|
||||
// locked stays object/uploadID.
|
||||
func (z *erasureServerPools) uploadIDNSLock(bucket, object, uploadID string) RWLocker {
|
||||
return z.objectNSLock(bucket, object, pathJoin(object, uploadID))
|
||||
}
|
||||
|
||||
// objectNSLock returns the lock of resource, a name owned by object, in the
|
||||
// lock domain of object.
|
||||
func (z *erasureServerPools) objectNSLock(bucket, object, resource string) RWLocker {
|
||||
if z.SinglePool() {
|
||||
return z.serverPools[0].getHashedSet(encodeDirObject(object)).NewNSLock(bucket, resource)
|
||||
}
|
||||
return z.NewNSLock(bucket, resource)
|
||||
}
|
||||
|
||||
// objectLockRequest is one object lock that lockObjects takes.
|
||||
type objectLockRequest struct {
|
||||
bucket, object string
|
||||
write bool
|
||||
}
|
||||
|
||||
// lockObjects takes the locks of several objects, such as the source and
|
||||
// destination of a copy, before any of them is read. A name that appears twice
|
||||
// gets one lock, a write lock if either asks for one. The locks are taken in
|
||||
// one order, by lock domain and then by bucket and object, and transactionally
|
||||
// (see getNSLocks), so that two copies in opposite directions, or an operation
|
||||
// that orders its locks differently, cannot hold each other forever. The
|
||||
// returned context ends when any of the locks is lost; the returned function
|
||||
// releases them all.
|
||||
func (z *erasureServerPools) lockObjects(ctx context.Context, reqs ...objectLockRequest) (LockContext, func(), error) {
|
||||
type entry struct {
|
||||
objectLockRequest
|
||||
domain int
|
||||
}
|
||||
locks := make([]entry, 0, len(reqs))
|
||||
for _, r := range reqs {
|
||||
r.object = encodeDirObject(r.object)
|
||||
i := slices.IndexFunc(locks, func(e entry) bool { return e.bucket == r.bucket && e.object == r.object })
|
||||
if i >= 0 {
|
||||
locks[i].write = locks[i].write || r.write
|
||||
continue
|
||||
}
|
||||
domain := 0
|
||||
if z.SinglePool() {
|
||||
domain = z.serverPools[0].getHashedSetIndex(r.object)
|
||||
}
|
||||
locks = append(locks, entry{objectLockRequest: r, domain: domain})
|
||||
}
|
||||
slices.SortFunc(locks, func(a, b entry) int {
|
||||
if a.domain != b.domain {
|
||||
return a.domain - b.domain
|
||||
}
|
||||
return strings.Compare(pathJoin(a.bucket, a.object), pathJoin(b.bucket, b.object))
|
||||
})
|
||||
nsLocks := make([]nsLockRequest, len(locks))
|
||||
for i, l := range locks {
|
||||
nsLocks[i] = nsLockRequest{
|
||||
newLock: func() RWLocker { return z.NewObjectNSLock(l.bucket, l.object) },
|
||||
write: l.write,
|
||||
atomic: true,
|
||||
}
|
||||
}
|
||||
return getNSLocks(ctx, nsLocks, globalOperationTimeout)
|
||||
}
|
||||
|
||||
// nsLockRequest is one lock of a transactional acquisition.
|
||||
type nsLockRequest struct {
|
||||
// newLock returns a new instance of the lock for each attempt. An
|
||||
// instance names its holder, and a distributed unlock retries in the
|
||||
// background; a later attempt with the same instance could be released
|
||||
// by such a retry of the attempt before.
|
||||
newLock func() RWLocker
|
||||
write bool
|
||||
// atomic: the lock is granted as a whole or not at all. A distributed
|
||||
// lock is, also over several names. The local backend takes several
|
||||
// names one at a time, holding the first while it waits for the next;
|
||||
// its lock context is the context it was given.
|
||||
atomic bool
|
||||
}
|
||||
|
||||
const (
|
||||
// nsLockTryTimeout bounds each wait for a lock while holding another.
|
||||
nsLockTryTimeout = 2 * time.Second
|
||||
// nsLockRetryWait is the longest random pause before a new attempt.
|
||||
nsLockRetryWait = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// getNSLocks acquires every lock of locks, in their order, or none. While it
|
||||
// holds a lock it waits for the next one only up to nsLockTryTimeout; then it
|
||||
// releases what it holds in reverse order, pauses, and tries again with a
|
||||
// fresh context, until the timeout of all attempts. A wait that holds nothing
|
||||
// can last for the rest of the timeout. Each lock is taken in the context of
|
||||
// the one before, so the returned context ends when any lease is lost.
|
||||
func getNSLocks(ctx context.Context, locks []nsLockRequest, timeout *dynamicTimeout) (LockContext, func(), error) {
|
||||
type held struct {
|
||||
lock RWLocker
|
||||
write bool
|
||||
lkctx LockContext
|
||||
}
|
||||
release := func(hs []held) {
|
||||
for i := len(hs) - 1; i >= 0; i-- {
|
||||
if hs[i].write {
|
||||
hs[i].lock.Unlock(hs[i].lkctx)
|
||||
} else {
|
||||
hs[i].lock.RUnlock(hs[i].lkctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fail as a single lock does: a canceled request with its own error,
|
||||
// anything else as a timeout.
|
||||
failed := func() error {
|
||||
if err := ctx.Err(); errors.Is(err, context.Canceled) {
|
||||
return err
|
||||
}
|
||||
return OperationTimedOut{}
|
||||
}
|
||||
start := time.Now()
|
||||
deadline := start.Add(timeout.Timeout())
|
||||
for {
|
||||
hs := make([]held, 0, len(locks))
|
||||
lctx := ctx
|
||||
var err error
|
||||
for _, l := range locks {
|
||||
wait := time.Until(deadline)
|
||||
if len(hs) > 0 || !l.atomic {
|
||||
wait = min(wait, nsLockTryTimeout)
|
||||
}
|
||||
if wait <= 0 {
|
||||
err = OperationTimedOut{}
|
||||
break
|
||||
}
|
||||
lk := l.newLock()
|
||||
try := newDynamicTimeout(wait, wait)
|
||||
// The local backend waits up to the timeout for each name: bound
|
||||
// the whole attempt by its context instead.
|
||||
tctx, cancel := lctx, context.CancelFunc(func() {})
|
||||
if !l.atomic {
|
||||
tctx, cancel = context.WithTimeout(lctx, wait)
|
||||
}
|
||||
var lkctx LockContext
|
||||
if l.write {
|
||||
lkctx, err = lk.GetLock(tctx, try)
|
||||
} else {
|
||||
lkctx, err = lk.GetRLock(tctx, try)
|
||||
}
|
||||
cancel()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !l.atomic {
|
||||
lkctx.ctx = lctx
|
||||
}
|
||||
hs = append(hs, held{lock: lk, write: l.write, lkctx: lkctx})
|
||||
lctx = lkctx.Context()
|
||||
}
|
||||
if err == nil {
|
||||
timeout.LogSuccess(time.Since(start))
|
||||
if len(hs) == 0 {
|
||||
return LockContext{ctx: ctx, cancel: func() {}}, func() {}, nil
|
||||
}
|
||||
return LockContext{ctx: lctx, cancel: func() {}}, func() { release(hs) }, nil
|
||||
}
|
||||
release(hs)
|
||||
// A lost lease cancels the context of a later wait; only the
|
||||
// request's own context ends the attempts.
|
||||
if ctx.Err() != nil || (!errors.As(err, &OperationTimedOut{}) && !errors.Is(err, context.Canceled)) {
|
||||
return LockContext{}, nil, err
|
||||
}
|
||||
pause := rand.N(nsLockRetryWait) + time.Millisecond
|
||||
if time.Until(deadline) <= pause {
|
||||
timeout.LogFailure()
|
||||
return LockContext{}, nil, OperationTimedOut{}
|
||||
}
|
||||
t := time.NewTimer(pause)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return LockContext{}, nil, failed()
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sharesLocalNSLock reports whether every set of the pool uses one local lock
|
||||
// table, as a single node does: there all sets are one lock domain.
|
||||
func (s *erasureSets) sharesLocalNSLock() bool {
|
||||
first := s.sets[0].nsMutex
|
||||
if first.isDistErasure {
|
||||
return false
|
||||
}
|
||||
for _, set := range s.sets[1:] {
|
||||
if set.nsMutex != first {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// deleteObjectsInSets is the bulk delete of a single pool whose sets do not
|
||||
// share one lock table. The names are grouped by the set they hash to, the
|
||||
// lock domain of their writers. The groups run one after another in ascending
|
||||
// set index; each takes its set's lock over all of its names in one
|
||||
// acquisition, deletes its sub-batch, and releases the lock before the next
|
||||
// group starts. No two sets' locks are held at once.
|
||||
//
|
||||
// Each group's lock is taken in the request's context. A group whose lock
|
||||
// cannot be taken fails as a whole and the next group still runs; once the
|
||||
// request is canceled the remaining groups fail without being attempted. The
|
||||
// results keep their positions in objects. Replication, events and auditing
|
||||
// stay with the callers, on the merged results.
|
||||
func (z *erasureServerPools) deleteObjectsInSets(ctx context.Context, bucket string, objects []ObjectToDelete, opts ObjectOptions, dobjects []DeletedObject, derrs []error, timeout *dynamicTimeout) {
|
||||
pool := z.serverPools[0]
|
||||
groups := make(map[int][]int)
|
||||
for i, obj := range objects {
|
||||
idx := pool.getHashedSetIndex(obj.ObjectName)
|
||||
groups[idx] = append(groups[idx], i)
|
||||
}
|
||||
order := make([]int, 0, len(groups))
|
||||
for idx := range groups {
|
||||
order = append(order, idx)
|
||||
}
|
||||
slices.Sort(order)
|
||||
|
||||
for _, idx := range order {
|
||||
members := groups[idx]
|
||||
fail := func(err error) {
|
||||
for _, i := range members {
|
||||
derrs[i] = err
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
fail(err)
|
||||
continue
|
||||
}
|
||||
er := pool.sets[idx]
|
||||
names := set.NewStringSet()
|
||||
batch := make([]ObjectToDelete, len(members))
|
||||
for j, i := range members {
|
||||
names.Add(objects[i].ObjectName)
|
||||
batch[j] = objects[i]
|
||||
}
|
||||
resources := names.ToSlice()
|
||||
lkctx, unlock, err := getNSLocks(ctx, []nsLockRequest{{
|
||||
newLock: func() RWLocker { return er.NewNSLock(bucket, resources...) },
|
||||
write: true,
|
||||
atomic: er.nsMutex.isDistErasure || len(resources) == 1,
|
||||
}}, timeout)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
continue
|
||||
}
|
||||
deleted, errs := er.DeleteObjects(lkctx.Context(), bucket, batch, opts)
|
||||
unlock()
|
||||
// Merge as the pools loop of DeleteObjects does for one pool.
|
||||
for j, i := range members {
|
||||
if errs[j] == nil && deleted[j].found {
|
||||
dobjects[i], derrs[i] = deleted[j], nil
|
||||
continue
|
||||
}
|
||||
if derrs[i] == nil {
|
||||
dobjects[i], derrs[i] = deleted[j], errs[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/dsync"
|
||||
)
|
||||
|
||||
// ldTry reports whether lk can be taken now, and releases it again.
|
||||
func ldTry(t *testing.T, lk RWLocker, write bool) bool {
|
||||
t.Helper()
|
||||
dt := newDynamicTimeout(100*time.Millisecond, 100*time.Millisecond)
|
||||
if write {
|
||||
lc, err := lk.GetLock(t.Context(), dt)
|
||||
if err == nil {
|
||||
lk.Unlock(lc)
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
lc, err := lk.GetRLock(t.Context(), dt)
|
||||
if err == nil {
|
||||
lk.RUnlock(lc)
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ldFree reports whether lk can be taken within a few seconds: a distributed
|
||||
// unlock completes in the background.
|
||||
func ldFree(t *testing.T, lk RWLocker, write bool) bool {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !ldTry(t, lk, write) {
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ldTaken waits until someone else holds lk.
|
||||
func ldTaken(t *testing.T, lk RWLocker) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for ldTry(t, lk, false) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("lock never taken")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func ldHoldLock(t *testing.T, lk RWLocker) func() {
|
||||
t.Helper()
|
||||
lc, err := lk.GetLock(t.Context(), globalOperationTimeout)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := false
|
||||
return func() {
|
||||
if !done {
|
||||
done = true
|
||||
lk.Unlock(lc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Object locks, and upload guards by their object, are in the set the
|
||||
// object's writes lock in; with several pools they stay the pools lock.
|
||||
func TestObjectNSLockRouting(t *testing.T) {
|
||||
for _, mode := range ldModes {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
z, bucket := ldPool(t, mode)
|
||||
pool := z.serverPools[0]
|
||||
key := ldKey(t, z, "route", 1)
|
||||
dir := ldKey(t, z, "route-dir", 0) + "/"
|
||||
for _, name := range []string{key, dir} {
|
||||
release := ldHoldLock(t, z.NewObjectNSLock(bucket, name))
|
||||
encoded := encodeDirObject(name)
|
||||
if ldTry(t, pool.getHashedSet(encoded).NewNSLock(bucket, encoded), false) {
|
||||
t.Errorf("%s: a writer of the object's set ignores the object lock", name)
|
||||
}
|
||||
release()
|
||||
}
|
||||
id := mustGetUUID()
|
||||
release := ldHoldLock(t, z.uploadIDNSLock(bucket, key, id))
|
||||
if ldTry(t, pool.getHashedSet(key).NewNSLock(bucket, pathJoin(key, id)), false) {
|
||||
t.Error("PutObjectPart's guard ignores the upload guard")
|
||||
}
|
||||
release()
|
||||
if mode != ldShared && pool.sharesLocalNSLock() {
|
||||
t.Error("split sets reported as one local table")
|
||||
}
|
||||
if mode == ldShared && !pool.sharesLocalNSLock() {
|
||||
t.Error("one local table not recognized")
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("pools", func(t *testing.T) {
|
||||
z, bucket := bulkDeletePools(t, 2)
|
||||
release := ldHoldLock(t, z.NewNSLock(bucket, "pooled"))
|
||||
defer release()
|
||||
if ldTry(t, z.NewObjectNSLock(bucket, "pooled"), false) || ldTry(t, z.uploadIDNSLock(bucket, "pooled", ""), false) {
|
||||
t.Error("multi-pool object locks left the pools lock")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// lockObjects merges a name asked twice into one write lock and takes source
|
||||
// and destination in their own sets.
|
||||
func TestLockObjects(t *testing.T) {
|
||||
for _, mode := range ldModes {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
z, bucket := ldPool(t, mode)
|
||||
pool := z.serverPools[0]
|
||||
src, dst := ldKey(t, z, "lock-src", 1), ldKey(t, z, "lock-dst", 0)
|
||||
lkctx, unlock, err := z.lockObjects(t.Context(), objectLockRequest{bucket: bucket, object: src}, objectLockRequest{bucket: bucket, object: dst, write: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lkctx.Context().Err() != nil {
|
||||
t.Fatal("lock context ended")
|
||||
}
|
||||
if !ldTry(t, pool.getHashedSet(src).NewNSLock(bucket, src), false) {
|
||||
t.Error("source not shared with readers")
|
||||
}
|
||||
if ldTry(t, pool.getHashedSet(src).NewNSLock(bucket, src), true) {
|
||||
t.Error("source not locked against writers")
|
||||
}
|
||||
if ldTry(t, pool.getHashedSet(dst).NewNSLock(bucket, dst), false) {
|
||||
t.Error("destination not locked")
|
||||
}
|
||||
unlock()
|
||||
if !ldFree(t, pool.getHashedSet(dst).NewNSLock(bucket, dst), true) || !ldFree(t, pool.getHashedSet(src).NewNSLock(bucket, src), true) {
|
||||
t.Error("locks left behind")
|
||||
}
|
||||
|
||||
// A copy onto itself takes one write lock; a directory object is
|
||||
// locked by its stored name.
|
||||
self := ldKey(t, z, "lock-self", 1) + "/"
|
||||
_, unlock, err = z.lockObjects(t.Context(), objectLockRequest{bucket: bucket, object: self}, objectLockRequest{bucket: bucket, object: self, write: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded := encodeDirObject(self)
|
||||
if ldTry(t, pool.getHashedSet(encoded).NewNSLock(bucket, encoded), false) {
|
||||
t.Error("self copy not write locked")
|
||||
}
|
||||
unlock()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ldLeaseLock is a lock whose lease the test ends.
|
||||
type ldLeaseLock struct {
|
||||
RWLocker
|
||||
lost chan context.CancelFunc
|
||||
}
|
||||
|
||||
func (l ldLeaseLock) GetLock(ctx context.Context, timeout *dynamicTimeout) (LockContext, error) {
|
||||
lc, err := l.RWLocker.GetLock(ctx, timeout)
|
||||
if err != nil {
|
||||
return lc, err
|
||||
}
|
||||
lease, cancel := context.WithCancel(lc.Context())
|
||||
select {
|
||||
case l.lost <- cancel:
|
||||
default:
|
||||
panic("too many lock attempts")
|
||||
}
|
||||
return LockContext{ctx: lease, cancel: cancel}, nil
|
||||
}
|
||||
|
||||
// getNSLocks never holds one lock while it waits for another for long: it
|
||||
// releases what it holds and retries, also after a lost lease, until its
|
||||
// timeout or the request ends, and leaves nothing behind.
|
||||
func TestGetNSLocksTransactional(t *testing.T) {
|
||||
ns := newNSLock(false)
|
||||
lock := func(name string) RWLocker { return ns.NewNSLock(nil, "bucket", name) }
|
||||
newLock := func(name string) func() RWLocker { return func() RWLocker { return lock(name) } }
|
||||
reqs := func() []nsLockRequest {
|
||||
return []nsLockRequest{{newLock: newLock("a"), write: true, atomic: true}, {newLock: newLock("b"), write: true, atomic: true}}
|
||||
}
|
||||
|
||||
t.Run("partial", func(t *testing.T) {
|
||||
releaseB := ldHoldLock(t, lock("b"))
|
||||
defer releaseB()
|
||||
type result struct {
|
||||
unlock func()
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
_, unlock, err := getNSLocks(t.Context(), reqs(), globalOperationTimeout)
|
||||
done <- result{unlock, err}
|
||||
}()
|
||||
// "a" is taken while "b" is waited for, and free again between
|
||||
// attempts.
|
||||
ldTaken(t, lock("a"))
|
||||
deadline := time.Now().Add(3 * nsLockTryTimeout)
|
||||
for !ldTry(t, lock("a"), true) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("a stays held while b is waited for")
|
||||
}
|
||||
}
|
||||
releaseB()
|
||||
res := <-done
|
||||
if res.err != nil {
|
||||
t.Fatal(res.err)
|
||||
}
|
||||
if ldTry(t, lock("a"), false) || ldTry(t, lock("b"), false) {
|
||||
t.Error("not both locks held")
|
||||
}
|
||||
res.unlock()
|
||||
if !ldTry(t, lock("a"), true) || !ldTry(t, lock("b"), true) {
|
||||
t.Error("locks left behind")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("timeout", func(t *testing.T) {
|
||||
releaseB := ldHoldLock(t, lock("b"))
|
||||
defer releaseB()
|
||||
start := time.Now()
|
||||
_, _, err := getNSLocks(t.Context(), reqs(), newDynamicTimeout(3*time.Second, 3*time.Second))
|
||||
if !errors.As(err, &OperationTimedOut{}) {
|
||||
t.Fatalf("got %v, want a timeout", err)
|
||||
}
|
||||
if took := time.Since(start); took > 6*time.Second {
|
||||
t.Errorf("timed out after %v", took)
|
||||
}
|
||||
if !ldTry(t, lock("a"), true) {
|
||||
t.Error("a left behind")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cancel", func(t *testing.T) {
|
||||
releaseB := ldHoldLock(t, lock("b"))
|
||||
defer releaseB()
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
time.AfterFunc(300*time.Millisecond, cancel)
|
||||
_, _, err := getNSLocks(ctx, reqs(), globalOperationTimeout)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("got %v, want canceled", err)
|
||||
}
|
||||
if !ldTry(t, lock("a"), true) {
|
||||
t.Error("a left behind")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lease", func(t *testing.T) {
|
||||
lost := make(chan context.CancelFunc, 64)
|
||||
leased := []nsLockRequest{{newLock: func() RWLocker { return ldLeaseLock{lock("a"), lost} }, write: true, atomic: true}, {newLock: newLock("b"), write: true, atomic: true}}
|
||||
// Losing the first lease while the second is waited for retries
|
||||
// with a fresh context.
|
||||
releaseB := ldHoldLock(t, lock("b"))
|
||||
type result struct {
|
||||
lkctx LockContext
|
||||
unlock func()
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
lkctx, unlock, err := getNSLocks(t.Context(), leased, globalOperationTimeout)
|
||||
done <- result{lkctx, unlock, err}
|
||||
}()
|
||||
(<-lost)()
|
||||
releaseB()
|
||||
res := <-done
|
||||
if res.err != nil {
|
||||
t.Fatal(res.err)
|
||||
}
|
||||
if res.lkctx.Context().Err() != nil {
|
||||
t.Fatal("returned context ended before any lease was lost")
|
||||
}
|
||||
// Losing a held lease ends the returned context.
|
||||
var cancel context.CancelFunc
|
||||
for len(lost) > 0 {
|
||||
cancel = <-lost
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-res.lkctx.Context().Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Error("lost lease did not end the locks' context")
|
||||
}
|
||||
res.unlock()
|
||||
if !ldTry(t, lock("a"), true) || !ldTry(t, lock("b"), true) {
|
||||
t.Error("locks left behind")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A group whose lock times out fails alone and the later groups still run.
|
||||
func TestDeleteObjectsInSetsLaterGroupsRun(t *testing.T) {
|
||||
for _, mode := range ldModes[1:] {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
z, bucket := ldPool(t, mode)
|
||||
k0, k1 := ldKey(t, z, "later", 0), ldKey(t, z, "later", 1)
|
||||
ldMustPut(t, z, bucket, k0, "zero")
|
||||
ldMustPut(t, z, bucket, k1, "one")
|
||||
release := ldHoldLock(t, z.serverPools[0].getHashedSet(k0).NewNSLock(bucket, k0))
|
||||
defer release()
|
||||
objects := []ObjectToDelete{{ObjectV: ObjectV{ObjectName: k0}}, {ObjectV: ObjectV{ObjectName: k1}}}
|
||||
dobjects := []DeletedObject{{ObjectName: k0}, {ObjectName: k1}}
|
||||
derrs := make([]error, 2)
|
||||
z.deleteObjectsInSets(t.Context(), bucket, objects, ObjectOptions{}, dobjects, derrs, newDynamicTimeout(time.Second, time.Second))
|
||||
if !errors.As(derrs[0], &OperationTimedOut{}) || derrs[1] != nil {
|
||||
t.Fatalf("results %v, want a timeout for k0 only", derrs)
|
||||
}
|
||||
release()
|
||||
if body, _ := ldBody(t, z, bucket, k0); body != "zero" {
|
||||
t.Errorf("k0 = %q", body)
|
||||
}
|
||||
ldAbsent(t, z, bucket, k1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The local lock takes the names of a group one by one. A group that waits
|
||||
// for a name must not keep the names it holds: a part upload holds its part
|
||||
// lock and then reads its upload ID, which the batch names too.
|
||||
func TestDeleteObjectsInSetsLocalGroupReleases(t *testing.T) {
|
||||
z, bucket := ldPool(t, ldLocal)
|
||||
pool := z.serverPools[0]
|
||||
var upload, part string
|
||||
for i := range 1000 {
|
||||
upload = ldKey(t, z, "local-upload", 1) + fmt.Sprintf("/u%d", i)
|
||||
part = upload + "/1"
|
||||
if pool.getHashedSetIndex(upload) == 1 && pool.getHashedSetIndex(part) == 1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
er := pool.getHashedSet(upload)
|
||||
releasePart := ldHoldLock(t, er.NewNSLock(bucket, part))
|
||||
defer releasePart()
|
||||
done := make(chan []error, 1)
|
||||
go func() {
|
||||
_, errs := z.DeleteObjects(t.Context(), bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: upload}}, {ObjectV: ObjectV{ObjectName: part}}}, ObjectOptions{})
|
||||
done <- errs
|
||||
}()
|
||||
// The batch takes the upload name, waits for the part, and lets the
|
||||
// part upload's read of its upload ID through.
|
||||
ldTaken(t, er.NewNSLock(bucket, upload))
|
||||
read := er.NewNSLock(bucket, upload)
|
||||
rctx, err := read.GetRLock(t.Context(), newDynamicTimeout(4*nsLockTryTimeout, 4*nsLockTryTimeout))
|
||||
if err != nil {
|
||||
t.Fatalf("batch kept a name while waiting: %v", err)
|
||||
}
|
||||
read.RUnlock(rctx)
|
||||
releasePart()
|
||||
select {
|
||||
case errs := <-done:
|
||||
if errors.Join(errs...) != nil {
|
||||
t.Fatal(errs)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("bulk delete did not finish")
|
||||
}
|
||||
}
|
||||
|
||||
// ldLostUnlockReply applies its first unlock but loses the reply until gate
|
||||
// opens, so dsync retries that unlock in the background.
|
||||
type ldLostUnlockReply struct {
|
||||
dsync.NetLocker
|
||||
gate <-chan struct{}
|
||||
applied, retried chan<- struct{}
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (l *ldLostUnlockReply) Unlock(ctx context.Context, args dsync.LockArgs) (bool, error) {
|
||||
n := l.calls.Add(1)
|
||||
ok, err := l.NetLocker.Unlock(ctx, args)
|
||||
switch n {
|
||||
case 1:
|
||||
l.applied <- struct{}{}
|
||||
<-l.gate
|
||||
return false, errors.New("unlock reply lost")
|
||||
case 2:
|
||||
l.retried <- struct{}{}
|
||||
}
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// ldFailFirst fails the first acquisition of any of its instances.
|
||||
type ldFailFirst struct {
|
||||
RWLocker
|
||||
calls *atomic.Int32
|
||||
}
|
||||
|
||||
func (l ldFailFirst) GetLock(ctx context.Context, timeout *dynamicTimeout) (LockContext, error) {
|
||||
if l.calls.Add(1) == 1 {
|
||||
return LockContext{}, OperationTimedOut{}
|
||||
}
|
||||
return l.RWLocker.GetLock(ctx, timeout)
|
||||
}
|
||||
|
||||
// A distributed unlock of a failed attempt that is retried late must not
|
||||
// release the lock the next attempt took.
|
||||
func TestGetNSLocksFreshLockPerAttempt(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
applied, retried := make(chan struct{}, 3), make(chan struct{}, 3)
|
||||
lockers := make([]dsync.NetLocker, 3)
|
||||
for i := range lockers {
|
||||
lockers[i] = &ldLostUnlockReply{NetLocker: newLocker(), gate: gate, applied: applied, retried: retried}
|
||||
}
|
||||
ns := newNSLock(true)
|
||||
dst := func() RWLocker {
|
||||
return ns.NewNSLock(func() ([]dsync.NetLocker, string) { return lockers, "test" }, "bucket", "destination")
|
||||
}
|
||||
local := newNSLock(false)
|
||||
var calls atomic.Int32
|
||||
src := func() RWLocker { return ldFailFirst{local.NewNSLock(nil, "bucket", "source"), &calls} }
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
opened := false
|
||||
open := func() {
|
||||
if !opened {
|
||||
opened = true
|
||||
close(gate)
|
||||
}
|
||||
}
|
||||
defer open()
|
||||
_, unlock, err := getNSLocks(ctx, []nsLockRequest{{newLock: dst, write: true, atomic: true}, {newLock: src, write: true, atomic: true}}, newDynamicTimeout(10*time.Second, 10*time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer unlock()
|
||||
for range lockers {
|
||||
select {
|
||||
case <-applied:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("the first attempt's unlock never reached the lockers")
|
||||
}
|
||||
}
|
||||
open()
|
||||
for range lockers {
|
||||
select {
|
||||
case <-retried:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("the lost unlock was not retried")
|
||||
}
|
||||
}
|
||||
competitor := dst()
|
||||
lc, err := competitor.GetLock(ctx, newDynamicTimeout(300*time.Millisecond, 300*time.Millisecond))
|
||||
if err == nil {
|
||||
competitor.Unlock(lc)
|
||||
t.Fatal("a late unlock retry released the held lock")
|
||||
}
|
||||
}
|
||||
|
||||
// One attempt at a local lock over several names is bounded as a whole, and so
|
||||
// is the acquisition.
|
||||
func TestGetNSLocksLocalAttemptBound(t *testing.T) {
|
||||
ns := newNSLock(false)
|
||||
a, b := ns.NewNSLock(nil, "bucket", "a"), ns.NewNSLock(nil, "bucket", "b")
|
||||
releaseA, releaseB := ldHoldLock(t, a), ldHoldLock(t, b)
|
||||
defer releaseA()
|
||||
defer releaseB()
|
||||
time.AfterFunc(900*time.Millisecond, releaseA)
|
||||
time.AfterFunc(1800*time.Millisecond, releaseB)
|
||||
start := time.Now()
|
||||
_, unlock, err := getNSLocks(t.Context(), []nsLockRequest{{
|
||||
newLock: func() RWLocker { return ns.NewNSLock(nil, "bucket", "a", "b") }, write: true,
|
||||
}}, newDynamicTimeout(1500*time.Millisecond, 1500*time.Millisecond))
|
||||
if err == nil {
|
||||
unlock()
|
||||
t.Fatalf("acquired after %v with a deadline of 1.5s", time.Since(start))
|
||||
}
|
||||
if !errors.As(err, &OperationTimedOut{}) {
|
||||
t.Fatalf("got %v, want a timeout", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ldLeaseLayer gives the COPY handler locks whose lease the test ends, and
|
||||
// holds its destination lookup until the lookup's context ends.
|
||||
type ldLeaseLayer struct {
|
||||
ObjectLayer
|
||||
destination string
|
||||
lost chan context.CancelFunc
|
||||
lookup chan context.Context
|
||||
}
|
||||
|
||||
func (l *ldLeaseLayer) lockObjects(ctx context.Context, reqs ...objectLockRequest) (LockContext, func(), error) {
|
||||
lc, unlock, err := l.ObjectLayer.lockObjects(ctx, reqs...)
|
||||
if err != nil {
|
||||
return lc, unlock, err
|
||||
}
|
||||
lease, cancel := context.WithCancel(lc.Context())
|
||||
l.lost <- cancel
|
||||
return LockContext{ctx: lease, cancel: cancel}, func() { cancel(); unlock() }, nil
|
||||
}
|
||||
|
||||
func (l *ldLeaseLayer) GetObjectInfo(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
if object == l.destination && opts.NoLock {
|
||||
l.lookup <- ctx
|
||||
<-ctx.Done()
|
||||
return ObjectInfo{}, ctx.Err()
|
||||
}
|
||||
return l.ObjectLayer.GetObjectInfo(ctx, bucket, object, opts)
|
||||
}
|
||||
|
||||
// The COPY handler's work under its locks ends when a lease is lost, the
|
||||
// destination lookups included.
|
||||
func TestLockDomainCopyLookupEndsWithLease(t *testing.T) {
|
||||
z, bucket, _ := ldAPI(t, ldShared)
|
||||
ldMustPut(t, z, bucket, "source", "")
|
||||
prevTier := globalTierConfigMgr
|
||||
t.Cleanup(func() { globalTierConfigMgr = prevTier })
|
||||
globalTierConfigMgr = NewTierConfigMgr()
|
||||
globalTierConfigMgr.Tiers["WARM"] = madmin.TierConfig{Version: madmin.TierConfigVer, Type: madmin.S3, Name: "WARM", S3: &madmin.TierS3{Bucket: "warm"}}
|
||||
layer := &ldLeaseLayer{ObjectLayer: z, destination: "destination", lost: make(chan context.CancelFunc, 1), lookup: make(chan context.Context, 1)}
|
||||
router := initTestAPIEndPoints(layer, []string{"CopyObject"})
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
code := make(chan int, 1)
|
||||
go func() { code <- ldCopyRequest(ctx, t, router, bucket, "source", "destination", nil).Code }()
|
||||
var lookup context.Context
|
||||
select {
|
||||
case lookup = <-layer.lookup:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("COPY did not look up its destination")
|
||||
}
|
||||
(<-layer.lost)()
|
||||
select {
|
||||
case <-lookup.Done():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the destination lookup outlived the lost lease")
|
||||
}
|
||||
if c := <-code; c == http.StatusOK {
|
||||
t.Error("COPY succeeded after losing its lock")
|
||||
}
|
||||
}
|
||||
@@ -605,6 +605,10 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
|
||||
// a noncurrent version and has to be moved like any other. The
|
||||
// scanner applies the expiry once it lives in another pool.
|
||||
return lifecycle.NoneAction
|
||||
case evt.Action.Delete() && !z.migrationExpiryApplies(ctx, objInfo, evt):
|
||||
// Another pool holds versions with which the rule does not
|
||||
// apply. The version moves like any other.
|
||||
return lifecycle.NoneAction
|
||||
case evt.Action.Delete():
|
||||
return evt.Action
|
||||
}
|
||||
|
||||
+119
-44
@@ -1239,8 +1239,26 @@ func (z *erasureServerPools) DeleteObject(ctx context.Context, bucket string, ob
|
||||
object = encodeDirObject(object)
|
||||
}
|
||||
|
||||
// Serialize logical deletion with pooled writes and metadata updates.
|
||||
lk := z.NewNSLock(bucket, object)
|
||||
// Serialize logical deletion with the writes and metadata updates of the
|
||||
// object: take the lock they take (see NewObjectNSLock). With one pool
|
||||
// that is the object's own erasure set, which is where PutObject,
|
||||
// multipart commit and the metadata updates lock, so a pool whose sets
|
||||
// use different hosts still excludes them. With several pools it is the
|
||||
// pools lock, as before. Exact-key deletes that use prefix deletion
|
||||
// (DeletePrefixObject) are deletes of one object. A true prefix deletion
|
||||
// keeps the pools lock on the prefix name, which never covered the
|
||||
// objects below it.
|
||||
//
|
||||
// Nodes that still run a release before this routing lock single-pool
|
||||
// deletes and copies in the first set. In a pool whose sets use different
|
||||
// hosts they do not exclude the deletes and copies of updated nodes, nor
|
||||
// their writes, so such a pool must not run mixed versions.
|
||||
var lk RWLocker
|
||||
if opts.DeletePrefix && !opts.DeletePrefixObject {
|
||||
lk = z.NewNSLock(bucket, object)
|
||||
} else {
|
||||
lk = z.NewObjectNSLock(bucket, object)
|
||||
}
|
||||
lkctx, err := lk.GetLock(ctx, globalDeleteOperationTimeout)
|
||||
if err != nil {
|
||||
return ObjectInfo{}, err
|
||||
@@ -1248,6 +1266,19 @@ func (z *erasureServerPools) DeleteObject(ctx context.Context, bucket string, ob
|
||||
ctx = lkctx.Context()
|
||||
defer lk.Unlock(lkctx)
|
||||
|
||||
// An ILM expiry is decided on the versions in one pool. Under the lock,
|
||||
// check it with the versions in all pools; the sets then take it as
|
||||
// decided, without a recheck by the versions each one holds. They only
|
||||
// keep a version whose protection they read differently.
|
||||
if z.expiryNeedsAllPools(opts) {
|
||||
var approved objectLockState
|
||||
if approved, err = z.checkExpiryAllPools(ctx, bucket, object, opts); err != nil {
|
||||
return ObjectInfo{Name: decodeDirObject(object), VersionID: opts.VersionID}, err
|
||||
}
|
||||
opts.Expiration.CheckedAllPools = true
|
||||
opts.Expiration.ApprovedProtection = approved
|
||||
}
|
||||
|
||||
if opts.DeletePrefix {
|
||||
return ObjectInfo{}, z.deletePrefix(ctx, bucket, object)
|
||||
}
|
||||
@@ -1309,10 +1340,10 @@ func (z *erasureServerPools) DeleteObject(ctx context.Context, bucket string, ob
|
||||
|
||||
// Evaluate the conditional (If-Match) precondition while the write lock
|
||||
// acquired above is held, before the delete-marker short-circuit and before
|
||||
// any version is removed, so the object cannot change between the check and
|
||||
// the delete. This is scoped to a single erasure set (see the note at the
|
||||
// lock above): only there do the delete lock and the write path share the
|
||||
// same lock namespace, making the check-then-delete atomic.
|
||||
// any version is removed. The object's writers take the same lock (see the
|
||||
// note at the lock above), so the object cannot change between the check
|
||||
// and the delete. Multi-pool conditional deletes return above, through
|
||||
// deleteObjectReconciled, under the same pools lock.
|
||||
if opts.CheckPrecondFn != nil {
|
||||
// pinfo.ObjInfo is the current latest version. getPoolInfoExistingWithOpts
|
||||
// intentionally strips VersionID, so for a version-scoped delete read the
|
||||
@@ -1496,6 +1527,18 @@ func (z *erasureServerPools) DeleteObjects(ctx context.Context, bucket string, o
|
||||
objSets.Add(objects[i].ObjectName)
|
||||
}
|
||||
|
||||
// A single pool locks each name in the set its writers lock it in, one
|
||||
// set at a time. A single node keeps one lock table for all sets, so it
|
||||
// keeps the one bulk lock below: its local lock takes the names one by
|
||||
// one and holds the earlier names while it waits.
|
||||
if z.SinglePool() && !z.serverPools[0].sharesLocalNSLock() {
|
||||
z.deleteObjectsInSets(ctx, bucket, objects, opts, dobjects, derrs, globalOperationTimeout)
|
||||
for i := range dobjects {
|
||||
dobjects[i].ObjectName = decodeDirObject(dobjects[i].ObjectName)
|
||||
}
|
||||
return dobjects, derrs
|
||||
}
|
||||
|
||||
// Acquire a bulk write lock across 'objects'
|
||||
multiDeleteLock := z.NewNSLock(bucket, objSets.ToSlice()...)
|
||||
lkctx, err := multiDeleteLock.GetLock(ctx, globalOperationTimeout)
|
||||
@@ -1517,10 +1560,31 @@ func (z *erasureServerPools) DeleteObjects(ctx context.Context, bucket string, o
|
||||
batch = make([]ObjectToDelete, 0, len(objects))
|
||||
batchIdx = make([]int, 0, len(objects))
|
||||
data, dataIdx := make([]ObjectToDelete, 0, len(objects)), make([]int, 0, len(objects))
|
||||
approved := make([]objectLockState, 0, len(objects))
|
||||
ilm := make(map[string]lifecycleAllPools)
|
||||
for i, obj := range objects {
|
||||
if derrs[i] != nil {
|
||||
continue
|
||||
}
|
||||
// A version that ILM expires is checked with the versions of
|
||||
// its object in all pools, see checkExpiryAllPools. When the
|
||||
// expiry no longer applies, nothing is deleted and nothing is
|
||||
// left to replicate.
|
||||
var protection objectLockState
|
||||
vopts := ObjectOptions{Expiration: opts.Expiration, VersionID: obj.VersionID}
|
||||
if obj.VersionID != "" && z.expiryNeedsAllPools(vopts) {
|
||||
all, ok := ilm[obj.ObjectName]
|
||||
if !ok {
|
||||
all.versions, all.events, all.err = z.lifecycleEventsAllPools(ctx, bucket, obj.ObjectName)
|
||||
ilm[obj.ObjectName] = all
|
||||
}
|
||||
if err := all.expiryApplies(bucket, obj.ObjectName, vopts); err != nil {
|
||||
dobjects[i] = DeletedObject{ObjectName: obj.ObjectName, VersionID: obj.VersionID}
|
||||
derrs[i] = err
|
||||
continue
|
||||
}
|
||||
protection = all.protection(obj.VersionID)
|
||||
}
|
||||
if deletesData(obj, opts) {
|
||||
if z.copyWithoutDeleteQuorum(ctx, bucket, obj.ObjectName, obj.VersionID) {
|
||||
derrs[i] = InsufficientWriteQuorum{}
|
||||
@@ -1528,13 +1592,14 @@ func (z *erasureServerPools) DeleteObjects(ctx context.Context, bucket string, o
|
||||
}
|
||||
data = append(data, obj)
|
||||
dataIdx = append(dataIdx, i)
|
||||
approved = append(approved, protection)
|
||||
continue
|
||||
}
|
||||
batch = append(batch, obj)
|
||||
batchIdx = append(batchIdx, i)
|
||||
}
|
||||
if len(data) > 0 {
|
||||
deleted, errs := z.deleteObjectsReconciled(ctx, bucket, data, opts)
|
||||
deleted, errs := z.deleteObjectsReconciled(ctx, bucket, data, approved, opts)
|
||||
for j, i := range dataIdx {
|
||||
dobjects[i], derrs[i] = deleted[j], errs[j]
|
||||
}
|
||||
@@ -1598,8 +1663,10 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec
|
||||
|
||||
cpSrcDstSame := isStringEqual(pathJoin(srcBucket, srcObject), pathJoin(dstBucket, dstObject))
|
||||
|
||||
// A caller that also locks the source takes both with lockObjects, before
|
||||
// it reads the source, and passes NoLock.
|
||||
if !dstOpts.NoLock {
|
||||
ns := z.NewNSLock(dstBucket, dstObject)
|
||||
ns := z.NewObjectNSLock(dstBucket, dstObject)
|
||||
lkctx, err := ns.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return ObjectInfo{}, err
|
||||
@@ -1625,7 +1692,21 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec
|
||||
return oi, err
|
||||
}
|
||||
|
||||
poolIdx, err := z.getPoolIdxNoLock(ctx, dstBucket, dstObject, srcInfo.Size)
|
||||
var poolIdx int
|
||||
if dstOpts.MetadataChg && cpSrcDstSame && srcInfo.metadataOnly && srcOpts.VersionID == dstOpts.VersionID {
|
||||
// Restore status updates address an existing version. Keep ordinary
|
||||
// copy/write allocation unchanged, including its latest-version lookup.
|
||||
poolIdx, err = z.getPoolIdxExistingWithOpts(ctx, dstBucket, dstObject, ObjectOptions{
|
||||
VersionID: dstOpts.VersionID,
|
||||
MetadataChg: true,
|
||||
NoLock: true,
|
||||
Transition: dstOpts.Transition,
|
||||
SkipDecommissioned: true,
|
||||
SkipRebalancing: true,
|
||||
})
|
||||
} else {
|
||||
poolIdx, err = z.getPoolIdxNoLock(ctx, dstBucket, dstObject, srcInfo.Size)
|
||||
}
|
||||
if err != nil {
|
||||
return objInfo, err
|
||||
}
|
||||
@@ -2182,7 +2263,7 @@ func (z *erasureServerPools) GetMultipartInfo(ctx context.Context, bucket, objec
|
||||
return MultipartInfo{}, err
|
||||
}
|
||||
|
||||
uploadIDLock := z.NewNSLock(bucket, pathJoin(object, uploadID))
|
||||
uploadIDLock := z.uploadIDNSLock(bucket, object, uploadID)
|
||||
lkctx, err := uploadIDLock.GetRLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return MultipartInfo{}, err
|
||||
@@ -2223,7 +2304,7 @@ func (z *erasureServerPools) ListObjectParts(ctx context.Context, bucket, object
|
||||
return ListPartsInfo{}, err
|
||||
}
|
||||
|
||||
uploadIDLock := z.NewNSLock(bucket, pathJoin(object, uploadID))
|
||||
uploadIDLock := z.uploadIDNSLock(bucket, object, uploadID)
|
||||
lkctx, err := uploadIDLock.GetRLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return ListPartsInfo{}, err
|
||||
@@ -2274,7 +2355,7 @@ func (z *erasureServerPools) AbortMultipartUpload(ctx context.Context, bucket, o
|
||||
}
|
||||
}(ctx)
|
||||
|
||||
lk := z.NewNSLock(bucket, pathJoin(object, uploadID))
|
||||
lk := z.uploadIDNSLock(bucket, object, uploadID)
|
||||
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2312,9 +2393,12 @@ func (z *erasureServerPools) CompleteMultipartUpload(ctx context.Context, bucket
|
||||
}
|
||||
}()
|
||||
|
||||
// Several pools take the object lock first, then the upload lock below.
|
||||
// A single pool takes the upload lock here and the set takes the object
|
||||
// lock, in the object's own erasure set, only right before it commits.
|
||||
if !z.SinglePool() {
|
||||
if !opts.NoLock {
|
||||
lk := z.NewNSLock(bucket, encodeDirObject(object))
|
||||
lk := z.NewObjectNSLock(bucket, encodeDirObject(object))
|
||||
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return objInfo, err
|
||||
@@ -2375,13 +2459,18 @@ func (z *erasureServerPools) CompleteMultipartUpload(ctx context.Context, bucket
|
||||
|
||||
// Hold write locks to verify uploaded parts, also disallows any
|
||||
// parallel PutObjectPart() requests.
|
||||
uploadIDLock := z.NewNSLock(bucket, pathJoin(object, uploadID))
|
||||
wlkctx, err := uploadIDLock.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return objInfo, err
|
||||
// An empty upload ID would name the object lock itself, which this
|
||||
// completion holds or takes later: let the storage layer return its
|
||||
// normal missing bucket/upload error instead.
|
||||
if uploadID != "" {
|
||||
uploadIDLock := z.uploadIDNSLock(bucket, object, uploadID)
|
||||
wlkctx, err := uploadIDLock.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return objInfo, err
|
||||
}
|
||||
ctx = wlkctx.Context()
|
||||
defer uploadIDLock.Unlock(wlkctx)
|
||||
}
|
||||
ctx = wlkctx.Context()
|
||||
defer uploadIDLock.Unlock(wlkctx)
|
||||
|
||||
if z.SinglePool() {
|
||||
return z.serverPools[0].CompleteMultipartUpload(ctx, bucket, object, uploadID, uploadedParts, opts)
|
||||
@@ -3309,39 +3398,25 @@ func (z *erasureServerPools) TransitionObject(ctx context.Context, bucket, objec
|
||||
return z.serverPools[idx].TransitionObject(ctx, bucket, object, opts)
|
||||
}
|
||||
|
||||
// RestoreTransitionedObject - restore transitioned object content locally on this cluster.
|
||||
func (z *erasureServerPools) RestoreTransitionedObject(ctx context.Context, bucket, object string, opts ObjectOptions) error {
|
||||
func (z *erasureServerPools) CheckAbandonedParts(ctx context.Context, bucket, object string, opts madmin.HealOpts) error {
|
||||
object = encodeDirObject(object)
|
||||
if z.SinglePool() {
|
||||
return z.serverPools[0].RestoreTransitionedObject(ctx, bucket, object, opts)
|
||||
return z.serverPools[0].CheckAbandonedParts(ctx, bucket, object, opts)
|
||||
}
|
||||
|
||||
if !opts.NoLock {
|
||||
// Acquire write lock before restoring transitioned object
|
||||
lk := z.NewNSLock(bucket, object)
|
||||
lkctx, err := lk.GetLock(ctx, globalDeleteOperationTimeout)
|
||||
// Several pools: remove abandoned data under the pools lock, which every
|
||||
// write of the object takes, as healObjectInPool does. The lock of one
|
||||
// pool's set would not exclude a write that has moved its data directory
|
||||
// in place but not yet published the metadata that references it, such
|
||||
// as a native restore's commit.
|
||||
if opts.Remove && !opts.DryRun && !opts.NoLock {
|
||||
lk := z.NewObjectNSLock(bucket, object)
|
||||
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = lkctx.Context()
|
||||
defer lk.Unlock(lkctx)
|
||||
}
|
||||
|
||||
// Avoid restoring object from a pool being decommissioned.
|
||||
opts.SkipDecommissioned = true
|
||||
opts.NoLock = true
|
||||
idx, err := z.getPoolIdxExistingWithOpts(ctx, bucket, object, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return z.serverPools[idx].RestoreTransitionedObject(ctx, bucket, object, opts)
|
||||
}
|
||||
|
||||
func (z *erasureServerPools) CheckAbandonedParts(ctx context.Context, bucket, object string, opts madmin.HealOpts) error {
|
||||
object = encodeDirObject(object)
|
||||
if z.SinglePool() {
|
||||
return z.serverPools[0].CheckAbandonedParts(ctx, bucket, object, opts)
|
||||
opts.NoLock = true
|
||||
}
|
||||
errs := make([]error, len(z.serverPools))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -319,6 +319,8 @@ var (
|
||||
|
||||
globalOperationTimeout = newDynamicTimeout(10*time.Minute, 5*time.Minute) // default timeout for general ops
|
||||
globalDeleteOperationTimeout = newDynamicTimeout(5*time.Minute, 1*time.Minute) // default time for delete ops
|
||||
// Queued restores retry this wait; keep their failures out of delete timeouts.
|
||||
globalRestoreLockTimeout = newDynamicTimeout(5*time.Minute, 1*time.Minute)
|
||||
// Listing recovery has its own failure history and a five-second caller budget.
|
||||
globalListingOperationTimeout = newDynamicTimeout(5*time.Second, 5*time.Second)
|
||||
|
||||
|
||||
@@ -265,9 +265,12 @@ func (z *erasureServerPools) listPathShortcutEOF(ctx context.Context, bucket str
|
||||
}
|
||||
|
||||
// listingRLock covers the lock domains used by normal same-key mutations.
|
||||
// Multi-pool writes use the pools lock. In a single pool, DELETE/COPY use the
|
||||
// first set while PUT and metadata updates use the destination set, so recovery
|
||||
// takes both in that order. No writer routing or wire protocol changes here.
|
||||
// Multi-pool writes use the pools lock. In a single pool every object write,
|
||||
// DELETE and COPY included, locks the object's own set (see NewObjectNSLock),
|
||||
// which is the source set here. Releases before that routing locked
|
||||
// single-pool DELETE and COPY in the first set, so recovery still takes the
|
||||
// first set too, in that order, while nodes of those releases may run. It can
|
||||
// drop that lock only behind a version boundary that keeps such nodes out.
|
||||
func (z *erasureServerPools) listingRLock(ctx context.Context, bucket, object string, source *erasureObjects) (LockContext, func(), error) {
|
||||
firstSet := z.serverPools[0].sets[0]
|
||||
first := z.NewNSLock(bucket, object)
|
||||
|
||||
@@ -159,6 +159,14 @@ type WalkOptions struct {
|
||||
// ExpirationOptions represents object options for object expiration at objectLayer.
|
||||
type ExpirationOptions struct {
|
||||
Expire bool
|
||||
// CheckedAllPools: the expiry was checked with the versions of the
|
||||
// object in all pools (checkExpiryAllPools). A set does not check it
|
||||
// again with the versions it holds alone.
|
||||
CheckedAllPools bool
|
||||
// ApprovedProtection: the Object Lock state of the version with which
|
||||
// the check approved its expiry. The set compares it with the state it
|
||||
// reads (checkedExpiryRefused). In memory only.
|
||||
ApprovedProtection objectLockState `msg:"-"`
|
||||
}
|
||||
|
||||
// TransitionOptions represents object options for transition ObjectLayer operation
|
||||
@@ -169,6 +177,15 @@ type TransitionOptions struct {
|
||||
RestoreRequest *RestoreObjectRequest
|
||||
RestoreExpiry time.Time
|
||||
ExpireRestored bool
|
||||
|
||||
// attempt is the native restore a call belongs to: the attempt that
|
||||
// POST marked on the version, see restoreRequest.
|
||||
attempt *restoreAttempt
|
||||
// owner is the restore lock of the version, which POST hands to the
|
||||
// worker that runs attempt, see erasureServerPools.RestoreTransitionedObject.
|
||||
owner *restoreOwner
|
||||
// restoreDate is the request date POST recorded with attempt.
|
||||
restoreDate string
|
||||
}
|
||||
|
||||
// MakeBucketOptions represents bucket options for ObjectLayer bucket operations
|
||||
@@ -250,7 +267,13 @@ func (o *ObjectOptions) SetEvalRetentionBypassFn(f EvalRetentionBypassFn) {
|
||||
// ObjectLayer implements primitives for object API layer.
|
||||
type ObjectLayer interface {
|
||||
// Locking operations on object.
|
||||
// NewNSLock locks names in one stable domain. It is for names that are
|
||||
// not objects, such as bucket and IAM metadata locks.
|
||||
NewNSLock(bucket string, objects ...string) RWLocker
|
||||
// NewObjectNSLock locks one object in the lock domain of its writes.
|
||||
NewObjectNSLock(bucket, object string) RWLocker
|
||||
// lockObjects locks several objects together, see erasureServerPools.
|
||||
lockObjects(ctx context.Context, reqs ...objectLockRequest) (LockContext, func(), error)
|
||||
|
||||
// Storage operations.
|
||||
Shutdown(context.Context) error
|
||||
@@ -287,6 +310,8 @@ type ObjectLayer interface {
|
||||
DeleteObjects(ctx context.Context, bucket string, objects []ObjectToDelete, opts ObjectOptions) ([]DeletedObject, []error)
|
||||
TransitionObject(ctx context.Context, bucket, object string, opts ObjectOptions) error
|
||||
RestoreTransitionedObject(ctx context.Context, bucket, object string, opts ObjectOptions) error
|
||||
// restoreRequest records a POST restore on a version, see erasureServerPools.
|
||||
restoreRequest(ctx context.Context, bucket, object string, opts ObjectOptions, target ObjectInfo) (restoreRequestStatus, error)
|
||||
|
||||
// Multipart operations.
|
||||
ListMultipartUploads(ctx context.Context, bucket, prefix, keyMarker, uploadIDMarker, delimiter string, maxUploads int) (result ListMultipartsInfo, err error)
|
||||
|
||||
@@ -79,10 +79,13 @@ func (z BucketOptions) Msgsize() (s int) {
|
||||
// MarshalMsg implements msgp.Marshaler
|
||||
func (z ExpirationOptions) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
o = msgp.Require(b, z.Msgsize())
|
||||
// map header, size 1
|
||||
// map header, size 2
|
||||
// string "Expire"
|
||||
o = append(o, 0x81, 0xa6, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65)
|
||||
o = append(o, 0x82, 0xa6, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65)
|
||||
o = msgp.AppendBool(o, z.Expire)
|
||||
// string "CheckedAllPools"
|
||||
o = append(o, 0xaf, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x65, 0x64, 0x41, 0x6c, 0x6c, 0x50, 0x6f, 0x6f, 0x6c, 0x73)
|
||||
o = msgp.AppendBool(o, z.CheckedAllPools)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,6 +113,12 @@ func (z *ExpirationOptions) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
err = msgp.WrapError(err, "Expire")
|
||||
return
|
||||
}
|
||||
case "CheckedAllPools":
|
||||
z.CheckedAllPools, bts, err = msgp.ReadBoolBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "CheckedAllPools")
|
||||
return
|
||||
}
|
||||
default:
|
||||
bts, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
@@ -124,7 +133,7 @@ func (z *ExpirationOptions) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
|
||||
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
|
||||
func (z ExpirationOptions) Msgsize() (s int) {
|
||||
s = 1 + 7 + msgp.BoolSize
|
||||
s = 1 + 7 + msgp.BoolSize + 16 + msgp.BoolSize
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -340,9 +340,9 @@ func TestCopyRewritesObjectData(t *testing.T) {
|
||||
name: "data copy always rewrites",
|
||||
want: true,
|
||||
},
|
||||
// PostRestoreObjectHandler, updateRestoreMetadata and batchKeyRotate all
|
||||
// address the same version on both sides and never set Versioned, so they
|
||||
// only ever reach these two cases.
|
||||
// In-place metadata callers such as batchKeyRotate address the same
|
||||
// version on both sides and never set Versioned, so they only ever
|
||||
// reach these two cases.
|
||||
{
|
||||
name: "unversioned in-place metadata update",
|
||||
metadataOnly: true,
|
||||
|
||||
@@ -467,9 +467,17 @@ func deleteObjectVersions(ctx context.Context, o ObjectLayer, bucket string, toD
|
||||
deletedObjs, errs := o.DeleteObjects(ctx, bucket, toDel, ObjectOptions{
|
||||
PrefixEnabledFn: vc.PrefixEnabled,
|
||||
VersionSuspended: vc.Suspended(),
|
||||
Expiration: ExpirationOptions{Expire: true},
|
||||
})
|
||||
|
||||
for i, dobj := range deletedObjs {
|
||||
if errs[i] != nil && !isErrObjectNotFound(errs[i]) && !isErrVersionNotFound(errs[i]) {
|
||||
// Only a completed delete, or one confirmed already absent, is
|
||||
// a removal. After a refusal, a quorum, configuration or any
|
||||
// other error the version may remain; do not audit or announce
|
||||
// it. The next scan re-evaluates it.
|
||||
continue
|
||||
}
|
||||
oi := ObjectInfo{
|
||||
Bucket: bucket,
|
||||
Name: dobj.ObjectName,
|
||||
|
||||
+111
-51
@@ -34,6 +34,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -183,7 +184,7 @@ func (api objectAPIHandlers) SelectObjectContentHandler(w http.ResponseWriter, r
|
||||
|
||||
// Take read lock on object, here so subsequent lower-level
|
||||
// calls do not need to.
|
||||
lock := objectAPI.NewNSLock(bucket, object)
|
||||
lock := objectAPI.NewObjectNSLock(bucket, object)
|
||||
lkctx, err := lock.GetRLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
@@ -492,9 +493,10 @@ func (api objectAPIHandlers) getObjectHandler(ctx context.Context, objectAPI Obj
|
||||
return
|
||||
}
|
||||
event := evalActionFromLifecycle(ctx, *lc, rcfg, replcfg, objInfo)
|
||||
expiryInfo, event := recheckReadExpiry(ctx, objectAPI, objInfo, event)
|
||||
if event.Action.Delete() {
|
||||
// apply whatever the expiry rule is.
|
||||
applyExpiryRule(event, lcEventSrc_s3GetObject, objInfo)
|
||||
applyExpiryRule(event, lcEventSrc_s3GetObject, expiryInfo)
|
||||
if !event.Action.DeleteRestored() {
|
||||
// If the ILM action is not on restored object return error.
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrNoSuchKey), r.URL)
|
||||
@@ -972,9 +974,10 @@ func (api objectAPIHandlers) headObjectHandler(ctx context.Context, objectAPI Ob
|
||||
return
|
||||
}
|
||||
event := evalActionFromLifecycle(ctx, *lc, rcfg, replcfg, objInfo)
|
||||
expiryInfo, event := recheckReadExpiry(ctx, objectAPI, objInfo, event)
|
||||
if event.Action.Delete() {
|
||||
// apply whatever the expiry rule is.
|
||||
applyExpiryRule(event, lcEventSrc_s3HeadObject, objInfo)
|
||||
applyExpiryRule(event, lcEventSrc_s3HeadObject, expiryInfo)
|
||||
if !event.Action.DeleteRestored() {
|
||||
// If the ILM action is not on restored object return error.
|
||||
writeErrorResponseHeadersOnly(w, errorCodes.ToAPIErr(ErrNoSuchKey))
|
||||
@@ -1468,12 +1471,35 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
return checkCopyObjectPreconditions(ctx, w, r, o)
|
||||
}
|
||||
getOpts.CheckPrecondFn = checkCopyPrecondFn
|
||||
if cpSrcDstSame {
|
||||
getOpts.NoLock = true
|
||||
|
||||
// Lock the source for reading and a local destination for writing before
|
||||
// the source is opened, both in the lock domain of their writers. Taking
|
||||
// them together in one order keeps copies in opposite directions from
|
||||
// holding each other; a copy onto itself takes one write lock. The source
|
||||
// is then read, preconditions and inline data included, and the
|
||||
// destination looked up and written, all under these locks and without
|
||||
// taking them again. That work runs in lockCtx, which ends when either
|
||||
// lock is lost and so aborts the copy; errors are still reported in the
|
||||
// request's context. Both locks are released once the copy has been
|
||||
// written: a synchronous replication below reads the destination with its
|
||||
// own lock.
|
||||
locks := []objectLockRequest{{bucket: srcBucket, object: srcObject}}
|
||||
if !remoteCallRequired {
|
||||
locks = append(locks, objectLockRequest{bucket: dstBucket, object: dstObject, write: true})
|
||||
}
|
||||
lkctx, unlock, err := objectAPI.lockObjects(ctx, locks...)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
unlockCopy := sync.OnceFunc(unlock)
|
||||
defer unlockCopy()
|
||||
lockCtx := lkctx.Context()
|
||||
getOpts.NoLock = true
|
||||
dstOpts.NoLock = true
|
||||
|
||||
var rs *HTTPRangeSpec
|
||||
gr, err := getObjectNInfo(ctx, srcBucket, srcObject, rs, r.Header, getOpts)
|
||||
gr, err := getObjectNInfo(lockCtx, srcBucket, srcObject, rs, r.Header, getOpts)
|
||||
if err != nil {
|
||||
if isErrPreconditionFailed(err) {
|
||||
return
|
||||
@@ -1540,7 +1566,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
length := actualSize
|
||||
|
||||
if !cpSrcDstSame {
|
||||
if err := enforceBucketQuotaHard(ctx, dstBucket, actualSize); err != nil {
|
||||
if err := enforceBucketQuotaHard(lockCtx, dstBucket, actualSize); err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
@@ -1558,12 +1584,12 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
compressMetadata[ReservedMetadataPrefix+"compression"] = compressionAlgorithmV2
|
||||
compressMetadata[ReservedMetadataPrefix+"actual-size"] = strconv.FormatInt(actualSize, 10)
|
||||
|
||||
reader = etag.NewReader(ctx, reader, nil, nil)
|
||||
reader = etag.NewReader(lockCtx, reader, nil, nil)
|
||||
} else {
|
||||
reader = gr
|
||||
}
|
||||
|
||||
srcInfo.Reader, err = hash.NewReader(ctx, reader, length, "", "", actualSize)
|
||||
srcInfo.Reader, err = hash.NewReader(lockCtx, reader, length, "", "", actualSize)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
@@ -1658,7 +1684,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
}
|
||||
|
||||
if err = rotateKey(ctx, oldKey, newKeyID, newKey, srcBucket, srcObject, encMetadata, kmsCtx); err != nil {
|
||||
if err = rotateKey(lockCtx, oldKey, newKeyID, newKey, srcBucket, srcObject, encMetadata, kmsCtx); err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
@@ -1743,7 +1769,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
dstOpts.IndexCB = cb
|
||||
defer s2c.Close()
|
||||
reader = etag.Wrap(s2c, checksumReader)
|
||||
srcInfo.Reader, err = hash.NewReader(ctx, reader, -1, "", "", actualSize)
|
||||
srcInfo.Reader, err = hash.NewReader(lockCtx, reader, -1, "", "", actualSize)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
@@ -1756,7 +1782,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
if isTargetEncrypted && !remoteCallRequired {
|
||||
var encReader io.Reader
|
||||
kind, _ := crypto.IsRequested(r.Header)
|
||||
encReader, objEncKey, err = newEncryptReader(ctx, srcInfo.Reader, kind, newKeyID, newKey, dstBucket, dstObject, encMetadata, kmsCtx)
|
||||
encReader, objEncKey, err = newEncryptReader(lockCtx, srcInfo.Reader, kind, newKeyID, newKey, dstBucket, dstObject, encMetadata, kmsCtx)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
@@ -1771,7 +1797,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
// do not try to verify encrypted content
|
||||
srcInfo.Reader, err = hash.NewReader(ctx, reader, targetSize, "", "", actualSize)
|
||||
srcInfo.Reader, err = hash.NewReader(lockCtx, reader, targetSize, "", "", actualSize)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
@@ -1843,7 +1869,12 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
srcInfo.UserDefined = objectlock.FilterObjectLockMetadata(srcInfo.UserDefined, true, true)
|
||||
retPerms := isPutActionAllowed(ctx, getRequestAuthType(r), dstBucket, dstObject, r, policy.PutObjectRetentionAction)
|
||||
holdPerms := isPutActionAllowed(ctx, getRequestAuthType(r), dstBucket, dstObject, r, policy.PutObjectLegalHoldAction)
|
||||
getObjectInfo := objectAPI.GetObjectInfo
|
||||
// The destination is locked above: look it up without its lock, in the
|
||||
// locks' context.
|
||||
getObjectInfo := func(_ context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
opts.NoLock = true
|
||||
return objectAPI.GetObjectInfo(lockCtx, bucket, object, opts)
|
||||
}
|
||||
|
||||
// apply default bucket configuration/governance headers for dest side.
|
||||
retentionMode, retentionDate, legalHold, s3Err := checkPutObjectLockAllowed(ctx, r, dstBucket, dstObject, getObjectInfo, retPerms, holdPerms, replicaTrusted)
|
||||
@@ -2029,9 +2060,11 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
// For an ordinary federated copy, actualSize is the logical plaintext
|
||||
// length to declare. srcInfo.Size is not a reliable wire length because
|
||||
// the read path may already have adjusted it.
|
||||
writeCtx, modified := withFederatedWriteTime(ctx)
|
||||
writeCtx, modified := withFederatedWriteTime(lockCtx)
|
||||
remoteObjInfo, rerr := core.PutObject(writeCtx, dstBucket, dstObject, srcInfo.Reader,
|
||||
actualSize, "", "", opts)
|
||||
gr.Close()
|
||||
unlockCopy()
|
||||
if rerr != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, rerr), r.URL)
|
||||
return
|
||||
@@ -2078,7 +2111,9 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
|
||||
// Copy source object to destination, if source and destination
|
||||
// object is same then only metadata is updated.
|
||||
objInfo, err = copyObjectFn(ctx, srcBucket, srcObject, dstBucket, dstObject, srcInfo, copySrcOpts, dstOpts)
|
||||
objInfo, err = copyObjectFn(lockCtx, srcBucket, srcObject, dstBucket, dstObject, srcInfo, copySrcOpts, dstOpts)
|
||||
gr.Close()
|
||||
unlockCopy()
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
@@ -3951,7 +3986,13 @@ func (api objectAPIHandlers) PostRestoreObjectHandler(w http.ResponseWriter, r *
|
||||
return
|
||||
}
|
||||
|
||||
objInfo, err := getObjectInfo(ctx, bucket, object, opts)
|
||||
// Preserve a failed restore read even before the request body is parsed.
|
||||
opts.Transition.RestoreRequest = &RestoreObjectRequest{}
|
||||
// Resolve the target without the object lock; the status step below
|
||||
// takes the restore lock and then the object lock.
|
||||
preOpts := opts
|
||||
preOpts.NoLock = true
|
||||
objInfo, err := getObjectInfo(ctx, bucket, object, preOpts)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
@@ -3976,46 +4017,56 @@ func (api objectAPIHandlers) PostRestoreObjectHandler(w http.ResponseWriter, r *
|
||||
writeErrorResponse(ctx, w, apiErr, r.URL)
|
||||
return
|
||||
}
|
||||
opts.Transition.RestoreRequest = rreq
|
||||
statusCode := http.StatusOK
|
||||
alreadyRestored := false
|
||||
if err == nil {
|
||||
if objInfo.RestoreOngoing && rreq.Type != SelectRestoreRequest {
|
||||
// set or upgrade restore expiry
|
||||
restoreExpiry := lifecycle.ExpectedExpiryTime(time.Now().UTC(), rreq.Days)
|
||||
|
||||
// update self with restore metadata
|
||||
var (
|
||||
attempt *restoreAttempt
|
||||
owner *restoreOwner
|
||||
restoreDate string
|
||||
)
|
||||
if rreq.Type != SelectRestoreRequest {
|
||||
// Native restore needs every server to follow the restore lock and
|
||||
// the staged commit; refuse it, writing nothing, while that is not
|
||||
// established.
|
||||
if err := nativeRestoreAvailable(ctx); err != nil {
|
||||
apiErr := errorCodes.ToAPIErr(ErrBusy)
|
||||
apiErr.Code, apiErr.Description = "ServiceUnavailable", err.Error()
|
||||
writeErrorResponse(ctx, w, apiErr, r.URL)
|
||||
return
|
||||
}
|
||||
// Decide on a fresh read under the object lock: renew a restored copy
|
||||
// that is still there, or mark the version ongoing for a new attempt.
|
||||
// Only the version resolved above, with its identity, is restored.
|
||||
statusOpts := opts
|
||||
statusOpts.Transition.RestoreExpiry = restoreExpiry
|
||||
status, err := objectAPI.restoreRequest(GlobalContext, bucket, object, statusOpts, objInfo)
|
||||
if errors.Is(err, errRestoreInProgress) {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrObjectRestoreAlreadyInProgress), r.URL)
|
||||
return
|
||||
}
|
||||
if !objInfo.RestoreOngoing && !objInfo.RestoreExpires.IsZero() {
|
||||
statusCode = http.StatusAccepted
|
||||
alreadyRestored = true
|
||||
}
|
||||
}
|
||||
// set or upgrade restore expiry
|
||||
restoreExpiry := lifecycle.ExpectedExpiryTime(time.Now().UTC(), rreq.Days)
|
||||
metadata := cloneMSS(objInfo.UserDefined)
|
||||
|
||||
// update self with restore metadata
|
||||
if rreq.Type != SelectRestoreRequest {
|
||||
objInfo.metadataOnly = true // Perform only metadata updates.
|
||||
metadata[xhttp.AmzRestoreExpiryDays] = strconv.Itoa(rreq.Days)
|
||||
metadata[xhttp.AmzRestoreRequestDate] = time.Now().UTC().Format(http.TimeFormat)
|
||||
if alreadyRestored {
|
||||
metadata[xhttp.AmzRestore] = completedRestoreObj(restoreExpiry).String()
|
||||
} else {
|
||||
metadata[xhttp.AmzRestore] = ongoingRestoreObj().String()
|
||||
}
|
||||
objInfo.UserDefined = metadata
|
||||
if _, err := objectAPI.CopyObject(GlobalContext, bucket, object, bucket, object, objInfo, ObjectOptions{
|
||||
VersionID: objInfo.VersionID,
|
||||
}, ObjectOptions{
|
||||
VersionID: objInfo.VersionID,
|
||||
MTime: objInfo.ModTime,
|
||||
}); err != nil {
|
||||
if errors.As(err, &InvalidObjectState{}) || errors.As(err, &MethodNotAllowed{}) {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidObjectState), r.URL)
|
||||
return
|
||||
}
|
||||
// for previously restored object, just update the restore expiry
|
||||
if alreadyRestored {
|
||||
if err != nil {
|
||||
// Such as a read quorum error while telling whether it restores.
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
// Events keep the version ID as this request resolved it.
|
||||
status.objInfo.VersionID = objInfo.VersionID
|
||||
objInfo = status.objInfo
|
||||
// for previously restored object, just update the restore expiry
|
||||
if status.attempt == nil {
|
||||
return
|
||||
}
|
||||
attempt = status.attempt
|
||||
// The restore lock goes to the worker below, which releases it.
|
||||
owner, restoreDate = status.owner, status.date
|
||||
}
|
||||
|
||||
restoreObject := mustGetUUID()
|
||||
@@ -4035,6 +4086,9 @@ func (api objectAPIHandlers) PostRestoreObjectHandler(w http.ResponseWriter, r *
|
||||
// now process the restore in background
|
||||
go func() {
|
||||
rctx := GlobalContext
|
||||
// The restore releases the lock it was handed; this covers a path
|
||||
// that does not reach it. Releasing twice is a no-op.
|
||||
defer owner.release()
|
||||
if !rreq.SelectParameters.IsEmpty() {
|
||||
actualSize, err := objInfo.GetActualSize()
|
||||
if err != nil {
|
||||
@@ -4080,14 +4134,20 @@ func (api objectAPIHandlers) PostRestoreObjectHandler(w http.ResponseWriter, r *
|
||||
rreq.SelectParameters.Close()
|
||||
return
|
||||
}
|
||||
opts := ObjectOptions{
|
||||
// The versioning context and the explicit version POST resolved.
|
||||
restoreOpts := ObjectOptions{
|
||||
Transition: TransitionOptions{
|
||||
RestoreRequest: rreq,
|
||||
RestoreExpiry: restoreExpiry,
|
||||
attempt: attempt,
|
||||
owner: owner,
|
||||
restoreDate: restoreDate,
|
||||
},
|
||||
VersionID: objInfo.VersionID,
|
||||
VersionID: attempt.VersionID,
|
||||
Versioned: opts.Versioned,
|
||||
VersionSuspended: opts.VersionSuspended,
|
||||
}
|
||||
if err := objectAPI.RestoreTransitionedObject(rctx, bucket, object, opts); err != nil {
|
||||
if err := objectAPI.RestoreTransitionedObject(rctx, bucket, object, restoreOpts); err != nil {
|
||||
s3LogIf(ctx, fmt.Errorf("Unable to restore transitioned bucket/object %s/%s: %w", bucket, object, err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
// An explicit-version read uses ReadVersion. Only the added lifecycle scan
|
||||
// uses ReadXL, so its errors/absence can be injected without failing the read.
|
||||
type lifecycleReadDisk struct {
|
||||
StorageAPI
|
||||
bucket, object string
|
||||
scans *atomic.Int64
|
||||
err error
|
||||
}
|
||||
|
||||
func (d lifecycleReadDisk) ReadXL(ctx context.Context, bucket, object string, data bool) (RawFileInfo, error) {
|
||||
if bucket == d.bucket && object == d.object {
|
||||
d.scans.Add(1)
|
||||
if d.err != nil {
|
||||
return RawFileInfo{}, d.err
|
||||
}
|
||||
}
|
||||
return d.StorageAPI.ReadXL(ctx, bucket, object, data)
|
||||
}
|
||||
|
||||
func lifecycleReadDisks(t *testing.T, s *splitILM, object string, failedPool int, err error) *atomic.Int64 {
|
||||
t.Helper()
|
||||
var scans atomic.Int64
|
||||
for p, pool := range s.z.serverPools {
|
||||
pool.erasureDisksMu.Lock()
|
||||
previous := pool.erasureDisks
|
||||
wrapped := make([][]StorageAPI, len(previous))
|
||||
for i, disks := range previous {
|
||||
wrapped[i] = append([]StorageAPI(nil), disks...)
|
||||
for j, disk := range disks {
|
||||
var injected error
|
||||
if p == failedPool {
|
||||
injected = err
|
||||
}
|
||||
wrapped[i][j] = lifecycleReadDisk{StorageAPI: disk, bucket: s.bucket, object: object, scans: &scans, err: injected}
|
||||
}
|
||||
}
|
||||
pool.erasureDisks = wrapped
|
||||
pool.erasureDisksMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
pool.erasureDisksMu.Lock()
|
||||
pool.erasureDisks = previous
|
||||
pool.erasureDisksMu.Unlock()
|
||||
})
|
||||
}
|
||||
return &scans
|
||||
}
|
||||
|
||||
// Real signed GET and HEAD requests use the author's two-pool storage fixture.
|
||||
// Checking the queue as well as the response catches both false hiding and
|
||||
// accidentally authorizing the latest version instead of the version read.
|
||||
func TestPoolsLifecycleReadHTTP(t *testing.T) {
|
||||
const bothRules = `<Rule><ID>both</ID><Status>Enabled</Status><Filter><Prefix>both-</Prefix></Filter>` +
|
||||
`<Expiration><Days>1</Days></Expiration><NoncurrentVersionExpiration><NoncurrentDays>1</NoncurrentDays></NoncurrentVersionExpiration></Rule>`
|
||||
for _, state := range []versioning.State{versioning.Enabled, versioning.Suspended} {
|
||||
t.Run(string(state), func(t *testing.T) {
|
||||
s := newSplitILM(t, state, splitILMRules+bothRules)
|
||||
router := initTestAPIEndPoints(s.z, nil)
|
||||
for _, tc := range []struct {
|
||||
name, prefix string
|
||||
latestAge float64
|
||||
readLatest bool
|
||||
implicit bool
|
||||
action lifecycle.Action
|
||||
local lifecycle.Action
|
||||
fault error
|
||||
wantScan bool
|
||||
}{
|
||||
{name: "old-version-new-latest", prefix: "exp-", latestAge: 0.01, local: lifecycle.DeleteAction, wantScan: true},
|
||||
{name: "old-version-expired-latest", prefix: "exp-", latestAge: 3, local: lifecycle.DeleteAction, wantScan: true},
|
||||
{name: "old-version-delete-all-latest", prefix: "eav-", latestAge: 3, local: lifecycle.DeleteAllVersionsAction, wantScan: true},
|
||||
{name: "expired-current", prefix: "exp-", latestAge: 3, readLatest: true, local: lifecycle.DeleteAction, action: lifecycle.DeleteAction, wantScan: true},
|
||||
{name: "expired-current-implicit", prefix: "exp-", latestAge: 3, readLatest: true, implicit: true, local: lifecycle.DeleteAction, action: lifecycle.DeleteAction, wantScan: true},
|
||||
{name: "expired-noncurrent", prefix: "both-", latestAge: 3, local: lifecycle.DeleteAction, action: lifecycle.DeleteVersionAction, wantScan: true},
|
||||
{name: "extra-read-quorum-error", prefix: "exp-", latestAge: 3, local: lifecycle.DeleteAction, fault: errDiskNotFound, wantScan: true},
|
||||
{name: "read-version-missing-from-recheck", prefix: "exp-", latestAge: 3, local: lifecycle.DeleteAction, fault: errFileNotFound, wantScan: true},
|
||||
{name: "local-none-global-expired", prefix: "ncv-", latestAge: 3, local: lifecycle.NoneAction},
|
||||
} {
|
||||
for _, null := range []bool{false, true} {
|
||||
for oldPool := range 2 {
|
||||
t.Run(fmt.Sprintf("%s/null=%t/old-pool=%d", tc.name, null, oldPool), func(t *testing.T) {
|
||||
key := fmt.Sprintf("%s%s-%t-%d", tc.prefix, tc.name, null, oldPool)
|
||||
old := splitAt("v1", 5, oldPool)
|
||||
old.null = null
|
||||
s.write(key, old)
|
||||
s.write(key, splitAt("v2", tc.latestAge, 1-oldPool))
|
||||
label := "v1"
|
||||
if tc.readLatest {
|
||||
label = "v2"
|
||||
}
|
||||
vid := s.ids[key+" "+label]
|
||||
if vid == "" {
|
||||
vid = nullVersionID
|
||||
}
|
||||
oi, err := s.z.GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: vid, Versioned: state == versioning.Enabled, VersionSuspended: state == versioning.Suspended})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lc, err := globalLifecycleSys.Get(s.bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if local := lc.Eval(oi.ToLifecycleOpts()); local.Action != tc.local {
|
||||
t.Fatalf("fixture local event=%v, want %v", local.Action, tc.local)
|
||||
}
|
||||
versions, events, err := s.z.lifecycleEventsAllPools(t.Context(), s.bucket, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
index := 1
|
||||
if tc.readLatest {
|
||||
index = 0
|
||||
}
|
||||
if tc.local == lifecycle.NoneAction && events[index].Action != lifecycle.DeleteVersionAction {
|
||||
t.Fatal("fixture must be globally expired but locally NoneAction")
|
||||
}
|
||||
scans := lifecycleReadDisks(t, s, key, oldPool, tc.fault)
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
scans.Store(0)
|
||||
requestID := vid
|
||||
if tc.implicit {
|
||||
requestID = ""
|
||||
}
|
||||
rec := consistencyRequest(t, router, method, s.bucket, key, requestID)
|
||||
want := http.StatusOK
|
||||
if tc.action.Delete() {
|
||||
want = http.StatusNotFound
|
||||
}
|
||||
if rec.Code != want {
|
||||
t.Errorf("status=%d want=%d body=%s", rec.Code, want, rec.Body.String())
|
||||
}
|
||||
if want == http.StatusOK && method == http.MethodGet && rec.Body.String() != key+" "+label {
|
||||
t.Errorf("body=%q, want requested version %s", rec.Body.String(), label)
|
||||
}
|
||||
if !tc.implicit && (scans.Load() > 0) != tc.wantScan {
|
||||
t.Errorf("ReadXL calls=%d, want scan=%t", scans.Load(), tc.wantScan)
|
||||
}
|
||||
if tc.action.Delete() {
|
||||
select {
|
||||
case op := <-s.queue:
|
||||
task, ok := op.(expiryTask)
|
||||
src := lcEventSrc_s3GetObject
|
||||
if method == http.MethodHead {
|
||||
src = lcEventSrc_s3HeadObject
|
||||
}
|
||||
global := versions[index]
|
||||
if !ok || task.src != src || task.event != events[index] || task.event.Action != tc.action ||
|
||||
task.objInfo.VersionID != global.VersionID || task.objInfo.IsLatest != global.IsLatest ||
|
||||
task.objInfo.NumVersions != global.NumVersions || !task.objInfo.SuccessorModTime.Equal(global.SuccessorModTime) {
|
||||
t.Errorf("wrong queued expiry: %+v; want %s with global event %+v", task, vid, events[index])
|
||||
}
|
||||
default:
|
||||
t.Error("missing expiry task")
|
||||
}
|
||||
}
|
||||
if n := len(s.queue); n != 0 {
|
||||
t.Errorf("unexpected expiry tasks: %d", n)
|
||||
for len(s.queue) > 0 {
|
||||
<-s.queue
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsLifecycleReadAuthorizationAndClose(t *testing.T) {
|
||||
s := newSplitILM(t, versioning.Enabled, splitILMRules)
|
||||
initConfigSubsystem(t.Context(), s.z)
|
||||
globalIAMSys.Init(t.Context(), s.z, globalEtcdClient, 2*time.Second)
|
||||
denied := newObjectAttributesAuthzUser(t, "lifecycle-read", s.bucket, `"s3:PutObject"`)
|
||||
router := initTestAPIEndPoints(s.z, nil)
|
||||
for _, expire := range []bool{false, true} {
|
||||
key := fmt.Sprintf("exp-reader-%t", expire)
|
||||
v := splitAt("v1", 5, 0)
|
||||
v.size = 1 << 20 // a non-inline reader retains the object read lock
|
||||
s.write(key, v)
|
||||
if !expire {
|
||||
s.write(key, splitAt("v2", 0.01, 1))
|
||||
}
|
||||
vid := s.ids[key+" v1"]
|
||||
oi, err := s.z.GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: vid, Versioned: true})
|
||||
if err != nil || oi.Inlined {
|
||||
t.Fatalf("fixture must retain a reader lock: inlined=%t err=%v", oi.Inlined, err)
|
||||
}
|
||||
scans := lifecycleReadDisks(t, s, key, -1, nil)
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
||||
req, err := newTestSignedRequestV4(method, getGetObjectURL("", s.bucket, key)+"?versionId="+vid, 0, nil, denied.AccessKey, denied.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden || scans.Load() != 0 || len(s.queue) != 0 {
|
||||
t.Fatalf("denied %s: status=%d scans=%d queued=%d", method, rec.Code, scans.Load(), len(s.queue))
|
||||
}
|
||||
}
|
||||
rec := consistencyRequest(t, router, http.MethodGet, s.bucket, key, vid)
|
||||
want := http.StatusOK
|
||||
if expire {
|
||||
want = http.StatusNotFound
|
||||
}
|
||||
if rec.Code != want {
|
||||
t.Errorf("authorized GET status=%d want=%d", rec.Code, want)
|
||||
}
|
||||
// The reader owns the multi-pool read lock until Close, including the
|
||||
// early 404 path. A writer must acquire it immediately after the handler.
|
||||
lk := s.z.NewNSLock(s.bucket, key)
|
||||
lkctx, err := lk.GetLock(t.Context(), newDynamicTimeout(time.Second, time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("GET leaked its reader/lock: %v", err)
|
||||
}
|
||||
lk.Unlock(lkctx)
|
||||
for len(s.queue) > 0 {
|
||||
<-s.queue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsLifecycleReadBoundaries(t *testing.T) {
|
||||
const transitionRules = `<Rule><ID>transition</ID><Status>Enabled</Status><Filter><Prefix>transition-</Prefix></Filter>` +
|
||||
`<Transition><Days>1</Days><StorageClass>WARM</StorageClass></Transition></Rule>`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
state versioning.State
|
||||
null bool
|
||||
singlePool bool
|
||||
restored bool
|
||||
transition bool
|
||||
action lifecycle.Action
|
||||
wantScan bool
|
||||
}{
|
||||
{name: "single-pool", state: versioning.Enabled, singlePool: true, action: lifecycle.DeleteAction},
|
||||
{name: "single-pool-null", state: versioning.Enabled, singlePool: true, null: true, action: lifecycle.DeleteAction},
|
||||
{name: "unversioned", null: true, action: lifecycle.DeleteAction},
|
||||
{name: "current-null-enabled", state: versioning.Enabled, null: true, action: lifecycle.DeleteAction, wantScan: true},
|
||||
{name: "current-null-suspended", state: versioning.Suspended, null: true, action: lifecycle.DeleteAction, wantScan: true},
|
||||
{name: "transition", state: versioning.Enabled, transition: true, action: lifecycle.TransitionAction},
|
||||
{name: "restored", state: versioning.Enabled, restored: true, action: lifecycle.DeleteRestoredAction},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := newSplitILM(t, tc.state, splitILMRules+transitionRules)
|
||||
key := "exp-" + tc.name
|
||||
if tc.restored || tc.transition {
|
||||
key = "transition-" + tc.name
|
||||
}
|
||||
v := splitAt("v1", 5, 0)
|
||||
v.null, v.tiered = tc.null, tc.restored
|
||||
s.write(key, v)
|
||||
vid := s.ids[key+" v1"]
|
||||
if vid == "" {
|
||||
vid = nullVersionID
|
||||
}
|
||||
if tc.restored {
|
||||
globalTierConfigMgr.drivercache["WARM"] = &tierExpiryTestBackend{objects: map[string][]byte{"remote-v1": []byte(key + " v1")}}
|
||||
opts := ObjectOptions{VersionID: vid, Versioned: true}
|
||||
opts.EvalMetadataFn = func(oi *ObjectInfo, _ error) (ReplicateDecision, error) {
|
||||
oi.UserDefined[xhttp.AmzRestore] = completedRestoreObj(UTCNow().Add(-time.Hour)).String()
|
||||
return ReplicateDecision{}, nil
|
||||
}
|
||||
if _, err := s.z.PutObjectMetadata(t.Context(), s.bucket, key, opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
z := s.z
|
||||
if tc.singlePool {
|
||||
// Exercise the single-pool dispatch with the same storage fixture.
|
||||
z = &erasureServerPools{serverPools: s.z.serverPools[:1]}
|
||||
}
|
||||
router := initTestAPIEndPoints(z, []string{"GetObject", "HeadObject"})
|
||||
scans := lifecycleReadDisks(t, s, key, -1, nil)
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
||||
scans.Store(0)
|
||||
rec := consistencyRequest(t, router, method, s.bucket, key, vid)
|
||||
want := http.StatusOK
|
||||
if tc.action.Delete() && !tc.action.DeleteRestored() {
|
||||
want = http.StatusNotFound
|
||||
}
|
||||
if rec.Code != want || (scans.Load() > 0) != tc.wantScan {
|
||||
t.Errorf("%s status=%d want=%d scans=%d wantScan=%t body=%s", method, rec.Code, want, scans.Load(), tc.wantScan, rec.Body.String())
|
||||
}
|
||||
if want == http.StatusOK && method == http.MethodGet && rec.Body.String() != key+" v1" {
|
||||
t.Errorf("%s lost object contents: %q", method, rec.Body.String())
|
||||
}
|
||||
if tc.action.Delete() {
|
||||
select {
|
||||
case op := <-s.queue:
|
||||
task, ok := op.(expiryTask)
|
||||
if !ok || task.event.Action != tc.action || s.key(key, task.objInfo.VersionID) != s.key(key, vid) {
|
||||
t.Errorf("%s wrong expiry: %+v", method, task)
|
||||
}
|
||||
default:
|
||||
t.Errorf("%s missing expiry", method)
|
||||
}
|
||||
}
|
||||
if n := len(s.queue); n != 0 {
|
||||
t.Fatalf("%s queued %d unexpected expiries", method, n)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsLifecycleReadProxy(t *testing.T) {
|
||||
s := newSplitILM(t, versioning.Enabled, splitILMRules)
|
||||
const key, body = "exp-proxied", "remote object remains readable"
|
||||
vid := mustGetUUID()
|
||||
target := newILMTestTarget(t)
|
||||
var remoteRequests atomic.Int64
|
||||
registerILMTestTarget(t, s.z, s.bucket, target, func(w http.ResponseWriter, r *http.Request) {
|
||||
remoteRequests.Add(1)
|
||||
w.Header().Set(xhttp.ContentLength, fmt.Sprint(len(body)))
|
||||
w.Header().Set(xhttp.LastModified, UTCNow().Add(-5*24*time.Hour).Format(http.TimeFormat))
|
||||
w.Header().Set(xhttp.ETag, `"da39a3ee5e6b4b0d3255bfef95601890"`)
|
||||
w.Header().Set(xhttp.AmzVersionID, vid)
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
})
|
||||
setILMTestReplicationConfig(t, s.bucket, target.arn, target.bucket)
|
||||
// The delete-only target fixture omits credentials; proxy discovery clones
|
||||
// the target descriptor and requires its credentials field to be present.
|
||||
globalBucketTargetSys.Lock()
|
||||
globalBucketTargetSys.targetsMap[s.bucket][0].Credentials = &madmin.Credentials{AccessKey: "proxy-test"}
|
||||
globalBucketTargetSys.Unlock()
|
||||
stats := NewReplicationStats(t.Context(), nil)
|
||||
previous := globalReplicationStats.Swap(stats)
|
||||
t.Cleanup(func() { globalReplicationStats.Store(previous) })
|
||||
scans := lifecycleReadDisks(t, s, key, -1, nil)
|
||||
router := initTestAPIEndPoints(s.z, nil)
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
||||
before := remoteRequests.Load()
|
||||
rec := consistencyRequest(t, router, method, s.bucket, key, vid)
|
||||
if rec.Code != http.StatusOK || remoteRequests.Load() == before || scans.Load() != 0 || len(s.queue) != 0 {
|
||||
t.Errorf("proxy %s: status=%d remote=%d scans=%d queued=%d body=%s", method, rec.Code, remoteRequests.Load()-before, scans.Load(), len(s.queue), rec.Body.String())
|
||||
}
|
||||
if method == http.MethodGet && rec.Body.String() != body {
|
||||
t.Errorf("proxy body=%q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -544,6 +544,18 @@ func (p *hookBeforeWrite) CompleteMultipartUpload(ctx context.Context, bucket, k
|
||||
return p.ObjectLayer.CompleteMultipartUpload(ctx, bucket, key, uploadID, parts, opts)
|
||||
}
|
||||
|
||||
// The COPY handler checks Object Lock under its destination's write lock, so
|
||||
// no writer can land between that check and the copy. The latest a writer can
|
||||
// land is just before the handler takes its locks.
|
||||
func (p *hookBeforeWrite) lockObjects(ctx context.Context, reqs ...objectLockRequest) (LockContext, func(), error) {
|
||||
for _, r := range reqs {
|
||||
if r.write {
|
||||
p.fire(r.object)
|
||||
}
|
||||
}
|
||||
return p.ObjectLayer.lockObjects(ctx, reqs...)
|
||||
}
|
||||
|
||||
func TestDirectoryObjectLockAfterPreflight(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t,
|
||||
|
||||
@@ -116,6 +116,7 @@ var (
|
||||
stopRebalanceRPC = grid.NewSingleHandler[*grid.MSS, grid.NoPayload](grid.HandlerStopRebalance, grid.NewMSS, grid.NewNoPayload)
|
||||
updateMetacacheListingRPC = grid.NewSingleHandler[*metacache, *metacache](grid.HandlerUpdateMetacacheListing, func() *metacache { return &metacache{} }, func() *metacache { return &metacache{} })
|
||||
cleanupUploadIDCacheMetaRPC = grid.NewSingleHandler[*grid.MSS, grid.NoPayload](grid.HandlerClearUploadID, grid.NewMSS, grid.NewNoPayload)
|
||||
restoreCapabilityRPC = grid.NewSingleHandler[*grid.MSS, *grid.MSS](grid.HandlerRestoreCapability, grid.NewMSS, grid.NewMSS)
|
||||
|
||||
// STREAMS
|
||||
// Set an output capacity of 100 for consoleLog and listenRPC
|
||||
@@ -1440,6 +1441,7 @@ func registerPeerRESTHandlers(router *mux.Router, gm *grid.Manager) {
|
||||
logger.FatalIf(stopRebalanceRPC.Register(gm, server.StopRebalanceHandler), "unable to register handler")
|
||||
logger.FatalIf(updateMetacacheListingRPC.Register(gm, server.UpdateMetacacheListingHandler), "unable to register handler")
|
||||
logger.FatalIf(cleanupUploadIDCacheMetaRPC.Register(gm, server.HandlerClearUploadID), "unable to register handler")
|
||||
logger.FatalIf(restoreCapabilityRPC.Register(gm, restoreCapabilityHandler), "unable to register handler")
|
||||
|
||||
logger.FatalIf(gm.RegisterStreamingHandler(grid.HandlerTrace, grid.StreamHandler{
|
||||
Handle: server.TraceHandler,
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
// The version that POST marks ongoing names the native restore attempt that
|
||||
// owns the marker. Clients cannot set it: requests with reserved metadata are
|
||||
// refused, and it is never extracted from headers.
|
||||
const restoreAttemptKey = ReservedMetadataPrefixLower + "restore-attempt"
|
||||
|
||||
const (
|
||||
// restoreTakeoverWait bounds how long a POST waits for the restore lock
|
||||
// of its version. A running restore holds it for its whole run.
|
||||
restoreTakeoverWait = 2 * time.Second
|
||||
// restoreCleanupTimeout bounds each status reset and convergence pass.
|
||||
restoreCleanupTimeout = time.Minute
|
||||
)
|
||||
|
||||
// restoreRecoveryDelays are the in-memory retries that clear the ongoing
|
||||
// marker of a restore that failed before it could clear the marker itself.
|
||||
// A restart loses them; a later POST then takes the marker over.
|
||||
var restoreRecoveryDelays = []time.Duration{0, 100 * time.Millisecond, time.Second, 5 * time.Second, 30 * time.Second, 2 * time.Minute}
|
||||
|
||||
var (
|
||||
// errRestoreSuperseded: the version no longer belongs to this attempt.
|
||||
// The attempt must not rehydrate, complete or reset anything.
|
||||
errRestoreSuperseded = errors.New("restore attempt superseded")
|
||||
// errRestoreInProgress: a restore holds the restore lock of the version.
|
||||
errRestoreInProgress = errors.New("restore already in progress")
|
||||
// errRestoreUnchanged: a restore status update found nothing to write.
|
||||
errRestoreUnchanged = errors.New("restore status unchanged")
|
||||
)
|
||||
|
||||
// restoreResetError marks a failed restore that may have left its ongoing
|
||||
// marker behind. The pools layer hands it to recoverRestoreStatus.
|
||||
type restoreResetError struct{ error }
|
||||
|
||||
func (e restoreResetError) Unwrap() error { return e.error }
|
||||
|
||||
// restoreVersionID is the explicit version ID of a resolved restore target.
|
||||
func restoreVersionID(versionID string) string {
|
||||
if versionID == "" {
|
||||
return nullVersionID
|
||||
}
|
||||
return versionID
|
||||
}
|
||||
|
||||
// restoreAttempt identifies one native restore of one stored version.
|
||||
type restoreAttempt struct {
|
||||
ID string `json:"id"`
|
||||
VersionID string `json:"v"` // "null" for the null version
|
||||
ETag string `json:"e"`
|
||||
Size int64 `json:"s"`
|
||||
ModTime time.Time `json:"m"`
|
||||
}
|
||||
|
||||
func newRestoreAttempt(oi ObjectInfo) *restoreAttempt {
|
||||
return &restoreAttempt{
|
||||
ID: mustGetUUID(),
|
||||
VersionID: restoreVersionID(oi.VersionID),
|
||||
ETag: oi.ETag,
|
||||
Size: oi.Size,
|
||||
ModTime: oi.ModTime.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *restoreAttempt) String() string {
|
||||
b, _ := json.Marshal(a)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// targets reports whether oi is still the version the attempt was made for.
|
||||
// A replacement under the same version ID differs in ETag, size or ModTime.
|
||||
func (a *restoreAttempt) targets(oi ObjectInfo) bool {
|
||||
return a != nil && !oi.DeleteMarker && restoreVersionID(oi.VersionID) == a.VersionID &&
|
||||
oi.ETag == a.ETag && oi.Size == a.Size && oi.ModTime.Equal(a.ModTime) &&
|
||||
oi.TransitionedObject.Status == lifecycle.TransitionComplete
|
||||
}
|
||||
|
||||
// owns reports whether the version is ongoing under this attempt.
|
||||
func (a *restoreAttempt) owns(oi ObjectInfo) bool {
|
||||
return a.targets(oi) && oi.RestoreOngoing && oi.UserDefined[restoreAttemptKey] == a.ID
|
||||
}
|
||||
|
||||
// ownsGeneration reports whether the attempt owns the authoritative
|
||||
// generation of the version. Where the drives disagree only in their restore
|
||||
// fields, see restoreGeneration, it owns the version only when its marker is
|
||||
// the only ongoing marker there: holding one of several is not enough.
|
||||
func (a *restoreAttempt) ownsGeneration(gen restoreGeneration, bucket, object string) bool {
|
||||
oi := gen.fi.ToObjectInfo(bucket, object, false)
|
||||
if !gen.split {
|
||||
return a.owns(oi)
|
||||
}
|
||||
return a.targets(oi) && len(gen.ongoing) == 1 && gen.ongoing[a.ID]
|
||||
}
|
||||
|
||||
// restoreGeneration is the metadata generation of one version that the drives
|
||||
// of a set hold authoritatively: the revision (header and normalized metadata
|
||||
// digest, versionRevision) that at least max(n/2+1, its read quorum) drives
|
||||
// hold. drives has each drive's own copy of it, with that drive's erasure
|
||||
// index and inline data, and an invalid entry where a drive holds another or
|
||||
// no revision; disks has nil there.
|
||||
//
|
||||
// A failed restore status write can leave drives that differ only in their
|
||||
// restore fields, with no revision on a quorum. Then the generation is the
|
||||
// restore-neutral revision that a quorum holds once those fields are left
|
||||
// out: fi has no restore fields, and ongoing names the attempts whose
|
||||
// ongoing marker some of its drives hold ("" for one without an attempt).
|
||||
// Every other field, protection included, still needs the quorum.
|
||||
//
|
||||
// revs has, for every drive that holds the version, the full revision of the
|
||||
// entry it holds, whether or not that revision is the generation; nil where a
|
||||
// drive does not hold the version or cannot be read. A native restore's
|
||||
// commit compares the drives against it later, see restorePublication.
|
||||
type restoreGeneration struct {
|
||||
fi FileInfo
|
||||
drives []FileInfo
|
||||
disks []StorageAPI
|
||||
split bool
|
||||
ongoing map[string]bool
|
||||
revs []*revisionKey
|
||||
}
|
||||
|
||||
// restoreFieldKeys are the metadata keys a restore status write sets.
|
||||
var restoreFieldKeys = []string{xhttp.AmzRestore, xhttp.AmzRestoreExpiryDays, xhttp.AmzRestoreRequestDate, restoreAttemptKey}
|
||||
|
||||
// restoreHealMeta returns latest, the metadata heal copies to drives that
|
||||
// lack the version, with the restore fields that at least quorum drives of
|
||||
// that version hold. When some drive holds restore fields and no set of
|
||||
// them has the quorum, healing the version waits.
|
||||
func restoreHealMeta(metas []FileInfo, latest FileInfo, quorum int) (FileInfo, error) {
|
||||
counts := make(map[string]int)
|
||||
fields := make(map[string]map[string]string)
|
||||
restoring := false
|
||||
for _, m := range metas {
|
||||
if !m.IsValid() || m.Deleted || !m.ModTime.Equal(latest.ModTime) {
|
||||
continue
|
||||
}
|
||||
f := make(map[string]string)
|
||||
var key strings.Builder
|
||||
for _, k := range restoreFieldKeys {
|
||||
if v, ok := m.Metadata[k]; ok {
|
||||
f[k] = v
|
||||
fmt.Fprintf(&key, "%d:%s=%d:%s;", len(k), k, len(v), v)
|
||||
restoring = true
|
||||
}
|
||||
}
|
||||
counts[key.String()]++
|
||||
fields[key.String()] = f
|
||||
}
|
||||
if !restoring {
|
||||
return latest, nil
|
||||
}
|
||||
best, bestCount := "", 0
|
||||
for key, c := range counts {
|
||||
if c > bestCount {
|
||||
best, bestCount = key, c
|
||||
}
|
||||
}
|
||||
if bestCount < quorum {
|
||||
return latest, errErasureReadQuorum
|
||||
}
|
||||
latest.Metadata = maps.Clone(latest.Metadata)
|
||||
for _, k := range restoreFieldKeys {
|
||||
delete(latest.Metadata, k)
|
||||
}
|
||||
maps.Copy(latest.Metadata, fields[best])
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
// restoreNeutralRevision is the revision of ver without its restore fields.
|
||||
func restoreNeutralRevision(ver xlMetaV2ShallowVersion, metaV uint8) (revisionKey, bool) {
|
||||
var v xlMetaV2Version
|
||||
if _, err := v.unmarshalV(metaV, ver.meta); err != nil || v.Type != ObjectType || v.ObjectV2 == nil {
|
||||
return revisionKey{}, false
|
||||
}
|
||||
o := *v.ObjectV2
|
||||
o.MetaUser, o.MetaSys = maps.Clone(o.MetaUser), maps.Clone(o.MetaSys)
|
||||
for _, k := range restoreFieldKeys {
|
||||
delete(o.MetaUser, k)
|
||||
delete(o.MetaSys, k)
|
||||
}
|
||||
v.ObjectV2 = &o
|
||||
meta, err := v.MarshalMsg(nil)
|
||||
if err != nil {
|
||||
return revisionKey{}, false
|
||||
}
|
||||
return versionRevision(xlMetaV2ShallowVersion{header: v.header(), meta: meta}, xlMetaVersion), true
|
||||
}
|
||||
|
||||
// readRestoreGeneration selects the authoritative generation of a version,
|
||||
// or fails closed: a revision that only some drives hold, such as one a
|
||||
// failed metadata update left behind, is never selected. An empty version ID
|
||||
// addresses the latest version, as a direct restore may.
|
||||
func (er erasureObjects) readRestoreGeneration(ctx context.Context, bucket, object, versionID string) (restoreGeneration, error) {
|
||||
if versionID == "" {
|
||||
fi, _, _, err := er.getObjectFileInfo(ctx, bucket, object, ObjectOptions{
|
||||
NoLock: true,
|
||||
Transition: TransitionOptions{RestoreRequest: &RestoreObjectRequest{}},
|
||||
}, false)
|
||||
if err != nil {
|
||||
return restoreGeneration{}, err
|
||||
}
|
||||
versionID = restoreVersionID(fi.VersionID)
|
||||
}
|
||||
var uv [16]byte
|
||||
if versionID != nullVersionID {
|
||||
id, err := uuid.Parse(versionID)
|
||||
if err != nil {
|
||||
return restoreGeneration{}, errFileVersionNotFound
|
||||
}
|
||||
uv = id
|
||||
}
|
||||
disks := er.getDisks()
|
||||
n := len(disks)
|
||||
majority := n/2 + 1
|
||||
raws, errs := readAllRawFileInfo(ctx, disks, bucket, object, true)
|
||||
type revision struct {
|
||||
ver xlMetaV2ShallowVersion
|
||||
metaV uint8
|
||||
count int
|
||||
}
|
||||
keys := make([]*revisionKey, n)
|
||||
neutral := make([]*revisionKey, n)
|
||||
revisions := make(map[revisionKey]*revision)
|
||||
neutrals := make(map[revisionKey]*revision)
|
||||
absent := 0
|
||||
for i := range disks {
|
||||
if errs[i] != nil {
|
||||
if errors.Is(errs[i], errFileNotFound) || errors.Is(errs[i], errVolumeNotFound) {
|
||||
absent++
|
||||
}
|
||||
continue
|
||||
}
|
||||
var xl xlMetaV2
|
||||
if xl.LoadOrConvert(raws[i].Buf) != nil {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, ver := range xl.versions {
|
||||
if ver.header.VersionID != uv || ver.header.FreeVersion() {
|
||||
continue
|
||||
}
|
||||
key := versionRevision(ver, xl.metaV)
|
||||
keys[i], found = &key, true
|
||||
r := revisions[key]
|
||||
if r == nil {
|
||||
r = &revision{ver: ver, metaV: xl.metaV}
|
||||
revisions[key] = r
|
||||
}
|
||||
r.count++
|
||||
if nkey, ok := restoreNeutralRevision(ver, xl.metaV); ok {
|
||||
neutral[i] = &nkey
|
||||
if neutrals[nkey] == nil {
|
||||
neutrals[nkey] = &revision{ver: ver, metaV: xl.metaV}
|
||||
}
|
||||
neutrals[nkey].count++
|
||||
}
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
absent++
|
||||
}
|
||||
}
|
||||
accepted := func(revs map[revisionKey]*revision) (revisionKey, bool) {
|
||||
var best *revision
|
||||
var bestKey revisionKey
|
||||
for key, r := range revs {
|
||||
if best == nil || r.count > best.count {
|
||||
best, bestKey = r, key
|
||||
}
|
||||
}
|
||||
return bestKey, best != nil && best.count >= majority &&
|
||||
best.count >= er.versionReadQuorum(ctx, bucket, object, best.ver, best.metaV, make([]FileInfo, n), make([]error, n))
|
||||
}
|
||||
gen := restoreGeneration{drives: make([]FileInfo, n), disks: make([]StorageAPI, n), revs: slices.Clone(keys)}
|
||||
bestKey, ok := accepted(revisions)
|
||||
if !ok {
|
||||
if bestKey, ok = accepted(neutrals); !ok {
|
||||
if absent >= majority {
|
||||
return restoreGeneration{}, errFileVersionNotFound
|
||||
}
|
||||
return restoreGeneration{}, errErasureReadQuorum
|
||||
}
|
||||
keys, gen.split, gen.ongoing = neutral, true, make(map[string]bool)
|
||||
}
|
||||
for i := range disks {
|
||||
if keys[i] == nil || *keys[i] != bestKey {
|
||||
continue
|
||||
}
|
||||
fi, err := getFileInfo(raws[i].Buf, bucket, object, versionID, fileInfoOpts{Data: true})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
gen.drives[i], gen.disks[i] = fi, disks[i]
|
||||
if gen.split {
|
||||
if st, err := parseRestoreObjStatus(fi.Metadata[xhttp.AmzRestore]); err == nil && st.Ongoing() {
|
||||
gen.ongoing[fi.Metadata[restoreAttemptKey]] = true
|
||||
}
|
||||
}
|
||||
if !gen.fi.IsValid() {
|
||||
gen.fi = fi
|
||||
}
|
||||
}
|
||||
if !gen.fi.IsValid() && !gen.fi.Deleted {
|
||||
return restoreGeneration{}, errErasureReadQuorum
|
||||
}
|
||||
if gen.split {
|
||||
gen.fi.Metadata = maps.Clone(gen.fi.Metadata)
|
||||
for _, k := range restoreFieldKeys {
|
||||
delete(gen.fi.Metadata, k)
|
||||
}
|
||||
}
|
||||
return gen, nil
|
||||
}
|
||||
|
||||
// updateRestoreStatus rewrites the restore fields of one stored version. It
|
||||
// selects the authoritative generation of the version, see
|
||||
// readRestoreGeneration, patches only the restore fields onto exactly that
|
||||
// generation, keeping every other field and its ModTime, and writes it back
|
||||
// only to the drives that hold it. The caller holds the object lock. update
|
||||
// edits the metadata in place, or returns an error, errRestoreUnchanged to
|
||||
// write nothing; ongoing names the attempts whose ongoing marker the
|
||||
// generation holds. A failed read never removes the version, and a failed
|
||||
// write puts the generation it read back.
|
||||
func (er erasureObjects) updateRestoreStatus(ctx context.Context, bucket, object, versionID string, update func(oi ObjectInfo, meta map[string]string, ongoing map[string]bool) error) (ObjectInfo, error) {
|
||||
gen, err := er.readRestoreGeneration(ctx, bucket, object, versionID)
|
||||
if err != nil {
|
||||
return ObjectInfo{}, toObjectErr(err, bucket, object, versionID)
|
||||
}
|
||||
fi := gen.fi
|
||||
oi := fi.ToObjectInfo(bucket, object, false)
|
||||
if fi.Deleted {
|
||||
return oi, toObjectErr(errMethodNotAllowed, bucket, object)
|
||||
}
|
||||
ongoing := gen.ongoing
|
||||
if !gen.split && oi.RestoreOngoing {
|
||||
ongoing = map[string]bool{oi.UserDefined[restoreAttemptKey]: true}
|
||||
}
|
||||
meta := maps.Clone(fi.Metadata)
|
||||
if err := update(oi, meta, ongoing); err != nil {
|
||||
return oi, err
|
||||
}
|
||||
write := func(meta func(FileInfo) map[string]string) error {
|
||||
metaArr := make([]FileInfo, len(gen.drives))
|
||||
for i, d := range gen.drives {
|
||||
if gen.disks[i] == nil {
|
||||
continue
|
||||
}
|
||||
if !d.InlineData() {
|
||||
d.Data = nil
|
||||
}
|
||||
d.Metadata = meta(d)
|
||||
metaArr[i] = d
|
||||
}
|
||||
// The write assigns each drive its slot as erasure index: order the
|
||||
// drives by the version's distribution first.
|
||||
disks, metaArr := shuffleDisksAndPartsMetadata(gen.disks, metaArr, fi)
|
||||
_, err := writeUniqueFileInfo(ctx, disks, "", bucket, object, metaArr, fi.WriteQuorum(er.defaultWQuorum()))
|
||||
return err
|
||||
}
|
||||
if err := write(func(FileInfo) map[string]string { return meta }); err != nil {
|
||||
// Without the read generation back, its drives could hold two
|
||||
// revisions and no quorum for either. A split that stays differs
|
||||
// only in restore fields, which the next write or reset resolves.
|
||||
if rerr := write(func(d FileInfo) map[string]string { return d.Metadata }); rerr != nil {
|
||||
storageLogIf(ctx, fmt.Errorf("Unable to put back the restore status of %s/%s(%s): %w", bucket, object, versionID, rerr))
|
||||
}
|
||||
return oi, toObjectErr(err, bucket, object)
|
||||
}
|
||||
fi.Metadata = meta
|
||||
return fi.ToObjectInfo(bucket, object, false), nil
|
||||
}
|
||||
|
||||
// clearRestoreStatus makes a version retryable again while attempt a still
|
||||
// owns its ongoing marker. It never brings back an earlier completed status:
|
||||
// POST only starts a restore when that restored copy is gone.
|
||||
func (er erasureObjects) clearRestoreStatus(ctx context.Context, bucket, object, versionID string, a *restoreAttempt) error {
|
||||
_, err := er.updateRestoreStatus(ctx, bucket, object, versionID, func(oi ObjectInfo, meta map[string]string, ongoing map[string]bool) error {
|
||||
id := ""
|
||||
if a != nil {
|
||||
id = a.ID
|
||||
if !a.targets(oi) {
|
||||
return errRestoreUnchanged
|
||||
}
|
||||
}
|
||||
// Clear only this attempt's marker. Where drives disagree, another
|
||||
// attempt's marker may be the acknowledged one: leave that to it,
|
||||
// or to a later POST, which takes it over under the lock.
|
||||
if !ongoing[id] || len(ongoing) != 1 {
|
||||
return errRestoreUnchanged
|
||||
}
|
||||
delete(meta, xhttp.AmzRestore)
|
||||
delete(meta, xhttp.AmzRestoreExpiryDays)
|
||||
delete(meta, xhttp.AmzRestoreRequestDate)
|
||||
delete(meta, restoreAttemptKey)
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errRestoreUnchanged) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// restoreLock is the object lock that restores and their status updates
|
||||
// share: the lock every write of the object takes.
|
||||
func (z *erasureServerPools) restoreLock(bucket, object string) RWLocker {
|
||||
return z.NewObjectNSLock(bucket, object)
|
||||
}
|
||||
|
||||
// restoreRequestStatus is what a POST restore recorded on its version.
|
||||
type restoreRequestStatus struct {
|
||||
objInfo ObjectInfo
|
||||
// attempt is the restore that must rehydrate the version; nil when a
|
||||
// restored copy was still there and its expiry was renewed.
|
||||
attempt *restoreAttempt
|
||||
// owner is the restore lock of the version, held for attempt. The
|
||||
// caller hands it to the worker that runs attempt, or releases it.
|
||||
owner *restoreOwner
|
||||
// date is the request date recorded with attempt.
|
||||
date string
|
||||
}
|
||||
|
||||
// restoreRequest records a POST restore on target, the version that POST
|
||||
// resolved, on a fresh read under the object lock. The version must still
|
||||
// be target: a replacement under the same version ID is refused. A copy that
|
||||
// still holds its restored data is renewed. Otherwise the version is marked
|
||||
// ongoing for a new attempt, which also takes over a marker that no running
|
||||
// restore holds. Only the copy that is to be restored is written, so no copy
|
||||
// claims a restored copy it does not hold.
|
||||
//
|
||||
// It takes the restore lock R of the version before the object lock O. A
|
||||
// running restore holds R for its whole run, so a POST that cannot take R
|
||||
// within the takeover wait answers from the drives: in progress when they
|
||||
// show the version ongoing, and a retryable timeout otherwise, such as while
|
||||
// another POST of the version registers. ctx must outlive the request: a new
|
||||
// attempt keeps R in the returned status.
|
||||
func (z *erasureServerPools) restoreRequest(ctx context.Context, bucket, object string, opts ObjectOptions, target ObjectInfo) (status restoreRequestStatus, err error) {
|
||||
object = encodeDirObject(object)
|
||||
versionID := restoreVersionID(target.VersionID)
|
||||
owner, err := z.takeRestoreOwner(ctx, bucket, object, versionID, newDynamicTimeout(restoreTakeoverWait, restoreTakeoverWait))
|
||||
if err != nil {
|
||||
if errors.As(err, &OperationTimedOut{}) && ctx.Err() == nil {
|
||||
running, rerr := z.restoreRunning(ctx, bucket, object, versionID)
|
||||
switch {
|
||||
case rerr != nil:
|
||||
return restoreRequestStatus{}, rerr
|
||||
case running:
|
||||
return restoreRequestStatus{}, errRestoreInProgress
|
||||
}
|
||||
}
|
||||
return restoreRequestStatus{}, err
|
||||
}
|
||||
defer func() {
|
||||
if status.attempt == nil {
|
||||
owner.release()
|
||||
}
|
||||
}()
|
||||
lk := z.restoreLock(bucket, object)
|
||||
lkctx, err := lk.GetLock(owner.ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return restoreRequestStatus{}, err
|
||||
}
|
||||
ctx = lkctx.Context()
|
||||
defer lk.Unlock(lkctx)
|
||||
|
||||
expected := newRestoreAttempt(target)
|
||||
copies, err := z.objectPoolInfos(ctx, bucket, object, ObjectOptions{
|
||||
VersionID: versionID,
|
||||
Versioned: opts.Versioned,
|
||||
VersionSuspended: opts.VersionSuspended,
|
||||
Transition: opts.Transition,
|
||||
})
|
||||
if err != nil {
|
||||
return restoreRequestStatus{}, err
|
||||
}
|
||||
// Restore the copy reads serve first, outside pools being decommissioned,
|
||||
// as RestoreTransitionedObject does.
|
||||
pool := -1
|
||||
for _, c := range copies {
|
||||
if !z.IsSuspended(c.Index) {
|
||||
pool = c.Index
|
||||
break
|
||||
}
|
||||
}
|
||||
if pool < 0 {
|
||||
return restoreRequestStatus{}, VersionNotFound{Bucket: bucket, Object: decodeDirObject(object), VersionID: versionID}
|
||||
}
|
||||
|
||||
rreq := opts.Transition.RestoreRequest
|
||||
date := UTCNow().Format(http.TimeFormat)
|
||||
var attempt *restoreAttempt
|
||||
oi, err := z.serverPools[pool].getHashedSet(object).updateRestoreStatus(ctx, bucket, object, versionID, func(oi ObjectInfo, meta map[string]string, _ map[string]bool) error {
|
||||
if !expected.targets(oi) {
|
||||
// Not the version POST resolved, or no longer transitioned.
|
||||
return InvalidObjectState{Bucket: bucket, Object: decodeDirObject(object)}
|
||||
}
|
||||
attempt = nil
|
||||
meta[xhttp.AmzRestoreExpiryDays] = strconv.Itoa(rreq.Days)
|
||||
meta[xhttp.AmzRestoreRequestDate] = date
|
||||
if !oi.RestoreOngoing && !oi.RestoreExpires.IsZero() {
|
||||
// Cleanup removes a restored copy together with its header.
|
||||
meta[xhttp.AmzRestore] = completedRestoreObj(opts.Transition.RestoreExpiry).String()
|
||||
delete(meta, restoreAttemptKey)
|
||||
return nil
|
||||
}
|
||||
attempt = newRestoreAttempt(oi)
|
||||
meta[xhttp.AmzRestore] = ongoingRestoreObj().String()
|
||||
meta[restoreAttemptKey] = attempt.ID
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if attempt != nil {
|
||||
// The marker may have reached some drives, or all of them.
|
||||
z.recoverRestoreStatus(bucket, object, ObjectOptions{
|
||||
VersionID: attempt.VersionID,
|
||||
Transition: TransitionOptions{attempt: attempt},
|
||||
})
|
||||
}
|
||||
return restoreRequestStatus{}, err
|
||||
}
|
||||
if attempt == nil {
|
||||
return restoreRequestStatus{objInfo: oi}, nil
|
||||
}
|
||||
return restoreRequestStatus{objInfo: oi, attempt: attempt, owner: owner, date: date}, nil
|
||||
}
|
||||
|
||||
// restoreRunning reports, without the object lock and within the takeover
|
||||
// wait, whether the drives show a restore of the version ongoing in a pool.
|
||||
// An error means that this cannot be told.
|
||||
func (z *erasureServerPools) restoreRunning(ctx context.Context, bucket, object, versionID string) (bool, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, restoreTakeoverWait)
|
||||
defer cancel()
|
||||
for _, pool := range z.serverPools {
|
||||
gen, err := pool.getHashedSet(object).readRestoreGeneration(ctx, bucket, object, versionID)
|
||||
if errors.Is(err, errFileVersionNotFound) || errors.Is(err, errFileNotFound) {
|
||||
continue
|
||||
}
|
||||
if err == nil && ctx.Err() != nil {
|
||||
err = ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
return false, toObjectErr(err, bucket, object, versionID)
|
||||
}
|
||||
if len(gen.ongoing) > 0 || !gen.split && gen.fi.ToObjectInfo(bucket, object, false).RestoreOngoing {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// restorePoolIdx returns the pool whose copy the restore rehydrates: the one
|
||||
// POST marked for its attempt, or for a direct caller the existing version.
|
||||
func (z *erasureServerPools) restorePoolIdx(ctx context.Context, bucket, object string, opts ObjectOptions) (int, error) {
|
||||
a := opts.Transition.attempt
|
||||
if a == nil {
|
||||
// Restore an existing version, not the latest version's write location.
|
||||
lookupOpts := opts
|
||||
lookupOpts.MetadataChg = true
|
||||
idx, err := z.getPoolIdxExistingWithOpts(ctx, bucket, object, lookupOpts)
|
||||
if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
|
||||
return idx, restoreResetError{err}
|
||||
}
|
||||
return idx, err
|
||||
}
|
||||
found := false
|
||||
for idx, pool := range z.serverPools {
|
||||
gen, err := pool.getHashedSet(object).readRestoreGeneration(ctx, bucket, object, a.VersionID)
|
||||
if errors.Is(err, errFileVersionNotFound) || errors.Is(err, errFileNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return -1, restoreResetError{toObjectErr(err, bucket, object, a.VersionID)}
|
||||
}
|
||||
found = true
|
||||
if !z.IsSuspended(idx) && a.ownsGeneration(gen, bucket, object) {
|
||||
return idx, nil
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return -1, VersionNotFound{Bucket: bucket, Object: decodeDirObject(object), VersionID: a.VersionID}
|
||||
}
|
||||
return -1, errRestoreSuperseded
|
||||
}
|
||||
|
||||
// recoverRestoreStatus retries, in memory and for a bounded time, to clear the
|
||||
// ongoing marker that a failed restore left behind, so a later POST is not
|
||||
// refused. Each try takes the object lock afresh and clears the marker only
|
||||
// while it still belongs to this attempt.
|
||||
func (z *erasureServerPools) recoverRestoreStatus(bucket, object string, opts ObjectOptions) {
|
||||
go func() {
|
||||
var err error
|
||||
for _, delay := range restoreRecoveryDelays {
|
||||
select {
|
||||
case <-GlobalContext.Done():
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
if err = z.resetRestoreStatus(bucket, object, opts); err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
storageLogIf(GlobalContext, fmt.Errorf("Unable to clear the restore status of %s/%s(%s): %w", bucket, object, opts.VersionID, err))
|
||||
}()
|
||||
}
|
||||
|
||||
// resetRestoreStatus is one try of recoverRestoreStatus.
|
||||
func (z *erasureServerPools) resetRestoreStatus(bucket, object string, opts ObjectOptions) error {
|
||||
ctx, cancel := context.WithTimeout(GlobalContext, restoreCleanupTimeout)
|
||||
defer cancel()
|
||||
lk := z.restoreLock(bucket, object)
|
||||
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer lk.Unlock(lkctx)
|
||||
ctx = lkctx.Context()
|
||||
|
||||
a := opts.Transition.attempt
|
||||
versionID := opts.VersionID
|
||||
if a != nil {
|
||||
versionID = a.VersionID
|
||||
}
|
||||
for _, pool := range z.serverPools {
|
||||
err := pool.getHashedSet(object).clearRestoreStatus(ctx, bucket, object, versionID, a)
|
||||
if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) && !isErrMethodNotAllowed(err) && !isErrBucketNotFound(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/grid"
|
||||
)
|
||||
|
||||
// A native restore keeps its multipart upload private and marks the version
|
||||
// it rehydrates (restore-attempt.go). A server that predates this ignores
|
||||
// the marker: it would list, change, abort or complete the upload. Native
|
||||
// restore therefore runs only while every server of the cluster positively
|
||||
// reports that it understands the marker. A server that does not answer,
|
||||
// or does not know the question, as an older server does not, blocks it.
|
||||
|
||||
// nativeRestoreCapability is the native restore protocol this server speaks.
|
||||
const nativeRestoreCapability = "2"
|
||||
|
||||
const (
|
||||
peerRESTRestoreCapability = "restore"
|
||||
// restoreCapabilityTTL bounds how long a positive answer is reused while
|
||||
// no server reconnected.
|
||||
restoreCapabilityTTL = 10 * time.Second
|
||||
// restoreCapabilityTimeout bounds the question to each server.
|
||||
restoreCapabilityTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
var errNativeRestoreUnavailable = errors.New("native restore is unavailable during a rolling upgrade: not every server of the cluster supports it yet")
|
||||
|
||||
// restoreCapabilityHandler answers the question on this server.
|
||||
func restoreCapabilityHandler(*grid.MSS) (*grid.MSS, *grid.RemoteErr) {
|
||||
return grid.NewMSSWith(map[string]string{peerRESTRestoreCapability: nativeRestoreCapability}), nil
|
||||
}
|
||||
|
||||
// restoreCapabilityPeer is another server of the cluster.
|
||||
type restoreCapabilityPeer interface {
|
||||
// restoreConnection identifies the current connection to the server; it
|
||||
// changes when the server reconnects, as a restarted one does. ok is
|
||||
// false when the server is not connected.
|
||||
restoreConnection() (key string, ok bool)
|
||||
restoreCapability(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
type restorePeerClient struct{ *peerRESTClient }
|
||||
|
||||
func (c restorePeerClient) restoreConnection() (string, bool) {
|
||||
if c.peerRESTClient == nil || c.gridConn == nil {
|
||||
return "", false
|
||||
}
|
||||
conn := c.gridConn()
|
||||
if conn == nil {
|
||||
return "", false
|
||||
}
|
||||
st := conn.Stats()
|
||||
if st.Connected == 0 {
|
||||
return "", false
|
||||
}
|
||||
// Keys are compared by the position of their server.
|
||||
return fmt.Sprintf("%d/%d", st.LastConnectTime.UnixNano(), st.ReconnectCount), true
|
||||
}
|
||||
|
||||
func (c restorePeerClient) restoreCapability(ctx context.Context) (string, error) {
|
||||
conn := c.gridConn()
|
||||
if conn == nil {
|
||||
return "", errPeerOffline
|
||||
}
|
||||
resp, err := restoreCapabilityRPC.Call(ctx, conn, grid.NewMSS())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Get(peerRESTRestoreCapability), nil
|
||||
}
|
||||
|
||||
// restoreCapabilityGate decides whether native restore may run, caching a
|
||||
// positive answer for restoreCapabilityTTL while the connections to all
|
||||
// servers stay the same. A negative answer is not cached.
|
||||
type restoreCapabilityGate struct {
|
||||
peers func() []restoreCapabilityPeer
|
||||
|
||||
once sync.Once
|
||||
turn chan struct{} // one check at a time
|
||||
checked time.Time
|
||||
keys []string
|
||||
}
|
||||
|
||||
var globalRestoreCapability = &restoreCapabilityGate{peers: notificationRestorePeers}
|
||||
|
||||
func notificationRestorePeers() []restoreCapabilityPeer {
|
||||
if globalNotificationSys == nil {
|
||||
return nil
|
||||
}
|
||||
peers := make([]restoreCapabilityPeer, 0, len(globalNotificationSys.peerClients))
|
||||
for _, client := range globalNotificationSys.peerClients {
|
||||
peers = append(peers, restorePeerClient{client})
|
||||
}
|
||||
return peers
|
||||
}
|
||||
|
||||
// nativeRestoreAvailable returns errNativeRestoreUnavailable unless every
|
||||
// other server of the cluster supports native restore.
|
||||
func nativeRestoreAvailable(ctx context.Context) error {
|
||||
return globalRestoreCapability.check(ctx)
|
||||
}
|
||||
|
||||
func (g *restoreCapabilityGate) check(ctx context.Context) error {
|
||||
g.once.Do(func() { g.turn = make(chan struct{}, 1) })
|
||||
// One deadline covers the wait for another check and the questions.
|
||||
ctx, cancel := context.WithTimeout(ctx, restoreCapabilityTimeout)
|
||||
defer cancel()
|
||||
select {
|
||||
case g.turn <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return errNativeRestoreUnavailable
|
||||
}
|
||||
defer func() { <-g.turn }()
|
||||
|
||||
// Read the connections only now: one may have changed while this check
|
||||
// waited for another.
|
||||
peers := g.peers()
|
||||
connections := func() ([]string, bool) {
|
||||
keys := make([]string, len(peers))
|
||||
for i, p := range peers {
|
||||
key, ok := p.restoreConnection()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
keys[i] = key
|
||||
}
|
||||
return keys, true
|
||||
}
|
||||
keys, ok := connections()
|
||||
if !ok {
|
||||
g.checked, g.keys = time.Time{}, nil
|
||||
return errNativeRestoreUnavailable
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return errNativeRestoreUnavailable
|
||||
}
|
||||
if !g.checked.IsZero() && time.Since(g.checked) < restoreCapabilityTTL && slices.Equal(keys, g.keys) {
|
||||
return nil
|
||||
}
|
||||
g.checked, g.keys = time.Time{}, nil
|
||||
answers := make([]bool, len(peers))
|
||||
var wg sync.WaitGroup
|
||||
for i, p := range peers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
version, err := p.restoreCapability(ctx)
|
||||
answers[i] = err == nil && version == nativeRestoreCapability
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
// An answer counts only for the connection it was asked on: a server
|
||||
// that reconnected meanwhile may be another binary.
|
||||
after, ok := connections()
|
||||
if !ok || !slices.Equal(keys, after) || slices.Contains(answers, false) || ctx.Err() != nil {
|
||||
return errNativeRestoreUnavailable
|
||||
}
|
||||
g.checked, g.keys = time.Now(), keys
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
"github.com/minio/minio/internal/grid"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
// A server answers the capability question over the grid. One that does not
|
||||
// know it, as an older server, or answers something else, blocks native
|
||||
// restore, and so does one that is not connected.
|
||||
func TestNativeRestoreCapabilityGrid(t *testing.T) {
|
||||
tg, err := grid.SetupTestGrid(4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(tg.Cleanup)
|
||||
if err := restoreCapabilityRPC.Register(tg.Managers[1], restoreCapabilityHandler); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Managers[2] never registers the handler.
|
||||
if err := restoreCapabilityRPC.Register(tg.Managers[3], func(*grid.MSS) (*grid.MSS, *grid.RemoteErr) {
|
||||
return grid.NewMSSWith(map[string]string{peerRESTRestoreCapability: "0"}), nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
peer := func(i int) restoreCapabilityPeer {
|
||||
return restorePeerClient{&peerRESTClient{gridConn: func() *grid.Connection { return tg.Managers[0].Connection(tg.Hosts[i]) }}}
|
||||
}
|
||||
offline := restorePeerClient{&peerRESTClient{gridConn: func() *grid.Connection { return nil }}}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
peers []restoreCapabilityPeer
|
||||
ok bool
|
||||
}{
|
||||
{"single-server", nil, true},
|
||||
{"all-capable", []restoreCapabilityPeer{peer(1)}, true},
|
||||
{"older-server", []restoreCapabilityPeer{peer(1), peer(2)}, false},
|
||||
{"other-answer", []restoreCapabilityPeer{peer(1), peer(3)}, false},
|
||||
{"offline", []restoreCapabilityPeer{peer(1), offline}, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
g := &restoreCapabilityGate{peers: func() []restoreCapabilityPeer { return tc.peers }}
|
||||
if err := g.check(t.Context()); (err == nil) != tc.ok {
|
||||
t.Errorf("capability: %v, want available=%v", err, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// restoreCapabilityFake is a server whose connection and answer a test sets.
|
||||
type restoreCapabilityFake struct {
|
||||
capable atomic.Bool
|
||||
connected atomic.Bool
|
||||
conn atomic.Int64
|
||||
asked atomic.Int64
|
||||
}
|
||||
|
||||
func newRestoreCapabilityFake() *restoreCapabilityFake {
|
||||
p := &restoreCapabilityFake{}
|
||||
p.capable.Store(true)
|
||||
p.connected.Store(true)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *restoreCapabilityFake) restoreConnection() (string, bool) {
|
||||
return strings.Repeat("c", int(p.conn.Load())), p.connected.Load()
|
||||
}
|
||||
|
||||
func (p *restoreCapabilityFake) restoreCapability(context.Context) (string, error) {
|
||||
p.asked.Add(1)
|
||||
if !p.capable.Load() {
|
||||
return "", errors.New("Invalid Handler for type: RestoreCapability")
|
||||
}
|
||||
return nativeRestoreCapability, nil
|
||||
}
|
||||
|
||||
// A positive answer is reused while the connections stay the same; a
|
||||
// reconnect asks again, a disconnected server blocks without being asked,
|
||||
// and a negative answer is not reused.
|
||||
func TestNativeRestoreCapabilityCache(t *testing.T) {
|
||||
p := newRestoreCapabilityFake()
|
||||
g := &restoreCapabilityGate{peers: func() []restoreCapabilityPeer { return []restoreCapabilityPeer{p} }}
|
||||
for range 3 {
|
||||
if err := g.check(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if n := p.asked.Load(); n != 1 {
|
||||
t.Errorf("asked %d times while connected and capable, want 1", n)
|
||||
}
|
||||
// The server restarts with an older binary and reconnects.
|
||||
p.capable.Store(false)
|
||||
p.conn.Add(1)
|
||||
if err := g.check(t.Context()); !errors.Is(err, errNativeRestoreUnavailable) {
|
||||
t.Errorf("after a reconnect to an older server: %v", err)
|
||||
}
|
||||
p.capable.Store(true)
|
||||
if err := g.check(t.Context()); err != nil {
|
||||
t.Errorf("a negative answer was reused: %v", err)
|
||||
}
|
||||
asked := p.asked.Load()
|
||||
p.connected.Store(false)
|
||||
if err := g.check(t.Context()); !errors.Is(err, errNativeRestoreUnavailable) || p.asked.Load() != asked {
|
||||
t.Errorf("disconnected server: %v, asked %d -> %d", err, asked, p.asked.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// During a rolling upgrade POST restore answers 503 and writes nothing, and
|
||||
// a restore queued before it creates no upload and leaves the version
|
||||
// retryable.
|
||||
func TestNativeRestoreRollingUpgradeGate(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi, _ := restoreIsolationSeed(t, r, 0, false, true)
|
||||
p := newRestoreCapabilityFake()
|
||||
previous := globalRestoreCapability
|
||||
globalRestoreCapability = &restoreCapabilityGate{peers: func() []restoreCapabilityPeer { return []restoreCapabilityPeer{p} }}
|
||||
t.Cleanup(func() { globalRestoreCapability = previous })
|
||||
|
||||
p.capable.Store(false)
|
||||
before := r.fileInfo(oi.VersionID)
|
||||
layer := newRestoreIsolationLayer(r)
|
||||
rec := restoreIsolationPOST(t, r, layer, oi.VersionID)
|
||||
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "rolling upgrade") {
|
||||
t.Errorf("POST during a rolling upgrade: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if after := r.fileInfo(oi.VersionID); !reflect.DeepEqual(before.Metadata, after.Metadata) {
|
||||
t.Error("refused POST changed the version")
|
||||
}
|
||||
select {
|
||||
case err := <-layer.done:
|
||||
t.Fatalf("refused POST started a restore: %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
p.capable.Store(true)
|
||||
release := make(chan struct{})
|
||||
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("POST: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// A server is replaced by an older binary while the restore is queued.
|
||||
p.capable.Store(false)
|
||||
p.conn.Add(1)
|
||||
close(release)
|
||||
if err := layer.wait(t); !errors.Is(err, errNativeRestoreUnavailable) {
|
||||
t.Fatalf("queued restore during a rolling upgrade: %v", err)
|
||||
}
|
||||
if uploads := restoreIsolationUploads(t, r, 0); len(uploads) != 0 {
|
||||
t.Errorf("queued restore created an upload during a rolling upgrade: %v", uploads)
|
||||
}
|
||||
info, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
|
||||
if err != nil || info.RestoreOngoing || !info.IsRemote() || info.UserDefined[xhttp.AmzRestore] != "" {
|
||||
t.Errorf("version after a refused queued restore: %q, %v", info.UserDefined[xhttp.AmzRestore], err)
|
||||
}
|
||||
}
|
||||
|
||||
// restoreCapabilityHeld is a fake server whose answer a test holds.
|
||||
type restoreCapabilityHeld struct {
|
||||
restoreCapabilityFake
|
||||
asking chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (p *restoreCapabilityHeld) restoreCapability(ctx context.Context) (string, error) {
|
||||
v, err := p.restoreCapabilityFake.restoreCapability(ctx)
|
||||
p.asking <- struct{}{}
|
||||
select {
|
||||
case <-p.release:
|
||||
return v, err
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// A check that waits for another reads the connections once its turn
|
||||
// comes, an answer counts only if its server did not reconnect meanwhile,
|
||||
// and a waiting check ends with its context.
|
||||
func TestNativeRestoreCapabilityRevalidates(t *testing.T) {
|
||||
t.Run("reconnect-during-check", func(t *testing.T) {
|
||||
held := func() *restoreCapabilityHeld {
|
||||
p := &restoreCapabilityHeld{asking: make(chan struct{}, 4), release: make(chan struct{})}
|
||||
p.capable.Store(true)
|
||||
p.connected.Store(true)
|
||||
return p
|
||||
}
|
||||
fast, slow := held(), held()
|
||||
g := &restoreCapabilityGate{peers: func() []restoreCapabilityPeer { return []restoreCapabilityPeer{fast, slow} }}
|
||||
first := make(chan error, 1)
|
||||
go func() { first <- g.check(t.Context()) }()
|
||||
<-fast.asking
|
||||
<-slow.asking
|
||||
waiting := make(chan error, 1)
|
||||
go func() { waiting <- g.check(t.Context()) }()
|
||||
// Both answered "capable"; then fast reconnects as an older server.
|
||||
fast.capable.Store(false)
|
||||
fast.conn.Add(1)
|
||||
close(fast.release)
|
||||
close(slow.release)
|
||||
if err := <-first; !errors.Is(err, errNativeRestoreUnavailable) {
|
||||
t.Errorf("check whose server reconnected while it asked: %v", err)
|
||||
}
|
||||
if err := <-waiting; !errors.Is(err, errNativeRestoreUnavailable) {
|
||||
t.Errorf("waiting check accepted a reconnected older server: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("canceled-cached", func(t *testing.T) {
|
||||
p := newRestoreCapabilityFake()
|
||||
g := &restoreCapabilityGate{peers: func() []restoreCapabilityPeer { return []restoreCapabilityPeer{p} }}
|
||||
if err := g.check(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
if err := g.check(ctx); err == nil {
|
||||
t.Error("a cached answer accepted an ended context")
|
||||
}
|
||||
})
|
||||
t.Run("canceled-wait", func(t *testing.T) {
|
||||
slow := &restoreCapabilityHeld{restoreCapabilityFake: restoreCapabilityFake{}, asking: make(chan struct{}, 4), release: make(chan struct{})}
|
||||
slow.capable.Store(true)
|
||||
slow.connected.Store(true)
|
||||
defer close(slow.release)
|
||||
g := &restoreCapabilityGate{peers: func() []restoreCapabilityPeer { return []restoreCapabilityPeer{slow} }}
|
||||
go g.check(t.Context())
|
||||
<-slow.asking
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
waiting := make(chan error, 1)
|
||||
go func() { waiting <- g.check(ctx) }()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-waiting:
|
||||
if err == nil {
|
||||
t.Error("canceled check succeeded")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("canceled check kept waiting for another check")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// 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"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
"github.com/minio/minio/internal/event"
|
||||
"github.com/minio/minio/internal/hash"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/kms"
|
||||
"github.com/minio/minio/internal/pubsub"
|
||||
"github.com/minio/mux"
|
||||
)
|
||||
|
||||
// Exercise the stored representation produced by real S3 uploads. Native
|
||||
// restore copies encoded bytes, but must retain logical sizes and identity.
|
||||
func TestNativeRestoreEncodedObjects(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
for _, encoding := range []string{"plain", "compressed", "ssec", "s3-compressed"} {
|
||||
for _, layout := range []string{"single", "multipart", "sparse-one-part"} {
|
||||
t.Run(fmt.Sprintf("pools=%d/%s/%s", pools, encoding, layout), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
r.object = "restore-encoded.txt"
|
||||
previousKMS, previousTLS := GlobalKMS, globalIsTLS
|
||||
t.Cleanup(func() { GlobalKMS, globalIsTLS = previousKMS, previousTLS })
|
||||
globalIsTLS = true
|
||||
headers := make(map[string]string)
|
||||
readHeaders := make(map[string]string)
|
||||
if encoding == "compressed" || encoding == "s3-compressed" {
|
||||
enableAttributesPartsCompression(t)
|
||||
}
|
||||
if encoding == "ssec" {
|
||||
headers = ssecTestHeaders(0x53)
|
||||
readHeaders = ssecTestHeaders(0x53)
|
||||
}
|
||||
if encoding == "s3-compressed" {
|
||||
var err error
|
||||
GlobalKMS, err = kms.NewBuiltin("restore-test-key", bytes.Repeat([]byte{0x59}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headers[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionAES
|
||||
}
|
||||
previousObjectAPI := newObjectLayerFn()
|
||||
t.Cleanup(func() { setObjectLayer(previousObjectAPI) })
|
||||
router := initTestAPIEndPoints(r.z, []string{"NewMultipart", "PutObjectPart", "CompleteMultipart", "PutObject", "GetObject"})
|
||||
bodies := [][]byte{bytes.Repeat([]byte("encoded-restore-"), compMinIndexSize/16+1)}
|
||||
partNumbers := []int{1}
|
||||
switch layout {
|
||||
case "multipart":
|
||||
bodies = append(bodies, []byte("last encoded part"))
|
||||
partNumbers = []int{1, 2}
|
||||
case "sparse-one-part":
|
||||
partNumbers = []int{5}
|
||||
}
|
||||
want := bytes.Join(bodies, nil)
|
||||
if layout == "single" {
|
||||
headers[xhttp.AmzChecksumCRC32] = mustChecksum(t, hash.ChecksumCRC32, want)
|
||||
putCopyChecksumSource(t, router, globalActiveCred, r.bucket, r.object, want, headers)
|
||||
} else {
|
||||
attributesPartsUpload(t, router, globalActiveCred, r.bucket, r.object, headers, bodies, partNumbers)
|
||||
}
|
||||
before, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before.IsCompressed() != (encoding == "compressed" || encoding == "s3-compressed") {
|
||||
t.Fatalf("unexpected compression state: %v", before.IsCompressed())
|
||||
}
|
||||
if before.IsCompressed() && len(before.Parts[0].Index) == 0 {
|
||||
t.Fatal("fixture must have a compression index")
|
||||
}
|
||||
if layout == "single" && len(before.Checksum) == 0 {
|
||||
t.Fatal("fixture must have a stored checksum")
|
||||
}
|
||||
objectURL := getGetObjectURL("", r.bucket, r.object) + "?versionId=" + url.QueryEscape(before.VersionID)
|
||||
beforeGet := attributesPartsSignedRequest(t, router, globalActiveCred, http.MethodGet, objectURL, nil, readHeaders)
|
||||
if beforeGet.Code != http.StatusOK || !bytes.Equal(beforeGet.Body.Bytes(), want) {
|
||||
t.Fatalf("fixture GET: %d bytes=%d want=%d", beforeGet.Code, beforeGet.Body.Len(), len(want))
|
||||
}
|
||||
beforeETags := beforeGet.Header()[xhttp.ETag]
|
||||
if len(beforeETags) != 1 || beforeETags[0] == "" {
|
||||
t.Fatal("fixture GET must expose a nonempty ETag")
|
||||
}
|
||||
beforeRaw := nativeRestoreSnapshot(t, r)
|
||||
topts := r.opts(before.VersionID)
|
||||
topts.MTime = before.ModTime
|
||||
topts.Transition = TransitionOptions{Tier: "WARM", ETag: before.ETag}
|
||||
if err := r.z.TransitionObject(t.Context(), r.bucket, r.object, topts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
observed := nativeRestoreHTTPObserver{r.z, make(chan nativeRestoreHTTPResult, 1)}
|
||||
api := objectAPIHandlers{ObjectAPI: func() ObjectLayer { return observed }}
|
||||
events, done := make(chan event.Event, 1), make(chan struct{})
|
||||
defer close(done)
|
||||
if err := globalHTTPListen.Subscribe(pubsub.MaskFromMaskable(event.ObjectRestoreCompleted), events, done, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restoreXML := []byte(`<RestoreRequest xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Days>1</Days></RestoreRequest>`)
|
||||
req, err := newTestSignedRequestV4(http.MethodPost, objectURL+"&restore", int64(len(restoreXML)), bytes.NewReader(restoreXML), globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := req.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = mux.SetURLVars(req, map[string]string{"bucket": r.bucket, "object": r.object})
|
||||
rec := httptest.NewRecorder()
|
||||
api.PostRestoreObjectHandler(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("restore POST: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
select {
|
||||
case result := <-observed.done:
|
||||
if result.err != nil {
|
||||
t.Fatalf("background restore: %v", result.err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("restore did not finish")
|
||||
}
|
||||
select {
|
||||
case <-events:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("restore completion event missing")
|
||||
}
|
||||
after, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(before.VersionID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.ETag != before.ETag || after.Size != before.Size || !after.ModTime.Equal(before.ModTime) {
|
||||
t.Error("restore changed content identity")
|
||||
}
|
||||
afterRaw := nativeRestoreSnapshot(t, r)
|
||||
for pool := range beforeRaw {
|
||||
if len(afterRaw[pool]) != len(beforeRaw[pool]) {
|
||||
t.Fatalf("pool %d version count changed", pool)
|
||||
}
|
||||
for i, fi := range beforeRaw[pool] {
|
||||
if !bytes.Equal(fi.Checksum, afterRaw[pool][i].Checksum) {
|
||||
t.Error("restore lost stored checksum")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(after.Parts) != len(before.Parts) {
|
||||
t.Fatal("restore changed part count")
|
||||
}
|
||||
for i, part := range after.Parts {
|
||||
old := before.Parts[i]
|
||||
if part.Number != old.Number || part.Size != old.Size || part.ActualSize != old.ActualSize || !bytes.Equal(part.Index, old.Index) {
|
||||
t.Errorf("part %d layout or compression index changed", i)
|
||||
}
|
||||
}
|
||||
tier := globalTierConfigMgr.drivercache["WARM"].(*restoreTestTier)
|
||||
reads := tier.gets.Load()
|
||||
got := attributesPartsSignedRequest(t, router, globalActiveCred, http.MethodGet, objectURL, nil, readHeaders)
|
||||
if got.Code != http.StatusOK || !bytes.Equal(got.Body.Bytes(), want) {
|
||||
t.Errorf("restored GET: %d bytes=%d want=%d", got.Code, got.Body.Len(), len(want))
|
||||
}
|
||||
afterETags := got.Header()[xhttp.ETag]
|
||||
if len(afterETags) != 1 || afterETags[0] == "" || afterETags[0] != beforeETags[0] {
|
||||
t.Error("restore changed client ETag")
|
||||
}
|
||||
for i, number := range partNumbers {
|
||||
part := attributesPartsSignedRequest(t, router, globalActiveCred, http.MethodGet, objectURL+fmt.Sprintf("&partNumber=%d", number), nil, readHeaders)
|
||||
if part.Code != http.StatusPartialContent || !bytes.Equal(part.Body.Bytes(), bodies[i]) {
|
||||
t.Errorf("restored part %d: %d bytes=%d want=%d", number, part.Code, part.Body.Len(), len(bodies[i]))
|
||||
}
|
||||
}
|
||||
if tier.gets.Load() != reads {
|
||||
t.Error("restored GET still used remote tier")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// XL metadata predating compression has no ActualSize. Conversion and healing
|
||||
// preserve those zero values; a plain multipart restore must derive the size.
|
||||
func TestNativeRestoreLegacyPartSizes(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, "")
|
||||
oi, body := nativeRestoreSeed(t, r, 0, true, true)
|
||||
for _, disk := range r.z.serverPools[0].getHashedSet(r.object).getDisks() {
|
||||
fi, err := disk.ReadVersion(t.Context(), r.bucket, r.bucket, r.object, oi.VersionID, ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range fi.Parts {
|
||||
fi.Parts[i].ActualSize = 0
|
||||
}
|
||||
delete(fi.Metadata, ReservedMetadataPrefix+"actual-size")
|
||||
if err := disk.WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
before, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, part := range before.Parts {
|
||||
if part.ActualSize != 0 {
|
||||
t.Fatal("legacy fixture was normalized")
|
||||
}
|
||||
}
|
||||
_, got, err := r.read(oi.VersionID)
|
||||
if err != nil || !bytes.Equal(got, body) {
|
||||
t.Fatalf("legacy fixture read: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tier := globalTierConfigMgr.drivercache["WARM"].(*restoreTestTier)
|
||||
reads := tier.gets.Load()
|
||||
_, got, err = r.read(oi.VersionID)
|
||||
if err != nil || !bytes.Equal(got, body) {
|
||||
t.Fatalf("legacy restored read: %v", err)
|
||||
}
|
||||
if tier.gets.Load() != reads {
|
||||
t.Error("legacy restored read used the remote tier")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
// 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"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
// restoreGenerationDisk fails metadata writes or updates of the object, or
|
||||
// metadata reads of uploads, while its toggles are set.
|
||||
type restoreGenerationDisk struct {
|
||||
StorageAPI
|
||||
bucket, object string
|
||||
write, update, uploadRead *atomic.Bool
|
||||
}
|
||||
|
||||
func (d restoreGenerationDisk) WriteMetadata(ctx context.Context, orig, vol, path string, fi FileInfo) error {
|
||||
if d.write != nil && d.write.Load() && vol == d.bucket && path == d.object {
|
||||
return errFaultyDisk
|
||||
}
|
||||
return d.StorageAPI.WriteMetadata(ctx, orig, vol, path, fi)
|
||||
}
|
||||
|
||||
func (d restoreGenerationDisk) UpdateMetadata(ctx context.Context, vol, path string, fi FileInfo, opts UpdateMetadataOpts) error {
|
||||
if d.update != nil && d.update.Load() && vol == d.bucket && path == d.object {
|
||||
return errFaultyDisk
|
||||
}
|
||||
return d.StorageAPI.UpdateMetadata(ctx, vol, path, fi, opts)
|
||||
}
|
||||
|
||||
func (d restoreGenerationDisk) ReadVersion(ctx context.Context, orig, vol, path, version string, opts ReadOptions) (FileInfo, error) {
|
||||
if d.uploadRead != nil && d.uploadRead.Load() && vol == minioMetaMultipartBucket {
|
||||
return FileInfo{}, errFaultyDisk
|
||||
}
|
||||
return d.StorageAPI.ReadVersion(ctx, orig, vol, path, version, opts)
|
||||
}
|
||||
|
||||
// restoreGenerationOffline makes a drive's object metadata unreadable and
|
||||
// unwritable while set, its uploads still readable.
|
||||
type restoreGenerationOffline struct {
|
||||
StorageAPI
|
||||
bucket, object string
|
||||
fail *atomic.Bool
|
||||
}
|
||||
|
||||
func (d restoreGenerationOffline) ReadXL(ctx context.Context, bucket, object string, data bool) (RawFileInfo, error) {
|
||||
if d.fail.Load() && bucket == d.bucket && object == d.object {
|
||||
return RawFileInfo{}, errFaultyDisk
|
||||
}
|
||||
return d.StorageAPI.ReadXL(ctx, bucket, object, data)
|
||||
}
|
||||
|
||||
func (d restoreGenerationOffline) ReadVersion(ctx context.Context, orig, bucket, object, version string, opts ReadOptions) (FileInfo, error) {
|
||||
if d.fail.Load() && bucket == d.bucket && object == d.object {
|
||||
return FileInfo{}, errFaultyDisk
|
||||
}
|
||||
return d.StorageAPI.ReadVersion(ctx, orig, bucket, object, version, opts)
|
||||
}
|
||||
|
||||
func (d restoreGenerationOffline) WriteMetadata(ctx context.Context, orig, bucket, object string, fi FileInfo) error {
|
||||
if d.fail.Load() && bucket == d.bucket && object == d.object {
|
||||
return errFaultyDisk
|
||||
}
|
||||
return d.StorageAPI.WriteMetadata(ctx, orig, bucket, object, fi)
|
||||
}
|
||||
|
||||
// restoreGenerationMetadata returns the version's metadata on each drive.
|
||||
func restoreGenerationMetadata(t *testing.T, disks []StorageAPI, r *restoreTest, versionID string) []map[string]string {
|
||||
t.Helper()
|
||||
metas := make([]map[string]string, len(disks))
|
||||
for i, d := range disks {
|
||||
fi, err := d.ReadVersion(t.Context(), "", r.bucket, r.object, versionID, ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metas[i] = fi.Metadata
|
||||
}
|
||||
return metas
|
||||
}
|
||||
|
||||
// An Object Lock update acknowledged on a quorum, which one drive missed,
|
||||
// survives POST restore: POST patches the restore fields onto the quorum's
|
||||
// generation and never writes the stale drive's metadata anywhere.
|
||||
func TestRestoreStatusUsesQuorumGeneration(t *testing.T) {
|
||||
for _, kind := range []string{"legal-hold", "retention"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
enableBucketObjectLock(t, r.bucket)
|
||||
oi, _ := nativeRestoreSeed(t, r, 0, false, false)
|
||||
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initial := []byte(`<LegalHold xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>OFF</Status></LegalHold>`)
|
||||
updated := []byte(`<LegalHold xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>ON</Status></LegalHold>`)
|
||||
key := strings.ToLower(xhttp.AmzObjectLockLegalHold)
|
||||
if kind == "retention" {
|
||||
initial = retentionBody("COMPLIANCE", UTCNow().Add(24*time.Hour))
|
||||
updated = retentionBody("COMPLIANCE", UTCNow().Add(48*time.Hour))
|
||||
key = strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)
|
||||
}
|
||||
if rec := nativeRestoreLockRequest(t.Context(), t, r, oi.VersionID, kind, initial); rec.Code != http.StatusOK {
|
||||
t.Fatal(rec.Body.String())
|
||||
}
|
||||
er := r.z.serverPools[0].getHashedSet(r.object)
|
||||
disks := er.getDisks()
|
||||
var fail atomic.Bool
|
||||
wrapped := append([]StorageAPI(nil), disks...)
|
||||
wrapped[0] = restoreGenerationDisk{StorageAPI: disks[0], bucket: r.bucket, object: r.object, update: &fail}
|
||||
er.getDisks = func() []StorageAPI { return wrapped }
|
||||
fail.Store(true)
|
||||
if rec := nativeRestoreLockRequest(t.Context(), t, r, oi.VersionID, kind, updated); rec.Code != http.StatusOK {
|
||||
t.Fatalf("protection update: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
fail.Store(false)
|
||||
before := restoreGenerationMetadata(t, disks, r, oi.VersionID)
|
||||
acknowledged := before[1][key]
|
||||
if before[0][key] == acknowledged {
|
||||
t.Fatal("fixture: drive 0 did not miss the update")
|
||||
}
|
||||
for i := 1; i < len(disks); i++ {
|
||||
if before[i][key] != acknowledged {
|
||||
t.Fatal("fixture: the update did not reach the other drives")
|
||||
}
|
||||
}
|
||||
if rec := restoreIsolationPOST(t, r, r.z, oi.VersionID); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
after := restoreGenerationMetadata(t, disks, r, oi.VersionID)
|
||||
for i := 1; i < len(disks); i++ {
|
||||
if after[i][key] != acknowledged {
|
||||
t.Errorf("drive %d: POST wrote %q over the acknowledged %q", i, after[i][key], acknowledged)
|
||||
}
|
||||
if after[i][xhttp.AmzRestoreRequestDate] == before[i][xhttp.AmzRestoreRequestDate] {
|
||||
t.Errorf("drive %d: POST did not record its request", i)
|
||||
}
|
||||
}
|
||||
// The stale drive is left to heal; reads that pick it are not
|
||||
// changed here.
|
||||
if after[0][key] != before[0][key] || after[0][xhttp.AmzRestoreRequestDate] != before[0][xhttp.AmzRestoreRequestDate] {
|
||||
t.Error("POST wrote the stale drive instead of leaving it to heal")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The attempt is part of a version's metadata generation. A POST whose
|
||||
// marker reached all drives but one is acknowledged: its restore owns the
|
||||
// version, and the reset of an older attempt leaves the newer marker alone.
|
||||
func TestRestoreAttemptGenerationFencing(t *testing.T) {
|
||||
for _, mode := range []string{"worker", "old-reset", "degraded-old-reset"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi, body := restoreIsolationSeed(t, r, 0, false, false)
|
||||
er := r.z.serverPools[0].getHashedSet(r.object)
|
||||
disks := er.getDisks()
|
||||
var fail, offline atomic.Bool
|
||||
wrapped := append([]StorageAPI(nil), disks...)
|
||||
wrapped[0] = restoreGenerationDisk{StorageAPI: disks[0], bucket: r.bucket, object: r.object, write: &fail}
|
||||
wrapped[1] = restoreGenerationOffline{StorageAPI: disks[1], bucket: r.bucket, object: r.object, fail: &offline}
|
||||
er.getDisks = func() []StorageAPI { return wrapped }
|
||||
captured := make(chan ObjectOptions, 2)
|
||||
layer := newRestoreIsolationLayer(r)
|
||||
layer.restore = func(_ context.Context, _, _ string, opts ObjectOptions) error {
|
||||
captured <- opts
|
||||
return nil
|
||||
}
|
||||
var older ObjectOptions
|
||||
if mode != "worker" {
|
||||
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
|
||||
t.Fatal(rec.Body.String())
|
||||
}
|
||||
older = <-captured
|
||||
layer.wait(t)
|
||||
}
|
||||
fail.Store(true)
|
||||
rec := restoreIsolationPOST(t, r, layer, oi.VersionID)
|
||||
fail.Store(false)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST on all drives but one: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
newer := <-captured
|
||||
layer.wait(t)
|
||||
id := newer.Transition.attempt.ID
|
||||
if mode == "worker" {
|
||||
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, newer); err != nil {
|
||||
t.Fatalf("restore of the acknowledged attempt: %v", err)
|
||||
}
|
||||
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
||||
t.Fatalf("restored contents: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if mode == "degraded-old-reset" {
|
||||
// One drive of the newer attempt is unreadable: the drives
|
||||
// that answer no longer show which attempt is acknowledged.
|
||||
offline.Store(true)
|
||||
}
|
||||
if err := r.z.resetRestoreStatus(r.bucket, r.object, older); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
offline.Store(false)
|
||||
after := restoreGenerationMetadata(t, disks, r, oi.VersionID)
|
||||
for i := 1; i < len(disks); i++ {
|
||||
if after[i][restoreAttemptKey] != id || !strings.Contains(after[i][xhttp.AmzRestore], "true") {
|
||||
t.Errorf("drive %d: the older attempt's reset cleared the newer marker: %q %q", i, after[i][restoreAttemptKey], after[i][xhttp.AmzRestore])
|
||||
}
|
||||
}
|
||||
if after[0][restoreAttemptKey] != older.Transition.attempt.ID {
|
||||
t.Error("the reset wrote the stale drive")
|
||||
}
|
||||
// The newer attempt still owns the version and restores it.
|
||||
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, newer); err != nil {
|
||||
t.Fatalf("restore of the newer attempt after the older reset: %v", err)
|
||||
}
|
||||
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
||||
t.Fatalf("restored contents: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// POST restores the version it resolved with that version's identity. A
|
||||
// replacement under the same version ID between its read and its status
|
||||
// write is refused, and nothing restores it.
|
||||
func TestRestorePOSTRejectsReplacedTarget(t *testing.T) {
|
||||
for _, state := range []versioning.State{"", versioning.Suspended} {
|
||||
t.Run(fmt.Sprintf("versioning=%q", state), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, state)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi, _ := restoreIsolationSeed(t, r, 0, true, false)
|
||||
layer := newRestoreIsolationLayer(r)
|
||||
var replacement ObjectInfo
|
||||
layer.afterRead = func() {
|
||||
data := []byte("replacement after POST resolved its target")
|
||||
var err error
|
||||
replacement, err = r.z.PutObject(t.Context(), r.bucket, r.object,
|
||||
mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{VersionSuspended: state == versioning.Suspended})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts := r.opts(nullVersionID)
|
||||
opts.MTime = replacement.ModTime
|
||||
opts.Transition = TransitionOptions{Tier: "WARM", ETag: replacement.ETag}
|
||||
if err := r.z.serverPools[0].TransitionObject(t.Context(), r.bucket, r.object, opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if rec := restoreIsolationPOST(t, r, layer, ""); rec.Code < 400 {
|
||||
layer.wait(t)
|
||||
t.Errorf("POST accepted a replacement of its target: %d", rec.Code)
|
||||
}
|
||||
after, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(nullVersionID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.ETag != replacement.ETag || after.UserDefined[xhttp.AmzRestore] != "" || after.ETag == oi.ETag {
|
||||
t.Errorf("replacement %q changed by POST: etag %q restore %q", replacement.ETag, after.ETag, after.UserDefined[xhttp.AmzRestore])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A POST on a version whose restore holds the object lock answers within
|
||||
// the two-second takeover bound: its first read does not wait for the lock.
|
||||
func TestRestorePOSTOngoingBound(t *testing.T) {
|
||||
for _, mode := range []string{"pools=1", "pools=2", "stale-minority", "failed-activity-read"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
pools := 1
|
||||
if mode == "pools=2" {
|
||||
pools = 2
|
||||
}
|
||||
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi, _ := restoreIsolationSeed(t, r, 0, false, false)
|
||||
tier := newRestoreIsolationGateTier(0, nil)
|
||||
globalTierConfigMgr.drivercache["WARM"] = tier
|
||||
defer tier.unblock()
|
||||
var fail atomic.Bool
|
||||
if mode == "stale-minority" {
|
||||
// The first read may pick a drive that missed the marker.
|
||||
er := r.z.serverPools[0].getHashedSet(r.object)
|
||||
disks := er.getDisks()
|
||||
wrapped := append([]StorageAPI(nil), disks...)
|
||||
wrapped[0] = restoreGenerationDisk{StorageAPI: disks[0], bucket: r.bucket, object: r.object, write: &fail}
|
||||
er.getDisks = func() []StorageAPI { return wrapped }
|
||||
fail.Store(true)
|
||||
}
|
||||
layer := newRestoreIsolationLayer(r)
|
||||
rec := restoreIsolationPOST(t, r, layer, oi.VersionID)
|
||||
fail.Store(false)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatal(rec.Body.String())
|
||||
}
|
||||
restoreIsolationEntered(t, tier)
|
||||
var unreadable atomic.Bool
|
||||
second := newRestoreIsolationLayer(r)
|
||||
if mode == "failed-activity-read" {
|
||||
// Once POST resolved its target, nothing tells whether it
|
||||
// is restoring.
|
||||
er := r.z.serverPools[0].getHashedSet(r.object)
|
||||
disks := er.getDisks()
|
||||
wrapped := make([]StorageAPI, len(disks))
|
||||
for i, d := range disks {
|
||||
wrapped[i] = restoreGenerationOffline{StorageAPI: d, bucket: r.bucket, object: r.object, fail: &unreadable}
|
||||
}
|
||||
er.getDisks = func() []StorageAPI { return wrapped }
|
||||
second.afterRead = func() { unreadable.Store(true) }
|
||||
}
|
||||
done := make(chan *httptest.ResponseRecorder, 1)
|
||||
start := time.Now()
|
||||
go func() { done <- restoreIsolationPOST(t, r, second, oi.VersionID) }()
|
||||
select {
|
||||
case rec := <-done:
|
||||
want := rec.Code == http.StatusConflict
|
||||
if mode == "failed-activity-read" {
|
||||
want = rec.Code >= 500
|
||||
}
|
||||
if !want || time.Since(start) > restoreTakeoverWait+time.Second {
|
||||
t.Errorf("POST during a running restore: %d after %s, want a bounded refusal", rec.Code, time.Since(start))
|
||||
}
|
||||
case <-time.After(restoreTakeoverWait + 2*time.Second):
|
||||
t.Error("POST during a running restore waited for the object lock")
|
||||
unreadable.Store(false)
|
||||
tier.unblock()
|
||||
<-done
|
||||
}
|
||||
unreadable.Store(false)
|
||||
tier.unblock()
|
||||
if err := layer.wait(t); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A POST whose status write fails on drives that stay unwritten puts the
|
||||
// generation it read back, and a split that a failed put-back leaves
|
||||
// differs only in restore fields: the attempt's reset and a later POST both
|
||||
// resolve it, while every other field still needs the quorum.
|
||||
func TestRestorePOSTStatusWriteSplit(t *testing.T) {
|
||||
for _, mode := range []string{"put-back", "split-reset", "split-post"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi, body := restoreIsolationSeed(t, r, 0, false, false)
|
||||
er := r.z.serverPools[0].getHashedSet(r.object)
|
||||
disks := er.getDisks()
|
||||
var fail atomic.Bool
|
||||
wrapped := append([]StorageAPI(nil), disks...)
|
||||
for i := range 2 {
|
||||
wrapped[i] = restoreGenerationDisk{StorageAPI: disks[i], bucket: r.bucket, object: r.object, write: &fail}
|
||||
}
|
||||
er.getDisks = func() []StorageAPI { return wrapped }
|
||||
before := restoreGenerationMetadata(t, disks, r, oi.VersionID)
|
||||
var attempt *restoreAttempt
|
||||
if mode == "put-back" {
|
||||
fail.Store(true)
|
||||
rec := restoreIsolationPOST(t, r, r.z, oi.VersionID)
|
||||
fail.Store(false)
|
||||
if rec.Code < 400 {
|
||||
t.Fatalf("POST acknowledged a status write on two of four drives: %d", rec.Code)
|
||||
}
|
||||
if after := restoreGenerationMetadata(t, disks, r, oi.VersionID); !reflect.DeepEqual(before, after) {
|
||||
t.Error("failed status write was not put back")
|
||||
}
|
||||
} else {
|
||||
// A status write and its put-back both failed on two drives.
|
||||
attempt = newRestoreAttempt(oi)
|
||||
for _, d := range disks[2:] {
|
||||
fi, err := d.ReadVersion(t.Context(), "", r.bucket, r.object, oi.VersionID, ReadOptions{ReadData: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi.Metadata[xhttp.AmzRestore] = ongoingRestoreObj().String()
|
||||
fi.Metadata[restoreAttemptKey] = attempt.ID
|
||||
if err := d.WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := er.readRestoreGeneration(t.Context(), r.bucket, r.object, oi.VersionID); err != nil {
|
||||
t.Fatalf("a split in restore fields only has no generation: %v", err)
|
||||
}
|
||||
}
|
||||
if mode == "split-reset" {
|
||||
opts := nativeRestoreOptions(r, oi.VersionID)
|
||||
opts.Transition.attempt = attempt
|
||||
if err := r.z.resetRestoreStatus(r.bucket, r.object, opts); err != nil {
|
||||
t.Fatalf("reset of the split attempt: %v", err)
|
||||
}
|
||||
for i, meta := range restoreGenerationMetadata(t, disks, r, oi.VersionID) {
|
||||
if meta[xhttp.AmzRestore] != "" || meta[restoreAttemptKey] != "" {
|
||||
t.Errorf("drive %d keeps the split attempt: %q %q", i, meta[xhttp.AmzRestore], meta[restoreAttemptKey])
|
||||
}
|
||||
}
|
||||
}
|
||||
layer := newRestoreIsolationLayer(r)
|
||||
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST after the failed status write: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := layer.wait(t); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
||||
t.Fatalf("restore contents: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A split in any other field is not resolved: the generation fails closed.
|
||||
func TestRestoreGenerationSplitNeedsQuorum(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
oi, _ := restoreIsolationSeed(t, r, 0, false, false)
|
||||
er := r.z.serverPools[0].getHashedSet(r.object)
|
||||
for i, d := range er.getDisks()[2:] {
|
||||
fi, err := d.ReadVersion(t.Context(), "", r.bucket, r.object, oi.VersionID, ReadOptions{ReadData: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi.Metadata[xhttp.AmzRestore] = ongoingRestoreObj().String()
|
||||
fi.Metadata[strings.ToLower(xhttp.AmzObjectLockLegalHold)] = fmt.Sprint("ON", i)
|
||||
if err := d.WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := er.readRestoreGeneration(t.Context(), r.bucket, r.object, oi.VersionID); !errors.Is(err, errErasureReadQuorum) {
|
||||
t.Fatalf("split outside the restore fields: %v, want read quorum", err)
|
||||
}
|
||||
if rec := restoreIsolationPOST(t, r, r.z, oi.VersionID); rec.Code < 400 {
|
||||
t.Errorf("POST over a split outside the restore fields: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// restoreGenerationCleanupDisk misses restored-copy cleanups and metadata
|
||||
// writes of the object while its toggles are set.
|
||||
type restoreGenerationCleanupDisk struct {
|
||||
StorageAPI
|
||||
bucket, object string
|
||||
cleanup, metaWrites *atomic.Bool
|
||||
}
|
||||
|
||||
func (d restoreGenerationCleanupDisk) DeleteVersion(ctx context.Context, bucket, object string, fi FileInfo, force bool, opts DeleteOptions) error {
|
||||
if bucket == d.bucket && object == d.object && fi.ExpireRestored && d.cleanup.Load() {
|
||||
return errFaultyDisk
|
||||
}
|
||||
return d.StorageAPI.DeleteVersion(ctx, bucket, object, fi, force, opts)
|
||||
}
|
||||
|
||||
func (d restoreGenerationCleanupDisk) WriteMetadata(ctx context.Context, orig, bucket, object string, fi FileInfo) error {
|
||||
if bucket == d.bucket && object == d.object && d.metaWrites.Load() {
|
||||
return errFaultyDisk
|
||||
}
|
||||
return d.StorageAPI.WriteMetadata(ctx, orig, bucket, object, fi)
|
||||
}
|
||||
|
||||
// Restored-copy expiry decides on the version's authoritative generation. A
|
||||
// drive that missed a cleanup and the POST after it keeps an expired header,
|
||||
// which must not clear the quorum's ongoing restore; drives split between
|
||||
// an expired and a renewed header authorize nothing.
|
||||
func TestExpireRestoredUsesQuorumGeneration(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
for _, mode := range []string{"after-partial-cleanup", "split-renewal"} {
|
||||
t.Run(fmt.Sprintf("pools=%d/%s", pools, mode), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi := r.put("cache", false, true)
|
||||
r.restore(oi)
|
||||
stale, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
er := r.z.serverPools[0].getHashedSet(r.object)
|
||||
disks := er.getDisks()
|
||||
expire := func() error {
|
||||
return expireTransitionedObject(t.Context(), r.z, &stale, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner)
|
||||
}
|
||||
if mode == "split-renewal" {
|
||||
// A renewal reached two drives; the other two keep the
|
||||
// expired header.
|
||||
dataDir := r.fileInfo(oi.VersionID).DataDir
|
||||
for _, d := range disks[2:] {
|
||||
fi, err := d.ReadVersion(t.Context(), "", r.bucket, r.object, oi.VersionID, ReadOptions{ReadData: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi.Metadata[xhttp.AmzRestore] = completedRestoreObj(UTCNow().Add(48 * time.Hour)).String()
|
||||
if err := d.WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := expire(); err == nil || r.dataDirs(dataDir) != len(disks) {
|
||||
t.Errorf("expiry on drives split between expired and renewed: %v, %d/%d cache dirs", err, r.dataDirs(dataDir), len(disks))
|
||||
}
|
||||
return
|
||||
}
|
||||
var cleanup, metaWrites atomic.Bool
|
||||
wrapped := append([]StorageAPI(nil), disks...)
|
||||
wrapped[0] = restoreGenerationCleanupDisk{StorageAPI: disks[0], bucket: r.bucket, object: r.object, cleanup: &cleanup, metaWrites: &metaWrites}
|
||||
er.getDisks = func() []StorageAPI { return wrapped }
|
||||
cleanup.Store(true)
|
||||
err = expire()
|
||||
cleanup.Store(false)
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup on all drives but one: %v", err)
|
||||
}
|
||||
captured := make(chan ObjectOptions, 1)
|
||||
layer := newRestoreIsolationLayer(r)
|
||||
layer.restore = func(_ context.Context, _, _ string, opts ObjectOptions) error {
|
||||
captured <- opts
|
||||
return nil
|
||||
}
|
||||
metaWrites.Store(true)
|
||||
rec := restoreIsolationPOST(t, r, layer, oi.VersionID)
|
||||
metaWrites.Store(false)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST after the cleanup: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
opts := <-captured
|
||||
layer.wait(t)
|
||||
// The same cleanup, queued twice, runs again.
|
||||
if err := expire(); err == nil {
|
||||
t.Error("a drive's expired header cleared the quorum's ongoing restore")
|
||||
}
|
||||
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, opts); err != nil {
|
||||
t.Fatalf("restore of the acknowledged attempt: %v", err)
|
||||
}
|
||||
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, r.bodies["cache"]) {
|
||||
t.Fatalf("restored contents: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// restoreGenerationHidden hides a drive's copy of the object version while
|
||||
// set, as a drive that lost it and that heal writes again.
|
||||
type restoreGenerationHidden struct {
|
||||
StorageAPI
|
||||
bucket, object string
|
||||
hidden *atomic.Bool
|
||||
}
|
||||
|
||||
func (d restoreGenerationHidden) ReadVersion(ctx context.Context, orig, bucket, object, version string, opts ReadOptions) (FileInfo, error) {
|
||||
if d.hidden.Load() && bucket == d.bucket && object == d.object {
|
||||
return FileInfo{}, errFileNotFound
|
||||
}
|
||||
return d.StorageAPI.ReadVersion(ctx, orig, bucket, object, version, opts)
|
||||
}
|
||||
|
||||
// Heal copies the restore fields to a drive only as a quorum of the drives
|
||||
// holds them. While they disagree, healing the version waits: it must not
|
||||
// create a quorum for an expired header over a renewal, nor for a
|
||||
// superseded attempt. Fields a quorum agrees on are copied.
|
||||
func TestRestoreHealNeedsRestoreQuorum(t *testing.T) {
|
||||
for _, mode := range []string{"expired-over-renewal", "superseded-attempt", "agreed"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
er := r.z.serverPools[0].getHashedSet(r.object)
|
||||
disks := er.getDisks()
|
||||
setRestore := func(versionID string, drives []StorageAPI, fields map[string]string) {
|
||||
for _, d := range drives {
|
||||
fi, err := d.ReadVersion(t.Context(), "", r.bucket, r.object, versionID, ReadOptions{ReadData: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
maps.Copy(fi.Metadata, fields)
|
||||
if err := d.WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
var versionID string
|
||||
var stale ObjectInfo
|
||||
var older ObjectOptions
|
||||
hide := 3
|
||||
switch mode {
|
||||
case "expired-over-renewal", "agreed":
|
||||
oi := r.put("cache", false, true)
|
||||
r.restore(oi)
|
||||
versionID = oi.VersionID
|
||||
var err error
|
||||
if stale, err = r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(versionID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renewed := map[string]string{xhttp.AmzRestore: completedRestoreObj(UTCNow().Add(48 * time.Hour)).String()}
|
||||
if mode == "agreed" {
|
||||
// Three drives renewed, the hidden one expired.
|
||||
setRestore(versionID, disks[:3], renewed)
|
||||
} else {
|
||||
// No quorum: two drives expired an hour ago, one renewed,
|
||||
// and the hidden one expired a day ago.
|
||||
setRestore(versionID, disks[:2], map[string]string{xhttp.AmzRestore: completedRestoreObj(UTCNow().Add(-time.Hour)).String()})
|
||||
setRestore(versionID, disks[2:3], renewed)
|
||||
}
|
||||
case "superseded-attempt":
|
||||
oi, _ := restoreIsolationSeed(t, r, 0, false, false)
|
||||
versionID = oi.VersionID
|
||||
captured := make(chan ObjectOptions, 1)
|
||||
layer := newRestoreIsolationLayer(r)
|
||||
layer.restore = func(_ context.Context, _, _ string, opts ObjectOptions) error {
|
||||
captured <- opts
|
||||
return nil
|
||||
}
|
||||
if rec := restoreIsolationPOST(t, r, layer, versionID); rec.Code != http.StatusOK {
|
||||
t.Fatal(rec.Body.String())
|
||||
}
|
||||
older = <-captured
|
||||
layer.wait(t)
|
||||
// A newer attempt reached two drives.
|
||||
setRestore(versionID, disks[2:], map[string]string{xhttp.AmzRestore: ongoingRestoreObj().String(), restoreAttemptKey: newRestoreAttempt(oi).ID})
|
||||
hide = 2
|
||||
}
|
||||
before := restoreGenerationMetadata(t, disks, r, versionID)
|
||||
var hidden atomic.Bool
|
||||
wrapped := append([]StorageAPI(nil), disks...)
|
||||
wrapped[hide] = restoreGenerationHidden{StorageAPI: disks[hide], bucket: r.bucket, object: r.object, hidden: &hidden}
|
||||
er.getDisks = func() []StorageAPI { return wrapped }
|
||||
hidden.Store(true)
|
||||
_, healErr := er.HealObject(t.Context(), r.bucket, r.object, versionID, madmin.HealOpts{ScanMode: madmin.HealNormalScan})
|
||||
hidden.Store(false)
|
||||
er.getDisks = func() []StorageAPI { return disks }
|
||||
after := restoreGenerationMetadata(t, disks, r, versionID)
|
||||
if mode == "agreed" {
|
||||
if healErr != nil || after[hide][xhttp.AmzRestore] != after[0][xhttp.AmzRestore] {
|
||||
t.Errorf("heal did not copy the restore fields a quorum holds: %v, %q", healErr, after[hide][xhttp.AmzRestore])
|
||||
}
|
||||
return
|
||||
}
|
||||
if healErr == nil {
|
||||
t.Error("heal proceeded on drives that disagree on the restore fields")
|
||||
}
|
||||
if after[hide][xhttp.AmzRestore] != before[hide][xhttp.AmzRestore] || after[hide][restoreAttemptKey] != before[hide][restoreAttemptKey] {
|
||||
t.Errorf("heal wrote a minority restore state: %q -> %q", before[hide][xhttp.AmzRestore], after[hide][xhttp.AmzRestore])
|
||||
}
|
||||
if mode == "expired-over-renewal" {
|
||||
dataDir := r.fileInfo(versionID).DataDir
|
||||
if err := expireTransitionedObject(t.Context(), r.z, &stale, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner); err == nil || r.dataDirs(dataDir) != len(disks) {
|
||||
t.Errorf("expiry after heal: %v, %d/%d cache dirs", err, r.dataDirs(dataDir), len(disks))
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, older); !errors.Is(err, errRestoreSuperseded) {
|
||||
t.Errorf("superseded attempt after heal: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// restoreGenerationRand draws UUIDs from crypto/rand for the whole test
|
||||
// binary, installed before any goroutine runs. While a pause is set, it holds
|
||||
// the UUID that stale cleanup draws in its deadline-bound worker, for the
|
||||
// trash path right before its rename, until the pause is released.
|
||||
var restoreGenerationRand = &restoreGenerationRandReader{}
|
||||
|
||||
func init() { uuid.SetRand(restoreGenerationRand) }
|
||||
|
||||
type restoreGenerationRandReader struct {
|
||||
pause atomic.Pointer[restoreGenerationPause]
|
||||
}
|
||||
|
||||
type restoreGenerationPause struct {
|
||||
entered, release chan struct{}
|
||||
}
|
||||
|
||||
func (r *restoreGenerationRandReader) Read(b []byte) (int, error) {
|
||||
if p := r.pause.Load(); p != nil && restoreGenerationInCleanupRename() && r.pause.CompareAndSwap(p, nil) {
|
||||
close(p.entered)
|
||||
<-p.release
|
||||
}
|
||||
return rand.Read(b)
|
||||
}
|
||||
|
||||
// restoreGenerationInCleanupRename reports whether the caller runs in stale
|
||||
// cleanup's deadline-bound worker.
|
||||
func restoreGenerationInCleanupRename() bool {
|
||||
pcs := make([]uintptr, 32)
|
||||
frames := runtime.CallersFrames(pcs[:runtime.Callers(3, pcs)])
|
||||
cleanup, worker := false, false
|
||||
for {
|
||||
f, more := frames.Next()
|
||||
cleanup = cleanup || strings.Contains(f.Function, "cleanupStaleUploadsOnDisk")
|
||||
worker = worker || strings.Contains(f.Function, "ioutil.WithDeadline")
|
||||
if !more {
|
||||
return cleanup && worker
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,930 @@
|
||||
// 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"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/mux"
|
||||
)
|
||||
|
||||
// The private attempt metadata of a native restore, as stored. A literal name
|
||||
// keeps these regressions meaningful on a tree without the key.
|
||||
const restoreIsolationAttemptKey = "x-minio-internal-restore-attempt"
|
||||
|
||||
// restoreIsolationLayer hooks the object layer a POST restore handler uses.
|
||||
// afterRead runs once POST has read the version, before POST records its
|
||||
// status. restore, when set, replaces the background restore; every
|
||||
// background restore result is sent to done.
|
||||
type restoreIsolationLayer struct {
|
||||
ObjectLayer
|
||||
afterRead func()
|
||||
once sync.Once
|
||||
restore func(ctx context.Context, bucket, object string, opts ObjectOptions) error
|
||||
done chan error
|
||||
}
|
||||
|
||||
func newRestoreIsolationLayer(r *restoreTest) *restoreIsolationLayer {
|
||||
return &restoreIsolationLayer{ObjectLayer: r.z, done: make(chan error, 4)}
|
||||
}
|
||||
|
||||
func (l *restoreIsolationLayer) GetObjectInfo(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
oi, err := l.ObjectLayer.GetObjectInfo(ctx, bucket, object, opts)
|
||||
if err == nil && l.afterRead != nil {
|
||||
l.once.Do(l.afterRead)
|
||||
}
|
||||
return oi, err
|
||||
}
|
||||
|
||||
func (l *restoreIsolationLayer) RestoreTransitionedObject(ctx context.Context, bucket, object string, opts ObjectOptions) error {
|
||||
var err error
|
||||
if l.restore != nil {
|
||||
err = l.restore(ctx, bucket, object, opts)
|
||||
} else {
|
||||
err = l.ObjectLayer.RestoreTransitionedObject(ctx, bucket, object, opts)
|
||||
}
|
||||
l.done <- err
|
||||
return err
|
||||
}
|
||||
|
||||
// restoreTestWait bounds how long a restore test waits for a step that does
|
||||
// real drive work: metadata writes with synchronous persistence and renames
|
||||
// on every drive. It is a liveness bound, not a latency contract; a loaded
|
||||
// machine has stalled such a step past ten seconds.
|
||||
const restoreTestWait = time.Minute
|
||||
|
||||
// wait returns the result of the next background restore.
|
||||
func (l *restoreIsolationLayer) wait(t *testing.T) error {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-l.done:
|
||||
return err
|
||||
case <-time.After(restoreTestWait):
|
||||
buf := make([]byte, 1<<20)
|
||||
t.Fatalf("background restore did not finish in %v; goroutines:\n%s", restoreTestWait, buf[:runtime.Stack(buf, true)])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func restoreIsolationPOST(t *testing.T, r *restoreTest, layer ObjectLayer, versionID string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body := []byte(`<RestoreRequest xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Days>1</Days></RestoreRequest>`)
|
||||
path := "/" + r.bucket + "/" + r.object + "?restore"
|
||||
if versionID != "" {
|
||||
path += "&versionId=" + url.QueryEscape(versionID)
|
||||
}
|
||||
req, err := newTestSignedRequestV4(http.MethodPost, path, int64(len(body)), bytes.NewReader(body), globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := req.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = mux.SetURLVars(req, map[string]string{"bucket": r.bucket, "object": r.object})
|
||||
rec := httptest.NewRecorder()
|
||||
api := objectAPIHandlers{ObjectAPI: func() ObjectLayer { return layer }}
|
||||
api.PostRestoreObjectHandler(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// restoreIsolationSeed is a transitioned version without the ongoing marker
|
||||
// that nativeRestoreSeed leaves for direct restores: POST sets its own.
|
||||
func restoreIsolationSeed(t *testing.T, r *restoreTest, pool int, null, multipart bool) (ObjectInfo, []byte) {
|
||||
t.Helper()
|
||||
oi, body := nativeRestoreSeed(t, r, pool, null, multipart)
|
||||
opts := r.opts(oi.VersionID)
|
||||
opts.MTime = oi.ModTime
|
||||
opts.EvalMetadataFn = func(o *ObjectInfo, _ error) (ReplicateDecision, error) {
|
||||
o.UserDefined[xhttp.AmzRestore] = ""
|
||||
return ReplicateDecision{}, nil
|
||||
}
|
||||
if _, err := r.z.PutObjectMetadata(t.Context(), r.bucket, r.object, opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return oi, body
|
||||
}
|
||||
|
||||
// restoreIsolationSettled waits until the version is no longer ongoing.
|
||||
func restoreIsolationSettled(t *testing.T, r *restoreTest, versionID string) ObjectInfo {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
oi, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(versionID))
|
||||
if err == nil && !oi.RestoreOngoing {
|
||||
return oi
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("version stays ongoing, refusing every later POST: %q, %v", oi.UserDefined[xhttp.AmzRestore], err)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// restoreIsolationUploads returns the upload directories of the object on
|
||||
// each drive of a pool, as stored, whatever listings show.
|
||||
func restoreIsolationUploads(t *testing.T, r *restoreTest, pool int) map[string]int {
|
||||
t.Helper()
|
||||
er := r.z.serverPools[pool].getHashedSet(r.object)
|
||||
found := make(map[string]int)
|
||||
for _, disk := range er.getDisks() {
|
||||
entries, err := disk.ListDir(t.Context(), r.bucket, minioMetaMultipartBucket, er.getMultipartSHADir(r.bucket, r.object), -1)
|
||||
if errors.Is(err, errFileNotFound) || errors.Is(err, errVolumeNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
found[strings.TrimSuffix(entry, SlashSeparator)]++
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// restoreIsolationGateTier holds a download after its first bytes and can
|
||||
// fail it once released.
|
||||
type restoreIsolationGateTier struct {
|
||||
WarmBackend
|
||||
first int
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
failure error
|
||||
enter sync.Once
|
||||
open sync.Once
|
||||
}
|
||||
|
||||
func newRestoreIsolationGateTier(first int, failure error) *restoreIsolationGateTier {
|
||||
return &restoreIsolationGateTier{
|
||||
WarmBackend: globalTierConfigMgr.drivercache["WARM"],
|
||||
first: first, failure: failure,
|
||||
entered: make(chan struct{}), release: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *restoreIsolationGateTier) unblock() { b.open.Do(func() { close(b.release) }) }
|
||||
|
||||
func (b *restoreIsolationGateTier) Get(ctx context.Context, object string, version remoteVersionID, opts WarmBackendGetOpts) (io.ReadCloser, error) {
|
||||
rd, err := b.WarmBackend.Get(ctx, object, version, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &restoreIsolationGateReader{ReadCloser: rd, tier: b, ctx: ctx, remaining: b.first}, nil
|
||||
}
|
||||
|
||||
type restoreIsolationGateReader struct {
|
||||
io.ReadCloser
|
||||
tier *restoreIsolationGateTier
|
||||
ctx context.Context
|
||||
remaining int
|
||||
}
|
||||
|
||||
func (r *restoreIsolationGateReader) Read(p []byte) (int, error) {
|
||||
if r.remaining > 0 {
|
||||
if len(p) > r.remaining {
|
||||
p = p[:r.remaining]
|
||||
}
|
||||
n, err := r.ReadCloser.Read(p)
|
||||
r.remaining -= n
|
||||
return n, err
|
||||
}
|
||||
r.tier.enter.Do(func() { close(r.tier.entered) })
|
||||
select {
|
||||
case <-r.tier.release:
|
||||
case <-r.ctx.Done():
|
||||
return 0, r.ctx.Err()
|
||||
}
|
||||
if r.tier.failure != nil {
|
||||
return 0, r.tier.failure
|
||||
}
|
||||
return r.ReadCloser.Read(p)
|
||||
}
|
||||
|
||||
func restoreIsolationEntered(t *testing.T, tier *restoreIsolationGateTier) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-tier.entered:
|
||||
case <-time.After(restoreTestWait):
|
||||
t.Fatal("restore never reached its download gate")
|
||||
}
|
||||
}
|
||||
|
||||
// restoreIsolationFaultDisk fails metadata reads of the object while
|
||||
// failReads is set, and reports writes of its metadata as failed, after
|
||||
// they were made, while failWrites is set. Toggles keep the wrapper in
|
||||
// place, so no restore retry races a swap of the drives.
|
||||
type restoreIsolationFaultDisk struct {
|
||||
StorageAPI
|
||||
bucket, object string
|
||||
failReads *atomic.Bool
|
||||
failWrites *atomic.Bool
|
||||
}
|
||||
|
||||
func (d restoreIsolationFaultDisk) ReadVersion(ctx context.Context, origvolume, volume, path, version string, opts ReadOptions) (FileInfo, error) {
|
||||
if d.failReads.Load() && volume == d.bucket && path == d.object {
|
||||
return FileInfo{}, errDiskNotFound
|
||||
}
|
||||
return d.StorageAPI.ReadVersion(ctx, origvolume, volume, path, version, opts)
|
||||
}
|
||||
|
||||
func (d restoreIsolationFaultDisk) ReadXL(ctx context.Context, volume, path string, readData bool) (RawFileInfo, error) {
|
||||
if d.failReads.Load() && volume == d.bucket && path == d.object {
|
||||
return RawFileInfo{}, errDiskNotFound
|
||||
}
|
||||
return d.StorageAPI.ReadXL(ctx, volume, path, readData)
|
||||
}
|
||||
|
||||
func (d restoreIsolationFaultDisk) WriteMetadata(ctx context.Context, origvolume, volume, path string, fi FileInfo) error {
|
||||
err := d.StorageAPI.WriteMetadata(ctx, origvolume, volume, path, fi)
|
||||
if err == nil && d.failWrites != nil && d.failWrites.Load() && volume == d.bucket && path == d.object {
|
||||
return errFaultyDisk
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// installRestoreIsolationFaults wraps the drives of the object's set in a
|
||||
// pool for the rest of the test; failing marks the drives whose writes fail.
|
||||
func installRestoreIsolationFaults(r *restoreTest, pool int, failing int) (reads, writes *atomic.Bool) {
|
||||
reads, writes = new(atomic.Bool), new(atomic.Bool)
|
||||
er := r.z.serverPools[pool].getHashedSet(r.object)
|
||||
disks := er.getDisks()
|
||||
wrapped := make([]StorageAPI, len(disks))
|
||||
for i, disk := range disks {
|
||||
d := restoreIsolationFaultDisk{StorageAPI: disk, bucket: r.bucket, object: r.object, failReads: reads}
|
||||
if i < failing {
|
||||
d.failWrites = writes
|
||||
}
|
||||
wrapped[i] = d
|
||||
}
|
||||
er.getDisks = func() []StorageAPI { return wrapped }
|
||||
return reads, writes
|
||||
}
|
||||
|
||||
// POST records its status on a fresh read under the object lock, so an
|
||||
// Object Lock update it did not see survives.
|
||||
func TestRestorePOSTKeepsConcurrentProtection(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
for _, kind := range []string{"legal-hold", "retention"} {
|
||||
t.Run(fmt.Sprintf("pools=%d/%s", pools, kind), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
enableBucketObjectLock(t, r.bucket)
|
||||
oi, _ := nativeRestoreSeed(t, r, 0, false, false)
|
||||
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initial := []byte(`<LegalHold xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>OFF</Status></LegalHold>`)
|
||||
updated := []byte(`<LegalHold xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>ON</Status></LegalHold>`)
|
||||
key := strings.ToLower(xhttp.AmzObjectLockLegalHold)
|
||||
if kind == "retention" {
|
||||
initial = retentionBody("COMPLIANCE", UTCNow().Add(24*time.Hour))
|
||||
updated = retentionBody("COMPLIANCE", UTCNow().Add(48*time.Hour))
|
||||
key = strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)
|
||||
}
|
||||
if rec := nativeRestoreLockRequest(t.Context(), t, r, oi.VersionID, kind, initial); rec.Code != http.StatusOK {
|
||||
t.Fatalf("initial protection: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var accepted ObjectInfo
|
||||
layer := newRestoreIsolationLayer(r)
|
||||
layer.afterRead = func() {
|
||||
if rec := nativeRestoreLockRequest(t.Context(), t, r, oi.VersionID, kind, updated); rec.Code != http.StatusOK {
|
||||
t.Fatalf("update: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var err error
|
||||
if accepted, err = r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code >= 400 {
|
||||
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
after, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if accepted.UserDefined[key] == "" || after.UserDefined[key] != accepted.UserDefined[key] {
|
||||
t.Errorf("POST restore overwrote acknowledged Object Lock metadata: %q -> %q", accepted.UserDefined[key], after.UserDefined[key])
|
||||
}
|
||||
if !after.ModTime.Equal(oi.ModTime) || after.ETag != oi.ETag {
|
||||
t.Error("POST restore changed the version identity")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A restored copy removed between POST's read and its status write leads to
|
||||
// a new restore, never to a completed header without the restored data.
|
||||
func TestRestorePOSTRacesCacheExpiry(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
t.Run(fmt.Sprintf("pools=%d", pools), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi := r.put("restored", false, true)
|
||||
r.restore(oi)
|
||||
layer := newRestoreIsolationLayer(r)
|
||||
layer.afterRead = func() {
|
||||
if err := expireTransitionedObject(t.Context(), r.z, &oi, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code >= 400 {
|
||||
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
select {
|
||||
case err := <-layer.done:
|
||||
if err != nil {
|
||||
t.Fatalf("rehydration: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("POST renewed a removed restored copy instead of restoring it")
|
||||
}
|
||||
_, body, err := r.read(oi.VersionID)
|
||||
if err != nil || !bytes.Equal(body, r.bodies["restored"]) {
|
||||
t.Errorf("version unreadable after POST raced cache expiry: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A restore queued with the versioning POST captured writes back the null
|
||||
// version it resolved, even after versioning was enabled in between.
|
||||
func TestRestoreKeepsCapturedNullIdentity(t *testing.T) {
|
||||
enable := func(t *testing.T, r *restoreTest) {
|
||||
meta, err := globalBucketMetadataSys.Get(r.bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.VersioningConfigXML = []byte(`<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>`)
|
||||
if err := meta.Save(t.Context(), r.z); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalBucketMetadataSys.Set(r.bucket, meta)
|
||||
}
|
||||
for _, mode := range []string{"direct", "post"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, "")
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
var oi ObjectInfo
|
||||
var body []byte
|
||||
if mode == "direct" {
|
||||
oi, body = nativeRestoreSeed(t, r, 0, true, false)
|
||||
// These are the options that POST captured while versioning was off.
|
||||
queued := nativeRestoreOptions(r, "")
|
||||
enable(t, r)
|
||||
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, queued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
oi, body = restoreIsolationSeed(t, r, 0, true, false)
|
||||
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, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
enable(t, r)
|
||||
close(release)
|
||||
if err := layer.wait(t); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
versions := nativeRestoreSnapshot(t, r)[0]
|
||||
if len(versions) != 1 || versions[0].VersionID != "" {
|
||||
t.Errorf("restore created a UUID alias of the queued null version: %d versions", len(versions))
|
||||
}
|
||||
_, got, err := r.read(oi.VersionID)
|
||||
if err != nil || !bytes.Equal(got, body) {
|
||||
t.Fatalf("null original: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A restore that fails before it reads the version, on its lock wait or on
|
||||
// its first read, must not leave POST's ongoing marker for every later POST
|
||||
// to refuse: bounded retries clear it once the version is readable again.
|
||||
func TestRestoreRecoversStrandedMarker(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
for _, stage := range []string{"lock-wait", "initial-read"} {
|
||||
t.Run(fmt.Sprintf("pools=%d/%s", pools, stage), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
|
||||
oi, _ := nativeRestoreSeed(t, r, 0, false, false)
|
||||
var err error
|
||||
if stage == "lock-wait" {
|
||||
lk := r.z.NewNSLock(r.bucket, r.object)
|
||||
if pools == 1 {
|
||||
lk = r.z.serverPools[0].getHashedSet(r.object).NewNSLock(r.bucket, r.object)
|
||||
}
|
||||
lkctx, lerr := lk.GetLock(t.Context(), globalOperationTimeout)
|
||||
if lerr != nil {
|
||||
t.Fatal(lerr)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
err = r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
|
||||
cancel()
|
||||
lk.Unlock(lkctx)
|
||||
} else {
|
||||
reads, _ := installRestoreIsolationFaults(r, 0, 0)
|
||||
reads.Store(true)
|
||||
err = r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
|
||||
reads.Store(false)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("restore succeeded without its lock or its version")
|
||||
}
|
||||
after := restoreIsolationSettled(t, r, oi.VersionID)
|
||||
if !after.IsRemote() || !after.ModTime.Equal(oi.ModTime) {
|
||||
t.Error("recovery changed more than the restore status")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A later POST takes over a marker that no running restore holds, and the
|
||||
// superseded attempt then neither downloads nor clears the newer status,
|
||||
// also when both POSTs share a second, the precision of the request date.
|
||||
func TestRestoreSupersededAttempt(t *testing.T) {
|
||||
for _, mode := range []string{"newer-completes", "newer-fails"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi, body := restoreIsolationSeed(t, r, 0, false, false)
|
||||
tier := globalTierConfigMgr.drivercache["WARM"].(*restoreTestTier)
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
t.Cleanup(unblock)
|
||||
older := newRestoreIsolationLayer(r)
|
||||
older.restore = func(ctx context.Context, bucket, object string, opts ObjectOptions) error {
|
||||
// The queued worker loses the restore lock POST handed it,
|
||||
// as when its refresh found it gone: the marker then belongs
|
||||
// to no running restore.
|
||||
opts.Transition.owner.release()
|
||||
<-release
|
||||
return r.z.RestoreTransitionedObject(ctx, bucket, object, opts)
|
||||
}
|
||||
newer := newRestoreIsolationLayer(r)
|
||||
time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(time.Second + 10*time.Millisecond)))
|
||||
if rec := restoreIsolationPOST(t, r, older, oi.VersionID); rec.Code != http.StatusOK {
|
||||
t.Fatalf("first POST: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
first := r.fileInfo(oi.VersionID).Metadata[xhttp.AmzRestoreRequestDate]
|
||||
if mode == "newer-fails" {
|
||||
globalTierConfigMgr.drivercache["WARM"] = nativeRestoreFailTier{tier, errors.New("injected remote failure")}
|
||||
}
|
||||
if rec := restoreIsolationPOST(t, r, newer, oi.VersionID); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST over a marker no restore holds: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
second := r.fileInfo(oi.VersionID).Metadata[xhttp.AmzRestoreRequestDate]
|
||||
t.Logf("request dates %q %q, same second: %v", first, second, first == second)
|
||||
newerErr := newer.wait(t)
|
||||
globalTierConfigMgr.drivercache["WARM"] = tier
|
||||
before := r.fileInfo(oi.VersionID)
|
||||
gets := tier.gets.Load()
|
||||
unblock()
|
||||
if err := older.wait(t); err == nil {
|
||||
t.Error("superseded restore reported success")
|
||||
}
|
||||
if tier.gets.Load() != gets {
|
||||
t.Error("superseded restore downloaded the version again")
|
||||
}
|
||||
after := r.fileInfo(oi.VersionID)
|
||||
if !reflect.DeepEqual(before.Metadata, after.Metadata) || before.DataDir != after.DataDir {
|
||||
t.Errorf("superseded restore changed the version: %q -> %q", before.Metadata[xhttp.AmzRestore], after.Metadata[xhttp.AmzRestore])
|
||||
}
|
||||
info, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
|
||||
if err != nil || info.RestoreOngoing {
|
||||
t.Fatalf("version left ongoing: %q, %v", info.UserDefined[xhttp.AmzRestore], err)
|
||||
}
|
||||
if mode == "newer-completes" {
|
||||
if newerErr != nil || info.IsRemote() {
|
||||
t.Fatalf("newer restore: %v remote=%v", newerErr, info.IsRemote())
|
||||
}
|
||||
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) || tier.gets.Load() != gets {
|
||||
t.Errorf("restored contents: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if newerErr == nil || !info.IsRemote() || info.UserDefined[xhttp.AmzRestore] != "" {
|
||||
t.Fatalf("failed newer restore is not retryable: %v %q", newerErr, info.UserDefined[xhttp.AmzRestore])
|
||||
}
|
||||
if rec := restoreIsolationPOST(t, r, newer, oi.VersionID); rec.Code != http.StatusOK {
|
||||
t.Fatalf("retry POST: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := newer.wait(t); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
||||
t.Errorf("retried restore contents: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A restart loses the restore that POST started; a POST to the restarted
|
||||
// server takes the stranded marker over and restores the version.
|
||||
func TestRestoreRestartTakeover(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi, body := restoreIsolationSeed(t, r, 0, false, true)
|
||||
lost := newRestoreIsolationLayer(r)
|
||||
lost.restore = func(context.Context, string, string, ObjectOptions) error { return nil }
|
||||
if rec := restoreIsolationPOST(t, r, lost, oi.VersionID); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST before restart: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
lost.wait(t)
|
||||
if info, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID)); err != nil || !info.RestoreOngoing {
|
||||
t.Fatalf("fixture: %q %v", info.UserDefined[xhttp.AmzRestore], err)
|
||||
}
|
||||
|
||||
endpoints := make(EndpointServerPools, len(r.z.serverPools))
|
||||
for i, pool := range r.z.serverPools {
|
||||
endpoints[i] = pool.endpoints
|
||||
}
|
||||
obj, err := newErasureServerPools(t.Context(), endpoints)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restarted := obj.(*erasureServerPools)
|
||||
t.Cleanup(func() { restarted.Shutdown(context.Background()) })
|
||||
r.z = restarted
|
||||
layer := newRestoreIsolationLayer(r)
|
||||
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST after restart: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := layer.wait(t); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info := restoreIsolationSettled(t, r, oi.VersionID)
|
||||
if info.IsRemote() || info.UserDefined[restoreIsolationAttemptKey] != "" {
|
||||
t.Errorf("restore after restart incomplete: remote=%v attempt=%q", info.IsRemote(), info.UserDefined[restoreIsolationAttemptKey])
|
||||
}
|
||||
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
||||
t.Errorf("contents after restart: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A null version replaced while its restore was queued is another version:
|
||||
// the restore must not rehydrate it, even if it is transitioned as well.
|
||||
func TestRestoreReplacementNullObject(t *testing.T) {
|
||||
for _, state := range []versioning.State{"", versioning.Suspended} {
|
||||
t.Run(fmt.Sprintf("versioning=%q", state), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, state)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi, _ := restoreIsolationSeed(t, r, 0, true, false)
|
||||
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, ""); rec.Code != http.StatusOK {
|
||||
close(release)
|
||||
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
replacement := []byte("replacement null object")
|
||||
repl, err := r.z.serverPools[0].PutObject(t.Context(), r.bucket, r.object,
|
||||
mustGetPutObjReader(t, bytes.NewReader(replacement), int64(len(replacement)), "", ""),
|
||||
ObjectOptions{VersionSuspended: state == versioning.Suspended})
|
||||
if err != nil {
|
||||
close(release)
|
||||
t.Fatal(err)
|
||||
}
|
||||
topts := r.opts(nullVersionID)
|
||||
topts.MTime = repl.ModTime
|
||||
topts.Transition = TransitionOptions{Tier: "WARM", ETag: repl.ETag}
|
||||
if err := r.z.serverPools[0].TransitionObject(t.Context(), r.bucket, r.object, topts); err != nil {
|
||||
close(release)
|
||||
t.Fatal(err)
|
||||
}
|
||||
tier := globalTierConfigMgr.drivercache["WARM"].(*restoreTestTier)
|
||||
before := r.fileInfo(nullVersionID)
|
||||
gets := tier.gets.Load()
|
||||
close(release)
|
||||
layer.wait(t)
|
||||
after := r.fileInfo(nullVersionID)
|
||||
if tier.gets.Load() != gets || after.Metadata[xhttp.AmzRestore] != "" || !reflect.DeepEqual(before.Metadata, after.Metadata) {
|
||||
t.Errorf("queued restore rehydrated the replacement null object: %q", after.Metadata[xhttp.AmzRestore])
|
||||
}
|
||||
if after.ModTime.Equal(oi.ModTime) || !after.ModTime.Equal(repl.ModTime) {
|
||||
t.Fatal("fixture did not replace the null version")
|
||||
}
|
||||
if _, got, err := r.read(nullVersionID); err != nil || !bytes.Equal(got, replacement) {
|
||||
t.Errorf("replacement contents: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// POST's own status write can fail after its ongoing marker reached the
|
||||
// drives; the marker must not stay for every later POST to refuse.
|
||||
func TestRestorePOSTStatusWritePartialFailure(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi, body := restoreIsolationSeed(t, r, 0, false, false)
|
||||
_, writes := installRestoreIsolationFaults(r, 0, 2)
|
||||
layer := newRestoreIsolationLayer(r)
|
||||
writes.Store(true)
|
||||
rec := restoreIsolationPOST(t, r, layer, oi.VersionID)
|
||||
writes.Store(false)
|
||||
if rec.Code < 400 {
|
||||
t.Fatalf("POST acknowledged a failed status write: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if info, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID)); err == nil && !info.RestoreOngoing {
|
||||
t.Log("the failed status write left no marker in quorum")
|
||||
}
|
||||
restoreIsolationSettled(t, r, oi.VersionID)
|
||||
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST after a failed status write: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := layer.wait(t); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
||||
t.Errorf("restore after a failed status write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate copies of a version in two pools can differ in whether they
|
||||
// hold a restored copy. A completed restore header must never reach a copy
|
||||
// without one; POST renews or restores only the copy that restores serve.
|
||||
func TestRestoreDuplicatePoolCacheStates(t *testing.T) {
|
||||
for _, mode := range []string{"suspended-owner", "unrestored-first", "restored-first"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 2, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi, body := restoreIsolationSeed(t, r, 0, false, false)
|
||||
// The duplicate is the same stored version, metadata only.
|
||||
src := r.z.serverPools[0].getHashedSet(r.object).getDisks()
|
||||
dst := r.z.serverPools[1].getHashedSet(r.object).getDisks()
|
||||
for i := range src {
|
||||
raw, err := src[i].ReadAll(t.Context(), r.bucket, pathJoin(r.object, xlStorageFormatFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := dst[i].WriteAll(t.Context(), r.bucket, pathJoin(r.object, xlStorageFormatFile), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
owner := 0
|
||||
if mode == "unrestored-first" {
|
||||
owner = 1
|
||||
}
|
||||
if err := r.z.serverPools[owner].RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode == "suspended-owner" {
|
||||
r.z.poolMetaMutex.Lock()
|
||||
r.z.poolMeta.Pools[0].Decommission = &PoolDecommissionInfo{}
|
||||
r.z.poolMetaMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
r.z.poolMetaMutex.Lock()
|
||||
r.z.poolMeta.Pools[0].Decommission = nil
|
||||
r.z.poolMetaMutex.Unlock()
|
||||
})
|
||||
}
|
||||
before := nativeRestoreSnapshot(t, r)
|
||||
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 mode != "restored-first" {
|
||||
select {
|
||||
case err := <-layer.done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("POST did not restore the copy without restored data")
|
||||
}
|
||||
}
|
||||
after := nativeRestoreSnapshot(t, r)
|
||||
for pool := range r.z.serverPools {
|
||||
gr, err := r.z.serverPools[pool].GetObjectNInfo(t.Context(), r.bucket, r.object, nil, nil, ObjectOptions{VersionID: oi.VersionID})
|
||||
if err != nil {
|
||||
t.Errorf("pool %d copy unreadable, header %q: %v", pool, after[pool][0].Metadata[xhttp.AmzRestore], err)
|
||||
continue
|
||||
}
|
||||
got, err := io.ReadAll(gr)
|
||||
gr.Close()
|
||||
if err != nil || !bytes.Equal(got, body) {
|
||||
t.Errorf("pool %d copy contents: %v", pool, err)
|
||||
}
|
||||
}
|
||||
// The copy that is neither renewed nor restored stays as it was.
|
||||
untouched := 1
|
||||
if mode == "suspended-owner" {
|
||||
untouched = 0
|
||||
}
|
||||
if !reflect.DeepEqual(before[untouched], after[untouched]) {
|
||||
t.Errorf("POST changed the pool %d copy: %q -> %q", untouched, before[untouched][0].Metadata[xhttp.AmzRestore], after[untouched][0].Metadata[xhttp.AmzRestore])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A restored copy expires only by its own reason, on a fresh read: its
|
||||
// restore deadline passed and nothing else. A renewed deadline survives
|
||||
// another rule falling due and an expiry checked across all pools.
|
||||
func TestExpireRestoredNeedsOwnReason(t *testing.T) {
|
||||
for _, mode := range []string{"other-expiry-due", "checked-all-pools", "ongoing", "renewed", "expired"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
oi := r.put("restored", false, true)
|
||||
r.restore(oi)
|
||||
before := r.fileInfo(oi.VersionID)
|
||||
if mode == "other-expiry-due" {
|
||||
meta, err := globalBucketMetadataSys.Get(r.bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.LifecycleConfigXML = []byte(`<LifecycleConfiguration><Rule><ID>delete</ID><Status>Enabled</Status><Filter><Prefix></Prefix></Filter><Expiration><Date>2020-01-01T00:00:00Z</Date></Expiration></Rule></LifecycleConfiguration>`)
|
||||
if err := meta.Save(t.Context(), r.z); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalBucketMetadataSys.Set(r.bucket, meta)
|
||||
}
|
||||
header := completedRestoreObj(UTCNow().Add(48 * time.Hour)).String()
|
||||
switch mode {
|
||||
case "ongoing":
|
||||
header = ongoingRestoreObj().String()
|
||||
case "expired":
|
||||
header = ""
|
||||
}
|
||||
if header != "" {
|
||||
opts := r.opts(oi.VersionID)
|
||||
opts.MTime = oi.ModTime
|
||||
opts.EvalMetadataFn = func(o *ObjectInfo, _ error) (ReplicateDecision, error) {
|
||||
o.UserDefined[xhttp.AmzRestore] = header
|
||||
return ReplicateDecision{}, nil
|
||||
}
|
||||
if _, err := r.z.PutObjectMetadata(t.Context(), r.bucket, r.object, opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var err error
|
||||
if mode == "checked-all-pools" {
|
||||
opts := r.opts(oi.VersionID)
|
||||
opts.Expiration = ExpirationOptions{Expire: true, CheckedAllPools: true}
|
||||
opts.Transition.ExpireRestored = true
|
||||
_, err = r.z.serverPools[0].getHashedSet(r.object).DeleteObject(t.Context(), r.bucket, r.object, opts)
|
||||
} else {
|
||||
err = expireTransitionedObject(t.Context(), r.z, &oi, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner)
|
||||
}
|
||||
after := r.fileInfo(oi.VersionID)
|
||||
if mode == "expired" {
|
||||
// Control: an elapsed deadline is the reason itself.
|
||||
if err != nil || after.Metadata[xhttp.AmzRestore] != "" || r.dataDirs(before.DataDir) != 0 {
|
||||
t.Errorf("expired restored copy not removed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Logf("cleanup=%v header=%q", err, after.Metadata[xhttp.AmzRestore])
|
||||
if err == nil || after.Metadata[xhttp.AmzRestore] != header || r.dataDirs(before.DataDir) != len(r.z.serverPools[0].getHashedSet(r.object).getDisks()) {
|
||||
t.Error("cache cleanup removed a restored copy whose own deadline has not passed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Controls: an ordinary POST restore, a renewal, and a client's multipart
|
||||
// upload of the same key while a restore runs.
|
||||
func TestRestorePOSTControls(t *testing.T) {
|
||||
t.Run("restore", func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi, body := restoreIsolationSeed(t, r, 0, false, true)
|
||||
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); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
|
||||
if err != nil || info.IsRemote() || info.RestoreOngoing || info.UserDefined[restoreIsolationAttemptKey] != "" {
|
||||
t.Fatalf("restored status: %q %q, %v", info.UserDefined[xhttp.AmzRestore], info.UserDefined[restoreIsolationAttemptKey], err)
|
||||
}
|
||||
if !info.ModTime.Equal(oi.ModTime) || info.ETag != oi.ETag || info.UserDefined["x-amz-meta-preserve"] != "keep-me" {
|
||||
t.Error("restore changed the version identity or metadata")
|
||||
}
|
||||
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
||||
t.Errorf("restored contents: %v", err)
|
||||
}
|
||||
if uploads := restoreIsolationUploads(t, r, 0); len(uploads) != 0 {
|
||||
t.Errorf("completed restore left its upload: %v", uploads)
|
||||
}
|
||||
})
|
||||
t.Run("renewal", func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi, body := nativeRestoreSeed(t, r, 0, false, false)
|
||||
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tier := globalTierConfigMgr.drivercache["WARM"].(*restoreTestTier)
|
||||
before := r.fileInfo(oi.VersionID)
|
||||
gets := tier.gets.Load()
|
||||
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())
|
||||
}
|
||||
select {
|
||||
case err := <-layer.done:
|
||||
t.Fatalf("renewal restored again: %v", err)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
after := r.fileInfo(oi.VersionID)
|
||||
if after.DataDir != before.DataDir || !after.ModTime.Equal(before.ModTime) || after.Metadata[xhttp.AmzRestore] == "" {
|
||||
t.Error("renewal changed the restored copy")
|
||||
}
|
||||
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) || tier.gets.Load() != gets {
|
||||
t.Errorf("renewed copy not served locally: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("public-multipart-same-key", func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi, body := restoreIsolationSeed(t, r, 0, false, true)
|
||||
tier := newRestoreIsolationGateTier(5<<20, nil)
|
||||
globalTierConfigMgr.drivercache["WARM"] = tier
|
||||
defer tier.unblock()
|
||||
layer := newRestoreIsolationLayer(r)
|
||||
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
restoreIsolationEntered(t, tier)
|
||||
upload, err := r.z.NewMultipartUpload(t.Context(), r.bucket, r.object, ObjectOptions{Versioned: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uploads, err := r.z.ListMultipartUploads(t.Context(), r.bucket, r.object, "", "", "", 100)
|
||||
listed := false
|
||||
for _, u := range uploads.Uploads {
|
||||
listed = listed || u.UploadID == upload.UploadID
|
||||
}
|
||||
if err != nil || !listed {
|
||||
t.Fatalf("client upload not listed: %+v, %v", uploads.Uploads, err)
|
||||
}
|
||||
tier.unblock()
|
||||
if err := layer.wait(t); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Single-pool part writes wait for the object lock of a running restore.
|
||||
data := []byte("client data")
|
||||
part, err := r.z.PutObjectPart(t.Context(), r.bucket, r.object, upload.UploadID, 1, mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts, err := r.z.ListObjectParts(t.Context(), r.bucket, r.object, upload.UploadID, 0, 10, ObjectOptions{})
|
||||
if err != nil || len(parts.Parts) != 1 {
|
||||
t.Fatalf("client parts: %+v, %v", parts, err)
|
||||
}
|
||||
newer, err := r.z.CompleteMultipartUpload(t.Context(), r.bucket, r.object, upload.UploadID, []CompletePart{{PartNumber: 1, ETag: part.ETag}}, ObjectOptions{Versioned: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, got, err := r.read(newer.VersionID); err != nil || !bytes.Equal(got, data) {
|
||||
t.Errorf("client version: %v", err)
|
||||
}
|
||||
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
|
||||
t.Errorf("restored version: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,935 @@
|
||||
// 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"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
"github.com/minio/minio/internal/event"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/pubsub"
|
||||
"github.com/minio/mux"
|
||||
)
|
||||
|
||||
func nativeRestoreSeed(t *testing.T, r *restoreTest, pool int, null, multipart bool) (ObjectInfo, []byte) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
opts := ObjectOptions{Versioned: !null, MTime: UTCNow().Add(-2 * time.Hour), UserDefined: map[string]string{
|
||||
"content-type": "application/x-restore-test", "x-amz-meta-preserve": "keep-me",
|
||||
}}
|
||||
body := bytes.Repeat([]byte("restore-payload"), 100000)
|
||||
var oi ObjectInfo
|
||||
var err error
|
||||
if multipart {
|
||||
parts := [][]byte{bytes.Repeat([]byte("a"), 5<<20), []byte("last-part")}
|
||||
body = bytes.Join(parts, nil)
|
||||
upload, err := r.z.serverPools[pool].NewMultipartUpload(ctx, r.bucket, r.object, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var complete []CompletePart
|
||||
for i, data := range parts {
|
||||
p, err := r.z.serverPools[pool].PutObjectPart(ctx, r.bucket, r.object, upload.UploadID, i+1,
|
||||
mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
complete = append(complete, CompletePart{PartNumber: i + 1, ETag: p.ETag})
|
||||
}
|
||||
oi, err = r.z.serverPools[pool].CompleteMultipartUpload(ctx, r.bucket, r.object, upload.UploadID, complete, opts)
|
||||
if err == nil && len(oi.Parts) != 2 {
|
||||
t.Fatalf("fixture has %d parts, want 2", len(oi.Parts))
|
||||
}
|
||||
} else {
|
||||
oi, err = r.z.serverPools[pool].PutObject(ctx, r.bucket, r.object,
|
||||
mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), opts)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oi.VersionID = restoreTestVersionKey(oi.VersionID)
|
||||
topts := r.opts(oi.VersionID)
|
||||
topts.MTime = oi.ModTime
|
||||
topts.Transition = TransitionOptions{Tier: "WARM", ETag: oi.ETag}
|
||||
if err := r.z.serverPools[pool].TransitionObject(ctx, r.bucket, r.object, topts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Use the production metadata writer to mark the request ongoing.
|
||||
topts.Transition = TransitionOptions{}
|
||||
topts.EvalMetadataFn = func(o *ObjectInfo, _ error) (ReplicateDecision, error) {
|
||||
o.UserDefined[xhttp.AmzRestore] = ongoingRestoreObj().String()
|
||||
return ReplicateDecision{}, nil
|
||||
}
|
||||
if _, err := r.z.PutObjectMetadata(ctx, r.bucket, r.object, topts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return oi, body
|
||||
}
|
||||
|
||||
func nativeRestoreSnapshot(t *testing.T, r *restoreTest) [][]FileInfo {
|
||||
t.Helper()
|
||||
result := make([][]FileInfo, len(r.z.serverPools))
|
||||
for p, pool := range r.z.serverPools {
|
||||
raw, err := pool.getHashedSet(r.object).getDisks()[0].ReadXL(t.Context(), r.bucket, r.object, false)
|
||||
if errors.Is(err, errFileNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
infos, err := getFileInfoVersions(raw.Buf, r.bucket, r.object, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result[p] = append(infos.Versions, infos.FreeVersions...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func nativeRestoreOptions(r *restoreTest, id string) ObjectOptions {
|
||||
opts := r.opts(id)
|
||||
opts.Transition = TransitionOptions{RestoreRequest: &RestoreObjectRequest{Days: 1}}
|
||||
return opts
|
||||
}
|
||||
|
||||
type nativeRestoreFailTier struct {
|
||||
WarmBackend
|
||||
failure error
|
||||
}
|
||||
|
||||
func (b nativeRestoreFailTier) Get(context.Context, string, remoteVersionID, WarmBackendGetOpts) (io.ReadCloser, error) {
|
||||
return nil, b.failure
|
||||
}
|
||||
|
||||
func TestNativeRestorePools(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
for _, multipart := range []bool{false, true} {
|
||||
for _, fail := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("pools=%d/multipart=%v/failure=%v", pools, multipart, fail), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
|
||||
oi, body := nativeRestoreSeed(t, r, 0, false, multipart)
|
||||
before := nativeRestoreSnapshot(t, r)
|
||||
tier := globalTierConfigMgr.drivercache["WARM"].(*restoreTestTier)
|
||||
failure := errors.New("injected remote download failure")
|
||||
if fail {
|
||||
globalTierConfigMgr.drivercache["WARM"] = nativeRestoreFailTier{tier, failure}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
err := r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
|
||||
if fail {
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("restore error=%v, want remote failure", err)
|
||||
}
|
||||
got, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
|
||||
if err != nil || got.UserDefined[xhttp.AmzRestore] != "" {
|
||||
t.Fatalf("failed restore did not clear ongoing status: %v, %q", err, got.UserDefined[xhttp.AmzRestore])
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reads := tier.gets.Load()
|
||||
_, got, err := r.read(oi.VersionID)
|
||||
if err != nil || !bytes.Equal(got, body) {
|
||||
t.Fatalf("restored contents differ: %v", err)
|
||||
}
|
||||
if tier.gets.Load() != reads {
|
||||
t.Error("restored read still fetched the remote tier")
|
||||
}
|
||||
}
|
||||
after := nativeRestoreSnapshot(t, r)
|
||||
if len(after[0]) != len(before[0]) || after[0][0].VersionID != before[0][0].VersionID || after[0][0].TransitionedObjName != before[0][0].TransitionedObjName {
|
||||
t.Fatal("restore changed the version stack or remote object identity")
|
||||
}
|
||||
if after[0][0].Metadata["content-type"] != "application/x-restore-test" || after[0][0].Metadata["x-amz-meta-preserve"] != "keep-me" {
|
||||
t.Error("restore lost original metadata")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeRestoreVersionRouting(t *testing.T) {
|
||||
for _, pool := range []int{0, 1} {
|
||||
for _, null := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("target-pool=%d/null=%v", pool, null), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 2, versioning.Enabled)
|
||||
oi, body := nativeRestoreSeed(t, r, pool, null, false)
|
||||
latest := putConsistencyObject(t, r.z, r.bucket, r.object, 1-pool, "latest-stays", ObjectOptions{Versioned: true, MTime: oi.ModTime.Add(time.Hour)})
|
||||
before := nativeRestoreSnapshot(t, r)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, got, err := r.read(oi.VersionID)
|
||||
if err != nil || !bytes.Equal(got, body) {
|
||||
t.Fatalf("requested version not restored: %v", err)
|
||||
}
|
||||
after := nativeRestoreSnapshot(t, r)
|
||||
if !reflect.DeepEqual(before[1-pool], after[1-pool]) {
|
||||
t.Error("restore changed the latest version in another pool")
|
||||
}
|
||||
if len(after[pool]) != 1 || after[pool][0].VersionID != before[pool][0].VersionID {
|
||||
t.Error("restore created a version alias")
|
||||
}
|
||||
gotInfo, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, ObjectOptions{})
|
||||
if err != nil || gotInfo.VersionID != latest.VersionID {
|
||||
t.Errorf("latest changed: %v", err)
|
||||
}
|
||||
before = nativeRestoreSnapshot(t, r)
|
||||
if err := r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, mustGetUUID())); !isErrVersionNotFound(err) && !isErrObjectNotFound(err) {
|
||||
t.Errorf("missing version: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, nativeRestoreSnapshot(t, r)) {
|
||||
t.Error("missing version restore mutated metadata")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeRestoreInitialFailurePreservesMetadata(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
oi := putConsistencyObject(t, r.z, r.bucket, r.object, 0, "original-latest", ObjectOptions{Versioned: true, UserDefined: map[string]string{"content-type": "application/x-original", "x-amz-meta-preserve": "keep"}})
|
||||
before := nativeRestoreSnapshot(t, r)
|
||||
for _, missing := range []string{mustGetUUID(), nullVersionID} {
|
||||
err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, missing))
|
||||
if !isErrVersionNotFound(err) {
|
||||
t.Errorf("missing restore %s: %v", missing, err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, nativeRestoreSnapshot(t, r)) {
|
||||
t.Error("initial restore failure changed stored metadata or version stack")
|
||||
}
|
||||
}
|
||||
_, body, err := r.read(oi.VersionID)
|
||||
if err != nil || string(body) != "original-latest" {
|
||||
t.Errorf("initial restore failure changed content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeRestoreWithoutOuterLock(t *testing.T) {
|
||||
for _, mode := range []string{"single-part", "multipart", "failure-metadata"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
oi, _ := nativeRestoreSeed(t, r, 0, false, mode == "multipart")
|
||||
tier := globalTierConfigMgr.drivercache["WARM"]
|
||||
failure := errors.New("injected remote failure")
|
||||
if mode == "failure-metadata" {
|
||||
globalTierConfigMgr.drivercache["WARM"] = nativeRestoreFailTier{tier, failure}
|
||||
}
|
||||
er := r.z.serverPools[0].getHashedSet(r.object)
|
||||
lock := er.NewNSLock(r.bucket, r.object)
|
||||
lkctx, err := lock.GetLock(t.Context(), globalOperationTimeout)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer lock.Unlock(lkctx)
|
||||
before := nativeRestoreSnapshot(t, r)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
err = er.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
|
||||
if err == nil {
|
||||
t.Error("restore wrote through another caller's lock")
|
||||
}
|
||||
if !reflect.DeepEqual(before, nativeRestoreSnapshot(t, r)) {
|
||||
t.Error("NoLock=false failed to protect the stored version")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type nativeRestoreHTTPResult struct {
|
||||
opts ObjectOptions
|
||||
err error
|
||||
}
|
||||
type nativeRestoreHTTPObserver struct {
|
||||
ObjectLayer
|
||||
done chan nativeRestoreHTTPResult
|
||||
}
|
||||
|
||||
func (o nativeRestoreHTTPObserver) RestoreTransitionedObject(ctx context.Context, bucket, object string, opts ObjectOptions) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
err := o.ObjectLayer.RestoreTransitionedObject(ctx, bucket, object, opts)
|
||||
o.done <- nativeRestoreHTTPResult{opts, err}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestNativeRestorePOSTVersionIdentity(t *testing.T) {
|
||||
for _, state := range []versioning.State{versioning.Enabled, versioning.Suspended} {
|
||||
for _, target := range []int{0, 1} {
|
||||
for _, split := range []bool{false, true} {
|
||||
for _, multipart := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/target=%d/split=%v/multipart=%v", state, target, split, multipart), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 2, state)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi, body := nativeRestoreSeed(t, r, target, !multipart, multipart)
|
||||
metaOpts := r.opts(oi.VersionID)
|
||||
metaOpts.MTime = oi.ModTime
|
||||
metaOpts.EvalMetadataFn = func(o *ObjectInfo, _ error) (ReplicateDecision, error) {
|
||||
o.UserDefined[xhttp.AmzRestore] = ""
|
||||
return ReplicateDecision{}, nil
|
||||
}
|
||||
if _, err := r.z.PutObjectMetadata(t.Context(), r.bucket, r.object, metaOpts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if split {
|
||||
putConsistencyObject(t, r.z, r.bucket, r.object, 1-target, "newer-version", ObjectOptions{Versioned: true, MTime: oi.ModTime.Add(time.Hour)})
|
||||
}
|
||||
before := nativeRestoreSnapshot(t, r)
|
||||
observed := nativeRestoreHTTPObserver{r.z, make(chan nativeRestoreHTTPResult, 1)}
|
||||
api := objectAPIHandlers{ObjectAPI: func() ObjectLayer { return observed }}
|
||||
events := make(chan event.Event, 1)
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
if err := globalHTTPListen.Subscribe(pubsub.MaskFromMaskable(event.ObjectRestoreCompleted), events, done, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bodyXML := []byte(`<RestoreRequest xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Days>1</Days></RestoreRequest>`)
|
||||
path := "/" + r.bucket + "/" + r.object + "?restore"
|
||||
if split {
|
||||
path += "&versionId=" + url.QueryEscape(oi.VersionID)
|
||||
}
|
||||
req, err := newTestSignedRequestV4(http.MethodPost, path, int64(len(bodyXML)), bytes.NewReader(bodyXML), globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = req.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = mux.SetURLVars(req, map[string]string{"bucket": r.bucket, "object": r.object})
|
||||
rec := httptest.NewRecorder()
|
||||
api.PostRestoreObjectHandler(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST restore: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
select {
|
||||
case result := <-observed.done:
|
||||
if result.err != nil {
|
||||
t.Fatal(result.err)
|
||||
}
|
||||
if result.opts.VersionID != oi.VersionID || result.opts.Versioned != (state == versioning.Enabled) || result.opts.VersionSuspended != (state == versioning.Suspended) {
|
||||
t.Errorf("background options lost version context: %+v", result.opts)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("background restore did not return")
|
||||
}
|
||||
select {
|
||||
case <-events:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("no restore completion event")
|
||||
}
|
||||
after := nativeRestoreSnapshot(t, r)
|
||||
if len(after[target]) != 1 || after[target][0].VersionID != before[target][0].VersionID || after[target][0].TransitionedObjName != before[target][0].TransitionedObjName {
|
||||
t.Fatal("POST restore added a version alias")
|
||||
}
|
||||
if !reflect.DeepEqual(before[1-target], after[1-target]) {
|
||||
t.Error("POST changed another pool's version")
|
||||
}
|
||||
_, got, err := r.read(oi.VersionID)
|
||||
if err != nil || !bytes.Equal(got, body) {
|
||||
t.Fatalf("POST-restored contents differ: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A restore request is internal context, not an exception for multipart
|
||||
// overwrites: a native restore never completes an upload, so no completion,
|
||||
// whatever restore context it carries, writes an existing version back.
|
||||
func TestNativeRestoreMultipartOverwriteBoundary(t *testing.T) {
|
||||
for _, mode := range []string{"matching-restore", "ordinary-complete", "restore-without-attempt", "other-attempt", "not-tiered", "changed-time", "changed-size", "changed-etag"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
oi, body := nativeRestoreSeed(t, r, 0, false, true)
|
||||
pool := r.z.serverPools[0]
|
||||
if mode == "not-tiered" {
|
||||
er := pool.getHashedSet(r.object)
|
||||
fi, _, _, err := er.getObjectFileInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi.TransitionStatus = lifecycle.TransitionPending
|
||||
for _, disk := range er.getDisks() {
|
||||
if err := disk.WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
current, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts := putRestoreOpts(&RestoreObjectRequest{Days: 1}, current, r.opts(oi.VersionID))
|
||||
opts.Transition.RestoreRequest = &RestoreObjectRequest{Days: 1}
|
||||
opts.PreserveETag = current.ETag
|
||||
if mode == "ordinary-complete" {
|
||||
opts.Transition.RestoreRequest = nil
|
||||
}
|
||||
if mode == "changed-time" {
|
||||
opts.MTime = opts.MTime.Add(time.Second)
|
||||
}
|
||||
// Hold the ETag constant for the size case, so only size can reject it.
|
||||
if mode == "changed-size" {
|
||||
body = append(body, 'x')
|
||||
}
|
||||
if mode == "changed-etag" {
|
||||
opts.PreserveETag = "different-etag"
|
||||
}
|
||||
// The attempt claims what its upload claims; only the matching
|
||||
// mode claims exactly the stored version.
|
||||
if mode != "ordinary-complete" && mode != "restore-without-attempt" {
|
||||
opts.Transition.attempt = &restoreAttempt{ID: mustGetUUID(), VersionID: oi.VersionID, ETag: opts.PreserveETag, Size: current.Size, ModTime: opts.MTime}
|
||||
}
|
||||
uopts := opts
|
||||
uopts.MTime = time.Time{}
|
||||
upload, err := pool.NewMultipartUpload(t.Context(), r.bucket, r.object, uopts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var complete []CompletePart
|
||||
for i, data := range [][]byte{body[:5<<20], body[5<<20:]} {
|
||||
part, err := pool.PutObjectPart(t.Context(), r.bucket, r.object, upload.UploadID, i+1,
|
||||
mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{Transition: opts.Transition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
complete = append(complete, CompletePart{PartNumber: i + 1, ETag: part.ETag})
|
||||
}
|
||||
if mode != "changed-size" && getCompleteMultipartMD5(complete) != current.ETag {
|
||||
t.Fatal("fixture multipart ETag does not match original")
|
||||
}
|
||||
if mode == "other-attempt" {
|
||||
other := *opts.Transition.attempt
|
||||
other.ID = mustGetUUID()
|
||||
opts.Transition.attempt = &other
|
||||
}
|
||||
before := nativeRestoreSnapshot(t, r)
|
||||
_, err = pool.CompleteMultipartUpload(t.Context(), r.bucket, r.object, upload.UploadID, complete, opts)
|
||||
if !errors.Is(err, errAuthentication) {
|
||||
t.Fatalf("existing version overwrite: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, nativeRestoreSnapshot(t, r)) {
|
||||
t.Error("rejected completion changed the target")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Hold the download after the restore snapshot has been read. Object Lock
|
||||
// updates must serialize with both successful rehydration and failure cleanup.
|
||||
type nativeRestoreBlockedTier struct {
|
||||
WarmBackend
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
failure error
|
||||
afterFirstPart bool
|
||||
}
|
||||
|
||||
func (b *nativeRestoreBlockedTier) Get(ctx context.Context, object string, version remoteVersionID, opts WarmBackendGetOpts) (io.ReadCloser, error) {
|
||||
if b.afterFirstPart {
|
||||
r, err := b.WarmBackend.Get(ctx, object, version, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &nativeRestorePartGateReader{ReadCloser: r, gate: b, ctx: ctx, remaining: 5 << 20}, nil
|
||||
}
|
||||
b.once.Do(func() { close(b.entered) })
|
||||
select {
|
||||
case <-b.release:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if b.failure != nil {
|
||||
return nil, b.failure
|
||||
}
|
||||
return b.WarmBackend.Get(ctx, object, version, opts)
|
||||
}
|
||||
|
||||
func nativeRestoreLockRequest(ctx context.Context, t *testing.T, r *restoreTest, id, kind string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
sum := md5.Sum(body)
|
||||
req, err := newTestSignedRequestV4(http.MethodPut, "/"+r.bucket+"/"+r.object+"?"+kind+"&versionId="+url.QueryEscape(id), int64(len(body)), bytes.NewReader(body), globalActiveCred.AccessKey, globalActiveCred.SecretKey, map[string]string{xhttp.ContentMD5: base64.StdEncoding.EncodeToString(sum[:])})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := req.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = mux.SetURLVars(req.WithContext(ctx), map[string]string{"bucket": r.bucket, "object": r.object})
|
||||
rec := httptest.NewRecorder()
|
||||
api := objectAPIHandlers{ObjectAPI: func() ObjectLayer { return r.z }}
|
||||
if kind == "legal-hold" {
|
||||
api.PutObjectLegalHoldHandler(rec, req)
|
||||
} else {
|
||||
api.PutObjectRetentionHandler(rec, req)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
// A native restore downloads without the object lock. An Object Lock
|
||||
// update made meanwhile commits at once, and the restore, whether it
|
||||
// completes or fails, keeps it.
|
||||
func TestNativeRestoreKeepsObjectLockUpdate(t *testing.T) {
|
||||
for _, kind := range []string{"legal-hold", "retention"} {
|
||||
for _, fail := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/failure=%v", kind, fail), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
enableBucketObjectLock(t, r.bucket)
|
||||
oi, _ := nativeRestoreSeed(t, r, 0, false, true)
|
||||
initial := []byte(`<LegalHold xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>OFF</Status></LegalHold>`)
|
||||
updated := []byte(`<LegalHold xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>ON</Status></LegalHold>`)
|
||||
keys := []string{strings.ToLower(xhttp.AmzObjectLockLegalHold), ReservedMetadataPrefixLower + ObjectLockLegalHoldTimestamp}
|
||||
if kind == "retention" {
|
||||
initial = retentionBody("COMPLIANCE", UTCNow().Add(24*time.Hour))
|
||||
updated = retentionBody("COMPLIANCE", UTCNow().Add(48*time.Hour))
|
||||
keys = []string{strings.ToLower(xhttp.AmzObjectLockMode), strings.ToLower(xhttp.AmzObjectLockRetainUntilDate), ReservedMetadataPrefixLower + ObjectLockRetentionTimestamp}
|
||||
}
|
||||
if rec := nativeRestoreLockRequest(t.Context(), t, r, oi.VersionID, kind, initial); rec.Code != http.StatusOK {
|
||||
t.Fatalf("initial protection: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
before, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocked := &nativeRestoreBlockedTier{WarmBackend: globalTierConfigMgr.drivercache["WARM"], entered: make(chan struct{}), release: make(chan struct{})}
|
||||
if fail {
|
||||
blocked.failure = errors.New("injected download failure after snapshot")
|
||||
}
|
||||
globalTierConfigMgr.drivercache["WARM"] = blocked
|
||||
var release sync.Once
|
||||
unblock := func() { release.Do(func() { close(blocked.release) }) }
|
||||
defer unblock()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
result <- r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
|
||||
}()
|
||||
select {
|
||||
case <-blocked.entered:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("restore never started download")
|
||||
}
|
||||
// The download holds no object lock: an Object Lock update
|
||||
// commits at once, and the restore's commit keeps it.
|
||||
updateCtx, updateCancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
rec := nativeRestoreLockRequest(updateCtx, t, r, oi.VersionID, kind, updated)
|
||||
updateCancel()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("Object Lock update during the restore download: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
readOpts := r.opts(oi.VersionID)
|
||||
readOpts.NoLock = true
|
||||
during, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, readOpts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unblock()
|
||||
select {
|
||||
case err := <-result:
|
||||
if !errors.Is(err, blocked.failure) {
|
||||
t.Fatalf("restore returned %v, want %v", err, blocked.failure)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("restore failed to finish")
|
||||
}
|
||||
after, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if during.UserDefined[key] == "" || after.UserDefined[key] != during.UserDefined[key] {
|
||||
t.Errorf("restore changed protection %s: %q -> %q", key, during.UserDefined[key], after.UserDefined[key])
|
||||
}
|
||||
}
|
||||
changed := keys[0]
|
||||
if kind == "retention" {
|
||||
changed = keys[1]
|
||||
}
|
||||
if after.UserDefined[changed] == before.UserDefined[changed] {
|
||||
t.Error("the update made during the download was not kept")
|
||||
}
|
||||
if !after.ModTime.Equal(before.ModTime) || after.ETag != before.ETag || after.Size != before.Size {
|
||||
t.Error("Object Lock update changed content identity")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The POST handler marks a version ongoing before its background restore
|
||||
// takes the object lock, and only that restore clears the marker. A restore
|
||||
// queued behind another version's download must keep waiting: one that gave up
|
||||
// after a lock timeout would leave the marker for every later POST to refuse.
|
||||
// The wait still ends with its context.
|
||||
func TestNativeRestoreQueuedBehindDownload(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
t.Run(fmt.Sprintf("pools=%d", pools), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
|
||||
first, _ := nativeRestoreSeed(t, r, 0, false, false)
|
||||
queued, body := nativeRestoreSeed(t, r, pools-1, false, false)
|
||||
prev := globalRestoreLockTimeout
|
||||
globalRestoreLockTimeout = newDynamicTimeout(50*time.Millisecond, 50*time.Millisecond)
|
||||
t.Cleanup(func() { globalRestoreLockTimeout = prev })
|
||||
tier := globalTierConfigMgr.drivercache["WARM"].(*restoreTestTier)
|
||||
blocked := &nativeRestoreBlockedTier{WarmBackend: tier, entered: make(chan struct{}), release: make(chan struct{})}
|
||||
globalTierConfigMgr.drivercache["WARM"] = blocked
|
||||
var once sync.Once
|
||||
unblock := func() { once.Do(func() { close(blocked.release) }) }
|
||||
defer unblock()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
firstDone := make(chan error, 1)
|
||||
go func() {
|
||||
firstDone <- r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, first.VersionID))
|
||||
}()
|
||||
select {
|
||||
case <-blocked.entered:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("first restore never reached the tier")
|
||||
}
|
||||
short, shortCancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
||||
defer shortCancel()
|
||||
ended := make(chan error, 1)
|
||||
go func() {
|
||||
ended <- r.z.RestoreTransitionedObject(short, r.bucket, r.object, nativeRestoreOptions(r, queued.VersionID))
|
||||
}()
|
||||
select {
|
||||
case err := <-ended:
|
||||
if err == nil {
|
||||
t.Fatal("restore finished without the object lock")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("restore kept waiting for the lock after its context ended")
|
||||
}
|
||||
queuedDone := make(chan error, 1)
|
||||
go func() {
|
||||
queuedDone <- r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, queued.VersionID))
|
||||
}()
|
||||
select {
|
||||
case err := <-queuedDone:
|
||||
t.Fatalf("queued restore gave up behind the download: %v", err)
|
||||
case <-time.After(10 * 50 * time.Millisecond):
|
||||
}
|
||||
unblock()
|
||||
for _, done := range []chan error{firstDone, queuedDone} {
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("restore did not finish")
|
||||
}
|
||||
}
|
||||
info, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(queued.VersionID))
|
||||
if err != nil || info.RestoreOngoing {
|
||||
t.Fatalf("queued version still ongoing after restore: %q, %v", info.UserDefined[xhttp.AmzRestore], err)
|
||||
}
|
||||
reads := tier.gets.Load()
|
||||
if _, got, err := r.read(queued.VersionID); err != nil || !bytes.Equal(got, body) {
|
||||
t.Fatalf("queued restore contents: %v", err)
|
||||
}
|
||||
if tier.gets.Load() != reads {
|
||||
t.Error("queued version was not restored locally")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type nativeRestorePartGateReader struct {
|
||||
io.ReadCloser
|
||||
gate *nativeRestoreBlockedTier
|
||||
ctx context.Context
|
||||
remaining int
|
||||
}
|
||||
|
||||
func (r *nativeRestorePartGateReader) Read(p []byte) (int, error) {
|
||||
if r.remaining > 0 {
|
||||
if len(p) > r.remaining {
|
||||
p = p[:r.remaining]
|
||||
}
|
||||
n, err := r.ReadCloser.Read(p)
|
||||
r.remaining -= n
|
||||
return n, err
|
||||
}
|
||||
r.gate.once.Do(func() { close(r.gate.entered) })
|
||||
select {
|
||||
case <-r.gate.release:
|
||||
case <-r.ctx.Done():
|
||||
return 0, r.ctx.Err()
|
||||
}
|
||||
return r.ReadCloser.Read(p)
|
||||
}
|
||||
|
||||
// Invalid upload paths must retain the backend's normal error precedence,
|
||||
// rather than reacquiring the object lock as the upload lock.
|
||||
func TestMultipartCompletionLockAlias(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
for _, id := range []string{"", ".", "./"} {
|
||||
t.Run(fmt.Sprintf("pools=%d/id=%q", pools, id), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
|
||||
for _, missingBucket := range []bool{false, true} {
|
||||
bucket := r.bucket
|
||||
if missingBucket {
|
||||
bucket = "missing-bucket"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
_, err := r.z.CompleteMultipartUpload(ctx, bucket, r.object, id, nil, ObjectOptions{})
|
||||
cancel()
|
||||
switch {
|
||||
case id != "":
|
||||
if !errors.As(err, &MalformedUploadID{}) {
|
||||
t.Errorf("malformed upload: %v", err)
|
||||
}
|
||||
case missingBucket:
|
||||
if !isErrBucketNotFound(err) {
|
||||
t.Errorf("missing bucket: %v", err)
|
||||
}
|
||||
default:
|
||||
if !errors.As(err, &InvalidUploadID{}) {
|
||||
t.Errorf("invalid upload: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type nativeRestoreReadHook struct {
|
||||
io.ReadCloser
|
||||
remaining int
|
||||
mutate func()
|
||||
}
|
||||
|
||||
func (r *nativeRestoreReadHook) Read(p []byte) (int, error) {
|
||||
n, err := r.ReadCloser.Read(p)
|
||||
r.remaining -= n
|
||||
if r.remaining == 0 && r.mutate != nil {
|
||||
f := r.mutate
|
||||
r.mutate = nil
|
||||
f()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
type nativeRestoreReadHookTier struct {
|
||||
WarmBackend
|
||||
size int
|
||||
mutate func()
|
||||
}
|
||||
|
||||
func (b nativeRestoreReadHookTier) Get(ctx context.Context, object string, version remoteVersionID, opts WarmBackendGetOpts) (io.ReadCloser, error) {
|
||||
rd, err := b.WarmBackend.Get(ctx, object, version, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &nativeRestoreReadHook{rd, b.size, b.mutate}, nil
|
||||
}
|
||||
|
||||
// Remove only the addressed entry from a majority of disks, keeping shards
|
||||
// and the healthy newer version. Return errors to the synchronous caller.
|
||||
func nativeRestoreLoseMetadata(ctx context.Context, disks []StorageAPI, bucket, object, id string) error {
|
||||
for _, disk := range disks[1:] {
|
||||
raw, err := disk.ReadXL(ctx, bucket, object, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var meta xlMetaV2
|
||||
if err := meta.Load(raw.Buf); err != nil {
|
||||
return err
|
||||
}
|
||||
del := FileInfo{VersionID: id}
|
||||
del.SetSkipTierFreeVersion()
|
||||
if _, err := meta.DeleteVersion(del); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := meta.AppendTo(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := disk.WriteAll(ctx, bucket, filepath.Join(object, xlStorageFormatFile), data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// A target can lose metadata quorum after the restore snapshot. Nested
|
||||
// completion/retention reads must not turn rehydration into remote GC.
|
||||
func TestNativeRestoreCompletionReadSafety(t *testing.T) {
|
||||
for _, null := range []bool{false, true} {
|
||||
for _, multipart := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("null=%v/multipart=%v", null, multipart), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
oi, body := nativeRestoreSeed(t, r, 0, null, multipart)
|
||||
latest := r.put("newer", false, false)
|
||||
enableBucketObjectLock(t, r.bucket)
|
||||
disks := r.z.serverPools[0].getHashedSet(r.object).getDisks()
|
||||
var mutationErr error
|
||||
mutated := false
|
||||
mutate := func() {
|
||||
mutated = true
|
||||
mutationErr = nativeRestoreLoseMetadata(t.Context(), disks, r.bucket, r.object, oi.VersionID)
|
||||
}
|
||||
tier := globalTierConfigMgr.drivercache["WARM"].(*restoreTestTier)
|
||||
globalTierConfigMgr.drivercache["WARM"] = nativeRestoreReadHookTier{tier, len(body), mutate}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
err := r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("restore timed out: %v", err)
|
||||
}
|
||||
if !mutated || mutationErr != nil {
|
||||
t.Fatalf("invalid injection: mutated=%v err=%v", mutated, mutationErr)
|
||||
}
|
||||
t.Logf("restore after addressed metadata lost: %v", err)
|
||||
for i, disk := range disks {
|
||||
raw, e := disk.ReadXL(t.Context(), r.bucket, r.object, true)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
versions, e := getFileInfoVersions(raw.Buf, r.bucket, r.object, false)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if len(versions.FreeVersions) > 0 {
|
||||
t.Errorf("disk %d restore manufactured %d remote GC entries", i, len(versions.FreeVersions))
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
reads := tier.gets.Load()
|
||||
_, got, readErr := r.read(oi.VersionID)
|
||||
if readErr != nil || !bytes.Equal(got, body) || tier.gets.Load() != reads {
|
||||
t.Errorf("successful restore did not rehydrate its own content: %v", readErr)
|
||||
}
|
||||
}
|
||||
_, got, readErr := r.read(latest.VersionID)
|
||||
if readErr != nil || !bytes.Equal(got, r.bodies["newer"]) {
|
||||
t.Errorf("restore changed the other version: %v", readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nativeRestoreStatusHook runs afterRead once POST has read the version and
|
||||
// before POST records its status on it.
|
||||
type nativeRestoreStatusHook struct {
|
||||
ObjectLayer
|
||||
afterRead func()
|
||||
readCalled bool
|
||||
backgroundCalled atomic.Bool
|
||||
}
|
||||
|
||||
func (h *nativeRestoreStatusHook) GetObjectInfo(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
oi, err := h.ObjectLayer.GetObjectInfo(ctx, bucket, object, opts)
|
||||
if err == nil && !h.readCalled {
|
||||
h.readCalled = true
|
||||
h.afterRead()
|
||||
}
|
||||
return oi, err
|
||||
}
|
||||
|
||||
func (h *nativeRestoreStatusHook) RestoreTransitionedObject(context.Context, string, string, ObjectOptions) error {
|
||||
h.backgroundCalled.Store(true)
|
||||
return errors.New("unexpected background restore after failed status update")
|
||||
}
|
||||
|
||||
func TestNativeRestorePOSTReadSafety(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
for _, stage := range []string{"initial", "status-copy"} {
|
||||
t.Run(fmt.Sprintf("pools=%d/%s", pools, stage), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
|
||||
initConfigSubsystem(t.Context(), r.z)
|
||||
oi, _ := nativeRestoreSeed(t, r, 0, false, false)
|
||||
metaOpts := r.opts(oi.VersionID)
|
||||
metaOpts.MTime = oi.ModTime
|
||||
metaOpts.EvalMetadataFn = func(o *ObjectInfo, _ error) (ReplicateDecision, error) {
|
||||
o.UserDefined[xhttp.AmzRestore] = ""
|
||||
return ReplicateDecision{}, nil
|
||||
}
|
||||
if _, err := r.z.PutObjectMetadata(t.Context(), r.bucket, r.object, metaOpts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.put("newer", false, false)
|
||||
disks := r.z.serverPools[0].getHashedSet(r.object).getDisks()
|
||||
var before [][]byte
|
||||
mutate := func() {
|
||||
if err := nativeRestoreLoseMetadata(t.Context(), disks, r.bucket, r.object, oi.VersionID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before = restoreBoundarySnapshot(t, r)
|
||||
}
|
||||
hook := &nativeRestoreStatusHook{ObjectLayer: r.z, afterRead: mutate}
|
||||
if stage == "initial" {
|
||||
mutate()
|
||||
}
|
||||
api := objectAPIHandlers{ObjectAPI: func() ObjectLayer { return hook }}
|
||||
body := []byte(`<RestoreRequest xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Days>1</Days></RestoreRequest>`)
|
||||
req, err := newTestSignedRequestV4(http.MethodPost, "/"+r.bucket+"/"+r.object+"?restore&versionId="+url.QueryEscape(oi.VersionID), int64(len(body)), bytes.NewReader(body), globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := req.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = mux.SetURLVars(req, map[string]string{"bucket": r.bucket, "object": r.object})
|
||||
rec := httptest.NewRecorder()
|
||||
api.PostRestoreObjectHandler(rec, req)
|
||||
if rec.Code < 400 {
|
||||
t.Fatalf("POST acknowledged failed read: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if hook.readCalled != (stage == "status-copy") || hook.backgroundCalled.Load() {
|
||||
t.Fatal("request followed the wrong failure path")
|
||||
}
|
||||
if !reflect.DeepEqual(before, restoreBoundarySnapshot(t, r)) {
|
||||
t.Error("failed POST read changed stored metadata or free versions")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,900 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/pgsty/silo-pkg/v3/sync/errgroup"
|
||||
)
|
||||
|
||||
// A native restore runs in two phases. It downloads the tier copy of a
|
||||
// version and erasure-codes it into the tmp volume of the version's set
|
||||
// without the object lock O, then takes O only to check the version again
|
||||
// and publish the staged copy drive by drive, as heal does. Hot versions of
|
||||
// the key stay readable and writable during the download.
|
||||
//
|
||||
// The restore lock R of the version tells whether a restore of it runs: a
|
||||
// POST takes R before O, and the worker that rehydrates the version holds R
|
||||
// for its whole run. Nothing else takes R, so nothing holds O while it waits
|
||||
// for R. R is a liveness hint, not an exclusion: a lost refresh can let two
|
||||
// workers overlap. Only the checks under O decide what is written.
|
||||
|
||||
// restoreOwnerPrefix names the restore locks under minioMetaBucket.
|
||||
const restoreOwnerPrefix = "restore"
|
||||
|
||||
// restorePublicationDelays are the in-memory retries of a convergence after
|
||||
// the one under the commit's own lock hold, see recoverRestorePublication.
|
||||
var restorePublicationDelays = []time.Duration{100 * time.Millisecond, time.Second, 5 * time.Second, 30 * time.Second, 2 * time.Minute}
|
||||
|
||||
var (
|
||||
// errRestoreMoved: the version moved to another pool after the restore
|
||||
// staged its copy in this one.
|
||||
errRestoreMoved = errors.New("restored version moved to another pool")
|
||||
// errRestoreNotConverged: a publication that reached some drives but not
|
||||
// a write quorum could not be brought back to the unrestored version yet.
|
||||
errRestoreNotConverged = errors.New("restore publication did not converge")
|
||||
)
|
||||
|
||||
// restoreOwner is the restore lock R of one version, held by a POST that
|
||||
// registers an attempt and then by the worker that runs it. ctx ends when the
|
||||
// lock is released or when its holder learns that it lost it.
|
||||
type restoreOwner struct {
|
||||
lk RWLocker
|
||||
lkctx LockContext
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// release unlocks R once. It is a no-op on a nil owner.
|
||||
func (o *restoreOwner) release() {
|
||||
if o == nil {
|
||||
return
|
||||
}
|
||||
o.once.Do(func() {
|
||||
o.cancel()
|
||||
o.lk.Unlock(o.lkctx)
|
||||
})
|
||||
}
|
||||
|
||||
// restoreOwnerResource is the name of the restore lock of a version. Bucket
|
||||
// names hold no slash, object names no ".." element, and the version ID has
|
||||
// a fixed form, so the name is unambiguous.
|
||||
func restoreOwnerResource(bucket, object, versionID string) string {
|
||||
return pathJoin(restoreOwnerPrefix, bucket, object, restoreVersionID(versionID))
|
||||
}
|
||||
|
||||
// takeRestoreOwner takes the restore lock of a version. With a nil timeout it
|
||||
// waits for as long as ctx lives, as a direct caller does; otherwise it gives
|
||||
// up after timeout. ctx must outlive the restore that the lock is held for.
|
||||
func (z *erasureServerPools) takeRestoreOwner(ctx context.Context, bucket, object, versionID string, timeout *dynamicTimeout) (*restoreOwner, error) {
|
||||
for {
|
||||
// A new instance for each try: a distributed unlock retries in the
|
||||
// background and could release a later try of the same instance.
|
||||
lk := z.NewNSLock(minioMetaBucket, restoreOwnerResource(bucket, object, versionID))
|
||||
wait := timeout
|
||||
if wait == nil {
|
||||
wait = globalRestoreLockTimeout
|
||||
}
|
||||
lkctx, err := lk.GetLock(ctx, wait)
|
||||
if err != nil {
|
||||
if timeout == nil && errors.As(err, &OperationTimedOut{}) && ctx.Err() == nil {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
octx, cancel := context.WithCancel(lkctx.Context())
|
||||
return &restoreOwner{lk: lk, lkctx: lkctx, ctx: octx, cancel: cancel}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// restoreStage is the copy of one version that a native restore encoded into
|
||||
// the tmp volume of the version's set, under tmpID/dataDir, before it takes
|
||||
// the object lock.
|
||||
type restoreStage struct {
|
||||
versionID string
|
||||
// fi is the version as the download read it: the commit only publishes
|
||||
// the copy for a version that still has this content, see
|
||||
// restoreSameContent.
|
||||
fi FileInfo
|
||||
tmpID string
|
||||
dataDir string
|
||||
// staged tells, in the order of the version's erasure distribution,
|
||||
// which drives hold every part of the copy.
|
||||
staged []bool
|
||||
expiry time.Time
|
||||
// request is set for an attempt that POST registered: days and date
|
||||
// are what it recorded. A direct caller keeps the version's own.
|
||||
request bool
|
||||
days int
|
||||
date string
|
||||
}
|
||||
|
||||
// restoreSameContent reports whether two reads of a version name the same
|
||||
// encoded bytes, laid out the same way: the version, its tier copy, its parts
|
||||
// and its erasure layout. The tier copy is never rewritten under the same
|
||||
// name and remote version, so these decide the bytes. Every other field, such
|
||||
// as tags, Object Lock, replication state or a rewrapped object key, is taken
|
||||
// from the version as the commit reads it.
|
||||
func restoreSameContent(a, b FileInfo) bool {
|
||||
if a.VersionID != b.VersionID || a.Deleted || b.Deleted || !a.ModTime.Equal(b.ModTime) ||
|
||||
a.Metadata["etag"] != b.Metadata["etag"] || a.Size != b.Size ||
|
||||
a.TransitionStatus != lifecycle.TransitionComplete || b.TransitionStatus != lifecycle.TransitionComplete ||
|
||||
a.TransitionTier != b.TransitionTier || a.TransitionedObjName != b.TransitionedObjName ||
|
||||
a.TransitionVersionID != b.TransitionVersionID {
|
||||
return false
|
||||
}
|
||||
if len(a.Parts) != len(b.Parts) {
|
||||
return false
|
||||
}
|
||||
for i := range a.Parts {
|
||||
if a.Parts[i].Number != b.Parts[i].Number || a.Parts[i].Size != b.Parts[i].Size {
|
||||
return false
|
||||
}
|
||||
}
|
||||
ea, eb := a.Erasure, b.Erasure
|
||||
return ea.Algorithm == eb.Algorithm && ea.DataBlocks == eb.DataBlocks && ea.ParityBlocks == eb.ParityBlocks &&
|
||||
ea.BlockSize == eb.BlockSize && slices.Equal(ea.Distribution, eb.Distribution)
|
||||
}
|
||||
|
||||
// restoreInlineKey marks a version whose data lives in its xl.meta.
|
||||
const restoreInlineKey = ReservedMetadataPrefixLower + "inline-data"
|
||||
|
||||
// stageRestore downloads the tier copy of the version opts address and
|
||||
// erasure-codes it, part by part, into the tmp volume of the drives of this
|
||||
// set, as heal writes the parts it rebuilds. It holds no object lock. A drive
|
||||
// that fails to write any part, or that misses a part afterwards, for
|
||||
// example because its tmp volume was moved away when its server restarted,
|
||||
// takes no further part and no part in the commit.
|
||||
func (er erasureObjects) stageRestore(ctx context.Context, bucket, object string, opts ObjectOptions) (*restoreStage, error) {
|
||||
rreq := opts.Transition.RestoreRequest
|
||||
if rreq == nil {
|
||||
return nil, errInvalidArgument
|
||||
}
|
||||
gen, err := er.readRestoreGeneration(ctx, bucket, object, opts.VersionID)
|
||||
if err != nil {
|
||||
return nil, toObjectErr(err, bucket, object, opts.VersionID)
|
||||
}
|
||||
fi := gen.fi
|
||||
if fi.Deleted {
|
||||
return nil, toObjectErr(errMethodNotAllowed, bucket, object)
|
||||
}
|
||||
if a := opts.Transition.attempt; a != nil && !a.ownsGeneration(gen, bucket, object) {
|
||||
// A later POST took the version over, or it was replaced.
|
||||
return nil, errRestoreSuperseded
|
||||
}
|
||||
disks := er.getDisks()
|
||||
var total int64
|
||||
for _, p := range fi.Parts {
|
||||
if p.Size < 0 {
|
||||
return nil, toObjectErr(errFileCorrupt, bucket, object)
|
||||
}
|
||||
total += p.Size
|
||||
}
|
||||
if fi.TransitionStatus != lifecycle.TransitionComplete || len(fi.Parts) == 0 || total != fi.Size ||
|
||||
len(fi.Erasure.Distribution) != len(disks) {
|
||||
return nil, InvalidObjectState{Bucket: bucket, Object: object}
|
||||
}
|
||||
erasure, err := NewErasure(ctx, fi.Erasure.DataBlocks, fi.Erasure.ParityBlocks, fi.Erasure.BlockSize)
|
||||
if err != nil {
|
||||
return nil, toObjectErr(err, bucket, object)
|
||||
}
|
||||
writeQuorum := fi.WriteQuorum(er.defaultWQuorum())
|
||||
st := &restoreStage{
|
||||
versionID: restoreVersionID(fi.VersionID),
|
||||
fi: fi,
|
||||
tmpID: mustGetUUID(),
|
||||
dataDir: mustGetUUID(),
|
||||
expiry: lifecycle.ExpectedExpiryTime(time.Now().UTC(), rreq.Days),
|
||||
}
|
||||
if opts.Transition.restoreDate != "" {
|
||||
st.request, st.days, st.date = true, rreq.Days, opts.Transition.restoreDate
|
||||
}
|
||||
staged := false
|
||||
defer func() {
|
||||
if !staged {
|
||||
er.deleteAll(context.Background(), minioMetaTmpBucket, st.tmpID)
|
||||
}
|
||||
}()
|
||||
|
||||
// The tier copy is the encoded stream of the parts, encrypted or
|
||||
// compressed as stored: the parts get exactly these bytes back.
|
||||
oi := fi.ToObjectInfo(bucket, object, opts.Versioned || opts.VersionSuspended)
|
||||
gr, err := getTransitionedObjectReader(ctx, bucket, object, nil, http.Header{}, oi, opts)
|
||||
if err != nil {
|
||||
return nil, toObjectErr(err, bucket, object)
|
||||
}
|
||||
defer gr.Close()
|
||||
|
||||
var buffer []byte
|
||||
pool := globalBytePoolCap.Load()
|
||||
if int64(pool.Width()) < fi.Erasure.BlockSize {
|
||||
buffer = make([]byte, fi.Erasure.BlockSize)
|
||||
} else {
|
||||
buffer = pool.Get()
|
||||
defer pool.Put(buffer)
|
||||
}
|
||||
buffer = buffer[:fi.Erasure.BlockSize]
|
||||
|
||||
// Slots follow the erasure distribution: the drive in slot s gets shard
|
||||
// s+1 of every part, and the commit gives it erasure index s+1.
|
||||
slots := shuffleDisks(disks, fi.Erasure.Distribution)
|
||||
for _, part := range fi.Parts {
|
||||
partPath := pathJoin(st.tmpID, st.dataDir, fmt.Sprintf("part.%d", part.Number))
|
||||
writers := make([]io.Writer, len(slots))
|
||||
for s, disk := range slots {
|
||||
if disk != nil {
|
||||
writers[s] = newBitrotWriter(disk, bucket, minioMetaTmpBucket, partPath,
|
||||
erasure.ShardFileSize(part.Size), DefaultBitrotAlgorithm, erasure.ShardSize())
|
||||
}
|
||||
}
|
||||
n, err := erasure.Encode(ctx, io.LimitReader(gr, part.Size), writers, buffer, writeQuorum)
|
||||
closeErrs := closeBitrotWriters(writers)
|
||||
if err != nil {
|
||||
return nil, toObjectErr(err, bucket, object)
|
||||
}
|
||||
if n != part.Size {
|
||||
return nil, IncompleteBody{Bucket: bucket, Object: object}
|
||||
}
|
||||
for s := range slots {
|
||||
if writers[s] == nil || closeErrs[s] != nil {
|
||||
slots[s] = nil
|
||||
}
|
||||
}
|
||||
if countOnlineDisks(slots) < writeQuorum {
|
||||
return nil, toObjectErr(errErasureWriteQuorum, bucket, object)
|
||||
}
|
||||
}
|
||||
// The tier copy must end with the last part.
|
||||
var extra [1]byte
|
||||
if n, _ := io.ReadFull(gr, extra[:]); n != 0 {
|
||||
return nil, InvalidObjectState{Bucket: bucket, Object: object}
|
||||
}
|
||||
|
||||
// A tmp volume that was moved away and recreated by a later part lacks the
|
||||
// earlier parts: find them before anything is published. CheckParts sees a
|
||||
// part that is present and not shorter than its shards; a staged drive
|
||||
// that wrote every part without an error has exactly that.
|
||||
check := fi
|
||||
check.DataDir = st.dataDir
|
||||
g := errgroup.WithNErrs(len(slots))
|
||||
for s, disk := range slots {
|
||||
if disk == nil {
|
||||
continue
|
||||
}
|
||||
g.Go(func() error {
|
||||
resp, err := disk.CheckParts(ctx, minioMetaTmpBucket, st.tmpID, check)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, result := range resp.Results {
|
||||
if result != checkPartSuccess {
|
||||
return errFileCorrupt
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, s)
|
||||
}
|
||||
st.staged = make([]bool, len(slots))
|
||||
count := 0
|
||||
for s, err := range g.Wait() {
|
||||
if slots[s] != nil && err == nil {
|
||||
st.staged[s] = true
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count < writeQuorum {
|
||||
return nil, toObjectErr(errErasureWriteQuorum, bucket, object)
|
||||
}
|
||||
staged = true
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// fileInfoRevisions are the revisions the entry that writing fi creates can
|
||||
// have, as readRestoreGeneration and the convergence identify the entries
|
||||
// they read. The header flag that tells whether a transitioned version uses
|
||||
// its data directory follows whether its restored copy has expired at the
|
||||
// moment a drive writes the entry, so a restore whose copy expires while it
|
||||
// publishes can find either value: both are this entry. Every other field of
|
||||
// the header and the full metadata digest still have to match.
|
||||
func fileInfoRevisions(fi FileInfo) ([]revisionKey, error) {
|
||||
var x xlMetaV2
|
||||
if err := x.AddVersion(fi); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf, err := x.AppendTo(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var y xlMetaV2
|
||||
if err := y.Load(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(y.versions) != 1 {
|
||||
return nil, errFileCorrupt
|
||||
}
|
||||
ver := y.versions[0]
|
||||
other := ver
|
||||
other.header.Flags ^= xlFlagUsesDataDir
|
||||
return []revisionKey{versionRevision(ver, y.metaV), versionRevision(other, y.metaV)}, nil
|
||||
}
|
||||
|
||||
// restorePublication remembers, for a commit that published the staged copy
|
||||
// on some drives but not on a write quorum, what each drive held before the
|
||||
// commit (Sᵢ), what the commit wrote there (Pᵢ) and what a convergence writes
|
||||
// there (Vᵢ), in the order of the version's erasure distribution. Only the
|
||||
// worker that published knows them; a convergence touches a version only
|
||||
// while every drive holds one of them.
|
||||
type restorePublication struct {
|
||||
bucket, object string
|
||||
versionID string
|
||||
uv [16]byte
|
||||
dataDir string
|
||||
attempt *restoreAttempt
|
||||
quorum int
|
||||
dist []int
|
||||
before []*revisionKey
|
||||
anyBefore map[revisionKey]bool
|
||||
published map[revisionKey]bool
|
||||
conv []FileInfo
|
||||
convKeys map[revisionKey]bool
|
||||
}
|
||||
|
||||
// restorePublished is the entry a commit publishes on the drive in slot s:
|
||||
// the version as the commit read it on that drive, with the staged data
|
||||
// directory and a completed restore. Restore fields are written alike on
|
||||
// every drive: the request POST recorded, or for a direct caller the one
|
||||
// the authoritative generation auth holds.
|
||||
func restorePublished(base, auth FileInfo, st *restoreStage, slot int) FileInfo {
|
||||
fi := base
|
||||
fi.Metadata = maps.Clone(base.Metadata)
|
||||
if fi.Metadata == nil {
|
||||
fi.Metadata = make(map[string]string)
|
||||
}
|
||||
for _, k := range restoreFieldKeys {
|
||||
delete(fi.Metadata, k)
|
||||
}
|
||||
// An earlier restore may have inlined the data, and expiring that copy
|
||||
// leaves the inline marker behind; this copy lives in its data directory.
|
||||
delete(fi.Metadata, restoreInlineKey)
|
||||
fi.Metadata[xhttp.AmzRestore] = completedRestoreObj(st.expiry).String()
|
||||
if st.request {
|
||||
fi.Metadata[xhttp.AmzRestoreExpiryDays] = strconv.Itoa(st.days)
|
||||
fi.Metadata[xhttp.AmzRestoreRequestDate] = st.date
|
||||
} else {
|
||||
for _, k := range []string{xhttp.AmzRestoreExpiryDays, xhttp.AmzRestoreRequestDate} {
|
||||
if v, ok := auth.Metadata[k]; ok {
|
||||
fi.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
fi.DataDir = st.dataDir
|
||||
fi.Data = nil
|
||||
fi.Parts = slices.Clone(base.Parts)
|
||||
fi.Erasure.Index = slot + 1
|
||||
// The staged parts use the default streaming bitrot algorithm.
|
||||
fi.Erasure.Checksums = nil
|
||||
return fi
|
||||
}
|
||||
|
||||
// restoreUnrestored is the entry a convergence writes on the drive in slot s:
|
||||
// the version as the commit read it, with no restore fields, so that its tier
|
||||
// copy serves reads again and a later POST can restore it.
|
||||
func restoreUnrestored(base FileInfo, slot int) FileInfo {
|
||||
fi := base
|
||||
fi.Metadata = maps.Clone(base.Metadata)
|
||||
for _, k := range restoreFieldKeys {
|
||||
delete(fi.Metadata, k)
|
||||
}
|
||||
delete(fi.Metadata, restoreInlineKey)
|
||||
fi.Data = nil
|
||||
fi.Erasure.Index = slot + 1
|
||||
return fi
|
||||
}
|
||||
|
||||
// commitRestore publishes a staged copy while the caller holds the object
|
||||
// lock. It reads the version again, checks that the attempt still owns it and
|
||||
// that it still has the content the copy was staged for, then publishes the
|
||||
// copy on every staged drive that holds the version, with RenameData on each
|
||||
// drive, as heal does: never through erasure renameData, whose undo of a
|
||||
// replaced version could delete the new data and keep metadata that points
|
||||
// at it.
|
||||
//
|
||||
// It returns nil when the copy reached a write quorum. Otherwise it returns
|
||||
// an error, and a publication when some drive may already have the copy; the
|
||||
// caller must then converge, see convergeRestore.
|
||||
func (er erasureObjects) commitRestore(ctx context.Context, bucket, object string, st *restoreStage, opts ObjectOptions, placed func(context.Context) error) (*restorePublication, error) {
|
||||
if placed != nil {
|
||||
if err := placed(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
gen, err := er.readRestoreGeneration(ctx, bucket, object, st.versionID)
|
||||
if err != nil {
|
||||
return nil, toObjectErr(err, bucket, object, st.versionID)
|
||||
}
|
||||
if gen.fi.Deleted {
|
||||
return nil, toObjectErr(errMethodNotAllowed, bucket, object)
|
||||
}
|
||||
a := opts.Transition.attempt
|
||||
if a != nil && !a.ownsGeneration(gen, bucket, object) {
|
||||
return nil, errRestoreSuperseded
|
||||
}
|
||||
if !restoreSameContent(st.fi, gen.fi) {
|
||||
// Replaced, or its tier copy or layout changed since the download.
|
||||
return nil, InvalidObjectState{Bucket: bucket, Object: object}
|
||||
}
|
||||
disks := er.getDisks()
|
||||
dist := gen.fi.Erasure.Distribution
|
||||
if len(dist) != len(disks) || len(gen.revs) != len(disks) {
|
||||
return nil, InvalidObjectState{Bucket: bucket, Object: object}
|
||||
}
|
||||
n := len(disks)
|
||||
writeQuorum := gen.fi.WriteQuorum(er.defaultWQuorum())
|
||||
|
||||
pub := &restorePublication{
|
||||
bucket: bucket, object: object, versionID: st.versionID, dataDir: st.dataDir,
|
||||
attempt: a, quorum: writeQuorum, dist: slices.Clone(dist),
|
||||
before: make([]*revisionKey, n),
|
||||
anyBefore: make(map[revisionKey]bool),
|
||||
published: make(map[revisionKey]bool),
|
||||
conv: make([]FileInfo, n),
|
||||
convKeys: make(map[revisionKey]bool),
|
||||
}
|
||||
if st.versionID != nullVersionID {
|
||||
id, err := uuid.Parse(st.versionID)
|
||||
if err != nil {
|
||||
return nil, toObjectErr(errFileVersionNotFound, bucket, object, st.versionID)
|
||||
}
|
||||
pub.uv = id
|
||||
}
|
||||
targets := make([]StorageAPI, n)
|
||||
files := make([]FileInfo, n)
|
||||
for i := range disks {
|
||||
s := dist[i] - 1
|
||||
if s < 0 || s >= n {
|
||||
return nil, InvalidObjectState{Bucket: bucket, Object: object}
|
||||
}
|
||||
if gen.revs[i] != nil {
|
||||
pub.before[s] = gen.revs[i]
|
||||
pub.anyBefore[*gen.revs[i]] = true
|
||||
}
|
||||
v := restoreUnrestored(gen.fi, s)
|
||||
keys, err := fileInfoRevisions(v)
|
||||
if err != nil {
|
||||
return nil, toObjectErr(err, bucket, object)
|
||||
}
|
||||
pub.conv[s] = v
|
||||
// The unrestored entry has no restore header: its flag never varies.
|
||||
pub.convKeys[keys[0]] = true
|
||||
if gen.disks[i] == nil || !st.staged[s] {
|
||||
continue
|
||||
}
|
||||
fi := restorePublished(gen.drives[i], gen.fi, st, s)
|
||||
if keys, err = fileInfoRevisions(fi); err != nil {
|
||||
return nil, toObjectErr(err, bucket, object)
|
||||
}
|
||||
for _, key := range keys {
|
||||
pub.published[key] = true
|
||||
}
|
||||
targets[s], files[s] = gen.disks[i], fi
|
||||
}
|
||||
if countOnlineDisks(targets) < writeQuorum {
|
||||
// Nothing is published.
|
||||
return nil, toObjectErr(errErasureWriteQuorum, bucket, object)
|
||||
}
|
||||
|
||||
errs := make([]error, n)
|
||||
olds := make([]string, n)
|
||||
var wg sync.WaitGroup
|
||||
for s, disk := range targets {
|
||||
if disk == nil {
|
||||
errs[s] = errDiskNotFound
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
resp, err := disk.RenameData(ctx, minioMetaTmpBucket, st.tmpID, files[s], bucket, object, RenameOptions{})
|
||||
olds[s], errs[s] = resp.OldDataDir, err
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if countErrs(errs, nil) < writeQuorum {
|
||||
return pub, toObjectErr(errErasureWriteQuorum, bucket, object)
|
||||
}
|
||||
// The data directory of the replaced entry, which held a former restored
|
||||
// copy, goes only when every drive that published reports the same one:
|
||||
// RenameData reports none where another version shares it, and then the
|
||||
// directory stays everywhere. It goes only from the drives that reported
|
||||
// it.
|
||||
old := ""
|
||||
cleanup := make([]StorageAPI, n)
|
||||
for s := range targets {
|
||||
if errs[s] != nil {
|
||||
continue
|
||||
}
|
||||
if olds[s] == "" || (old != "" && olds[s] != old) {
|
||||
old = ""
|
||||
break
|
||||
}
|
||||
old = olds[s]
|
||||
cleanup[s] = targets[s]
|
||||
}
|
||||
if old != "" && old != st.dataDir {
|
||||
er.commitRenameDataDir(ctx, bucket, object, old, cleanup, 1)
|
||||
}
|
||||
if countErrs(errs, nil) < n {
|
||||
er.addPartial(bucket, object, gen.fi.VersionID)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// restoreConvergence is the outcome of one convergence pass.
|
||||
type restoreConvergence int
|
||||
|
||||
const (
|
||||
// restoreIncomplete: the drives are not settled yet; try again.
|
||||
restoreIncomplete restoreConvergence = iota
|
||||
// restoreConverged: every drive that holds the version holds it
|
||||
// unrestored again.
|
||||
restoreConverged
|
||||
// restoreCommitted: the copy reached a write quorum after all, such as
|
||||
// when a drive applied a publication whose answer was lost.
|
||||
restoreCommitted
|
||||
// restoreStopped: some drive holds an entry that neither this attempt
|
||||
// nor the version before it wrote: someone else changed the version.
|
||||
restoreStopped
|
||||
)
|
||||
|
||||
// classify reads the entry of the version on every drive and sorts it into
|
||||
// the slot of its drive: held tells which slots hold an entry this
|
||||
// publication knows, published and converged count the drives that hold
|
||||
// what the commit and the convergence write. A drive that cannot be read is
|
||||
// left out. An entry the publication does not know stops it.
|
||||
func (pub *restorePublication) classify(ctx context.Context, disks []StorageAPI) (held []bool, published, converged int, err error) {
|
||||
raws, errs := readAllRawFileInfo(ctx, disks, pub.bucket, pub.object, false)
|
||||
held = make([]bool, len(disks))
|
||||
for i := range disks {
|
||||
if errs[i] != nil || i >= len(pub.dist) {
|
||||
continue
|
||||
}
|
||||
var xl xlMetaV2
|
||||
if xl.LoadOrConvert(raws[i].Buf) != nil {
|
||||
continue
|
||||
}
|
||||
s := pub.dist[i] - 1
|
||||
for _, ver := range xl.versions {
|
||||
if ver.header.VersionID != pub.uv || ver.header.FreeVersion() {
|
||||
continue
|
||||
}
|
||||
key := versionRevision(ver, xl.metaV)
|
||||
switch {
|
||||
case pub.published[key]:
|
||||
published++
|
||||
case pub.convKeys[key]:
|
||||
converged++
|
||||
case pub.before[s] != nil && *pub.before[s] == key:
|
||||
case pub.before[s] == nil && pub.anyBefore[key]:
|
||||
default:
|
||||
return nil, 0, 0, errRestoreSuperseded
|
||||
}
|
||||
held[s] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return held, published, converged, nil
|
||||
}
|
||||
|
||||
// convergeRestore settles a publication while the caller holds the object
|
||||
// lock. When every drive still holds what the version held before the commit,
|
||||
// what the commit wrote, or what an earlier pass wrote, it either finds the
|
||||
// copy on a write quorum, or writes the unrestored version to every drive
|
||||
// that holds the version. It deletes no directory: an unreferenced staged
|
||||
// directory is left to abandoned-data cleanup. Any other entry stops it, so
|
||||
// that it never overwrites what someone else wrote between two passes.
|
||||
func (er erasureObjects) convergeRestore(ctx context.Context, pub *restorePublication) (restoreConvergence, error) {
|
||||
disks := er.getDisks()
|
||||
held, published, _, err := pub.classify(ctx, disks)
|
||||
if err != nil {
|
||||
return restoreStopped, err
|
||||
}
|
||||
if published >= pub.quorum {
|
||||
er.addPartial(pub.bucket, pub.object, pub.versionID)
|
||||
return restoreCommitted, nil
|
||||
}
|
||||
slots := shuffleDisks(disks, pub.dist)
|
||||
files := make([]FileInfo, len(slots))
|
||||
for s := range slots {
|
||||
if !held[s] {
|
||||
slots[s] = nil
|
||||
continue
|
||||
}
|
||||
files[s] = pub.conv[s]
|
||||
}
|
||||
_, werr := writeUniqueFileInfo(ctx, slots, "", pub.bucket, pub.object, files, pub.quorum)
|
||||
|
||||
held, published, converged, err := pub.classify(ctx, disks)
|
||||
if err != nil {
|
||||
return restoreStopped, err
|
||||
}
|
||||
holding := 0
|
||||
for _, h := range held {
|
||||
if h {
|
||||
holding++
|
||||
}
|
||||
}
|
||||
if published == 0 && converged == holding && converged >= pub.quorum {
|
||||
return restoreConverged, nil
|
||||
}
|
||||
if werr == nil {
|
||||
werr = errErasureWriteQuorum
|
||||
}
|
||||
return restoreIncomplete, werr
|
||||
}
|
||||
|
||||
// recoverRestorePublication retries a convergence in memory, for a bounded
|
||||
// time, each pass under the object lock taken afresh. A restart loses the
|
||||
// retries; the version then stays as the drives hold it, see the design's
|
||||
// excluded residuals.
|
||||
func (er erasureObjects) recoverRestorePublication(pub *restorePublication, lock func(context.Context) (LockContext, func(), error)) {
|
||||
go func() {
|
||||
err := errRestoreNotConverged
|
||||
for _, delay := range restorePublicationDelays {
|
||||
select {
|
||||
case <-GlobalContext.Done():
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(GlobalContext, restoreCleanupTimeout)
|
||||
lkctx, unlock, lerr := lock(ctx)
|
||||
if lerr != nil {
|
||||
cancel()
|
||||
err = lerr
|
||||
continue
|
||||
}
|
||||
var res restoreConvergence
|
||||
res, err = er.convergeRestore(lkctx.Context(), pub)
|
||||
unlock()
|
||||
cancel()
|
||||
if res == restoreConverged || res == restoreCommitted {
|
||||
return
|
||||
}
|
||||
if res == restoreStopped {
|
||||
break
|
||||
}
|
||||
}
|
||||
er.logRestoreNotConverged(pub, err)
|
||||
}()
|
||||
}
|
||||
|
||||
func (er erasureObjects) logRestoreNotConverged(pub *restorePublication, err error) {
|
||||
attempt := ""
|
||||
if pub.attempt != nil {
|
||||
attempt = pub.attempt.ID
|
||||
}
|
||||
storageLogIf(GlobalContext, fmt.Errorf("%w: %s/%s(%s), attempt %q, data dir %s: %v",
|
||||
errRestoreNotConverged, pub.bucket, pub.object, pub.versionID, attempt, pub.dataDir, err))
|
||||
}
|
||||
|
||||
// restoreHooks tie a restore of one set to its caller.
|
||||
type restoreHooks struct {
|
||||
// lock takes the object lock O of the version, in the lock domain of the
|
||||
// object's writes, and returns its release.
|
||||
lock func(ctx context.Context) (LockContext, func(), error)
|
||||
// placed tells, under O, whether the version still lives in this set.
|
||||
placed func(ctx context.Context) error
|
||||
// clear clears the attempt's ongoing marker after a failure that
|
||||
// published nothing, taking O itself, and retries in the background.
|
||||
clear func()
|
||||
}
|
||||
|
||||
// restoreVersion rehydrates the transitioned version opts address in this
|
||||
// set: it stages the copy without the object lock, then commits it under the
|
||||
// lock. A failure that published nothing clears the attempt's marker. A
|
||||
// publication that reached some drives but not a write quorum converges
|
||||
// under the same lock hold, and in bounded background retries after it.
|
||||
func (er erasureObjects) restoreVersion(ctx context.Context, bucket, object string, opts ObjectOptions, h restoreHooks) error {
|
||||
st, err := er.stageRestore(ctx, bucket, object, opts)
|
||||
if err != nil {
|
||||
return restoreAbandon(err, h)
|
||||
}
|
||||
defer er.deleteAll(context.Background(), minioMetaTmpBucket, st.tmpID)
|
||||
|
||||
lkctx, unlock, err := h.lock(ctx)
|
||||
if err != nil {
|
||||
return restoreAbandon(err, h)
|
||||
}
|
||||
pub, err := er.commitRestore(lkctx.Context(), bucket, object, st, opts, h.placed)
|
||||
if err == nil {
|
||||
unlock()
|
||||
return nil
|
||||
}
|
||||
if pub == nil {
|
||||
unlock()
|
||||
return restoreAbandon(err, h)
|
||||
}
|
||||
res, cerr := er.convergeRestore(lkctx.Context(), pub)
|
||||
unlock()
|
||||
switch res {
|
||||
case restoreCommitted:
|
||||
return nil
|
||||
case restoreConverged:
|
||||
return err
|
||||
case restoreStopped:
|
||||
er.logRestoreNotConverged(pub, cerr)
|
||||
return err
|
||||
default:
|
||||
er.recoverRestorePublication(pub, h.lock)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// restoreAbandon clears the marker of an attempt that published nothing,
|
||||
// unless another attempt owns the version. A not-found error may come from the
|
||||
// tier rather than from the version, and the clear checks the marker's owner
|
||||
// and finds nothing to do for a version that is gone, so it runs then too.
|
||||
func restoreAbandon(err error, h restoreHooks) error {
|
||||
if errors.Is(err, errRestoreSuperseded) {
|
||||
return err
|
||||
}
|
||||
if h.clear != nil {
|
||||
h.clear()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// RestoreTransitionedObject - restore transitioned object content locally on
|
||||
// this cluster. This is similar to PostObjectRestore from AWS GLACIER storage
|
||||
// class. When PostObjectRestore API is called, a temporary copy of the object
|
||||
// is restored locally to the bucket on source cluster until the restore
|
||||
// expiry date. The copy that was transitioned continues to reside in the
|
||||
// transitioned tier.
|
||||
//
|
||||
// The set entry serves direct callers of one set. It takes the object lock in
|
||||
// the set's own domain and no restore lock; native restores of a cluster go
|
||||
// through erasureServerPools.
|
||||
func (er erasureObjects) RestoreTransitionedObject(ctx context.Context, bucket, object string, opts ObjectOptions) error {
|
||||
lock := func(ctx context.Context) (LockContext, func(), error) {
|
||||
lk := er.NewNSLock(bucket, object)
|
||||
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return LockContext{}, nil, err
|
||||
}
|
||||
return lkctx, func() { lk.Unlock(lkctx) }, nil
|
||||
}
|
||||
clearMarker := func() {
|
||||
ctx, cancel := context.WithTimeout(GlobalContext, restoreCleanupTimeout)
|
||||
defer cancel()
|
||||
lkctx, unlock, err := lock(ctx)
|
||||
if err != nil {
|
||||
storageLogIf(ctx, fmt.Errorf("Unable to clear the restore status of %s/%s(%s): %w", bucket, object, opts.VersionID, err))
|
||||
return
|
||||
}
|
||||
defer unlock()
|
||||
versionID := opts.VersionID
|
||||
if a := opts.Transition.attempt; a != nil {
|
||||
versionID = a.VersionID
|
||||
}
|
||||
if err := er.clearRestoreStatus(lkctx.Context(), bucket, object, versionID, opts.Transition.attempt); err != nil &&
|
||||
!isErrObjectNotFound(err) && !isErrVersionNotFound(err) && !isErrMethodNotAllowed(err) {
|
||||
storageLogIf(ctx, fmt.Errorf("Unable to clear the restore status of %s/%s(%s): %w", bucket, object, versionID, err))
|
||||
}
|
||||
}
|
||||
return er.restoreVersion(ctx, bucket, object, opts, restoreHooks{lock: lock, clear: clearMarker})
|
||||
}
|
||||
|
||||
// RestoreTransitionedObject - restore transitioned object content locally on
|
||||
// this cluster, see erasureObjects.RestoreTransitionedObject.
|
||||
//
|
||||
// A POST hands over the restore lock R of the version it registered in
|
||||
// opts.Transition.owner; a direct caller takes R here, waiting while ctx
|
||||
// lives. The restore runs while R is held, and releases it when it returns.
|
||||
func (z *erasureServerPools) RestoreTransitionedObject(ctx context.Context, bucket, object string, opts ObjectOptions) error {
|
||||
object = encodeDirObject(object)
|
||||
owner := opts.Transition.owner
|
||||
opts.Transition.owner = nil
|
||||
if owner != nil && owner.ctx.Err() != nil {
|
||||
// The lock POST handed over ended before the restore ran, such as
|
||||
// when its holder lost it while the restore was queued: take it again
|
||||
// as a direct caller does. The attempt still has to own the version.
|
||||
owner = nil
|
||||
}
|
||||
defer func() { owner.release() }()
|
||||
|
||||
// Avoid restoring object from a pool being decommissioned.
|
||||
opts.SkipDecommissioned = true
|
||||
opts.NoLock = true
|
||||
clearMarker := func() {
|
||||
if err := z.resetRestoreStatus(bucket, object, opts); err != nil {
|
||||
z.recoverRestoreStatus(bucket, object, opts)
|
||||
}
|
||||
}
|
||||
idx, err := z.restorePoolIdx(ctx, bucket, object, opts)
|
||||
if err != nil {
|
||||
var reset restoreResetError
|
||||
if errors.As(err, &reset) {
|
||||
clearMarker()
|
||||
return reset.error
|
||||
}
|
||||
if isErrObjectNotFound(err) && opts.VersionID != "" {
|
||||
err = VersionNotFound{Bucket: bucket, Object: decodeDirObject(object), VersionID: opts.VersionID}
|
||||
}
|
||||
return err
|
||||
}
|
||||
// A server may have been replaced by an older binary while the restore
|
||||
// was queued: such a server knows neither the restore lock nor the
|
||||
// staged commit.
|
||||
if err := nativeRestoreAvailable(ctx); err != nil {
|
||||
clearMarker()
|
||||
return err
|
||||
}
|
||||
er := z.serverPools[idx].getHashedSet(object)
|
||||
if owner == nil {
|
||||
if opts.VersionID == "" {
|
||||
// Restore the version that is latest now, by its own ID.
|
||||
gen, err := er.readRestoreGeneration(ctx, bucket, object, "")
|
||||
if err != nil {
|
||||
return toObjectErr(err, bucket, object)
|
||||
}
|
||||
opts.VersionID = restoreVersionID(gen.fi.VersionID)
|
||||
}
|
||||
if owner, err = z.takeRestoreOwner(ctx, bucket, object, opts.VersionID, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return er.restoreVersion(owner.ctx, bucket, object, opts, restoreHooks{
|
||||
lock: func(ctx context.Context) (LockContext, func(), error) {
|
||||
lk := z.restoreLock(bucket, object)
|
||||
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return LockContext{}, nil, err
|
||||
}
|
||||
return lkctx, func() { lk.Unlock(lkctx) }, nil
|
||||
},
|
||||
placed: func(ctx context.Context) error {
|
||||
got, err := z.restorePoolIdx(ctx, bucket, object, opts)
|
||||
var reset restoreResetError
|
||||
if errors.As(err, &reset) {
|
||||
return reset.error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if got != idx {
|
||||
return errRestoreMoved
|
||||
}
|
||||
return nil
|
||||
},
|
||||
clear: clearMarker,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1372,6 +1372,26 @@ func (x *xlMetaV2) DeleteVersion(fi FileInfo) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if fi.ExpireRestored {
|
||||
i, ver, err := x.findVersion(uv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Check the stored type and transition before any marker, legacy or
|
||||
// replication mutation. Only completed tiered objects own a cache.
|
||||
if ver.Type != ObjectType || string(ver.ObjectV2.MetaSys[metaTierStatus]) != lifecycle.TransitionComplete {
|
||||
return "", nil
|
||||
}
|
||||
ver.ObjectV2.RemoveRestoreHdrs()
|
||||
if err := x.setIdx(i, *ver); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if x.SharedDataDirCount(ver.ObjectV2.VersionID, ver.ObjectV2.DataDir) > 0 {
|
||||
return "", nil
|
||||
}
|
||||
return uuid.UUID(ver.ObjectV2.DataDir).String(), nil
|
||||
}
|
||||
|
||||
var ventry xlMetaV2Version
|
||||
if fi.Deleted {
|
||||
ventry = xlMetaV2Version{
|
||||
@@ -1496,11 +1516,8 @@ func (x *xlMetaV2) DeleteVersion(fi FileInfo) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch {
|
||||
case fi.ExpireRestored:
|
||||
ver.ObjectV2.RemoveRestoreHdrs()
|
||||
err = x.setIdx(i, *ver)
|
||||
case fi.TransitionStatus == lifecycle.TransitionComplete:
|
||||
switch fi.TransitionStatus {
|
||||
case lifecycle.TransitionComplete:
|
||||
ver.ObjectV2.SetTransition(fi)
|
||||
ver.ObjectV2.ResetInlineData()
|
||||
err = x.setIdx(i, *ver)
|
||||
|
||||
@@ -1371,6 +1371,9 @@ func (s *xlStorage) DeleteVersion(ctx context.Context, volume, path string, fi F
|
||||
}
|
||||
|
||||
if legacyJSON {
|
||||
if fi.ExpireRestored {
|
||||
return nil // Legacy objects cannot contain a completed transition.
|
||||
}
|
||||
// Delete the meta file, if there are no more versions the
|
||||
// top level parent is automatically removed.
|
||||
return s.deleteFile(volumeDir, pathJoin(volumeDir, path), true, false)
|
||||
|
||||
@@ -116,6 +116,7 @@ const (
|
||||
HandlerRenamePart
|
||||
HandlerClearUploadID
|
||||
HandlerCheckParts3
|
||||
HandlerRestoreCapability
|
||||
|
||||
// Add more above here ^^^
|
||||
// If all handlers are used, the type of Handler can be changed.
|
||||
@@ -200,6 +201,7 @@ var handlerPrefixes = [handlerLast]string{
|
||||
HandlerCheckParts3: storagePrefix,
|
||||
HandlerRenamePart: storagePrefix,
|
||||
HandlerClearUploadID: peerPrefix,
|
||||
HandlerRestoreCapability: peerPrefix,
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -86,14 +86,15 @@ func _() {
|
||||
_ = x[HandlerRenamePart-75]
|
||||
_ = x[HandlerClearUploadID-76]
|
||||
_ = x[HandlerCheckParts3-77]
|
||||
_ = x[handlerTest-78]
|
||||
_ = x[handlerTest2-79]
|
||||
_ = x[handlerLast-80]
|
||||
_ = x[HandlerRestoreCapability-78]
|
||||
_ = x[handlerTest-79]
|
||||
_ = x[handlerTest2-80]
|
||||
_ = x[handlerLast-81]
|
||||
}
|
||||
|
||||
const _HandlerID_name = "handlerInvalidLockLockLockRLockLockUnlockLockRUnlockLockRefreshLockForceUnlockWalkDirStatVolDiskInfoNSScannerReadXLReadVersionDeleteFileDeleteVersionUpdateMetadataWriteMetadataCheckPartsRenameDataRenameFileReadAllServerVerifyTraceListenDeleteBucketMetadataLoadBucketMetadataReloadSiteReplicationConfigReloadPoolMetaStopRebalanceLoadRebalanceMetaLoadTransitionTierConfigDeletePolicyLoadPolicyLoadPolicyMappingDeleteServiceAccountLoadServiceAccountDeleteUserLoadUserLoadGroupHealBucketMakeBucketHeadBucketDeleteBucketGetMetricsGetResourceMetricsGetMemInfoGetProcInfoGetOSInfoGetPartitionsGetNetInfoGetCPUsServerInfoGetSysConfigGetSysServicesGetSysErrorsGetAllBucketStatsGetBucketStatsGetSRMetricsGetPeerMetricsGetMetacacheListingUpdateMetacacheListingGetPeerBucketMetricsStorageInfoConsoleLogListDirGetLocksBackgroundHealStatusGetLastDayTierStatsSignalServiceGetBandwidthWriteAllListBucketsRenameDataInlineRenameData2CheckParts2RenamePartClearUploadIDCheckParts3handlerTesthandlerTest2handlerLast"
|
||||
const _HandlerID_name = "handlerInvalidLockLockLockRLockLockUnlockLockRUnlockLockRefreshLockForceUnlockWalkDirStatVolDiskInfoNSScannerReadXLReadVersionDeleteFileDeleteVersionUpdateMetadataWriteMetadataCheckPartsRenameDataRenameFileReadAllServerVerifyTraceListenDeleteBucketMetadataLoadBucketMetadataReloadSiteReplicationConfigReloadPoolMetaStopRebalanceLoadRebalanceMetaLoadTransitionTierConfigDeletePolicyLoadPolicyLoadPolicyMappingDeleteServiceAccountLoadServiceAccountDeleteUserLoadUserLoadGroupHealBucketMakeBucketHeadBucketDeleteBucketGetMetricsGetResourceMetricsGetMemInfoGetProcInfoGetOSInfoGetPartitionsGetNetInfoGetCPUsServerInfoGetSysConfigGetSysServicesGetSysErrorsGetAllBucketStatsGetBucketStatsGetSRMetricsGetPeerMetricsGetMetacacheListingUpdateMetacacheListingGetPeerBucketMetricsStorageInfoConsoleLogListDirGetLocksBackgroundHealStatusGetLastDayTierStatsSignalServiceGetBandwidthWriteAllListBucketsRenameDataInlineRenameData2CheckParts2RenamePartClearUploadIDCheckParts3RestoreCapabilityhandlerTesthandlerTest2handlerLast"
|
||||
|
||||
var _HandlerID_index = [...]uint16{0, 14, 22, 31, 41, 52, 63, 78, 85, 92, 100, 109, 115, 126, 136, 149, 163, 176, 186, 196, 206, 213, 225, 230, 236, 256, 274, 301, 315, 328, 345, 369, 381, 391, 408, 428, 446, 456, 464, 473, 483, 493, 503, 515, 525, 543, 553, 564, 573, 586, 596, 603, 613, 625, 639, 651, 668, 682, 694, 708, 727, 749, 769, 780, 790, 797, 805, 825, 844, 857, 869, 877, 888, 904, 915, 926, 936, 949, 960, 971, 983, 994}
|
||||
var _HandlerID_index = [...]uint16{0, 14, 22, 31, 41, 52, 63, 78, 85, 92, 100, 109, 115, 126, 136, 149, 163, 176, 186, 196, 206, 213, 225, 230, 236, 256, 274, 301, 315, 328, 345, 369, 381, 391, 408, 428, 446, 456, 464, 473, 483, 493, 503, 515, 525, 543, 553, 564, 573, 586, 596, 603, 613, 625, 639, 651, 668, 682, 694, 708, 727, 749, 769, 780, 790, 797, 805, 825, 844, 857, 869, 877, 888, 904, 915, 926, 936, 949, 960, 977, 988, 1000, 1011}
|
||||
|
||||
func (i HandlerID) String() string {
|
||||
idx := int(i) - 0
|
||||
|
||||
Reference in New Issue
Block a user