-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix: Address unbounded memory growth in upload_stream when source outpaces upload #3407
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: version-3
Are you sure you want to change the base?
Changes from all commits
8080821
842f7e7
906c099
c796bc5
b20cd27
e1d7bf2
fc5ad61
9ed42bd
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 |
|---|---|---|
|
|
@@ -380,7 +380,9 @@ def public_url(options = {}) | |
| # and {Client#upload_part} can be provided. | ||
| # | ||
| # @option options [Integer] :thread_count (10) The number of parallel multipart uploads. | ||
| # An additional thread is used internally for task coordination. | ||
| # An additional thread is used internally for task coordination. This also bounds | ||
| # how many parts are buffered ahead of the upload, limiting memory usage to roughly | ||
| # `2 * :thread_count * :part_size`. | ||
| # | ||
| # @option options [Boolean] :tempfile (false) Normally read data is stored | ||
| # in memory when building the parts in order to complete the underlying | ||
|
|
@@ -405,7 +407,10 @@ def public_url(options = {}) | |
| # @see Client#upload_part | ||
| 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 | ||
|
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. 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. |
||
| # A bounded queue prevents the source from reading ahead without limit when it | ||
| # produces data faster than parts can be uploaded. | ||
| executor = DefaultExecutor.new(max_threads: thread_count, max_queue: thread_count) | ||
|
jterapin marked this conversation as resolved.
|
||
| uploader = MultipartStreamUploader.new( | ||
| client: client, | ||
| executor: executor, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,8 +11,9 @@ class DefaultExecutor | |
|
|
||
| def initialize(options = {}) | ||
| @max_threads = options[:max_threads] || DEFAULT_MAX_THREADS | ||
| @max_queue = options[:max_queue] || 0 | ||
| @state = RUNNING | ||
| @queue = Queue.new | ||
| @queue = @max_queue.zero? ? Queue.new : SizedQueue.new(@max_queue) # 0 is unbounded | ||
| @pool = [] | ||
| @mutex = Mutex.new | ||
| end | ||
|
|
@@ -25,10 +26,15 @@ def post(*args, &block) | |
| @mutex.synchronize do | ||
| raise 'Executor has been shutdown and is no longer accepting tasks' unless @state == RUNNING | ||
|
|
||
| @queue << [args, block] | ||
| ensure_worker_available | ||
| end | ||
| # Pushed outside the mutex because a bounded queue blocks the caller when | ||
| # full and holding the lock while parked would deadlock #shutdown and #kill. | ||
| @queue.push([args, block]) | ||
|
jterapin marked this conversation as resolved.
|
||
| true | ||
| rescue ClosedQueueError | ||
| # shutdown or kill happened while parked on a full queue | ||
| raise 'Executor has been shutdown and is no longer accepting tasks' | ||
|
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. We should define an actual error class for this. Something like, |
||
| end | ||
|
|
||
| # Immediately terminates all worker threads and clears pending tasks. | ||
|
|
@@ -38,6 +44,7 @@ def post(*args, &block) | |
| def kill | ||
| @mutex.synchronize do | ||
| @state = SHUTDOWN | ||
| @queue.close # wakes any producer parked on a full queue | ||
| @pool.each(&:kill) | ||
| @pool.clear | ||
| @queue.clear | ||
|
|
@@ -56,7 +63,9 @@ def shutdown(timeout = nil) | |
| return true if @state == SHUTDOWN | ||
|
|
||
| @state = SHUTTING_DOWN | ||
| @pool.size.times { @queue << :shutdown } | ||
| # Closing wakes parked producers and lets workers drain remaining tasks | ||
| # before exiting without pushing sentinels onto a queue that may be full. | ||
| @queue.close | ||
| end | ||
|
|
||
| if timeout | ||
|
|
@@ -91,8 +100,6 @@ def ensure_worker_available | |
| def spawn_worker | ||
| Thread.new do | ||
| while (job = @queue.shift) | ||
| break if job == :shutdown | ||
|
|
||
| args, block = job | ||
| block.call(*args) | ||
|
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. 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? |
||
| end | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -149,21 +149,32 @@ def upload_with_executor(pending, completed, options) | |
| while (part = pending.shift) | ||
| break if abort_upload | ||
|
|
||
| upload_attempts += 1 | ||
| @executor.post(part) do |p| | ||
| Thread.current[:net_http_override_body_stream_chunk] = @http_chunk_size if @http_chunk_size | ||
| update_progress(progress, p) | ||
| resp = @client.upload_part(p) | ||
| completed_part = { etag: resp.etag, part_number: p[:part_number] } | ||
| apply_part_checksum(resp, completed_part) | ||
| completed.push(completed_part) | ||
| begin | ||
|
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 believe we will have to apply similar changes to FileDownloader but you could this in a fast-follow PR. |
||
| @executor.post(part) do |p| | ||
| Thread.current[:net_http_override_body_stream_chunk] = @http_chunk_size if @http_chunk_size | ||
| update_progress(progress, p) | ||
| resp = @client.upload_part(p) | ||
| completed_part = { etag: resp.etag, part_number: p[:part_number] } | ||
| apply_part_checksum(resp, completed_part) | ||
| completed.push(completed_part) | ||
| rescue StandardError => e | ||
| abort_upload = true | ||
| errors << e | ||
| ensure | ||
| p[:body].close | ||
| Thread.current[:net_http_override_body_stream_chunk] = nil if @http_chunk_size | ||
| completion_queue << :done | ||
| 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 | ||
|
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. Could rename this var into |
||
| rescue StandardError => e | ||
| # The executor rejected the task (e.g. shut down mid-upload). Record | ||
| # it so the abort ceremony runs instead of the error escaping and | ||
| # leaving the multipart upload orphaned on S3. | ||
| abort_upload = true | ||
| errors << e | ||
| ensure | ||
| p[:body].close | ||
| Thread.current[:net_http_override_body_stream_chunk] = nil if @http_chunk_size | ||
| completion_queue << :done | ||
| break | ||
| end | ||
| end | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.