fix(restore): keep queued restores waiting for the object lock

The POST handler marks a version ongoing before its background restore
takes the object lock, and only the restore clears that marker. Native
restore holds the lock through the tier download, so a restore of
another version of the same key waits behind it. Both restore entry
points gave up after one delete-operation timeout and returned before
any cleanup, leaving ongoing-request="true" with no owner. Every later
POST for that version was refused with 409 and nothing expired the
marker.

Retry the lock while the restore's context lives, with a dedicated
dynamic timeout so repeated waits do not lengthen delete timeouts.
Shutdown or a caller deadline still ends the wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
Feng Ruohang
2026-11-04 16:37:42 +08:00
parent 3ddf83182f
commit 82dd12faf8
4 changed files with 97 additions and 2 deletions
+14 -1
View File
@@ -2675,6 +2675,19 @@ func (er erasureObjects) updateRestoreMetadata(ctx context.Context, bucket, obje
return nil
}
// 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
// restore clears that marker. A restore queued behind another version's
// download must therefore keep waiting rather than give up after one timeout.
func getRestoreLock(ctx context.Context, lk RWLocker) (LockContext, error) {
for {
lkctx, err := lk.GetLock(ctx, globalRestoreLockTimeout)
if !errors.As(err, &OperationTimedOut{}) || ctx.Err() != nil {
return lkctx, err
}
}
}
// restoreTransitionedObject for multipart object chunks the file stream from remote tier into the same number of parts
// as in the xl.meta for this version and rehydrates the part.n into the fi.DataDir for this version as in the xl.meta
func (er erasureObjects) restoreTransitionedObject(ctx context.Context, bucket string, object string, opts ObjectOptions) error {
@@ -2682,7 +2695,7 @@ func (er erasureObjects) restoreTransitionedObject(ctx context.Context, bucket s
// Single-pool and direct callers must protect the metadata snapshot
// through download, rehydration and failure cleanup, just like pools.
lk := er.NewNSLock(bucket, object)
lkctx, err := lk.GetLock(ctx, globalDeleteOperationTimeout)
lkctx, err := getRestoreLock(ctx, lk)
if err != nil {
return err
}
+1 -1
View File
@@ -3371,7 +3371,7 @@ func (z *erasureServerPools) RestoreTransitionedObject(ctx context.Context, buck
if !opts.NoLock {
// Acquire write lock before restoring transitioned object
lk := z.NewNSLock(bucket, object)
lkctx, err := lk.GetLock(ctx, globalDeleteOperationTimeout)
lkctx, err := getRestoreLock(ctx, lk)
if err != nil {
return err
}
+2
View File
@@ -319,6 +319,8 @@ var (
globalOperationTimeout = newDynamicTimeout(10*time.Minute, 5*time.Minute) // default timeout for general ops
globalDeleteOperationTimeout = newDynamicTimeout(5*time.Minute, 1*time.Minute) // default time for delete ops
// Queued restores retry this wait; keep their failures out of delete timeouts.
globalRestoreLockTimeout = newDynamicTimeout(5*time.Minute, 1*time.Minute)
// Listing recovery has its own failure history and a five-second caller budget.
globalListingOperationTimeout = newDynamicTimeout(5*time.Second, 5*time.Second)
+80
View File
@@ -603,6 +603,86 @@ func TestNativeRestoreSerializesObjectLock(t *testing.T) {
}
}
// The POST handler marks a version ongoing before its background restore
// takes the object lock, and only that restore clears the marker. A restore
// queued behind another version's download must keep waiting: one that gave up
// after a lock timeout would leave the marker for every later POST to refuse.
// The wait still ends with its context.
func TestNativeRestoreQueuedBehindDownload(t *testing.T) {
for _, pools := range []int{1, 2} {
t.Run(fmt.Sprintf("pools=%d", pools), func(t *testing.T) {
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
first, _ := nativeRestoreSeed(t, r, 0, false, false)
queued, body := nativeRestoreSeed(t, r, pools-1, false, false)
prev := globalRestoreLockTimeout
globalRestoreLockTimeout = newDynamicTimeout(50*time.Millisecond, 50*time.Millisecond)
t.Cleanup(func() { globalRestoreLockTimeout = prev })
tier := globalTierConfigMgr.drivercache["WARM"].(*restoreTestTier)
blocked := &nativeRestoreBlockedTier{WarmBackend: tier, entered: make(chan struct{}), release: make(chan struct{})}
globalTierConfigMgr.drivercache["WARM"] = blocked
var once sync.Once
unblock := func() { once.Do(func() { close(blocked.release) }) }
defer unblock()
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
firstDone := make(chan error, 1)
go func() {
firstDone <- r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, first.VersionID))
}()
select {
case <-blocked.entered:
case <-ctx.Done():
t.Fatal("first restore never reached the tier")
}
short, shortCancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer shortCancel()
ended := make(chan error, 1)
go func() {
ended <- r.z.RestoreTransitionedObject(short, r.bucket, r.object, nativeRestoreOptions(r, queued.VersionID))
}()
select {
case err := <-ended:
if err == nil {
t.Fatal("restore finished without the object lock")
}
case <-time.After(5 * time.Second):
t.Fatal("restore kept waiting for the lock after its context ended")
}
queuedDone := make(chan error, 1)
go func() {
queuedDone <- r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, queued.VersionID))
}()
select {
case err := <-queuedDone:
t.Fatalf("queued restore gave up behind the download: %v", err)
case <-time.After(10 * 50 * time.Millisecond):
}
unblock()
for _, done := range []chan error{firstDone, queuedDone} {
select {
case err := <-done:
if err != nil {
t.Fatalf("restore: %v", err)
}
case <-ctx.Done():
t.Fatal("restore did not finish")
}
}
info, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(queued.VersionID))
if err != nil || info.RestoreOngoing {
t.Fatalf("queued version still ongoing after restore: %q, %v", info.UserDefined[xhttp.AmzRestore], err)
}
reads := tier.gets.Load()
if _, got, err := r.read(queued.VersionID); err != nil || !bytes.Equal(got, body) {
t.Fatalf("queued restore contents: %v", err)
}
if tier.gets.Load() != reads {
t.Error("queued version was not restored locally")
}
})
}
}
func TestNativeRestoreConcurrentCompletion(t *testing.T) {
for _, separateSets := range []bool{false, true} {
t.Run(fmt.Sprintf("separate-set-locks=%v", separateSets), func(t *testing.T) {