package console import ( "sync" "testing" "time" ) // The property the walks reason from: two holders of one store's lock never // run at once. Asserted by counting concurrent occupancy rather than by // timing, so the test fails on a broken lock rather than on a slow machine. func TestStoreWalkLocksExcludePerApp(t *testing.T) { l := newStoreWalkLocks() var mu sync.Mutex live, maxLive := 0, 0 var wg sync.WaitGroup for i := 0; i < 8; i++ { wg.Add(1) go func() { defer wg.Done() defer l.lock("bab", "titogo", "")() mu.Lock() live++ if live > maxLive { maxLive = live } mu.Unlock() time.Sleep(time.Millisecond) mu.Lock() live-- mu.Unlock() }() } wg.Wait() if maxLive != 1 { t.Fatalf("max concurrent holders of one app's walk lock = %d, want 1", maxLive) } } // Two apps share no estate — one bucket and one IAM user each, both named for // the (account, app) pair — so their walks must not queue behind each other. func TestStoreWalkLocksDoNotSerialiseAcrossApps(t *testing.T) { l := newStoreWalkLocks() release := l.lock("bab", "titogo", "") defer release() done := make(chan struct{}) go func() { l.lock("bab", "other", "")() close(done) }() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("a walk on another app blocked behind this one's lock") } } // Two STORES of one app share no estate either (2026-09-19): a bucket, an IAM // user and a config bundle each, all named for the (account, app, // environment) triple. An environment store's walk must not queue behind the // app store's, or behind another environment's. func TestStoreWalkLocksDoNotSerialiseAcrossEnvironments(t *testing.T) { l := newStoreWalkLocks() release := l.lock("bab", "titogo", "") defer release() done := make(chan struct{}) go func() { l.lock("bab", "titogo", "beta")() l.lock("bab", "titogo", "alpha")() close(done) }() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("a walk on another store of the same app blocked behind this one's lock") } } // An app walked once must not hold a mutex for the life of the process. func TestStoreWalkLocksDropEntriesAtZero(t *testing.T) { l := newStoreWalkLocks() l.lock("bab", "titogo", "")() l.mu.Lock() n := len(l.m) l.mu.Unlock() if n != 0 { t.Fatalf("locks map holds %d entries after the last release, want 0", n) } } // Holding one app's lock while a second waiter is queued must keep the entry // alive — dropping it at the wrong moment would hand the waiter a different // mutex and silently defeat the exclusion above. func TestStoreWalkLocksKeepEntryWhileWaitersQueue(t *testing.T) { l := newStoreWalkLocks() first := l.lock("bab", "titogo", "") queued := make(chan func(), 1) go func() { queued <- l.lock("bab", "titogo", "") }() // Give the waiter time to register its reference before we look. deadline := time.Now().Add(2 * time.Second) for { l.mu.Lock() e := l.m[storeWalkKey("bab", "titogo", "")] refs := 0 if e != nil { refs = e.refs } l.mu.Unlock() if refs == 2 { break } if time.Now().After(deadline) { t.Fatalf("waiter never registered: refs = %d", refs) } time.Sleep(time.Millisecond) } first() (<-queued)() l.mu.Lock() n := len(l.m) l.mu.Unlock() if n != 0 { t.Fatalf("locks map holds %d entries after both released, want 0", n) } } // Except where the names collide (#218): app "shop" with environment "eu" and // app "shop-eu" resolve to one bucket and one IAM user, so their walks must // queue. The name claims refuse the second grant either way; the lock keeps // the two from interleaving their reads of each other's records. func TestStoreWalkLocksSerialiseCollidingStores(t *testing.T) { l := newStoreWalkLocks() release := l.lock("bab", "shop-eu", "") acquired := make(chan struct{}) go func() { l.lock("bab", "shop", "eu")() close(acquired) }() select { case <-acquired: t.Fatal("a walk on a store whose names collide with this one's did not queue behind its lock") case <-time.After(50 * time.Millisecond): } release() select { case <-acquired: case <-time.After(2 * time.Second): t.Fatal("the queued walk never got the lock") } }