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

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

A restore now runs in two phases:

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

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

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

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

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

901 lines
31 KiB
Go

// Copyright (c) 2026 Feng Ruohang
//
// This file is part of Silo Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package cmd
import (
"context"
"errors"
"fmt"
"io"
"maps"
"net/http"
"slices"
"strconv"
"sync"
"time"
"github.com/google/uuid"
"github.com/minio/minio/internal/bucket/lifecycle"
xhttp "github.com/minio/minio/internal/http"
"github.com/pgsty/silo-pkg/v3/sync/errgroup"
)
// A native restore runs in two phases. It downloads the tier copy of a
// version and erasure-codes it into the tmp volume of the version's set
// without the object lock O, then takes O only to check the version again
// and publish the staged copy drive by drive, as heal does. Hot versions of
// the key stay readable and writable during the download.
//
// The restore lock R of the version tells whether a restore of it runs: a
// POST takes R before O, and the worker that rehydrates the version holds R
// for its whole run. Nothing else takes R, so nothing holds O while it waits
// for R. R is a liveness hint, not an exclusion: a lost refresh can let two
// workers overlap. Only the checks under O decide what is written.
// restoreOwnerPrefix names the restore locks under minioMetaBucket.
const restoreOwnerPrefix = "restore"
// restorePublicationDelays are the in-memory retries of a convergence after
// the one under the commit's own lock hold, see recoverRestorePublication.
var restorePublicationDelays = []time.Duration{100 * time.Millisecond, time.Second, 5 * time.Second, 30 * time.Second, 2 * time.Minute}
var (
// errRestoreMoved: the version moved to another pool after the restore
// staged its copy in this one.
errRestoreMoved = errors.New("restored version moved to another pool")
// errRestoreNotConverged: a publication that reached some drives but not
// a write quorum could not be brought back to the unrestored version yet.
errRestoreNotConverged = errors.New("restore publication did not converge")
)
// restoreOwner is the restore lock R of one version, held by a POST that
// registers an attempt and then by the worker that runs it. ctx ends when the
// lock is released or when its holder learns that it lost it.
type restoreOwner struct {
lk RWLocker
lkctx LockContext
ctx context.Context
cancel context.CancelFunc
once sync.Once
}
// release unlocks R once. It is a no-op on a nil owner.
func (o *restoreOwner) release() {
if o == nil {
return
}
o.once.Do(func() {
o.cancel()
o.lk.Unlock(o.lkctx)
})
}
// restoreOwnerResource is the name of the restore lock of a version. Bucket
// names hold no slash, object names no ".." element, and the version ID has
// a fixed form, so the name is unambiguous.
func restoreOwnerResource(bucket, object, versionID string) string {
return pathJoin(restoreOwnerPrefix, bucket, object, restoreVersionID(versionID))
}
// takeRestoreOwner takes the restore lock of a version. With a nil timeout it
// waits for as long as ctx lives, as a direct caller does; otherwise it gives
// up after timeout. ctx must outlive the restore that the lock is held for.
func (z *erasureServerPools) takeRestoreOwner(ctx context.Context, bucket, object, versionID string, timeout *dynamicTimeout) (*restoreOwner, error) {
for {
// A new instance for each try: a distributed unlock retries in the
// background and could release a later try of the same instance.
lk := z.NewNSLock(minioMetaBucket, restoreOwnerResource(bucket, object, versionID))
wait := timeout
if wait == nil {
wait = globalRestoreLockTimeout
}
lkctx, err := lk.GetLock(ctx, wait)
if err != nil {
if timeout == nil && errors.As(err, &OperationTimedOut{}) && ctx.Err() == nil {
continue
}
return nil, err
}
octx, cancel := context.WithCancel(lkctx.Context())
return &restoreOwner{lk: lk, lkctx: lkctx, ctx: octx, cancel: cancel}, nil
}
}
// restoreStage is the copy of one version that a native restore encoded into
// the tmp volume of the version's set, under tmpID/dataDir, before it takes
// the object lock.
type restoreStage struct {
versionID string
// fi is the version as the download read it: the commit only publishes
// the copy for a version that still has this content, see
// restoreSameContent.
fi FileInfo
tmpID string
dataDir string
// staged tells, in the order of the version's erasure distribution,
// which drives hold every part of the copy.
staged []bool
expiry time.Time
// request is set for an attempt that POST registered: days and date
// are what it recorded. A direct caller keeps the version's own.
request bool
days int
date string
}
// restoreSameContent reports whether two reads of a version name the same
// encoded bytes, laid out the same way: the version, its tier copy, its parts
// and its erasure layout. The tier copy is never rewritten under the same
// name and remote version, so these decide the bytes. Every other field, such
// as tags, Object Lock, replication state or a rewrapped object key, is taken
// from the version as the commit reads it.
func restoreSameContent(a, b FileInfo) bool {
if a.VersionID != b.VersionID || a.Deleted || b.Deleted || !a.ModTime.Equal(b.ModTime) ||
a.Metadata["etag"] != b.Metadata["etag"] || a.Size != b.Size ||
a.TransitionStatus != lifecycle.TransitionComplete || b.TransitionStatus != lifecycle.TransitionComplete ||
a.TransitionTier != b.TransitionTier || a.TransitionedObjName != b.TransitionedObjName ||
a.TransitionVersionID != b.TransitionVersionID {
return false
}
if len(a.Parts) != len(b.Parts) {
return false
}
for i := range a.Parts {
if a.Parts[i].Number != b.Parts[i].Number || a.Parts[i].Size != b.Parts[i].Size {
return false
}
}
ea, eb := a.Erasure, b.Erasure
return ea.Algorithm == eb.Algorithm && ea.DataBlocks == eb.DataBlocks && ea.ParityBlocks == eb.ParityBlocks &&
ea.BlockSize == eb.BlockSize && slices.Equal(ea.Distribution, eb.Distribution)
}
// restoreInlineKey marks a version whose data lives in its xl.meta.
const restoreInlineKey = ReservedMetadataPrefixLower + "inline-data"
// stageRestore downloads the tier copy of the version opts address and
// erasure-codes it, part by part, into the tmp volume of the drives of this
// set, as heal writes the parts it rebuilds. It holds no object lock. A drive
// that fails to write any part, or that misses a part afterwards, for
// example because its tmp volume was moved away when its server restarted,
// takes no further part and no part in the commit.
func (er erasureObjects) stageRestore(ctx context.Context, bucket, object string, opts ObjectOptions) (*restoreStage, error) {
rreq := opts.Transition.RestoreRequest
if rreq == nil {
return nil, errInvalidArgument
}
gen, err := er.readRestoreGeneration(ctx, bucket, object, opts.VersionID)
if err != nil {
return nil, toObjectErr(err, bucket, object, opts.VersionID)
}
fi := gen.fi
if fi.Deleted {
return nil, toObjectErr(errMethodNotAllowed, bucket, object)
}
if a := opts.Transition.attempt; a != nil && !a.ownsGeneration(gen, bucket, object) {
// A later POST took the version over, or it was replaced.
return nil, errRestoreSuperseded
}
disks := er.getDisks()
var total int64
for _, p := range fi.Parts {
if p.Size < 0 {
return nil, toObjectErr(errFileCorrupt, bucket, object)
}
total += p.Size
}
if fi.TransitionStatus != lifecycle.TransitionComplete || len(fi.Parts) == 0 || total != fi.Size ||
len(fi.Erasure.Distribution) != len(disks) {
return nil, InvalidObjectState{Bucket: bucket, Object: object}
}
erasure, err := NewErasure(ctx, fi.Erasure.DataBlocks, fi.Erasure.ParityBlocks, fi.Erasure.BlockSize)
if err != nil {
return nil, toObjectErr(err, bucket, object)
}
writeQuorum := fi.WriteQuorum(er.defaultWQuorum())
st := &restoreStage{
versionID: restoreVersionID(fi.VersionID),
fi: fi,
tmpID: mustGetUUID(),
dataDir: mustGetUUID(),
expiry: lifecycle.ExpectedExpiryTime(time.Now().UTC(), rreq.Days),
}
if opts.Transition.restoreDate != "" {
st.request, st.days, st.date = true, rreq.Days, opts.Transition.restoreDate
}
staged := false
defer func() {
if !staged {
er.deleteAll(context.Background(), minioMetaTmpBucket, st.tmpID)
}
}()
// The tier copy is the encoded stream of the parts, encrypted or
// compressed as stored: the parts get exactly these bytes back.
oi := fi.ToObjectInfo(bucket, object, opts.Versioned || opts.VersionSuspended)
gr, err := getTransitionedObjectReader(ctx, bucket, object, nil, http.Header{}, oi, opts)
if err != nil {
return nil, toObjectErr(err, bucket, object)
}
defer gr.Close()
var buffer []byte
pool := globalBytePoolCap.Load()
if int64(pool.Width()) < fi.Erasure.BlockSize {
buffer = make([]byte, fi.Erasure.BlockSize)
} else {
buffer = pool.Get()
defer pool.Put(buffer)
}
buffer = buffer[:fi.Erasure.BlockSize]
// Slots follow the erasure distribution: the drive in slot s gets shard
// s+1 of every part, and the commit gives it erasure index s+1.
slots := shuffleDisks(disks, fi.Erasure.Distribution)
for _, part := range fi.Parts {
partPath := pathJoin(st.tmpID, st.dataDir, fmt.Sprintf("part.%d", part.Number))
writers := make([]io.Writer, len(slots))
for s, disk := range slots {
if disk != nil {
writers[s] = newBitrotWriter(disk, bucket, minioMetaTmpBucket, partPath,
erasure.ShardFileSize(part.Size), DefaultBitrotAlgorithm, erasure.ShardSize())
}
}
n, err := erasure.Encode(ctx, io.LimitReader(gr, part.Size), writers, buffer, writeQuorum)
closeErrs := closeBitrotWriters(writers)
if err != nil {
return nil, toObjectErr(err, bucket, object)
}
if n != part.Size {
return nil, IncompleteBody{Bucket: bucket, Object: object}
}
for s := range slots {
if writers[s] == nil || closeErrs[s] != nil {
slots[s] = nil
}
}
if countOnlineDisks(slots) < writeQuorum {
return nil, toObjectErr(errErasureWriteQuorum, bucket, object)
}
}
// The tier copy must end with the last part.
var extra [1]byte
if n, _ := io.ReadFull(gr, extra[:]); n != 0 {
return nil, InvalidObjectState{Bucket: bucket, Object: object}
}
// A tmp volume that was moved away and recreated by a later part lacks the
// earlier parts: find them before anything is published. CheckParts sees a
// part that is present and not shorter than its shards; a staged drive
// that wrote every part without an error has exactly that.
check := fi
check.DataDir = st.dataDir
g := errgroup.WithNErrs(len(slots))
for s, disk := range slots {
if disk == nil {
continue
}
g.Go(func() error {
resp, err := disk.CheckParts(ctx, minioMetaTmpBucket, st.tmpID, check)
if err != nil {
return err
}
for _, result := range resp.Results {
if result != checkPartSuccess {
return errFileCorrupt
}
}
return nil
}, s)
}
st.staged = make([]bool, len(slots))
count := 0
for s, err := range g.Wait() {
if slots[s] != nil && err == nil {
st.staged[s] = true
count++
}
}
if count < writeQuorum {
return nil, toObjectErr(errErasureWriteQuorum, bucket, object)
}
staged = true
return st, nil
}
// fileInfoRevisions are the revisions the entry that writing fi creates can
// have, as readRestoreGeneration and the convergence identify the entries
// they read. The header flag that tells whether a transitioned version uses
// its data directory follows whether its restored copy has expired at the
// moment a drive writes the entry, so a restore whose copy expires while it
// publishes can find either value: both are this entry. Every other field of
// the header and the full metadata digest still have to match.
func fileInfoRevisions(fi FileInfo) ([]revisionKey, error) {
var x xlMetaV2
if err := x.AddVersion(fi); err != nil {
return nil, err
}
buf, err := x.AppendTo(nil)
if err != nil {
return nil, err
}
var y xlMetaV2
if err := y.Load(buf); err != nil {
return nil, err
}
if len(y.versions) != 1 {
return nil, errFileCorrupt
}
ver := y.versions[0]
other := ver
other.header.Flags ^= xlFlagUsesDataDir
return []revisionKey{versionRevision(ver, y.metaV), versionRevision(other, y.metaV)}, nil
}
// restorePublication remembers, for a commit that published the staged copy
// on some drives but not on a write quorum, what each drive held before the
// commit (Sᵢ), what the commit wrote there (Pᵢ) and what a convergence writes
// there (Vᵢ), in the order of the version's erasure distribution. Only the
// worker that published knows them; a convergence touches a version only
// while every drive holds one of them.
type restorePublication struct {
bucket, object string
versionID string
uv [16]byte
dataDir string
attempt *restoreAttempt
quorum int
dist []int
before []*revisionKey
anyBefore map[revisionKey]bool
published map[revisionKey]bool
conv []FileInfo
convKeys map[revisionKey]bool
}
// restorePublished is the entry a commit publishes on the drive in slot s:
// the version as the commit read it on that drive, with the staged data
// directory and a completed restore. Restore fields are written alike on
// every drive: the request POST recorded, or for a direct caller the one
// the authoritative generation auth holds.
func restorePublished(base, auth FileInfo, st *restoreStage, slot int) FileInfo {
fi := base
fi.Metadata = maps.Clone(base.Metadata)
if fi.Metadata == nil {
fi.Metadata = make(map[string]string)
}
for _, k := range restoreFieldKeys {
delete(fi.Metadata, k)
}
// An earlier restore may have inlined the data, and expiring that copy
// leaves the inline marker behind; this copy lives in its data directory.
delete(fi.Metadata, restoreInlineKey)
fi.Metadata[xhttp.AmzRestore] = completedRestoreObj(st.expiry).String()
if st.request {
fi.Metadata[xhttp.AmzRestoreExpiryDays] = strconv.Itoa(st.days)
fi.Metadata[xhttp.AmzRestoreRequestDate] = st.date
} else {
for _, k := range []string{xhttp.AmzRestoreExpiryDays, xhttp.AmzRestoreRequestDate} {
if v, ok := auth.Metadata[k]; ok {
fi.Metadata[k] = v
}
}
}
fi.DataDir = st.dataDir
fi.Data = nil
fi.Parts = slices.Clone(base.Parts)
fi.Erasure.Index = slot + 1
// The staged parts use the default streaming bitrot algorithm.
fi.Erasure.Checksums = nil
return fi
}
// restoreUnrestored is the entry a convergence writes on the drive in slot s:
// the version as the commit read it, with no restore fields, so that its tier
// copy serves reads again and a later POST can restore it.
func restoreUnrestored(base FileInfo, slot int) FileInfo {
fi := base
fi.Metadata = maps.Clone(base.Metadata)
for _, k := range restoreFieldKeys {
delete(fi.Metadata, k)
}
delete(fi.Metadata, restoreInlineKey)
fi.Data = nil
fi.Erasure.Index = slot + 1
return fi
}
// commitRestore publishes a staged copy while the caller holds the object
// lock. It reads the version again, checks that the attempt still owns it and
// that it still has the content the copy was staged for, then publishes the
// copy on every staged drive that holds the version, with RenameData on each
// drive, as heal does: never through erasure renameData, whose undo of a
// replaced version could delete the new data and keep metadata that points
// at it.
//
// It returns nil when the copy reached a write quorum. Otherwise it returns
// an error, and a publication when some drive may already have the copy; the
// caller must then converge, see convergeRestore.
func (er erasureObjects) commitRestore(ctx context.Context, bucket, object string, st *restoreStage, opts ObjectOptions, placed func(context.Context) error) (*restorePublication, error) {
if placed != nil {
if err := placed(ctx); err != nil {
return nil, err
}
}
gen, err := er.readRestoreGeneration(ctx, bucket, object, st.versionID)
if err != nil {
return nil, toObjectErr(err, bucket, object, st.versionID)
}
if gen.fi.Deleted {
return nil, toObjectErr(errMethodNotAllowed, bucket, object)
}
a := opts.Transition.attempt
if a != nil && !a.ownsGeneration(gen, bucket, object) {
return nil, errRestoreSuperseded
}
if !restoreSameContent(st.fi, gen.fi) {
// Replaced, or its tier copy or layout changed since the download.
return nil, InvalidObjectState{Bucket: bucket, Object: object}
}
disks := er.getDisks()
dist := gen.fi.Erasure.Distribution
if len(dist) != len(disks) || len(gen.revs) != len(disks) {
return nil, InvalidObjectState{Bucket: bucket, Object: object}
}
n := len(disks)
writeQuorum := gen.fi.WriteQuorum(er.defaultWQuorum())
pub := &restorePublication{
bucket: bucket, object: object, versionID: st.versionID, dataDir: st.dataDir,
attempt: a, quorum: writeQuorum, dist: slices.Clone(dist),
before: make([]*revisionKey, n),
anyBefore: make(map[revisionKey]bool),
published: make(map[revisionKey]bool),
conv: make([]FileInfo, n),
convKeys: make(map[revisionKey]bool),
}
if st.versionID != nullVersionID {
id, err := uuid.Parse(st.versionID)
if err != nil {
return nil, toObjectErr(errFileVersionNotFound, bucket, object, st.versionID)
}
pub.uv = id
}
targets := make([]StorageAPI, n)
files := make([]FileInfo, n)
for i := range disks {
s := dist[i] - 1
if s < 0 || s >= n {
return nil, InvalidObjectState{Bucket: bucket, Object: object}
}
if gen.revs[i] != nil {
pub.before[s] = gen.revs[i]
pub.anyBefore[*gen.revs[i]] = true
}
v := restoreUnrestored(gen.fi, s)
keys, err := fileInfoRevisions(v)
if err != nil {
return nil, toObjectErr(err, bucket, object)
}
pub.conv[s] = v
// The unrestored entry has no restore header: its flag never varies.
pub.convKeys[keys[0]] = true
if gen.disks[i] == nil || !st.staged[s] {
continue
}
fi := restorePublished(gen.drives[i], gen.fi, st, s)
if keys, err = fileInfoRevisions(fi); err != nil {
return nil, toObjectErr(err, bucket, object)
}
for _, key := range keys {
pub.published[key] = true
}
targets[s], files[s] = gen.disks[i], fi
}
if countOnlineDisks(targets) < writeQuorum {
// Nothing is published.
return nil, toObjectErr(errErasureWriteQuorum, bucket, object)
}
errs := make([]error, n)
olds := make([]string, n)
var wg sync.WaitGroup
for s, disk := range targets {
if disk == nil {
errs[s] = errDiskNotFound
continue
}
wg.Add(1)
go func() {
defer wg.Done()
resp, err := disk.RenameData(ctx, minioMetaTmpBucket, st.tmpID, files[s], bucket, object, RenameOptions{})
olds[s], errs[s] = resp.OldDataDir, err
}()
}
wg.Wait()
if countErrs(errs, nil) < writeQuorum {
return pub, toObjectErr(errErasureWriteQuorum, bucket, object)
}
// The data directory of the replaced entry, which held a former restored
// copy, goes only when every drive that published reports the same one:
// RenameData reports none where another version shares it, and then the
// directory stays everywhere. It goes only from the drives that reported
// it.
old := ""
cleanup := make([]StorageAPI, n)
for s := range targets {
if errs[s] != nil {
continue
}
if olds[s] == "" || (old != "" && olds[s] != old) {
old = ""
break
}
old = olds[s]
cleanup[s] = targets[s]
}
if old != "" && old != st.dataDir {
er.commitRenameDataDir(ctx, bucket, object, old, cleanup, 1)
}
if countErrs(errs, nil) < n {
er.addPartial(bucket, object, gen.fi.VersionID)
}
return nil, nil
}
// restoreConvergence is the outcome of one convergence pass.
type restoreConvergence int
const (
// restoreIncomplete: the drives are not settled yet; try again.
restoreIncomplete restoreConvergence = iota
// restoreConverged: every drive that holds the version holds it
// unrestored again.
restoreConverged
// restoreCommitted: the copy reached a write quorum after all, such as
// when a drive applied a publication whose answer was lost.
restoreCommitted
// restoreStopped: some drive holds an entry that neither this attempt
// nor the version before it wrote: someone else changed the version.
restoreStopped
)
// classify reads the entry of the version on every drive and sorts it into
// the slot of its drive: held tells which slots hold an entry this
// publication knows, published and converged count the drives that hold
// what the commit and the convergence write. A drive that cannot be read is
// left out. An entry the publication does not know stops it.
func (pub *restorePublication) classify(ctx context.Context, disks []StorageAPI) (held []bool, published, converged int, err error) {
raws, errs := readAllRawFileInfo(ctx, disks, pub.bucket, pub.object, false)
held = make([]bool, len(disks))
for i := range disks {
if errs[i] != nil || i >= len(pub.dist) {
continue
}
var xl xlMetaV2
if xl.LoadOrConvert(raws[i].Buf) != nil {
continue
}
s := pub.dist[i] - 1
for _, ver := range xl.versions {
if ver.header.VersionID != pub.uv || ver.header.FreeVersion() {
continue
}
key := versionRevision(ver, xl.metaV)
switch {
case pub.published[key]:
published++
case pub.convKeys[key]:
converged++
case pub.before[s] != nil && *pub.before[s] == key:
case pub.before[s] == nil && pub.anyBefore[key]:
default:
return nil, 0, 0, errRestoreSuperseded
}
held[s] = true
break
}
}
return held, published, converged, nil
}
// convergeRestore settles a publication while the caller holds the object
// lock. When every drive still holds what the version held before the commit,
// what the commit wrote, or what an earlier pass wrote, it either finds the
// copy on a write quorum, or writes the unrestored version to every drive
// that holds the version. It deletes no directory: an unreferenced staged
// directory is left to abandoned-data cleanup. Any other entry stops it, so
// that it never overwrites what someone else wrote between two passes.
func (er erasureObjects) convergeRestore(ctx context.Context, pub *restorePublication) (restoreConvergence, error) {
disks := er.getDisks()
held, published, _, err := pub.classify(ctx, disks)
if err != nil {
return restoreStopped, err
}
if published >= pub.quorum {
er.addPartial(pub.bucket, pub.object, pub.versionID)
return restoreCommitted, nil
}
slots := shuffleDisks(disks, pub.dist)
files := make([]FileInfo, len(slots))
for s := range slots {
if !held[s] {
slots[s] = nil
continue
}
files[s] = pub.conv[s]
}
_, werr := writeUniqueFileInfo(ctx, slots, "", pub.bucket, pub.object, files, pub.quorum)
held, published, converged, err := pub.classify(ctx, disks)
if err != nil {
return restoreStopped, err
}
holding := 0
for _, h := range held {
if h {
holding++
}
}
if published == 0 && converged == holding && converged >= pub.quorum {
return restoreConverged, nil
}
if werr == nil {
werr = errErasureWriteQuorum
}
return restoreIncomplete, werr
}
// recoverRestorePublication retries a convergence in memory, for a bounded
// time, each pass under the object lock taken afresh. A restart loses the
// retries; the version then stays as the drives hold it, see the design's
// excluded residuals.
func (er erasureObjects) recoverRestorePublication(pub *restorePublication, lock func(context.Context) (LockContext, func(), error)) {
go func() {
err := errRestoreNotConverged
for _, delay := range restorePublicationDelays {
select {
case <-GlobalContext.Done():
return
case <-time.After(delay):
}
ctx, cancel := context.WithTimeout(GlobalContext, restoreCleanupTimeout)
lkctx, unlock, lerr := lock(ctx)
if lerr != nil {
cancel()
err = lerr
continue
}
var res restoreConvergence
res, err = er.convergeRestore(lkctx.Context(), pub)
unlock()
cancel()
if res == restoreConverged || res == restoreCommitted {
return
}
if res == restoreStopped {
break
}
}
er.logRestoreNotConverged(pub, err)
}()
}
func (er erasureObjects) logRestoreNotConverged(pub *restorePublication, err error) {
attempt := ""
if pub.attempt != nil {
attempt = pub.attempt.ID
}
storageLogIf(GlobalContext, fmt.Errorf("%w: %s/%s(%s), attempt %q, data dir %s: %v",
errRestoreNotConverged, pub.bucket, pub.object, pub.versionID, attempt, pub.dataDir, err))
}
// restoreHooks tie a restore of one set to its caller.
type restoreHooks struct {
// lock takes the object lock O of the version, in the lock domain of the
// object's writes, and returns its release.
lock func(ctx context.Context) (LockContext, func(), error)
// placed tells, under O, whether the version still lives in this set.
placed func(ctx context.Context) error
// clear clears the attempt's ongoing marker after a failure that
// published nothing, taking O itself, and retries in the background.
clear func()
}
// restoreVersion rehydrates the transitioned version opts address in this
// set: it stages the copy without the object lock, then commits it under the
// lock. A failure that published nothing clears the attempt's marker. A
// publication that reached some drives but not a write quorum converges
// under the same lock hold, and in bounded background retries after it.
func (er erasureObjects) restoreVersion(ctx context.Context, bucket, object string, opts ObjectOptions, h restoreHooks) error {
st, err := er.stageRestore(ctx, bucket, object, opts)
if err != nil {
return restoreAbandon(err, h)
}
defer er.deleteAll(context.Background(), minioMetaTmpBucket, st.tmpID)
lkctx, unlock, err := h.lock(ctx)
if err != nil {
return restoreAbandon(err, h)
}
pub, err := er.commitRestore(lkctx.Context(), bucket, object, st, opts, h.placed)
if err == nil {
unlock()
return nil
}
if pub == nil {
unlock()
return restoreAbandon(err, h)
}
res, cerr := er.convergeRestore(lkctx.Context(), pub)
unlock()
switch res {
case restoreCommitted:
return nil
case restoreConverged:
return err
case restoreStopped:
er.logRestoreNotConverged(pub, cerr)
return err
default:
er.recoverRestorePublication(pub, h.lock)
return err
}
}
// restoreAbandon clears the marker of an attempt that published nothing,
// unless another attempt owns the version. A not-found error may come from the
// tier rather than from the version, and the clear checks the marker's owner
// and finds nothing to do for a version that is gone, so it runs then too.
func restoreAbandon(err error, h restoreHooks) error {
if errors.Is(err, errRestoreSuperseded) {
return err
}
if h.clear != nil {
h.clear()
}
return err
}
// RestoreTransitionedObject - restore transitioned object content locally on
// this cluster. This is similar to PostObjectRestore from AWS GLACIER storage
// class. When PostObjectRestore API is called, a temporary copy of the object
// is restored locally to the bucket on source cluster until the restore
// expiry date. The copy that was transitioned continues to reside in the
// transitioned tier.
//
// The set entry serves direct callers of one set. It takes the object lock in
// the set's own domain and no restore lock; native restores of a cluster go
// through erasureServerPools.
func (er erasureObjects) RestoreTransitionedObject(ctx context.Context, bucket, object string, opts ObjectOptions) error {
lock := func(ctx context.Context) (LockContext, func(), error) {
lk := er.NewNSLock(bucket, object)
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
if err != nil {
return LockContext{}, nil, err
}
return lkctx, func() { lk.Unlock(lkctx) }, nil
}
clearMarker := func() {
ctx, cancel := context.WithTimeout(GlobalContext, restoreCleanupTimeout)
defer cancel()
lkctx, unlock, err := lock(ctx)
if err != nil {
storageLogIf(ctx, fmt.Errorf("Unable to clear the restore status of %s/%s(%s): %w", bucket, object, opts.VersionID, err))
return
}
defer unlock()
versionID := opts.VersionID
if a := opts.Transition.attempt; a != nil {
versionID = a.VersionID
}
if err := er.clearRestoreStatus(lkctx.Context(), bucket, object, versionID, opts.Transition.attempt); err != nil &&
!isErrObjectNotFound(err) && !isErrVersionNotFound(err) && !isErrMethodNotAllowed(err) {
storageLogIf(ctx, fmt.Errorf("Unable to clear the restore status of %s/%s(%s): %w", bucket, object, versionID, err))
}
}
return er.restoreVersion(ctx, bucket, object, opts, restoreHooks{lock: lock, clear: clearMarker})
}
// RestoreTransitionedObject - restore transitioned object content locally on
// this cluster, see erasureObjects.RestoreTransitionedObject.
//
// A POST hands over the restore lock R of the version it registered in
// opts.Transition.owner; a direct caller takes R here, waiting while ctx
// lives. The restore runs while R is held, and releases it when it returns.
func (z *erasureServerPools) RestoreTransitionedObject(ctx context.Context, bucket, object string, opts ObjectOptions) error {
object = encodeDirObject(object)
owner := opts.Transition.owner
opts.Transition.owner = nil
if owner != nil && owner.ctx.Err() != nil {
// The lock POST handed over ended before the restore ran, such as
// when its holder lost it while the restore was queued: take it again
// as a direct caller does. The attempt still has to own the version.
owner = nil
}
defer func() { owner.release() }()
// Avoid restoring object from a pool being decommissioned.
opts.SkipDecommissioned = true
opts.NoLock = true
clearMarker := func() {
if err := z.resetRestoreStatus(bucket, object, opts); err != nil {
z.recoverRestoreStatus(bucket, object, opts)
}
}
idx, err := z.restorePoolIdx(ctx, bucket, object, opts)
if err != nil {
var reset restoreResetError
if errors.As(err, &reset) {
clearMarker()
return reset.error
}
if isErrObjectNotFound(err) && opts.VersionID != "" {
err = VersionNotFound{Bucket: bucket, Object: decodeDirObject(object), VersionID: opts.VersionID}
}
return err
}
// A server may have been replaced by an older binary while the restore
// was queued: such a server knows neither the restore lock nor the
// staged commit.
if err := nativeRestoreAvailable(ctx); err != nil {
clearMarker()
return err
}
er := z.serverPools[idx].getHashedSet(object)
if owner == nil {
if opts.VersionID == "" {
// Restore the version that is latest now, by its own ID.
gen, err := er.readRestoreGeneration(ctx, bucket, object, "")
if err != nil {
return toObjectErr(err, bucket, object)
}
opts.VersionID = restoreVersionID(gen.fi.VersionID)
}
if owner, err = z.takeRestoreOwner(ctx, bucket, object, opts.VersionID, nil); err != nil {
return err
}
}
return er.restoreVersion(owner.ctx, bucket, object, opts, restoreHooks{
lock: func(ctx context.Context) (LockContext, func(), error) {
lk := z.restoreLock(bucket, object)
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
if err != nil {
return LockContext{}, nil, err
}
return lkctx, func() { lk.Unlock(lkctx) }, nil
},
placed: func(ctx context.Context) error {
got, err := z.restorePoolIdx(ctx, bucket, object, opts)
var reset restoreResetError
if errors.As(err, &reset) {
return reset.error
}
if err != nil {
return err
}
if got != idx {
return errRestoreMoved
}
return nil
},
clear: clearMarker,
})
}