Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
da5692a
Update checksum plugin to not load body into memory
jterapin Nov 17, 2025
20bd2b8
Add customization
jterapin Nov 17, 2025
392d536
Merge branch 'version-3' into unsigned-body
jterapin Nov 18, 2025
1a761ec
Fix trailer impl to include overhead bytes
jterapin Nov 21, 2025
c09bb65
Merge branch 'version-3' into unsigned-body
jterapin Nov 25, 2025
dc9b603
Update net http patches
jterapin Nov 26, 2025
192d651
Merge branch 'version-3' into unsigned-body
jterapin Dec 5, 2025
5d453b3
Update trailer impl with patch
jterapin Dec 6, 2025
81dfafd
Update MPU with custom chunk size
jterapin Dec 6, 2025
14b7f34
Generated S3 gem
jterapin Dec 6, 2025
06e08f2
Fix failing tests
jterapin Dec 6, 2025
ad14d42
Add temp changelog
jterapin Dec 6, 2025
de72cf1
Add http_chunk_size spec
jterapin Dec 7, 2025
4abf3f8
Improve documentation
jterapin Dec 7, 2025
4fbff2c
Fix changelog entries
jterapin Dec 7, 2025
41ae00c
Add chunk testing
jterapin Dec 11, 2025
c935ea7
Remove todo
jterapin Dec 11, 2025
cc98a36
Address feedbacks
jterapin Dec 11, 2025
81b6be5
Add fix
jterapin Dec 11, 2025
5426bbb
Merge branch 'version-3' into unsigned-body
jterapin Dec 11, 2025
78554ac
Fix changelog entry
jterapin Dec 11, 2025
cf84f02
Fix overhead tests
jterapin Dec 11, 2025
1bfa7ec
More changes
jterapin Dec 11, 2025
104cf03
Fix failing specs
jterapin Dec 15, 2025
0663037
More fixes
jterapin Dec 15, 2025
98f450c
Other updates
jterapin Dec 15, 2025
a443e69
Merge branch 'version-3' into unsigned-body
jterapin Dec 15, 2025
599be96
Update specs
jterapin Dec 15, 2025
eb3377c
Merge version-3 into branch
jterapin Dec 15, 2025
aacb57a
Update to fix
jterapin Dec 15, 2025
c083d27
Update to skip unsigned body tests for JRuby
jterapin Dec 16, 2025
15a4f12
Update flakey test
jterapin Dec 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion build_tools/customizations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,18 @@ def dynamodb_example_deep_transform(subsegment, keys)
api['metadata'].delete('signatureVersion')

# handled by endpoints 2.0
api['operations'].each do |_key, operation|
api['operations'].each do |key, operation|
# requestUri should always exist. Remove bucket from path
# and preserve request uri as /
if operation['http'] && operation['http']['requestUri']
operation['http']['requestUri'].gsub!('/{Bucket}', '/')
operation['http']['requestUri'].gsub!('//', '/')
end

next unless %w[PutObject UploadPart].include?(key)

operation['authType'] = 'v4-unsigned-body'
operation['unsignedPayload'] = true
end

# Ensure Expires is a timestamp regardless of model to be backwards
Expand Down
4 changes: 2 additions & 2 deletions build_tools/services.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ class ServiceEnumerator
MANIFEST_PATH = File.expand_path('../../services.json', __FILE__)

# Minimum `aws-sdk-core` version for new gem builds
MINIMUM_CORE_VERSION = "3.239.1"
MINIMUM_CORE_VERSION = "3.240.0"

# Minimum `aws-sdk-core` version for new S3 gem builds
MINIMUM_CORE_VERSION_S3 = "3.234.0"
MINIMUM_CORE_VERSION_S3 = "3.240.0"
Comment on lines -12 to +15
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Self-reminder to update the minimum core version when this is ready for release.


EVENTSTREAM_PLUGIN = "Aws::Plugins::EventStreamConfiguration"

Expand Down
2 changes: 2 additions & 0 deletions gems/aws-sdk-core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
Unreleased Changes
------------------

* Feature - Improved memory efficiency when calculating request checksums.

3.239.2 (2025-11-25)
------------------

Expand Down
128 changes: 82 additions & 46 deletions gems/aws-sdk-core/lib/aws-sdk-core/plugins/checksum_algorithm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module Plugins
# @api private
class ChecksumAlgorithm < Seahorse::Client::Plugin
CHUNK_SIZE = 1 * 1024 * 1024 # one MB
MIN_CHUNK_SIZE = 16_384 # 16 KB

