| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "sync" |
| 5 | "testing" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | // The property the walks reason from: two holders of one store's lock never |
| 10 | // run at once. Asserted by counting concurrent occupancy rather than by |
| 11 | // timing, so the test fails on a broken lock rather than on a slow machine. |
| 12 | func TestStoreWalkLocksExcludePerApp(t *testing.T) { |
| 13 | l := newStoreWalkLocks() |
| 14 | var mu sync.Mutex |
| 15 | live, maxLive := 0, 0 |
| 16 | |
| 17 | var wg sync.WaitGroup |
| 18 | for i := 0; i < 8; i++ { |
| 19 | wg.Add(1) |
| 20 | go func() { |
| 21 | defer wg.Done() |
| 22 | defer l.lock("bab", "titogo", "")() |
| 23 | mu.Lock() |
| 24 | live++ |
| 25 | if live > maxLive { |
| 26 | maxLive = live |
| 27 | } |
| 28 | mu.Unlock() |
| 29 | time.Sleep(time.Millisecond) |
| 30 | mu.Lock() |
| 31 | live-- |
| 32 | mu.Unlock() |
| 33 | }() |
| 34 | } |
| 35 | wg.Wait() |
| 36 | if maxLive != 1 { |
| 37 | t.Fatalf("max concurrent holders of one app's walk lock = %d, want 1", maxLive) |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | // Two apps share no estate — one bucket and one IAM user each, both named for |
| 42 | // the (account, app) pair — so their walks must not queue behind each other. |
| 43 | func TestStoreWalkLocksDoNotSerialiseAcrossApps(t *testing.T) { |
| 44 | l := newStoreWalkLocks() |
| 45 | release := l.lock("bab", "titogo", "") |
| 46 | defer release() |
| 47 | |
| 48 | done := make(chan struct{}) |
| 49 | go func() { |
| 50 | l.lock("bab", "other", "")() |
| 51 | close(done) |
| 52 | }() |
| 53 | select { |
| 54 | case <-done: |
| 55 | case <-time.After(2 * time.Second): |
| 56 | t.Fatal("a walk on another app blocked behind this one's lock") |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // Two STORES of one app share no estate either (2026-09-19): a bucket, an IAM |
| 61 | // user and a config bundle each, all named for the (account, app, |
| 62 | // environment) triple. An environment store's walk must not queue behind the |
| 63 | // app store's, or behind another environment's. |
| 64 | func TestStoreWalkLocksDoNotSerialiseAcrossEnvironments(t *testing.T) { |
| 65 | l := newStoreWalkLocks() |
| 66 | release := l.lock("bab", "titogo", "") |
| 67 | defer release() |
| 68 | |
| 69 | done := make(chan struct{}) |
| 70 | go func() { |
| 71 | l.lock("bab", "titogo", "beta")() |
| 72 | l.lock("bab", "titogo", "alpha")() |
| 73 | close(done) |
| 74 | }() |
| 75 | select { |
| 76 | case <-done: |
| 77 | case <-time.After(2 * time.Second): |
| 78 | t.Fatal("a walk on another store of the same app blocked behind this one's lock") |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // An app walked once must not hold a mutex for the life of the process. |
| 83 | func TestStoreWalkLocksDropEntriesAtZero(t *testing.T) { |
| 84 | l := newStoreWalkLocks() |
| 85 | l.lock("bab", "titogo", "")() |
| 86 | |
| 87 | l.mu.Lock() |
| 88 | n := len(l.m) |
| 89 | l.mu.Unlock() |
| 90 | if n != 0 { |
| 91 | t.Fatalf("locks map holds %d entries after the last release, want 0", n) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | // Holding one app's lock while a second waiter is queued must keep the entry |
| 96 | // alive — dropping it at the wrong moment would hand the waiter a different |
| 97 | // mutex and silently defeat the exclusion above. |
| 98 | func TestStoreWalkLocksKeepEntryWhileWaitersQueue(t *testing.T) { |
| 99 | l := newStoreWalkLocks() |
| 100 | first := l.lock("bab", "titogo", "") |
| 101 | |
| 102 | queued := make(chan func(), 1) |
| 103 | go func() { queued <- l.lock("bab", "titogo", "") }() |
| 104 | |
| 105 | // Give the waiter time to register its reference before we look. |
| 106 | deadline := time.Now().Add(2 * time.Second) |
| 107 | for { |
| 108 | l.mu.Lock() |
| 109 | e := l.m[storeWalkKey("bab", "titogo", "")] |
| 110 | refs := 0 |
| 111 | if e != nil { |
| 112 | refs = e.refs |
| 113 | } |
| 114 | l.mu.Unlock() |
| 115 | if refs == 2 { |
| 116 | break |
| 117 | } |
| 118 | if time.Now().After(deadline) { |
| 119 | t.Fatalf("waiter never registered: refs = %d", refs) |
| 120 | } |
| 121 | time.Sleep(time.Millisecond) |
| 122 | } |
| 123 | |
| 124 | first() |
| 125 | (<-queued)() |
| 126 | |
| 127 | l.mu.Lock() |
| 128 | n := len(l.m) |
| 129 | l.mu.Unlock() |
| 130 | if n != 0 { |
| 131 | t.Fatalf("locks map holds %d entries after both released, want 0", n) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // Except where the names collide (#218): app "shop" with environment "eu" and |
| 136 | // app "shop-eu" resolve to one bucket and one IAM user, so their walks must |
| 137 | // queue. The name claims refuse the second grant either way; the lock keeps |
| 138 | // the two from interleaving their reads of each other's records. |
| 139 | func TestStoreWalkLocksSerialiseCollidingStores(t *testing.T) { |
| 140 | l := newStoreWalkLocks() |
| 141 | release := l.lock("bab", "shop-eu", "") |
| 142 | |
| 143 | acquired := make(chan struct{}) |
| 144 | go func() { |
| 145 | l.lock("bab", "shop", "eu")() |
| 146 | close(acquired) |
| 147 | }() |
| 148 | select { |
| 149 | case <-acquired: |
| 150 | t.Fatal("a walk on a store whose names collide with this one's did not queue behind its lock") |
| 151 | case <-time.After(50 * time.Millisecond): |
| 152 | } |
| 153 | release() |
| 154 | select { |
| 155 | case <-acquired: |
| 156 | case <-time.After(2 * time.Second): |
| 157 | t.Fatal("the queued walk never got the lock") |
| 158 | } |
| 159 | } |
| 160 | |