detached d0074e3

test(ci): cover the bounds of the refs cache

commit
d0074e3f0214a1bfab187e970e1c873b547b9af7
author
brice · · 2026-10-17 19:43 UTC
parent
de33daa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 (
66 "context"
77 "fmt"
88 "strings"
9+ "sync"
910 "testing"
1011 "time"
1112
@@ -129,6 +130,49 @@ func TestRemoteRefsCache(t *testing.T) {
129130 assert.Equal(t, 6, remote.calls, "a missing repository is not cached")
130131 }
131132
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+
132176 // An unanswered ls-remote fails the call with the action named.
133177 func TestResolveActionNamesTheActionGitHubDidNotAnswer(t *testing.T) {
134178 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close