# determine the set of supported client side checksum algorithms
# CRC32c requires aws-crt (optional sdk dependency) for support
Expand Down Expand Up @@ -162,9 +163,7 @@ def call(context)
context[:http_checksum] ||= {}

# Set validation mode to enabled when supported.
if context.config.response_checksum_validation == 'when_supported'
enable_request_validation_mode(context)
end
enable_request_validation_mode(context) if context.config.response_checksum_validation == 'when_supported'

@handler.call(context)
end
Expand Down Expand Up @@ -194,9 +193,7 @@ def call(context)
calculate_request_checksum(context, request_algorithm)
end

if should_verify_response_checksum?(context)
add_verify_response_checksum_handlers(context)
end
add_verify_response_checksum_handlers(context) if should_verify_response_checksum?(context)

with_metrics(context.config, algorithm) { @handler.call(context) }
end
Expand Down Expand Up @@ -334,6 +331,11 @@ def calculate_request_checksum(context, checksum_properties)
if (algorithm_header = checksum_properties[:request_algorithm_header])
headers[algorithm_header] = checksum_properties[:algorithm]
end

# Trailer implementation within JRUBY environment is facing some
# network issues that will need further investigation
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe link to the possible open issue?

return apply_request_checksum(context, headers, checksum_properties) if defined?(JRUBY_VERSION)

case checksum_properties[:in]
when 'header'
apply_request_checksum(context, headers, checksum_properties)
Expand All @@ -346,17 +348,18 @@ def calculate_request_checksum(context, checksum_properties)

def apply_request_checksum(context, headers, checksum_properties)
header_name = checksum_properties[:name]
body = context.http_request.body_contents
headers[header_name] = calculate_checksum(
checksum_properties[:algorithm],
body
context.http_request.body
)
end

def calculate_checksum(algorithm, body)
digest = ChecksumAlgorithm.digest_for_algorithm(algorithm)
if body.respond_to?(:read)
body.rewind
update_in_chunks(digest, body)
body.rewind
else
digest.update(body)
end
Expand Down Expand Up @@ -388,13 +391,14 @@ def apply_request_trailer_checksum(context, headers, checksum_properties)
unless context.http_request.body.respond_to?(:size)
raise Aws::Errors::ChecksumError, 'Could not determine length of the body'
end
headers['X-Amz-Decoded-Content-Length'] = context.http_request.body.size

context.http_request.body = AwsChunkedTrailerDigestIO.new(
context.http_request.body,
checksum_properties[:algorithm],
location_name
)
headers['X-Amz-Decoded-Content-Length'] = context.http_request.body.size
context.http_request.body =
AwsChunkedTrailerDigestIO.new(
io: context.http_request.body,
algorithm: checksum_properties[:algorithm],
location_name: location_name
)
end

def should_verify_response_checksum?(context)
Expand All @@ -417,10 +421,7 @@ def add_verify_response_headers_handler(context, checksum_context)
context[:http_checksum][:validation_list] = validation_list

context.http_response.on_headers do |_status, headers|
header_name, algorithm = response_header_to_verify(
headers,
validation_list
)
header_name, algorithm = response_header_to_verify(headers, validation_list)
next unless header_name

expected = headers[header_name]
Expand Down Expand Up @@ -466,52 +467,87 @@ def response_header_to_verify(headers, validation_list)
# Wrapper for request body that implements application-layer
# chunking with Digest computed on chunks + added as a trailer
class AwsChunkedTrailerDigestIO
CHUNK_SIZE = 16_384

def initialize(io, algorithm, location_name)
@io = io
@location_name = location_name
@algorithm = algorithm
@digest = ChecksumAlgorithm.digest_for_algorithm(algorithm)
@trailer_io = nil
def initialize(options = {})
@io = options.delete(:io)
@location_name = options.delete(:location_name)
@algorithm = options.delete(:algorithm)
@digest = ChecksumAlgorithm.digest_for_algorithm(@algorithm)
@chunk_size = Thread.current[:net_http_override_body_stream_chunk] || MIN_CHUNK_SIZE
@overhead_bytes = calculate_overhead(@chunk_size)
@base_chunk_size = @chunk_size - @overhead_bytes
@buffer = +''
@current_chunk = +''
@eof = false
end

