Skip to content

Commit 645ec13

Browse files
committed
feat: Cache paginated GitHub tags per repository
Aggregate and cache all tag pages per owner/repo in github.Service so multiple modules sharing a repo (and repeated calls within the TTL) reuse a single paginated fetch instead of re-walking every page.
1 parent 70182b1 commit 645ec13

3 files changed

Lines changed: 147 additions & 16 deletions

File tree

cmd/server/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ type config struct {
2424
Enabled bool `envconfig:"ENABLED"`
2525
AuthDisabled bool `envconfig:"AUTH_DISABLED"`
2626
Path string `envconfig:"PATH" default:"/tmp"`
27-
Expiration time.Duration `envconfig:"EXPIRATION" default:"10s"`
27+
Expiration time.Duration `envconfig:"EXPIRATION" default:"5m"`
2828
} `envconfig:"CACHE_"`
2929
Github github.Config `envconfig:"GITHUB_"`
3030
Modules modules.Config `envconfig:"MODULES_"`
@@ -40,7 +40,7 @@ func main() {
4040
var repo modules.Repository
4141
repo = github.New(cfg.Github, &http.Client{
4242
Timeout: 5 * time.Second,
43-
})
43+
}, mcache.New[string, []string](cfg.Cache.Expiration))
4444

4545
if cfg.Cache.Enabled {
4646
log.Info("enabling cache", "path", cfg.Cache.Path, "expiration", cfg.Cache.Expiration, "authDisabled", cfg.Cache.AuthDisabled)

pkg/github/github.go

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"regexp"
1717
"slices"
1818
"strings"
19+
"time"
1920

2021
"github.com/reMarkable/orbit/pkg/auth"
2122
)
@@ -36,16 +37,25 @@ type HTTPClient interface {
3637
Do(req *http.Request) (*http.Response, error)
3738
}
3839

39-
func New(cfg Config, c HTTPClient) *Service {
40+
// TagCache caches the aggregated tag names of a repository, keyed by
41+
// "owner/repo", to avoid re-paginating all tags on every request.
42+
type TagCache interface {
43+
Get(key string) ([]string, bool)
44+
Set(key string, value []string, d ...time.Duration)
45+
}
46+
47+
func New(cfg Config, c HTTPClient, cache TagCache) *Service {
4048
return &Service{
4149
cfg: cfg,
4250
client: c,
51+
cache: cache,
4352
}
4453
}
4554

4655
type Service struct {
4756
cfg Config
4857
client HTTPClient
58+
cache TagCache
4959
}
5060

5161
// https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repository-tags
@@ -55,10 +65,33 @@ func (s *Service) ListVersions(ctx context.Context, system, repo, module string)
5565
return nil, err
5666
}
5767

