// 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 . package cmd import ( "context" "fmt" "strings" "sync/atomic" "testing" "testing/synctest" "time" "github.com/minio/minio/internal/dsync" ) const listingLockerTestSource = "[metacache-server-pool.go:274:(*erasureServerPools).listingRLock()]" func listingLockerTestArgs(uid, resource, source string) dsync.LockArgs { quorum := 3 return dsync.LockArgs{UID: uid, Resources: []string{resource}, Source: source, Owner: "listing-wait-test", Quorum: &quorum} } func listingLockerTestRead(t *testing.T, l *localLocker, args dsync.LockArgs) { t.Helper() if ok, err := l.RLock(t.Context(), args); !ok || err != nil { t.Fatalf("RLock = %v, %v", ok, err) } } func listingLockerTestRUnlock(t *testing.T, l *localLocker, args dsync.LockArgs) { t.Helper() if ok, err := l.RUnlock(t.Context(), args); !ok || err != nil { t.Fatalf("RUnlock = %v, %v", ok, err) } } type listingLockerTestState struct { waiting, resources, uids int } func listingLockerTestSnapshot(l *localLocker) listingLockerTestState { defer l.getMutex()() return listingLockerTestState{waiting: l.listingWaiters, resources: len(l.lockMap), uids: len(l.lockUID)} } func TestLocalLockerListingSource(t *testing.T) { for _, source := range []string{ "", "listingRLock", "[:0:()]", strings.Replace(listingLockerTestSource, ":274:", ":", 1), strings.Replace(listingLockerTestSource, "274", "", 1), strings.Replace(listingLockerTestSource, "274", "0", 1), strings.Replace(listingLockerTestSource, "274", "1x", 1), strings.Replace(listingLockerTestSource, "274", "-1", 1), strings.Replace(listingLockerTestSource, "listingRLock", "GetObjectNInfo", 1), strings.Replace(listingLockerTestSource, "listingRLock", "listingRLock.func1", 1), strings.Replace(listingLockerTestSource, "metacache-server-pool.go", "other.go", 1), listingLockerTestSource + "trailing", } { if isListingReadLock([]lockRequesterInfo{{Source: source}}) { t.Errorf("unrecognized source accepted: %q", source) } } if isListingReadLock(nil) || isListingReadLock([]lockRequesterInfo{{Source: listingLockerTestSource, Writer: true}}) { t.Fatal("empty or write holders accepted") } if isListingReadLock([]lockRequesterInfo{{Source: listingLockerTestSource}, {Source: "ordinary-reader"}}) { t.Fatal("mixed readers accepted") } // Check the real runtime-generated source, not only a hand-written label. synctest.Test(t, func(t *testing.T) { lockers := []dsync.NetLocker{newLocker(), newLocker(), newLocker(), newLocker()} set := &erasureObjects{nsMutex: newNSLock(true), getLockers: func() ([]dsync.NetLocker, string) { return lockers, "listing-source-test" }} z := &erasureServerPools{serverPools: []*erasureSets{{sets: []*erasureObjects{set}}, {sets: []*erasureObjects{set}}}} _, unlock, err := z.listingRLock(t.Context(), "bucket", "object", set) if err != nil { t.Fatal(err) } for _, c := range lockers { l := c.(*localLocker) for _, holders := range l.DupLockMap() { if !isListingReadLock(holders) { t.Errorf("actual listingRLock source was not recognized: %#v", holders) } } } unlock() synctest.Wait() }) } func FuzzListingLockSource(f *testing.F) { f.Add(listingLockerTestSource) f.Add("[metacache-server-pool.go:(*erasureServerPools).listingRLock()]") f.Add("") f.Fuzz(func(t *testing.T, source string) { // Source is diagnostic text from a peer, including older versions. No // malformed string may panic or make a writer eligible for this wait. isListingReadLock([]lockRequesterInfo{{Source: source}}) if isListingReadLock([]lockRequesterInfo{{Source: source, Writer: true}}) { t.Fatal("writer was accepted as a listing reader") } }) } func TestLocalLockerListingWaitRechecks(t *testing.T) { for _, tc := range []struct { name string change func(*testing.T, *localLocker, dsync.LockArgs) want bool }{ {"reader-released", func(t *testing.T, l *localLocker, r dsync.LockArgs) { listingLockerTestRUnlock(t, l, r) }, true}, {"reader-still-held", func(*testing.T, *localLocker, dsync.LockArgs) {}, false}, {"one-of-two-readers-remains", func(t *testing.T, l *localLocker, r dsync.LockArgs) { other := r other.UID = "second-reader" listingLockerTestRead(t, l, other) listingLockerTestRUnlock(t, l, r) }, false}, {"ordinary-reader-arrives", func(t *testing.T, l *localLocker, r dsync.LockArgs) { listingLockerTestRUnlock(t, l, r) listingLockerTestRead(t, l, listingLockerTestArgs("new-reader", "key", "ordinary-reader")) }, false}, {"other-writer-wins", func(t *testing.T, l *localLocker, r dsync.LockArgs) { listingLockerTestRUnlock(t, l, r) if ok, err := l.Lock(t.Context(), listingLockerTestArgs("other-writer", "key", "writer")); !ok || err != nil { t.Fatalf("competing Lock = %v, %v", ok, err) } }, false}, {"force-unlock", func(t *testing.T, l *localLocker, r dsync.LockArgs) { if ok, err := l.ForceUnlock(t.Context(), r); !ok || err != nil { t.Fatalf("ForceUnlock = %v, %v", ok, err) } }, true}, {"expiry", func(_ *testing.T, l *localLocker, _ dsync.LockArgs) { l.expireOldLocks(-time.Minute) }, true}, } { t.Run(tc.name, func(t *testing.T) { synctest.Test(t, func(t *testing.T) { l := newLocker() reader := listingLockerTestArgs("reader", "key", listingLockerTestSource) listingLockerTestRead(t, l, reader) var got bool var err error start := time.Now() go func() { got, err = l.Lock(t.Context(), listingLockerTestArgs("writer", "key", "writer")) }() synctest.Wait() if state := listingLockerTestSnapshot(l); state.waiting != 1 { t.Fatalf("waiting = %d, want 1", state.waiting) } // The global mutex must be released throughout the timer wait. if ok, err := l.Lock(t.Context(), listingLockerTestArgs("unrelated", "other-key", "writer")); !ok || err != nil { t.Fatalf("unrelated key blocked: %v, %v", ok, err) } if elapsed := time.Since(start); elapsed != 0 { t.Fatalf("unrelated key waited %v", elapsed) } time.Sleep(time.Millisecond) tc.change(t, l, reader) time.Sleep(time.Millisecond) synctest.Wait() if state := listingLockerTestSnapshot(l); got != tc.want || err != nil || state.waiting != 0 { t.Fatalf("Lock = %v, %v, waiting = %d; want %v, nil, 0", got, err, state.waiting, tc.want) } if elapsed := time.Since(start); elapsed != 2*time.Millisecond { t.Fatalf("one attempt elapsed %v, want 2ms", elapsed) } }) }) } } func TestLocalLockerListingWaitCancellation(t *testing.T) { for _, when := range []string{"before-call", "during-wait", "deadline"} { t.Run(when, func(t *testing.T) { synctest.Test(t, func(t *testing.T) { l := newLocker() reader := listingLockerTestArgs("reader", "key", listingLockerTestSource) listingLockerTestRead(t, l, reader) ctx, cancel := context.WithCancel(t.Context()) if when == "deadline" { cancel() ctx, cancel = context.WithTimeout(t.Context(), time.Millisecond) } defer cancel() if when == "before-call" { cancel() } var got bool var err error go func() { got, err = l.Lock(ctx, listingLockerTestArgs("writer", "key", "writer")) }() synctest.Wait() if when != "before-call" { if state := listingLockerTestSnapshot(l); state.waiting != 1 { t.Fatalf("waiting = %d", state.waiting) } time.Sleep(time.Millisecond) cancel() } synctest.Wait() if state := listingLockerTestSnapshot(l); got || err != ctx.Err() || err == nil || state.waiting != 0 { t.Fatalf("canceled Lock = %v, %v; context %v; waiting = %d", got, err, ctx.Err(), state.waiting) } listingLockerTestRUnlock(t, l, reader) if state := listingLockerTestSnapshot(l); state.resources != 0 || state.uids != 0 { t.Fatal("canceled wait left a lock") } }) }) } } func TestLocalLockerListingWaitExcluded(t *testing.T) { for _, kind := range []string{"ordinary-reader", "unknown-reader", "mixed-readers", "writer", "bulk", "overloaded"} { t.Run(kind, func(t *testing.T) { synctest.Test(t, func(t *testing.T) { l := newLocker() reader := listingLockerTestArgs("holder", "key", listingLockerTestSource) if kind == "ordinary-reader" { reader.Source = "[erasure-object.go:100:(*erasureObjects).GetObjectNInfo()]" } if kind == "unknown-reader" { reader.Source = "" } if kind == "writer" { if ok, err := l.Lock(t.Context(), reader); !ok || err != nil { t.Fatalf("initial write = %v, %v", ok, err) } } else { listingLockerTestRead(t, l, reader) } if kind == "mixed-readers" { listingLockerTestRead(t, l, listingLockerTestArgs("ordinary", "key", "ordinary-reader")) } writer := listingLockerTestArgs("writer", "key", "writer") if kind == "bulk" { writer.Resources = []string{"key", "other-key"} } if kind == "overloaded" { l.waitMutex.Store(lockMutexWaitLimit + 1) } start := time.Now() if ok, err := l.Lock(t.Context(), writer); ok || err != nil { t.Fatalf("conflicting Lock = %v, %v", ok, err) } state := listingLockerTestSnapshot(l) if elapsed := time.Since(start); elapsed != 0 || state.waiting != 0 { t.Fatalf("excluded path waited %v; waiters = %d", elapsed, state.waiting) } if _, ok := l.DupLockMap()["other-key"]; ok { t.Fatal("bulk request partially acquired an unrelated key") } }) }) } } func TestLocalLockerListingWaitAdmission(t *testing.T) { synctest.Test(t, func(t *testing.T) { l := newLocker() reader := listingLockerTestArgs("reader", "key", listingLockerTestSource) listingLockerTestRead(t, l, reader) ctx, cancel := context.WithCancel(t.Context()) defer cancel() results := make(chan error, listingLockWaitLimit) for i := range listingLockWaitLimit { go func() { ok, err := l.Lock(ctx, listingLockerTestArgs(fmt.Sprintf("writer-%d", i), "key", "writer")) if ok { err = fmt.Errorf("granted over a live reader") } results <- err }() } synctest.Wait() if state := listingLockerTestSnapshot(l); state.waiting != listingLockWaitLimit { t.Fatalf("waiting = %d, want %d", state.waiting, listingLockWaitLimit) } start := time.Now() if ok, err := l.Lock(ctx, listingLockerTestArgs("excess", "key", "writer")); ok || err != nil { t.Fatalf("excess waiter = %v, %v", ok, err) } if state := listingLockerTestSnapshot(l); time.Since(start) != 0 || state.waiting != listingLockWaitLimit { t.Fatal("excess waiter consumed time or admission") } if ok, err := l.Lock(ctx, listingLockerTestArgs("unrelated", "other-key", "writer")); !ok || err != nil { t.Fatalf("admission limit rejected an uncontended key: %v, %v", ok, err) } cancel() synctest.Wait() for range listingLockWaitLimit { if err := <-results; err != context.Canceled { t.Fatalf("canceled waiter: %v", err) } } if state := listingLockerTestSnapshot(l); state.waiting != 0 { t.Fatalf("admission leaked: %d", state.waiting) } }) } func TestLocalLockerListingWaitContended(t *testing.T) { for _, n := range []int{2, 8, 32} { t.Run(fmt.Sprintf("%d-writers", n), func(t *testing.T) { synctest.Test(t, func(t *testing.T) { l := newLocker() reader := listingLockerTestArgs("reader", "key", listingLockerTestSource) listingLockerTestRead(t, l, reader) results := make(chan bool, n) for i := range n { go func() { ok, err := l.Lock(t.Context(), listingLockerTestArgs(fmt.Sprintf("writer-%d", i), "key", "writer")) if err != nil { t.Errorf("Lock: %v", err) } results <- ok }() } synctest.Wait() if state := listingLockerTestSnapshot(l); state.waiting != n { t.Fatalf("waiting = %d, want %d", state.waiting, n) } time.Sleep(time.Millisecond) listingLockerTestRUnlock(t, l, reader) time.Sleep(time.Millisecond) synctest.Wait() granted := 0 for range n { if <-results { granted++ } } if state := listingLockerTestSnapshot(l); granted != 1 || state.uids != 1 || state.waiting != 0 { t.Fatalf("grants = %d, indexed = %d, waiting = %d", granted, state.uids, state.waiting) } }) }) } } type listingCountLocker struct { *localLocker calls atomic.Int32 } func (l *listingCountLocker) Lock(ctx context.Context, args dsync.LockArgs) (bool, error) { l.calls.Add(1) return l.localLocker.Lock(ctx, args) } // Exercise real DRWMutex quorum and late-response cleanup. The mixed case with // two immediate rejections fails before the upgraded lockers finish waiting. func TestDistributedListingWait(t *testing.T) { for _, ordinaryReaders := range []int{0, 1, 2} { t.Run(fmt.Sprintf("%d-immediate-rejections", ordinaryReaders), func(t *testing.T) { synctest.Test(t, func(t *testing.T) { clients := make([]dsync.NetLocker, 4) reader := listingLockerTestArgs("reader", "key", listingLockerTestSource) for i := range clients { l := &listingCountLocker{localLocker: newLocker()} clients[i] = l r := reader if i < ordinaryReaders { r.Source = "old-node-unknown-source" } listingLockerTestRead(t, l.localLocker, r) } ds := &dsync.Dsync{GetLockers: func() ([]dsync.NetLocker, string) { return clients, reader.Owner }, Timeouts: dsync.DefaultTimeouts} dm := dsync.NewDRWMutex(ds, "key") var got bool go func() { got = dm.GetLock(t.Context(), nil, "writer", "writer", dsync.Options{Timeout: time.Second, RetryInterval: -1}) }() synctest.Wait() time.Sleep(time.Millisecond) for _, c := range clients { listingLockerTestRUnlock(t, c.(*listingCountLocker).localLocker, reader) } time.Sleep(time.Millisecond) synctest.Wait() if got != (ordinaryReaders < 2) { t.Fatalf("GetLock = %v", got) } granted := 0 for _, c := range clients { l := c.(*listingCountLocker) if l.calls.Load() != 1 { t.Fatalf("lock calls = %d, want one round", l.calls.Load()) } state := listingLockerTestSnapshot(l.localLocker) if state.resources != 0 { granted++ } if state.waiting != 0 { t.Fatal("wait admission leaked") } } if got && granted < 3 { t.Fatalf("writer accepted without quorum: %d", granted) } if !got && granted != 0 { t.Fatalf("late grants leaked after rejected attempt: %d", granted) } if got { dm.Unlock(t.Context()) synctest.Wait() } for _, c := range clients { l := c.(*listingCountLocker) if state := listingLockerTestSnapshot(l.localLocker); state.resources != 0 || state.uids != 0 { t.Fatal("final lock state not empty") } } }) }) } }