# the size of the application layer aws-chunked + trailer body
def size
# compute the number of chunks
# a full chunk has 4 + 4 bytes overhead, a partial chunk is len.to_s(16).size + 4
orig_body_size = @io.size
n_full_chunks = orig_body_size / CHUNK_SIZE
partial_bytes = orig_body_size % CHUNK_SIZE
chunked_body_size = n_full_chunks * (CHUNK_SIZE + 8)
chunked_body_size += partial_bytes.to_s(16).size + partial_bytes + 4 unless partial_bytes.zero?
n_full_chunks = orig_body_size / @base_chunk_size
partial_bytes = orig_body_size % @base_chunk_size

chunked_body_size = n_full_chunks * (@base_chunk_size + @base_chunk_size.to_s(16).size + 4)
chunked_body_size += partial_bytes.to_s(16).size + partial_bytes + 4 unless partial_bytes.zero?
trailer_size = ChecksumAlgorithm.trailer_length(@algorithm, @location_name)
chunked_body_size + trailer_size
end

def rewind
@io.rewind
@buffer = +''
@current_chunk = +''
@eof = false
end

def read(length, buf = nil)
# account for possible leftover bytes at the end, if we have trailer bytes, send them
if @trailer_io
return @trailer_io.read(length, buf)
def read(length = nil, buf = nil)
return @io.read unless length
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm a little confused about this - this means we won't use AWS chunked encoding when we're not given a length?


return if @eof && @buffer.empty? && @current_chunk.empty?

buf&.clear
output_buffer = buf || +''

while output_buffer.bytesize < length # fill until output buffer is ready
unless @buffer.empty?
take = [length - output_buffer.bytesize, @buffer.bytesize].min
slice_data = @buffer.slice!(0, take)
output_buffer << slice_data
next
end

unless @current_chunk.empty?
take = [length - output_buffer.bytesize, @current_chunk.bytesize].min
slice_data = @current_chunk.slice!(0, take)
output_buffer << slice_data
next
end

if !@eof
fill_chunk
else
break
end
end

chunk = @io.read(length)
if chunk
output_buffer
end

private

def calculate_overhead(chunk_size)
chunk_size.to_s(16).size + 4 # hex_length + "\r\n\r\n"
end

def fill_chunk
chunk = @io.read(@base_chunk_size)
Copy link
Contributor

Choose a reason for hiding this comment

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

What happens here is fewer (but non-zero) bytes than the base_chunk_size are returned? (I can't 100% remember Ruby, but I think IO objects are allowed to return <= the requested length when the rest of the bytes aren't yet available)

if chunk && !chunk.empty?
@digest.update(chunk)
application_chunked = "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
return StringIO.new(application_chunked).read(application_chunked.size, buf)
@current_chunk << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
else
trailers = {}
trailers[@location_name] = @digest.base64digest
trailers = trailers.map { |k,v| "#{k}:#{v}" }.join("\r\n")
@trailer_io = StringIO.new("0\r\n#{trailers}\r\n\r\n")
chunk = @trailer_io.read(length, buf)
trailer_str = { @location_name => @digest.base64digest }.map { |k, v| "#{k}:#{v}" }.join("\r\n")
@current_chunk << "0\r\n#{trailer_str}\r\n\r\n"
@eof = true
end
chunk
end
end
end
Expand Down
55 changes: 44 additions & 11 deletions gems/aws-sdk-core/lib/seahorse/client/net_http/patches.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,61 @@ module Seahorse
module Client
# @api private
module NetHttp

# @api private
module Patches

def self.apply!
Net::HTTPGenericRequest.prepend(PatchDefaultContentType)
Net::HTTPGenericRequest.prepend(RequestPatches)
end

# For requests with bodies, Net::HTTP sets a default content type of:
# 'application/x-www-form-urlencoded'
# There are cases where we should not send content type at all.
# Even when no body is supplied, Net::HTTP uses a default empty body
# and sets it anyway. This patch disables the behavior when a Thread
# local variable is set.
module PatchDefaultContentType
# Patches intended to override Net::HTTP functionality
module RequestPatches
# For requests with bodies, Net::HTTP sets a default content type of:
# 'application/x-www-form-urlencoded'
# There are cases where we should not send content type at all.
# Even when no body is supplied, Net::HTTP uses a default empty body
# and sets it anyway. This patch disables the behavior when a Thread
# local variable is set.
# See: https://github.com/ruby/net-http/issues/205
def supply_default_content_type
return if Thread.current[:net_http_skip_default_content_type]

super
end
end

