Skip to content

Commit f09a14c

Browse files
authored
Fix rare race condition in multipart upload with low maxInFlightParts (#7172)
* fix(s3): Prevent premature CompleteMultipartUpload with low maxInFlightParts The multipart upload subscribers decided whether to initiate CompleteMultipartUpload using a stale snapshot of the in-flight part counter taken by decrementAndGet() in the upload completion callback. Between that snapshot and the completion check, subscription.request(1) can synchronously deliver the final AsyncRequestBody (starting a new upload) followed by onComplete() (setting isDone), because SimplePublisher delivers queued signals on the requesting thread. The callback then saw isDone == true with its stale count of 0 and initiated CompleteMultipartUpload while the final part was still in flight. For unknown content length this failed uploads with 'The number of UploadParts requests is not equal to the expected number of parts. Expected: N, Actual: N-1'. For known content length the part-count validation passed (partNumber was already incremented by the final onNext) and CompleteMultipartUpload was sent with a null part entry. The window only opens when delivery of the final body is gated on completion callbacks' request(1) calls, which is why it was observed with maxInFlightParts=2 but not with the default of 50, and only intermittently under real network timing. Fix: re-read asyncRequestBodyInFlight inside the completion check instead of trusting the caller's snapshot. Once the volatile isDone is observed true, all onNext increments are visible, so a fresh read of 0 guarantees every started upload has finished. Both regression tests script the exact delivery order with a SimplePublisher-faithful Subscription and fail without the fix. * docs: Add changelog entry for multipart upload race fix * minor PR cleanups + refactor out common test util classes
1 parent 1d38255 commit f09a14c

7 files changed

Lines changed: 275 additions & 9 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "bugfix",
3+
"category": "Amazon S3",
4+
"contributor": "",
5+
"description": "Fix a race condition in multipart upload with low maxInFlightParts where CompleteMultipartUpload could be initiated while the final part was still uploading."
6+
}

services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/KnownContentLengthAsyncRequestBodySubscriber.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ public void onNext(CloseableAsyncRequestBody asyncRequestBody) {
207207
subscription.request(1);
208208
}
209209
}
210-
completeMultipartUploadIfFinished(inFlight);
210+
completeMultipartUploadIfFinished();
211211
}
212212
});
213213
}
@@ -263,12 +263,13 @@ public void onComplete() {
263263
log.debug(() -> "Received onComplete()");
264264
isDone = true;
265265
if (!isPaused) {
266-
completeMultipartUploadIfFinished(asyncRequestBodyInFlight.get());
266+
completeMultipartUploadIfFinished();
267267
}
268268
}
269269

270-
private void completeMultipartUploadIfFinished(int requestsInFlight) {
271-
if (isDone && requestsInFlight == 0 && completedMultipartInitiated.compareAndSet(false, true)) {
270+
private void completeMultipartUploadIfFinished() {
271+
// All atomics including asyncRequestBodyInFlight MUST be re-read here rather than passed in by the caller.
272+
if (isDone && asyncRequestBodyInFlight.get() == 0 && completedMultipartInitiated.compareAndSet(false, true)) {
272273
CompletedPart[] parts;
273274

274275
if (existingParts.isEmpty()) {

services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/UnknownContentLengthAsyncRequestBodySubscriber.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ private void sendUploadPartRequest(String uploadId,
232232
subscription.request(1);
233233
}
234234
}
235-
completeMultipartUploadIfFinish(inFlight);
235+
completeMultipartUploadIfFinish();
236236
}
237237
});
238238
}
@@ -266,12 +266,13 @@ public void onComplete() {
266266
multipartUploadHelper.uploadInOneChunk(putObjectRequest, entireRequestBody, returnFuture);
267267
} else {
268268
isDone = true;
269-
completeMultipartUploadIfFinish(asyncRequestBodyInFlight.get());
269+
completeMultipartUploadIfFinish();
270270
}
271271
}
272272

