Skip to content

fix: Address unbounded memory growth in upload_stream when source outpaces upload - #3407

Open
richardwang1124 wants to merge 8 commits into
version-3from
fix/upload_stream
Open

fix: Address unbounded memory growth in upload_stream when source outpaces upload#3407
richardwang1124 wants to merge 8 commits into
version-3from
fix/upload_stream

Conversation

@richardwang1124

@richardwang1124 richardwang1124 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Fixes unbounded memory growth in upload_stream when the source produces data faster than parts can be uploaded. Previously every part read off the pipe was buffered with no limit. Now DefaultExecutor supports a bounded queue that applies backpressure and upload_stream sets a limit to thread_count. Also switches in memory read path from copy_stream to single right-sized read.

Fixes #3393.


By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

  1. To make sure we include your contribution in the release notes, please make sure to add description entry for your changes in the "unreleased changes" section of the CHANGELOG.md file (at corresponding gem). For the description entry, please make sure it lives in one line and starts with Feature or Issue in the correct format.

  2. For generated code changes, please checkout below instructions first:
    https://github.com/aws/aws-sdk-ruby/blob/version-3/CONTRIBUTING.md

Thank you for your contribution!

@richardwang1124
richardwang1124 requested a review from a team as a code owner July 9, 2026 17:53
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Detected 1 possible performance regressions:

  • aws-sdk-s3.put_object_multipart_large_allocated_kb - z-score regression: 11658.45 -> 11658.45. Z-score: Infinity

@jterapin jterapin 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.

Thanks for picking this up! this is a real gap and a good catch. I've left a few questions on the current implementation inline. We're also missing a CHANGELOG entry, so we'll want to add one before this merges.

Comment thread gems/aws-sdk-s3/lib/aws-sdk-s3/default_executor.rb Outdated
Comment thread gems/aws-sdk-s3/lib/aws-sdk-s3/default_executor.rb Outdated
Comment thread gems/aws-sdk-s3/lib/aws-sdk-s3/multipart_stream_uploader.rb

@jterapin jterapin 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 overall approach makes sense. I would like to see a fuller PR description for historical purposes (aside from the issue link).

Comment thread gems/aws-sdk-s3/lib/aws-sdk-s3/default_executor.rb Outdated
Comment thread gems/aws-sdk-s3/lib/aws-sdk-s3/customizations/object.rb
Comment thread gems/aws-sdk-s3/lib/aws-sdk-s3/multipart_stream_uploader.rb
Comment thread gems/aws-sdk-s3/lib/aws-sdk-s3/default_executor.rb
def upload_stream(options = {}, &block)
upload_opts = options.merge(bucket: bucket_name, key: key)
executor = DefaultExecutor.new(max_threads: upload_opts.delete(:thread_count))
thread_count = upload_opts.delete(:thread_count) || DefaultExecutor::DEFAULT_MAX_THREADS

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.

Could we expose the queue size as its own option instead of deriving it from thread_count? Right now max_queue: thread_count couples two separate concerns: how many uploads run in parallel, and how far ahead we're allowed to buffer.

Or this could be an additional thing to have down the road. Your choice.

true
rescue ClosedQueueError
# shutdown or kill happened while parked on a full queue
raise 'Executor has been shutdown and is no longer accepting tasks'

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.

We should define an actual error class for this. Something like, RejectedExecutionError which is what concurrent-ruby has.

end
# Count only successfully queued parts; a failed post never runs the
# block, so it never pushes :done and must not be waited on below.
upload_attempts += 1

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.

Could rename this var into queued_parts to be consistent and accurate

Unreleased Changes
------------------

* Issue - Bound memory usage in `upload_stream` when the source produces data faster than parts can be uploaded.

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.

Suggested change
* Issue - Bound memory usage in `upload_stream` when the source produces data faster than parts can be uploaded.
* Issue - Fix unbounded memory growth in `upload_stream` when the data source outpaces the upload (#3407).

completed_part = { etag: resp.etag, part_number: p[:part_number] }
apply_part_checksum(resp, completed_part)
completed.push(completed_part)
begin

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 believe we will have to apply similar changes to FileDownloader but you could this in a fast-follow PR.

break if job == :shutdown

args, block = job
block.call(*args)

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.

Now that the queue is bounded, I think this needs a guard around block.call. If a task raises outside StandardError (OOM, a signal), the worker dies here, and with a full queue the producer stays parked on push with nothing to drain it and no way to spawn a replacement, so the upload freezes silently.

Any thoughts on how to counter this?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Object#upload_stream retains unbounded memory when the source outpaces the upload

3 participants