68+
tags, err := s.listTags(ctx, owner, repo)
69+
if err != nil {
70+
return nil, err
71+
}
72+
73+
prefix := module + "/"
74+
versions := []string{}
75+
for _, name := range tags {
76+
if strings.HasPrefix(name, prefix) {
77+
versions = append(versions, strings.TrimPrefix(name, prefix))
78+
}
79+
}
80+
return versions, nil
81+
}
82+
83+
// listTags returns the names of all tags in the repository, fetching every page
84+
// from the GitHub API. Results are cached per repository to avoid re-paginating
85+
// all tags on subsequent requests within the cache expiration window.
86+
func (s *Service) listTags(ctx context.Context, owner, repo string) ([]string, error) {
87+
key := owner + "/" + repo
88+
if tags, ok := s.cache.Get(key); ok {
89+
return tags, nil
90+
}
91+
5892
var (
59-
page = 1
60-
prefix = module + "/"
61-
versions = []string{}
93+
page = 1
94+
tags = []string{}
6295
)
6396
for {
6497
uri := fmt.Sprintf("repos/%s/%s/tags?per_page=%d&page=%d", owner, repo, tagsPerPage, page)
@@ -67,10 +100,10 @@ func (s *Service) ListVersions(ctx context.Context, system, repo, module string)
67100
return nil, err
68101
}
69102

70-
var tags []struct {
103+
var batch []struct {
71104
Name string `json:"name"`
72105
}
73-
err = json.NewDecoder(res).Decode(&tags)
106+
err = json.NewDecoder(res).Decode(&batch)
74107
cerr := res.Close()
75108
if cerr != nil {
76109
return nil, fmt.Errorf("closing response: %w", cerr)
@@ -80,18 +113,18 @@ func (s *Service) ListVersions(ctx context.Context, system, repo, module string)
80113
return nil, fmt.Errorf("decoding response: %w", err)
81114
}
82115

83-
for _, tag := range tags {
84-
if strings.HasPrefix(tag.Name, prefix) {
85-
versions = append(versions, strings.TrimPrefix(tag.Name, prefix))
86-
}
116+
for _, tag := range batch {
117+
tags = append(tags, tag.Name)
87118
}
88119

89-
if len(tags) < tagsPerPage {
120+
if len(batch) < tagsPerPage {
90121
break
91122
}
92123
page++
93124
}
94-
return versions, nil
125+
126+
s.cache.Set(key, tags)
127+
return tags, nil
95128
}
96129

97130
func (s *Service) ProxyDownload(ctx context.Context, system, repo, module, version string, w io.Writer) error {

pkg/github/github_test.go

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ import (
66
"compress/gzip"
77
"context"
88
"errors"
9+
"fmt"
910
"io"
1011
"net/http"
1112
"testing"
13+
14+
"github.com/reMarkable/orbit/pkg/mcache"
1215
)
1316

1417
type mockHTTPClient struct {
@@ -40,7 +43,7 @@ func TestService_ListVersions(t *testing.T) {
4043
cfg := Config{
4144
OrgMappings: map[string]string{"test-system": "test-org"},
4245
}
43-
service := New(cfg, mockClient)
46+
service := New(cfg, mockClient, mcache.New[string, []string](mcache.NoExpiration))
4447

4548
versions, err := service.ListVersions(context.Background(), "test-system", "test-repo", "module")
4649
if err != nil {
@@ -97,7 +100,7 @@ func TestService_ProxyDownload(t *testing.T) {
97100
cfg := Config{
98101
OrgMappings: map[string]string{"test-system": "test-org"},
99102
}
100-
service := New(cfg, mockClient)
103+
service := New(cfg, mockClient, mcache.New[string, []string](mcache.NoExpiration))
101104

102105
var buf bytes.Buffer
103106
err := service.ProxyDownload(context.Background(), "test-system", "test-repo", "module", "v1.0.0", &buf)
@@ -126,3 +129,98 @@ func TestService_ProxyDownload(t *testing.T) {
126129
t.Errorf("expected tarball to contain %q, but it was %q", expectedContent, tarBuf.Bytes())
127130
}
128131
}
132+
133+
func TestService_ListVersions_CachesTagsPerRepo(t *testing.T) {
134+
var calls int
135+
mockClient := &mockHTTPClient{
136+
doFunc: func(req *http.Request) (*http.Response, error) {
137+
if req.URL.Path == "/repos/test-org/test-repo/tags" {
138+
calls++
139+
body := `[
140+
{"name": "module/v1.0.0"},
141+
{"name": "other/v2.0.0"}
142+
]`
143+
return &http.Response{
144+
StatusCode: http.StatusOK,
145+
Body: io.NopCloser(bytes.NewReader([]byte(body))),
146+
}, nil
147+
}
148+
return nil, errors.New("unexpected request")
149+
},
150+
}
151+
152+
cfg := Config{
153+
OrgMappings: map[string]string{"test-system": "test-org"},
154+
}
155+
service := New(cfg, mockClient, mcache.New[string, []string](mcache.NoExpiration))
156+
157+
// First call for "module" should hit the API.
158+
if _, err := service.ListVersions(context.Background(), "test-system", "test-repo", "module"); err != nil {
159+
t.Fatalf("unexpected error: %v", err)
160+
}
161+
162+
// Second call for a different module in the same repo should be served from
163+
// the cache without hitting the API again.
164+
other, err := service.ListVersions(context.Background(), "test-system", "test-repo", "other")
165+
if err != nil {
166+
t.Fatalf("unexpected error: %v", err)
167+
}
168+
169+
if calls != 1 {
170+
t.Fatalf("expected tags endpoint to be called once, got %d", calls)
171+
}
172+
173+
if len(other) != 1 || other[0] != "v2.0.0" {
174+
t.Errorf("expected cached tags to yield [v2.0.0], got %v", other)
175+
}
176+
}
177+
178+
func TestService_ListVersions_Pagination(t *testing.T) {
179+
// Build a first page with exactly tagsPerPage entries to force a second request.
180+
var firstPage bytes.Buffer
181+
firstPage.WriteString("[")
182+
for i := 0; i < tagsPerPage; i++ {
183+
if i > 0 {
184+
firstPage.WriteString(",")
185+
}
186+
fmt.Fprintf(&firstPage, `{"name": "filler/v0.0.%d"}`, i)
187+
}
188+
firstPage.WriteString("]")
189+
190+
mockClient := &mockHTTPClient{
191+
doFunc: func(req *http.Request) (*http.Response, error) {
192+
if req.URL.Path != "/repos/test-org/test-repo/tags" {
193+
return nil, errors.New("unexpected request")
194+
}
195+
switch req.URL.Query().Get("page") {
196+
case "1":
197+
return &http.Response{
198+
StatusCode: http.StatusOK,
199+
Body: io.NopCloser(bytes.NewReader(firstPage.Bytes())),
200+
}, nil
201+
case "2":
202+
body := `[{"name": "module/v1.0.0"}]`
203+
return &http.Response{
204+
StatusCode: http.StatusOK,
205+
Body: io.NopCloser(bytes.NewReader([]byte(body))),
206+
}, nil
207+
default:
208+
return nil, errors.New("unexpected page")
209+
}
210+
},
211+
}
212+
213+
cfg := Config{
214+
OrgMappings: map[string]string{"test-system": "test-org"},
215+
}
216+
service := New(cfg, mockClient, mcache.New[string, []string](mcache.NoExpiration))
217+
218+
versions, err := service.ListVersions(context.Background(), "test-system", "test-repo", "module")
219+
if err != nil {
220+
t.Fatalf("unexpected error: %v", err)
221+
}
222+
223+
if len(versions) != 1 || versions[0] != "v1.0.0" {
224+
t.Errorf("expected [v1.0.0] across pages, got %v", versions)
225+
}
226+
}

0 commit comments

Comments
 (0)