Skip to content

fix (cache): Migrate cache S3 manager to AWS transfermanager - #4118

Open
ss1909 wants to merge 6 commits into
mainfrom
sneha/A-1470
Open

fix (cache): Migrate cache S3 manager to AWS transfermanager#4118
ss1909 wants to merge 6 commits into
mainfrom
sneha/A-1470

Conversation

@ss1909

@ss1909 ss1909 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Description

The agent cache S3 store used the deprecated aws-sdk-go-v2 feature/s3/manager uploader/downloader for cache save and restore. AWS has superseded that package with feature/s3/transfermanager, which the agent already uses for artifact multipart downloads. This migrates cache save/restore onto transfermanager, aligning cache transfers with the maintained API and letting us drop the manager-related //nolint:staticcheck (SA1019) suppressions from the cache package.

Restore uses explicit range-based downloads (GetObjectType = GetObjectRanges) instead of the SDK's default part-number fan-out. Part-number fan-out only parallelises objects uploaded as multipart — a single PutObject (or a CopyObject, e.g. our TTL refresh) collapses to a single stream. Range mode fans out for any object, so restore parallelism is a property of our configured concurrency/part size rather than of how each object happened to be written. Save uses transfermanager.UploadObject.

Unlike the old manager, which streamed parts straight from the file, transfermanager.UploadObject buffers each in-flight part in memory, eagerly allocating (concurrency+1) × partSize. To keep this bounded, upload settings are decoupled from the S3 URL (concurrency/part_size_mb now tune restore only), and upload concurrency is chosen per object from its size so the pool stays under a 256 MiB budget — full parallelism for normal caches, stepping down to 1 for multi-TiB objects. The residual floor is S3's 10,000-part limit: a buffering uploader must hold at least one part of size/10_000, which only a streaming uploader could avoid.

Context

Linear - https://linear.app/buildkite/issue/A-1470/migrate-cache-s3-transfers-to-aws-transfermanager

Changes

  • internal/cache/store/s3.go: replace manager.Uploader/manager.Downloader with two transfermanager.Clients (upload- and download-tuned) for save and restore.
  • Restore: transfermanager.DownloadObject with GetObjectType = GetObjectRanges.
  • Save: transfermanager.UploadObject.
  • Keep the 412-retry loop (downloadWithRetry); update the objectDownloader interface to the DownloadObject signature.
  • Replace the smithy PartCounter middleware with a computed part count (ceil(bytes / partSize)); remove the now-unused sync/atomic and smithy-go/middleware imports.
  • Decouple upload from the URL: concurrency/part_size_mb now tune restore only; uploads use fixed defaults (manager.DefaultUploadConcurrency/DefaultUploadPartSize replaced by local 5 × 5 MiB constants).
  • Bound upload memory: uploadConcurrencyForSize picks per-object concurrency so the eager part-buffer pool (concurrency+1) × partSize stays under a 256 MiB budget; pin MaxUploadParts and MultipartUploadThreshold for deterministic part sizing.
  • Remove the feature/s3/manager import and all //nolint:staticcheck // SA1019 suppressions in the package.
  • Tests: update TestResolveTransferSettings, TestDownloadWithRetry, and the fakeDownloader double to the new API; add TestNewS3Blob (custom-endpoint + path-style construction) and TestUploadConcurrencyForSize (size→concurrency bounding).

Testing

  • Tests have run locally (with go test ./...). Buildkite employees may check this if the pipeline has run automatically.
  • Code is formatted (with go tool gofumpt -extra -w .)

Affiliation (optional, external contributors)

Disclosures / Credits

@ss1909 ss1909 added change Not a new feature, but a user observable non-breaking behavior change. cleanup Cleaning up code, refactoring, etc and removed change Not a new feature, but a user observable non-breaking behavior change. labels Jul 23, 2026
@ss1909
ss1909 marked this pull request as ready for review July 23, 2026 02:09
@ss1909
ss1909 requested review from a team as code owners July 23, 2026 02:09
@ss1909 ss1909 added internal Non-user facing, internal change. and removed cleanup Cleaning up code, refactoring, etc labels Jul 23, 2026