# IO.copy_stream is capped at 16KB buffer so this patch intends to
# increase its chunk size for better performance.
# Only intended to use for S3 TM implementation.
# See: https://github.com/ruby/net-http/blob/master/lib/net/http/generic_request.rb#L292
def send_request_with_body_stream(sock, ver, path, f)
return super unless (chunk_size = Thread.current[:net_http_override_body_stream_chunk])

unless content_length || chunked?
raise ArgumentError, 'Content-Length not given and Transfer-Encoding is not `chunked`'
end

supply_default_content_type
write_header(sock, ver, path)
wait_for_continue sock, ver if sock.continue_timeout
if chunked?
chunker = Chunker.new(sock)
RequestIO.custom_stream(f, chunker, chunk_size)
chunker.finish
else
RequestIO.custom_stream(f, sock, chunk_size)
end
end

class RequestIO
def self.custom_stream(src, dst, chunk_size)
copied = 0
while (chunk = src.read(chunk_size))
dst.write(chunk)
copied += chunk.bytesize
end
copied
end
end
end
end
end
end
Expand Down
6 changes: 3 additions & 3 deletions gems/aws-sdk-core/spec/aws/plugins/checksum_algorithm_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,8 @@ module Plugins
end
end

context 'request streaming checksum calculation' do
# JRuby live testing against service is not reliable. We plan to investigate deeper before enabling
context 'request streaming checksum calculation', skip: defined?(JRUBY_VERSION) do
file = File.expand_path('checksum_streaming_request.json', __dir__)
test_cases = JSON.load_file(file)

Expand All @@ -259,8 +260,7 @@ module Plugins
client.stub_responses(:http_checksum_streaming_operation, lambda do |context|
headers = context.http_request.headers

expect(headers['x-amz-content-sha256'])
.to eq('STREAMING-UNSIGNED-PAYLOAD-TRAILER')
expect(headers['x-amz-content-sha256']).to eq('STREAMING-UNSIGNED-PAYLOAD-TRAILER')
test_case['expectHeaders'].each do |key, value|
expect(headers[key]).to eq(value)
end
Expand Down
5 changes: 5 additions & 0 deletions gems/aws-sdk-s3/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
Unreleased Changes
------------------

* Feature - Added `:http_chunk_size` parameter to `TransferManager#upload_file` to control the buffer size when streaming request bodies over HTTP. Larger chunk sizes may improve network throughput at the cost of higher memory usage (Ruby MRI only).

* Feature - Improved memory efficiency when calculating request checksums for large file uploads (Ruby MRI only).

1.207.0 (2025-12-15)
------------------

* Feature - This release adds support for the new optional field 'LifecycleExpirationDate' in S3 Inventory configurations.


1.206.0 (2025-12-02)
------------------

Expand Down
4 changes: 2 additions & 2 deletions gems/aws-sdk-s3/lib/aws-sdk-s3/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17659,7 +17659,7 @@ def put_bucket_website(params = {}, options = {})
# [3]: https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-using-rest-api.html
# [4]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html
#
# @option params [String, StringIO, File] :body
# @option params [String, IO] :body
# Object data.
#
# @option params [required, String] :bucket
Expand Down Expand Up @@ -20968,7 +20968,7 @@ def update_bucket_metadata_journal_table_configuration(params = {}, options = {}
# [16]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListParts.html
# [17]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListMultipartUploads.html
#
# @option params [String, StringIO, File] :body
# @option params [String, IO] :body
# Object data.
#
# @option params [required, String] :bucket
Expand Down
2 changes: 2 additions & 0 deletions gems/aws-sdk-s3/lib/aws-sdk-s3/client_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4121,6 +4121,7 @@ module ClientApi
"requestAlgorithmMember" => "checksum_algorithm",
"requestChecksumRequired" => false,
}
o['unsignedPayload'] = true
o.input = Shapes::ShapeRef.new(shape: PutObjectRequest)
o.output = Shapes::ShapeRef.new(shape: PutObjectOutput)
o.errors << Shapes::ShapeRef.new(shape: InvalidRequest)
Expand Down Expand Up @@ -4309,6 +4310,7 @@ module ClientApi
"requestAlgorithmMember" => "checksum_algorithm",
"requestChecksumRequired" => false,
}
o['unsignedPayload'] = true
o.input = Shapes::ShapeRef.new(shape: UploadPartRequest)
o.output = Shapes::ShapeRef.new(shape: UploadPartOutput)
end)
Expand Down
Loading
Loading