99 "fmt"
1010 "io"
1111 "os"
12+ "path/filepath"
13+ "sync"
1214 "time"
1315)
1416
@@ -19,11 +21,29 @@ type KeyValueStore interface {
1921
2022type 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
2538func 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
2949type 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) ([
6792func (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
121219func (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.
126264func closer (c io.Closer , log Logger , msg string ) {
127265 if err := c .Close (); err != nil {
0 commit comments