Skip to content

Commit 91047cc

Browse files
retlehsclaude
andcommitted
Copy unchanged per-release files instead of re-uploading
Per-release p2/ files that haven't changed between builds are now copied server-side via CopyObject from the previous release prefix instead of being re-uploaded from disk. This reduces R2 operations from ~61k uploads to ~61k cheap copies + only changed files uploaded. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 34d6a8e commit 91047cc

File tree

2 files changed

+89
-23
lines changed

2 files changed

+89
-23
lines changed

cmd/wpcomposer/cmd/deploy.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ func secondsPtrSince(start time.Time) *int {
2525
return &v
2626
}
2727

28-
func syncToR2Timed(cmd *cobra.Command, buildDir, buildID, previousBuildDir string) error {
28+
func syncToR2Timed(cmd *cobra.Command, buildDir, buildID, previousBuildID, previousBuildDir string) error {
2929
started := time.Now()
30-
err := deploy.SyncToR2(cmd.Context(), application.Config.R2, buildDir, buildID, previousBuildDir, application.Logger)
30+
err := deploy.SyncToR2(cmd.Context(), application.Config.R2, buildDir, buildID, previousBuildID, previousBuildDir, application.Logger)
3131
deployR2SyncSeconds = secondsPtrSince(started)
3232
if err != nil {
3333
return fmt.Errorf("R2 sync failed: %w", err)
@@ -42,7 +42,7 @@ func runDeploy(cmd *cobra.Command, args []string) error {
4242
repoDir := filepath.Join("storage", "repository")
4343
cleanup, _ := cmd.Flags().GetBool("cleanup")
4444
toR2, _ := cmd.Flags().GetBool("to-r2")
45-
previousBuildDir := previousBuildDirFor(repoDir)
45+
previousBuildID, previousBuildDir := previousBuildFor(repoDir)
4646

4747
r2Cleanup, _ := cmd.Flags().GetBool("r2-cleanup")
4848
retainCount, _ := cmd.Flags().GetInt("retain")
@@ -99,7 +99,7 @@ func runDeploy(cmd *cobra.Command, args []string) error {
9999

100100
// Sync to R2 first, then promote locally
101101
if toR2 || application.Config.R2.Enabled {
102-
if err := syncToR2Timed(cmd, buildDir, target, previousBuildDir); err != nil {
102+
if err := syncToR2Timed(cmd, buildDir, target, previousBuildID, previousBuildDir); err != nil {
103103
return err
104104
}
105105
}
@@ -130,7 +130,7 @@ func runDeploy(cmd *cobra.Command, args []string) error {
130130

131131
// Sync to R2 first, then promote locally
132132
if toR2 || application.Config.R2.Enabled {
133-
if err := syncToR2Timed(cmd, buildDir, buildID, previousBuildDir); err != nil {
133+
if err := syncToR2Timed(cmd, buildDir, buildID, previousBuildID, previousBuildDir); err != nil {
134134
return err
135135
}
136136
}
@@ -142,23 +142,23 @@ func runDeploy(cmd *cobra.Command, args []string) error {
142142
return nil
143143
}
144144

145-
// previousBuildDirFor returns the build directory for the currently promoted
145+
// previousBuildFor returns the build ID and directory for the currently promoted
146146
// build, but only if that build was previously synced to R2 (has r2_synced_at).
147-
// Returns "" if no build is promoted or the promoted build was never R2-synced.
148-
// This prevents skipping shared p/ uploads for builds that only exist locally.
149-
func previousBuildDirFor(repoDir string) string {
147+
// Returns ("", "") if no build is promoted or the promoted build was never R2-synced.
148+
// This prevents skipping uploads for builds that only exist locally.
149+
func previousBuildFor(repoDir string) (string, string) {
150150
id, _ := deploy.CurrentBuildID(repoDir)
151151
if id == "" {
152-
return ""
152+
return "", ""
153153
}
154154
var synced *string
155155
err := application.DB.QueryRow(
156156
`SELECT r2_synced_at FROM builds WHERE id = ?`, id,
157157
).Scan(&synced)
158158
if err != nil || synced == nil {
159-
return ""
159+
return "", ""
160160
}
161-
return deploy.BuildDirFromID(repoDir, id)
161+
return id, deploy.BuildDirFromID(repoDir, id)
162162
}
163163

164164
func recordR2Sync(cmd *cobra.Command, buildID string) {

internal/deploy/r2.go

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)