273-
private void completeMultipartUploadIfFinish(int requestsInFlight) {
274-
if (isDone && requestsInFlight == 0 && completedMultipartInitiated.compareAndSet(false, true)) {
273+
private void completeMultipartUploadIfFinish() {
274+
// All atomics including asyncRequestBodyInFlight MUST be re-read here rather than passed in by the caller.
275+
if (isDone && asyncRequestBodyInFlight.get() == 0 && completedMultipartInitiated.compareAndSet(false, true)) {
275276
CompletedPart[] parts = completedParts.stream()
276277
.sorted(Comparator.comparingInt(CompletedPart::partNumber))
277278
.toArray(CompletedPart[]::new);

services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/KnownContentLengthAsyncRequestBodySubscriberTest.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717

1818
import static org.assertj.core.api.Assertions.assertThat;
1919
import static org.mockito.ArgumentMatchers.any;
20+
import static org.mockito.ArgumentMatchers.anyLong;
2021
import static org.mockito.ArgumentMatchers.eq;
2122
import static org.mockito.Mockito.mock;
23+
import static org.mockito.Mockito.never;
2224
import static org.mockito.Mockito.times;
2325
import static org.mockito.Mockito.verify;
2426
import static org.mockito.Mockito.when;
@@ -42,6 +44,8 @@
4244
import software.amazon.awssdk.core.async.CloseableAsyncRequestBody;
4345
import software.amazon.awssdk.core.exception.SdkClientException;
4446
import software.amazon.awssdk.services.s3.S3AsyncClient;
47+
import software.amazon.awssdk.services.s3.internal.multipart.utils.ControlledSubscription;
48+
import software.amazon.awssdk.services.s3.internal.multipart.utils.ManagedUploadPart;
4549
import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadResponse;
4650
import software.amazon.awssdk.services.s3.model.CompletedPart;
4751
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
@@ -261,6 +265,63 @@ void maxInFlightPutObjectParts_shouldLimitConcurrentUploads() {
261265
verify(mockSubscription, times(1)).request(1);
262266
}
263267

268+
/**
269+
* Regression test for early CompleteMultipartUpload with a missing part under low maxInFlightParts.
270+
*/
271+
@Test
272+
void lastBodyAndOnCompleteDeliveredInsideCompletionCallback_shouldCompleteWithAllParts() {
273+
int maxInFlight = 2;
274+
int totalParts = 5;
275+
long contentSize = totalParts * PART_SIZE;
276+
277+
MpuRequestContext context = MpuRequestContext.builder()
278+
.request(Pair.of(putObjectRequest, asyncRequestBody))
279+
.contentLength(contentSize)
280+
.partSize(PART_SIZE)
281+
.uploadId(UPLOAD_ID)
282+
.numPartsCompleted(0L)
283+
.expectedNumParts(totalParts)
284+
.build();
285+
286+
ManagedUploadPart recorder = new ManagedUploadPart();
287+
when(multipartUploadHelper.sendIndividualUploadPartRequest(eq(UPLOAD_ID), any(), any(), any(), any()))
288+
.thenAnswer(recorder);
289+
290+
KnownContentLengthAsyncRequestBodySubscriber sub = createSubscriber(context, maxInFlight);
291+
ControlledSubscription controlledSubscription = new ControlledSubscription(sub);
292+
sub.onSubscribe(controlledSubscription); // requests maxInFlight upfront
293+
294+
controlledSubscription.enqueueBodyAndDeliver(createMockAsyncRequestBody(PART_SIZE)); // part 1 starts
295+
controlledSubscription.enqueueBodyAndDeliver(createMockAsyncRequestBody(PART_SIZE)); // part 2 starts
296+
recorder.completePart(1); // frees a slot
297+
controlledSubscription.enqueueBodyAndDeliver(createMockAsyncRequestBody(PART_SIZE)); // part 3 starts
298+
recorder.completePart(2);
299+
controlledSubscription.enqueueBodyAndDeliver(createMockAsyncRequestBody(PART_SIZE)); // part 4 starts
300+
301+
// Part 3 completes while the final chunk is not buffered yet: its request(1) delivers nothing.
302+
recorder.completePart(3);
303+
304+
// The producer now queues the final body and the stream-complete signal. They will be delivered
305+
// synchronously inside the next request(1), which happens in part 4's completion callback,
306+
// between its decrementAndGet() (returning the stale 0) and completeMultipartUploadIfFinished.
307+
controlledSubscription.enqueueBodyQuietly(createMockAsyncRequestBody(PART_SIZE));
308+
controlledSubscription.enqueueStreamCompleteQuietly();
309+
recorder.completePart(4);
310+
311+
// verify we haven't called CompleteMultipartUpload yet.
312+
verify(multipartUploadHelper, never()).completeMultipartUpload(any(), any(), any(), any(), anyLong());
313+
verify(multipartUploadHelper, never()).failRequestsElegantly(any(), any(), any(), any(), any());
314+
315+
// Part 5's upload finishes; only now should CompleteMultipartUpload be sent, with all 5 parts.
316+
recorder.completePart(5);
317+
318+
ArgumentCaptor<CompletedPart[]> partsCaptor = ArgumentCaptor.forClass(CompletedPart[].class);
319+
verify(multipartUploadHelper).completeMultipartUpload(eq(returnFuture), eq(UPLOAD_ID), partsCaptor.capture(),
320+
eq(putObjectRequest), eq(contentSize));
321+
assertThat(partsCaptor.getValue()).hasSize(totalParts).doesNotContainNull();
322+
verify(multipartUploadHelper, never()).failRequestsElegantly(any(), any(), any(), any(), any());
323+
}
324+
264325
private MpuRequestContext createDefaultMpuRequestContext() {
265326
return MpuRequestContext.builder()
266327
.request(Pair.of(putObjectRequest, AsyncRequestBody.fromFile(testFile)))

services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/UnknownContentLengthAsyncRequestBodySubscriberTest.java

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818
import static org.assertj.core.api.Assertions.assertThat;
1919
import static org.assertj.core.api.Assertions.assertThatThrownBy;
2020
import static org.mockito.ArgumentMatchers.any;
21+
import static org.mockito.ArgumentMatchers.anyLong;
2122
import static org.mockito.ArgumentMatchers.eq;
2223
import static org.mockito.Mockito.mock;
24+
import static org.mockito.Mockito.never;
2325
import static org.mockito.Mockito.times;
2426
import static org.mockito.Mockito.verify;
2527
import static org.mockito.Mockito.when;
@@ -32,7 +34,8 @@
3234
import org.reactivestreams.Subscription;
3335
import software.amazon.awssdk.core.async.CloseableAsyncRequestBody;
3436
import software.amazon.awssdk.core.exception.SdkClientException;
35-
import software.amazon.awssdk.services.s3.S3AsyncClient;
37+
import software.amazon.awssdk.services.s3.internal.multipart.utils.ControlledSubscription;
38+
import software.amazon.awssdk.services.s3.internal.multipart.utils.ManagedUploadPart;
3639
import software.amazon.awssdk.services.s3.model.CompletedPart;
3740
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
3841
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
@@ -165,6 +168,57 @@ void onComplete_withNoParts_shouldUploadEmptyBody() {
165168
verify(multipartUploadHelper).uploadInOneChunk(eq(putObjectRequest), any(), eq(returnFuture));
166169
}
167170

171+
/**
172+
* Regression test for the "Expected: N, Actual: N-1" part-count failure seen with low maxInFlightParts.
173+
*/
174+
@Test
175+
void lastBodyAndOnCompleteDeliveredInsideCompletionCallback_shouldCompleteWithAllParts() {
176+
int maxInFlight = 2;
177+
int numParts = 5;
178+
UnknownContentLengthAsyncRequestBodySubscriber subscriber = createSubscriber(maxInFlight);
179+
180+
stubSuccessfulCreateMultipartCall();
181+
when(genericMultipartHelper.determinePartCount(anyLong(), anyLong()))
182+
.thenAnswer(invocation -> (int) Math.ceil(invocation.getArgument(0, Long.class)
183+
/ (double) invocation.getArgument(1, Long.class)));
184+
ManagedUploadPart recorder = new ManagedUploadPart();
185+
when(multipartUploadHelper.sendIndividualUploadPartRequest(any(), any(), any(), any(), any()))
186+
.thenAnswer(recorder);
187+
188+
ControlledSubscription subscription = new ControlledSubscription(subscriber);
189+
subscriber.onSubscribe(subscription);
190+
191+
subscription.enqueueBodyAndDeliver(createMockAsyncRequestBody(PART_SIZE)); // held as firstRequestBody
192+
subscription.enqueueBodyAndDeliver(createMockAsyncRequestBody(PART_SIZE)); // triggers MPU; parts 1, 2 start
193+
recorder.completePart(1); // frees a slot
194+
subscription.enqueueBodyAndDeliver(createMockAsyncRequestBody(PART_SIZE)); // part 3 starts
195+
recorder.completePart(2);
196+
subscription.enqueueBodyAndDeliver(createMockAsyncRequestBody(PART_SIZE)); // part 4 starts
197+
198+
// Part 3 completes while the final chunk is not buffered yet: its request(1) delivers nothing.
199+
recorder.completePart(3);
200+
201+
// The producer now queues the final body and the stream-complete signal. They will be delivered
202+
// synchronously inside the next request(1), which happens in part 4's completion callback,
203+
// between its decrementAndGet() (returning the stale 0) and completeMultipartUploadIfFinish.
204+
subscription.enqueueBodyQuietly(createMockAsyncRequestBody(PART_SIZE));
205+
subscription.enqueueStreamCompleteQuietly();
206+
recorder.completePart(4);
207+
208+
// verify we haven't called CompleteMultipartUpload yet.
209+
verify(multipartUploadHelper, never()).failRequestsElegantly(any(), any(), any(), any(), any());
210+
verify(multipartUploadHelper, never()).completeMultipartUpload(any(), any(), any(), any(), anyLong());
211+
212+
// Part 5's upload finishes; only now should CompleteMultipartUpload be sent, with all 5 parts.
213+
recorder.completePart(5);
214+
215+
ArgumentCaptor<CompletedPart[]> partsCaptor = ArgumentCaptor.forClass(CompletedPart[].class);
216+
verify(multipartUploadHelper).completeMultipartUpload(eq(returnFuture), eq(UPLOAD_ID), partsCaptor.capture(),
217+
eq(putObjectRequest), eq(numParts * PART_SIZE));
218+
assertThat(partsCaptor.getValue()).hasSize(numParts).doesNotContainNull();
219+
verify(multipartUploadHelper, never()).failRequestsElegantly(any(), any(), any(), any(), any());
220+
}
221+
168222
private UnknownContentLengthAsyncRequestBodySubscriber createSubscriber(int maxInFlightParts) {
169223
return new UnknownContentLengthAsyncRequestBodySubscriber(
170224
PART_SIZE, putObjectRequest, returnFuture,
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.awssdk.services.s3.internal.multipart.utils;
17+
18+
import java.util.ArrayDeque;
19+
import java.util.Deque;
20+
import org.reactivestreams.Subscriber;
21+
import org.reactivestreams.Subscription;
22+
import software.amazon.awssdk.core.async.CloseableAsyncRequestBody;
23+
24+
/**
25+
* A Subscription that mimics {@link software.amazon.awssdk.utils.async.SimplePublisher}: queued signals
26+
* are delivered synchronously on the thread that calls request(), onNext delivery is gated on demand,
27+
* and onComplete is delivered once the queue drains, without needing demand.
28+
*/
29+
public final class ControlledSubscription implements Subscription {
30+
private final Subscriber<? super CloseableAsyncRequestBody> subscriber;
31+
private final Deque<CloseableAsyncRequestBody> queuedBodies = new ArrayDeque<>();
32+
private long demand;
33+
private boolean streamComplete;
34+
private boolean onCompleteDelivered;
35+
private boolean draining;
36+
37+
public ControlledSubscription(Subscriber<? super CloseableAsyncRequestBody> subscriber) {
38+
this.subscriber = subscriber;
39+
}
40+
41+
@Override
42+
public void request(long n) {
43+
demand += n;
44+
drain();
45+
}
46+
47+
@Override
48+
public void cancel() {
49+
}
50+
51+
public void enqueueBodyAndDeliver(CloseableAsyncRequestBody body) {
52+
queuedBodies.add(body);
53+
drain();
54+
}
55+
56+
public void enqueueBodyQuietly(CloseableAsyncRequestBody body) {
57+
queuedBodies.add(body);
58+
}
59+
60+
public void enqueueStreamCompleteQuietly() {
61+
streamComplete = true;
62+
}
63+
64+
private void drain() {
65+
if (draining) {
66+
return; // mirrors SimplePublisher's processingQueue flag: no re-entrant delivery
67+
}
68+
draining = true;
69+
try {
70+
while (demand > 0 && !queuedBodies.isEmpty()) {
71+
demand--;
72+
subscriber.onNext(queuedBodies.poll());
73+
}
74+
if (streamComplete && queuedBodies.isEmpty() && !onCompleteDelivered) {
75+
onCompleteDelivered = true;
76+
subscriber.onComplete();
77+
}
78+
} finally {
79+
draining = false;
80+
}
81+
}
82+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.awssdk.services.s3.internal.multipart.utils;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
20+
import java.util.HashMap;
21+
import java.util.Map;
22+
import java.util.concurrent.CompletableFuture;
23+
import java.util.function.Consumer;
24+
import org.mockito.invocation.InvocationOnMock;
25+
import org.mockito.stubbing.Answer;
26+
import software.amazon.awssdk.core.async.AsyncRequestBody;
27+
import software.amazon.awssdk.services.s3.model.CompletedPart;
28+
import software.amazon.awssdk.services.s3.model.UploadPartRequest;
29+
import software.amazon.awssdk.utils.Pair;
30+
31+
/**
32+
* Records the consumer and pending future of each UploadPart request so a test can complete parts
33+
* the same way MultipartUploadHelper does: consumer.accept(completedPart) followed by future completion.
34+
*
35+
* <p>Intended to be used as a Mockito {@link Answer} for
36+
* {@code MultipartUploadHelper#sendIndividualUploadPartRequest}.
37+
*/
38+
public final class ManagedUploadPart implements Answer<CompletableFuture<CompletedPart>> {
39+
private final Map<Integer, Consumer<CompletedPart>> consumers = new HashMap<>();
40+
private final Map<Integer, CompletableFuture<CompletedPart>> futures = new HashMap<>();
41+
42+
@Override
43+
@SuppressWarnings("unchecked")
44+
public CompletableFuture<CompletedPart> answer(InvocationOnMock invocation) {
45+
Consumer<CompletedPart> consumer = invocation.getArgument(1, Consumer.class);
46+
Pair<UploadPartRequest, AsyncRequestBody> pair = invocation.getArgument(3, Pair.class);
47+
int partNumber = pair.left().partNumber();
48+
CompletableFuture<CompletedPart> future = new CompletableFuture<>();
49+
consumers.put(partNumber, consumer);
50+
futures.put(partNumber, future);
51+
return future;
52+
}
53+
54+
public void completePart(int partNumber) {
55+
CompletableFuture<CompletedPart> future = futures.get(partNumber);
56+
assertThat(future).withFailMessage("UploadPart request for part %d was never sent", partNumber).isNotNull();
57+
CompletedPart part = CompletedPart.builder().partNumber(partNumber).eTag("etag-" + partNumber).build();
58+
consumers.get(partNumber).accept(part);
59+
future.complete(part);
60+
}
61+
}

0 commit comments

Comments
 (0)