Skip to content

Commit 533e740

Browse files
committed
fix(cache): Write files atomically and mutex cache reads
Changes: 1. **Atomic writes (fixes read-while-writing).** `FileStorage.Create` now returns a new `CacheWriter` interface (`io.Writer` + `Commit()` + `Close()`). `StoreInPath.Create` writes to a temp file (`os.CreateTemp` in the same dir) and only `os.Rename`s it into place on `Commit()`. Since rename is atomic on the same filesystem, readers see either the complete old file or the complete new file — never a partial one. Closing without committing discards the temp file, so failed/partial downloads leave no garbage. 2. **Ref-counted keyed mutex (fixes write/write + dedups downloads).** `Cache` gained a `map[string]*keyLock` guarded by a `sync.Mutex`. `lockKey(filename)` serializes work per-artifact and cleans up the entry once no goroutine holds or waits on it. On a cache miss, only one goroutine downloads; others block, then serve the freshly cached file. 3. **Rewrote `ProxyDownload`** into: verify `RepoHead` → serve-from-cache fast path → acquire per-file lock → re-check cache → download-to-cache → serve → fall back to direct proxy if caching is unavailable. `RepoHead` now runs up front on every path, so cached content is never served without an access check. Prompt: > Do we need a mutex or similar to protect against two go routines writing to the same file and/or reading from a file while writing in `ProxyDownload`?
1 parent c03de14 commit 533e740

2 files changed

Lines changed: 410 additions & 42 deletions

File tree

services/modules/cache.go

Lines changed: 169 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"fmt"
1010
"io"
1111
"os"
12+
"path/filepath"
13+
"sync"
1214
"time"
1315
)
1416

