Skip to content

Commit 644899a

Browse files
committed
Add backpressure on MultipartStreamUploader
to prevent memory or disk leaks.
1 parent dd69152 commit 644899a

3 files changed

Lines changed: 19 additions & 3 deletions

File tree

gems/aws-sdk-s3/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
Unreleased Changes
22
------------------
33

4+
* Issue - Fix stream uploader to not read ahead of thread pool capacity, to prevent unbounded usage of memory or disk.
5+
46
1.226.0 (2026-06-16)
57
------------------
68

gems/aws-sdk-s3/lib/aws-sdk-s3/default_executor.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ class DefaultExecutor
99
SHUTTING_DOWN = :shutting_down
1010
SHUTDOWN = :shutdown
1111

12+
attr_reader :max_threads
13+
1214
def initialize(options = {})
1315
@max_threads = options[:max_threads] || DEFAULT_MAX_THREADS
1416
@state = RUNNING

gems/aws-sdk-s3/lib/aws-sdk-s3/multipart_stream_uploader.rb

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# frozen_string_literal: true
22

3-
require 'thread'
43
require 'set'
54
require 'tempfile'
65
require 'stringio'
@@ -9,7 +8,6 @@ module Aws
98
module S3
109
# @api private
1110
class MultipartStreamUploader
12-
1311
DEFAULT_PART_SIZE = 5 * 1024 * 1024 # 5MB
1412
CREATE_OPTIONS = Set.new(Client.api.operation(:create_multipart_upload).input.shape.member_names)
1513
UPLOAD_PART_OPTIONS = Set.new(Client.api.operation(:upload_part).input.shape.member_names)
@@ -133,10 +131,22 @@ def upload_with_executor(read_pipe, completed, errors, options)
133131
queued_parts = 0
134132
part_number = 0
135133
mutex = Mutex.new
134+
concurent_readed_parts = 0 # slots available for reading
135+
136136
loop do
137137
part_body, current_part_num = mutex.synchronize do
138-
[read_to_part_body(read_pipe), part_number += 1]
138+
# Prevent reading ahead of the executor if no slots available
139+
if concurent_readed_parts >= @executor.max_threads
140+
[:skip, -1]
141+
else
142+
concurent_readed_parts += 1
143+
[read_to_part_body(read_pipe), part_number += 1]
144+
end
139145
end
146+
147+
next if part_body == :skip # Wait for the executor free a read slot
148+
149+
# No more parts to read or we need at least one part for empty content
140150
break unless part_body || current_part_num == 1
141151

142152
queued_parts += 1
@@ -145,8 +155,10 @@ def upload_with_executor(read_pipe, completed, errors, options)
145155
resp = @client.upload_part(part)
146156
completed_part = create_completed_part(resp, part)
147157
completed.push(completed_part)
158+
mutex.synchronize { concurent_readed_parts -= 1 } # free a read slot
148159
rescue StandardError => e
149160
mutex.synchronize do
161+
concurent_readed_parts -= 1 # free a read slot
150162
errors.push(e)
151163
read_pipe.close_read unless read_pipe.closed?
152164
end

0 commit comments

Comments
 (0)