mirror of
https://github.com/pgsty/minio.git
synced 2026-11-09 17:21:51 +03:00
fix(restore): give each native restore an owner and a private upload
A native restore (POST ?restore, issue #281) marks the version ongoing, and a background worker rehydrates it from the tier. The worker, its upload and every status write could be reached or confused by other callers. A public client could write into or complete the restore upload. POST copied a whole metadata snapshot over newer protection changes. An old or failed attempt could act on a newer one. Restore, expiry and heal decided on whichever drive's metadata they read. Each POST that starts a rehydration now writes an attempt ID into the version's internal metadata, with the identity it resolved: version ID, ETag, size and ModTime. The worker, its upload, its part writes and its completion carry the attempt. A worker whose attempt no longer owns the version stops without writing, completing or resetting. Every write uses the versioning state and explicit version ID captured by POST, so a null target stays null. The restore upload carries an internal marker, decided over all drives. Public multipart calls answer NoSuchUpload and listings hide it. An unknown marker fails closed. A completion holds a commit lock from staging its commit in the upload until the commit ends. Stale cleanup decides on every upload past the stale age under that lock, and keeps it until its rename ends, also past the drive deadline. Abort of a committing upload takes the lock and removes a failed commit's leftover only when every copy is staged and older than one hour. POST, the worker, the reset, the ExpireRestored recheck and heal all decide on the authoritative revision of the exact version. That is the revision held by max(n/2+1, read quorum) drives, identified by the version header and the normalized metadata digest. Status writes patch only the restore fields of that revision. They write only the drives that hold it, and put the old revision back on failure. Heal copies restore fields only when a quorum holds the same fields. A failed attempt resets itself with bounded in-memory retries. A later POST takes over a stranded marker when no attempt runs. Otherwise it answers RestoreAlreadyInProgress within a few seconds. Older nodes do not know the marker. A new grid capability handler lets the serving node ask every peer. POST answers 503 ServiceUnavailable and writes nothing until all peers support native restore, and a queued worker asks again before it creates its upload. The single-pool lock-domain split between DELETE/CopyObject and restore is closed by its own fix, which this change depends on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
@@ -137,8 +137,7 @@ func TestExpireRestoredMetadataReadKeepsMinorityVersion(t *testing.T) {
|
|||||||
case "restore-cleanup":
|
case "restore-cleanup":
|
||||||
// The initial read succeeded before remote download failed;
|
// The initial read succeeded before remote download failed;
|
||||||
// cleanup must not purge a now-incomplete target on reread.
|
// cleanup must not purge a now-incomplete target on reread.
|
||||||
opts.Transition.RestoreRequest = &RestoreObjectRequest{Days: 1}
|
err = er.clearRestoreStatus(ctx, r.bucket, r.object, oi.VersionID, nil)
|
||||||
err = er.updateRestoreMetadata(ctx, r.bucket, r.object, fi.ToObjectInfo(r.bucket, r.object, true), opts)
|
|
||||||
case "lookup-cleanup", "lookup-restore":
|
case "lookup-cleanup", "lookup-restore":
|
||||||
// Version-sensitive lookup also covers native restore routing.
|
// Version-sensitive lookup also covers native restore routing.
|
||||||
opts.MetadataChg = true
|
opts.MetadataChg = true
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ func TestExpireRestoredRenewedOrMissing(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
err = expireTransitionedObject(t.Context(), r.z, &stale, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner)
|
err = expireTransitionedObject(t.Context(), r.z, &stale, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner)
|
||||||
if !isErrVersionNotFound(err) {
|
if !errors.Is(err, errLifecycleNotApplicable) {
|
||||||
t.Errorf("stale task should be rejected, got %v", err)
|
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 {
|
if got := r.fileInfo(oi.VersionID); got.Metadata[xhttp.AmzRestore] != renewed || got.DataDir != before.DataDir {
|
||||||
|
|||||||
+30
-8
@@ -37,6 +37,7 @@ import (
|
|||||||
"github.com/minio/minio/internal/amztime"
|
"github.com/minio/minio/internal/amztime"
|
||||||
sse "github.com/minio/minio/internal/bucket/encryption"
|
sse "github.com/minio/minio/internal/bucket/encryption"
|
||||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
"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/bucket/replication"
|
||||||
"github.com/minio/minio/internal/event"
|
"github.com/minio/minio/internal/event"
|
||||||
xhttp "github.com/minio/minio/internal/http"
|
xhttp "github.com/minio/minio/internal/http"
|
||||||
@@ -987,8 +988,10 @@ func postRestoreOpts(ctx context.Context, r *http.Request, bucket, object string
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// set ObjectOptions for PUT call to restore temporary copy of transitioned data
|
// set ObjectOptions for PUT call to restore temporary copy of transitioned data.
|
||||||
func putRestoreOpts(bucket, object string, rreq *RestoreObjectRequest, objInfo ObjectInfo) (putOpts ObjectOptions) {
|
// 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)
|
meta := make(map[string]string)
|
||||||
sc := rreq.OutputLocation.S3.StorageClass
|
sc := rreq.OutputLocation.S3.StorageClass
|
||||||
if sc == "" {
|
if sc == "" {
|
||||||
@@ -1011,8 +1014,8 @@ func putRestoreOpts(bucket, object string, rreq *RestoreObjectRequest, objInfo O
|
|||||||
meta[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionAES
|
meta[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionAES
|
||||||
}
|
}
|
||||||
return ObjectOptions{
|
return ObjectOptions{
|
||||||
Versioned: globalBucketVersioningSys.PrefixEnabled(bucket, object),
|
Versioned: opts.Versioned,
|
||||||
VersionSuspended: globalBucketVersioningSys.PrefixSuspended(bucket, object),
|
VersionSuspended: opts.VersionSuspended,
|
||||||
UserDefined: meta,
|
UserDefined: meta,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1020,19 +1023,38 @@ func putRestoreOpts(bucket, object string, rreq *RestoreObjectRequest, objInfo O
|
|||||||
if len(objInfo.UserTags) != 0 {
|
if len(objInfo.UserTags) != 0 {
|
||||||
meta[xhttp.AmzObjectTagging] = objInfo.UserTags
|
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)
|
restoreExpiry := lifecycle.ExpectedExpiryTime(time.Now().UTC(), rreq.Days)
|
||||||
meta[xhttp.AmzRestore] = completedRestoreObj(restoreExpiry).String()
|
meta[xhttp.AmzRestore] = completedRestoreObj(restoreExpiry).String()
|
||||||
|
delete(meta, restoreAttemptKey)
|
||||||
|
delete(meta, restoreUploadKey)
|
||||||
return ObjectOptions{
|
return ObjectOptions{
|
||||||
Versioned: globalBucketVersioningSys.PrefixEnabled(bucket, object),
|
Versioned: opts.Versioned,
|
||||||
VersionSuspended: globalBucketVersioningSys.PrefixSuspended(bucket, object),
|
VersionSuspended: opts.VersionSuspended,
|
||||||
UserDefined: meta,
|
UserDefined: meta,
|
||||||
VersionID: objInfo.VersionID,
|
VersionID: restoreVersionID(objInfo.VersionID),
|
||||||
MTime: objInfo.ModTime,
|
MTime: objInfo.ModTime,
|
||||||
Expires: objInfo.Expires,
|
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")
|
var errRestoreHDRMalformed = fmt.Errorf("x-amz-restore header malformed")
|
||||||
|
|
||||||
// IsRemote returns true if this object version's contents are in its remote
|
// IsRemote returns true if this object version's contents are in its remote
|
||||||
|
|||||||
@@ -373,6 +373,13 @@ func (er *erasureObjects) healObject(ctx context.Context, bucket string, object
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
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
|
// No modtime quorum
|
||||||
filterDisksByETag := quorumETag != ""
|
filterDisksByETag := quorumETag != ""
|
||||||
|
|||||||
@@ -123,7 +123,20 @@ func (d *multipartCleanupDisk) Endpoint() Endpoint {
|
|||||||
|
|
||||||
func (d *multipartCleanupDisk) ReadVersion(context.Context, string, string, string, string, ReadOptions) (FileInfo, error) {
|
func (d *multipartCleanupDisk) ReadVersion(context.Context, string, string, string, string, ReadOptions) (FileInfo, error) {
|
||||||
d.reads++
|
d.reads++
|
||||||
return FileInfo{ModTime: d.modTime}, d.readErr
|
if d.readErr != nil {
|
||||||
|
return FileInfo{}, d.readErr
|
||||||
|
}
|
||||||
|
fi := newFileInfo("obj", 1, 0)
|
||||||
|
fi.Erasure.Index, fi.ModTime = 1, d.modTime
|
||||||
|
return fi, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// multipartCleanupSet is a set of one drive, which also answers the
|
||||||
|
// metadata reads that establish the restore marker of an expired upload.
|
||||||
|
// Its locks are local: stale cleanup decides a removal under the upload's
|
||||||
|
// commit lock.
|
||||||
|
func multipartCleanupSet(disk StorageAPI) erasureObjects {
|
||||||
|
return erasureObjects{getDisks: func() []StorageAPI { return []StorageAPI{disk} }, nsMutex: newNSLock(false)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *multipartCleanupDisk) StatVol(ctx context.Context, volume string) (VolInfo, error) {
|
func (d *multipartCleanupDisk) StatVol(ctx context.Context, volume string) (VolInfo, error) {
|
||||||
@@ -143,9 +156,10 @@ func TestCleanupStaleUploadsOnDisk(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{name: "recent", prefix: uuid, age: expiry / 2, readErr: errFileNotFound},
|
{name: "recent", prefix: uuid, age: expiry / 2, readErr: errFileNotFound},
|
||||||
{name: "plaintext_base64_collision", prefix: "4eacd477-e733-4851-b821-6fd42785ff87", age: expiry / 2, readErr: errFileNotFound},
|
{name: "plaintext_base64_collision", prefix: "4eacd477-e733-4851-b821-6fd42785ff87", age: expiry / 2, readErr: errFileNotFound},
|
||||||
{name: "expired", prefix: uuid, age: 2 * expiry, readErr: errFileNotFound, wantDeleted: true},
|
// Only an expired upload is read on every drive, for its restore marker.
|
||||||
|
{name: "expired", prefix: uuid, age: 2 * expiry, readErr: errFileNotFound, wantReads: 1, wantDeleted: true},
|
||||||
{name: "legacy_recent", prefix: uuid, age: expiry / 2, legacy: true, wantReads: 1},
|
{name: "legacy_recent", prefix: uuid, age: expiry / 2, legacy: true, wantReads: 1},
|
||||||
{name: "legacy_expired", prefix: uuid, age: 2 * expiry, legacy: true, wantReads: 1, wantDeleted: true},
|
{name: "legacy_expired", prefix: uuid, age: 2 * expiry, legacy: true, wantReads: 2, wantDeleted: true},
|
||||||
{name: "legacy_metadata_missing", prefix: uuid, age: 2 * expiry, legacy: true, readErr: errFileNotFound, wantReads: 1},
|
{name: "legacy_metadata_missing", prefix: uuid, age: 2 * expiry, legacy: true, readErr: errFileNotFound, wantReads: 1},
|
||||||
{name: "malformed_metadata_missing", prefix: "not-a-uuid", age: 2 * expiry, readErr: errFileNotFound, wantReads: 1},
|
{name: "malformed_metadata_missing", prefix: "not-a-uuid", age: 2 * expiry, readErr: errFileNotFound, wantReads: 1},
|
||||||
} {
|
} {
|
||||||
@@ -166,7 +180,7 @@ func TestCleanupStaleUploadsOnDisk(t *testing.T) {
|
|||||||
if err := os.Chtimes(filepath.Dir(uploadDir), modTime, modTime); err != nil {
|
if err := os.Chtimes(filepath.Dir(uploadDir), modTime, modTime); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
erasureObjects{}.cleanupStaleUploadsOnDisk(t.Context(), disk)
|
multipartCleanupSet(disk).cleanupStaleUploadsOnDisk(t.Context(), disk)
|
||||||
if disk.reads != tc.wantReads {
|
if disk.reads != tc.wantReads {
|
||||||
t.Errorf("ReadVersion calls = %d, want %d", disk.reads, tc.wantReads)
|
t.Errorf("ReadVersion calls = %d, want %d", disk.reads, tc.wantReads)
|
||||||
}
|
}
|
||||||
@@ -199,7 +213,7 @@ func TestCleanupStaleUploadsEmptyHashDirectory(t *testing.T) {
|
|||||||
if err := os.Chtimes(shaDir, modTime, modTime); err != nil {
|
if err := os.Chtimes(shaDir, modTime, modTime); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
erasureObjects{}.cleanupStaleUploadsOnDisk(t.Context(), disk)
|
multipartCleanupSet(disk).cleanupStaleUploadsOnDisk(t.Context(), disk)
|
||||||
if _, err := os.Stat(shaDir); !os.IsNotExist(err) {
|
if _, err := os.Stat(shaDir); !os.IsNotExist(err) {
|
||||||
t.Fatalf("empty hash directory must be removed regardless of age: %v", err)
|
t.Fatalf("empty hash directory must be removed regardless of age: %v", err)
|
||||||
}
|
}
|
||||||
@@ -224,7 +238,7 @@ func TestCleanupStaleUploadsSharedHashDirectory(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
erasureObjects{}.cleanupStaleUploadsOnDisk(t.Context(), disk)
|
multipartCleanupSet(disk).cleanupStaleUploadsOnDisk(t.Context(), disk)
|
||||||
if _, err := os.Stat(filepath.Join(shaDir, expiredID)); !os.IsNotExist(err) {
|
if _, err := os.Stat(filepath.Join(shaDir, expiredID)); !os.IsNotExist(err) {
|
||||||
t.Errorf("expired upload must be removed: %v", err)
|
t.Errorf("expired upload must be removed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+152
-16
@@ -34,7 +34,6 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/klauspost/readahead"
|
"github.com/klauspost/readahead"
|
||||||
"github.com/minio/minio-go/v7/pkg/set"
|
"github.com/minio/minio-go/v7/pkg/set"
|
||||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
|
||||||
"github.com/minio/minio/internal/config/storageclass"
|
"github.com/minio/minio/internal/config/storageclass"
|
||||||
"github.com/minio/minio/internal/crypto"
|
"github.com/minio/minio/internal/crypto"
|
||||||
"github.com/minio/minio/internal/hash"
|
"github.com/minio/minio/internal/hash"
|
||||||
@@ -72,6 +71,7 @@ func (er erasureObjects) getMultipartSHADir(bucket, object string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkUploadIDExists - verify if a given uploadID exists and is valid.
|
// checkUploadIDExists - verify if a given uploadID exists and is valid.
|
||||||
|
// The metadata read from each drive is returned also with an error.
|
||||||
func (er erasureObjects) checkUploadIDExists(ctx context.Context, bucket, object, uploadID string, write bool) (fi FileInfo, metArr []FileInfo, err error) {
|
func (er erasureObjects) checkUploadIDExists(ctx context.Context, bucket, object, uploadID string, write bool) (fi FileInfo, metArr []FileInfo, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if errors.Is(err, errFileNotFound) {
|
if errors.Is(err, errFileNotFound) {
|
||||||
@@ -89,15 +89,15 @@ func (er erasureObjects) checkUploadIDExists(ctx context.Context, bucket, object
|
|||||||
|
|
||||||
readQuorum, writeQuorum, err := objectQuorumFromMeta(ctx, partsMetadata, errs, er.defaultParityCount)
|
readQuorum, writeQuorum, err := objectQuorumFromMeta(ctx, partsMetadata, errs, er.defaultParityCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fi, nil, err
|
return fi, partsMetadata, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if readQuorum < 0 {
|
if readQuorum < 0 {
|
||||||
return fi, nil, errErasureReadQuorum
|
return fi, partsMetadata, errErasureReadQuorum
|
||||||
}
|
}
|
||||||
|
|
||||||
if writeQuorum < 0 {
|
if writeQuorum < 0 {
|
||||||
return fi, nil, errErasureWriteQuorum
|
return fi, partsMetadata, errErasureWriteQuorum
|
||||||
}
|
}
|
||||||
|
|
||||||
quorum := readQuorum
|
quorum := readQuorum
|
||||||
@@ -114,7 +114,7 @@ func (er erasureObjects) checkUploadIDExists(ctx context.Context, bucket, object
|
|||||||
err = reduceReadQuorumErrs(ctx, errs, objectOpIgnoredErrs, readQuorum)
|
err = reduceReadQuorumErrs(ctx, errs, objectOpIgnoredErrs, readQuorum)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fi, nil, err
|
return fi, partsMetadata, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick one from the first valid metadata.
|
// Pick one from the first valid metadata.
|
||||||
@@ -217,12 +217,55 @@ func (er erasureObjects) cleanupStaleUploadsOnDisk(ctx context.Context, disk Sto
|
|||||||
hasSkippedUploads = true
|
hasSkippedUploads = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// A completion holds this lock from staging its commit in the
|
||||||
|
// upload until the commit ends: decide on a removal only while
|
||||||
|
// holding it, and skip an upload whose commit is running. Only
|
||||||
|
// uploads past the stale age pay for the lock and the read below.
|
||||||
|
lk := er.restoreCommitLock(uploadIDPath)
|
||||||
|
lkctx, err := lk.GetLock(ctx, newDynamicTimeout(time.Second, time.Second))
|
||||||
|
if err != nil {
|
||||||
|
hasSkippedUploads = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The rename below releases the lock when it ends, also when it
|
||||||
|
// outlives its deadline; every other exit releases it here.
|
||||||
|
renaming := false
|
||||||
|
defer func() {
|
||||||
|
if !renaming {
|
||||||
|
lk.Unlock(lkctx)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// A native restore can run past the stale age: keep its upload
|
||||||
|
// while its attempt still owns the version, and keep any upload
|
||||||
|
// whose marker the drives cannot establish.
|
||||||
|
view := er.readRestoreUpload(lkctx.Context(), "", uploadIDPath)
|
||||||
|
if view.committing {
|
||||||
|
// What a commit staged, with no commit running: only a staged
|
||||||
|
// copy itself shows when it staged, only an old one is a
|
||||||
|
// leftover of a failed commit, and a copy not staged yet
|
||||||
|
// tells nothing.
|
||||||
|
local, err := disk.ReadVersion(lkctx.Context(), "", minioMetaMultipartBucket, uploadIDPath, "", ReadOptions{})
|
||||||
|
st, serr := os.Stat(pathJoin(drivePath, minioMetaMultipartBucket, uploadIDPath, xlStorageFormatFile))
|
||||||
|
if err != nil || serr != nil || !restoreUploadStaged(local) || time.Since(st.ModTime()) < restoreCommitGrace {
|
||||||
|
hasSkippedUploads = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else if view.live && (!view.known || (view.marker != nil && er.restoreUploadActive(lkctx.Context(), view))) {
|
||||||
|
hasSkippedUploads = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
renaming = true
|
||||||
w := xioutil.NewDeadlineWorker(globalDriveConfig.GetMaxTimeout())
|
w := xioutil.NewDeadlineWorker(globalDriveConfig.GetMaxTimeout())
|
||||||
return w.Run(func() error {
|
return w.Run(func() error {
|
||||||
wait := deleteMultipartCleanupSleeper.Timer(ctx)
|
wait := deleteMultipartCleanupSleeper.Timer(ctx)
|
||||||
pathUUID := mustGetUUID()
|
pathUUID := mustGetUUID()
|
||||||
targetPath := pathJoin(drivePath, minioMetaTmpDeletedBucket, pathUUID)
|
targetPath := pathJoin(drivePath, minioMetaTmpDeletedBucket, pathUUID)
|
||||||
renameAll(pathJoin(drivePath, minioMetaMultipartBucket, uploadIDPath), targetPath, pathJoin(drivePath, minioMetaBucket))
|
err := renameAll(pathJoin(drivePath, minioMetaMultipartBucket, uploadIDPath), targetPath, pathJoin(drivePath, minioMetaBucket))
|
||||||
|
lk.Unlock(lkctx)
|
||||||
|
if err != nil {
|
||||||
|
// The next cleanup round tries again.
|
||||||
|
storageLogIf(ctx, fmt.Errorf("Unable to remove stale upload %s on %s: %w", uploadIDPath, drivePath, err))
|
||||||
|
}
|
||||||
wait()
|
wait()
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -463,6 +506,13 @@ func (er erasureObjects) listMultipartUploadsExact(ctx context.Context, bucket,
|
|||||||
if populatedUploadIDs.Contains(uploadID) {
|
if populatedUploadIDs.Contains(uploadID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// A native restore's upload is private to its attempt; list only an
|
||||||
|
// upload that the drives establish as an ordinary one.
|
||||||
|
metas, errs := readAllFileInfo(ctx, er.getDisks(), bucket, minioMetaMultipartBucket,
|
||||||
|
pathJoin(er.getMultipartSHADir(bucket, object), uploadID), "", false, false)
|
||||||
|
if !restoreUploadState(metas, errs).visible(ObjectOptions{}) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var fallback time.Time
|
var fallback time.Time
|
||||||
if _, ok := multipartUploadTime(uploadID); !ok {
|
if _, ok := multipartUploadTime(uploadID); !ok {
|
||||||
fi, err := disk.ReadVersion(ctx, bucket, minioMetaMultipartBucket,
|
fi, err := disk.ReadVersion(ctx, bucket, minioMetaMultipartBucket,
|
||||||
@@ -562,6 +612,11 @@ func (er erasureObjects) newMultipartUpload(ctx context.Context, bucket string,
|
|||||||
if opts.PreserveETag != "" {
|
if opts.PreserveETag != "" {
|
||||||
userDefined["etag"] = opts.PreserveETag
|
userDefined["etag"] = opts.PreserveETag
|
||||||
}
|
}
|
||||||
|
// Only a native restore marks its upload private, never caller metadata.
|
||||||
|
delete(userDefined, restoreUploadKey)
|
||||||
|
if a := opts.Transition.attempt; a != nil {
|
||||||
|
userDefined[restoreUploadKey] = a.String()
|
||||||
|
}
|
||||||
onlineDisks := er.getDisks()
|
onlineDisks := er.getDisks()
|
||||||
|
|
||||||
// Get parity and data drive count based on storage class metadata
|
// Get parity and data drive count based on storage class metadata
|
||||||
@@ -743,7 +798,10 @@ func (er erasureObjects) PutObjectPart(ctx context.Context, bucket, object, uplo
|
|||||||
|
|
||||||
uploadIDPath := er.getUploadIDDir(bucket, object, uploadID)
|
uploadIDPath := er.getUploadIDDir(bucket, object, uploadID)
|
||||||
// Validates if upload ID exists.
|
// Validates if upload ID exists.
|
||||||
fi, _, err := er.checkUploadIDExists(ctx, bucket, object, uploadID, true)
|
fi, metas, err := er.checkUploadIDExists(ctx, bucket, object, uploadID, true)
|
||||||
|
if err == nil && !restoreUploadVisible(metas, opts) {
|
||||||
|
err = errUploadIDNotFound
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errVolumeNotFound) {
|
if errors.Is(err, errVolumeNotFound) {
|
||||||
return pi, toObjectErr(err, bucket)
|
return pi, toObjectErr(err, bucket)
|
||||||
@@ -980,7 +1038,10 @@ func (er erasureObjects) GetMultipartInfo(ctx context.Context, bucket, object, u
|
|||||||
UploadID: uploadID,
|
UploadID: uploadID,
|
||||||
}
|
}
|
||||||
|
|
||||||
fi, _, err := er.checkUploadIDExists(ctx, bucket, object, uploadID, false)
|
fi, metas, err := er.checkUploadIDExists(ctx, bucket, object, uploadID, false)
|
||||||
|
if err == nil && !restoreUploadVisible(metas, opts) {
|
||||||
|
err = errUploadIDNotFound
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errVolumeNotFound) {
|
if errors.Is(err, errVolumeNotFound) {
|
||||||
return result, toObjectErr(err, bucket)
|
return result, toObjectErr(err, bucket)
|
||||||
@@ -1058,7 +1119,10 @@ func (er erasureObjects) ListObjectParts(ctx context.Context, bucket, object, up
|
|||||||
auditObjectErasureSet(ctx, "ListObjectParts", object, &er)
|
auditObjectErasureSet(ctx, "ListObjectParts", object, &er)
|
||||||
}
|
}
|
||||||
|
|
||||||
fi, _, err := er.checkUploadIDExists(ctx, bucket, object, uploadID, false)
|
fi, metas, err := er.checkUploadIDExists(ctx, bucket, object, uploadID, false)
|
||||||
|
if err == nil && !restoreUploadVisible(metas, opts) {
|
||||||
|
err = errUploadIDNotFound
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, toObjectErr(err, bucket, object, uploadID)
|
return result, toObjectErr(err, bucket, object, uploadID)
|
||||||
}
|
}
|
||||||
@@ -1271,7 +1335,7 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
|||||||
auditObjectErasureSet(ctx, "CompleteMultipartUpload", object, &er)
|
auditObjectErasureSet(ctx, "CompleteMultipartUpload", object, &er)
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.CheckPrecondFn != nil || opts.ReplicaLockReconcile {
|
if opts.CheckPrecondFn != nil || opts.ReplicaLockReconcile || opts.Transition.attempt != nil {
|
||||||
if !opts.NoLock {
|
if !opts.NoLock {
|
||||||
ns := er.NewNSLock(bucket, object)
|
ns := er.NewNSLock(bucket, object)
|
||||||
lkctx, err := ns.GetLock(ctx, globalOperationTimeout)
|
lkctx, err := ns.GetLock(ctx, globalOperationTimeout)
|
||||||
@@ -1304,7 +1368,24 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.Transition.attempt != nil {
|
||||||
|
// A native restore holds the upload write lock from validation
|
||||||
|
// through commit, after the object lock and in the lock domain of
|
||||||
|
// PutObjectPart, which takes a part lock and then this lock to read.
|
||||||
|
// NoLock only says that the caller holds the object lock.
|
||||||
|
uploadIDLock := er.NewNSLock(bucket, pathJoin(object, uploadID))
|
||||||
|
ulkctx, err := uploadIDLock.GetLock(ctx, globalOperationTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return oi, err
|
||||||
|
}
|
||||||
|
ctx = ulkctx.Context()
|
||||||
|
defer uploadIDLock.Unlock(ulkctx)
|
||||||
|
}
|
||||||
|
|
||||||
fi, partsMetadata, err := er.checkUploadIDExists(ctx, bucket, object, uploadID, true)
|
fi, partsMetadata, err := er.checkUploadIDExists(ctx, bucket, object, uploadID, true)
|
||||||
|
if err == nil && !restoreUploadVisible(partsMetadata, opts) {
|
||||||
|
err = errUploadIDNotFound
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errVolumeNotFound) {
|
if errors.Is(err, errVolumeNotFound) {
|
||||||
return oi, toObjectErr(err, bucket)
|
return oi, toObjectErr(err, bucket)
|
||||||
@@ -1618,6 +1699,7 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
|||||||
delete(fi.Metadata, hash.MinIOMultipartChecksumType)
|
delete(fi.Metadata, hash.MinIOMultipartChecksumType)
|
||||||
delete(fi.Metadata, multipartMetaBucket)
|
delete(fi.Metadata, multipartMetaBucket)
|
||||||
delete(fi.Metadata, multipartMetaObject)
|
delete(fi.Metadata, multipartMetaObject)
|
||||||
|
delete(fi.Metadata, restoreUploadKey)
|
||||||
|
|
||||||
// Save the final object size and modtime.
|
// Save the final object size and modtime.
|
||||||
fi.Size = objectSize
|
fi.Size = objectSize
|
||||||
@@ -1697,6 +1779,20 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
|||||||
if err := checkObjectLockedForOverwrite(ctx, bucket, object, getObjectInfo, opts.Transition); err != nil {
|
if err := checkObjectLockedForOverwrite(ctx, bucket, object, getObjectInfo, opts.Transition); err != nil {
|
||||||
return oi, toObjectErr(err, bucket, object)
|
return oi, toObjectErr(err, bucket, object)
|
||||||
}
|
}
|
||||||
|
if a := opts.Transition.attempt; a != nil {
|
||||||
|
// A native restore writes back only the null version it targets.
|
||||||
|
current, gerr := getObjectInfo(ctx, bucket, object, ObjectOptions{
|
||||||
|
VersionID: nullVersionID,
|
||||||
|
Transition: opts.Transition,
|
||||||
|
NoLock: true,
|
||||||
|
})
|
||||||
|
if gerr != nil {
|
||||||
|
return oi, toObjectErr(gerr, bucket, object)
|
||||||
|
}
|
||||||
|
if !a.rehydrates(current, fi) {
|
||||||
|
return oi, toObjectErr(errAuthentication, bucket, object)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
current, gerr := getObjectInfo(ctx, bucket, object, ObjectOptions{
|
current, gerr := getObjectInfo(ctx, bucket, object, ObjectOptions{
|
||||||
VersionID: fi.VersionID,
|
VersionID: fi.VersionID,
|
||||||
@@ -1706,11 +1802,10 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
|||||||
NoLock: true,
|
NoLock: true,
|
||||||
})
|
})
|
||||||
switch {
|
switch {
|
||||||
case gerr == nil && opts.Transition.RestoreRequest != nil &&
|
case gerr == nil && opts.Transition.attempt.rehydrates(current, fi):
|
||||||
current.TransitionedObject.Status == lifecycle.TransitionComplete &&
|
// A native restore's own private upload rehydrates this same
|
||||||
current.ETag == fi.Metadata["etag"] && current.Size == fi.Size && current.ModTime.Equal(fi.ModTime):
|
// tiered version. Any other completion must still reject a
|
||||||
// The internal restore path rehydrates this same tiered version.
|
// version overwrite.
|
||||||
// Ordinary multipart completion must still reject a version overwrite.
|
|
||||||
case gerr == nil, errors.As(gerr, &MethodNotAllowed{}):
|
case gerr == nil, errors.As(gerr, &MethodNotAllowed{}):
|
||||||
return oi, toObjectErr(errAuthentication, bucket, object)
|
return oi, toObjectErr(errAuthentication, bucket, object)
|
||||||
case isErrVersionNotFound(gerr) || isErrObjectNotFound(gerr):
|
case isErrVersionNotFound(gerr) || isErrObjectNotFound(gerr):
|
||||||
@@ -1720,6 +1815,16 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stale cleanup and abort judge an upload whose commit staged object
|
||||||
|
// metadata in it by this lock: hold it from staging through the commit.
|
||||||
|
commitLock := er.restoreCommitLock(uploadIDPath)
|
||||||
|
clkctx, err := commitLock.GetLock(ctx, globalOperationTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return oi, err
|
||||||
|
}
|
||||||
|
ctx = clkctx.Context()
|
||||||
|
defer commitLock.Unlock(clkctx)
|
||||||
|
|
||||||
er.cleanupMultipartPath(ctx, paths...) // cleanup all part.N.meta, and skipped part.N's before final rename().
|
er.cleanupMultipartPath(ctx, paths...) // cleanup all part.N.meta, and skipped part.N's before final rename().
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -1791,7 +1896,38 @@ func (er erasureObjects) abortMultipartUpload(ctx context.Context, bucket, objec
|
|||||||
if !ok || internalID == "" || internalID == "." || internalID == ".." || strings.ContainsAny(internalID, "/\\") {
|
if !ok || internalID == "" || internalID == "." || internalID == ".." || strings.ContainsAny(internalID, "/\\") {
|
||||||
return false, InvalidUploadID{Bucket: bucket, Object: object, UploadID: uploadID}
|
return false, InvalidUploadID{Bucket: bucket, Object: object, UploadID: uploadID}
|
||||||
}
|
}
|
||||||
defer er.deleteAll(ctx, minioMetaMultipartBucket, er.getUploadIDDir(bucket, object, uploadID))
|
// Authorize before the unconditional cleanup below. A native restore's
|
||||||
|
// upload is absent to every other caller, and an upload whose marker the
|
||||||
|
// drives cannot establish is not removed by anyone.
|
||||||
|
uploadIDPath := er.getUploadIDDir(bucket, object, uploadID)
|
||||||
|
view := er.readRestoreUpload(ctx, bucket, uploadIDPath)
|
||||||
|
if view.committing {
|
||||||
|
// What a failed commit left behind, also after stale cleanup could
|
||||||
|
// not remove it: only with no commit running, and once every copy
|
||||||
|
// shows a staging older than the grace.
|
||||||
|
lk := er.restoreCommitLock(uploadIDPath)
|
||||||
|
lkctx, err := lk.GetLock(ctx, newDynamicTimeout(restoreTakeoverWait, restoreTakeoverWait))
|
||||||
|
if err != nil {
|
||||||
|
return false, toObjectErr(errErasureReadQuorum, bucket, object, uploadID)
|
||||||
|
}
|
||||||
|
defer lk.Unlock(lkctx)
|
||||||
|
if !er.restoreCommitAbandoned(lkctx.Context(), bucket, uploadIDPath) {
|
||||||
|
return false, toObjectErr(errErasureReadQuorum, bucket, object, uploadID)
|
||||||
|
}
|
||||||
|
er.deleteAll(lkctx.Context(), minioMetaMultipartBucket, uploadIDPath)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case view.visible(opts):
|
||||||
|
case view.known && view.marked:
|
||||||
|
return false, nil
|
||||||
|
case view.known && opts.Transition.attempt != nil:
|
||||||
|
// A native restore's own abort, of an upload no longer its own.
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, toObjectErr(errErasureReadQuorum, bucket, object, uploadID)
|
||||||
|
}
|
||||||
|
defer er.deleteAll(ctx, minioMetaMultipartBucket, uploadIDPath)
|
||||||
_, _, err = er.checkUploadIDExists(ctx, bucket, object, uploadID, false)
|
_, _, err = er.checkUploadIDExists(ctx, bucket, object, uploadID, false)
|
||||||
err = toObjectErr(err, bucket, object, uploadID)
|
err = toObjectErr(err, bucket, object, uploadID)
|
||||||
if _, absent := err.(InvalidUploadID); absent {
|
if _, absent := err.(InvalidUploadID); absent {
|
||||||
|
|||||||
+84
-52
@@ -2158,6 +2158,20 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
|||||||
if opts.Transition.ExpireRestored && (gerr != nil || goi.DeleteMarker) {
|
if opts.Transition.ExpireRestored && (gerr != nil || goi.DeleteMarker) {
|
||||||
return objInfo, VersionNotFound{Bucket: bucket, Object: object, VersionID: opts.VersionID}
|
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 {
|
if opts.EvalMetadataFn != nil {
|
||||||
dsc, err := opts.EvalMetadataFn(&goi, gerr)
|
dsc, err := opts.EvalMetadataFn(&goi, gerr)
|
||||||
@@ -2660,29 +2674,6 @@ func (er erasureObjects) RestoreTransitionedObject(ctx context.Context, bucket,
|
|||||||
return er.restoreTransitionedObject(ctx, bucket, object, opts)
|
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,
|
|
||||||
Transition: opts.Transition,
|
|
||||||
}, ObjectOptions{
|
|
||||||
VersionID: oi.VersionID,
|
|
||||||
MTime: oi.ModTime,
|
|
||||||
NoLock: opts.NoLock,
|
|
||||||
Transition: opts.Transition,
|
|
||||||
}); 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRestoreLock waits for a restore's object lock for as long as ctx lives.
|
// getRestoreLock waits for a restore's object lock for as long as ctx lives.
|
||||||
// The POST handler has already marked the version ongoing, and only the
|
// The POST handler has already marked the version ongoing, and only the
|
||||||
// restore clears that marker. A restore queued behind another version's
|
// restore clears that marker. A restore queued behind another version's
|
||||||
@@ -2698,6 +2689,11 @@ func getRestoreLock(ctx context.Context, lk RWLocker) (LockContext, error) {
|
|||||||
|
|
||||||
// restoreTransitionedObject for multipart object chunks the file stream from remote tier into the same number of parts
|
// 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
|
// 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
|
||||||
|
//
|
||||||
|
// A POST restore publishes its attempt on the version's ongoing marker. The
|
||||||
|
// restore rehydrates the version only while that attempt owns the marker, and
|
||||||
|
// on failure clears the marker only while it still does. A direct caller
|
||||||
|
// publishes no attempt; its attempt only names the private upload.
|
||||||
func (er erasureObjects) restoreTransitionedObject(ctx context.Context, bucket string, object string, opts ObjectOptions) error {
|
func (er erasureObjects) restoreTransitionedObject(ctx context.Context, bucket string, object string, opts ObjectOptions) error {
|
||||||
if !opts.NoLock {
|
if !opts.NoLock {
|
||||||
// Single-pool and direct callers must protect the metadata snapshot
|
// Single-pool and direct callers must protect the metadata snapshot
|
||||||
@@ -2705,30 +2701,45 @@ func (er erasureObjects) restoreTransitionedObject(ctx context.Context, bucket s
|
|||||||
lk := er.NewNSLock(bucket, object)
|
lk := er.NewNSLock(bucket, object)
|
||||||
lkctx, err := getRestoreLock(ctx, lk)
|
lkctx, err := getRestoreLock(ctx, lk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return restoreResetError{err}
|
||||||
}
|
}
|
||||||
ctx = lkctx.Context()
|
ctx = lkctx.Context()
|
||||||
defer lk.Unlock(lkctx)
|
defer lk.Unlock(lkctx)
|
||||||
opts.NoLock = true
|
opts.NoLock = true
|
||||||
}
|
}
|
||||||
|
|
||||||
setRestoreHeaderFn := func(oi ObjectInfo, rerr error) error {
|
published := opts.Transition.attempt
|
||||||
if rerr == nil {
|
// Restore the authoritative generation of the version, never a revision
|
||||||
return nil // nothing to do; restore object was successful
|
// that only some drives hold.
|
||||||
}
|
gen, err := er.readRestoreGeneration(ctx, bucket, object, opts.VersionID)
|
||||||
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 {
|
if err != nil {
|
||||||
// No version was read, so there is no restore metadata to update.
|
err = toObjectErr(err, bucket, object, opts.VersionID)
|
||||||
return toObjectErr(err, bucket, object)
|
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||||
|
// No version was read, so there is no restore marker to clear.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return restoreResetError{err}
|
||||||
|
}
|
||||||
|
actualfi := gen.fi
|
||||||
|
if actualfi.Deleted {
|
||||||
|
return toObjectErr(errMethodNotAllowed, bucket, object)
|
||||||
}
|
}
|
||||||
|
|
||||||
oi = actualfi.ToObjectInfo(bucket, object, opts.Versioned || opts.VersionSuspended)
|
oi := actualfi.ToObjectInfo(bucket, object, opts.Versioned || opts.VersionSuspended)
|
||||||
ropts := putRestoreOpts(bucket, object, opts.Transition.RestoreRequest, oi)
|
attempt := published
|
||||||
|
if attempt == nil {
|
||||||
|
attempt = newRestoreAttempt(oi)
|
||||||
|
} else if !attempt.owns(oi) {
|
||||||
|
// A later POST took the version over, or it was replaced.
|
||||||
|
return errRestoreSuperseded
|
||||||
|
}
|
||||||
|
opts.Transition.attempt = attempt
|
||||||
|
versionID := restoreVersionID(actualfi.VersionID)
|
||||||
|
fail := func(cause error) error {
|
||||||
|
return er.restoreFailed(ctx, bucket, object, versionID, published, cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
ropts := putRestoreOpts(opts.Transition.RestoreRequest, oi, opts)
|
||||||
ropts.NoLock = opts.NoLock
|
ropts.NoLock = opts.NoLock
|
||||||
ropts.Transition = opts.Transition
|
ropts.Transition = opts.Transition
|
||||||
// Rehydration copies encoded bytes without changing their content identity.
|
// Rehydration copies encoded bytes without changing their content identity.
|
||||||
@@ -2738,35 +2749,51 @@ func (er erasureObjects) restoreTransitionedObject(ctx context.Context, bucket s
|
|||||||
if len(oi.Parts) == 1 && !oi.isMultipart() {
|
if len(oi.Parts) == 1 && !oi.isMultipart() {
|
||||||
actualSize, err := oi.GetActualSize()
|
actualSize, err := oi.GetActualSize()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return setRestoreHeaderFn(oi, err)
|
return fail(err)
|
||||||
}
|
}
|
||||||
ropts.IndexCB = func() []byte { return oi.Parts[0].Index }
|
ropts.IndexCB = func() []byte { return oi.Parts[0].Index }
|
||||||
var rs *HTTPRangeSpec
|
var rs *HTTPRangeSpec
|
||||||
gr, err := getTransitionedObjectReader(ctx, bucket, object, rs, http.Header{}, oi, opts)
|
gr, err := getTransitionedObjectReader(ctx, bucket, object, rs, http.Header{}, oi, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return setRestoreHeaderFn(oi, toObjectErr(err, bucket, object))
|
return fail(toObjectErr(err, bucket, object))
|
||||||
}
|
}
|
||||||
defer gr.Close()
|
defer gr.Close()
|
||||||
hashReader, err := hash.NewReader(ctx, gr, gr.ObjInfo.Size, "", "", actualSize)
|
hashReader, err := hash.NewReader(ctx, gr, gr.ObjInfo.Size, "", "", actualSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return setRestoreHeaderFn(oi, toObjectErr(err, bucket, object))
|
return fail(toObjectErr(err, bucket, object))
|
||||||
}
|
}
|
||||||
pReader := NewPutObjReader(hashReader)
|
pReader := NewPutObjReader(hashReader)
|
||||||
_, err = er.PutObject(ctx, bucket, object, pReader, ropts)
|
if _, err = er.PutObject(ctx, bucket, object, pReader, ropts); err != nil {
|
||||||
return setRestoreHeaderFn(oi, toObjectErr(err, bucket, object))
|
return fail(toObjectErr(err, bucket, object))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := er.NewMultipartUpload(ctx, bucket, object, ropts)
|
// Every server must keep the upload private, also a queued restore's.
|
||||||
if err != nil {
|
if err := nativeRestoreAvailable(ctx); err != nil {
|
||||||
return setRestoreHeaderFn(oi, err)
|
return fail(err)
|
||||||
}
|
}
|
||||||
|
// The upload records when it starts, which the stale-upload cleanup ages;
|
||||||
|
// completion still commits the version's own ModTime.
|
||||||
|
uopts := ropts
|
||||||
|
uopts.MTime = time.Time{}
|
||||||
|
res, err := er.NewMultipartUpload(ctx, bucket, object, uopts)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
completed := false
|
||||||
|
defer func() {
|
||||||
|
if !completed {
|
||||||
|
er.abortRestoreUpload(bucket, object, res.UploadID, attempt)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
var uploadedParts []CompletePart
|
var uploadedParts []CompletePart
|
||||||
var rs *HTTPRangeSpec
|
var rs *HTTPRangeSpec
|
||||||
// get reader from the warm backend - note that even in the case of encrypted objects, this stream is still encrypted.
|
// 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)
|
gr, err := getTransitionedObjectReader(ctx, bucket, object, rs, http.Header{}, oi, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return setRestoreHeaderFn(oi, err)
|
return fail(err)
|
||||||
}
|
}
|
||||||
defer gr.Close()
|
defer gr.Close()
|
||||||
|
|
||||||
@@ -2781,16 +2808,17 @@ func (er erasureObjects) restoreTransitionedObject(ctx context.Context, bucket s
|
|||||||
}
|
}
|
||||||
hr, err := hash.NewReader(ctx, io.LimitReader(gr, partInfo.Size), partInfo.Size, "", "", actualSize)
|
hr, err := hash.NewReader(ctx, io.LimitReader(gr, partInfo.Size), partInfo.Size, "", "", actualSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return setRestoreHeaderFn(oi, err)
|
return fail(err)
|
||||||
}
|
}
|
||||||
pInfo, err := er.PutObjectPart(ctx, bucket, object, res.UploadID, partInfo.Number, NewPutObjReader(hr), ObjectOptions{
|
pInfo, err := er.PutObjectPart(ctx, bucket, object, res.UploadID, partInfo.Number, NewPutObjReader(hr), ObjectOptions{
|
||||||
IndexCB: func() []byte { return partInfo.Index },
|
IndexCB: func() []byte { return partInfo.Index },
|
||||||
|
Transition: TransitionOptions{attempt: attempt},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return setRestoreHeaderFn(oi, err)
|
return fail(err)
|
||||||
}
|
}
|
||||||
if pInfo.Size != partInfo.Size {
|
if pInfo.Size != partInfo.Size {
|
||||||
return setRestoreHeaderFn(oi, InvalidObjectState{Bucket: bucket, Object: object})
|
return fail(InvalidObjectState{Bucket: bucket, Object: object})
|
||||||
}
|
}
|
||||||
uploadedParts = append(uploadedParts, CompletePart{
|
uploadedParts = append(uploadedParts, CompletePart{
|
||||||
PartNumber: pInfo.PartNumber,
|
PartNumber: pInfo.PartNumber,
|
||||||
@@ -2804,7 +2832,11 @@ func (er erasureObjects) restoreTransitionedObject(ctx context.Context, bucket s
|
|||||||
VersionSuspended: opts.VersionSuspended,
|
VersionSuspended: opts.VersionSuspended,
|
||||||
Transition: opts.Transition,
|
Transition: opts.Transition,
|
||||||
})
|
})
|
||||||
return setRestoreHeaderFn(oi, err)
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
completed = true
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecomTieredObject - moves tiered object to another pool during decommissioning.
|
// DecomTieredObject - moves tiered object to another pool during decommissioning.
|
||||||
|
|||||||
@@ -3369,8 +3369,16 @@ func (z *erasureServerPools) TransitionObject(ctx context.Context, bucket, objec
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RestoreTransitionedObject - restore transitioned object content locally on this cluster.
|
// 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) RestoreTransitionedObject(ctx context.Context, bucket, object string, opts ObjectOptions) (err error) {
|
||||||
object = encodeDirObject(object)
|
object = encodeDirObject(object)
|
||||||
|
defer func() {
|
||||||
|
// The restore stopped before it could clear POST's ongoing marker.
|
||||||
|
var reset restoreResetError
|
||||||
|
if errors.As(err, &reset) {
|
||||||
|
z.recoverRestoreStatus(bucket, object, opts)
|
||||||
|
err = reset.error
|
||||||
|
}
|
||||||
|
}()
|
||||||
if z.SinglePool() {
|
if z.SinglePool() {
|
||||||
return z.serverPools[0].RestoreTransitionedObject(ctx, bucket, object, opts)
|
return z.serverPools[0].RestoreTransitionedObject(ctx, bucket, object, opts)
|
||||||
}
|
}
|
||||||
@@ -3380,7 +3388,7 @@ func (z *erasureServerPools) RestoreTransitionedObject(ctx context.Context, buck
|
|||||||
lk := z.NewNSLock(bucket, object)
|
lk := z.NewNSLock(bucket, object)
|
||||||
lkctx, err := getRestoreLock(ctx, lk)
|
lkctx, err := getRestoreLock(ctx, lk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return restoreResetError{err}
|
||||||
}
|
}
|
||||||
ctx = lkctx.Context()
|
ctx = lkctx.Context()
|
||||||
defer lk.Unlock(lkctx)
|
defer lk.Unlock(lkctx)
|
||||||
@@ -3389,10 +3397,7 @@ func (z *erasureServerPools) RestoreTransitionedObject(ctx context.Context, buck
|
|||||||
// Avoid restoring object from a pool being decommissioned.
|
// Avoid restoring object from a pool being decommissioned.
|
||||||
opts.SkipDecommissioned = true
|
opts.SkipDecommissioned = true
|
||||||
opts.NoLock = true
|
opts.NoLock = true
|
||||||
// Restore an existing version, not the latest version's write location.
|
idx, err := z.restorePoolIdx(ctx, bucket, object, opts)
|
||||||
lookupOpts := opts
|
|
||||||
lookupOpts.MetadataChg = true
|
|
||||||
idx, err := z.getPoolIdxExistingWithOpts(ctx, bucket, object, lookupOpts)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,10 @@ type TransitionOptions struct {
|
|||||||
RestoreRequest *RestoreObjectRequest
|
RestoreRequest *RestoreObjectRequest
|
||||||
RestoreExpiry time.Time
|
RestoreExpiry time.Time
|
||||||
ExpireRestored bool
|
ExpireRestored bool
|
||||||
|
|
||||||
|
// attempt is the native restore a call belongs to. Only that restore
|
||||||
|
// sees its private upload, see restoreUploadVisible.
|
||||||
|
attempt *restoreAttempt
|
||||||
}
|
}
|
||||||
|
|
||||||
// MakeBucketOptions represents bucket options for ObjectLayer bucket operations
|
// MakeBucketOptions represents bucket options for ObjectLayer bucket operations
|
||||||
@@ -295,6 +299,8 @@ type ObjectLayer interface {
|
|||||||
DeleteObjects(ctx context.Context, bucket string, objects []ObjectToDelete, opts ObjectOptions) ([]DeletedObject, []error)
|
DeleteObjects(ctx context.Context, bucket string, objects []ObjectToDelete, opts ObjectOptions) ([]DeletedObject, []error)
|
||||||
TransitionObject(ctx context.Context, bucket, object string, opts ObjectOptions) error
|
TransitionObject(ctx context.Context, bucket, object string, opts ObjectOptions) error
|
||||||
RestoreTransitionedObject(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.
|
// Multipart operations.
|
||||||
ListMultipartUploads(ctx context.Context, bucket, prefix, keyMarker, uploadIDMarker, delimiter string, maxUploads int) (result ListMultipartsInfo, err error)
|
ListMultipartUploads(ctx context.Context, bucket, prefix, keyMarker, uploadIDMarker, delimiter string, maxUploads int) (result ListMultipartsInfo, err error)
|
||||||
|
|||||||
@@ -340,9 +340,9 @@ func TestCopyRewritesObjectData(t *testing.T) {
|
|||||||
name: "data copy always rewrites",
|
name: "data copy always rewrites",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
// PostRestoreObjectHandler, updateRestoreMetadata and batchKeyRotate all
|
// In-place metadata callers such as batchKeyRotate address the same
|
||||||
// address the same version on both sides and never set Versioned, so they
|
// version on both sides and never set Versioned, so they only ever
|
||||||
// only ever reach these two cases.
|
// reach these two cases.
|
||||||
{
|
{
|
||||||
name: "unversioned in-place metadata update",
|
name: "unversioned in-place metadata update",
|
||||||
metadataOnly: true,
|
metadataOnly: true,
|
||||||
|
|||||||
+41
-36
@@ -3955,7 +3955,11 @@ func (api objectAPIHandlers) PostRestoreObjectHandler(w http.ResponseWriter, r *
|
|||||||
|
|
||||||
// Preserve a failed restore read even before the request body is parsed.
|
// Preserve a failed restore read even before the request body is parsed.
|
||||||
opts.Transition.RestoreRequest = &RestoreObjectRequest{}
|
opts.Transition.RestoreRequest = &RestoreObjectRequest{}
|
||||||
objInfo, err := getObjectInfo(ctx, bucket, object, opts)
|
// Resolve the target without the object lock: a running restore holds it
|
||||||
|
// for its download, and the status step bounds its own wait for it.
|
||||||
|
preOpts := opts
|
||||||
|
preOpts.NoLock = true
|
||||||
|
objInfo, err := getObjectInfo(ctx, bucket, object, preOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||||
return
|
return
|
||||||
@@ -3982,48 +3986,47 @@ func (api objectAPIHandlers) PostRestoreObjectHandler(w http.ResponseWriter, r *
|
|||||||
}
|
}
|
||||||
opts.Transition.RestoreRequest = rreq
|
opts.Transition.RestoreRequest = rreq
|
||||||
statusCode := http.StatusOK
|
statusCode := http.StatusOK
|
||||||
alreadyRestored := false
|
// set or upgrade restore expiry
|
||||||
if err == nil {
|
restoreExpiry := lifecycle.ExpectedExpiryTime(time.Now().UTC(), rreq.Days)
|
||||||
if objInfo.RestoreOngoing && rreq.Type != SelectRestoreRequest {
|
|
||||||
|
// update self with restore metadata
|
||||||
|
var attempt *restoreAttempt
|
||||||
|
if rreq.Type != SelectRestoreRequest {
|
||||||
|
// Native restore needs every server to keep its upload private;
|
||||||
|
// 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)
|
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrObjectRestoreAlreadyInProgress), r.URL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !objInfo.RestoreOngoing && !objInfo.RestoreExpires.IsZero() {
|
if errors.As(err, &InvalidObjectState{}) || errors.As(err, &MethodNotAllowed{}) {
|
||||||
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,
|
|
||||||
Transition: opts.Transition,
|
|
||||||
}, ObjectOptions{
|
|
||||||
VersionID: objInfo.VersionID,
|
|
||||||
Transition: opts.Transition,
|
|
||||||
MTime: objInfo.ModTime,
|
|
||||||
MetadataChg: true,
|
|
||||||
}); err != nil {
|
|
||||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidObjectState), r.URL)
|
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidObjectState), r.URL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// for previously restored object, just update the restore expiry
|
if err != nil {
|
||||||
if alreadyRestored {
|
// Such as a read quorum error while telling whether it restores.
|
||||||
|
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||||
return
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreObject := mustGetUUID()
|
restoreObject := mustGetUUID()
|
||||||
@@ -4088,12 +4091,14 @@ func (api objectAPIHandlers) PostRestoreObjectHandler(w http.ResponseWriter, r *
|
|||||||
rreq.SelectParameters.Close()
|
rreq.SelectParameters.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The versioning context and the explicit version POST resolved.
|
||||||
restoreOpts := ObjectOptions{
|
restoreOpts := ObjectOptions{
|
||||||
Transition: TransitionOptions{
|
Transition: TransitionOptions{
|
||||||
RestoreRequest: rreq,
|
RestoreRequest: rreq,
|
||||||
RestoreExpiry: restoreExpiry,
|
RestoreExpiry: restoreExpiry,
|
||||||
|
attempt: attempt,
|
||||||
},
|
},
|
||||||
VersionID: objInfo.VersionID,
|
VersionID: attempt.VersionID,
|
||||||
Versioned: opts.Versioned,
|
Versioned: opts.Versioned,
|
||||||
VersionSuspended: opts.VersionSuspended,
|
VersionSuspended: opts.VersionSuspended,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ var (
|
|||||||
stopRebalanceRPC = grid.NewSingleHandler[*grid.MSS, grid.NoPayload](grid.HandlerStopRebalance, grid.NewMSS, grid.NewNoPayload)
|
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{} })
|
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)
|
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
|
// STREAMS
|
||||||
// Set an output capacity of 100 for consoleLog and listenRPC
|
// 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(stopRebalanceRPC.Register(gm, server.StopRebalanceHandler), "unable to register handler")
|
||||||
logger.FatalIf(updateMetacacheListingRPC.Register(gm, server.UpdateMetacacheListingHandler), "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(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{
|
logger.FatalIf(gm.RegisterStreamingHandler(grid.HandlerTrace, grid.StreamHandler{
|
||||||
Handle: server.TraceHandler,
|
Handle: server.TraceHandler,
|
||||||
|
|||||||
@@ -0,0 +1,862 @@
|
|||||||
|
// 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"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||||
|
xhttp "github.com/minio/minio/internal/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A native restore keeps two private records. The version that POST marks
|
||||||
|
// ongoing names the attempt that owns the marker; that attempt's multipart
|
||||||
|
// upload names the attempt and the version it rehydrates. Clients cannot
|
||||||
|
// set either: requests with reserved metadata are refused, it is never
|
||||||
|
// extracted from headers, and uploads only get the marker from the restore.
|
||||||
|
const (
|
||||||
|
restoreAttemptKey = ReservedMetadataPrefixLower + "restore-attempt"
|
||||||
|
restoreUploadKey = ReservedMetadataPrefixLower + "restore-upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// restoreTakeoverWait bounds how long a POST on an ongoing version waits
|
||||||
|
// for the object lock. A running restore holds it for its download.
|
||||||
|
restoreTakeoverWait = 2 * time.Second
|
||||||
|
// restoreCommitGrace is how long stale cleanup keeps an upload whose
|
||||||
|
// completion staged object metadata in it; a commit takes seconds.
|
||||||
|
restoreCommitGrace = time.Hour
|
||||||
|
// restoreCleanupTimeout bounds each status reset and upload abort.
|
||||||
|
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 object 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRestoreAttempt(s string) (*restoreAttempt, bool) {
|
||||||
|
var a restoreAttempt
|
||||||
|
if s == "" || json.Unmarshal([]byte(s), &a) != nil || a.ID == "" || a.VersionID == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &a, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *restoreAttempt) equal(b *restoreAttempt) bool {
|
||||||
|
return a != nil && b != nil && a.ID == b.ID && a.VersionID == b.VersionID &&
|
||||||
|
a.ETag == b.ETag && a.Size == b.Size && a.ModTime.Equal(b.ModTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// rehydrates reports whether completing upload fi, the attempt's private
|
||||||
|
// upload, writes back the very version current.
|
||||||
|
func (a *restoreAttempt) rehydrates(current ObjectInfo, fi FileInfo) bool {
|
||||||
|
return a.targets(current) && fi.Metadata["etag"] == a.ETag && fi.Size == a.Size && fi.ModTime.Equal(a.ModTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreUploadView is the restore marker of an upload as the drives of
|
||||||
|
// its set establish it. A marked upload is written to a write quorum, at
|
||||||
|
// least a majority of the drives, and its copies never change their marker.
|
||||||
|
type restoreUploadView struct {
|
||||||
|
// marker is the attempt of a native restore's upload: one marker that a
|
||||||
|
// majority of the drives hold.
|
||||||
|
marker *restoreAttempt
|
||||||
|
// marked: some readable drive holds a marker.
|
||||||
|
marked bool
|
||||||
|
// known: the marker state is established. Without it, callers fail
|
||||||
|
// closed: nobody lists, uses, aborts or removes the upload.
|
||||||
|
known bool
|
||||||
|
// live: enough drives hold the upload or cannot tell that a written
|
||||||
|
// upload may exist; otherwise only remnants of one are left.
|
||||||
|
live bool
|
||||||
|
// committing: some drive holds object metadata in the upload's place.
|
||||||
|
// Completion stages the committed version's object metadata there,
|
||||||
|
// without the marker, before it renames the upload into the object.
|
||||||
|
committing bool
|
||||||
|
// bucket and object the upload of an established marker belongs to.
|
||||||
|
bucket, object string
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreUploadState establishes the marker of an upload from the metadata of
|
||||||
|
// every drive. errs may be nil, then a drive without valid metadata is one
|
||||||
|
// that cannot tell. A marker is established on a majority of the drives; its
|
||||||
|
// absence only when no readable drive shows one and the drives that cannot
|
||||||
|
// tell are too few to hold a marked upload.
|
||||||
|
func restoreUploadState(metas []FileInfo, errs []error) restoreUploadView {
|
||||||
|
n := len(metas)
|
||||||
|
majority := n/2 + 1
|
||||||
|
markers := make(map[string]int)
|
||||||
|
var unmarked, unknown, committing int
|
||||||
|
for i, fi := range metas {
|
||||||
|
if errs != nil && errs[i] != nil {
|
||||||
|
if !errors.Is(errs[i], errFileNotFound) && !errors.Is(errs[i], errFileVersionNotFound) &&
|
||||||
|
!errors.Is(errs[i], errVolumeNotFound) && !errors.Is(errs[i], errUploadIDNotFound) {
|
||||||
|
unknown++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !fi.IsValid() {
|
||||||
|
unknown++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if restoreUploadStaged(fi) {
|
||||||
|
committing++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := fi.Metadata[restoreUploadKey]; m != "" {
|
||||||
|
markers[m]++
|
||||||
|
} else {
|
||||||
|
unmarked++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
present := unmarked
|
||||||
|
for _, c := range markers {
|
||||||
|
present += c
|
||||||
|
}
|
||||||
|
v := restoreUploadView{marked: len(markers) > 0, live: present+unknown+committing >= majority, committing: committing > 0}
|
||||||
|
if v.committing {
|
||||||
|
// Nobody but the completion may use or remove it now.
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
for m, c := range markers {
|
||||||
|
if c >= majority {
|
||||||
|
v.marker, v.known = parseRestoreAttempt(m)
|
||||||
|
if v.known {
|
||||||
|
// The marker also names the upload's own target.
|
||||||
|
for _, fi := range metas {
|
||||||
|
if !fi.IsValid() || fi.Metadata[restoreUploadKey] != m {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if restoreVersionID(fi.VersionID) != v.marker.VersionID || fi.Metadata["etag"] != v.marker.ETag {
|
||||||
|
v.marker, v.known = nil, false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
v.bucket, v.object = fi.Metadata[multipartMetaBucket], fi.Metadata[multipartMetaObject]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A marker without a majority is established only as a remnant.
|
||||||
|
v.known = !v.live || (!v.marked && unknown < majority)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreUploadStaged reports whether an upload's record is object metadata
|
||||||
|
// that a completion staged there: an upload record has no parts and no
|
||||||
|
// other versions.
|
||||||
|
func restoreUploadStaged(fi FileInfo) bool {
|
||||||
|
return fi.Deleted || len(fi.Parts) > 0 || fi.NumVersions > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// visible reports whether a caller sees the upload: a native restore its own
|
||||||
|
// upload, any other caller only an upload established as unmarked.
|
||||||
|
func (v restoreUploadView) visible(opts ObjectOptions) bool {
|
||||||
|
if a := opts.Transition.attempt; a != nil {
|
||||||
|
return v.marker.equal(a)
|
||||||
|
}
|
||||||
|
return v.known && !v.marked
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreUploadVisible reports whether a caller sees an upload whose metadata
|
||||||
|
// is metas, one entry for each drive.
|
||||||
|
func restoreUploadVisible(metas []FileInfo, opts ObjectOptions) bool {
|
||||||
|
return restoreUploadState(metas, nil).visible(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRestoreUpload reads the metadata of an upload from every drive.
|
||||||
|
func (er erasureObjects) readRestoreUpload(ctx context.Context, bucket, uploadIDPath string) restoreUploadView {
|
||||||
|
metas, errs := readAllFileInfo(ctx, er.getDisks(), bucket, minioMetaMultipartBucket, uploadIDPath, "", false, false)
|
||||||
|
return restoreUploadState(metas, errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreCommitLock is held by a completion from staging through its commit;
|
||||||
|
// it is named by the upload's own path, which an upload whose copies all
|
||||||
|
// hold staged object metadata still has.
|
||||||
|
func (er erasureObjects) restoreCommitLock(uploadIDPath string) RWLocker {
|
||||||
|
return er.NewNSLock(minioMetaMultipartBucket, uploadIDPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreCommitAbandoned reports whether an upload holds only what a failed
|
||||||
|
// commit left: every drive answers, and every copy that is there is staged
|
||||||
|
// object metadata, see restoreUploadStaged, older than restoreCommitGrace by
|
||||||
|
// that drive's own metadata file. A copy that cannot be read, or that is not
|
||||||
|
// staged yet, may belong to a commit still running. The caller holds the
|
||||||
|
// upload's restoreCommitLock.
|
||||||
|
func (er erasureObjects) restoreCommitAbandoned(ctx context.Context, bucket, uploadIDPath string) bool {
|
||||||
|
disks := er.getDisks()
|
||||||
|
metas, errs := readAllFileInfo(ctx, disks, bucket, minioMetaMultipartBucket, uploadIDPath, "", false, false)
|
||||||
|
staged := 0
|
||||||
|
for i, fi := range metas {
|
||||||
|
if errs[i] != nil {
|
||||||
|
if errors.Is(errs[i], errFileNotFound) || errors.Is(errs[i], errFileVersionNotFound) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !fi.IsValid() || !restoreUploadStaged(fi) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
st, err := disks[i].StatInfoFile(ctx, minioMetaMultipartBucket, pathJoin(uploadIDPath, xlStorageFormatFile), false)
|
||||||
|
if err != nil || len(st) == 0 || time.Since(st[0].ModTime) < restoreCommitGrace {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
staged++
|
||||||
|
}
|
||||||
|
return staged > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
type restoreGeneration struct {
|
||||||
|
fi FileInfo
|
||||||
|
drives []FileInfo
|
||||||
|
disks []StorageAPI
|
||||||
|
split bool
|
||||||
|
ongoing map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreFailed clears the ongoing marker of a failed attempt while the
|
||||||
|
// object lock is held. A lost lock or an ended context leaves that to the
|
||||||
|
// bounded retries of the pools layer, which take the lock afresh.
|
||||||
|
func (er erasureObjects) restoreFailed(ctx context.Context, bucket, object, versionID string, published *restoreAttempt, cause error) error {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return restoreResetError{cause}
|
||||||
|
}
|
||||||
|
if err := er.clearRestoreStatus(ctx, bucket, object, versionID, published); err != nil {
|
||||||
|
storageLogIf(ctx, fmt.Errorf("Unable to clear the restore status of %s/%s(%s): %w", bucket, object, versionID, err))
|
||||||
|
return restoreResetError{cause}
|
||||||
|
}
|
||||||
|
return cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// abortRestoreUpload removes the private upload of a failed attempt, or
|
||||||
|
// leaves it to stale-upload cleanup. The restore's own context may have
|
||||||
|
// ended, so it uses a fresh bounded one, and the upload lock as any abort.
|
||||||
|
// It matches the upload to its own attempt rather than to the version, which
|
||||||
|
// a newer attempt may own by now, and it never touches the version.
|
||||||
|
func (er erasureObjects) abortRestoreUpload(bucket, object, uploadID string, a *restoreAttempt) {
|
||||||
|
ctx, cancel := context.WithTimeout(GlobalContext, restoreCleanupTimeout)
|
||||||
|
defer cancel()
|
||||||
|
lk := er.NewNSLock(bucket, pathJoin(object, uploadID))
|
||||||
|
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
|
||||||
|
if err == nil {
|
||||||
|
defer lk.Unlock(lkctx)
|
||||||
|
_, err = er.abortMultipartUpload(lkctx.Context(), bucket, object, uploadID, ObjectOptions{
|
||||||
|
NoAuditLog: true,
|
||||||
|
Transition: TransitionOptions{attempt: a},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
storageLogIf(ctx, fmt.Errorf("Unable to abort the restore upload of %s/%s: %w", bucket, object, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreUploadActive reports whether the restore that owns a stale-aged
|
||||||
|
// upload still runs: its attempt owns the version's ongoing marker. A long
|
||||||
|
// download keeps its upload; one whose attempt ended loses it, and an
|
||||||
|
// ongoing marker alone keeps nothing.
|
||||||
|
func (er erasureObjects) restoreUploadActive(ctx context.Context, v restoreUploadView) bool {
|
||||||
|
gen, err := er.readRestoreGeneration(ctx, v.bucket, v.object, v.marker.VersionID)
|
||||||
|
if err != nil {
|
||||||
|
// An unreadable version may still be restoring; retry next round.
|
||||||
|
return !errors.Is(err, errFileVersionNotFound) && !errors.Is(err, errFileNotFound)
|
||||||
|
}
|
||||||
|
if gen.split {
|
||||||
|
// Drives disagree on the restore status: that the attempt ended
|
||||||
|
// is not established.
|
||||||
|
return gen.ongoing[v.marker.ID]
|
||||||
|
}
|
||||||
|
return v.marker.owns(gen.fi.ToObjectInfo(v.bucket, v.object, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreLock is the object lock that restores and their status updates
|
||||||
|
// share: with one pool the set's own lock, as single-pool writes use.
|
||||||
|
func (z *erasureServerPools) restoreLock(bucket, object string) RWLocker {
|
||||||
|
if z.SinglePool() {
|
||||||
|
return z.serverPools[0].getHashedSet(object).NewNSLock(bucket, object)
|
||||||
|
}
|
||||||
|
return z.NewNSLock(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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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; that also takes over a marker no running
|
||||||
|
// restore holds, as a running restore holds the object lock for its whole
|
||||||
|
// download. Only the copy that is to be restored is written, so no copy
|
||||||
|
// claims a restored copy it does not hold.
|
||||||
|
func (z *erasureServerPools) restoreRequest(ctx context.Context, bucket, object string, opts ObjectOptions, target ObjectInfo) (restoreRequestStatus, error) {
|
||||||
|
object = encodeDirObject(object)
|
||||||
|
versionID := restoreVersionID(target.VersionID)
|
||||||
|
lk := z.restoreLock(bucket, object)
|
||||||
|
// Wait only briefly first, whatever the lockless read before saw: a
|
||||||
|
// restore of this version holds the lock for its whole download. Then
|
||||||
|
// wait as usual only if the drives show no restore of it running.
|
||||||
|
lkctx, err := lk.GetLock(ctx, newDynamicTimeout(restoreTakeoverWait, restoreTakeoverWait))
|
||||||
|
if errors.As(err, &OperationTimedOut{}) && ctx.Err() == nil {
|
||||||
|
running, rerr := z.restoreRunning(ctx, bucket, object, versionID)
|
||||||
|
switch {
|
||||||
|
case rerr != nil:
|
||||||
|
// Whether it is restoring is unknown: answer now, not after
|
||||||
|
// the lock frees.
|
||||||
|
return restoreRequestStatus{}, rerr
|
||||||
|
case running:
|
||||||
|
return restoreRequestStatus{}, errRestoreInProgress
|
||||||
|
}
|
||||||
|
lkctx, err = lk.GetLock(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
|
||||||
|
}
|
||||||
|
return restoreRequestStatus{objInfo: oi, attempt: attempt}, 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.owns(gen.fi.ToObjectInfo(bucket, object, false)) {
|
||||||
|
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 = "1"
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+45
-27
@@ -375,8 +375,10 @@ func TestNativeRestorePOSTVersionIdentity(t *testing.T) {
|
|||||||
|
|
||||||
// A restore request is internal context, not an exception for normal S3
|
// A restore request is internal context, not an exception for normal S3
|
||||||
// multipart overwrites or for a version that has never completed transition.
|
// multipart overwrites or for a version that has never completed transition.
|
||||||
|
// Only the upload of a native restore attempt, whose marker names the very
|
||||||
|
// version being completed, may write that version back.
|
||||||
func TestNativeRestoreMultipartOverwriteBoundary(t *testing.T) {
|
func TestNativeRestoreMultipartOverwriteBoundary(t *testing.T) {
|
||||||
for _, mode := range []string{"matching-restore", "ordinary-complete", "not-tiered", "changed-time", "changed-size", "changed-etag"} {
|
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) {
|
t.Run(mode, func(t *testing.T) {
|
||||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||||
oi, body := nativeRestoreSeed(t, r, 0, false, true)
|
oi, body := nativeRestoreSeed(t, r, 0, false, true)
|
||||||
@@ -398,8 +400,9 @@ func TestNativeRestoreMultipartOverwriteBoundary(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
opts := putRestoreOpts(r.bucket, r.object, &RestoreObjectRequest{Days: 1}, current)
|
opts := putRestoreOpts(&RestoreObjectRequest{Days: 1}, current, r.opts(oi.VersionID))
|
||||||
opts.Transition.RestoreRequest = &RestoreObjectRequest{Days: 1}
|
opts.Transition.RestoreRequest = &RestoreObjectRequest{Days: 1}
|
||||||
|
opts.PreserveETag = current.ETag
|
||||||
if mode == "ordinary-complete" {
|
if mode == "ordinary-complete" {
|
||||||
opts.Transition.RestoreRequest = nil
|
opts.Transition.RestoreRequest = nil
|
||||||
}
|
}
|
||||||
@@ -408,20 +411,26 @@ func TestNativeRestoreMultipartOverwriteBoundary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Hold the ETag constant for the size case, so only size can reject it.
|
// Hold the ETag constant for the size case, so only size can reject it.
|
||||||
if mode == "changed-size" {
|
if mode == "changed-size" {
|
||||||
opts.UserDefined["etag"] = current.ETag
|
|
||||||
body = append(body, 'x')
|
body = append(body, 'x')
|
||||||
}
|
}
|
||||||
if mode == "changed-etag" {
|
if mode == "changed-etag" {
|
||||||
opts.UserDefined["etag"] = "different-etag"
|
opts.PreserveETag = "different-etag"
|
||||||
}
|
}
|
||||||
upload, err := pool.NewMultipartUpload(t.Context(), r.bucket, r.object, opts)
|
// 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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var complete []CompletePart
|
var complete []CompletePart
|
||||||
for i, data := range [][]byte{body[:5<<20], body[5<<20:]} {
|
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,
|
part, err := pool.PutObjectPart(t.Context(), r.bucket, r.object, upload.UploadID, i+1,
|
||||||
mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{})
|
mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{Transition: opts.Transition})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -430,6 +439,11 @@ func TestNativeRestoreMultipartOverwriteBoundary(t *testing.T) {
|
|||||||
if mode != "changed-size" && getCompleteMultipartMD5(complete) != current.ETag {
|
if mode != "changed-size" && getCompleteMultipartMD5(complete) != current.ETag {
|
||||||
t.Fatal("fixture multipart ETag does not match original")
|
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)
|
before := nativeRestoreSnapshot(t, r)
|
||||||
_, err = pool.CompleteMultipartUpload(t.Context(), r.bucket, r.object, upload.UploadID, complete, opts)
|
_, err = pool.CompleteMultipartUpload(t.Context(), r.bucket, r.object, upload.UploadID, complete, opts)
|
||||||
if mode == "matching-restore" {
|
if mode == "matching-restore" {
|
||||||
@@ -440,9 +454,16 @@ func TestNativeRestoreMultipartOverwriteBoundary(t *testing.T) {
|
|||||||
if err != nil || !bytes.Equal(got, body) {
|
if err != nil || !bytes.Equal(got, body) {
|
||||||
t.Fatalf("matching restore contents: %v", err)
|
t.Fatalf("matching restore contents: %v", err)
|
||||||
}
|
}
|
||||||
|
if after := r.fileInfo(oi.VersionID); after.Metadata[restoreUploadKey] != "" || after.Metadata[restoreAttemptKey] != "" {
|
||||||
|
t.Error("restore committed its private upload or attempt marker")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !errors.Is(err, errAuthentication) {
|
if mode == "other-attempt" {
|
||||||
|
if !errors.As(err, &InvalidUploadID{}) {
|
||||||
|
t.Fatalf("another attempt completed a private upload: %v", err)
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, errAuthentication) {
|
||||||
t.Fatalf("existing version overwrite: %v", err)
|
t.Fatalf("existing version overwrite: %v", err)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(before, nativeRestoreSnapshot(t, r)) {
|
if !reflect.DeepEqual(before, nativeRestoreSnapshot(t, r)) {
|
||||||
@@ -721,18 +742,10 @@ func TestNativeRestoreConcurrentCompletion(t *testing.T) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
t.Fatal("restore never started")
|
t.Fatal("restore never started")
|
||||||
}
|
}
|
||||||
// A client's exact-prefix listing can discover the internal upload. It can
|
// A client that learns the internal upload ID, which listings no
|
||||||
// try to complete the first part while native restore owns the object.
|
// longer show, can try to complete the first part while native
|
||||||
uploads, err := r.z.ListMultipartUploads(t.Context(), r.bucket, r.object, "", "", "", 10)
|
// restore owns the object.
|
||||||
if err != nil || len(uploads.Uploads) != 1 {
|
id, part := restoreIsolationUpload(t, r, 0)
|
||||||
t.Fatalf("restore upload discovery: %+v, %v", uploads, err)
|
|
||||||
}
|
|
||||||
id := uploads.Uploads[0].UploadID
|
|
||||||
parts, err := r.z.ListObjectParts(t.Context(), r.bucket, r.object, id, 0, 10, ObjectOptions{})
|
|
||||||
if err != nil || len(parts.Parts) != 1 {
|
|
||||||
t.Fatalf("restore must have published its first part: %+v, %v", parts, err)
|
|
||||||
}
|
|
||||||
part := parts.Parts[0]
|
|
||||||
completed := make(chan struct{})
|
completed := make(chan struct{})
|
||||||
var completeErr error
|
var completeErr error
|
||||||
go func() {
|
go func() {
|
||||||
@@ -942,17 +955,22 @@ func TestNativeRestoreCompletionReadSafety(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nativeRestoreStatusHook runs afterRead once POST has read the version and
|
||||||
|
// before POST records its status on it.
|
||||||
type nativeRestoreStatusHook struct {
|
type nativeRestoreStatusHook struct {
|
||||||
ObjectLayer
|
ObjectLayer
|
||||||
beforeCopy func()
|
afterRead func()
|
||||||
copyCalled bool
|
readCalled bool
|
||||||
backgroundCalled atomic.Bool
|
backgroundCalled atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *nativeRestoreStatusHook) CopyObject(ctx context.Context, srcBucket, srcObject, dstBucket, dstObject string, info ObjectInfo, srcOpts, dstOpts ObjectOptions) (ObjectInfo, error) {
|
func (h *nativeRestoreStatusHook) GetObjectInfo(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
|
||||||
h.copyCalled = true
|
oi, err := h.ObjectLayer.GetObjectInfo(ctx, bucket, object, opts)
|
||||||
h.beforeCopy()
|
if err == nil && !h.readCalled {
|
||||||
return h.ObjectLayer.CopyObject(ctx, srcBucket, srcObject, dstBucket, dstObject, info, srcOpts, dstOpts)
|
h.readCalled = true
|
||||||
|
h.afterRead()
|
||||||
|
}
|
||||||
|
return oi, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *nativeRestoreStatusHook) RestoreTransitionedObject(context.Context, string, string, ObjectOptions) error {
|
func (h *nativeRestoreStatusHook) RestoreTransitionedObject(context.Context, string, string, ObjectOptions) error {
|
||||||
@@ -985,7 +1003,7 @@ func TestNativeRestorePOSTReadSafety(t *testing.T) {
|
|||||||
}
|
}
|
||||||
before = restoreBoundarySnapshot(t, r)
|
before = restoreBoundarySnapshot(t, r)
|
||||||
}
|
}
|
||||||
hook := &nativeRestoreStatusHook{ObjectLayer: r.z, beforeCopy: mutate}
|
hook := &nativeRestoreStatusHook{ObjectLayer: r.z, afterRead: mutate}
|
||||||
if stage == "initial" {
|
if stage == "initial" {
|
||||||
mutate()
|
mutate()
|
||||||
}
|
}
|
||||||
@@ -1004,7 +1022,7 @@ func TestNativeRestorePOSTReadSafety(t *testing.T) {
|
|||||||
if rec.Code < 400 {
|
if rec.Code < 400 {
|
||||||
t.Fatalf("POST acknowledged failed read: %d %s", rec.Code, rec.Body.String())
|
t.Fatalf("POST acknowledged failed read: %d %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
if hook.copyCalled != (stage == "status-copy") || hook.backgroundCalled.Load() {
|
if hook.readCalled != (stage == "status-copy") || hook.backgroundCalled.Load() {
|
||||||
t.Fatal("request followed the wrong failure path")
|
t.Fatal("request followed the wrong failure path")
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(before, restoreBoundarySnapshot(t, r)) {
|
if !reflect.DeepEqual(before, restoreBoundarySnapshot(t, r)) {
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ const (
|
|||||||
HandlerRenamePart
|
HandlerRenamePart
|
||||||
HandlerClearUploadID
|
HandlerClearUploadID
|
||||||
HandlerCheckParts3
|
HandlerCheckParts3
|
||||||
|
HandlerRestoreCapability
|
||||||
|
|
||||||
// Add more above here ^^^
|
// Add more above here ^^^
|
||||||
// If all handlers are used, the type of Handler can be changed.
|
// If all handlers are used, the type of Handler can be changed.
|
||||||
@@ -200,6 +201,7 @@ var handlerPrefixes = [handlerLast]string{
|
|||||||
HandlerCheckParts3: storagePrefix,
|
HandlerCheckParts3: storagePrefix,
|
||||||
HandlerRenamePart: storagePrefix,
|
HandlerRenamePart: storagePrefix,
|
||||||
HandlerClearUploadID: peerPrefix,
|
HandlerClearUploadID: peerPrefix,
|
||||||
|
HandlerRestoreCapability: peerPrefix,
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -86,14 +86,15 @@ func _() {
|
|||||||
_ = x[HandlerRenamePart-75]
|
_ = x[HandlerRenamePart-75]
|
||||||
_ = x[HandlerClearUploadID-76]
|
_ = x[HandlerClearUploadID-76]
|
||||||
_ = x[HandlerCheckParts3-77]
|
_ = x[HandlerCheckParts3-77]
|
||||||
_ = x[handlerTest-78]
|
_ = x[HandlerRestoreCapability-78]
|
||||||
_ = x[handlerTest2-79]
|
_ = x[handlerTest-79]
|
||||||
_ = x[handlerLast-80]
|
_ = 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 {
|
func (i HandlerID) String() string {
|
||||||
idx := int(i) - 0
|
idx := int(i) - 0
|
||||||
|
|||||||
Reference in New Issue
Block a user