diff --git a/.changes/next-release/bugfix-AWSSDKforJavav2-974558b.json b/.changes/next-release/bugfix-AWSSDKforJavav2-974558b.json
new file mode 100644
index 000000000000..77b4001abc2d
--- /dev/null
+++ b/.changes/next-release/bugfix-AWSSDKforJavav2-974558b.json
@@ -0,0 +1,6 @@
+{
+ "category": "AWS SDK for Java v2",
+ "contributor": "",
+ "type": "bugfix",
+ "description": "Fixed an issue where event streams might fail with ClassCastException or NoSuchElementExceptions"
+}
diff --git a/.changes/next-release/feature-AWSSDKforJavav2-d2a3922.json b/.changes/next-release/feature-AWSSDKforJavav2-d2a3922.json
new file mode 100644
index 000000000000..5396cf9ffcc5
--- /dev/null
+++ b/.changes/next-release/feature-AWSSDKforJavav2-d2a3922.json
@@ -0,0 +1,6 @@
+{
+ "category": "AWS SDK for Java v2",
+ "contributor": "",
+ "type": "feature",
+ "description": "Added new convenience methods to SdkPublisher: doAfterOnError, doAfterOnComplete, and doAfterCancel."
+}
diff --git a/core/aws-core/pom.xml b/core/aws-core/pom.xml
index be41b0b84d10..907e79959fa9 100644
--- a/core/aws-core/pom.xml
+++ b/core/aws-core/pom.xml
@@ -73,10 +73,6 @@
utils
${awsjavasdk.version}
-
- org.slf4j
- slf4j-api
-
software.amazon.eventstream
eventstream
diff --git a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/eventstream/EventStreamAsyncResponseTransformer.java b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/eventstream/EventStreamAsyncResponseTransformer.java
index d8437707427e..95b664774423 100644
--- a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/eventstream/EventStreamAsyncResponseTransformer.java
+++ b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/eventstream/EventStreamAsyncResponseTransformer.java
@@ -15,41 +15,35 @@
package software.amazon.awssdk.awscore.eventstream;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singleton;
import static java.util.Collections.singletonList;
import static software.amazon.awssdk.core.http.HttpResponseHandler.X_AMZN_REQUEST_ID_HEADER;
import static software.amazon.awssdk.core.http.HttpResponseHandler.X_AMZN_REQUEST_ID_HEADERS;
import static software.amazon.awssdk.core.http.HttpResponseHandler.X_AMZ_ID_2_HEADER;
-import static software.amazon.awssdk.utils.FunctionalUtils.runAndLogError;
import java.io.ByteArrayInputStream;
import java.nio.ByteBuffer;
import java.util.HashMap;
-import java.util.LinkedList;
import java.util.List;
import java.util.Map;
-import java.util.Queue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.concurrent.atomic.AtomicReference;
-import org.reactivestreams.Publisher;
-import org.reactivestreams.Subscriber;
-import org.reactivestreams.Subscription;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import java.util.function.Supplier;
import software.amazon.awssdk.annotations.SdkProtectedApi;
import software.amazon.awssdk.core.SdkResponse;
import software.amazon.awssdk.core.async.AsyncResponseTransformer;
import software.amazon.awssdk.core.async.SdkPublisher;
import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.core.http.HttpResponseHandler;
import software.amazon.awssdk.core.interceptor.ExecutionAttributes;
import software.amazon.awssdk.core.interceptor.SdkExecutionAttribute;
import software.amazon.awssdk.http.AbortableInputStream;
-import software.amazon.awssdk.http.SdkCancellationException;
import software.amazon.awssdk.http.SdkHttpFullResponse;
-import software.amazon.awssdk.utils.BinaryUtils;
+import software.amazon.awssdk.utils.Logger;
+import software.amazon.awssdk.utils.Validate;
import software.amazon.awssdk.utils.http.SdkHttpUtils;
import software.amazon.eventstream.Message;
import software.amazon.eventstream.MessageDecoder;
@@ -64,12 +58,7 @@
@SdkProtectedApi
public final class EventStreamAsyncResponseTransformer
implements AsyncResponseTransformer {
-
- private static final Logger log = LoggerFactory.getLogger(EventStreamAsyncResponseTransformer.class);
-
- private static final Object ON_COMPLETE_EVENT = new Object();
-
- private static final ExecutionAttributes EMPTY_EXECUTION_ATTRIBUTES = new ExecutionAttributes();
+ private static final Logger log = Logger.loggerFor(EventStreamAsyncResponseTransformer.class);
/**
* {@link EventStreamResponseHandler} provided by customer.
@@ -91,51 +80,7 @@ public final class EventStreamAsyncResponseTransformer
*/
private final HttpResponseHandler extends Throwable> exceptionResponseHandler;
- /**
- * Remaining demand (i.e number of unmarshalled events) we need to provide to the customers subscriber.
- */
- private final AtomicLong remainingDemand = new AtomicLong(0);
-
- /**
- * Reference to customers subscriber to events.
- */
- private final AtomicReference> subscriberRef = new AtomicReference<>();
-
- private final AtomicReference dataSubscription = new AtomicReference<>();
-
- /**
- * Event stream message decoder that decodes the binary data into "frames". These frames are then passed to the
- * unmarshaller to produce the event POJO.
- */
- private final MessageDecoder decoder = new MessageDecoder(this::handleMessage);
-
- /**
- * Tracks whether we have delivered a terminal notification to the subscriber and response handler
- * (i.e. exception or completion).
- */
- private volatile boolean isDone = false;
-
- /**
- * Executor to deliver events to the subscriber
- */
- private final Executor executor;
-
- /**
- * Queue of events to deliver to downstream subscriber. Will contain mostly objects
- * of type EventT, the special {@link #ON_COMPLETE_EVENT} will be added when all events
- * have been added to the queue.
- */
- private final Queue
+
+ org.reactivestreams
+ reactive-streams-tck
+ test
+
diff --git a/utils/src/main/java/software/amazon/awssdk/utils/async/DelegatingSubscriber.java b/utils/src/main/java/software/amazon/awssdk/utils/async/DelegatingSubscriber.java
index 72b2fbe9269c..04e4725fd670 100644
--- a/utils/src/main/java/software/amazon/awssdk/utils/async/DelegatingSubscriber.java
+++ b/utils/src/main/java/software/amazon/awssdk/utils/async/DelegatingSubscriber.java
@@ -15,14 +15,15 @@
package software.amazon.awssdk.utils.async;
+import java.util.concurrent.atomic.AtomicBoolean;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import software.amazon.awssdk.annotations.SdkProtectedApi;
@SdkProtectedApi
public abstract class DelegatingSubscriber implements Subscriber {
-
protected final Subscriber super U> subscriber;
+ private final AtomicBoolean complete = new AtomicBoolean(false);
protected DelegatingSubscriber(Subscriber super U> subscriber) {
this.subscriber = subscriber;
@@ -35,12 +36,15 @@ public void onSubscribe(Subscription subscription) {
@Override
public void onError(Throwable throwable) {
- subscriber.onError(throwable);
+ if (complete.compareAndSet(false, true)) {
+ subscriber.onError(throwable);
+ }
}
@Override
public void onComplete() {
- subscriber.onComplete();
+ if (complete.compareAndSet(false, true)) {
+ subscriber.onComplete();
+ }
}
-
}
diff --git a/utils/src/main/java/software/amazon/awssdk/utils/async/EventListeningSubscriber.java b/utils/src/main/java/software/amazon/awssdk/utils/async/EventListeningSubscriber.java
new file mode 100644
index 000000000000..7639c57086be
--- /dev/null
+++ b/utils/src/main/java/software/amazon/awssdk/utils/async/EventListeningSubscriber.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.utils.async;
+
+import java.util.function.Consumer;
+import org.reactivestreams.Subscriber;
+import org.reactivestreams.Subscription;
+import software.amazon.awssdk.annotations.SdkProtectedApi;
+import software.amazon.awssdk.utils.Logger;
+
+/**
+ * A {@link Subscriber} that can invoke callbacks during various parts of the subscriber and subscription lifecycle.
+ */
+@SdkProtectedApi
+public final class EventListeningSubscriber extends DelegatingSubscriber {
+ private static final Logger log = Logger.loggerFor(EventListeningSubscriber.class);
+
+ private final Runnable afterCompleteListener;
+ private final Consumer afterErrorListener;
+ private final Runnable afterCancelListener;
+
+ public EventListeningSubscriber(Subscriber subscriber,
+ Runnable afterCompleteListener,
+ Consumer afterErrorListener,
+ Runnable afterCancelListener) {
+ super(subscriber);
+ this.afterCompleteListener = afterCompleteListener;
+ this.afterErrorListener = afterErrorListener;
+ this.afterCancelListener = afterCancelListener;
+ }
+
+ @Override
+ public void onNext(T t) {
+ super.subscriber.onNext(t);
+ }
+
+ @Override
+ public void onSubscribe(Subscription subscription) {
+ super.onSubscribe(new CancelListeningSubscriber(subscription));
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ super.onError(throwable);
+ if (afterErrorListener != null) {
+ callListener(() -> afterErrorListener.accept(throwable),
+ "Post-onError callback failed. This exception will be dropped.");
+ }
+ }
+
+ @Override
+ public void onComplete() {
+ super.onComplete();
+ callListener(afterCompleteListener, "Post-onComplete callback failed. This exception will be dropped.");
+ }
+
+ private class CancelListeningSubscriber extends DelegatingSubscription {
+ protected CancelListeningSubscriber(Subscription s) {
+ super(s);
+ }
+
+ @Override
+ public void cancel() {
+ super.cancel();
+ callListener(afterCompleteListener, "Post-cancel callback failed. This exception will be dropped.");
+ }
+ }
+
+ private void callListener(Runnable listener, String listenerFailureMessage) {
+ if (listener != null) {
+ try {
+ listener.run();
+ } catch (RuntimeException e) {
+ log.error(() -> listenerFailureMessage, e);
+ }
+ }
+ }
+}
diff --git a/utils/src/main/java/software/amazon/awssdk/utils/async/FlatteningSubscriber.java b/utils/src/main/java/software/amazon/awssdk/utils/async/FlatteningSubscriber.java
index 08c11556a836..3303485e1250 100644
--- a/utils/src/main/java/software/amazon/awssdk/utils/async/FlatteningSubscriber.java
+++ b/utils/src/main/java/software/amazon/awssdk/utils/async/FlatteningSubscriber.java
@@ -15,48 +15,82 @@
package software.amazon.awssdk.utils.async;
-import java.util.LinkedList;
-import java.util.Queue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
-import java.util.stream.Collectors;
-import java.util.stream.StreamSupport;
+import java.util.concurrent.atomic.AtomicReference;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import software.amazon.awssdk.annotations.SdkProtectedApi;
+import software.amazon.awssdk.utils.Logger;
+import software.amazon.awssdk.utils.Validate;
@SdkProtectedApi
public class FlatteningSubscriber extends DelegatingSubscriber, U> {
+ private static final Logger log = Logger.loggerFor(FlatteningSubscriber.class);
- private final AtomicLong demand = new AtomicLong(0);
- private final Object lock = new Object();
+ /**
+ * The amount of unfulfilled demand open against the upstream subscriber.
+ */
+ private final AtomicLong upstreamDemand = new AtomicLong(0);
- private boolean requestedNextBatch;
- private Queue currentBatch;
- private boolean onCompleteCalled = false;
- private Subscription sourceSubscription;
+ /**
+ * The amount of unfulfilled demand the downstream subscriber has opened against us.
+ */
+ private final AtomicLong downstreamDemand = new AtomicLong(0);
+
+ /**
+ * A flag that is used to ensure that only one thread is handling updates to the state of this subscriber at a time. This
+ * allows us to ensure that the downstream onNext, onComplete and onError are only ever invoked serially.
+ */
+ private final AtomicBoolean handlingStateUpdate = new AtomicBoolean(false);
+
+ /**
+ * Items given to us by the upstream subscriber that we will use to fulfill demand of the downstream subscriber.
+ */
+ private final LinkedBlockingQueue allItems = new LinkedBlockingQueue<>();
+
+ /**
+ * Whether the upstream subscriber has called onError on us. If this is null, we haven't gotten an onError. If it's non-null
+ * this will be the exception that the upstream passed to our onError. After we get an onError, we'll call onError on the
+ * downstream subscriber as soon as possible.
+ */
+ private final AtomicReference onErrorFromUpstream = new AtomicReference<>(null);
+
+ /**
+ * Whether we have called onComplete or onNext on the downstream subscriber.
+ */
+ private volatile boolean terminalCallMadeDownstream = false;
+
+ /**
+ * Whether the upstream subscriber has called onComplete on us. After this happens, we'll drain any outstanding items in the
+ * allItems queue and then call onComplete on the downstream subscriber.
+ */
+ private volatile boolean onCompleteCalledByUpstream = false;
+
+ /**
+ * The subscription to the upstream subscriber.
+ */
+ private Subscription upstreamSubscription;
public FlatteningSubscriber(Subscriber super U> subscriber) {
super(subscriber);
- currentBatch = new LinkedList<>();
}
@Override
public void onSubscribe(Subscription subscription) {
- sourceSubscription = subscription;
+ if (upstreamSubscription != null) {
+ log.warn(() -> "Received duplicate subscription, cancelling the duplicate.", new IllegalStateException());
+ subscription.cancel();
+ return;
+ }
+
+ upstreamSubscription = subscription;
subscriber.onSubscribe(new Subscription() {
@Override
public void request(long l) {
- synchronized (lock) {
- demand.addAndGet(l);
- // Execution goes into `if` block only once for the initial request
- // After that requestedNextBatch is always true and more requests are made in fulfillDemand()
- if (!requestedNextBatch) {
- requestedNextBatch = true;
- sourceSubscription.request(1);
- } else {
- fulfillDemand();
- }
- }
+ addDownstreamDemand(l);
+ handleStateUpdate();
}
@Override
@@ -68,34 +102,165 @@ public void cancel() {
@Override
public void onNext(Iterable nextItems) {
- synchronized (lock) {
- currentBatch = StreamSupport.stream(nextItems.spliterator(), false)
- .collect(Collectors.toCollection(LinkedList::new));
- fulfillDemand();
+ try {
+ nextItems.forEach(item -> {
+ Validate.notNull(nextItems, "Collections flattened by the flattening subscriber must not contain null.");
+ allItems.add(item);
+ });
+ } catch (NullPointerException e) {
+ upstreamSubscription.cancel();
+ onError(e);
+ throw e;
+ }
+
+ upstreamDemand.decrementAndGet();
+ handleStateUpdate();
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ onErrorFromUpstream.compareAndSet(null, throwable);
+ handleStateUpdate();
+ }
+
+ @Override
+ public void onComplete() {
+ onCompleteCalledByUpstream = true;
+ handleStateUpdate();
+ }
+
+ /**
+ * Increment the downstream demand by the provided value, accounting for overflow.
+ */
+ private void addDownstreamDemand(long l) {
+ Validate.isTrue(l > 0, "Demand must not be negative.");
+ downstreamDemand.getAndUpdate(current -> {
+ long newValue = current + l;
+ return newValue >= 0 ? newValue : Long.MAX_VALUE;
+ });
+ }
+
+ /**
+ * This is invoked after each downstream request or upstream onNext, onError or onComplete.
+ */
+ private void handleStateUpdate() {
+ do {
+ // Anything that happens after this if statement and before we set handlingStateUpdate to false is guaranteed to only
+ // happen on one thread. For that reason, we should only invoke onNext, onComplete or onError within that block.
+ if (!handlingStateUpdate.compareAndSet(false, true)) {
+ return;
+ }
+
+ try {
+ // If we've already called onComplete or onError, don't do anything.
+ if (terminalCallMadeDownstream) {
+ return;
+ }
+
+ // Call onNext, onComplete and onError as needed based on the current subscriber state.
+ handleOnNextState();
+ handleUpstreamDemandState();
+ handleOnCompleteState();
+ handleOnErrorState();
+ } catch (Error e) {
+ throw e;
+ } catch (Throwable e) {
+ log.error(() -> "Unexpected exception encountered that violates the reactive streams specification. Attempting "
+ + "to terminate gracefully.", e);
+ upstreamSubscription.cancel();
+ onError(e);
+ } finally {
+ handlingStateUpdate.set(false);
+ }
+
+ // It's possible we had an important state change between when we decided to release the state update flag, and we
+ // actually released it. If that seems to have happened, try to handle that state change on this thread, because
+ // another thread is not guaranteed to come around and do so.
+ } while (onNextNeeded() || upstreamDemandNeeded() || onCompleteNeeded() || onErrorNeeded());
+ }
+
+ /**
+ * Fulfill downstream demand by pulling items out of the item queue and sending them downstream.
+ */
+ private void handleOnNextState() {
+ while (onNextNeeded() && !onErrorNeeded()) {
+ downstreamDemand.decrementAndGet();
+ subscriber.onNext(allItems.poll());
}
}
- private void fulfillDemand() {
- while (demand.get() > 0 && !currentBatch.isEmpty()) {
- demand.decrementAndGet();
- subscriber.onNext(currentBatch.poll());
+ /**
+ * Returns true if we need to call onNext downstream. If this is executed outside the handling-state-update condition, the
+ * result is subject to change.
+ */
+ private boolean onNextNeeded() {
+ return !allItems.isEmpty() && downstreamDemand.get() > 0;
+ }
+
+ /**
+ * Request more upstream demand if it's needed.
+ */
+ private void handleUpstreamDemandState() {
+ if (upstreamDemandNeeded()) {
+ ensureUpstreamDemandExists();
}
+ }
- if (onCompleteCalled && currentBatch.isEmpty()) {
+ /**
+ * Returns true if we need to increase our upstream demand.
+ */
+ private boolean upstreamDemandNeeded() {
+ return upstreamDemand.get() <= 0 && downstreamDemand.get() > 0 && allItems.isEmpty();
+ }
+
+ /**
+ * If there are zero pending items in the queue and the upstream has called onComplete, then tell the downstream
+ * we're done.
+ */
+ private void handleOnCompleteState() {
+ if (onCompleteNeeded()) {
+ terminalCallMadeDownstream = true;
subscriber.onComplete();
- } else if (currentBatch.isEmpty() && demand.get() > 0) {
- requestedNextBatch = true;
- sourceSubscription.request(1);
}
}
- @Override
- public void onComplete() {
- synchronized (lock) {
- onCompleteCalled = true;
- if (currentBatch.isEmpty()) {
- subscriber.onComplete();
- }
+ /**
+ * Returns true if we need to call onNext downstream. If this is executed outside the handling-state-update condition, the
+ * result is subject to change.
+ */
+ private boolean onCompleteNeeded() {
+ return allItems.isEmpty() && onCompleteCalledByUpstream && !terminalCallMadeDownstream;
+ }
+
+ /**
+ * If the upstream has called onError, then tell the downstream we're done, no matter what state the queue is in.
+ */
+ private void handleOnErrorState() {
+ if (onErrorNeeded()) {
+ terminalCallMadeDownstream = true;
+ subscriber.onError(onErrorFromUpstream.get());
+ }
+ }
+
+ /**
+ * Returns true if we need to call onError downstream. If this is executed outside the handling-state-update condition, the
+ * result is subject to change.
+ */
+ private boolean onErrorNeeded() {
+ return onErrorFromUpstream.get() != null && !terminalCallMadeDownstream;
+ }
+
+ /**
+ * Ensure that we have at least 1 demand upstream, so that we can get more items.
+ */
+ private void ensureUpstreamDemandExists() {
+ if (this.upstreamDemand.get() < 0) {
+ log.error(() -> "Upstream delivered more data than requested. Resetting state to prevent a frozen stream.",
+ new IllegalStateException());
+ upstreamDemand.set(1);
+ upstreamSubscription.request(1);
+ } else if (this.upstreamDemand.compareAndSet(0, 1)) {
+ upstreamSubscription.request(1);
}
}
}
diff --git a/utils/src/main/java/software/amazon/awssdk/utils/async/SequentialSubscriber.java b/utils/src/main/java/software/amazon/awssdk/utils/async/SequentialSubscriber.java
index e66afb50d2bd..77db8e2d15b5 100644
--- a/utils/src/main/java/software/amazon/awssdk/utils/async/SequentialSubscriber.java
+++ b/utils/src/main/java/software/amazon/awssdk/utils/async/SequentialSubscriber.java
@@ -28,7 +28,6 @@
*/
@SdkProtectedApi
public class SequentialSubscriber implements Subscriber {
-
private final Consumer consumer;
private final CompletableFuture> future;
private Subscription subscription;
diff --git a/utils/src/test/java/software/amazon/awssdk/utils/async/FlatteningSubscriberTckTest.java b/utils/src/test/java/software/amazon/awssdk/utils/async/FlatteningSubscriberTckTest.java
new file mode 100644
index 000000000000..b4c27f991849
--- /dev/null
+++ b/utils/src/test/java/software/amazon/awssdk/utils/async/FlatteningSubscriberTckTest.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.utils.async;
+
+import java.util.Arrays;
+import java.util.concurrent.CompletableFuture;
+import org.reactivestreams.Subscriber;
+import org.reactivestreams.Subscription;
+import org.reactivestreams.tck.SubscriberWhiteboxVerification;
+import org.reactivestreams.tck.TestEnvironment;
+
+public class FlatteningSubscriberTckTest extends SubscriberWhiteboxVerification> {
+ protected FlatteningSubscriberTckTest() {
+ super(new TestEnvironment());
+ }
+
+ @Override
+ public Subscriber> createSubscriber(WhiteboxSubscriberProbe> probe) {
+ Subscriber foo = new SequentialSubscriber<>(s -> {}, new CompletableFuture<>());
+ return new FlatteningSubscriber(foo) {
+ @Override
+ public void onError(Throwable throwable) {
+ super.onError(throwable);
+ probe.registerOnError(throwable);
+ }
+
+ @Override
+ public void onSubscribe(Subscription subscription) {
+ super.onSubscribe(subscription);
+ probe.registerOnSubscribe(new SubscriberPuppet() {
+ @Override
+ public void triggerRequest(long elements) {
+ subscription.request(elements);
+ }
+
+ @Override
+ public void signalCancel() {
+ subscription.cancel();
+ }
+ });
+ }
+
+ @Override
+ public void onNext(Iterable nextItems) {
+ super.onNext(nextItems);
+ probe.registerOnNext(nextItems);
+ }
+
+ @Override
+ public void onComplete() {
+ super.onComplete();
+ probe.registerOnComplete();
+ }
+ };
+ }
+
+ @Override
+ public Iterable createElement(int element) {
+ return Arrays.asList(element, element);
+ }
+}
\ No newline at end of file
diff --git a/utils/src/test/java/software/amazon/awssdk/utils/async/FlatteningSubscriberTest.java b/utils/src/test/java/software/amazon/awssdk/utils/async/FlatteningSubscriberTest.java
new file mode 100644
index 000000000000..fc03fdead024
--- /dev/null
+++ b/utils/src/test/java/software/amazon/awssdk/utils/async/FlatteningSubscriberTest.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.utils.async;
+
+import static org.mockito.Mockito.times;
+
+import java.util.Arrays;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+import org.reactivestreams.Subscriber;
+import org.reactivestreams.Subscription;
+
+public class FlatteningSubscriberTest {
+ private Subscriber mockDelegate;
+ private Subscription mockUpstream;
+ private FlatteningSubscriber flatteningSubscriber;
+
+ @Before
+ @SuppressWarnings("unchecked")
+ public void setup() {
+ mockDelegate = Mockito.mock(Subscriber.class);
+ mockUpstream = Mockito.mock(Subscription.class);
+ flatteningSubscriber = new FlatteningSubscriber<>(mockDelegate);
+ }
+
+ @Test
+ public void requestOne() {
+ flatteningSubscriber.onSubscribe(mockUpstream);
+
+ Subscription downstream = getDownstreamFromDelegate();
+ downstream.request(1);
+ Mockito.verify(mockUpstream).request(1);
+
+ flatteningSubscriber.onNext(Arrays.asList("foo", "bar"));
+
+ Mockito.verify(mockDelegate).onNext("foo");
+
+ Mockito.verifyNoMoreInteractions(mockUpstream, mockDelegate);
+ }
+
+ @Test
+ public void requestTwo() {
+ flatteningSubscriber.onSubscribe(mockUpstream);
+
+ Subscription downstream = getDownstreamFromDelegate();
+ downstream.request(2);
+
+ Mockito.verify(mockUpstream).request(1);
+
+ flatteningSubscriber.onNext(Arrays.asList("foo", "bar"));
+
+ Mockito.verify(mockDelegate).onNext("foo");
+ Mockito.verify(mockDelegate).onNext("bar");
+ Mockito.verifyNoMoreInteractions(mockUpstream, mockDelegate);
+ }
+
+ @Test
+ public void requestThree() {
+ flatteningSubscriber.onSubscribe(mockUpstream);
+
+ Subscription downstream = getDownstreamFromDelegate();
+ downstream.request(3);
+
+ Mockito.verify(mockUpstream, times(1)).request(1);
+ Mockito.verifyNoMoreInteractions(mockUpstream, mockDelegate);
+ Mockito.reset(mockUpstream, mockDelegate);
+
+ flatteningSubscriber.onNext(Arrays.asList("foo", "bar"));
+
+ Mockito.verify(mockDelegate).onNext("foo");
+ Mockito.verify(mockDelegate).onNext("bar");
+ Mockito.verify(mockUpstream).request(1);
+ Mockito.verifyNoMoreInteractions(mockUpstream, mockDelegate);
+ Mockito.reset(mockUpstream, mockDelegate);
+
+ flatteningSubscriber.onNext(Arrays.asList("baz"));
+
+ Mockito.verify(mockDelegate).onNext("baz");
+ Mockito.verifyNoMoreInteractions(mockUpstream, mockDelegate);
+ }
+
+ @Test
+ public void requestInfinite() {
+ flatteningSubscriber.onSubscribe(mockUpstream);
+
+ Subscription downstream = getDownstreamFromDelegate();
+ downstream.request(1);
+ downstream.request(Long.MAX_VALUE);
+ downstream.request(Long.MAX_VALUE);
+ downstream.request(Long.MAX_VALUE);
+ downstream.request(Long.MAX_VALUE);
+
+ Mockito.verify(mockUpstream, times(1)).request(1);
+
+ flatteningSubscriber.onNext(Arrays.asList("foo", "bar"));
+ flatteningSubscriber.onComplete();
+
+ Mockito.verify(mockDelegate).onNext("foo");
+ Mockito.verify(mockDelegate).onNext("bar");
+ Mockito.verify(mockDelegate).onComplete();
+ Mockito.verifyNoMoreInteractions(mockDelegate);
+ }
+
+ @Test
+ public void onCompleteDelayedUntilAllDataDelivered() {
+ flatteningSubscriber.onSubscribe(mockUpstream);
+
+ Subscription downstream = getDownstreamFromDelegate();
+ downstream.request(1);
+
+ Mockito.verify(mockUpstream).request(1);
+
+ flatteningSubscriber.onNext(Arrays.asList("foo", "bar"));
+ flatteningSubscriber.onComplete();
+
+ Mockito.verify(mockDelegate).onNext("foo");
+ Mockito.verifyNoMoreInteractions(mockUpstream, mockDelegate);
+ Mockito.reset(mockUpstream, mockDelegate);
+
+ downstream.request(1);
+ Mockito.verify(mockDelegate).onNext("bar");
+ Mockito.verify(mockDelegate).onComplete();
+ Mockito.verifyNoMoreInteractions(mockUpstream, mockDelegate);
+ }
+
+ @Test
+ public void onErrorDropsBufferedData() {
+ Throwable t = new Throwable();
+
+ flatteningSubscriber.onSubscribe(mockUpstream);
+
+ Subscription downstream = getDownstreamFromDelegate();
+ downstream.request(1);
+
+ Mockito.verify(mockUpstream).request(1);
+
+ flatteningSubscriber.onNext(Arrays.asList("foo", "bar"));
+ flatteningSubscriber.onError(t);
+
+ Mockito.verify(mockDelegate).onNext("foo");
+ Mockito.verify(mockDelegate).onError(t);
+ Mockito.verifyNoMoreInteractions(mockUpstream, mockDelegate);
+ }
+
+ @Test
+ public void requestsFromDownstreamDoNothingAfterOnComplete() {
+ flatteningSubscriber.onSubscribe(mockUpstream);
+
+ Subscription downstream = getDownstreamFromDelegate();
+ downstream.request(1);
+
+ Mockito.verify(mockUpstream).request(1);
+
+ flatteningSubscriber.onComplete();
+
+ Mockito.verify(mockDelegate).onComplete();
+ Mockito.verifyNoMoreInteractions(mockUpstream, mockDelegate);
+
+ downstream.request(1);
+ Mockito.verifyNoMoreInteractions(mockUpstream, mockDelegate);
+ }
+
+ @Test
+ public void requestsFromDownstreamDoNothingAfterOnError() {
+ Throwable t = new Throwable();
+
+ flatteningSubscriber.onSubscribe(mockUpstream);
+
+ Subscription downstream = getDownstreamFromDelegate();
+ downstream.request(1);
+
+ Mockito.verify(mockUpstream).request(1);
+
+ flatteningSubscriber.onError(t);
+
+ Mockito.verify(mockDelegate).onError(t);
+ Mockito.verifyNoMoreInteractions(mockUpstream, mockDelegate);
+
+ downstream.request(1);
+ Mockito.verifyNoMoreInteractions(mockUpstream, mockDelegate);
+ }
+
+ private Subscription getDownstreamFromDelegate() {
+ ArgumentCaptor subscriptionCaptor = ArgumentCaptor.forClass(Subscription.class);
+ Mockito.verify(mockDelegate).onSubscribe(subscriptionCaptor.capture());
+ return subscriptionCaptor.getValue();
+ }
+
+}
\ No newline at end of file