@buildsworth-bk-app buildsworth-bk-app Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The upload migration needs a bounded-memory adjustment before it is safe for the existing transfer settings.

Want to dig deeper?

The full session log is attached to this Buildkite build. Download the session file and open a new pi session with it:

Download the buildsworth logs from build 7312, then answer my questions about the findings.

Comment thread internal/cache/store/s3.go

@buildsworth-bk-app buildsworth-bk-app Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The upload-memory blocker is still open; I’ve added the remaining size-driven case to the existing thread.

Want to dig deeper?

Paste this into your agent to explore the findings from this review's Buildkite build:

Download the buildsworth logs from build 7455, then answer my questions about the findings.

Install the reading-buildsworth-logs skill to run this.

@buildsworth-bk-app
buildsworth-bk-app Bot dismissed their stale review July 24, 2026 00:25

Previous concerns have been addressed.

@buildsworth-bk-app buildsworth-bk-app Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The upload-memory concern is addressed, and I found no new issues. I’m leaving this comment-only because this changes the performance-critical S3 cache transfer path and needs a human sanity-check.

Want to dig deeper?

Paste this into your agent to explore the findings from this review's Buildkite build:

Download the buildsworth logs from build 7722, then answer my questions about the findings.

Install the reading-buildsworth-logs skill to run this.

@ss1909
ss1909 enabled auto-merge July 24, 2026 00:30
@ss1909 ss1909 changed the title fix (cache): Migrate cache S3 transfers to AWS transfermanager fix (cache): Migrate cache S3 manager to AWS transfermanager Jul 24, 2026

@zhming0 zhming0 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have left some questions mainly for myself to understand this better 🙏

// pool. It holds for all normal object sizes; multi-TiB objects bottom out at
// concurrency 1, where one part buffer (size/uploadMaxParts) is unavoidable
// and may exceed this.
uploadMemoryBudget = 256 * 1024 * 1024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a question for my understanding, what was this memory budget before this change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So there was no explicit "budget" before - manager was inherently bounded as it was streaming rather than buffering (which is waht transferManager does).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what this is trying to achieve now. But I am not sure if hard coding to 256MB is the right path:

  1. We should at least detect the available RAM.
  2. But even then, we don't know how many concurrent upload is happening at the same time.
  3. We don't seem to clearly understand the implication of this knob → e.g. by turning it up and down, are we expecting a speed up/down? It feels to me that we are trying to use this knob to serve as a workaround for a pitfall in AWS transfer manager.

At this point, I think it'd be fair to pause to consider if transfer manager is mature enough to justify this change.

