Skip to content

maxInFlightParts support for PutObject#6801

Open
alextwoods wants to merge 13 commits intomasterfrom
alexwoo/max_in_flight_s3
Open

maxInFlightParts support for PutObject#6801
alextwoods wants to merge 13 commits intomasterfrom
alexwoo/max_in_flight_s3

Conversation

@alextwoods
Copy link
Contributor

@alextwoods alextwoods commented Mar 17, 2026

Motivation and Context

When MultipartS3AsyncClient.putObject() performs a multipart upload of a large object, all UploadPart requests are dispatched eagerly with no concurrency limit. For example, a 2 GB upload with 8 MiB parts produces ~256 UploadPart requests that immediately compete for the HTTP connection pool (default maxConcurrency=50), leading to connection acquisition timeouts:

SdkClientException: Unable to execute HTTP request: Acquire operation took longer than the configured maximum time.

The existing maxInFlightParts configuration in ParallelConfiguration already limits concurrent GetObject requests for multipart downloads, but was not applied to the upload path.

Fixes #6623

Modifications

  • Extended the maxInFlightParts configuration to also apply to multipart upload (putObject) concurrency. The setting now limits concurrent part requests for both getObject (download) and putObject (upload) operations. Updated the Javadoc on ParallelConfiguration.maxInFlightParts() to reflect this broader scope.
  • In KnownContentLengthAsyncRequestBodySubscriber.onNext(): instead of unconditionally calling subscription.request(1) after dispatching each UploadPart, the subscriber now checks asyncRequestBodyInFlight < maxInFlightParts. When a part completes and in-flight count drops below the limit, subscription.request(1) resumes flow.
  • Applied the same pattern in UnknownContentLengthAsyncRequestBodySubscriber.sendUploadPartRequest().
  • Extracted UnknownContentLengthAsyncRequestBodySubscriber from an inner class of UploadWithUnknownContentLengthHelper into its own top-level class, matching the existing pattern of KnownContentLengthAsyncRequestBodySubscriber.

Testing

  • New and existing unit tests.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)

Checklist

  • I have read the CONTRIBUTING document
  • Local run of mvn install succeeds
  • My code follows the code style of this project
  • My change requires a change to the Javadoc documentation
  • I have updated the Javadoc documentation accordingly
  • I have added tests to cover my changes
  • All new and existing tests passed
  • I have added a changelog entry. Adding a new entry must be accomplished by running the scripts/new-change script and following the instructions. Commit the new file created by the script in .changes/next-release with your changes.

License

  • I confirm that this pull request can be released under the Apache 2 license

@alextwoods alextwoods changed the title Alexwoo/max in flight s3 maxInFlightParts support for PutObject Mar 17, 2026
"software.amazon.awssdk.services.s3.internal.crt.CrtResponseFileResponseTransformer"),
ArchUtils.classNameToPattern(RetryableSubAsyncRequestBody.class)));
ArchUtils.classNameToPattern(RetryableSubAsyncRequestBody.class),
ArchUtils.classNameToPattern(KnownContentLengthAsyncRequestBodySubscriber.class)));
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This warn logging was pre-existing - see the change in UploadWithUnknownContentLengthHelper that moved the existing warning into KnownContentLengthAsyncRequestBodySubscriber

@alextwoods alextwoods added the api-surface-area-approved-by-team Indicate API surface area introduced by this PR has been approved by team label Mar 18, 2026
@alextwoods alextwoods marked this pull request as ready for review March 18, 2026 19:28
@alextwoods alextwoods requested a review from a team as a code owner March 18, 2026 19:28
import software.amazon.awssdk.utils.Pair;

@SdkInternalApi
public class UnknownContentLengthAsyncRequestBodySubscriber implements Subscriber<CloseableAsyncRequestBody> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for moving it to its own class! I've always wanted to do that!

Are there any changes on the logic?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It made it much easier to test!

There are changes in the logic - you can see them in the commit here before I moved it out: 335d6db#diff-4fe5c54707205f54fb73bd2acef1fd89181e7ccb1647b4a3a7cd64698c750a9d

completeMultipartUploadIfFinished(asyncRequestBodyInFlight.decrementAndGet());
int inFlight = asyncRequestBodyInFlight.decrementAndGet();
if (!isDone && inFlight < maxInFlightParts) {
subscription.request(1);
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it necessary to request here? It seems we could sent maxInFlightParts + 1 requests

1. Thread A (onNext): increments asyncRequestBodyInFlight to N, dispatches the part.
2. Thread B (whenComplete): decrements to N-1, sees N-1 < maxInFlightParts, calls request(1).
3. Thread A (inline): reads asyncRequestBodyInFlight.get() which is now N-1, sees N-1 < maxInFlightParts, calls request(1).

I think we also need synchronize subscription.request(1) here per https://github.com/reactive-streams/reactive-streams-jvm

§2.7: "A Subscriber MUST ensure that all calls on its Subscription's request and cancel methods are performed serially."

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ugh, Reactive streams are always so hard to think through.... Okay, so I think it is necessary to call request here in the completion callback - thats the most natural way I can think of to request more once requests complete.

But you're right that this results in a potential race condition where we request more than maxParts. I was trying to be too clever and keep the logic between known and unknown content length more consistent. What the ParallelMultipartDownloaderSubscriber does is request(maxInFlightParts) up front in onSubscribe and then only request more whenComplete. I've updated the code now to match this pattern - request maxInFlightParts up front and then only request more whenComplete. However, it complicates the UnknownContentLength case a little bit - we still request only 1 onSubscribe, but then once we know its an MPU case we then request the maxInflight.

You're also right about the synchronize. My first pass used atomics alone to ensure this, but with this approach we do need to sycnhronize :-(

Great catches! Reactive streams always hurts my brain.

Copy link
Contributor

Choose a reason for hiding this comment

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

Reactive streams always hurts my brain.

Haha, that makes two of us!

* The maximum number of concurrent part requests that are allowed for multipart operations, including both multipart
* download (GetObject) and multipart upload (PutObject). This limits the number of parts that can be in flight at any
* given time, preventing the client from overwhelming the HTTP connection pool when transferring large objects. For
* getObject it applies only when the {@link AsyncResponseTransformer} supports parallel split.
Copy link
Contributor

Choose a reason for hiding this comment

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

This is also true for putObject, right?

Copy link
Contributor Author

@alextwoods alextwoods Mar 23, 2026

Choose a reason for hiding this comment

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

Check my logic here, but I think its actually only true for GetObject - uploads only have a known/unknown length distinction rather than a parallel/serial split. In UploadObjectHelper#uploadObject we just check if we have contentLength and then delegate to either the known or unknown length helpers. Both of those work similarly (except the unknown requests a part first to see if the size is > part size before starting the MPU...) and both use asyncRequestBody.splitCloseable. (eg, see UploadWithKnownContentLengthHelper#uploadObject)

Copy link
Contributor

@zoewangg zoewangg Mar 24, 2026

Choose a reason for hiding this comment

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

Ah right, for uploads, we read in serial for non-file request bodies but send in parallel depending on the buffer

@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

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

Labels

api-surface-area-approved-by-team Indicate API surface area introduced by this PR has been approved by team