@@ -55,9 +55,10 @@ func isSharedFile(relPath string) bool {
5555// are stored once in a shared top-level prefix. When previousBuildDir is non-empty,
5656// shared files that also exist in the previous build are skipped (they're already
5757// on R2). Per-release files (p2/, packages.json, manifest.json) go under
58- // releases/<buildID>/. After all files are uploaded, the root packages.json is
59- // rewritten to point at the new release — the atomic pointer swap.
60- func SyncToR2 (ctx context.Context , cfg config.R2Config , buildDir , buildID , previousBuildDir string , logger * slog.Logger ) error {
58+ // releases/<buildID>/. Unchanged per-release files are copied server-side from
59+ // the previous release prefix. After all files are uploaded, the root packages.json
60+ // is rewritten to point at the new release — the atomic pointer swap.
61+ func SyncToR2 (ctx context.Context , cfg config.R2Config , buildDir , buildID , previousBuildID , previousBuildDir string , logger * slog.Logger ) error {
6162 client := newS3Client (cfg )
6263 releasePrefix := "releases/" + buildID + "/"
6364
@@ -103,8 +104,13 @@ func SyncToR2(ctx context.Context, cfg config.R2Config, buildDir, buildID, previ
103104 }
104105
105106 // Upload files (parallel, streaming from disk).
106- // Shared p/ files go to the top-level prefix; everything else under releases/<buildID>/.
107- var uploaded , skipped atomic.Int64
107+ // Shared p/ files go to the top-level prefix; per-release files under releases/<buildID>/.
108+ // Unchanged per-release files are copied server-side from the previous release.
109+ previousReleasePrefix := ""
110+ if previousBuildID != "" {
111+ previousReleasePrefix = "releases/" + previousBuildID + "/"
112+ }
113+ var uploaded , skipped , copied atomic.Int64
108114 g , gCtx := errgroup .WithContext (ctx )
109115 g .SetLimit (50 )
110116
@@ -115,8 +121,8 @@ func SyncToR2(ctx context.Context, cfg config.R2Config, buildDir, buildID, previ
115121 // Already on R2 from a previous deploy — skip entirely.
116122 if previousShared [relPath ] {
117123 n := skipped .Add (1 )
118- if (n + uploaded .Load ())% 500 == 0 {
119- logger .Info ("R2 upload progress" , "uploaded" , uploaded .Load (), "skipped" , n , "total" , total )
124+ if (n + uploaded .Load ()+ copied . Load () )% 500 == 0 {
125+ logger .Info ("R2 upload progress" , "uploaded" , uploaded .Load (), "copied" , copied . Load (), " skipped" , n , "total" , total )
120126 }
121127 return nil
122128 }
@@ -129,8 +135,20 @@ func SyncToR2(ctx context.Context, cfg config.R2Config, buildDir, buildID, previ
129135 return fmt .Errorf ("R2 sync: %w" , err )
130136 }
131137 } else {
132- // Per-release file — upload under release prefix.
133138 key := releasePrefix + relPath
139+ // If unchanged from previous build, copy server-side (no data transfer).
140+ if previousReleasePrefix != "" && fileUnchanged (previousBuildDir , buildDir , relPath ) {
141+ srcKey := previousReleasePrefix + relPath
142+ if err := copyObjectWithRetry (gCtx , client , cfg .Bucket , srcKey , key , logger ); err == nil {
143+ n := copied .Add (1 )
144+ if (n + uploaded .Load ()+ skipped .Load ())% 500 == 0 {
145+ logger .Info ("R2 upload progress" , "uploaded" , uploaded .Load (), "copied" , n , "skipped" , skipped .Load (), "total" , total )
146+ }
147+ return nil
148+ }
149+ // CopyObject failed (source missing?) — fall through to upload.
150+ }
151+ // Per-release file — upload from disk.
134152 data , err := os .ReadFile (filepath .Join (buildDir , relPath ))
135153 if err != nil {
136154 return fmt .Errorf ("reading %s: %w" , relPath , err )
@@ -140,8 +158,8 @@ func SyncToR2(ctx context.Context, cfg config.R2Config, buildDir, buildID, previ
140158 }
141159 }
142160 n := uploaded .Add (1 )
143- if (n + skipped .Load ())% 500 == 0 {
144- logger .Info ("R2 upload progress" , "uploaded" , n , "skipped" , skipped .Load (), "total" , total )
161+ if (n + skipped .Load ()+ copied . Load () )% 500 == 0 {
162+ logger .Info ("R2 upload progress" , "uploaded" , n , "copied" , copied . Load (), " skipped" , skipped .Load (), "total" , total )
145163 }
146164 return nil
147165 })
@@ -160,7 +178,7 @@ func SyncToR2(ctx context.Context, cfg config.R2Config, buildDir, buildID, previ
160178 return fmt .Errorf ("R2 sync (root packages.json): %w" , err )
161179 }
162180
163- logger .Info ("R2 sync complete" , "uploaded" , uploaded .Load (), "skipped" , skipped .Load (), "release" , releasePrefix )
181+ logger .Info ("R2 sync complete" , "uploaded" , uploaded .Load (), "copied" , copied . Load (), " skipped" , skipped .Load (), "release" , releasePrefix )
164182 return nil
165183}
166184
@@ -268,6 +286,54 @@ func putObjectWithRetry(ctx context.Context, client *s3.Client, bucket, key stri
268286 return fmt .Errorf ("uploading %s after %d attempts: %w" , key , r2MaxRetries , lastErr )
269287}
270288
289+ // copyObjectWithRetry copies a single object within R2 with exponential backoff retry.
290+ func copyObjectWithRetry (ctx context.Context , client * s3.Client , bucket , srcKey , dstKey string , logger * slog.Logger ) error {
291+ copySource := bucket + "/" + srcKey
292+ cacheControl := CacheControlForPath (dstKey )
293+
294+ var lastErr error
295+ for attempt := range r2MaxRetries {
296+ if attempt > 0 {
297+ delay := time .Duration (float64 (r2RetryBaseMs )* math .Pow (2 , float64 (attempt - 1 ))) * time .Millisecond
298+ select {
299+ case <- ctx .Done ():
300+ return ctx .Err ()
301+ case <- time .After (delay ):
302+ }
303+ }
304+
305+ _ , lastErr = client .CopyObject (ctx , & s3.CopyObjectInput {
306+ Bucket : aws .String (bucket ),
307+ CopySource : aws .String (copySource ),
308+ Key : aws .String (dstKey ),
309+ CacheControl : aws .String (cacheControl ),
310+ })
311+ if lastErr == nil {
312+ return nil
313+ }
314+ }
315+ return fmt .Errorf ("copying %s -> %s after %d attempts: %w" , srcKey , dstKey , r2MaxRetries , lastErr )
316+ }
317+
318+ // fileUnchanged returns true if relPath exists in both directories with identical content.
319+ func fileUnchanged (prevDir , curDir , relPath string ) bool {
320+ if prevDir == "" {
321+ return false
322+ }
323+ prevPath := filepath .Join (prevDir , filepath .FromSlash (relPath ))
324+ curPath := filepath .Join (curDir , filepath .FromSlash (relPath ))
325+
326+ prevData , err := os .ReadFile (prevPath )
327+ if err != nil {
328+ return false
329+ }
330+ curData , err := os .ReadFile (curPath )
331+ if err != nil {
332+ return false
333+ }
334+ return bytes .Equal (prevData , curData )
335+ }
336+
271337// CleanupR2 removes old release prefixes from R2, keeping the live release,
272338// releases within the grace period, and the top N most recent releases.
273339// It no longer depends on the local filesystem — all state is read from R2.
0 commit comments