test(ci): cover the bounds of the refs cache
- commit
- d0074e3f0214a1bfab187e970e1c873b547b9af7
- author
- brice · · 2026-10-17 19:43 UTC
- parent
- de33daa
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email - refs
- feat/action-refs-cache
1 file · +44 -0
M internal/usecase/ci_actions_test.go +44 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "strings" | |
| 9 | + | "sync" | |
| 9 | 10 | "testing" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| @@ -129,6 +130,49 @@ func TestRemoteRefsCache(t *testing.T) { | |||
| 129 | 130 | assert.Equal(t, 6, remote.calls, "a missing repository is not cached") | |
| 130 | 131 | } | |
| 131 | 132 | ||
| 133 | + | // A stale list stands in without becoming younger, the oldest repository makes | |
| 134 | + | // room for a new one, and the cache is safe for jobs resolving at once. | |
| 135 | + | func TestRefsCacheBounds(t *testing.T) { | |
| 136 | + | v4 := []ports.RemoteRef{{Name: "refs/tags/v4", ID: strings.Repeat("a", 40)}} | |
| 137 | + | now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) | |
| 138 | + | remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { return v4, nil }} | |
| 139 | + | c := &CI{cfg: CIConfig{Remote: remote}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()} | |
| 140 | + | list := func(repo string) error { | |
| 141 | + | r := c.newRemoteRefs(1) | |
| 142 | + | r.now = func() time.Time { return now } | |
| 143 | + | _, err := r.list(context.Background(), "actions", repo) | |
| 144 | + | return err | |
| 145 | + | } | |
| 146 | + | require.NoError(t, list("checkout")) | |
| 147 | + | remote.answer = func() ([]ports.RemoteRef, error) { return nil, domain.ErrUnavailable } | |
| 148 | + | now = now.Add(refsStale - time.Hour) | |
| 149 | + | require.NoError(t, list("checkout"), "stands in") | |
| 150 | + | now = now.Add(time.Hour) | |
| 151 | + | require.ErrorIs(t, list("checkout"), domain.ErrUnavailable, "standing in did not make it younger") | |
| 152 | + | ||
| 153 | + | cache := newRefsCache() | |
| 154 | + | for i := range refsMax { | |
| 155 | + | cache.put(fmt.Sprintf("o/r%d", i), v4, now.Add(time.Duration(i)*time.Second)) | |
| 156 | + | } | |
| 157 | + | cache.put("o/new", v4, now.Add(time.Hour)) | |
| 158 | + | _, ok := cache.stale("o/r0", now) | |
| 159 | + | assert.False(t, ok, "the oldest is evicted") | |
| 160 | + | _, ok = cache.stale("o/r1", now.Add(time.Second)) | |
| 161 | + | assert.True(t, ok) | |
| 162 | + | assert.Len(t, cache.m, refsMax) | |
| 163 | + | ||
| 164 | + | var wg sync.WaitGroup | |
| 165 | + | for i := range 8 { | |
| 166 | + | wg.Go(func() { | |
| 167 | + | key := fmt.Sprintf("o/r%d", i%3) | |
| 168 | + | cache.put(key, v4, now) | |
| 169 | + | cache.get(key, now) | |
| 170 | + | cache.stale(key, now) | |
| 171 | + | }) | |
| 172 | + | } | |
| 173 | + | wg.Wait() | |
| 174 | + | } | |
| 175 | + | ||
| 132 | 176 | // An unanswered ls-remote fails the call with the action named. | |
| 133 | 177 | func TestResolveActionNamesTheActionGitHubDidNotAnswer(t *testing.T) { | |
| 134 | 178 | remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "context" | 6 | "context" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "strings" | 8 | "strings" | ||
| 9 | + | "sync" | |||
| 9 | "testing" | 10 | "testing" | ||
| 10 | "time" | 11 | "time" | ||
| 11 | 12 | ||||
| @@ -129,6 +130,49 @@ func TestRemoteRefsCache(t *testing.T) { | |||||
| 129 | assert.Equal(t, 6, remote.calls, "a missing repository is not cached") | 130 | assert.Equal(t, 6, remote.calls, "a missing repository is not cached") | ||
| 130 | } | 131 | } | ||
| 131 | 132 | ||||
| 133 | + | // A stale list stands in without becoming younger, the oldest repository makes | |||
| 134 | + | // room for a new one, and the cache is safe for jobs resolving at once. | |||
| 135 | + | func TestRefsCacheBounds(t *testing.T) { | |||
| 136 | + | v4 := []ports.RemoteRef{{Name: "refs/tags/v4", ID: strings.Repeat("a", 40)}} | |||
| 137 | + | now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) | |||
| 138 | + | remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { return v4, nil }} | |||
| 139 | + | c := &CI{cfg: CIConfig{Remote: remote}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()} | |||
| 140 | + | list := func(repo string) error { | |||
| 141 | + | r := c.newRemoteRefs(1) | |||
| 142 | + | r.now = func() time.Time { return now } | |||
| 143 | + | _, err := r.list(context.Background(), "actions", repo) | |||
| 144 | + | return err | |||
| 145 | + | } | |||
| 146 | + | require.NoError(t, list("checkout")) | |||
| 147 | + | remote.answer = func() ([]ports.RemoteRef, error) { return nil, domain.ErrUnavailable } | |||
| 148 | + | now = now.Add(refsStale - time.Hour) | |||
| 149 | + | require.NoError(t, list("checkout"), "stands in") | |||
| 150 | + | now = now.Add(time.Hour) | |||
| 151 | + | require.ErrorIs(t, list("checkout"), domain.ErrUnavailable, "standing in did not make it younger") | |||
| 152 | + | ||||
| 153 | + | cache := newRefsCache() | |||
| 154 | + | for i := range refsMax { | |||
| 155 | + | cache.put(fmt.Sprintf("o/r%d", i), v4, now.Add(time.Duration(i)*time.Second)) | |||
| 156 | + | } | |||
| 157 | + | cache.put("o/new", v4, now.Add(time.Hour)) | |||
| 158 | + | _, ok := cache.stale("o/r0", now) | |||
| 159 | + | assert.False(t, ok, "the oldest is evicted") | |||
| 160 | + | _, ok = cache.stale("o/r1", now.Add(time.Second)) | |||
| 161 | + | assert.True(t, ok) | |||
| 162 | + | assert.Len(t, cache.m, refsMax) | |||
| 163 | + | ||||
| 164 | + | var wg sync.WaitGroup | |||
| 165 | + | for i := range 8 { | |||
| 166 | + | wg.Go(func() { | |||
| 167 | + | key := fmt.Sprintf("o/r%d", i%3) | |||
| 168 | + | cache.put(key, v4, now) | |||
| 169 | + | cache.get(key, now) | |||
| 170 | + | cache.stale(key, now) | |||
| 171 | + | }) | |||
| 172 | + | } | |||
| 173 | + | wg.Wait() | |||
| 174 | + | } | |||
| 175 | + | ||||
| 132 | // An unanswered ls-remote fails the call with the action named. | 176 | // An unanswered ls-remote fails the call with the action named. | ||
| 133 | func TestResolveActionNamesTheActionGitHubDidNotAnswer(t *testing.T) { | 177 | func TestResolveActionNamesTheActionGitHubDidNotAnswer(t *testing.T) { | ||
| 134 | remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { | 178 | remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { | ||