420 lines
9.3 KiB
Go
420 lines
9.3 KiB
Go
package repolock
|
|
|
|
import "sync"
|
|
import "sync/atomic"
|
|
import "testing"
|
|
import "time"
|
|
|
|
// TestLockUnlock verifies the basic lock/unlock cycle completes without error.
|
|
func TestLockUnlock(t *testing.T) {
|
|
var m *Manager
|
|
var unlock func()
|
|
m = NewManager()
|
|
unlock = m.Lock("repo-a")
|
|
unlock()
|
|
}
|
|
|
|
// TestLockIsExclusive verifies that a second goroutine blocks until the first
|
|
// releases the lock, and that the critical section is never entered concurrently.
|
|
func TestLockIsExclusive(t *testing.T) {
|
|
var m *Manager
|
|
var inside int32
|
|
var wg sync.WaitGroup
|
|
var violation int32
|
|
|
|
m = NewManager()
|
|
|
|
var run func()
|
|
run = func() {
|
|
defer wg.Done()
|
|
var unlock func()
|
|
unlock = m.Lock("repo-x")
|
|
defer unlock()
|
|
if atomic.AddInt32(&inside, 1) > 1 {
|
|
atomic.StoreInt32(&violation, 1)
|
|
}
|
|
time.Sleep(2 * time.Millisecond)
|
|
atomic.AddInt32(&inside, -1)
|
|
}
|
|
|
|
wg.Add(10)
|
|
var i int
|
|
for i = 0; i < 10; i++ {
|
|
go run()
|
|
}
|
|
wg.Wait()
|
|
|
|
if violation != 0 {
|
|
t.Fatal("lock was not exclusive: multiple goroutines inside critical section simultaneously")
|
|
}
|
|
}
|
|
|
|
// TestLockDifferentKeysDoNotBlock verifies that locks on distinct repo IDs are
|
|
// independent and do not block each other.
|
|
func TestLockDifferentKeysDoNotBlock(t *testing.T) {
|
|
var m *Manager
|
|
var ch chan struct{}
|
|
var unlock func()
|
|
|
|
m = NewManager()
|
|
ch = make(chan struct{})
|
|
|
|
unlock = m.Lock("repo-a")
|
|
|
|
go func() {
|
|
// locking a different key must not block
|
|
var u func()
|
|
u = m.Lock("repo-b")
|
|
close(ch)
|
|
u()
|
|
}()
|
|
|
|
select {
|
|
case <-ch:
|
|
// good
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("Lock on different key blocked unexpectedly")
|
|
}
|
|
unlock()
|
|
}
|
|
|
|
// TestTryLockSucceedsWhenFree verifies TryLock acquires the lock when available.
|
|
func TestTryLockSucceedsWhenFree(t *testing.T) {
|
|
var m *Manager
|
|
var unlock func()
|
|
var ok bool
|
|
|
|
m = NewManager()
|
|
unlock, ok = m.TryLock("repo-a")
|
|
if !ok {
|
|
t.Fatal("TryLock should succeed on a free lock")
|
|
}
|
|
unlock()
|
|
}
|
|
|
|
// TestTryLockFailsWhenHeld verifies TryLock returns false when another goroutine
|
|
// holds the lock.
|
|
func TestTryLockFailsWhenHeld(t *testing.T) {
|
|
var m *Manager
|
|
var unlock func()
|
|
var unlock2 func()
|
|
var ok bool
|
|
|
|
m = NewManager()
|
|
unlock = m.Lock("repo-a")
|
|
defer unlock()
|
|
|
|
unlock2, ok = m.TryLock("repo-a")
|
|
if ok {
|
|
unlock2()
|
|
t.Fatal("TryLock should fail when lock is already held")
|
|
}
|
|
}
|
|
|
|
// TestTryLockSucceedsAfterRelease verifies TryLock succeeds once the prior
|
|
// holder releases the lock.
|
|
func TestTryLockSucceedsAfterRelease(t *testing.T) {
|
|
var m *Manager
|
|
var unlock func()
|
|
var ok bool
|
|
|
|
m = NewManager()
|
|
unlock = m.Lock("repo-a")
|
|
unlock() // release immediately
|
|
|
|
unlock, ok = m.TryLock("repo-a")
|
|
if !ok {
|
|
t.Fatal("TryLock should succeed after the lock is released")
|
|
}
|
|
unlock()
|
|
}
|
|
|
|
// TestLockManyAcquiresAll verifies LockMany acquires all listed locks.
|
|
func TestLockManyAcquiresAll(t *testing.T) {
|
|
var m *Manager
|
|
var unlock func()
|
|
var ok bool
|
|
var u func()
|
|
|
|
m = NewManager()
|
|
unlock = m.LockMany([]string{"repo-a", "repo-b", "repo-c"})
|
|
defer unlock()
|
|
|
|
// all three should be held — TryLock should fail for each
|
|
u, ok = m.TryLock("repo-a")
|
|
if ok { u(); t.Error("repo-a should be locked") }
|
|
u, ok = m.TryLock("repo-b")
|
|
if ok { u(); t.Error("repo-b should be locked") }
|
|
u, ok = m.TryLock("repo-c")
|
|
if ok { u(); t.Error("repo-c should be locked") }
|
|
}
|
|
|
|
// TestLockManyReleasesAll verifies LockMany's unlock function releases all locks.
|
|
func TestLockManyReleasesAll(t *testing.T) {
|
|
var m *Manager
|
|
var unlock func()
|
|
var ok bool
|
|
var u func()
|
|
|
|
m = NewManager()
|
|
unlock = m.LockMany([]string{"repo-a", "repo-b"})
|
|
unlock()
|
|
|
|
u, ok = m.TryLock("repo-a")
|
|
if !ok { t.Error("repo-a should be free after unlock") } else { u() }
|
|
u, ok = m.TryLock("repo-b")
|
|
if !ok { t.Error("repo-b should be free after unlock") } else { u() }
|
|
}
|
|
|
|
// TestLockManyDeadlockFree verifies that two goroutines acquiring overlapping
|
|
// sets in opposite order do not deadlock. LockMany sorts keys before acquiring,
|
|
// so both goroutines always acquire in the same order.
|
|
func TestLockManyDeadlockFree(t *testing.T) {
|
|
var m *Manager
|
|
var wg sync.WaitGroup
|
|
var done int32
|
|
|
|
m = NewManager()
|
|
|
|
var runAB func()
|
|
runAB = func() {
|
|
defer wg.Done()
|
|
var unlock func()
|
|
unlock = m.LockMany([]string{"repo-a", "repo-b"})
|
|
time.Sleep(1 * time.Millisecond)
|
|
unlock()
|
|
atomic.AddInt32(&done, 1)
|
|
}
|
|
|
|
var runBA func()
|
|
runBA = func() {
|
|
defer wg.Done()
|
|
var unlock func()
|
|
unlock = m.LockMany([]string{"repo-b", "repo-a"}) // reverse order — must still sort
|
|
time.Sleep(1 * time.Millisecond)
|
|
unlock()
|
|
atomic.AddInt32(&done, 1)
|
|
}
|
|
|
|
wg.Add(2)
|
|
go runAB()
|
|
go runBA()
|
|
|
|
var finished bool
|
|
var ch chan struct{}
|
|
ch = make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(ch)
|
|
}()
|
|
|
|
select {
|
|
case <-ch:
|
|
finished = true
|
|
case <-time.After(2 * time.Second):
|
|
finished = false
|
|
}
|
|
|
|
if !finished {
|
|
t.Fatal("deadlock detected: LockMany([A,B]) vs LockMany([B,A]) did not complete")
|
|
}
|
|
if done != 2 {
|
|
t.Fatalf("expected both goroutines to finish, got %d", done)
|
|
}
|
|
}
|
|
|
|
// TestTryLockManySucceedsWhenAllFree verifies TryLockMany acquires all locks
|
|
// when none are held.
|
|
func TestTryLockManySucceedsWhenAllFree(t *testing.T) {
|
|
var m *Manager
|
|
var unlock func()
|
|
var ok bool
|
|
|
|
m = NewManager()
|
|
unlock, ok = m.TryLockMany([]string{"repo-a", "repo-b", "repo-c"})
|
|
if !ok {
|
|
t.Fatal("TryLockMany should succeed when all locks are free")
|
|
}
|
|
unlock()
|
|
}
|
|
|
|
// TestTryLockManyFailsAndReleasesWhenOneLockHeld verifies that TryLockMany
|
|
// releases any already-acquired locks when it fails to acquire one, leaving
|
|
// no locks held on failure.
|
|
func TestTryLockManyFailsAndReleasesWhenOneLockHeld(t *testing.T) {
|
|
var m *Manager
|
|
var holdUnlock func()
|
|
var unlock func()
|
|
var ok bool
|
|
var u func()
|
|
|
|
m = NewManager()
|
|
|
|
// hold repo-b
|
|
holdUnlock = m.Lock("repo-b")
|
|
defer holdUnlock()
|
|
|
|
// TryLockMany([repo-a, repo-b, repo-c]) should fail because repo-b is held.
|
|
// repo-a must be released as a result (no partial hold).
|
|
unlock, ok = m.TryLockMany([]string{"repo-a", "repo-b", "repo-c"})
|
|
if ok {
|
|
unlock()
|
|
t.Fatal("TryLockMany should fail when one of the locks is held")
|
|
}
|
|
|
|
// repo-a must be free now — TryLock should succeed
|
|
u, ok = m.TryLock("repo-a")
|
|
if !ok {
|
|
t.Fatal("repo-a should have been released by the failed TryLockMany")
|
|
}
|
|
u()
|
|
|
|
// repo-c should also be free
|
|
u, ok = m.TryLock("repo-c")
|
|
if !ok {
|
|
t.Fatal("repo-c should have been released by the failed TryLockMany")
|
|
}
|
|
u()
|
|
}
|
|
|
|
// TestLockManyDeduplicatesIDs verifies that passing duplicate IDs to LockMany
|
|
// does not cause a self-deadlock.
|
|
func TestLockManyDeduplicatesIDs(t *testing.T) {
|
|
var m *Manager
|
|
var unlock func()
|
|
var done chan struct{}
|
|
|
|
m = NewManager()
|
|
done = make(chan struct{})
|
|
|
|
go func() {
|
|
unlock = m.LockMany([]string{"repo-a", "repo-a", "repo-a"})
|
|
unlock()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// good
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("LockMany with duplicate IDs deadlocked")
|
|
}
|
|
}
|
|
|
|
// TestLockEmptyKeyDoesNotPanic verifies that empty string keys are handled
|
|
// safely and treated as a single canonical key.
|
|
func TestLockEmptyKeyDoesNotPanic(t *testing.T) {
|
|
var m *Manager
|
|
var unlock func()
|
|
|
|
m = NewManager()
|
|
unlock = m.Lock("")
|
|
unlock()
|
|
}
|
|
|
|
// TestTryLockManyEmptySlice verifies TryLockMany with no IDs succeeds and
|
|
// the returned unlock is safe to call.
|
|
func TestTryLockManyEmptySlice(t *testing.T) {
|
|
var m *Manager
|
|
var unlock func()
|
|
var ok bool
|
|
|
|
m = NewManager()
|
|
unlock, ok = m.TryLockMany([]string{})
|
|
if !ok {
|
|
t.Fatal("TryLockMany on empty slice should succeed")
|
|
}
|
|
if unlock != nil {
|
|
unlock()
|
|
}
|
|
}
|
|
|
|
// TestNilManagerIsNoop verifies all Manager methods on a nil receiver return
|
|
// safely without panicking.
|
|
func TestNilManagerIsNoop(t *testing.T) {
|
|
var m *Manager // nil
|
|
var unlock func()
|
|
var ok bool
|
|
|
|
unlock = m.Lock("repo-a")
|
|
unlock() // should not panic
|
|
|
|
unlock, ok = m.TryLock("repo-a")
|
|
if !ok {
|
|
t.Error("nil TryLock should report success (noop)")
|
|
}
|
|
unlock()
|
|
|
|
unlock = m.LockMany([]string{"repo-a", "repo-b"})
|
|
unlock()
|
|
|
|
unlock, ok = m.TryLockMany([]string{"repo-a"})
|
|
if !ok {
|
|
t.Error("nil TryLockMany should report success (noop)")
|
|
}
|
|
if unlock != nil {
|
|
unlock()
|
|
}
|
|
}
|
|
|
|
// TestLockHighConcurrency stress-tests Lock under high goroutine count to
|
|
// surface any data races (run with -race).
|
|
func TestLockHighConcurrency(t *testing.T) {
|
|
var m *Manager
|
|
var wg sync.WaitGroup
|
|
var counter int64
|
|
const goroutines = 100
|
|
|
|
m = NewManager()
|
|
|
|
var i int
|
|
for i = 0; i < goroutines; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
var unlock func()
|
|
unlock = m.Lock("shared-repo")
|
|
defer unlock()
|
|
atomic.AddInt64(&counter, 1)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if counter != goroutines {
|
|
t.Fatalf("expected counter=%d, got %d", goroutines, counter)
|
|
}
|
|
}
|
|
|
|
// TestTryLockManyHighConcurrency stress-tests TryLockMany under contention.
|
|
// Some attempts will fail (lock held); all must either fully succeed or fully
|
|
// fail with no partial holds.
|
|
func TestTryLockManyHighConcurrency(t *testing.T) {
|
|
var m *Manager
|
|
var wg sync.WaitGroup
|
|
var successes int64
|
|
const goroutines = 50
|
|
|
|
m = NewManager()
|
|
|
|
var i int
|
|
for i = 0; i < goroutines; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
var unlock func()
|
|
var ok bool
|
|
unlock, ok = m.TryLockMany([]string{"repo-x", "repo-y"})
|
|
if ok {
|
|
atomic.AddInt64(&successes, 1)
|
|
time.Sleep(1 * time.Millisecond)
|
|
unlock()
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if successes == 0 {
|
|
t.Fatal("expected at least one TryLockMany to succeed")
|
|
}
|
|
}
|