Fix multipart download response metadata for presigned URL and normal paths#7077
Conversation
| } | ||
|
|
||
| @Test | ||
| void multipartDownload_checksumModeEnabled_hasCorrectFullObjectMetadata() throws Exception { |
| } | ||
|
|
||
| @Test | ||
| void multipartDownload_toBytes_smallObject_hasCorrectFullObjectMetadata() throws Exception { |
There was a problem hiding this comment.
Can we consolidate this with multipartDownload_toFile_hasCorrectFullObjectMetadata using parameterized tests?
| } | ||
|
|
||
| @Test | ||
| void getObject_withRangeRequest_preservesPartialMetadata() throws Exception { |
There was a problem hiding this comment.
Same here, let's try to consolidate tests with parameterized tests
| } | ||
|
|
||
| @Test | ||
| void getObject_mpuObjectWithChecksumMode_hasCorrectMetadata() throws Exception { |
There was a problem hiding this comment.
Same here. How is checksum mode special?
| } | ||
|
|
||
| // Helper methods | ||
| private static void uploadMpuObjectWithChecksum() { |
There was a problem hiding this comment.
Checksum should be enabled by default, any reason we need to upload it with checksum?
There was a problem hiding this comment.
If an object is MPU without checksumMode enabled, S3 doesnt return checksum.
If an object is MPU with checksumMode enabled, S3 doesnt returns FULL_OBJECT checksum.
And if uploaded with checksum enabled and with an explicit checksum algorithm like .checksumAlgorithm(ChecksumAlgorithm.CRC32)) S3 returns COMPOSITE checksum.
| if (transformer instanceof ByteArrayAsyncResponseTransformer) { | ||
| return (SplitResult<GetObjectResponse, T>) | ||
| ((ByteArrayAsyncResponseTransformer<GetObjectResponse>) transformer).split(splitConfig, mapper); | ||
| } |
There was a problem hiding this comment.
Any reason we have special logic for ByteArrayAsyncResponseTransformer? ByteArrayAsyncResponseTransformer is an internal API and not supposed to be used across modules
There was a problem hiding this comment.
Oh yeah, removed the instanceof and added split(config, mapper) to the AsyncResponseTransformer interface (with ByteArrayAsyncResponseTransformer overriding it). splitWithResponseRewrite() now just calls transformer.split(splitConfig, mapper)
| if (cause instanceof EmptyObjectRangeNotSatisfiableException) { | ||
| // Parallel path wraps it as EmptyObjectRangeNotSatisfiableException; | ||
| // serial path (toBytes, custom transformers) surfaces raw S3Exception. | ||
| if (cause instanceof EmptyObjectRangeNotSatisfiableException |
There was a problem hiding this comment.
Question: what is EmptyObjectRangeNotSatisfiableException?
There was a problem hiding this comment.
EmptyObjectRangeNotSatisfiableException is an internal exception created by the parallel subscriber when it gets a 416 from S3 on a ranged request to an empty object. The serial path doesnt go through the subscriber, so the raw 416 S3Exception arrives without being wrapped. Planning to remove this exception class as a follow up and just use isRangeNotSatisfiable() for all paths.
| UnaryOperator.identity()); | ||
| } | ||
|
|
||
| private SplittingTransformer(AsyncResponseTransformer<ResponseT, ResultT> upstreamResponseTransformer, |
There was a problem hiding this comment.
Can we update this ctor to take a Builder parameter? That way, we don't need to create a new ctor.
| ? progressUpdater.wrapForNonSerialFileDownload( | ||
| responseTransformer, GetObjectRequest.builder().build()) | ||
| : progressUpdater.wrapResponseTransformer(responseTransformer); | ||
| if (isS3ClientMultipartEnabled() |
There was a problem hiding this comment.
Fixes test failure for bytesTransferred not firing for presigned toBytes multipart downloads.
That path was routed to wrapForNonSerialFileDownload, which only counts bytes inside its split() override, but the serial download splits and drives onStream directly, bypassing it. Now routed by parallelSplitSupported() so serial toBytes uses wrapResponseTransformerForMultipartDownload (counts in onStream), mirroring the regular download path
| * Creates a {@link SplitResult} with a response mapper applied at the upstream {@code onResponse} delivery point. | ||
| */ | ||
| @SdkInternalApi | ||
| default SplitResult<ResponseT, ResultT> split(SplittingTransformerConfiguration splitConfig, |
There was a problem hiding this comment.
IMO all public methods in a public API class are inherently public APIs, so we can't really add SdkInternalApi. Should we consider folding responseMapper into SplittingTransformerConfiguration. That way, we don't have to introduce another method
There was a problem hiding this comment.
moved responseMapper onto SplittingTransformerConfiguration and removed the extra split method
| this(upstreamResponseTransformer, resultFuture, UnaryOperator.identity()); | ||
| } | ||
|
|
||
| public ByteArraySplittingTransformer(AsyncResponseTransformer<ResponseT, ResponseBytes<ResponseT>> |
There was a problem hiding this comment.
Why do we need to new ctor? can we just add a new parameter?
There was a problem hiding this comment.
Cleaned up, the mapper now comes from the config rather than a separate split(config, mapper),
| : progressUpdater.wrapResponseTransformer(responseTransformer); | ||
| if (isS3ClientMultipartEnabled() | ||
| && presignedDownloadRequest.presignedUrlDownloadRequest().range() == null) { | ||
| if (responseTransformer.split(b -> b.bufferSizeInBytes(1L)).parallelSplitSupported()) { |
There was a problem hiding this comment.
I'm a bit concerned that invoking responseTransformer.split may have implications, for example, involving a service call (they are harmless in ou implementations today, but we can't guarantee future implementations or custom implementations).
Is there another way?
There was a problem hiding this comment.
good point, removed. With the mapper on the config, the wrapper's own split() handles both the rewrite and byte counting.
|
|
||
| private final Map<Integer, ByteBuffer> buffers; | ||
|
|
||
| private final UnaryOperator<ResponseT> responseMapper; |
There was a problem hiding this comment.
Question: don't we need to update FileAsyncResponseTransfomer as well?
There was a problem hiding this comment.
No, file's existing split(config) now reads the mapper from the config like every other transformer, so it's handled automatically
| * @return full-object response with total content-length, full content-range, | ||
| * and checksum values nulled if checksum type is COMPOSITE | ||
| */ | ||
| public static GetObjectResponse toFullObjectResponse(GetObjectResponse firstPartResponse) { |
There was a problem hiding this comment.
Should we include other fields such as etag, version ID etc if they are present?
There was a problem hiding this comment.
firstPartResponse.toBuilder() copies all fields. the function only overrides contentLength/contentRange and for COMPOSITE checksums nulls checksums.
Motivation and Context
When the S3 multipart async client downloads a large object via a presigned URL (using ranged GETs for each part), the response metadata exposed to the customer reflects only the first part — not the full object. Customers see an incorrect contentLength (part size instead of total), a partial contentRange, and meaningless per-part composite checksum values.
This fix rewrites the first part's response into a full-object response before it reaches the customer, across both presigned download paths (parallel toFile and serial toBytes/custom transformers).
Modifications
The fix has two prongs, because the two download architectures hand off the response in different places:
ParallelPresignedUrlMultipartDownloaderSubscriber calls toFullObjectResponse(...) before resultFuture.complete(...).
PresignedUrlDownloadHelper uses splitWithResponseRewrite(...).
Both prongs use the same MultipartDownloadUtils.toFullObjectResponse() to do the rewrite:
Response-mapper injection (sdk-core, shared infrastructure). Rather than adding a new split method, responseMapper is carried on the existing public SplittingTransformerConfiguration. The default split(config) reads it and threads it into SplittingTransformer / ByteArraySplittingTransformer, which apply it at their onResponse boundary (defaulting to identity when unset). This keeps the public API surface to a single optional config setter — no new method, no instanceof branching — and every transformer (including FileAsyncResponseTransformer) is handled through its own existing split(config).
Presigned 416 fix. Broadened the empty-object fallback catch to also match a raw S3Exception with status 416. The serial path surfaces the raw exception directly (unwrapped), so the original catch on EmptyObjectRangeNotSatisfiableException alone never matched for custom transformers, skipping the fallback. As a follow-up, EmptyObjectRangeNotSatisfiableException will be removed entirely and both paths unified on isRangeNotSatisfiable().
Transfer Manager progress. With the mapper on the config, splitWithResponseRewrite is a normal transformer.split(config) call, so the TM's progress wrapper's own split() runs and counts bytes correctly. No special routing is needed in GenericS3TransferManager.
Testing
Unit tests
WireMock tests
Integration tests
Screenshots (if appropriate)
Types of changes
Checklist
mvn installsucceedsscripts/new-changescript and following the instructions. Commit the new file created by the script in.changes/next-releasewith your changes.License