Files
minio/cmd/erasure-server-pool-locks_test.go
T
Feng Ruohang 32ab506924 fix(pools): lock single-pool object writes in the object's own erasure set
In a single pool, DELETE in every form, bulk delete, the CopyObject
destination, SelectObjectContent, batch key rotation and the upload-ID
guards of complete, abort, get-info and list-parts locked through the
first erasure set, while PUT, multipart commit, PutObjectPart, tag,
retention, legal-hold and metadata updates, restore, transition and heal
lock in the set the object hashes to. When the sets of a pool use
different hosts, as every automatic layout of 18 or more hosts does, the
two locks ask different lockers and do not exclude each other, and
concurrent read-modify-writes of xl.meta can lose acknowledged versions
and delete markers or leave an object on a minority of drives.

Route these locks through NewObjectNSLock, which returns the lock of
the object's own set in a single pool and the pools lock otherwise.
Upload guards choose the set by their object and keep the name
object/uploadID. True prefix deletion and the locks of names that are
not objects keep their domain.

Bulk delete groups its names by set and takes one set's lock at a time,
in ascending set index; a failed group fails alone. A single node, whose
sets share one local lock table, keeps its one bulk lock.

The COPY handler takes its source and destination locks before it opens
the source, sorted, and transactionally: bounded waits while holding a
lock, release in reverse order and retry with new lock instances, one
deadline. It reads the source under them, passes NoLock to every nested
call, and runs that work in the locks' context. This also removes a
deadlock of copies in opposite directions wherever both locks were in
one domain, such as a single node or several pools.

Lock names and RPCs are unchanged, but a pool is protected only when
every node runs this change: affected pools must be upgraded and rolled
back with a drained restart.

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