@@ -19,11 +21,29 @@ type KeyValueStore interface {
1921

2022
type FileStorage interface {
2123
Open(filename string) (io.ReadCloser, error)
22-
Create(filename string) (io.WriteCloser, error)
24+
Create(filename string) (CacheWriter, error)
25+
}
26+
27+
// CacheWriter is a handle to a cache entry that is being written. The written
28+
// content only becomes visible to Open after a successful Commit; closing
29+
// without committing discards it, so readers never observe partial writes.
30+
type CacheWriter interface {
31+
io.Writer
32+
// Commit atomically publishes the written content to readers.
33+
Commit() error
34+
// Close releases resources, discarding any uncommitted content.
35+
io.Closer
2336
}
2437

2538
func NewCache(r Repository, s KeyValueStore, f FileStorage, l Logger, authDisabled bool) *Cache {
26-
return &Cache{f, l, r, s, authDisabled}
39+
return &Cache{
40+
files: f,
41+
log: l,
42+
repo: r,
43+
store: s,
44+
authDisabled: authDisabled,
45+
locks: make(map[string]*keyLock),
46+
}
2747
}
2848

2949
type Cache struct {
@@ -32,6 +52,11 @@ type Cache struct {
3252
repo Repository
3353
store KeyValueStore
3454
authDisabled bool
55+
56+
// mu guards locks, the set of per-filename locks used to coalesce
57+
// concurrent downloads of the same artifact.
58+
mu sync.Mutex
59+
locks map[string]*keyLock
3560
}
3661

3762
// RepoHead is used to check if we can access the repository in a cheap way.
@@ -67,42 +92,109 @@ func (c *Cache) ListVersions(ctx context.Context, owner, repo, module string) ([
6792
func (c *Cache) ProxyDownload(ctx context.Context, owner, repo, module, version string, w io.Writer) error {
6893
filename := fmt.Sprintf("%s-%s-%s-%s.tar.gz", owner, repo, module, version)
6994

70-
r, err := c.files.Open(filename)
71-
if err == nil {
72-
// If we were able to open the cached file without any errors,
73-
// we can just copy it to the response and return.
74-
defer closer(r, c.log, "failed to close read cached file")
75-
76-
// But first we need to verify repository access before writing any
77-
// cached content to the response, otherwise we may leak the cached
78-
// file to callers who do not have access to the repository.
79-
if err := c.RepoHead(ctx, owner, repo); err != nil {
80-
return err
81-
}
95+
// Verify repository access before writing any bytes, so we never leak
96+
// cached content to callers who do not have access to the repository.
97+
if err := c.RepoHead(ctx, owner, repo); err != nil {
98+
return err
99+
}
82100

83-
if _, err := io.Copy(w, r); err != nil {
84-
c.log.Error("failed to copy cached file", "err", err)
85-
return err
86-
}
101+
// Fast path: serve directly from the cache when the file already exists.
102+
if served, err := c.serveCached(filename, w); served {
103+
return err
104+
}
87105

88-
// At this point we have copied the cached file, so we are done.
89-
return nil
106+
// Cache miss: take a per-filename lock so that only one goroutine downloads
107+
// and writes a given artifact at a time. Concurrent callers block here and
108+
// then serve the freshly cached file below, avoiding duplicate downloads
109+
// and interleaved writes to the same file.
110+
unlock := c.lockKey(filename)
111+
defer unlock()
112+
113+
// Another goroutine may have populated the cache while we waited.
114+
if served, err := c.serveCached(filename, w); served {
115+
return err
116+
}
117+
118+
if err := c.cacheDownload(ctx, filename, owner, repo, module, version); err != nil {
119+
return err
120+
}
121+
122+
if served, err := c.serveCached(filename, w); served {
123+
return err
90124
}
91125

92-
// If we failed to open the cached file, we'll just continue
93-
// and then re-download it from the re pository as usual.
94-
c.log.Info("failed to open cached file", "err", err)
126+
// Caching is unavailable (e.g. we failed to create the file); fall back to
127+
// proxying the download directly from the repository.
128+
return c.repo.ProxyDownload(ctx, owner, repo, module, version, w)
129+
}
130+
131+
// serveCached copies a cached file to w when it exists. It returns true if the
132+
// file was found (and thus handled), along with any error from copying.
133+
// Callers must have verified repository access before calling.
134+
func (c *Cache) serveCached(filename string, w io.Writer) (bool, error) {
135+
r, err := c.files.Open(filename)
136+
if err != nil {
137+
return false, nil
138+
}
139+
defer closer(r, c.log, "failed to close read cached file")
140+
141+
if _, err := io.Copy(w, r); err != nil {
142+
c.log.Error("failed to copy cached file", "err", err)
143+
return true, err
144+
}
95145

146+
return true, nil
147+
}
148+
149+
// cacheDownload downloads the artifact from the repository into the cache. The
150+
// content is written to a temporary file and only published on success, so a
151+
// failed or partial download never becomes visible to readers. A failure to
152+
// create the cache file is not fatal: it is logged and nil is returned so the
153+
// caller can fall back to a direct proxy download.
154+
func (c *Cache) cacheDownload(ctx context.Context, filename, owner, repo, module, version string) error {
96155
cw, err := c.files.Create(filename)
97156
if err != nil {
98-
// If we fail to create a cache file, we'll just proxy download directly
99-
// from the repository without caching.
100157
c.log.Error("failed to create cached file", "err", err)
101-
} else {
102-
defer closer(cw, c.log, "failed to close created cached file")
103-
w = io.MultiWriter(w, cw)
158+
return nil
159+
}
160+
defer closer(cw, c.log, "failed to close created cached file")
161+
162+
if err := c.repo.ProxyDownload(ctx, owner, repo, module, version, cw); err != nil {
163+
return err
164+
}
165+
return cw.Commit()
166+
}
167+
168+
// keyLock is a mutex with a reference count, allowing unused entries to be
169+
// removed from the Cache lock map once no goroutine holds or waits on them.
170+
type keyLock struct {
171+
mu sync.Mutex
172+
ref int
173+
}
174+
175+
// lockKey acquires the lock associated with key, creating it if necessary, and
176+
// returns a function that releases it and cleans up the entry when idle.
177+
func (c *Cache) lockKey(key string) func() {
178+
c.mu.Lock()
179+
kl, ok := c.locks[key]
180+
if !ok {
181+
kl = &keyLock{}
182+
c.locks[key] = kl
183+
}
184+
kl.ref++
185+
c.mu.Unlock()
186+
187+
kl.mu.Lock()
188+
return func() {
189+
kl.mu.Unlock()
190+
191+
c.mu.Lock()
192+
kl.ref--
193+
if kl.ref == 0 {
194+
delete(c.locks, key)
195+
}
196+
c.mu.Unlock()
104197
}
105-
return c.repo.ProxyDownload(ctx, owner, repo, module, version, w)
106198
}
107199

108200
// StoreInPath implements the FileStorage interface by storing files locally on
@@ -114,14 +206,60 @@ func (s StoreInPath) Open(filename string) (io.ReadCloser, error) {
114206
return os.Open(s.path(filename))
115207
}
116208

117-
func (s StoreInPath) Create(filename string) (io.WriteCloser, error) {
118-
return os.Create(s.path(filename))
209+
func (s StoreInPath) Create(filename string) (CacheWriter, error) {
210+
final := s.path(filename)
211+
f, err := os.CreateTemp(string(s), filepath.Base(filename)+".tmp-*")
212+
if err != nil {
213+
return nil, err
214+
}
215+
216+
return &atomicFile{file: f, finalPath: final}, nil
119217
}
120218

121219
func (s StoreInPath) path(filename string) string {
122220
return fmt.Sprintf("%s/%s", s, filename)
123221
}
124222

223+
// atomicFile writes to a temporary file and only moves it into its final
224+
// location on Commit, so concurrent readers never observe a partial file.
225+
// Closing before Commit discards the temporary file.
226+
type atomicFile struct {
227+
file *os.File
228+
finalPath string
229+
committed bool
230+
}
231+
232+
func (a *atomicFile) Write(p []byte) (int, error) {
233+
return a.file.Write(p)
234+
}
235+
236+
func (a *atomicFile) Commit() error {
237+
if err := a.file.Sync(); err != nil {
238+
return err
239+
}
240+
241+
if err := a.file.Close(); err != nil {
242+
return err
243+
}
244+
245+
if err := os.Rename(a.file.Name(), a.finalPath); err != nil {
246+
return err
247+
}
248+
249+
a.committed = true
250+
return nil
251+
}
252+
253+
func (a *atomicFile) Close() error {
254+
if a.committed {
255+
return nil
256+
}
257+
258+
// Discard the temporary file when it was never committed.
259+
_ = a.file.Close()
260+
return os.Remove(a.file.Name())
261+
}
262+
125263
// closer simply closes the closer and logs any errors.
126264
func closer(c io.Closer, log Logger, msg string) {
127265
if err := c.Close(); err != nil {

0 commit comments

Comments
 (0)