Skip to content

Commit 309cf72

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 309cf72

3 files changed

Lines changed: 167 additions & 16 deletions

File tree

cmd/server/main.go

Lines changed: 7 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:"1m"`
2828
} `envconfig:"CACHE_"`
2929
Github github.Config `envconfig:"GITHUB_"`
3030
Modules modules.Config `envconfig:"MODULES_"`
@@ -37,10 +37,15 @@ func main() {
3737

3838
log := slog.New(slog.NewTextHandler(os.Stdout, nil))
3939

40+
var tagCache github.TagCache
41+
if cfg.Cache.Enabled {
42+
tagCache = mcache.New[string, []string](cfg.Cache.Expiration)
43+
}
44+
4045
var repo modules.Repository
4146
repo = github.New(cfg.Github, &http.Client{
4247
Timeout: 5 * time.Second,
43-
})
48+
}, tagCache)
4449

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

pkg/github/github.go

Lines changed: 60 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,39 @@ 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+
// noopTagCache is a cache that never stores anything. It satisfies the same Get/Set
48+
// interface as Cache and can be used to disable caching
49+
type noopTagCache struct{}
50+
51+
// Get always reports a miss.
52+
func (noopTagCache) Get(string) ([]string, bool) { return nil, false }
53+
54+
// Set discards the value.
55+
func (noopTagCache) Set(string, []string, ...time.Duration) {}
56+
57+
func New(cfg Config, c HTTPClient, cache TagCache) *Service {
58+
if cache == nil {
59+
cache = noopTagCache{}
60+
}
61+
4062
return &Service{
4163
cfg: cfg,
4264
client: c,
65+
cache: cache,
4366
}
4467
}
4568

4669
type Service struct {
4770
cfg Config
4871
client HTTPClient
72+
cache TagCache
4973
}
5074

5175
// https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repository-tags
@@ -55,10 +79,34 @@ func (s *Service) ListVersions(ctx context.Context, system, repo, module string)
5579
return nil, err
5680
}
5781

82+
tags, err := s.listTags(ctx, owner, repo)
83+
if err != nil {
84+
return nil, err
85+
}
86+
87+
prefix := module + "/"
88+
versions := []string{}
89+
for _, name := range tags {
90+
if strings.HasPrefix(name, prefix) {
91+
versions = append(versions, strings.TrimPrefix(name, prefix))
92+
}
93+
}
94+
return versions, nil
95+
}
96+
97+
// listTags returns the names of all tags in the repository, fetching every page
98+
// from the GitHub API. When a cache is configured, results are cached per
99+
// repository to avoid re-paginating all tags on subsequent requests within the
100+
// cache expiration window.
101+
func (s *Service) listTags(ctx context.Context, owner, repo string) ([]string, error) {
102+
key := owner + "/" + repo
103+
if tags, ok := s.cache.Get(key); ok {
104+
return tags, nil
105+
}
106+
58107
var (
59-
page = 1
60-
prefix = module + "/"
61-
versions = []string{}
108+
page = 1
109+
tags = []string{}
62110
)
63111
for {
64112
uri := fmt.Sprintf("repos/%s/%s/tags?per_page=%d&page=%d", owner, repo, tagsPerPage, page)
@@ -67,10 +115,10 @@ func (s *Service) ListVersions(ctx context.Context, system, repo, module string)
67115
return nil, err
68116
}
69117

70-
var tags []struct {
118+
var batch []struct {
71119
Name string `json:"name"`
72120
}
73-
err = json.NewDecoder(res).Decode(&tags)
121+
err = json.NewDecoder(res).Decode(&batch)
74122
cerr := res.Close()
75123
if cerr != nil {
76124
return nil, fmt.Errorf("closing response: %w", cerr)
@@ -80,18 +128,18 @@ func (s *Service) ListVersions(ctx context.Context, system, repo, module string)
80128
return nil, fmt.Errorf("decoding response: %w", err)
81129
}
82130

83-
for _, tag := range tags {
84-
if strings.HasPrefix(tag.Name, prefix) {
85-
versions = append(versions, strings.TrimPrefix(tag.Name, prefix))
86-
}
131+
for _, tag := range batch {
132+
tags = append(tags, tag.Name)
87133
}
88134

89-
if len(tags) < tagsPerPage {
135+
if len(batch) < tagsPerPage {
90136
break
91137
}
92138
page++
93139
}
94-
return versions, nil
140+
141+
s.cache.Set(key, tags)
142+
return tags, nil
95143
}
96144

97145
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)