556 lines
18 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"
"net/http"
"sync/atomic"
"testing"
"time"
"github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/dsync"
)
// ldTry reports whether lk can be taken now, and releases it again.
func ldTry(t *testing.T, lk RWLocker, write bool) bool {
t.Helper()
dt := newDynamicTimeout(100*time.Millisecond, 100*time.Millisecond)
if write {
lc, err := lk.GetLock(t.Context(), dt)
if err == nil {
lk.Unlock(lc)
}
return err == nil
}
lc, err := lk.GetRLock(t.Context(), dt)
if err == nil {
lk.RUnlock(lc)
}
return err == nil
}
// ldFree reports whether lk can be taken within a few seconds: a distributed
// unlock completes in the background.
func ldFree(t *testing.T, lk RWLocker, write bool) bool {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !ldTry(t, lk, write) {
if time.Now().After(deadline) {
return false
}
}
return true
}
// ldTaken waits until someone else holds lk.
func ldTaken(t *testing.T, lk RWLocker) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for ldTry(t, lk, false) {
if time.Now().After(deadline) {
t.Fatal("lock never taken")
}
time.Sleep(time.Millisecond)
}
}
func ldHoldLock(t *testing.T, lk RWLocker) func() {
t.Helper()
lc, err := lk.GetLock(t.Context(), globalOperationTimeout)
if err != nil {
t.Fatal(err)
}
done := false
return func() {
if !done {
done = true
lk.Unlock(lc)
}
}
}
// Object locks, and upload guards by their object, are in the set the
// object's writes lock in; with several pools they stay the pools lock.
func TestObjectNSLockRouting(t *testing.T) {
for _, mode := range ldModes {
t.Run(mode, func(t *testing.T) {
z, bucket := ldPool(t, mode)
pool := z.serverPools[0]
key := ldKey(t, z, "route", 1)
dir := ldKey(t, z, "route-dir", 0) + "/"
for _, name := range []string{key, dir} {
release := ldHoldLock(t, z.NewObjectNSLock(bucket, name))
encoded := encodeDirObject(name)
if ldTry(t, pool.getHashedSet(encoded).NewNSLock(bucket, encoded), false) {
t.Errorf("%s: a writer of the object's set ignores the object lock", name)
}
release()
}
id := mustGetUUID()
release := ldHoldLock(t, z.uploadIDNSLock(bucket, key, id))
if ldTry(t, pool.getHashedSet(key).NewNSLock(bucket, pathJoin(key, id)), false) {
t.Error("PutObjectPart's guard ignores the upload guard")
}
release()
if mode != ldShared && pool.sharesLocalNSLock() {
t.Error("split sets reported as one local table")
}
if mode == ldShared && !pool.sharesLocalNSLock() {
t.Error("one local table not recognized")
}
})
}
t.Run("pools", func(t *testing.T) {
z, bucket := bulkDeletePools(t, 2)
release := ldHoldLock(t, z.NewNSLock(bucket, "pooled"))
defer release()
if ldTry(t, z.NewObjectNSLock(bucket, "pooled"), false) || ldTry(t, z.uploadIDNSLock(bucket, "pooled", ""), false) {
t.Error("multi-pool object locks left the pools lock")
}
})
}
// lockObjects merges a name asked twice into one write lock and takes source
// and destination in their own sets.
func TestLockObjects(t *testing.T) {
for _, mode := range ldModes {
t.Run(mode, func(t *testing.T) {
z, bucket := ldPool(t, mode)
pool := z.serverPools[0]
src, dst := ldKey(t, z, "lock-src", 1), ldKey(t, z, "lock-dst", 0)
lkctx, unlock, err := z.lockObjects(t.Context(), objectLockRequest{bucket: bucket, object: src}, objectLockRequest{bucket: bucket, object: dst, write: true})
if err != nil {
t.Fatal(err)
}
if lkctx.Context().Err() != nil {
t.Fatal("lock context ended")
}
if !ldTry(t, pool.getHashedSet(src).NewNSLock(bucket, src), false) {
t.Error("source not shared with readers")
}
if ldTry(t, pool.getHashedSet(src).NewNSLock(bucket, src), true) {
t.Error("source not locked against writers")
}
if ldTry(t, pool.getHashedSet(dst).NewNSLock(bucket, dst), false) {
t.Error("destination not locked")
}
unlock()
if !ldFree(t, pool.getHashedSet(dst).NewNSLock(bucket, dst), true) || !ldFree(t, pool.getHashedSet(src).NewNSLock(bucket, src), true) {
t.Error("locks left behind")
}
// A copy onto itself takes one write lock; a directory object is
// locked by its stored name.
self := ldKey(t, z, "lock-self", 1) + "/"
_, unlock, err = z.lockObjects(t.Context(), objectLockRequest{bucket: bucket, object: self}, objectLockRequest{bucket: bucket, object: self, write: true})
if err != nil {
t.Fatal(err)
}
encoded := encodeDirObject(self)
if ldTry(t, pool.getHashedSet(encoded).NewNSLock(bucket, encoded), false) {
t.Error("self copy not write locked")
}
unlock()
})
}
}
// ldLeaseLock is a lock whose lease the test ends.
type ldLeaseLock struct {
RWLocker
lost chan context.CancelFunc
}
func (l ldLeaseLock) GetLock(ctx context.Context, timeout *dynamicTimeout) (LockContext, error) {
lc, err := l.RWLocker.GetLock(ctx, timeout)
if err != nil {
return lc, err
}
lease, cancel := context.WithCancel(lc.Context())
select {
case l.lost <- cancel:
default:
panic("too many lock attempts")
}
return LockContext{ctx: lease, cancel: cancel}, nil
}
// getNSLocks never holds one lock while it waits for another for long: it
// releases what it holds and retries, also after a lost lease, until its
// timeout or the request ends, and leaves nothing behind.
func TestGetNSLocksTransactional(t *testing.T) {
ns := newNSLock(false)
lock := func(name string) RWLocker { return ns.NewNSLock(nil, "bucket", name) }
newLock := func(name string) func() RWLocker { return func() RWLocker { return lock(name) } }
reqs := func() []nsLockRequest {
return []nsLockRequest{{newLock: newLock("a"), write: true, atomic: true}, {newLock: newLock("b"), write: true, atomic: true}}
}
t.Run("partial", func(t *testing.T) {
releaseB := ldHoldLock(t, lock("b"))
defer releaseB()
type result struct {
unlock func()
err error
}
done := make(chan result, 1)
go func() {
_, unlock, err := getNSLocks(t.Context(), reqs(), globalOperationTimeout)
done <- result{unlock, err}
}()
// "a" is taken while "b" is waited for, and free again between
// attempts.
ldTaken(t, lock("a"))
deadline := time.Now().Add(3 * nsLockTryTimeout)
for !ldTry(t, lock("a"), true) {
if time.Now().After(deadline) {
t.Fatal("a stays held while b is waited for")
}
}
releaseB()
res := <-done
if res.err != nil {
t.Fatal(res.err)
}
if ldTry(t, lock("a"), false) || ldTry(t, lock("b"), false) {
t.Error("not both locks held")
}
res.unlock()
if !ldTry(t, lock("a"), true) || !ldTry(t, lock("b"), true) {
t.Error("locks left behind")
}
})
t.Run("timeout", func(t *testing.T) {
releaseB := ldHoldLock(t, lock("b"))
defer releaseB()
start := time.Now()
_, _, err := getNSLocks(t.Context(), reqs(), newDynamicTimeout(3*time.Second, 3*time.Second))
if !errors.As(err, &OperationTimedOut{}) {
t.Fatalf("got %v, want a timeout", err)
}
if took := time.Since(start); took > 6*time.Second {
t.Errorf("timed out after %v", took)
}
if !ldTry(t, lock("a"), true) {
t.Error("a left behind")
}
})
t.Run("cancel", func(t *testing.T) {
releaseB := ldHoldLock(t, lock("b"))
defer releaseB()
ctx, cancel := context.WithCancel(t.Context())
time.AfterFunc(300*time.Millisecond, cancel)
_, _, err := getNSLocks(ctx, reqs(), globalOperationTimeout)
if !errors.Is(err, context.Canceled) {
t.Fatalf("got %v, want canceled", err)
}
if !ldTry(t, lock("a"), true) {
t.Error("a left behind")
}
})
t.Run("lease", func(t *testing.T) {
lost := make(chan context.CancelFunc, 64)
leased := []nsLockRequest{{newLock: func() RWLocker { return ldLeaseLock{lock("a"), lost} }, write: true, atomic: true}, {newLock: newLock("b"), write: true, atomic: true}}
// Losing the first lease while the second is waited for retries
// with a fresh context.
releaseB := ldHoldLock(t, lock("b"))
type result struct {
lkctx LockContext
unlock func()
err error
}
done := make(chan result, 1)
go func() {
lkctx, unlock, err := getNSLocks(t.Context(), leased, globalOperationTimeout)
done <- result{lkctx, unlock, err}
}()
(<-lost)()
releaseB()
res := <-done
if res.err != nil {
t.Fatal(res.err)
}
if res.lkctx.Context().Err() != nil {
t.Fatal("returned context ended before any lease was lost")
}
// Losing a held lease ends the returned context.
var cancel context.CancelFunc
for len(lost) > 0 {
cancel = <-lost
}
cancel()
select {
case <-res.lkctx.Context().Done():
case <-time.After(time.Second):
t.Error("lost lease did not end the locks' context")
}
res.unlock()
if !ldTry(t, lock("a"), true) || !ldTry(t, lock("b"), true) {
t.Error("locks left behind")
}
})
}
// A group whose lock times out fails alone and the later groups still run.
func TestDeleteObjectsInSetsLaterGroupsRun(t *testing.T) {
for _, mode := range ldModes[1:] {
t.Run(mode, func(t *testing.T) {
z, bucket := ldPool(t, mode)
k0, k1 := ldKey(t, z, "later", 0), ldKey(t, z, "later", 1)
ldMustPut(t, z, bucket, k0, "zero")
ldMustPut(t, z, bucket, k1, "one")
release := ldHoldLock(t, z.serverPools[0].getHashedSet(k0).NewNSLock(bucket, k0))
defer release()
objects := []ObjectToDelete{{ObjectV: ObjectV{ObjectName: k0}}, {ObjectV: ObjectV{ObjectName: k1}}}
dobjects := []DeletedObject{{ObjectName: k0}, {ObjectName: k1}}
derrs := make([]error, 2)
z.deleteObjectsInSets(t.Context(), bucket, objects, ObjectOptions{}, dobjects, derrs, newDynamicTimeout(time.Second, time.Second))
if !errors.As(derrs[0], &OperationTimedOut{}) || derrs[1] != nil {
t.Fatalf("results %v, want a timeout for k0 only", derrs)
}
release()
if body, _ := ldBody(t, z, bucket, k0); body != "zero" {
t.Errorf("k0 = %q", body)
}
ldAbsent(t, z, bucket, k1)
})
}
}
// The local lock takes the names of a group one by one. A group that waits
// for a name must not keep the names it holds: a part upload holds its part
// lock and then reads its upload ID, which the batch names too.
func TestDeleteObjectsInSetsLocalGroupReleases(t *testing.T) {
z, bucket := ldPool(t, ldLocal)
pool := z.serverPools[0]
var upload, part string
for i := range 1000 {
upload = ldKey(t, z, "local-upload", 1) + fmt.Sprintf("/u%d", i)
part = upload + "/1"
if pool.getHashedSetIndex(upload) == 1 && pool.getHashedSetIndex(part) == 1 {
break
}
}
er := pool.getHashedSet(upload)
releasePart := ldHoldLock(t, er.NewNSLock(bucket, part))
defer releasePart()
done := make(chan []error, 1)
go func() {
_, errs := z.DeleteObjects(t.Context(), bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: upload}}, {ObjectV: ObjectV{ObjectName: part}}}, ObjectOptions{})
done <- errs
}()
// The batch takes the upload name, waits for the part, and lets the
// part upload's read of its upload ID through.
ldTaken(t, er.NewNSLock(bucket, upload))
read := er.NewNSLock(bucket, upload)
rctx, err := read.GetRLock(t.Context(), newDynamicTimeout(4*nsLockTryTimeout, 4*nsLockTryTimeout))
if err != nil {
t.Fatalf("batch kept a name while waiting: %v", err)
}
read.RUnlock(rctx)
releasePart()
select {
case errs := <-done:
if errors.Join(errs...) != nil {
t.Fatal(errs)
}
case <-time.After(30 * time.Second):
t.Fatal("bulk delete did not finish")
}
}
// ldLostUnlockReply applies its first unlock but loses the reply until gate
// opens, so dsync retries that unlock in the background.
type ldLostUnlockReply struct {
dsync.NetLocker
gate <-chan struct{}
applied, retried chan<- struct{}
calls atomic.Int32
}
func (l *ldLostUnlockReply) Unlock(ctx context.Context, args dsync.LockArgs) (bool, error) {
n := l.calls.Add(1)
ok, err := l.NetLocker.Unlock(ctx, args)
switch n {
case 1:
l.applied <- struct{}{}
<-l.gate
return false, errors.New("unlock reply lost")
case 2:
l.retried <- struct{}{}
}
return ok, err
}
// ldFailFirst fails the first acquisition of any of its instances.
type ldFailFirst struct {
RWLocker
calls *atomic.Int32
}
func (l ldFailFirst) GetLock(ctx context.Context, timeout *dynamicTimeout) (LockContext, error) {
if l.calls.Add(1) == 1 {
return LockContext{}, OperationTimedOut{}
}
return l.RWLocker.GetLock(ctx, timeout)
}
// A distributed unlock of a failed attempt that is retried late must not
// release the lock the next attempt took.
func TestGetNSLocksFreshLockPerAttempt(t *testing.T) {
gate := make(chan struct{})
applied, retried := make(chan struct{}, 3), make(chan struct{}, 3)
lockers := make([]dsync.NetLocker, 3)
for i := range lockers {
lockers[i] = &ldLostUnlockReply{NetLocker: newLocker(), gate: gate, applied: applied, retried: retried}
}
ns := newNSLock(true)
dst := func() RWLocker {
return ns.NewNSLock(func() ([]dsync.NetLocker, string) { return lockers, "test" }, "bucket", "destination")
}
local := newNSLock(false)
var calls atomic.Int32
src := func() RWLocker { return ldFailFirst{local.NewNSLock(nil, "bucket", "source"), &calls} }
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
defer cancel()
opened := false
open := func() {
if !opened {
opened = true
close(gate)
}
}
defer open()
_, unlock, err := getNSLocks(ctx, []nsLockRequest{{newLock: dst, write: true, atomic: true}, {newLock: src, write: true, atomic: true}}, newDynamicTimeout(10*time.Second, 10*time.Second))
if err != nil {
t.Fatal(err)
}
defer unlock()
for range lockers {
select {
case <-applied:
case <-ctx.Done():
t.Fatal("the first attempt's unlock never reached the lockers")
}
}
open()
for range lockers {
select {
case <-retried:
case <-ctx.Done():
t.Fatal("the lost unlock was not retried")
}
}
competitor := dst()
lc, err := competitor.GetLock(ctx, newDynamicTimeout(300*time.Millisecond, 300*time.Millisecond))
if err == nil {
competitor.Unlock(lc)
t.Fatal("a late unlock retry released the held lock")
}
}
// One attempt at a local lock over several names is bounded as a whole, and so
// is the acquisition.
func TestGetNSLocksLocalAttemptBound(t *testing.T) {
ns := newNSLock(false)
a, b := ns.NewNSLock(nil, "bucket", "a"), ns.NewNSLock(nil, "bucket", "b")
releaseA, releaseB := ldHoldLock(t, a), ldHoldLock(t, b)
defer releaseA()
defer releaseB()
time.AfterFunc(900*time.Millisecond, releaseA)
time.AfterFunc(1800*time.Millisecond, releaseB)
start := time.Now()
_, unlock, err := getNSLocks(t.Context(), []nsLockRequest{{
newLock: func() RWLocker { return ns.NewNSLock(nil, "bucket", "a", "b") }, write: true,
}}, newDynamicTimeout(1500*time.Millisecond, 1500*time.Millisecond))
if err == nil {
unlock()
t.Fatalf("acquired after %v with a deadline of 1.5s", time.Since(start))
}
if !errors.As(err, &OperationTimedOut{}) {
t.Fatalf("got %v, want a timeout", err)
}
}
// ldLeaseLayer gives the COPY handler locks whose lease the test ends, and
// holds its destination lookup until the lookup's context ends.
type ldLeaseLayer struct {
ObjectLayer
destination string
lost chan context.CancelFunc
lookup chan context.Context
}
func (l *ldLeaseLayer) lockObjects(ctx context.Context, reqs ...objectLockRequest) (LockContext, func(), error) {
lc, unlock, err := l.ObjectLayer.lockObjects(ctx, reqs...)
if err != nil {
return lc, unlock, err
}
lease, cancel := context.WithCancel(lc.Context())
l.lost <- cancel
return LockContext{ctx: lease, cancel: cancel}, func() { cancel(); unlock() }, nil
}
func (l *ldLeaseLayer) GetObjectInfo(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
if object == l.destination && opts.NoLock {
l.lookup <- ctx
<-ctx.Done()
return ObjectInfo{}, ctx.Err()
}
return l.ObjectLayer.GetObjectInfo(ctx, bucket, object, opts)
}
// The COPY handler's work under its locks ends when a lease is lost, the
// destination lookups included.
func TestLockDomainCopyLookupEndsWithLease(t *testing.T) {
z, bucket, _ := ldAPI(t, ldShared)
ldMustPut(t, z, bucket, "source", "")
prevTier := globalTierConfigMgr
t.Cleanup(func() { globalTierConfigMgr = prevTier })
globalTierConfigMgr = NewTierConfigMgr()
globalTierConfigMgr.Tiers["WARM"] = madmin.TierConfig{Version: madmin.TierConfigVer, Type: madmin.S3, Name: "WARM", S3: &madmin.TierS3{Bucket: "warm"}}
layer := &ldLeaseLayer{ObjectLayer: z, destination: "destination", lost: make(chan context.CancelFunc, 1), lookup: make(chan context.Context, 1)}
router := initTestAPIEndPoints(layer, []string{"CopyObject"})
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
code := make(chan int, 1)
go func() { code <- ldCopyRequest(ctx, t, router, bucket, "source", "destination", nil).Code }()
var lookup context.Context
select {
case lookup = <-layer.lookup:
case <-ctx.Done():
t.Fatal("COPY did not look up its destination")
}
(<-layer.lost)()
select {
case <-lookup.Done():
case <-time.After(5 * time.Second):
t.Fatal("the destination lookup outlived the lost lease")
}
if c := <-code; c == http.StatusOK {
t.Error("COPY succeeded after losing its lock")
}
}