Comment thread internal/cache/store/s3.go
Comment on lines 302 to -284
// resolveTransferSettings turns parsed Options into concrete transfer settings.
// An explicit concurrency or part_size_mb override applies to both uploads and
// downloads; otherwise uploads use the SDK's upload defaults and downloads use
// the download-tuned defaults.
// The concurrency and part_size_mb URL overrides restore
// path only; save always uses the fixed upload defaults.
func resolveTransferSettings(opts *Options) transferSettings {
uploadConcurrency := manager.DefaultUploadConcurrency
// Upload settings are fixed and intentionally not derived from the URL
uploadConcurrency := defaultUploadConcurrency
uploadPartSize := int64(defaultUploadPartSizeBytes)
downloadConcurrency := defaultDownloadConcurrency
downloadPartSize := int64(defaultDownloadPartSizeMB) * 1024 * 1024
if opts.Concurrency > 0 {
uploadConcurrency = opts.Concurrency
downloadConcurrency = opts.Concurrency
}

uploadPartSize := manager.DefaultUploadPartSize
downloadPartSize := int64(defaultDownloadPartSizeMB) * 1024 * 1024
if opts.PartSizeMB > 0 {
uploadPartSize = int64(opts.PartSizeMB) * 1024 * 1024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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:.

The thing is, our current url scheme allows a generic currency and part size setting, if we were to make this change, our URL params will become misleading.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

save and restore have differing memory behaviours:

  • Save buffers each in-flight part fully in RAM
  • Restore streams each range through a small (32 KiB) buffer
    Save/Upload buffers (memory grows with part size); Restore/download streams (memory doesn't). That mismatch is why this need for different concurrency/part-size handling arises.

I very much agree with the generic names for concurrency/part_size_mb, are misleading, so I propose we could do one of these:

  1. Document them as restore/download only tuning
  2. Rename to download_concurrency / download_part_size_mb

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Save buffers each in-flight part fully in RAM

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?

@ss1909 ss1909 Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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?

@buildsworth-bk-app

buildsworth-bk-app Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Sorry, my review failed. Check the build for details: https://buildkite.com/buildkite/buildsworth-review-pull-request/builds/8963

@buildsworth-bk-app buildsworth-bk-app Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The upload-memory fix still misses the transfer manager's separately held first multipart chunk, so the intended bound is exceeded for large caches. I've reopened the existing thread with the concrete allocation bounds.

Want to dig deeper?

Paste this into your agent to explore the findings from this review's Buildkite build:

Download the buildsworth logs from build 8963, then answer my questions about the findings.

Install the reading-buildsworth-logs skill to run this.

@buildsworth-bk-app
buildsworth-bk-app Bot dismissed their stale review July 28, 2026 05:45

Previous concerns have been addressed.

@buildsworth-bk-app buildsworth-bk-app Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The upload-memory concern is addressed, and I found no new issues. I’m leaving this comment-only because this changes the performance-critical S3 cache transfer path and needs a human sanity-check.

Want to dig deeper?

Paste this into your agent to explore the findings from this review's Buildkite build:

Download the buildsworth logs from build 9110, then answer my questions about the findings.

Install the reading-buildsworth-logs skill to run this.

@zhming0 zhming0 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should reconsider the upgrade. My quick read (I could be wrong), is that the upstream upload manager is quite flawed and we had to build extra finicky harness to prevent its causing OOM issue.

I am leaning towards not upgrading to transfer manager now (at least for the upload path) after seeing the efforts here. What do you think @ss1909?

Comment on lines +328 to +356
// uploadConcurrencyForSize picks how many part buffers UploadObject may hold in
// parallel for an object of the given size, so its peak allocation stays within
// uploadMemoryBudget. That peak is (concurrency+2) × partSize: the eager pool
// holds concurrency+1 buffers, and UploadObject reads the first chunk into a
// separate buffer outside the pool.
//
// transfermanager raises the part size to size/uploadMaxParts once an object
// would exceed S3's 10,000-part limit, so for large objects the part buffers
// grow with the object and we trade away concurrency to compensate. Multi-TiB
// objects bottom out at concurrency 1, where three part buffers are still
// unavoidable, so the budget is a ceiling for normal sizes, not a hard cap at
// every size.
func uploadConcurrencyForSize(size int64, maxConcurrency int) int {
partSize := int64(defaultUploadPartSizeBytes)
if forced := size/uploadMaxParts + 1; forced > partSize {
partSize = forced
}

// Peak = (concurrency+1) pool buffers + 1 separately-held first chunk, so
// solve (concurrency+2) × partSize <= budget.
concurrency := int(uploadMemoryBudget/partSize) - 2
if concurrency > maxConcurrency {
concurrency = maxConcurrency
}
if concurrency < 1 {
concurrency = 1
}
return concurrency
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] I believe the arithmetic is based on some internal logic of transfer function, this isn't ideal because we don't want to be coupled to something that will change anytime.

ss1909 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Yeah I concur. I reckon we could move this to later, maybe after the next milestone even.

zhming0 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

yeah, I agree. Let's move it off the current milestone back to backlog.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

internal Non-user facing, internal change.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants