Skip to content

Commit 224ea71

Browse files
Rework apk cache into coalescing caches
There's nothing inherently wrong with the existing implementation other than that I found it to be unnecessarily complex to read. This reworks it into a generic `coalescingCache`, which seems to be applicable to the etags cache as-is as well.
1 parent 5384f6f commit 224ea71

File tree

3 files changed

+109
-117
lines changed

3 files changed

+109
-117
lines changed

pkg/apk/apk/cache.go

Lines changed: 45 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -32,66 +32,60 @@ import (
3232
"chainguard.dev/apko/pkg/paths"
3333
)
3434

35-
type flightCache[T any] struct {
36-
flight *singleflight.Group
37-
cache *sync.Map
35+
// newCoalescingCache creates a new coalescingCache.
36+
func newCoalescingCache[K comparable, V any]() *coalescingCache[K, V] {
37+
return &coalescingCache[K, V]{
38+
cache: make(map[K]func() (V, error)),
39+
}
3840
}
3941

40-
// TODO: Consider [K, V] if we need a non-string key type.
41-
func newFlightCache[T any]() *flightCache[T] {
42-
return &flightCache[T]{
43-
flight: &singleflight.Group{},
44-
cache: &sync.Map{},
45-
}
42+
// coalescingCache combines singleflight's coalescing with a cache.
43+
type coalescingCache[K comparable, V any] struct {
44+
mux sync.RWMutex
45+
cache map[K]func() (V, error)
4646
}
4747

4848
// Do returns coalesces multiple calls, like singleflight, but also caches
49-
// the result if the call is successful. Failures are not cached to avoid
50-
// permanently failing for transient errors.
51-
func (f *flightCache[T]) Do(key string, fn func() (T, error)) (T, error) {
52-
v, ok := f.cache.Load(key)
53-
if ok {
54-
if t, ok := v.(T); ok {
55-
return t, nil
56-
} else {
57-
// This can't happen but just in case things change.
58-
return t, fmt.Errorf("unexpected type %T", v)
59-
}
49+
// the result if the call is successful.
50+
// Failures are not cached to avoid permanently failing for transient errors.
51+
func (f *coalescingCache[K, V]) Do(key K, fn func() (V, error)) (V, error) {
52+
f.mux.RLock()
53+
if v, ok := f.cache[key]; ok {
54+
f.mux.RUnlock()
55+
return v()
6056
}
57+
f.mux.RUnlock()
6158

62-
v, err, _ := f.flight.Do(key, func() (interface{}, error) {
63-
if v, ok := f.cache.Load(key); ok {
64-
return v, nil
65-
}
59+
f.mux.Lock()
6660

67-
// Don't cache errors, but maybe we should.
68-
v, err := fn()
61+
// Doubly-checked-locking in case of race conditions.
62+
if v, ok := f.cache[key]; ok {
63+
f.mux.Unlock()
64+
return v()
65+
}
66+
67+
v := sync.OnceValues(func() (V, error) {
68+
ret, err := fn()
6969
if err != nil {
70-
return nil, err
70+
// We've put this value into the cache before executing it, so we need to remove it
71+
// to avoid caching errors.
72+
f.mux.Lock()
73+
delete(f.cache, key)
74+
f.mux.Unlock()
7175
}
72-
73-
f.cache.Store(key, v)
74-
75-
return v, nil
76+
return ret, err
7677
})
78+
f.cache[key] = v
79+
f.mux.Unlock()
7780

78-
t, ok := v.(T)
79-
if err != nil {
80-
return t, err
81-
}
82-
if !ok {
83-
// This can't happen but just in case things change.
84-
return t, fmt.Errorf("unexpected type %T", v)
85-
}
86-
return t, nil
81+
return v()
8782
}
8883

8984
type Cache struct {
90-
etagCache *sync.Map
91-
headFlight *singleflight.Group
92-
getFlight *singleflight.Group
85+
getFlight *singleflight.Group
9386

94-
discoverKeys *flightCache[[]Key]
87+
etags *coalescingCache[string, *http.Response]
88+
discoverKeys *coalescingCache[string, []Key]
9589
}
9690

9791
// NewCache returns a new Cache, which allows us to persist the results of HEAD requests
@@ -107,39 +101,17 @@ type Cache struct {
107101
// requests for the same resource when passing etag=false.
108102
func NewCache(etag bool) *Cache {
109103
c := &Cache{
110-
headFlight: &singleflight.Group{},
111104
getFlight: &singleflight.Group{},
112-
discoverKeys: newFlightCache[[]Key](),
105+
discoverKeys: newCoalescingCache[string, []Key](),
113106
}
114107

115108
if etag {
116-
c.etagCache = &sync.Map{}
109+
c.etags = newCoalescingCache[string, *http.Response]()
117110
}
118111

119112
return c
120113
}
121114

122-
func (c *Cache) load(cacheFile string) (*http.Response, bool) {
123-
if c == nil || c.etagCache == nil {
124-
return nil, false
125-
}
126-
127-
v, ok := c.etagCache.Load(cacheFile)
128-
if !ok {
129-
return nil, false
130-
}
131-
132-
return v.(*http.Response), true
133-
}
134-
135-
func (c *Cache) store(cacheFile string, resp *http.Response) {
136-
if c == nil || c.etagCache == nil {
137-
return
138-
}
139-
140-
c.etagCache.Store(cacheFile, resp)
141-
}
142-
143115
// cache
144116
type cache struct {
145117
dir string
@@ -209,12 +181,7 @@ func (t *cacheTransport) RoundTrip(request *http.Request) (*http.Response, error
209181
}
210182

211183
func (t *cacheTransport) head(request *http.Request, cacheFile string) (*http.Response, error) {
212-
resp, ok := t.cache.load(cacheFile)
213-
if ok {
214-
return resp, nil
215-
}
216-
217-
v, err, _ := t.cache.headFlight.Do(cacheFile, func() (interface{}, error) {
184+
fetch := func() (*http.Response, error) {
218185
req := request.Clone(request.Context())
219186
req.Method = http.MethodHead
220187
resp, err := t.wrapped.Do(req)
@@ -225,15 +192,13 @@ func (t *cacheTransport) head(request *http.Request, cacheFile string) (*http.Re
225192
// HEAD shouldn't have a body. Make sure we close it so we can reuse the connection.
226193
defer resp.Body.Close()
227194

228-
t.cache.store(cacheFile, resp)
229-
230195
return resp, nil
231-
})
232-
if err != nil {
233-
return nil, err
234196
}
235197

236-
return v.(*http.Response), nil
198+
if t.cache.etags != nil {
199+
return t.cache.etags.Do(cacheFile, fetch)
200+
}
201+
return fetch()
237202
}
238203

239204
func (t *cacheTransport) get(ctx context.Context, request *http.Request, cacheFile, initialEtag string) (string, error) {

pkg/apk/apk/cache_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright 2025 Chainguard, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package apk
16+
17+
import (
18+
"testing"
19+
20+
"github.com/stretchr/testify/assert"
21+
"github.com/stretchr/testify/require"
22+
)
23+
24+
func TestFlightCache(t *testing.T) {
25+
s := newCoalescingCache[string, int]()
26+
var called int
27+
r1, err := s.Do("test", func() (int, error) {
28+
called++
29+
return 42, nil
30+
})
31+
require.NoError(t, err)
32+
33+
r2, err := s.Do("test", func() (int, error) {
34+
called++
35+
return 1337, nil
36+
})
37+
require.NoError(t, err)
38+
39+
require.Equal(t, r1, r2)
40+
require.Equal(t, 1, called, "Function should only be called once")
41+
}
42+
43+
func TestFlightCacheCachesNoErrors(t *testing.T) {
44+
s := newCoalescingCache[string, int]()
45+
var called int
46+
_, err := s.Do("test", func() (int, error) {
47+
called++
48+
return 42, assert.AnError
49+
})
50+
require.ErrorIs(t, assert.AnError, err)
51+
52+
r2, err := s.Do("test", func() (int, error) {
53+
called++
54+
return 1337, nil
55+
})
56+
require.NoError(t, err)
57+
58+
require.Equal(t, 1337, r2)
59+
require.Equal(t, 2, called, "Function should be called twice, once for the error and once for the success")
60+
}

pkg/apk/apk/implementation.go

Lines changed: 4 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ import (
3838
"runtime"
3939
"slices"
4040
"strings"
41-
"sync"
4241
"time"
4342

4443
"github.com/hashicorp/go-retryablehttp"
@@ -63,7 +62,7 @@ import (
6362
// This is terrible but simpler than plumbing around a cache for now.
6463
// We just hold the expanded APK in memory rather than re-parsing it every time,
6564
// which is expensive. This also dedupes simultaneous fetches.
66-
var globalApkCache = &apkCache{}
65+
var globalApkCache = newCoalescingCache[string, *expandapk.APKExpanded]()
6766

6867
type APK struct {
6968
arch string
@@ -1226,40 +1225,6 @@ func (a *APK) cachedPackage(ctx context.Context, pkg InstallablePackage, cacheDi
12261225
return &exp, nil
12271226
}
12281227

1229-
type apkResult struct {
1230-
exp *expandapk.APKExpanded
1231-
err error
1232-
}
1233-
1234-
type apkCache struct {
1235-
// url -> *sync.Once
1236-
onces sync.Map
1237-
1238-
// url -> apkResult
1239-
resps sync.Map
1240-
}
1241-
1242-
func (c *apkCache) get(ctx context.Context, a *APK, pkg InstallablePackage) (*expandapk.APKExpanded, error) {
1243-
u := pkg.URL()
1244-
// Do all the expensive things inside the once.
1245-
once, _ := c.onces.LoadOrStore(u, &sync.Once{})
1246-
once.(*sync.Once).Do(func() {
1247-
exp, err := expandPackage(ctx, a, pkg)
1248-
c.resps.Store(u, apkResult{
1249-
exp: exp,
1250-
err: err,
1251-
})
1252-
})
1253-
1254-
v, ok := c.resps.Load(u)
1255-
if !ok {
1256-
panic(fmt.Errorf("did not see apk %q after writing it", u))
1257-
}
1258-
1259-
result := v.(apkResult)
1260-
return result.exp, result.err
1261-
}
1262-
12631228
func (a *APK) expandPackage(ctx context.Context, pkg InstallablePackage) (*expandapk.APKExpanded, error) {
12641229
if a.cache == nil {
12651230
// If we don't have a cache configured, don't use the global cache.
@@ -1269,7 +1234,9 @@ func (a *APK) expandPackage(ctx context.Context, pkg InstallablePackage) (*expan
12691234
return expandPackage(ctx, a, pkg)
12701235
}
12711236

1272-
return globalApkCache.get(ctx, a, pkg)
1237+
return globalApkCache.Do(pkg.URL(), func() (*expandapk.APKExpanded, error) {
1238+
return expandPackage(ctx, a, pkg)
1239+
})
12731240
}
12741241

12751242
func expandPackage(ctx context.Context, a *APK, pkg InstallablePackage) (*expandapk.APKExpanded, error) {

0 commit comments

Comments
 (0)