-
Notifications
You must be signed in to change notification settings - Fork 370
fix (cache): Migrate cache S3 manager to AWS transfermanager #4118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
0712816
a8dd30d
b969269
488f804
56e0d3b
0441f48
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,25 +4,23 @@ import ( | |
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "log/slog" | ||
| "net/http" | ||
| "net/url" | ||
| "os" | ||
| "path" | ||
| "strconv" | ||
| "strings" | ||
| "sync/atomic" | ||
| "time" | ||
|
|
||
| "github.com/aws/aws-sdk-go-v2/aws" | ||
| awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" | ||
| "github.com/aws/aws-sdk-go-v2/config" | ||
| "github.com/aws/aws-sdk-go-v2/feature/s3/manager" | ||
| "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager" | ||
| tmtypes "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager/types" | ||
| "github.com/aws/aws-sdk-go-v2/service/s3" | ||
| "github.com/aws/aws-sdk-go-v2/service/s3/types" | ||
| smithy "github.com/aws/smithy-go" | ||
| smithymiddleware "github.com/aws/smithy-go/middleware" | ||
| "github.com/buildkite/agent/v3/internal/cache/internal/trace" | ||
| "github.com/buildkite/roko" | ||
| "go.opentelemetry.io/otel/attribute" | ||
|
|
@@ -35,6 +33,8 @@ import ( | |
| const ( | ||
| defaultDownloadConcurrency = 32 | ||
| defaultDownloadPartSizeMB = 32 | ||
| defaultUploadConcurrency = 5 | ||
| defaultUploadPartSizeBytes = 5 * 1024 * 1024 | ||
| ) | ||
|
|
||
| // Options holds configuration for S3Blob and can be constructed from an S3 URL in a similar way to gocloud.dev | ||
|
|
@@ -106,10 +106,9 @@ func OptionsFromURL(s3url string) (*Options, error) { | |
| return opts, nil | ||
| } | ||
|
|
||
| // objectDownloader is the subset of manager.Downloader used by downloadWithRetry, | ||
| // declared so the retry loop can be tested with a fake. | ||
| // objectDownloader is the subset of *transfermanager.Client used by downloadWithRetry | ||
| type objectDownloader interface { | ||
| Download(ctx context.Context, w io.WriterAt, input *s3.GetObjectInput, options ...func(*manager.Downloader)) (int64, error) //nolint:staticcheck // SA1019: pending migration to transfermanager | ||
| DownloadObject(ctx context.Context, input *transfermanager.DownloadObjectInput, opts ...func(*transfermanager.Options)) (*transfermanager.DownloadObjectOutput, error) | ||
| } | ||
|
|
||
| // isPreconditionFailed returns true when an error is an S3 412 PreconditionFailed. | ||
|
|
@@ -143,7 +142,7 @@ func isNotFound(err error) bool { | |
| // downloadWithRetry runs the multipart download, retrying on S3 412 | ||
| // PreconditionFailed (a concurrent restore's TTL-refresh CopyObject changed the | ||
| // object's ETag and invalidated the SDK's If-Match guard). Returns bytes written. | ||
| func downloadWithRetry(ctx context.Context, r *roko.Retrier, d objectDownloader, destPath string, in *s3.GetObjectInput, opts ...func(*manager.Downloader)) (int64, error) { //nolint:staticcheck // SA1019: pending migration to transfermanager | ||
| func downloadWithRetry(ctx context.Context, r *roko.Retrier, d objectDownloader, destPath string, in *transfermanager.DownloadObjectInput) (int64, error) { | ||
| var bytesWritten int64 | ||
| err := r.DoWithContext(ctx, func(r *roko.Retrier) error { | ||
| destFile, err := os.Create(destPath) | ||
|
|
@@ -153,7 +152,11 @@ func downloadWithRetry(ctx context.Context, r *roko.Retrier, d objectDownloader, | |
| } | ||
| defer func() { _ = destFile.Close() }() | ||
|
|
||
| n, err := d.Download(ctx, destFile, in, opts...) | ||
| // Parts arrive out of order via io.WriterAt, so point the transfer at a | ||
| // freshly-truncated file on each attempt. | ||
| in.WriterAt = destFile | ||
|
|
||
| out, err := d.DownloadObject(ctx, in) | ||
| if err != nil { | ||
| if isPreconditionFailed(err) { | ||
| slog.Warn("cache download hit 412 (concurrent ETag change), retrying", | ||
|
|
@@ -164,7 +167,7 @@ func downloadWithRetry(ctx context.Context, r *roko.Retrier, d objectDownloader, | |
| return err | ||
| } | ||
|
|
||
| bytesWritten = n | ||
| bytesWritten = aws.ToInt64(out.ContentLength) | ||
| return nil | ||
| }) | ||
| return bytesWritten, err | ||
|
|
@@ -173,12 +176,13 @@ func downloadWithRetry(ctx context.Context, r *roko.Retrier, d objectDownloader, | |
| // S3Blob implements the Blob interface using AWS S3 | ||
| type S3Blob struct { | ||
| client *s3.Client | ||
| uploader *manager.Uploader //nolint:staticcheck // SA1019: pending migration to transfermanager | ||
| downloader *manager.Downloader //nolint:staticcheck // SA1019: pending migration to transfermanager | ||
| uploader *transfermanager.Client | ||
| downloader *transfermanager.Client | ||
| bucketName string | ||
| prefix string | ||
| uploadConcurrency int | ||
| downloadConcurrency int | ||
| downloadPartSize int64 | ||
| } | ||
|
|
||
| // NewS3Blob creates a new S3Blob instance using an S3 URL and prefix | ||
|
|
@@ -227,14 +231,20 @@ func NewS3Blob(ctx context.Context, s3url string) (*S3Blob, error) { | |
| } | ||
| }) | ||
|
|
||
| // Create the uploader and downloader with their resolved settings | ||
| uploader := manager.NewUploader(client, func(u *manager.Uploader) { //nolint:staticcheck // SA1019: pending migration to transfermanager | ||
| u.Concurrency = settings.uploadConcurrency | ||
| u.PartSize = settings.uploadPartSize | ||
| // Create the transfer-manager clients with their resolved settings. Uploads | ||
| // and downloads are tuned differently, so each gets its own client. | ||
| uploader := transfermanager.New(client, func(o *transfermanager.Options) { | ||
| o.Concurrency = settings.uploadConcurrency | ||
| o.PartSizeBytes = settings.uploadPartSize | ||
| }) | ||
| downloader := manager.NewDownloader(client, func(d *manager.Downloader) { //nolint:staticcheck // SA1019: pending migration to transfermanager | ||
| d.Concurrency = settings.downloadConcurrency | ||
| d.PartSize = settings.downloadPartSize | ||
| downloader := transfermanager.New(client, func(o *transfermanager.Options) { | ||
| o.Concurrency = settings.downloadConcurrency | ||
| o.PartSizeBytes = settings.downloadPartSize | ||
| // Use Range-based fan-out rather than partNumber-based. The SDK default | ||
| // (PART) only fans out for objects uploaded as multipart; Ranges work on any object | ||
| // regardless of how it was uploaded, so restore parallelism | ||
| // is determined by our config (Concurrency × PartSizeBytes), not by upload history | ||
|
zhming0 marked this conversation as resolved.
|
||
| o.GetObjectType = tmtypes.GetObjectRanges | ||
| }) | ||
|
|
||
| slog.Debug("configured S3 transfer manager", | ||
|
|
@@ -253,6 +263,7 @@ func NewS3Blob(ctx context.Context, s3url string) (*S3Blob, error) { | |
| prefix: opts.Prefix, | ||
| uploadConcurrency: settings.uploadConcurrency, | ||
| downloadConcurrency: settings.downloadConcurrency, | ||
| downloadPartSize: settings.downloadPartSize, | ||
| }, nil | ||
| } | ||
|
|
||
|
|
@@ -271,14 +282,14 @@ type transferSettings struct { | |
| // downloads; otherwise uploads use the SDK's upload defaults and downloads use | ||
| // the download-tuned defaults. | ||
| func resolveTransferSettings(opts *Options) transferSettings { | ||
| uploadConcurrency := manager.DefaultUploadConcurrency | ||
| uploadConcurrency := defaultUploadConcurrency | ||
| downloadConcurrency := defaultDownloadConcurrency | ||
| if opts.Concurrency > 0 { | ||
| uploadConcurrency = opts.Concurrency | ||
| downloadConcurrency = opts.Concurrency | ||
| } | ||
|
|
||
| uploadPartSize := manager.DefaultUploadPartSize | ||
| uploadPartSize := int64(defaultUploadPartSizeBytes) | ||
| downloadPartSize := int64(defaultDownloadPartSizeMB) * 1024 * 1024 | ||
| if opts.PartSizeMB > 0 { | ||
| uploadPartSize = int64(opts.PartSizeMB) * 1024 * 1024 | ||
|
Comment on lines
303
to
-284
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, can you help me understand again (using your words) why we need to set different concurrency and part size for upload and download? I did read the buildsworth thread, that doesn't quite make sense to my brain :g_thinking:.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. save and restore have differing memory behaviours:
I very much agree with the generic names for concurrency/part_size_mb, are misleading, so I propose we could do one of these:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I can see that the buffer behave a bit differently, but why does the the configuration need to differ I wonder? I read the other thread it's a bit avoiding excessive memory? But does the upload memory budget setting cap the memory?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the reason why the configs need to be different is because the numbers that make restore fast are exactly the ones that make save allocate big chunks (gbs), so a single shared value can't serve both! it's either too small (slow restore) or too large (save OOM). Restore streams, so memory is flat regardless of part size/concurrency - the best config is high concurrency + large parts (c32/p32) purely for speed. Save buffers, so memory = (concurrency+1) × partSize - the best config is low concurrency + small parts to stay bounded. More on this here - https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html and https://github.com/aws/aws-sdk-go-v2/tree/50fd8e78e0780bc66f360bc50d59aa314d164d54/feature/s3/transfermanager does that make sense? |
||
|
|
@@ -327,8 +338,8 @@ func (b *S3Blob) Upload(ctx context.Context, filePath, key string) (*TransferInf | |
| "concurrency", b.uploadConcurrency, | ||
| ) | ||
|
|
||
| // Upload the file to S3 using the multipart uploader | ||
| result, err := b.uploader.Upload(ctx, &s3.PutObjectInput{ //nolint:staticcheck // SA1019: pending migration to transfermanager | ||
| // Upload the file to S3 using the multipart transfer manager | ||
| result, err := b.uploader.UploadObject(ctx, &transfermanager.UploadObjectInput{ | ||
| Bucket: aws.String(b.bucketName), | ||
| Key: aws.String(fullKey), | ||
| Body: file, | ||
|
|
@@ -345,7 +356,7 @@ func (b *S3Blob) Upload(ctx context.Context, filePath, key string) (*TransferInf | |
| } | ||
|
|
||
| // Extract request ID from the upload result (only set for multipart uploads) | ||
| requestID := result.UploadID | ||
| requestID := aws.ToString(result.UploadID) | ||
|
|
||
| duration := time.Since(start) | ||
| averageSpeed := calculateTransferSpeedMBps(bytesWritten, duration) | ||
|
|
@@ -401,30 +412,16 @@ func (b *S3Blob) Download(ctx context.Context, key, destPath string) (*TransferI | |
| "concurrency", b.downloadConcurrency, | ||
| ) | ||
|
|
||
| var partCount atomic.Int32 | ||
|
|
||
| // Download the file from S3 using parallel range requests, retrying on a | ||
| // 412 PreconditionFailed caused by a concurrent restore's ETag change. | ||
| retrier := roko.NewRetrier( | ||
| roko.WithMaxAttempts(3), | ||
| roko.WithStrategy(roko.ExponentialSubsecond(200*time.Millisecond)), | ||
| roko.WithJitterRange(0, 250*time.Millisecond), | ||
| ) | ||
| bytesWritten, err := downloadWithRetry(ctx, retrier, b.downloader, destPath, &s3.GetObjectInput{ | ||
| bytesWritten, err := downloadWithRetry(ctx, retrier, b.downloader, destPath, &transfermanager.DownloadObjectInput{ | ||
| Bucket: aws.String(b.bucketName), | ||
| Key: aws.String(fullKey), | ||
| }, func(d *manager.Downloader) { //nolint:staticcheck // SA1019: pending migration to transfermanager | ||
| d.ClientOptions = append(d.ClientOptions, func(o *s3.Options) { | ||
| o.APIOptions = append(o.APIOptions, func(stack *smithymiddleware.Stack) error { | ||
| return stack.Initialize.Add(smithymiddleware.InitializeMiddlewareFunc( | ||
| "PartCounter", | ||
| func(ctx context.Context, in smithymiddleware.InitializeInput, next smithymiddleware.InitializeHandler) (smithymiddleware.InitializeOutput, smithymiddleware.Metadata, error) { | ||
| partCount.Add(1) | ||
| return next.HandleInitialize(ctx, in) | ||
| }, | ||
| ), smithymiddleware.Before) | ||
| }) | ||
| }) | ||
| }) | ||
| if err != nil { | ||
| if isNotFound(err) { | ||
|
|
@@ -433,10 +430,12 @@ func (b *S3Blob) Download(ctx context.Context, key, destPath string) (*TransferI | |
| return nil, fmt.Errorf("failed to download file from S3: %w", err) | ||
| } | ||
|
|
||
| // Get actual part count from interceptor | ||
| actualPartCount := int(partCount.Load()) | ||
| if actualPartCount == 0 { | ||
| actualPartCount = 1 | ||
| // transfermanager doesn't report how many ranges it fetched, and an object's | ||
| // PartsCount reflects its original multipart upload, not the restore fan-out. | ||
| // For range-based downloads the range count is ceil(bytes / partSize). | ||
| actualPartCount := 1 | ||
| if b.downloadPartSize > 0 && bytesWritten > 0 { | ||
| actualPartCount = int((bytesWritten + b.downloadPartSize - 1) / b.downloadPartSize) | ||
| } | ||
|
|
||
| duration := time.Since(start) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.