From ec9f0a40ada10cafe7ba545050b767837082f415 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Thu, 5 Dec 2024 15:49:39 +0100 Subject: [PATCH 01/29] Dynamically configure SemaphoreBackPressureHandler with BackPressureLimiter (#1251) --- .../listener/AbstractContainerOptions.java | 38 +++ ...tractPipelineMessageListenerContainer.java | 11 +- .../sqs/listener/BackPressureLimiter.java | 44 +++ .../cloud/sqs/listener/ContainerOptions.java | 16 +- .../sqs/listener/ContainerOptionsBuilder.java | 18 ++ .../SemaphoreBackPressureHandler.java | 167 ++++++++-- .../sqs/integration/SqsIntegrationTests.java | 301 ++++++++++++++++++ 7 files changed, 562 insertions(+), 33 deletions(-) create mode 100644 spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureLimiter.java diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java index 2a120792f..3cf5e7a4f 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java @@ -49,12 +49,16 @@ public abstract class AbstractContainerOptions, private final Duration maxDelayBetweenPolls; + private final Duration standbyLimitPollingInterval; + private final Duration listenerShutdownTimeout; private final Duration acknowledgementShutdownTimeout; private final BackPressureMode backPressureMode; + private final BackPressureLimiter backPressureLimiter; + private final ListenerMode listenerMode; private final MessagingMessageConverter messageConverter; @@ -86,10 +90,12 @@ protected AbstractContainerOptions(Builder builder) { this.autoStartup = builder.autoStartup; this.pollTimeout = builder.pollTimeout; this.pollBackOffPolicy = builder.pollBackOffPolicy; + this.standbyLimitPollingInterval = builder.standbyLimitPollingInterval; this.maxDelayBetweenPolls = builder.maxDelayBetweenPolls; this.listenerShutdownTimeout = builder.listenerShutdownTimeout; this.acknowledgementShutdownTimeout = builder.acknowledgementShutdownTimeout; this.backPressureMode = builder.backPressureMode; + this.backPressureLimiter = builder.backPressureLimiter; this.listenerMode = builder.listenerMode; this.messageConverter = builder.messageConverter; this.acknowledgementMode = builder.acknowledgementMode; @@ -130,6 +136,11 @@ public BackOffPolicy getPollBackOffPolicy() { return this.pollBackOffPolicy; } + @Override + public Duration getStandbyLimitPollingInterval() { + return this.standbyLimitPollingInterval; + } + @Override public Duration getMaxDelayBetweenPolls() { return this.maxDelayBetweenPolls; @@ -162,6 +173,11 @@ public BackPressureMode getBackPressureMode() { return this.backPressureMode; } + @Override + public BackPressureLimiter getBackPressureLimiter() { + return this.backPressureLimiter; + } + @Override public ListenerMode getListenerMode() { return this.listenerMode; @@ -224,6 +240,8 @@ protected abstract static class Builder, private static final BackOffPolicy DEFAULT_POLL_BACK_OFF_POLICY = buildDefaultBackOffPolicy(); + private static final Duration DEFAULT_STANDBY_LIMIT_POLLING_INTERVAL = Duration.ofMillis(100); + private static final Duration DEFAULT_SEMAPHORE_TIMEOUT = Duration.ofSeconds(10); private static final Duration DEFAULT_LISTENER_SHUTDOWN_TIMEOUT = Duration.ofSeconds(20); @@ -232,6 +250,8 @@ protected abstract static class Builder, private static final BackPressureMode DEFAULT_THROUGHPUT_CONFIGURATION = BackPressureMode.AUTO; + private static final BackPressureLimiter DEFAULT_BACKPRESSURE_LIMITER = null; + private static final ListenerMode DEFAULT_MESSAGE_DELIVERY_STRATEGY = ListenerMode.SINGLE_MESSAGE; private static final MessagingMessageConverter DEFAULT_MESSAGE_CONVERTER = new SqsMessagingMessageConverter(); @@ -250,10 +270,14 @@ protected abstract static class Builder, private BackOffPolicy pollBackOffPolicy = DEFAULT_POLL_BACK_OFF_POLICY; + private Duration standbyLimitPollingInterval = DEFAULT_STANDBY_LIMIT_POLLING_INTERVAL; + private Duration maxDelayBetweenPolls = DEFAULT_SEMAPHORE_TIMEOUT; private BackPressureMode backPressureMode = DEFAULT_THROUGHPUT_CONFIGURATION; + private BackPressureLimiter backPressureLimiter = DEFAULT_BACKPRESSURE_LIMITER; + private Duration listenerShutdownTimeout = DEFAULT_LISTENER_SHUTDOWN_TIMEOUT; private Duration acknowledgementShutdownTimeout = DEFAULT_ACKNOWLEDGEMENT_SHUTDOWN_TIMEOUT; @@ -296,6 +320,7 @@ protected Builder(AbstractContainerOptions options) { this.listenerShutdownTimeout = options.listenerShutdownTimeout; this.acknowledgementShutdownTimeout = options.acknowledgementShutdownTimeout; this.backPressureMode = options.backPressureMode; + this.backPressureLimiter = options.backPressureLimiter; this.listenerMode = options.listenerMode; this.messageConverter = options.messageConverter; this.acknowledgementMode = options.acknowledgementMode; @@ -341,6 +366,13 @@ public B pollBackOffPolicy(BackOffPolicy pollBackOffPolicy) { return self(); } + @Override + public B standbyLimitPollingInterval(Duration standbyLimitPollingInterval) { + Assert.notNull(standbyLimitPollingInterval, "standbyLimitPollingInterval cannot be null"); + this.standbyLimitPollingInterval = standbyLimitPollingInterval; + return self(); + } + @Override public B maxDelayBetweenPolls(Duration maxDelayBetweenPolls) { Assert.notNull(maxDelayBetweenPolls, "semaphoreAcquireTimeout cannot be null"); @@ -390,6 +422,12 @@ public B backPressureMode(BackPressureMode backPressureMode) { return self(); } + @Override + public B backPressureLimiter(BackPressureLimiter backPressureLimiter) { + this.backPressureLimiter = backPressureLimiter; + return self(); + } + @Override public B acknowledgementInterval(Duration acknowledgementInterval) { Assert.notNull(acknowledgementInterval, "acknowledgementInterval cannot be null"); diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java index c5b0c19e8..62ea9d0f9 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java @@ -230,10 +230,13 @@ private TaskExecutor validateCustomExecutor(TaskExecutor taskExecutor) { } protected BackPressureHandler createBackPressureHandler() { - return SemaphoreBackPressureHandler.builder().batchSize(getContainerOptions().getMaxMessagesPerPoll()) - .totalPermits(getContainerOptions().getMaxConcurrentMessages()) - .acquireTimeout(getContainerOptions().getMaxDelayBetweenPolls()) - .throughputConfiguration(getContainerOptions().getBackPressureMode()).build(); + O containerOptions = getContainerOptions(); + return SemaphoreBackPressureHandler.builder().batchSize(containerOptions.getMaxMessagesPerPoll()) + .totalPermits(containerOptions.getMaxConcurrentMessages()) + .standbyLimitPollingInterval(containerOptions.getStandbyLimitPollingInterval()) + .acquireTimeout(containerOptions.getMaxDelayBetweenPolls()) + .throughputConfiguration(containerOptions.getBackPressureMode()) + .backPressureLimiter(containerOptions.getBackPressureLimiter()).build(); } protected TaskExecutor createSourcesTaskExecutor() { diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureLimiter.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureLimiter.java new file mode 100644 index 000000000..f85ddba82 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureLimiter.java @@ -0,0 +1,44 @@ +/* + * Copyright 2013-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.listener; + +/** + * The BackPressureLimiter enables a dynamic reduction of the queues consumption capacity depending on external factors. + */ +public interface BackPressureLimiter { + + /** + * {@return the limit to be applied to the queue consumption.} + * + * The limit can be used to reduce the queue consumption capabilities of the next polling attempts. The container + * will work toward satisfying the limit by decreasing the maximum number of concurrent messages that can ve + * processed. + * + * The following values will have the following effects: + * + *
    + *
  • zero or negative limits will stop consumption from the queue. When such a situation occurs, the queue + * processing is said to be on "standby".
  • + *
  • Values >= 1 and < {@link ContainerOptions#getMaxConcurrentMessages()} will reduce the queue consumption + * capabilities of the next polling attempts.
  • + *
  • Values >= {@link ContainerOptions#getMaxConcurrentMessages()} will not reduce the queue consumption + * capabilities
  • + *
+ * + * Note: the adjustment will require a few polling cycles to be in effect. + */ + int limit(); +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java index 838312ad2..7b8bd1afd 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java @@ -61,7 +61,15 @@ public interface ContainerOptions, B extends Co boolean isAutoStartup(); /** - * Set the maximum time the polling thread should wait for a full batch of permits to be available before trying to + * {@return the amount of time to wait before checking again for the current limit when the queue processing is on + * standby} Default is 100 milliseconds. + * + * @see BackPressureLimiter#limit() + */ + Duration getStandbyLimitPollingInterval(); + + /** + * Sets the maximum time the polling thread should wait for a full batch of permits to be available before trying to * acquire a partial batch if so configured. A poll is only actually executed if at least one permit is available. * Default is 10 seconds. * @@ -129,6 +137,12 @@ default BackOffPolicy getPollBackOffPolicy() { */ BackPressureMode getBackPressureMode(); + /** + * Return the {@link BackPressureLimiter} for this container. + * @return the backpressure limiter. + */ + BackPressureLimiter getBackPressureLimiter(); + /** * Return the {@link ListenerMode} mode for this container. * @return the listener mode. diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java index 1e6bb38e7..31fb2c0c2 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java @@ -57,6 +57,16 @@ public interface ContainerOptionsBuilder */ B autoStartup(boolean autoStartup); + /** + * Sets the amount of time to wait before checking again for the current limit when the queue processing is on + * standby. + * + * @param standbyLimitPollingInterval the limit polling interval when the queue processing is on standby. + * @return this instance. + * @see BackPressureLimiter#limit() + */ + B standbyLimitPollingInterval(Duration standbyLimitPollingInterval); + /** * Set the maximum time the polling thread should wait for a full batch of permits to be available before trying to * acquire a partial batch if so configured. A poll is only actually executed if at least one permit is available. @@ -146,6 +156,14 @@ default B pollBackOffPolicy(BackOffPolicy pollBackOffPolicy) { */ B backPressureMode(BackPressureMode backPressureMode); + /** + * Set the {@link BackPressureLimiter} for this container. Default is {@code null}. + * + * @param backPressureLimiter the backpressure limiter. + * @return this instance. + */ + B backPressureLimiter(BackPressureLimiter backPressureLimiter); + /** * Set the maximum interval between acknowledgements for batch acknowledgements. The default depends on the specific * {@link ContainerComponentFactory} implementation. diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java index 310b64519..e3d069bce 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java @@ -17,9 +17,11 @@ import java.time.Duration; import java.util.Arrays; +import java.util.Objects; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.util.Assert; @@ -35,33 +37,63 @@ public class SemaphoreBackPressureHandler implements BatchAwareBackPressureHandl private static final Logger logger = LoggerFactory.getLogger(SemaphoreBackPressureHandler.class); - private final Semaphore semaphore; + private final BackPressureLimiter backPressureLimiter; + + private final ReducibleSemaphore semaphore; private final int batchSize; + /** + * The theoretical maximum numbers of permits that can be acquired if no limit is set. + * @see #permitsLimit for the current limit. + */ private final int totalPermits; + /** + * The limit of permits that can be acquired at the current time. The permits limit is defined in the [0, + * totalPermits] interval. A value of {@literal 0} means that no permits can be acquired. + *

+ * This value is updated based on the downstream backpressure reported by the {@link #backPressureLimiter}. + */ + private final AtomicInteger permitsLimit; + + /** + * The duration to sleep when the queue processing is in standby. + */ + private final Duration standbyLimitPollingInterval; + private final Duration acquireTimeout; private final BackPressureMode backPressureConfiguration; private volatile CurrentThroughputMode currentThroughputMode; + /** + * The number of permits acquired in low throughput mode. This value is minimum value between {@link #permitsLimit} + * at the time of the acquire and {@link #totalPermits}. + */ + private final AtomicInteger lowThroughputAcquiredPermits = new AtomicInteger(0); + private final AtomicBoolean hasAcquiredFullPermits = new AtomicBoolean(false); private String id; + private final AtomicBoolean isDraining = new AtomicBoolean(false); + private SemaphoreBackPressureHandler(Builder builder) { this.batchSize = builder.batchSize; this.totalPermits = builder.totalPermits; + this.standbyLimitPollingInterval = builder.standbyLimitPollingInterval; this.acquireTimeout = builder.acquireTimeout; this.backPressureConfiguration = builder.backPressureMode; - this.semaphore = new Semaphore(totalPermits); + this.semaphore = new ReducibleSemaphore(totalPermits); this.currentThroughputMode = BackPressureMode.FIXED_HIGH_THROUGHPUT.equals(backPressureConfiguration) ? CurrentThroughputMode.HIGH : CurrentThroughputMode.LOW; logger.debug("SemaphoreBackPressureHandler created with configuration {} and {} total permits", backPressureConfiguration, totalPermits); + this.permitsLimit = new AtomicInteger(totalPermits); + this.backPressureLimiter = Objects.requireNonNullElse(builder.backPressureLimiter, () -> totalPermits); } public static Builder builder() { @@ -80,15 +112,17 @@ public String getId() { @Override public int request(int amount) throws InterruptedException { + updateAvailablePermitsBasedOnDownstreamBackpressure(); return tryAcquire(amount, this.currentThroughputMode) ? amount : 0; } // @formatter:off @Override public int requestBatch() throws InterruptedException { - return CurrentThroughputMode.LOW.equals(this.currentThroughputMode) - ? requestInLowThroughputMode() - : requestInHighThroughputMode(); + updateAvailablePermitsBasedOnDownstreamBackpressure(); + boolean useLowThroughput = CurrentThroughputMode.LOW.equals(this.currentThroughputMode) + || this.permitsLimit.get() < this.totalPermits; + return useLowThroughput ? requestInLowThroughputMode() : requestInHighThroughputMode(); } private int requestInHighThroughputMode() throws InterruptedException { @@ -103,10 +137,10 @@ private int tryAcquirePartial() throws InterruptedException { if (availablePermits == 0 || BackPressureMode.ALWAYS_POLL_MAX_MESSAGES.equals(this.backPressureConfiguration)) { return 0; } - int permitsToRequest = Math.min(availablePermits, this.batchSize); + int permitsToRequest = min(availablePermits, this.batchSize); CurrentThroughputMode currentThroughputModeNow = this.currentThroughputMode; - logger.trace("Trying to acquire partial batch of {} permits from {} available for {} in TM {}", - permitsToRequest, availablePermits, this.id, currentThroughputModeNow); + logger.trace("Trying to acquire partial batch of {} permits from {} (limit {}) available for {} in TM {}", + permitsToRequest, availablePermits, this.permitsLimit.get(), this.id, currentThroughputModeNow); boolean hasAcquiredPartial = tryAcquire(permitsToRequest, currentThroughputModeNow); return hasAcquiredPartial ? permitsToRequest : 0; } @@ -114,17 +148,35 @@ private int tryAcquirePartial() throws InterruptedException { private int requestInLowThroughputMode() throws InterruptedException { // Although LTM can be set / unset by many processes, only the MessageSource thread gets here, // so no actual concurrency - logger.debug("Trying to acquire full permits for {}. Permits left: {}", this.id, - this.semaphore.availablePermits()); - boolean hasAcquired = tryAcquire(this.totalPermits, CurrentThroughputMode.LOW); + logger.debug("Trying to acquire full permits for {}. Permits left: {}, Permits limit: {}", this.id, + this.semaphore.availablePermits(), this.permitsLimit.get()); + int permitsToRequest = min(this.permitsLimit.get(), this.totalPermits); + if (permitsToRequest == 0) { + logger.info("No permits usable for {} (limit = 0), sleeping for {}", this.id, + this.standbyLimitPollingInterval); + Thread.sleep(standbyLimitPollingInterval.toMillis()); + return 0; + } + boolean hasAcquired = tryAcquire(permitsToRequest, CurrentThroughputMode.LOW); if (hasAcquired) { - logger.debug("Acquired full permits for {}. Permits left: {}", this.id, this.semaphore.availablePermits()); + if (permitsToRequest >= this.totalPermits) { + logger.debug("Acquired full permits for {}. Permits left: {}, Permits limit: {}", this.id, + this.semaphore.availablePermits(), this.permitsLimit.get()); + } + else { + logger.debug("Acquired limited permits ({}) for {} . Permits left: {}, Permits limit: {}", + permitsToRequest, this.id, this.semaphore.availablePermits(), this.permitsLimit.get()); + } + int tokens = min(this.batchSize, permitsToRequest); // We've acquired all permits - there's no other process currently processing messages if (!this.hasAcquiredFullPermits.compareAndSet(false, true)) { - logger.warn("hasAcquiredFullPermits was already true. Permits left: {}", - this.semaphore.availablePermits()); + logger.warn("hasAcquiredFullPermits was already true. Permits left: {}, Permits limit: {}", + this.semaphore.availablePermits(), this.permitsLimit.get()); } - return this.batchSize; + else { + lowThroughputAcquiredPermits.set(permitsToRequest); + } + return tokens; } else { return 0; @@ -132,16 +184,20 @@ private int requestInLowThroughputMode() throws InterruptedException { } private boolean tryAcquire(int amount, CurrentThroughputMode currentThroughputModeNow) throws InterruptedException { + if (isDraining.get()) { + return false; + } logger.trace("Acquiring {} permits for {} in TM {}", amount, this.id, this.currentThroughputMode); boolean hasAcquired = this.semaphore.tryAcquire(amount, this.acquireTimeout.toMillis(), TimeUnit.MILLISECONDS); if (hasAcquired) { - logger.trace("{} permits acquired for {} in TM {}. Permits left: {}", amount, this.id, - currentThroughputModeNow, this.semaphore.availablePermits()); + logger.trace("{} permits acquired for {} in TM {}. Permits left: {}, Permits limit: {}", amount, this.id, + currentThroughputModeNow, this.semaphore.availablePermits(), this.permitsLimit.get()); } else { - logger.trace("Not able to acquire {} permits in {} milliseconds for {} in TM {}. Permits left: {}", amount, - this.acquireTimeout.toMillis(), this.id, currentThroughputModeNow, - this.semaphore.availablePermits()); + logger.trace( + "Not able to acquire {} permits in {} milliseconds for {} in TM {}. Permits left: {}, Permits limit: {}", + amount, this.acquireTimeout.toMillis(), this.id, currentThroughputModeNow, + this.semaphore.availablePermits(), this.permitsLimit.get()); } return hasAcquired; } @@ -181,11 +237,13 @@ public void release(int amount) { } private int getPermitsToRelease(int amount) { - return this.hasAcquiredFullPermits.compareAndSet(true, false) - // The first process that gets here should release all permits except for inflight messages - // We can have only one batch of messages at this point since we have all permits - ? this.totalPermits - (this.batchSize - amount) - : amount; + if (this.hasAcquiredFullPermits.compareAndSet(true, false)) { + int allAcquiredPermits = this.lowThroughputAcquiredPermits.getAndSet(0); + // The first process that gets here should release all permits except for inflight messages + // We can have only one batch of messages at this point since we have all permits + return (allAcquiredPermits - (min(this.batchSize, allAcquiredPermits) - amount)); + } + return amount; } private void maybeSwitchToHighThroughputMode(int amount) { @@ -200,6 +258,8 @@ private void maybeSwitchToHighThroughputMode(int amount) { public boolean drain(Duration timeout) { logger.debug("Waiting for up to {} seconds for approx. {} permits to be released for {}", timeout.getSeconds(), this.totalPermits - this.semaphore.availablePermits(), this.id); + isDraining.set(true); + updateMaxPermitsLimit(this.totalPermits); try { return this.semaphore.tryAcquire(this.totalPermits, (int) timeout.getSeconds(), TimeUnit.SECONDS); } @@ -209,6 +269,44 @@ public boolean drain(Duration timeout) { } } + private int min(int a, int p) { + return Math.max(0, Math.min(a, p)); + } + + private void updateAvailablePermitsBasedOnDownstreamBackpressure() { + if (!isDraining.get()) { + int limit = backPressureLimiter.limit(); + int newCurrentMaxPermits = min(limit, totalPermits); + updateMaxPermitsLimit(newCurrentMaxPermits); + if (isDraining.get()) { + updateMaxPermitsLimit(totalPermits); + } + } + } + + private void updateMaxPermitsLimit(int newCurrentMaxPermits) { + int oldValue = permitsLimit.getAndUpdate(i -> min(newCurrentMaxPermits, totalPermits)); + if (newCurrentMaxPermits < oldValue) { + int blockedPermits = oldValue - newCurrentMaxPermits; + semaphore.reducePermits(blockedPermits); + } + else if (newCurrentMaxPermits > oldValue) { + int releasedPermits = newCurrentMaxPermits - oldValue; + semaphore.release(releasedPermits); + } + } + + private static class ReducibleSemaphore extends Semaphore { + ReducibleSemaphore(int permits) { + super(permits); + } + + @Override + public void reducePermits(int reduction) { + super.reducePermits(reduction); + } + } + private enum CurrentThroughputMode { HIGH, @@ -223,10 +321,14 @@ public static class Builder { private int totalPermits; + private Duration standbyLimitPollingInterval; + private Duration acquireTimeout; private BackPressureMode backPressureMode; + private BackPressureLimiter backPressureLimiter; + public Builder batchSize(int batchSize) { this.batchSize = batchSize; return this; @@ -237,6 +339,11 @@ public Builder totalPermits(int totalPermits) { return this; } + public Builder standbyLimitPollingInterval(Duration standbyLimitPollingInterval) { + this.standbyLimitPollingInterval = standbyLimitPollingInterval; + return this; + } + public Builder acquireTimeout(Duration acquireTimeout) { this.acquireTimeout = acquireTimeout; return this; @@ -247,10 +354,14 @@ public Builder throughputConfiguration(BackPressureMode backPressureConfiguratio return this; } + public Builder backPressureLimiter(BackPressureLimiter backPressureLimiter) { + this.backPressureLimiter = backPressureLimiter; + return this; + } + public SemaphoreBackPressureHandler build() { - Assert.noNullElements( - Arrays.asList(this.batchSize, this.totalPermits, this.acquireTimeout, this.backPressureMode), - "Missing configuration"); + Assert.noNullElements(Arrays.asList(this.batchSize, this.totalPermits, this.standbyLimitPollingInterval, + this.acquireTimeout, this.backPressureMode), "Missing configuration"); return new SemaphoreBackPressureHandler(this); } diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsIntegrationTests.java index 0a1157cea..6f6ed4a21 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsIntegrationTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsIntegrationTests.java @@ -27,6 +27,7 @@ import io.awspring.cloud.sqs.config.SqsBootstrapConfiguration; import io.awspring.cloud.sqs.config.SqsListenerConfigurer; import io.awspring.cloud.sqs.config.SqsMessageListenerContainerFactory; +import io.awspring.cloud.sqs.listener.BackPressureLimiter; import io.awspring.cloud.sqs.listener.BatchVisibility; import io.awspring.cloud.sqs.listener.ContainerComponentFactory; import io.awspring.cloud.sqs.listener.MessageListenerContainer; @@ -65,18 +66,25 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Random; import java.util.UUID; import java.util.concurrent.BrokenBarrierException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.Semaphore; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.IntUnaryOperator; import java.util.stream.Collectors; import java.util.stream.IntStream; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -392,6 +400,7 @@ void manuallyCreatesInactiveContainer() throws Exception { logger.debug("Sent message to queue {} with messageBody {}", MANUALLY_CREATE_INACTIVE_CONTAINER_QUEUE_NAME, messageBody); assertThat(latchContainer.manuallyInactiveCreatedContainerLatch.await(10, TimeUnit.SECONDS)).isTrue(); + inactiveMessageListenerContainer.stop(); } // @formatter:off @@ -472,6 +481,298 @@ void maxConcurrentMessages() { assertDoesNotThrow(() -> latchContainer.maxConcurrentMessagesBarrier.await(10, TimeUnit.SECONDS)); } + static final class Limiter implements BackPressureLimiter { + private final AtomicInteger limit; + + Limiter(int max) { + limit = new AtomicInteger(max); + } + + public void setLimit(int value) { + logger.info("adjusting limit from {} to {}", limit.get(), value); + limit.set(value); + } + + @Override + public int limit() { + return Math.max(0, limit.get()); + } + } + + @ParameterizedTest + @CsvSource({ "2,2", "4,4", "5,5", "20,5" }) + void staticBackPressureLimitShouldCapQueueProcessingCapacity(int staticLimit, int expectedMaxConcurrentRequests) + throws Exception { + AtomicInteger concurrentRequest = new AtomicInteger(); + AtomicInteger maxConcurrentRequest = new AtomicInteger(); + Limiter limiter = new Limiter(staticLimit); + String queueName = "BACK_PRESSURE_LIMITER_QUEUE_NAME_STATIC_LIMIT_" + staticLimit; + IntStream.range(0, 10).forEach(index -> { + List> messages = create10Messages("staticBackPressureLimit" + staticLimit); + sqsTemplate.sendMany(queueName, messages); + }); + logger.debug("Sent 100 messages to queue {}", queueName); + var latch = new CountDownLatch(100); + var container = SqsMessageListenerContainer.builder().sqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()) + .queueNames(queueName).configure(options -> options.pollTimeout(Duration.ofSeconds(1)) + .maxConcurrentMessages(5).maxMessagesPerPoll(5).backPressureLimiter(limiter)) + .messageListener(msg -> { + int concurrentRqs = concurrentRequest.incrementAndGet(); + maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); + sleep(50L); + logger.debug("concurrent rq {}, max concurrent rq {}, latch count {}", concurrentRequest.get(), + maxConcurrentRequest.get(), latch.getCount()); + latch.countDown(); + concurrentRequest.decrementAndGet(); + }).build(); + container.start(); + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(maxConcurrentRequest.get()).isEqualTo(expectedMaxConcurrentRequests); + container.stop(); + } + + @Test + void zeroBackPressureLimitShouldStopQueueProcessing() throws Exception { + AtomicInteger concurrentRequest = new AtomicInteger(); + AtomicInteger maxConcurrentRequest = new AtomicInteger(); + Limiter limiter = new Limiter(0); + String queueName = "BACK_PRESSURE_LIMITER_QUEUE_NAME_STATIC_LIMIT_0"; + IntStream.range(0, 10).forEach(index -> { + List> messages = create10Messages("staticBackPressureLimit0"); + sqsTemplate.sendMany(queueName, messages); + }); + logger.debug("Sent 100 messages to queue {}", queueName); + var latch = new CountDownLatch(100); + var container = SqsMessageListenerContainer.builder().sqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()) + .queueNames(queueName).configure(options -> options.pollTimeout(Duration.ofSeconds(1)) + .maxConcurrentMessages(5).maxMessagesPerPoll(5).backPressureLimiter(limiter)) + .messageListener(msg -> { + int concurrentRqs = concurrentRequest.incrementAndGet(); + maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); + sleep(50L); + logger.debug("concurrent rq {}, max concurrent rq {}, latch count {}", concurrentRequest.get(), + maxConcurrentRequest.get(), latch.getCount()); + latch.countDown(); + concurrentRequest.decrementAndGet(); + }).build(); + container.start(); + assertThat(latch.await(2, TimeUnit.SECONDS)).isFalse(); + assertThat(maxConcurrentRequest.get()).isZero(); + assertThat(latch.getCount()).isEqualTo(100L); + container.stop(); + } + + @Test + void changeInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Exception { + AtomicInteger concurrentRequest = new AtomicInteger(); + AtomicInteger maxConcurrentRequest = new AtomicInteger(); + Limiter limiter = new Limiter(5); + String queueName = "BACK_PRESSURE_LIMITER_QUEUE_NAME_SYNC_ADAPTIVE_LIMIT"; + int nbMessages = 280; + IntStream.range(0, nbMessages / 10).forEach(index -> { + List> messages = create10Messages("syncAdaptiveBackPressureLimit"); + sqsTemplate.sendMany(queueName, messages); + }); + logger.debug("Sent {} messages to queue {}", nbMessages, queueName); + var latch = new CountDownLatch(nbMessages); + var controlSemaphore = new Semaphore(0); + var advanceSemaphore = new Semaphore(0); + var container = SqsMessageListenerContainer.builder().sqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()) + .queueNames(queueName).configure(options -> options.pollTimeout(Duration.ofSeconds(1)) + .maxConcurrentMessages(5).maxMessagesPerPoll(5).backPressureLimiter(limiter)) + .messageListener(msg -> { + try { + controlSemaphore.acquire(); + } + catch (InterruptedException e) { + throw new RuntimeException(e); + } + int concurrentRqs = concurrentRequest.incrementAndGet(); + maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); + latch.countDown(); + logger.debug("concurrent rq {}, max concurrent rq {}, latch count {}", concurrentRequest.get(), + maxConcurrentRequest.get(), latch.getCount()); + sleep(10L); + concurrentRequest.decrementAndGet(); + advanceSemaphore.release(); + }).build(); + class Controller { + private final Semaphore advanceSemaphore; + private final Semaphore controlSemaphore; + private final Limiter limiter; + private final AtomicInteger maxConcurrentRequest; + + Controller(Semaphore advanceSemaphore, Semaphore controlSemaphore, Limiter limiter, + AtomicInteger maxConcurrentRequest) { + this.advanceSemaphore = advanceSemaphore; + this.controlSemaphore = controlSemaphore; + this.limiter = limiter; + this.maxConcurrentRequest = maxConcurrentRequest; + } + + public void updateLimit(int newLimit) { + limiter.setLimit(newLimit); + } + + void updateLimitAndWaitForReset(int newLimit) throws InterruptedException { + updateLimit(newLimit); + int atLeastTwoPollingCycles = 2 * 5; + controlSemaphore.release(atLeastTwoPollingCycles); + waitForAdvance(atLeastTwoPollingCycles); + maxConcurrentRequest.set(0); + } + + void advance(int permits) { + controlSemaphore.release(permits); + } + + void waitForAdvance(int permits) throws InterruptedException { + assertThat(advanceSemaphore.tryAcquire(permits, 5, TimeUnit.SECONDS)) + .withFailMessage(() -> "Waiting for %d permits timed out. Only %d permits available" + .formatted(permits, advanceSemaphore.availablePermits())) + .isTrue(); + } + } + var controller = new Controller(advanceSemaphore, controlSemaphore, limiter, maxConcurrentRequest); + try { + container.start(); + + controller.advance(50); + controller.waitForAdvance(50); + // not limiting queue processing capacity + assertThat(controller.maxConcurrentRequest.get()).isEqualTo(5); + controller.updateLimitAndWaitForReset(2); + controller.advance(50); + + controller.waitForAdvance(50); + // limiting queue processing capacity + assertThat(controller.maxConcurrentRequest.get()).isEqualTo(2); + controller.updateLimitAndWaitForReset(7); + controller.advance(50); + + controller.waitForAdvance(50); + // not limiting queue processing capacity + assertThat(controller.maxConcurrentRequest.get()).isEqualTo(5); + controller.updateLimitAndWaitForReset(3); + controller.advance(50); + sleep(10L); + limiter.setLimit(1); + sleep(10L); + limiter.setLimit(2); + sleep(10L); + limiter.setLimit(3); + + controller.waitForAdvance(50); + assertThat(controller.maxConcurrentRequest.get()).isEqualTo(3); + // stopping processing of the queue + controller.updateLimit(0); + controller.advance(50); + assertThat(advanceSemaphore.tryAcquire(10, 5, TimeUnit.SECONDS)) + .withFailMessage("Acquiring semaphore should have timed out as limit was set to 0").isFalse(); + + // resume queue processing + controller.updateLimit(6); + + controller.waitForAdvance(50); + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(controller.maxConcurrentRequest.get()).isEqualTo(5); + } + finally { + container.stop(); + } + } + + /** + * This test simulates a progressive change in the back pressure limit. Unlike + * {@link #changeInBackPressureLimitShouldAdaptQueueProcessingCapacity()}, this test does not block message + * consumption while updating the limit. + *

+ * The limit is updated in a loop until all messages are consumed. The update follows a triangle wave pattern with a + * minimum of 0, a maximum of 15, and a period of 30 iterations. After each update of the limit, the test waits up + * to 10ms and samples the maximum number of concurrent messages that were processed since the update. This number + * can be higher than the defined limit during the adaptation period of the decreasing limit wave. For the + * increasing limit wave, it is usually lower due to the adaptation delay. In both cases, the maximum number of + * concurrent messages being processed rapidly converges toward the defined limit. + *

+ * The test passes if the sum of the sampled maximum number of concurrently processed messages is lower than the sum + * of the limits at those points in time. + */ + @Test + void unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Exception { + AtomicInteger concurrentRequest = new AtomicInteger(); + AtomicInteger maxConcurrentRequest = new AtomicInteger(); + Limiter limiter = new Limiter(0); + String queueName = "REACTIVE_BACK_PRESSURE_LIMITER_QUEUE_NAME_ADAPTIVE_LIMIT"; + int nbMessages = 1000; + Semaphore advanceSemaphore = new Semaphore(0); + IntStream.range(0, nbMessages / 10).forEach(index -> { + List> messages = create10Messages("reactAdaptiveBackPressureLimit"); + sqsTemplate.sendMany(queueName, messages); + }); + logger.debug("Sent {} messages to queue {}", nbMessages, queueName); + var latch = new CountDownLatch(nbMessages); + var container = SqsMessageListenerContainer.builder().sqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()) + .queueNames(queueName) + .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) + .standbyLimitPollingInterval(Duration.ofMillis(1)).maxConcurrentMessages(10) + .maxMessagesPerPoll(10).backPressureLimiter(limiter)) + .messageListener(msg -> { + int currentConcurrentRq = concurrentRequest.incrementAndGet(); + maxConcurrentRequest.updateAndGet(max -> Math.max(max, currentConcurrentRq)); + sleep(ThreadLocalRandom.current().nextInt(10)); + latch.countDown(); + logger.debug("concurrent rq {}, max concurrent rq {}, latch count {}", concurrentRequest.get(), + maxConcurrentRequest.get(), latch.getCount()); + concurrentRequest.decrementAndGet(); + advanceSemaphore.release(); + }).build(); + IntUnaryOperator progressiveLimitChange = (int x) -> { + int period = 30; + int halfPeriod = period / 2; + if (x % period < halfPeriod) { + return (x % halfPeriod); + } + else { + return (halfPeriod - (x % halfPeriod)); + } + }; + try { + container.start(); + Random random = new Random(); + int limitsSum = 0; + int maxConcurrentRqSum = 0; + int changeLimitCount = 0; + while (latch.getCount() > 0 && changeLimitCount < nbMessages) { + changeLimitCount++; + int limit = progressiveLimitChange.applyAsInt(changeLimitCount); + limiter.setLimit(limit); + maxConcurrentRequest.set(0); + sleep(random.nextInt(10)); + int actualLimit = Math.min(10, limit); + int max = maxConcurrentRequest.getAndSet(0); + if (max > 0) { + // Ignore iterations where nothing was polled (messages consumption slower than iteration) + limitsSum += actualLimit; + maxConcurrentRqSum += max; + } + } + assertThat(maxConcurrentRqSum).isLessThanOrEqualTo(limitsSum); + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); + } + finally { + container.stop(); + } + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + static class ReceivesMessageListener { @Autowired From 3da2be2529922d36a273cf0ca8828d394f15da5d Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Thu, 2 Jan 2025 17:18:24 +0100 Subject: [PATCH 02/29] Use a wrapper approach for dynamically limit the permits of SemaphoreBackPressureHandler (#1251) --- ...tractPipelineMessageListenerContainer.java | 13 +- .../listener/BackPressureHandlerLimiter.java | 153 ++++++++++++++++ .../SemaphoreBackPressureHandler.java | 167 +++--------------- 3 files changed, 190 insertions(+), 143 deletions(-) create mode 100644 spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerLimiter.java diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java index 62ea9d0f9..7c6a75a41 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java @@ -231,12 +231,17 @@ private TaskExecutor validateCustomExecutor(TaskExecutor taskExecutor) { protected BackPressureHandler createBackPressureHandler() { O containerOptions = getContainerOptions(); - return SemaphoreBackPressureHandler.builder().batchSize(containerOptions.getMaxMessagesPerPoll()) + BatchAwareBackPressureHandler backPressureHandler = SemaphoreBackPressureHandler.builder() + .batchSize(containerOptions.getMaxMessagesPerPoll()) .totalPermits(containerOptions.getMaxConcurrentMessages()) - .standbyLimitPollingInterval(containerOptions.getStandbyLimitPollingInterval()) .acquireTimeout(containerOptions.getMaxDelayBetweenPolls()) - .throughputConfiguration(containerOptions.getBackPressureMode()) - .backPressureLimiter(containerOptions.getBackPressureLimiter()).build(); + .throughputConfiguration(containerOptions.getBackPressureMode()).build(); + if (containerOptions.getBackPressureLimiter() != null) { + backPressureHandler = new BackPressureHandlerLimiter(backPressureHandler, + containerOptions.getBackPressureLimiter(), containerOptions.getStandbyLimitPollingInterval(), + containerOptions.getMaxDelayBetweenPolls()); + } + return backPressureHandler; } protected TaskExecutor createSourcesTaskExecutor() { diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerLimiter.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerLimiter.java new file mode 100644 index 000000000..aeb5a61cb --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerLimiter.java @@ -0,0 +1,153 @@ +/* + * Copyright 2013-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.listener; + +import java.time.Duration; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * A {@link BatchAwareBackPressureHandler} implementation that uses an internal {@link Semaphore} for adapting the + * maximum number of permits that can be acquired by the {@link #backPressureHandler} based on the downstream + * backpressure limit computed by the {@link #backPressureLimiter}. + * + * @see BackPressureLimiter + */ +public class BackPressureHandlerLimiter implements BatchAwareBackPressureHandler { + + /** + * The {@link BatchAwareBackPressureHandler} which permits should be limited by the {@link #backPressureLimiter}. + */ + private final BatchAwareBackPressureHandler backPressureHandler; + + /** + * The {@link BackPressureLimiter} which computes a limit on how many permits can be requested at a given moment. + */ + private final BackPressureLimiter backPressureLimiter; + + /** + * The duration to wait for permits to be acquired. + */ + private final Duration acquireTimeout; + + /** + * The duration to sleep when the queue processing is in standby. + */ + private final Duration standbyLimitPollingInterval; + + /** + * The limit of permits that can be acquired at the current time. The permits limit is defined in the [0, + * Integer.MAX_VALUE] interval. A value of {@literal 0} means that no permits can be acquired. + *

+ * This value is updated based on the downstream backpressure reported by the {@link #backPressureLimiter}. + */ + private final AtomicInteger permitsLimit = new AtomicInteger(0); + + private final ReducibleSemaphore semaphore = new ReducibleSemaphore(0); + + public BackPressureHandlerLimiter(BatchAwareBackPressureHandler backPressureHandler, + BackPressureLimiter backPressureLimiter, Duration standbyLimitPollingInterval, Duration acquireTimeout) { + this.backPressureHandler = backPressureHandler; + this.backPressureLimiter = backPressureLimiter; + this.acquireTimeout = acquireTimeout; + this.standbyLimitPollingInterval = standbyLimitPollingInterval; + } + + @Override + public int requestBatch() throws InterruptedException { + int permits = updatePermitsLimit(); + int batchSize = getBatchSize(); + if (permits < batchSize) { + return acquirePermits(permits, backPressureHandler::request); + } + return acquirePermits(batchSize, p -> backPressureHandler.requestBatch()); + } + + @Override + public void releaseBatch() { + semaphore.release(getBatchSize()); + backPressureHandler.releaseBatch(); + } + + @Override + public int getBatchSize() { + return backPressureHandler.getBatchSize(); + } + + @Override + public int request(int amount) throws InterruptedException { + int permits = Math.min(updatePermitsLimit(), amount); + return acquirePermits(permits, backPressureHandler::request); + } + + @Override + public void release(int amount) { + semaphore.release(amount); + backPressureHandler.release(amount); + } + + @Override + public boolean drain(Duration timeout) { + return backPressureHandler.drain(timeout); + } + + private int updatePermitsLimit() { + return permitsLimit.updateAndGet(oldLimit -> { + int newLimit = Math.max(0, backPressureLimiter.limit()); + if (newLimit < oldLimit) { + int blockedPermits = oldLimit - newLimit; + semaphore.reducePermits(blockedPermits); + } + else if (newLimit > oldLimit) { + int releasedPermits = newLimit - oldLimit; + semaphore.release(releasedPermits); + } + return newLimit; + }); + } + + private interface PermitsRequester { + int request(int amount) throws InterruptedException; + } + + private int acquirePermits(int amount, PermitsRequester permitsRequester) throws InterruptedException { + if (amount == 0) { + Thread.sleep(standbyLimitPollingInterval.toMillis()); + return 0; + } + if (semaphore.tryAcquire(amount, acquireTimeout.toMillis(), TimeUnit.MILLISECONDS)) { + int obtained = permitsRequester.request(amount); + if (obtained < amount) { + semaphore.release(amount - obtained); + } + return obtained; + } + return 0; + } + + private static class ReducibleSemaphore extends Semaphore { + + ReducibleSemaphore(int permits) { + super(permits); + } + + @Override + public void reducePermits(int reduction) { + super.reducePermits(reduction); + } + } +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java index e3d069bce..310b64519 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java @@ -17,11 +17,9 @@ import java.time.Duration; import java.util.Arrays; -import java.util.Objects; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.util.Assert; @@ -37,63 +35,33 @@ public class SemaphoreBackPressureHandler implements BatchAwareBackPressureHandl private static final Logger logger = LoggerFactory.getLogger(SemaphoreBackPressureHandler.class); - private final BackPressureLimiter backPressureLimiter; - - private final ReducibleSemaphore semaphore; + private final Semaphore semaphore; private final int batchSize; - /** - * The theoretical maximum numbers of permits that can be acquired if no limit is set. - * @see #permitsLimit for the current limit. - */ private final int totalPermits; - /** - * The limit of permits that can be acquired at the current time. The permits limit is defined in the [0, - * totalPermits] interval. A value of {@literal 0} means that no permits can be acquired. - *

- * This value is updated based on the downstream backpressure reported by the {@link #backPressureLimiter}. - */ - private final AtomicInteger permitsLimit; - - /** - * The duration to sleep when the queue processing is in standby. - */ - private final Duration standbyLimitPollingInterval; - private final Duration acquireTimeout; private final BackPressureMode backPressureConfiguration; private volatile CurrentThroughputMode currentThroughputMode; - /** - * The number of permits acquired in low throughput mode. This value is minimum value between {@link #permitsLimit} - * at the time of the acquire and {@link #totalPermits}. - */ - private final AtomicInteger lowThroughputAcquiredPermits = new AtomicInteger(0); - private final AtomicBoolean hasAcquiredFullPermits = new AtomicBoolean(false); private String id; - private final AtomicBoolean isDraining = new AtomicBoolean(false); - private SemaphoreBackPressureHandler(Builder builder) { this.batchSize = builder.batchSize; this.totalPermits = builder.totalPermits; - this.standbyLimitPollingInterval = builder.standbyLimitPollingInterval; this.acquireTimeout = builder.acquireTimeout; this.backPressureConfiguration = builder.backPressureMode; - this.semaphore = new ReducibleSemaphore(totalPermits); + this.semaphore = new Semaphore(totalPermits); this.currentThroughputMode = BackPressureMode.FIXED_HIGH_THROUGHPUT.equals(backPressureConfiguration) ? CurrentThroughputMode.HIGH : CurrentThroughputMode.LOW; logger.debug("SemaphoreBackPressureHandler created with configuration {} and {} total permits", backPressureConfiguration, totalPermits); - this.permitsLimit = new AtomicInteger(totalPermits); - this.backPressureLimiter = Objects.requireNonNullElse(builder.backPressureLimiter, () -> totalPermits); } public static Builder builder() { @@ -112,17 +80,15 @@ public String getId() { @Override public int request(int amount) throws InterruptedException { - updateAvailablePermitsBasedOnDownstreamBackpressure(); return tryAcquire(amount, this.currentThroughputMode) ? amount : 0; } // @formatter:off @Override public int requestBatch() throws InterruptedException { - updateAvailablePermitsBasedOnDownstreamBackpressure(); - boolean useLowThroughput = CurrentThroughputMode.LOW.equals(this.currentThroughputMode) - || this.permitsLimit.get() < this.totalPermits; - return useLowThroughput ? requestInLowThroughputMode() : requestInHighThroughputMode(); + return CurrentThroughputMode.LOW.equals(this.currentThroughputMode) + ? requestInLowThroughputMode() + : requestInHighThroughputMode(); } private int requestInHighThroughputMode() throws InterruptedException { @@ -137,10 +103,10 @@ private int tryAcquirePartial() throws InterruptedException { if (availablePermits == 0 || BackPressureMode.ALWAYS_POLL_MAX_MESSAGES.equals(this.backPressureConfiguration)) { return 0; } - int permitsToRequest = min(availablePermits, this.batchSize); + int permitsToRequest = Math.min(availablePermits, this.batchSize); CurrentThroughputMode currentThroughputModeNow = this.currentThroughputMode; - logger.trace("Trying to acquire partial batch of {} permits from {} (limit {}) available for {} in TM {}", - permitsToRequest, availablePermits, this.permitsLimit.get(), this.id, currentThroughputModeNow); + logger.trace("Trying to acquire partial batch of {} permits from {} available for {} in TM {}", + permitsToRequest, availablePermits, this.id, currentThroughputModeNow); boolean hasAcquiredPartial = tryAcquire(permitsToRequest, currentThroughputModeNow); return hasAcquiredPartial ? permitsToRequest : 0; } @@ -148,35 +114,17 @@ private int tryAcquirePartial() throws InterruptedException { private int requestInLowThroughputMode() throws InterruptedException { // Although LTM can be set / unset by many processes, only the MessageSource thread gets here, // so no actual concurrency - logger.debug("Trying to acquire full permits for {}. Permits left: {}, Permits limit: {}", this.id, - this.semaphore.availablePermits(), this.permitsLimit.get()); - int permitsToRequest = min(this.permitsLimit.get(), this.totalPermits); - if (permitsToRequest == 0) { - logger.info("No permits usable for {} (limit = 0), sleeping for {}", this.id, - this.standbyLimitPollingInterval); - Thread.sleep(standbyLimitPollingInterval.toMillis()); - return 0; - } - boolean hasAcquired = tryAcquire(permitsToRequest, CurrentThroughputMode.LOW); + logger.debug("Trying to acquire full permits for {}. Permits left: {}", this.id, + this.semaphore.availablePermits()); + boolean hasAcquired = tryAcquire(this.totalPermits, CurrentThroughputMode.LOW); if (hasAcquired) { - if (permitsToRequest >= this.totalPermits) { - logger.debug("Acquired full permits for {}. Permits left: {}, Permits limit: {}", this.id, - this.semaphore.availablePermits(), this.permitsLimit.get()); - } - else { - logger.debug("Acquired limited permits ({}) for {} . Permits left: {}, Permits limit: {}", - permitsToRequest, this.id, this.semaphore.availablePermits(), this.permitsLimit.get()); - } - int tokens = min(this.batchSize, permitsToRequest); + logger.debug("Acquired full permits for {}. Permits left: {}", this.id, this.semaphore.availablePermits()); // We've acquired all permits - there's no other process currently processing messages if (!this.hasAcquiredFullPermits.compareAndSet(false, true)) { - logger.warn("hasAcquiredFullPermits was already true. Permits left: {}, Permits limit: {}", - this.semaphore.availablePermits(), this.permitsLimit.get()); + logger.warn("hasAcquiredFullPermits was already true. Permits left: {}", + this.semaphore.availablePermits()); } - else { - lowThroughputAcquiredPermits.set(permitsToRequest); - } - return tokens; + return this.batchSize; } else { return 0; @@ -184,20 +132,16 @@ private int requestInLowThroughputMode() throws InterruptedException { } private boolean tryAcquire(int amount, CurrentThroughputMode currentThroughputModeNow) throws InterruptedException { - if (isDraining.get()) { - return false; - } logger.trace("Acquiring {} permits for {} in TM {}", amount, this.id, this.currentThroughputMode); boolean hasAcquired = this.semaphore.tryAcquire(amount, this.acquireTimeout.toMillis(), TimeUnit.MILLISECONDS); if (hasAcquired) { - logger.trace("{} permits acquired for {} in TM {}. Permits left: {}, Permits limit: {}", amount, this.id, - currentThroughputModeNow, this.semaphore.availablePermits(), this.permitsLimit.get()); + logger.trace("{} permits acquired for {} in TM {}. Permits left: {}", amount, this.id, + currentThroughputModeNow, this.semaphore.availablePermits()); } else { - logger.trace( - "Not able to acquire {} permits in {} milliseconds for {} in TM {}. Permits left: {}, Permits limit: {}", - amount, this.acquireTimeout.toMillis(), this.id, currentThroughputModeNow, - this.semaphore.availablePermits(), this.permitsLimit.get()); + logger.trace("Not able to acquire {} permits in {} milliseconds for {} in TM {}. Permits left: {}", amount, + this.acquireTimeout.toMillis(), this.id, currentThroughputModeNow, + this.semaphore.availablePermits()); } return hasAcquired; } @@ -237,13 +181,11 @@ public void release(int amount) { } private int getPermitsToRelease(int amount) { - if (this.hasAcquiredFullPermits.compareAndSet(true, false)) { - int allAcquiredPermits = this.lowThroughputAcquiredPermits.getAndSet(0); - // The first process that gets here should release all permits except for inflight messages - // We can have only one batch of messages at this point since we have all permits - return (allAcquiredPermits - (min(this.batchSize, allAcquiredPermits) - amount)); - } - return amount; + return this.hasAcquiredFullPermits.compareAndSet(true, false) + // The first process that gets here should release all permits except for inflight messages + // We can have only one batch of messages at this point since we have all permits + ? this.totalPermits - (this.batchSize - amount) + : amount; } private void maybeSwitchToHighThroughputMode(int amount) { @@ -258,8 +200,6 @@ private void maybeSwitchToHighThroughputMode(int amount) { public boolean drain(Duration timeout) { logger.debug("Waiting for up to {} seconds for approx. {} permits to be released for {}", timeout.getSeconds(), this.totalPermits - this.semaphore.availablePermits(), this.id); - isDraining.set(true); - updateMaxPermitsLimit(this.totalPermits); try { return this.semaphore.tryAcquire(this.totalPermits, (int) timeout.getSeconds(), TimeUnit.SECONDS); } @@ -269,44 +209,6 @@ public boolean drain(Duration timeout) { } } - private int min(int a, int p) { - return Math.max(0, Math.min(a, p)); - } - - private void updateAvailablePermitsBasedOnDownstreamBackpressure() { - if (!isDraining.get()) { - int limit = backPressureLimiter.limit(); - int newCurrentMaxPermits = min(limit, totalPermits); - updateMaxPermitsLimit(newCurrentMaxPermits); - if (isDraining.get()) { - updateMaxPermitsLimit(totalPermits); - } - } - } - - private void updateMaxPermitsLimit(int newCurrentMaxPermits) { - int oldValue = permitsLimit.getAndUpdate(i -> min(newCurrentMaxPermits, totalPermits)); - if (newCurrentMaxPermits < oldValue) { - int blockedPermits = oldValue - newCurrentMaxPermits; - semaphore.reducePermits(blockedPermits); - } - else if (newCurrentMaxPermits > oldValue) { - int releasedPermits = newCurrentMaxPermits - oldValue; - semaphore.release(releasedPermits); - } - } - - private static class ReducibleSemaphore extends Semaphore { - ReducibleSemaphore(int permits) { - super(permits); - } - - @Override - public void reducePermits(int reduction) { - super.reducePermits(reduction); - } - } - private enum CurrentThroughputMode { HIGH, @@ -321,14 +223,10 @@ public static class Builder { private int totalPermits; - private Duration standbyLimitPollingInterval; - private Duration acquireTimeout; private BackPressureMode backPressureMode; - private BackPressureLimiter backPressureLimiter; - public Builder batchSize(int batchSize) { this.batchSize = batchSize; return this; @@ -339,11 +237,6 @@ public Builder totalPermits(int totalPermits) { return this; } - public Builder standbyLimitPollingInterval(Duration standbyLimitPollingInterval) { - this.standbyLimitPollingInterval = standbyLimitPollingInterval; - return this; - } - public Builder acquireTimeout(Duration acquireTimeout) { this.acquireTimeout = acquireTimeout; return this; @@ -354,14 +247,10 @@ public Builder throughputConfiguration(BackPressureMode backPressureConfiguratio return this; } - public Builder backPressureLimiter(BackPressureLimiter backPressureLimiter) { - this.backPressureLimiter = backPressureLimiter; - return this; - } - public SemaphoreBackPressureHandler build() { - Assert.noNullElements(Arrays.asList(this.batchSize, this.totalPermits, this.standbyLimitPollingInterval, - this.acquireTimeout, this.backPressureMode), "Missing configuration"); + Assert.noNullElements( + Arrays.asList(this.batchSize, this.totalPermits, this.acquireTimeout, this.backPressureMode), + "Missing configuration"); return new SemaphoreBackPressureHandler(this); } From 296d05aaf24981fc9108e34c0a43414c9d3ac1a9 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Fri, 3 Jan 2025 14:14:14 +0100 Subject: [PATCH 03/29] Introduce a CompositeBackPressureHandler allowing for composition of BackPressureHandlers (#1251) --- ...tractPipelineMessageListenerContainer.java | 19 +++-- .../sqs/listener/BackPressureHandler.java | 41 ++++++++- .../listener/BackPressureHandlerLimiter.java | 68 ++++++--------- .../BatchAwareBackPressureHandler.java | 14 ---- .../CompositeBackPressureHandler.java | 84 +++++++++++++++++++ .../SemaphoreBackPressureHandler.java | 81 ++++++++---------- .../source/AbstractPollingMessageSource.java | 20 ++--- 7 files changed, 201 insertions(+), 126 deletions(-) create mode 100644 spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java index 7c6a75a41..f7431d555 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java @@ -36,6 +36,7 @@ import io.awspring.cloud.sqs.listener.source.MessageSource; import io.awspring.cloud.sqs.listener.source.PollingMessageSource; import io.awspring.cloud.sqs.support.observation.AbstractListenerObservation; +import java.time.Duration; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -231,17 +232,17 @@ private TaskExecutor validateCustomExecutor(TaskExecutor taskExecutor) { protected BackPressureHandler createBackPressureHandler() { O containerOptions = getContainerOptions(); - BatchAwareBackPressureHandler backPressureHandler = SemaphoreBackPressureHandler.builder() - .batchSize(containerOptions.getMaxMessagesPerPoll()) - .totalPermits(containerOptions.getMaxConcurrentMessages()) - .acquireTimeout(containerOptions.getMaxDelayBetweenPolls()) - .throughputConfiguration(containerOptions.getBackPressureMode()).build(); + List backPressureHandlers = new ArrayList<>(2); + Duration acquireTimeout = containerOptions.getMaxDelayBetweenPolls(); + int batchSize = containerOptions.getMaxMessagesPerPoll(); + backPressureHandlers.add(SemaphoreBackPressureHandler.builder().batchSize(batchSize) + .totalPermits(containerOptions.getMaxConcurrentMessages()).acquireTimeout(acquireTimeout) + .throughputConfiguration(containerOptions.getBackPressureMode()).build()); if (containerOptions.getBackPressureLimiter() != null) { - backPressureHandler = new BackPressureHandlerLimiter(backPressureHandler, - containerOptions.getBackPressureLimiter(), containerOptions.getStandbyLimitPollingInterval(), - containerOptions.getMaxDelayBetweenPolls()); + backPressureHandlers.add(new BackPressureHandlerLimiter(containerOptions.getBackPressureLimiter(), + acquireTimeout, containerOptions.getStandbyLimitPollingInterval(), batchSize)); } - return backPressureHandler; + return new CompositeBackPressureHandler(backPressureHandlers, batchSize); } protected TaskExecutor createSourcesTaskExecutor() { diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java index 1d76d6589..f2ff274b1 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java @@ -29,7 +29,7 @@ public interface BackPressureHandler { /** - * Request a number of permits. Each obtained permit allows the + * Requests a number of permits. Each obtained permit allows the * {@link io.awspring.cloud.sqs.listener.source.MessageSource} to retrieve one message. * @param amount the amount of permits to request. * @return the amount of permits obtained. @@ -38,11 +38,24 @@ public interface BackPressureHandler { int request(int amount) throws InterruptedException; /** - * Release the specified amount of permits. Each message that has been processed should release one permit, whether - * processing was successful or not. + * Releases the specified amount of permits for processed messages. Each message that has been processed should + * release one permit, whether processing was successful or not. + *

+ * This method can is called in the following use cases: + *

    + *
  • {@link ReleaseReason#LIMITED}: permits were not used because another BackPressureHandler has a lower permits + * limit and the difference in permits needs to be returned.
  • + *
  • {@link ReleaseReason#NONE_FETCHED}: none of the permits were actually used because no messages were retrieved + * from SQS. Permits need to be returned.
  • + *
  • {@link ReleaseReason#PARTIAL_FETCH}: some of the permits were used (some messages were retrieved from SQS). + * The unused ones need to be returned. The amount to be returned might be {@literal 0}, in which case it means all + * the permits will be used as the same number of messages were fetched from SQS.
  • + *
  • {@link ReleaseReason#PROCESSED}: a message processing finished, successfully or not.
  • + *
* @param amount the amount of permits to release. + * @param reason the reason why the permits were released. */ - void release(int amount); + void release(int amount, ReleaseReason reason); /** * Attempts to acquire all permits up to the specified timeout. If successful, means all permits were returned and @@ -52,4 +65,24 @@ public interface BackPressureHandler { */ boolean drain(Duration timeout); + enum ReleaseReason { + /** + * Permits were not used because another BackPressureHandler has a lower permits limit and the difference need + * to be aligned across all handlers. + */ + LIMITED, + /** + * No messages were retrieved from SQS, so all permits need to be returned. + */ + NONE_FETCHED, + /** + * Some messages were fetched from SQS. Unused permits need to be returned. + */ + PARTIAL_FETCH, + /** + * The processing of one or more messages finished, successfully or not. + */ + PROCESSED; + } + } diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerLimiter.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerLimiter.java index aeb5a61cb..cd031a129 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerLimiter.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerLimiter.java @@ -27,12 +27,7 @@ * * @see BackPressureLimiter */ -public class BackPressureHandlerLimiter implements BatchAwareBackPressureHandler { - - /** - * The {@link BatchAwareBackPressureHandler} which permits should be limited by the {@link #backPressureLimiter}. - */ - private final BatchAwareBackPressureHandler backPressureHandler; +public class BackPressureHandlerLimiter implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { /** * The {@link BackPressureLimiter} which computes a limit on how many permits can be requested at a given moment. @@ -59,50 +54,54 @@ public class BackPressureHandlerLimiter implements BatchAwareBackPressureHandler private final ReducibleSemaphore semaphore = new ReducibleSemaphore(0); - public BackPressureHandlerLimiter(BatchAwareBackPressureHandler backPressureHandler, - BackPressureLimiter backPressureLimiter, Duration standbyLimitPollingInterval, Duration acquireTimeout) { - this.backPressureHandler = backPressureHandler; + private final int batchSize; + + private String id; + + public BackPressureHandlerLimiter(BackPressureLimiter backPressureLimiter, Duration acquireTimeout, + Duration standbyLimitPollingInterval, int batchSize) { this.backPressureLimiter = backPressureLimiter; this.acquireTimeout = acquireTimeout; this.standbyLimitPollingInterval = standbyLimitPollingInterval; + this.batchSize = batchSize; } @Override - public int requestBatch() throws InterruptedException { - int permits = updatePermitsLimit(); - int batchSize = getBatchSize(); - if (permits < batchSize) { - return acquirePermits(permits, backPressureHandler::request); - } - return acquirePermits(batchSize, p -> backPressureHandler.requestBatch()); + public void setId(String id) { + this.id = id; } @Override - public void releaseBatch() { - semaphore.release(getBatchSize()); - backPressureHandler.releaseBatch(); + public String getId() { + return id; } @Override - public int getBatchSize() { - return backPressureHandler.getBatchSize(); + public int requestBatch() throws InterruptedException { + return request(batchSize); } @Override public int request(int amount) throws InterruptedException { int permits = Math.min(updatePermitsLimit(), amount); - return acquirePermits(permits, backPressureHandler::request); + if (permits == 0) { + Thread.sleep(standbyLimitPollingInterval.toMillis()); + return 0; + } + if (semaphore.tryAcquire(permits, acquireTimeout.toMillis(), TimeUnit.MILLISECONDS)) { + return permits; + } + return 0; } @Override - public void release(int amount) { + public void release(int amount, ReleaseReason reason) { semaphore.release(amount); - backPressureHandler.release(amount); } @Override public boolean drain(Duration timeout) { - return backPressureHandler.drain(timeout); + return true; } private int updatePermitsLimit() { @@ -120,25 +119,6 @@ else if (newLimit > oldLimit) { }); } - private interface PermitsRequester { - int request(int amount) throws InterruptedException; - } - - private int acquirePermits(int amount, PermitsRequester permitsRequester) throws InterruptedException { - if (amount == 0) { - Thread.sleep(standbyLimitPollingInterval.toMillis()); - return 0; - } - if (semaphore.tryAcquire(amount, acquireTimeout.toMillis(), TimeUnit.MILLISECONDS)) { - int obtained = permitsRequester.request(amount); - if (obtained < amount) { - semaphore.release(amount - obtained); - } - return obtained; - } - return 0; - } - private static class ReducibleSemaphore extends Semaphore { ReducibleSemaphore(int permits) { diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java index 51e12e0a0..c9ce20f9b 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java @@ -30,18 +30,4 @@ public interface BatchAwareBackPressureHandler extends BackPressureHandler { * @throws InterruptedException if the Thread is interrupted while waiting for permits. */ int requestBatch() throws InterruptedException; - - /** - * Release a batch of permits. This has the semantics of letting the {@link BackPressureHandler} know that all - * permits from a batch are being released, in opposition to {@link #release(int)} in which any number of permits - * can be specified. - */ - void releaseBatch(); - - /** - * Return the configured batch size for this handler. - * @return the batch size. - */ - int getBatchSize(); - } diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java new file mode 100644 index 000000000..42202438b --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java @@ -0,0 +1,84 @@ +/* + * Copyright 2013-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.listener; + +import java.time.Duration; +import java.util.List; + +public class CompositeBackPressureHandler implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { + + private final List backPressureHandlers; + + private final int batchSize; + + private String id; + + public CompositeBackPressureHandler(List backPressureHandlers, int batchSize) { + this.backPressureHandlers = backPressureHandlers; + this.batchSize = batchSize; + } + + @Override + public void setId(String id) { + this.id = id; + backPressureHandlers.stream().filter(IdentifiableContainerComponent.class::isInstance) + .map(IdentifiableContainerComponent.class::cast) + .forEach(bph -> bph.setId(bph.getClass().getSimpleName() + "-" + id)); + } + + @Override + public String getId() { + return id; + } + + @Override + public int requestBatch() throws InterruptedException { + return request(batchSize); + } + + @Override + public int request(int amount) throws InterruptedException { + int obtained = amount; + int[] obtainedPerBph = new int[backPressureHandlers.size()]; + for (int i = 0; i < backPressureHandlers.size() && obtained > 0; i++) { + obtainedPerBph[i] = backPressureHandlers.get(i).request(obtained); + obtained = Math.min(obtained, obtainedPerBph[i]); + } + for (int i = 0; i < backPressureHandlers.size(); i++) { + int obtainedForBph = obtainedPerBph[i]; + if (obtainedForBph > obtained) { + backPressureHandlers.get(i).release(obtainedForBph - obtained, ReleaseReason.LIMITED); + } + } + return obtained; + } + + @Override + public void release(int amount, ReleaseReason reason) { + for (BackPressureHandler handler : backPressureHandlers) { + handler.release(amount, reason); + } + } + + @Override + public boolean drain(Duration timeout) { + boolean result = true; + for (BackPressureHandler handler : backPressureHandlers) { + result &= !handler.drain(timeout); + } + return result; + } +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java index 310b64519..70ed3f306 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java @@ -19,7 +19,7 @@ import java.util.Arrays; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.util.Assert; @@ -47,7 +47,7 @@ public class SemaphoreBackPressureHandler implements BatchAwareBackPressureHandl private volatile CurrentThroughputMode currentThroughputMode; - private final AtomicBoolean hasAcquiredFullPermits = new AtomicBoolean(false); + private final AtomicInteger lowThroughputPermitsAcquired = new AtomicInteger(0); private String id; @@ -79,31 +79,31 @@ public String getId() { } @Override - public int request(int amount) throws InterruptedException { - return tryAcquire(amount, this.currentThroughputMode) ? amount : 0; + public int requestBatch() throws InterruptedException { + return request(batchSize); } // @formatter:off @Override - public int requestBatch() throws InterruptedException { + public int request(int amount) throws InterruptedException { return CurrentThroughputMode.LOW.equals(this.currentThroughputMode) - ? requestInLowThroughputMode() - : requestInHighThroughputMode(); + ? requestInLowThroughputMode(amount) + : requestInHighThroughputMode(amount); } - private int requestInHighThroughputMode() throws InterruptedException { - return tryAcquire(this.batchSize, CurrentThroughputMode.HIGH) - ? this.batchSize - : tryAcquirePartial(); + private int requestInHighThroughputMode(int amount) throws InterruptedException { + return tryAcquire(amount, CurrentThroughputMode.HIGH) + ? amount + : tryAcquirePartial(amount); } // @formatter:on - private int tryAcquirePartial() throws InterruptedException { + private int tryAcquirePartial(int max) throws InterruptedException { int availablePermits = this.semaphore.availablePermits(); if (availablePermits == 0 || BackPressureMode.ALWAYS_POLL_MAX_MESSAGES.equals(this.backPressureConfiguration)) { return 0; } - int permitsToRequest = Math.min(availablePermits, this.batchSize); + int permitsToRequest = Math.min(availablePermits, max); CurrentThroughputMode currentThroughputModeNow = this.currentThroughputMode; logger.trace("Trying to acquire partial batch of {} permits from {} available for {} in TM {}", permitsToRequest, availablePermits, this.id, currentThroughputModeNow); @@ -111,7 +111,7 @@ private int tryAcquirePartial() throws InterruptedException { return hasAcquiredPartial ? permitsToRequest : 0; } - private int requestInLowThroughputMode() throws InterruptedException { + private int requestInLowThroughputMode(int amount) throws InterruptedException { // Although LTM can be set / unset by many processes, only the MessageSource thread gets here, // so no actual concurrency logger.debug("Trying to acquire full permits for {}. Permits left: {}", this.id, @@ -120,11 +120,11 @@ private int requestInLowThroughputMode() throws InterruptedException { if (hasAcquired) { logger.debug("Acquired full permits for {}. Permits left: {}", this.id, this.semaphore.availablePermits()); // We've acquired all permits - there's no other process currently processing messages - if (!this.hasAcquiredFullPermits.compareAndSet(false, true)) { + if (this.lowThroughputPermitsAcquired.getAndSet(amount) != 0) { logger.warn("hasAcquiredFullPermits was already true. Permits left: {}", this.semaphore.availablePermits()); } - return this.batchSize; + return amount; } else { return 0; @@ -147,19 +147,22 @@ private boolean tryAcquire(int amount, CurrentThroughputMode currentThroughputMo } @Override - public void releaseBatch() { - maybeSwitchToLowThroughputMode(); - int permitsToRelease = getPermitsToRelease(this.batchSize); + public void release(int amount, ReleaseReason reason) { + logger.trace("Releasing {} permits ({}) for {}. Permits left: {}", amount, reason, this.id, + this.semaphore.availablePermits()); + switch (reason) { + case NONE_FETCHED -> maybeSwitchToLowThroughputMode(); + case PARTIAL_FETCH -> maybeSwitchToHighThroughputMode(amount); + case PROCESSED, LIMITED -> { + // No need to switch throughput mode + } + } + int permitsToRelease = getPermitsToRelease(amount); this.semaphore.release(permitsToRelease); - logger.trace("Released {} permits for {}. Permits left: {}", permitsToRelease, this.id, + logger.debug("Released {} permits ({}) for {}. Permits left: {}", permitsToRelease, reason, this.id, this.semaphore.availablePermits()); } - @Override - public int getBatchSize() { - return this.batchSize; - } - private void maybeSwitchToLowThroughputMode() { if (!BackPressureMode.FIXED_HIGH_THROUGHPUT.equals(this.backPressureConfiguration) && CurrentThroughputMode.HIGH.equals(this.currentThroughputMode)) { @@ -169,25 +172,6 @@ private void maybeSwitchToLowThroughputMode() { } } - @Override - public void release(int amount) { - logger.trace("Releasing {} permits for {}. Permits left: {}", amount, this.id, - this.semaphore.availablePermits()); - maybeSwitchToHighThroughputMode(amount); - int permitsToRelease = getPermitsToRelease(amount); - this.semaphore.release(permitsToRelease); - logger.trace("Released {} permits for {}. Permits left: {}", permitsToRelease, this.id, - this.semaphore.availablePermits()); - } - - private int getPermitsToRelease(int amount) { - return this.hasAcquiredFullPermits.compareAndSet(true, false) - // The first process that gets here should release all permits except for inflight messages - // We can have only one batch of messages at this point since we have all permits - ? this.totalPermits - (this.batchSize - amount) - : amount; - } - private void maybeSwitchToHighThroughputMode(int amount) { if (CurrentThroughputMode.LOW.equals(this.currentThroughputMode)) { logger.debug("{} unused permit(s), setting TM HIGH for {}. Permits left: {}", amount, this.id, @@ -196,6 +180,15 @@ private void maybeSwitchToHighThroughputMode(int amount) { } } + private int getPermitsToRelease(int amount) { + int lowThroughputPermits = this.lowThroughputPermitsAcquired.getAndSet(0); + return lowThroughputPermits > 0 + // The first process that gets here should release all permits except for inflight messages + // We can have only one batch of messages at this point since we have all permits + ? this.totalPermits - (lowThroughputPermits - amount) + : amount; + } + @Override public boolean drain(Duration timeout) { logger.debug("Waiting for up to {} seconds for approx. {} permits to be released for {}", timeout.getSeconds(), diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSource.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSource.java index e71dc4319..9041cd9c8 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSource.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSource.java @@ -17,6 +17,7 @@ import io.awspring.cloud.sqs.ConfigUtils; import io.awspring.cloud.sqs.listener.BackPressureHandler; +import io.awspring.cloud.sqs.listener.BackPressureHandler.ReleaseReason; import io.awspring.cloud.sqs.listener.BatchAwareBackPressureHandler; import io.awspring.cloud.sqs.listener.ContainerOptions; import io.awspring.cloud.sqs.listener.IdentifiableContainerComponent; @@ -214,7 +215,7 @@ private void pollAndEmitMessages() { if (!isRunning()) { logger.debug("MessageSource was stopped after permits where acquired. Returning {} permits", acquiredPermits); - this.backPressureHandler.release(acquiredPermits); + this.backPressureHandler.release(acquiredPermits, ReleaseReason.NONE_FETCHED); continue; } // @formatter:off @@ -252,15 +253,12 @@ private void handlePollBackOff() { protected abstract CompletableFuture> doPollForMessages(int messagesToRequest); public Collection> releaseUnusedPermits(int permits, Collection> msgs) { - if (msgs.isEmpty() && permits == this.backPressureHandler.getBatchSize()) { - this.backPressureHandler.releaseBatch(); - logger.trace("Released batch of unused permits for queue {}", this.pollingEndpointName); - } - else { - int permitsToRelease = permits - msgs.size(); - this.backPressureHandler.release(permitsToRelease); - logger.trace("Released {} unused permits for queue {}", permitsToRelease, this.pollingEndpointName); - } + int polledMessages = msgs.size(); + int permitsToRelease = permits - polledMessages; + ReleaseReason releaseReason = polledMessages == 0 ? ReleaseReason.NONE_FETCHED : ReleaseReason.PARTIAL_FETCH; + this.backPressureHandler.release(permitsToRelease, releaseReason); + logger.trace("Released {} unused ({}) permits for queue {} (messages polled {})", permitsToRelease, + releaseReason, this.pollingEndpointName, polledMessages); return msgs; } @@ -285,7 +283,7 @@ protected AcknowledgementCallback getAcknowledgementCallback() { private void releaseBackPressure() { logger.debug("Releasing permit for queue {}", this.pollingEndpointName); - this.backPressureHandler.release(1); + this.backPressureHandler.release(1, ReleaseReason.PROCESSED); } private Void handleSinkException(Throwable t) { From 3f7227793bd349421e845a03b67288cce91b3d77 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Thu, 6 Feb 2025 12:01:39 +0100 Subject: [PATCH 04/29] Remove BackPressureHandlerLimiter from the library and make it user-code in tests only (#1251) --- .../listener/AbstractContainerOptions.java | 19 +- ...tractPipelineMessageListenerContainer.java | 13 +- .../sqs/listener/BackPressureHandler.java | 12 + .../listener/BackPressureHandlerLimiter.java | 133 ----------- .../sqs/listener/BackPressureLimiter.java | 44 ---- .../BatchAwareBackPressureHandler.java | 24 ++ .../cloud/sqs/listener/ContainerOptions.java | 9 +- .../sqs/listener/ContainerOptionsBuilder.java | 8 +- .../sqs/integration/SqsIntegrationTests.java | 210 +++++++++++++++--- 9 files changed, 243 insertions(+), 229 deletions(-) delete mode 100644 spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerLimiter.java delete mode 100644 spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureLimiter.java diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java index 3cf5e7a4f..c85297cbd 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java @@ -22,6 +22,7 @@ import io.micrometer.observation.ObservationConvention; import io.micrometer.observation.ObservationRegistry; import java.time.Duration; +import java.util.function.Supplier; import org.springframework.core.task.TaskExecutor; import org.springframework.lang.Nullable; import org.springframework.retry.backoff.BackOffPolicy; @@ -57,7 +58,7 @@ public abstract class AbstractContainerOptions, private final BackPressureMode backPressureMode; - private final BackPressureLimiter backPressureLimiter; + private final Supplier backPressureHandlerSupplier; private final ListenerMode listenerMode; @@ -95,7 +96,7 @@ protected AbstractContainerOptions(Builder builder) { this.listenerShutdownTimeout = builder.listenerShutdownTimeout; this.acknowledgementShutdownTimeout = builder.acknowledgementShutdownTimeout; this.backPressureMode = builder.backPressureMode; - this.backPressureLimiter = builder.backPressureLimiter; + this.backPressureHandlerSupplier = builder.backPressureHandlerSupplier; this.listenerMode = builder.listenerMode; this.messageConverter = builder.messageConverter; this.acknowledgementMode = builder.acknowledgementMode; @@ -174,8 +175,8 @@ public BackPressureMode getBackPressureMode() { } @Override - public BackPressureLimiter getBackPressureLimiter() { - return this.backPressureLimiter; + public Supplier getBackPressureHandlerSupplier() { + return this.backPressureHandlerSupplier; } @Override @@ -250,7 +251,7 @@ protected abstract static class Builder, private static final BackPressureMode DEFAULT_THROUGHPUT_CONFIGURATION = BackPressureMode.AUTO; - private static final BackPressureLimiter DEFAULT_BACKPRESSURE_LIMITER = null; + private static final Supplier DEFAULT_BACKPRESSURE_LIMITER = null; private static final ListenerMode DEFAULT_MESSAGE_DELIVERY_STRATEGY = ListenerMode.SINGLE_MESSAGE; @@ -276,7 +277,7 @@ protected abstract static class Builder, private BackPressureMode backPressureMode = DEFAULT_THROUGHPUT_CONFIGURATION; - private BackPressureLimiter backPressureLimiter = DEFAULT_BACKPRESSURE_LIMITER; + private Supplier backPressureHandlerSupplier = DEFAULT_BACKPRESSURE_LIMITER; private Duration listenerShutdownTimeout = DEFAULT_LISTENER_SHUTDOWN_TIMEOUT; @@ -320,7 +321,7 @@ protected Builder(AbstractContainerOptions options) { this.listenerShutdownTimeout = options.listenerShutdownTimeout; this.acknowledgementShutdownTimeout = options.acknowledgementShutdownTimeout; this.backPressureMode = options.backPressureMode; - this.backPressureLimiter = options.backPressureLimiter; + this.backPressureHandlerSupplier = options.backPressureHandlerSupplier; this.listenerMode = options.listenerMode; this.messageConverter = options.messageConverter; this.acknowledgementMode = options.acknowledgementMode; @@ -423,8 +424,8 @@ public B backPressureMode(BackPressureMode backPressureMode) { } @Override - public B backPressureLimiter(BackPressureLimiter backPressureLimiter) { - this.backPressureLimiter = backPressureLimiter; + public B backPressureHandlerSupplier(Supplier backPressureHandlerSupplier) { + this.backPressureHandlerSupplier = backPressureHandlerSupplier; return self(); } diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java index f7431d555..2de091faf 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java @@ -232,17 +232,14 @@ private TaskExecutor validateCustomExecutor(TaskExecutor taskExecutor) { protected BackPressureHandler createBackPressureHandler() { O containerOptions = getContainerOptions(); - List backPressureHandlers = new ArrayList<>(2); + if (containerOptions.getBackPressureHandlerSupplier() != null) { + return containerOptions.getBackPressureHandlerSupplier().get(); + } Duration acquireTimeout = containerOptions.getMaxDelayBetweenPolls(); int batchSize = containerOptions.getMaxMessagesPerPoll(); - backPressureHandlers.add(SemaphoreBackPressureHandler.builder().batchSize(batchSize) + return SemaphoreBackPressureHandler.builder().batchSize(batchSize) .totalPermits(containerOptions.getMaxConcurrentMessages()).acquireTimeout(acquireTimeout) - .throughputConfiguration(containerOptions.getBackPressureMode()).build()); - if (containerOptions.getBackPressureLimiter() != null) { - backPressureHandlers.add(new BackPressureHandlerLimiter(containerOptions.getBackPressureLimiter(), - acquireTimeout, containerOptions.getStandbyLimitPollingInterval(), batchSize)); - } - return new CompositeBackPressureHandler(backPressureHandlers, batchSize); + .throughputConfiguration(containerOptions.getBackPressureMode()).build(); } protected TaskExecutor createSourcesTaskExecutor() { diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java index f2ff274b1..a5921de68 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java @@ -57,6 +57,18 @@ public interface BackPressureHandler { */ void release(int amount, ReleaseReason reason); + /** + * Release the specified amount of permits. Each message that has been processed should release one permit, whether + * processing was successful or not. + * @param amount the amount of permits to release. + * + * @deprecated This method is deprecated and will not be called by the Spring Cloud AWS SQS listener anymore. + * Implement {@link #release(int, ReleaseReason)} instead. + */ + @Deprecated + default void release(int amount) { + } + /** * Attempts to acquire all permits up to the specified timeout. If successful, means all permits were returned and * thus no activity is left in the {@link io.awspring.cloud.sqs.listener.source.MessageSource}. diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerLimiter.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerLimiter.java deleted file mode 100644 index cd031a129..000000000 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerLimiter.java +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2013-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License 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 io.awspring.cloud.sqs.listener; - -import java.time.Duration; -import java.util.concurrent.Semaphore; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * A {@link BatchAwareBackPressureHandler} implementation that uses an internal {@link Semaphore} for adapting the - * maximum number of permits that can be acquired by the {@link #backPressureHandler} based on the downstream - * backpressure limit computed by the {@link #backPressureLimiter}. - * - * @see BackPressureLimiter - */ -public class BackPressureHandlerLimiter implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { - - /** - * The {@link BackPressureLimiter} which computes a limit on how many permits can be requested at a given moment. - */ - private final BackPressureLimiter backPressureLimiter; - - /** - * The duration to wait for permits to be acquired. - */ - private final Duration acquireTimeout; - - /** - * The duration to sleep when the queue processing is in standby. - */ - private final Duration standbyLimitPollingInterval; - - /** - * The limit of permits that can be acquired at the current time. The permits limit is defined in the [0, - * Integer.MAX_VALUE] interval. A value of {@literal 0} means that no permits can be acquired. - *

- * This value is updated based on the downstream backpressure reported by the {@link #backPressureLimiter}. - */ - private final AtomicInteger permitsLimit = new AtomicInteger(0); - - private final ReducibleSemaphore semaphore = new ReducibleSemaphore(0); - - private final int batchSize; - - private String id; - - public BackPressureHandlerLimiter(BackPressureLimiter backPressureLimiter, Duration acquireTimeout, - Duration standbyLimitPollingInterval, int batchSize) { - this.backPressureLimiter = backPressureLimiter; - this.acquireTimeout = acquireTimeout; - this.standbyLimitPollingInterval = standbyLimitPollingInterval; - this.batchSize = batchSize; - } - - @Override - public void setId(String id) { - this.id = id; - } - - @Override - public String getId() { - return id; - } - - @Override - public int requestBatch() throws InterruptedException { - return request(batchSize); - } - - @Override - public int request(int amount) throws InterruptedException { - int permits = Math.min(updatePermitsLimit(), amount); - if (permits == 0) { - Thread.sleep(standbyLimitPollingInterval.toMillis()); - return 0; - } - if (semaphore.tryAcquire(permits, acquireTimeout.toMillis(), TimeUnit.MILLISECONDS)) { - return permits; - } - return 0; - } - - @Override - public void release(int amount, ReleaseReason reason) { - semaphore.release(amount); - } - - @Override - public boolean drain(Duration timeout) { - return true; - } - - private int updatePermitsLimit() { - return permitsLimit.updateAndGet(oldLimit -> { - int newLimit = Math.max(0, backPressureLimiter.limit()); - if (newLimit < oldLimit) { - int blockedPermits = oldLimit - newLimit; - semaphore.reducePermits(blockedPermits); - } - else if (newLimit > oldLimit) { - int releasedPermits = newLimit - oldLimit; - semaphore.release(releasedPermits); - } - return newLimit; - }); - } - - private static class ReducibleSemaphore extends Semaphore { - - ReducibleSemaphore(int permits) { - super(permits); - } - - @Override - public void reducePermits(int reduction) { - super.reducePermits(reduction); - } - } -} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureLimiter.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureLimiter.java deleted file mode 100644 index f85ddba82..000000000 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureLimiter.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2013-2024 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License 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 io.awspring.cloud.sqs.listener; - -/** - * The BackPressureLimiter enables a dynamic reduction of the queues consumption capacity depending on external factors. - */ -public interface BackPressureLimiter { - - /** - * {@return the limit to be applied to the queue consumption.} - * - * The limit can be used to reduce the queue consumption capabilities of the next polling attempts. The container - * will work toward satisfying the limit by decreasing the maximum number of concurrent messages that can ve - * processed. - * - * The following values will have the following effects: - * - *

    - *
  • zero or negative limits will stop consumption from the queue. When such a situation occurs, the queue - * processing is said to be on "standby".
  • - *
  • Values >= 1 and < {@link ContainerOptions#getMaxConcurrentMessages()} will reduce the queue consumption - * capabilities of the next polling attempts.
  • - *
  • Values >= {@link ContainerOptions#getMaxConcurrentMessages()} will not reduce the queue consumption - * capabilities
  • - *
- * - * Note: the adjustment will require a few polling cycles to be in effect. - */ - int limit(); -} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java index c9ce20f9b..06387976c 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java @@ -30,4 +30,28 @@ public interface BatchAwareBackPressureHandler extends BackPressureHandler { * @throws InterruptedException if the Thread is interrupted while waiting for permits. */ int requestBatch() throws InterruptedException; + + /** + * Release a batch of permits. This has the semantics of letting the {@link BackPressureHandler} know that all + * permits from a batch are being released, in opposition to {@link #release(int)} in which any number of permits + * can be specified. + * + * @deprecated This method is deprecated and will not be called by the Spring Cloud AWS SQS listener anymore. + * Implement {@link BackPressureHandler#release(int, ReleaseReason)} instead. + */ + @Deprecated + default void releaseBatch() { + } + + /** + * Return the configured batch size for this handler. + * @return the batch size. + * + * @deprecated This method is deprecated and will not be used by the Spring Cloud AWS SQS listener anymore. + */ + @Deprecated + default int getBatchSize() { + return 0; + } + } diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java index 7b8bd1afd..82dc85644 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java @@ -22,6 +22,7 @@ import io.micrometer.observation.ObservationRegistry; import java.time.Duration; import java.util.Collection; +import java.util.function.Supplier; import org.springframework.core.task.TaskExecutor; import org.springframework.lang.Nullable; import org.springframework.retry.backoff.BackOffPolicy; @@ -63,8 +64,6 @@ public interface ContainerOptions, B extends Co /** * {@return the amount of time to wait before checking again for the current limit when the queue processing is on * standby} Default is 100 milliseconds. - * - * @see BackPressureLimiter#limit() */ Duration getStandbyLimitPollingInterval(); @@ -138,10 +137,10 @@ default BackOffPolicy getPollBackOffPolicy() { BackPressureMode getBackPressureMode(); /** - * Return the {@link BackPressureLimiter} for this container. - * @return the backpressure limiter. + * Return the a {@link Supplier} to create a {@link BackPressureHandler} for this container. + * @return the BackPressureHandler supplier. */ - BackPressureLimiter getBackPressureLimiter(); + Supplier getBackPressureHandlerSupplier(); /** * Return the {@link ListenerMode} mode for this container. diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java index 31fb2c0c2..08ffad263 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java @@ -20,6 +20,7 @@ import io.awspring.cloud.sqs.support.converter.MessagingMessageConverter; import io.micrometer.observation.ObservationRegistry; import java.time.Duration; +import java.util.function.Supplier; import org.springframework.core.task.TaskExecutor; import org.springframework.retry.backoff.BackOffPolicy; @@ -63,7 +64,6 @@ public interface ContainerOptionsBuilder * * @param standbyLimitPollingInterval the limit polling interval when the queue processing is on standby. * @return this instance. - * @see BackPressureLimiter#limit() */ B standbyLimitPollingInterval(Duration standbyLimitPollingInterval); @@ -157,12 +157,12 @@ default B pollBackOffPolicy(BackOffPolicy pollBackOffPolicy) { B backPressureMode(BackPressureMode backPressureMode); /** - * Set the {@link BackPressureLimiter} for this container. Default is {@code null}. + * Set the {@link Supplier} of {@link BackPressureHandler} for this container. Default is {@code null}. * - * @param backPressureLimiter the backpressure limiter. + * @param backPressureHandlerSupplier the BackPressureHandler supplier. * @return this instance. */ - B backPressureLimiter(BackPressureLimiter backPressureLimiter); + B backPressureHandlerSupplier(Supplier backPressureHandlerSupplier); /** * Set the maximum interval between acknowledgements for batch acknowledgements. The default depends on the specific diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsIntegrationTests.java index 6f6ed4a21..4aa28b665 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsIntegrationTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsIntegrationTests.java @@ -27,11 +27,16 @@ import io.awspring.cloud.sqs.config.SqsBootstrapConfiguration; import io.awspring.cloud.sqs.config.SqsListenerConfigurer; import io.awspring.cloud.sqs.config.SqsMessageListenerContainerFactory; -import io.awspring.cloud.sqs.listener.BackPressureLimiter; +import io.awspring.cloud.sqs.listener.BackPressureHandler; +import io.awspring.cloud.sqs.listener.BackPressureMode; +import io.awspring.cloud.sqs.listener.BatchAwareBackPressureHandler; import io.awspring.cloud.sqs.listener.BatchVisibility; +import io.awspring.cloud.sqs.listener.CompositeBackPressureHandler; import io.awspring.cloud.sqs.listener.ContainerComponentFactory; +import io.awspring.cloud.sqs.listener.IdentifiableContainerComponent; import io.awspring.cloud.sqs.listener.MessageListenerContainer; import io.awspring.cloud.sqs.listener.QueueAttributes; +import io.awspring.cloud.sqs.listener.SemaphoreBackPressureHandler; import io.awspring.cloud.sqs.listener.SqsContainerOptions; import io.awspring.cloud.sqs.listener.SqsContainerOptionsBuilder; import io.awspring.cloud.sqs.listener.SqsHeaders; @@ -61,15 +66,22 @@ import io.micrometer.observation.tck.TestObservationRegistry; import io.micrometer.observation.tck.TestObservationRegistryAssert; import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Queue; import java.util.Random; import java.util.UUID; import java.util.concurrent.BrokenBarrierException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.Semaphore; @@ -481,10 +493,12 @@ void maxConcurrentMessages() { assertDoesNotThrow(() -> latchContainer.maxConcurrentMessagesBarrier.await(10, TimeUnit.SECONDS)); } - static final class Limiter implements BackPressureLimiter { + static final class NonBlockingExternalConcurrencyLimiterBackPressureHandler implements BackPressureHandler { private final AtomicInteger limit; + private final AtomicInteger inFlight = new AtomicInteger(0); + private final AtomicBoolean draining = new AtomicBoolean(false); - Limiter(int max) { + NonBlockingExternalConcurrencyLimiterBackPressureHandler(int max) { limit = new AtomicInteger(max); } @@ -494,8 +508,34 @@ public void setLimit(int value) { } @Override - public int limit() { - return Math.max(0, limit.get()); + public int request(int amount) { + if (draining.get()) { + return 0; + } + int permits = Math.max(0, Math.min(limit.get() - inFlight.get(), amount)); + inFlight.addAndGet(permits); + return permits; + } + + @Override + public void release(int amount, ReleaseReason reason) { + inFlight.addAndGet(-amount); + } + + @Override + public boolean drain(Duration timeout) { + Duration drainingTimeout = Duration.ofSeconds(10L); + Duration drainingPollingIntervalCheck = Duration.ofMillis(50L); + draining.set(true); + limit.set(0); + Instant start = Instant.now(); + while (Duration.between(start, Instant.now()).compareTo(drainingTimeout) < 0) { + if (inFlight.get() == 0) { + return true; + } + sleep(drainingPollingIntervalCheck.toMillis()); + } + return false; } } @@ -505,7 +545,8 @@ void staticBackPressureLimitShouldCapQueueProcessingCapacity(int staticLimit, in throws Exception { AtomicInteger concurrentRequest = new AtomicInteger(); AtomicInteger maxConcurrentRequest = new AtomicInteger(); - Limiter limiter = new Limiter(staticLimit); + NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter = new NonBlockingExternalConcurrencyLimiterBackPressureHandler( + staticLimit); String queueName = "BACK_PRESSURE_LIMITER_QUEUE_NAME_STATIC_LIMIT_" + staticLimit; IntStream.range(0, 10).forEach(index -> { List> messages = create10Messages("staticBackPressureLimit" + staticLimit); @@ -513,9 +554,17 @@ void staticBackPressureLimitShouldCapQueueProcessingCapacity(int staticLimit, in }); logger.debug("Sent 100 messages to queue {}", queueName); var latch = new CountDownLatch(100); - var container = SqsMessageListenerContainer.builder().sqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()) - .queueNames(queueName).configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .maxConcurrentMessages(5).maxMessagesPerPoll(5).backPressureLimiter(limiter)) + var container = SqsMessageListenerContainer + .builder().sqsAsyncClient( + BaseSqsIntegrationTest.createAsyncClient()) + .queueNames( + queueName) + .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) + .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler(List.of(limiter, + SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) + .acquireTimeout(Duration.ofSeconds(1L)) + .throughputConfiguration(BackPressureMode.AUTO).build()), + 5))) .messageListener(msg -> { int concurrentRqs = concurrentRequest.incrementAndGet(); maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); @@ -535,7 +584,8 @@ void staticBackPressureLimitShouldCapQueueProcessingCapacity(int staticLimit, in void zeroBackPressureLimitShouldStopQueueProcessing() throws Exception { AtomicInteger concurrentRequest = new AtomicInteger(); AtomicInteger maxConcurrentRequest = new AtomicInteger(); - Limiter limiter = new Limiter(0); + NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter = new NonBlockingExternalConcurrencyLimiterBackPressureHandler( + 0); String queueName = "BACK_PRESSURE_LIMITER_QUEUE_NAME_STATIC_LIMIT_0"; IntStream.range(0, 10).forEach(index -> { List> messages = create10Messages("staticBackPressureLimit0"); @@ -543,9 +593,17 @@ void zeroBackPressureLimitShouldStopQueueProcessing() throws Exception { }); logger.debug("Sent 100 messages to queue {}", queueName); var latch = new CountDownLatch(100); - var container = SqsMessageListenerContainer.builder().sqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()) - .queueNames(queueName).configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .maxConcurrentMessages(5).maxMessagesPerPoll(5).backPressureLimiter(limiter)) + var container = SqsMessageListenerContainer + .builder().sqsAsyncClient( + BaseSqsIntegrationTest.createAsyncClient()) + .queueNames( + queueName) + .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) + .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler(List.of(limiter, + SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) + .acquireTimeout(Duration.ofSeconds(1L)) + .throughputConfiguration(BackPressureMode.AUTO).build()), + 5))) .messageListener(msg -> { int concurrentRqs = concurrentRequest.incrementAndGet(); maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); @@ -566,7 +624,8 @@ void zeroBackPressureLimitShouldStopQueueProcessing() throws Exception { void changeInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Exception { AtomicInteger concurrentRequest = new AtomicInteger(); AtomicInteger maxConcurrentRequest = new AtomicInteger(); - Limiter limiter = new Limiter(5); + NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter = new NonBlockingExternalConcurrencyLimiterBackPressureHandler( + 5); String queueName = "BACK_PRESSURE_LIMITER_QUEUE_NAME_SYNC_ADAPTIVE_LIMIT"; int nbMessages = 280; IntStream.range(0, nbMessages / 10).forEach(index -> { @@ -577,9 +636,17 @@ void changeInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Except var latch = new CountDownLatch(nbMessages); var controlSemaphore = new Semaphore(0); var advanceSemaphore = new Semaphore(0); - var container = SqsMessageListenerContainer.builder().sqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()) - .queueNames(queueName).configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .maxConcurrentMessages(5).maxMessagesPerPoll(5).backPressureLimiter(limiter)) + var container = SqsMessageListenerContainer + .builder().sqsAsyncClient( + BaseSqsIntegrationTest.createAsyncClient()) + .queueNames( + queueName) + .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) + .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler(List.of(limiter, + SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) + .acquireTimeout(Duration.ofSeconds(1L)) + .throughputConfiguration(BackPressureMode.AUTO).build()), + 5))) .messageListener(msg -> { try { controlSemaphore.acquire(); @@ -599,10 +666,11 @@ void changeInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Except class Controller { private final Semaphore advanceSemaphore; private final Semaphore controlSemaphore; - private final Limiter limiter; + private final NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter; private final AtomicInteger maxConcurrentRequest; - Controller(Semaphore advanceSemaphore, Semaphore controlSemaphore, Limiter limiter, + Controller(Semaphore advanceSemaphore, Semaphore controlSemaphore, + NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter, AtomicInteger maxConcurrentRequest) { this.advanceSemaphore = advanceSemaphore; this.controlSemaphore = controlSemaphore; @@ -682,6 +750,75 @@ void waitForAdvance(int permits) throws InterruptedException { } } + static class EventsCsvWriter { + private final Queue events = new ConcurrentLinkedQueue<>(List.of("event,time,value")); + + void registerEvent(String event, int value) { + events.add("%s,%s,%d".formatted(event, Instant.now(), value)); + } + + void write(Path path) throws Exception { + Files.writeString(path, String.join("\n", events), StandardCharsets.UTF_8, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + } + } + + static class StatisticsBphDecorator implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { + private final BatchAwareBackPressureHandler delegate; + private final EventsCsvWriter eventCsv; + private String id; + + StatisticsBphDecorator(BatchAwareBackPressureHandler delegate, EventsCsvWriter eventsCsvWriter) { + this.delegate = delegate; + this.eventCsv = eventsCsvWriter; + } + + @Override + public int requestBatch() throws InterruptedException { + int permits = delegate.requestBatch(); + if (permits > 0) { + eventCsv.registerEvent("obtained_permits", permits); + } + return permits; + } + + @Override + public int request(int amount) throws InterruptedException { + int permits = delegate.request(amount); + if (permits > 0) { + eventCsv.registerEvent("obtained_permits", permits); + } + return permits; + } + + @Override + public void release(int amount, ReleaseReason reason) { + if (amount > 0) { + eventCsv.registerEvent("release_" + reason, amount); + } + delegate.release(amount, reason); + } + + @Override + public boolean drain(Duration timeout) { + eventCsv.registerEvent("drain", 1); + return delegate.drain(timeout); + } + + @Override + public void setId(String id) { + this.id = id; + if (delegate instanceof IdentifiableContainerComponent icc) { + icc.setId("delegate-" + id); + } + } + + @Override + public String getId() { + return id; + } + } + /** * This test simulates a progressive change in the back pressure limit. Unlike * {@link #changeInBackPressureLimitShouldAdaptQueueProcessingCapacity()}, this test does not block message @@ -701,7 +838,8 @@ void waitForAdvance(int permits) throws InterruptedException { void unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Exception { AtomicInteger concurrentRequest = new AtomicInteger(); AtomicInteger maxConcurrentRequest = new AtomicInteger(); - Limiter limiter = new Limiter(0); + NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter = new NonBlockingExternalConcurrencyLimiterBackPressureHandler( + 0); String queueName = "REACTIVE_BACK_PRESSURE_LIMITER_QUEUE_NAME_ADAPTIVE_LIMIT"; int nbMessages = 1000; Semaphore advanceSemaphore = new Semaphore(0); @@ -711,11 +849,22 @@ void unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity( }); logger.debug("Sent {} messages to queue {}", nbMessages, queueName); var latch = new CountDownLatch(nbMessages); - var container = SqsMessageListenerContainer.builder().sqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()) + EventsCsvWriter eventsCsvWriter = new EventsCsvWriter(); + var container = SqsMessageListenerContainer + .builder().sqsAsyncClient( + BaseSqsIntegrationTest.createAsyncClient()) .queueNames(queueName) - .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .standbyLimitPollingInterval(Duration.ofMillis(1)).maxConcurrentMessages(10) - .maxMessagesPerPoll(10).backPressureLimiter(limiter)) + .configure( + options -> options.pollTimeout(Duration.ofSeconds(1)) + .standbyLimitPollingInterval( + Duration.ofMillis(1)) + .backPressureHandlerSupplier(() -> new StatisticsBphDecorator( + new CompositeBackPressureHandler(List.of(limiter, + SemaphoreBackPressureHandler.builder().batchSize(10).totalPermits(10) + .acquireTimeout(Duration.ofSeconds(1L)) + .throughputConfiguration(BackPressureMode.AUTO).build()), + 10), + eventsCsvWriter))) .messageListener(msg -> { int currentConcurrentRq = concurrentRequest.incrementAndGet(); maxConcurrentRequest.updateAndGet(max -> Math.max(max, currentConcurrentRq)); @@ -745,17 +894,26 @@ void unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity( while (latch.getCount() > 0 && changeLimitCount < nbMessages) { changeLimitCount++; int limit = progressiveLimitChange.applyAsInt(changeLimitCount); + int expectedMax = Math.min(10, limit); limiter.setLimit(limit); maxConcurrentRequest.set(0); - sleep(random.nextInt(10)); + sleep(random.nextInt(20)); int actualLimit = Math.min(10, limit); - int max = maxConcurrentRequest.getAndSet(0); + int max = maxConcurrentRequest.get(); if (max > 0) { // Ignore iterations where nothing was polled (messages consumption slower than iteration) limitsSum += actualLimit; maxConcurrentRqSum += max; } + eventsCsvWriter.registerEvent("max_concurrent_rq", max); + eventsCsvWriter.registerEvent("concurrent_rq", concurrentRequest.get()); + eventsCsvWriter.registerEvent("limit", limit); + eventsCsvWriter.registerEvent("in_flight", limiter.inFlight.get()); + eventsCsvWriter.registerEvent("expected_max", expectedMax); + eventsCsvWriter.registerEvent("max_minus_expected_max", max - expectedMax); } + eventsCsvWriter.write(Path.of( + "target/0-stats-unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity.csv")); assertThat(maxConcurrentRqSum).isLessThanOrEqualTo(limitsSum); assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); } From 7dda70b83438a8447cfed093a0dc66ed67d39dd4 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Wed, 12 Feb 2025 14:48:46 +0100 Subject: [PATCH 05/29] Move SQS BackPressureHandlers tests to a dedicated integration test (#1251) --- .../SqsBackPressureIntegrationTests.java | 590 ++++++++++++++++++ .../sqs/integration/SqsIntegrationTests.java | 458 -------------- 2 files changed, 590 insertions(+), 458 deletions(-) create mode 100644 spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java new file mode 100644 index 000000000..7fc18e308 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java @@ -0,0 +1,590 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.integration; + +import static java.util.Collections.singletonMap; +import static org.assertj.core.api.Assertions.assertThat; + +import io.awspring.cloud.sqs.config.SqsBootstrapConfiguration; +import io.awspring.cloud.sqs.listener.BackPressureHandler; +import io.awspring.cloud.sqs.listener.BackPressureMode; +import io.awspring.cloud.sqs.listener.BatchAwareBackPressureHandler; +import io.awspring.cloud.sqs.listener.CompositeBackPressureHandler; +import io.awspring.cloud.sqs.listener.IdentifiableContainerComponent; +import io.awspring.cloud.sqs.listener.SemaphoreBackPressureHandler; +import io.awspring.cloud.sqs.listener.SqsMessageListenerContainer; +import io.awspring.cloud.sqs.operations.SqsTemplate; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Queue; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.IntUnaryOperator; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.model.QueueAttributeName; + +/** + * Integration tests for SQS containers back pressure management. + * + * @author Loïc Rouchon + */ +@SpringBootTest +class SqsBackPressureIntegrationTests extends BaseSqsIntegrationTest { + + private static final Logger logger = LoggerFactory.getLogger(SqsBackPressureIntegrationTests.class); + + static final String RECEIVES_MESSAGE_QUEUE_NAME = "receives_message_test_queue"; + + static final String RECEIVES_MESSAGE_BATCH_QUEUE_NAME = "receives_message_batch_test_queue"; + + static final String RECEIVES_MESSAGE_ASYNC_QUEUE_NAME = "receives_message_async_test_queue"; + + static final String DOES_NOT_ACK_ON_ERROR_QUEUE_NAME = "does_not_ack_test_queue"; + + static final String DOES_NOT_ACK_ON_ERROR_ASYNC_QUEUE_NAME = "does_not_ack_async_test_queue"; + + static final String DOES_NOT_ACK_ON_ERROR_BATCH_QUEUE_NAME = "does_not_ack_batch_test_queue"; + + static final String DOES_NOT_ACK_ON_ERROR_BATCH_ASYNC_QUEUE_NAME = "does_not_ack_batch_async_test_queue"; + + static final String RESOLVES_PARAMETER_TYPES_QUEUE_NAME = "resolves_parameter_type_test_queue"; + + static final String MANUALLY_START_CONTAINER = "manually_start_container_test_queue"; + + static final String MANUALLY_CREATE_CONTAINER_QUEUE_NAME = "manually_create_container_test_queue"; + + static final String MANUALLY_CREATE_INACTIVE_CONTAINER_QUEUE_NAME = "manually_create_inactive_container_test_queue"; + + static final String MANUALLY_CREATE_FACTORY_QUEUE_NAME = "manually_create_factory_test_queue"; + + static final String CONSUMES_ONE_MESSAGE_AT_A_TIME_QUEUE_NAME = "consumes_one_message_test_queue"; + + static final String MAX_CONCURRENT_MESSAGES_QUEUE_NAME = "max_concurrent_messages_test_queue"; + + static final String LOW_RESOURCE_FACTORY = "lowResourceFactory"; + + static final String MANUAL_ACK_FACTORY = "manualAcknowledgementFactory"; + + static final String MANUAL_ACK_BATCH_FACTORY = "manualAcknowledgementBatchFactory"; + + static final String ACK_AFTER_SECOND_ERROR_FACTORY = "ackAfterSecondErrorFactory"; + + @BeforeAll + static void beforeTests() { + SqsAsyncClient client = createAsyncClient(); + CompletableFuture.allOf(createQueue(client, RECEIVES_MESSAGE_QUEUE_NAME), + createQueue(client, DOES_NOT_ACK_ON_ERROR_QUEUE_NAME, + singletonMap(QueueAttributeName.VISIBILITY_TIMEOUT, "1")), + createQueue(client, DOES_NOT_ACK_ON_ERROR_ASYNC_QUEUE_NAME, + singletonMap(QueueAttributeName.VISIBILITY_TIMEOUT, "1")), + createQueue(client, DOES_NOT_ACK_ON_ERROR_BATCH_QUEUE_NAME, + singletonMap(QueueAttributeName.VISIBILITY_TIMEOUT, "1")), + createQueue(client, DOES_NOT_ACK_ON_ERROR_BATCH_ASYNC_QUEUE_NAME, + singletonMap(QueueAttributeName.VISIBILITY_TIMEOUT, "1")), + createQueue(client, RECEIVES_MESSAGE_ASYNC_QUEUE_NAME), + createQueue(client, RECEIVES_MESSAGE_BATCH_QUEUE_NAME), + createQueue(client, RESOLVES_PARAMETER_TYPES_QUEUE_NAME, + singletonMap(QueueAttributeName.VISIBILITY_TIMEOUT, "20")), + createQueue(client, MANUALLY_CREATE_CONTAINER_QUEUE_NAME), + createQueue(client, MANUALLY_CREATE_INACTIVE_CONTAINER_QUEUE_NAME), + createQueue(client, MANUALLY_CREATE_FACTORY_QUEUE_NAME), + createQueue(client, CONSUMES_ONE_MESSAGE_AT_A_TIME_QUEUE_NAME), + createQueue(client, MAX_CONCURRENT_MESSAGES_QUEUE_NAME)).join(); + } + + @Autowired + SqsTemplate sqsTemplate; + + static final class NonBlockingExternalConcurrencyLimiterBackPressureHandler implements BackPressureHandler { + private final AtomicInteger limit; + private final AtomicInteger inFlight = new AtomicInteger(0); + private final AtomicBoolean draining = new AtomicBoolean(false); + + NonBlockingExternalConcurrencyLimiterBackPressureHandler(int max) { + limit = new AtomicInteger(max); + } + + public void setLimit(int value) { + logger.info("adjusting limit from {} to {}", limit.get(), value); + limit.set(value); + } + + @Override + public int request(int amount) { + if (draining.get()) { + return 0; + } + int permits = Math.max(0, Math.min(limit.get() - inFlight.get(), amount)); + inFlight.addAndGet(permits); + return permits; + } + + @Override + public void release(int amount, ReleaseReason reason) { + inFlight.addAndGet(-amount); + } + + @Override + public boolean drain(Duration timeout) { + Duration drainingTimeout = Duration.ofSeconds(10L); + Duration drainingPollingIntervalCheck = Duration.ofMillis(50L); + draining.set(true); + limit.set(0); + Instant start = Instant.now(); + while (Duration.between(start, Instant.now()).compareTo(drainingTimeout) < 0) { + if (inFlight.get() == 0) { + return true; + } + sleep(drainingPollingIntervalCheck.toMillis()); + } + return false; + } + } + + @ParameterizedTest + @CsvSource({ "2,2", "4,4", "5,5", "20,5" }) + void staticBackPressureLimitShouldCapQueueProcessingCapacity(int staticLimit, int expectedMaxConcurrentRequests) + throws Exception { + AtomicInteger concurrentRequest = new AtomicInteger(); + AtomicInteger maxConcurrentRequest = new AtomicInteger(); + NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter = new NonBlockingExternalConcurrencyLimiterBackPressureHandler( + staticLimit); + String queueName = "BACK_PRESSURE_LIMITER_QUEUE_NAME_STATIC_LIMIT_" + staticLimit; + IntStream.range(0, 10).forEach(index -> { + List> messages = create10Messages("staticBackPressureLimit" + staticLimit); + sqsTemplate.sendMany(queueName, messages); + }); + logger.debug("Sent 100 messages to queue {}", queueName); + var latch = new CountDownLatch(100); + var container = SqsMessageListenerContainer + .builder().sqsAsyncClient( + BaseSqsIntegrationTest.createAsyncClient()) + .queueNames( + queueName) + .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) + .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler(List.of(limiter, + SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) + .acquireTimeout(Duration.ofSeconds(1L)) + .throughputConfiguration(BackPressureMode.AUTO).build()), + 5))) + .messageListener(msg -> { + int concurrentRqs = concurrentRequest.incrementAndGet(); + maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); + sleep(50L); + logger.debug("concurrent rq {}, max concurrent rq {}, latch count {}", concurrentRequest.get(), + maxConcurrentRequest.get(), latch.getCount()); + latch.countDown(); + concurrentRequest.decrementAndGet(); + }).build(); + container.start(); + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(maxConcurrentRequest.get()).isEqualTo(expectedMaxConcurrentRequests); + container.stop(); + } + + @Test + void zeroBackPressureLimitShouldStopQueueProcessing() throws Exception { + AtomicInteger concurrentRequest = new AtomicInteger(); + AtomicInteger maxConcurrentRequest = new AtomicInteger(); + NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter = new NonBlockingExternalConcurrencyLimiterBackPressureHandler( + 0); + String queueName = "BACK_PRESSURE_LIMITER_QUEUE_NAME_STATIC_LIMIT_0"; + IntStream.range(0, 10).forEach(index -> { + List> messages = create10Messages("staticBackPressureLimit0"); + sqsTemplate.sendMany(queueName, messages); + }); + logger.debug("Sent 100 messages to queue {}", queueName); + var latch = new CountDownLatch(100); + var container = SqsMessageListenerContainer + .builder().sqsAsyncClient( + BaseSqsIntegrationTest.createAsyncClient()) + .queueNames( + queueName) + .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) + .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler(List.of(limiter, + SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) + .acquireTimeout(Duration.ofSeconds(1L)) + .throughputConfiguration(BackPressureMode.AUTO).build()), + 5))) + .messageListener(msg -> { + int concurrentRqs = concurrentRequest.incrementAndGet(); + maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); + sleep(50L); + logger.debug("concurrent rq {}, max concurrent rq {}, latch count {}", concurrentRequest.get(), + maxConcurrentRequest.get(), latch.getCount()); + latch.countDown(); + concurrentRequest.decrementAndGet(); + }).build(); + container.start(); + assertThat(latch.await(2, TimeUnit.SECONDS)).isFalse(); + assertThat(maxConcurrentRequest.get()).isZero(); + assertThat(latch.getCount()).isEqualTo(100L); + container.stop(); + } + + @Test + void changeInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Exception { + AtomicInteger concurrentRequest = new AtomicInteger(); + AtomicInteger maxConcurrentRequest = new AtomicInteger(); + NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter = new NonBlockingExternalConcurrencyLimiterBackPressureHandler( + 5); + String queueName = "BACK_PRESSURE_LIMITER_QUEUE_NAME_SYNC_ADAPTIVE_LIMIT"; + int nbMessages = 280; + IntStream.range(0, nbMessages / 10).forEach(index -> { + List> messages = create10Messages("syncAdaptiveBackPressureLimit"); + sqsTemplate.sendMany(queueName, messages); + }); + logger.debug("Sent {} messages to queue {}", nbMessages, queueName); + var latch = new CountDownLatch(nbMessages); + var controlSemaphore = new Semaphore(0); + var advanceSemaphore = new Semaphore(0); + var container = SqsMessageListenerContainer + .builder().sqsAsyncClient( + BaseSqsIntegrationTest.createAsyncClient()) + .queueNames( + queueName) + .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) + .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler(List.of(limiter, + SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) + .acquireTimeout(Duration.ofSeconds(1L)) + .throughputConfiguration(BackPressureMode.AUTO).build()), + 5))) + .messageListener(msg -> { + try { + controlSemaphore.acquire(); + } + catch (InterruptedException e) { + throw new RuntimeException(e); + } + int concurrentRqs = concurrentRequest.incrementAndGet(); + maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); + latch.countDown(); + logger.debug("concurrent rq {}, max concurrent rq {}, latch count {}", concurrentRequest.get(), + maxConcurrentRequest.get(), latch.getCount()); + sleep(10L); + concurrentRequest.decrementAndGet(); + advanceSemaphore.release(); + }).build(); + class Controller { + private final Semaphore advanceSemaphore; + private final Semaphore controlSemaphore; + private final NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter; + private final AtomicInteger maxConcurrentRequest; + + Controller(Semaphore advanceSemaphore, Semaphore controlSemaphore, + NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter, + AtomicInteger maxConcurrentRequest) { + this.advanceSemaphore = advanceSemaphore; + this.controlSemaphore = controlSemaphore; + this.limiter = limiter; + this.maxConcurrentRequest = maxConcurrentRequest; + } + + public void updateLimit(int newLimit) { + limiter.setLimit(newLimit); + } + + void updateLimitAndWaitForReset(int newLimit) throws InterruptedException { + updateLimit(newLimit); + int atLeastTwoPollingCycles = 2 * 5; + controlSemaphore.release(atLeastTwoPollingCycles); + waitForAdvance(atLeastTwoPollingCycles); + maxConcurrentRequest.set(0); + } + + void advance(int permits) { + controlSemaphore.release(permits); + } + + void waitForAdvance(int permits) throws InterruptedException { + assertThat(advanceSemaphore.tryAcquire(permits, 5, TimeUnit.SECONDS)) + .withFailMessage(() -> "Waiting for %d permits timed out. Only %d permits available" + .formatted(permits, advanceSemaphore.availablePermits())) + .isTrue(); + } + } + var controller = new Controller(advanceSemaphore, controlSemaphore, limiter, maxConcurrentRequest); + try { + container.start(); + + controller.advance(50); + controller.waitForAdvance(50); + // not limiting queue processing capacity + assertThat(controller.maxConcurrentRequest.get()).isEqualTo(5); + controller.updateLimitAndWaitForReset(2); + controller.advance(50); + + controller.waitForAdvance(50); + // limiting queue processing capacity + assertThat(controller.maxConcurrentRequest.get()).isEqualTo(2); + controller.updateLimitAndWaitForReset(7); + controller.advance(50); + + controller.waitForAdvance(50); + // not limiting queue processing capacity + assertThat(controller.maxConcurrentRequest.get()).isEqualTo(5); + controller.updateLimitAndWaitForReset(3); + controller.advance(50); + sleep(10L); + limiter.setLimit(1); + sleep(10L); + limiter.setLimit(2); + sleep(10L); + limiter.setLimit(3); + + controller.waitForAdvance(50); + assertThat(controller.maxConcurrentRequest.get()).isEqualTo(3); + // stopping processing of the queue + controller.updateLimit(0); + controller.advance(50); + assertThat(advanceSemaphore.tryAcquire(10, 5, TimeUnit.SECONDS)) + .withFailMessage("Acquiring semaphore should have timed out as limit was set to 0").isFalse(); + + // resume queue processing + controller.updateLimit(6); + + controller.waitForAdvance(50); + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(controller.maxConcurrentRequest.get()).isEqualTo(5); + } + finally { + container.stop(); + } + } + + static class EventsCsvWriter { + private final Queue events = new ConcurrentLinkedQueue<>(List.of("event,time,value")); + + void registerEvent(String event, int value) { + events.add("%s,%s,%d".formatted(event, Instant.now(), value)); + } + + void write(Path path) throws Exception { + Files.writeString(path, String.join("\n", events), StandardCharsets.UTF_8, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + } + } + + static class StatisticsBphDecorator implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { + private final BatchAwareBackPressureHandler delegate; + private final EventsCsvWriter eventCsv; + private String id; + + StatisticsBphDecorator(BatchAwareBackPressureHandler delegate, EventsCsvWriter eventsCsvWriter) { + this.delegate = delegate; + this.eventCsv = eventsCsvWriter; + } + + @Override + public int requestBatch() throws InterruptedException { + int permits = delegate.requestBatch(); + if (permits > 0) { + eventCsv.registerEvent("obtained_permits", permits); + } + return permits; + } + + @Override + public int request(int amount) throws InterruptedException { + int permits = delegate.request(amount); + if (permits > 0) { + eventCsv.registerEvent("obtained_permits", permits); + } + return permits; + } + + @Override + public void release(int amount, ReleaseReason reason) { + if (amount > 0) { + eventCsv.registerEvent("release_" + reason, amount); + } + delegate.release(amount, reason); + } + + @Override + public boolean drain(Duration timeout) { + eventCsv.registerEvent("drain", 1); + return delegate.drain(timeout); + } + + @Override + public void setId(String id) { + this.id = id; + if (delegate instanceof IdentifiableContainerComponent icc) { + icc.setId("delegate-" + id); + } + } + + @Override + public String getId() { + return id; + } + } + + /** + * This test simulates a progressive change in the back pressure limit. Unlike + * {@link #changeInBackPressureLimitShouldAdaptQueueProcessingCapacity()}, this test does not block message + * consumption while updating the limit. + *

+ * The limit is updated in a loop until all messages are consumed. The update follows a triangle wave pattern with a + * minimum of 0, a maximum of 15, and a period of 30 iterations. After each update of the limit, the test waits up + * to 10ms and samples the maximum number of concurrent messages that were processed since the update. This number + * can be higher than the defined limit during the adaptation period of the decreasing limit wave. For the + * increasing limit wave, it is usually lower due to the adaptation delay. In both cases, the maximum number of + * concurrent messages being processed rapidly converges toward the defined limit. + *

+ * The test passes if the sum of the sampled maximum number of concurrently processed messages is lower than the sum + * of the limits at those points in time. + */ + @Test + void unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Exception { + AtomicInteger concurrentRequest = new AtomicInteger(); + AtomicInteger maxConcurrentRequest = new AtomicInteger(); + NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter = new NonBlockingExternalConcurrencyLimiterBackPressureHandler( + 0); + String queueName = "REACTIVE_BACK_PRESSURE_LIMITER_QUEUE_NAME_ADAPTIVE_LIMIT"; + int nbMessages = 1000; + Semaphore advanceSemaphore = new Semaphore(0); + IntStream.range(0, nbMessages / 10).forEach(index -> { + List> messages = create10Messages("reactAdaptiveBackPressureLimit"); + sqsTemplate.sendMany(queueName, messages); + }); + logger.debug("Sent {} messages to queue {}", nbMessages, queueName); + var latch = new CountDownLatch(nbMessages); + EventsCsvWriter eventsCsvWriter = new EventsCsvWriter(); + var container = SqsMessageListenerContainer + .builder().sqsAsyncClient( + BaseSqsIntegrationTest.createAsyncClient()) + .queueNames(queueName) + .configure( + options -> options.pollTimeout(Duration.ofSeconds(1)) + .standbyLimitPollingInterval( + Duration.ofMillis(1)) + .backPressureHandlerSupplier(() -> new StatisticsBphDecorator( + new CompositeBackPressureHandler(List.of(limiter, + SemaphoreBackPressureHandler.builder().batchSize(10).totalPermits(10) + .acquireTimeout(Duration.ofSeconds(1L)) + .throughputConfiguration(BackPressureMode.AUTO).build()), + 10), + eventsCsvWriter))) + .messageListener(msg -> { + int currentConcurrentRq = concurrentRequest.incrementAndGet(); + maxConcurrentRequest.updateAndGet(max -> Math.max(max, currentConcurrentRq)); + sleep(ThreadLocalRandom.current().nextInt(10)); + latch.countDown(); + logger.debug("concurrent rq {}, max concurrent rq {}, latch count {}", concurrentRequest.get(), + maxConcurrentRequest.get(), latch.getCount()); + concurrentRequest.decrementAndGet(); + advanceSemaphore.release(); + }).build(); + IntUnaryOperator progressiveLimitChange = (int x) -> { + int period = 30; + int halfPeriod = period / 2; + if (x % period < halfPeriod) { + return (x % halfPeriod); + } + else { + return (halfPeriod - (x % halfPeriod)); + } + }; + try { + container.start(); + Random random = new Random(); + int limitsSum = 0; + int maxConcurrentRqSum = 0; + int changeLimitCount = 0; + while (latch.getCount() > 0 && changeLimitCount < nbMessages) { + changeLimitCount++; + int limit = progressiveLimitChange.applyAsInt(changeLimitCount); + int expectedMax = Math.min(10, limit); + limiter.setLimit(limit); + maxConcurrentRequest.set(0); + sleep(random.nextInt(20)); + int actualLimit = Math.min(10, limit); + int max = maxConcurrentRequest.get(); + if (max > 0) { + // Ignore iterations where nothing was polled (messages consumption slower than iteration) + limitsSum += actualLimit; + maxConcurrentRqSum += max; + } + eventsCsvWriter.registerEvent("max_concurrent_rq", max); + eventsCsvWriter.registerEvent("concurrent_rq", concurrentRequest.get()); + eventsCsvWriter.registerEvent("limit", limit); + eventsCsvWriter.registerEvent("in_flight", limiter.inFlight.get()); + eventsCsvWriter.registerEvent("expected_max", expectedMax); + eventsCsvWriter.registerEvent("max_minus_expected_max", max - expectedMax); + } + eventsCsvWriter.write(Path.of( + "target/0-stats-unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity.csv")); + assertThat(maxConcurrentRqSum).isLessThanOrEqualTo(limitsSum); + assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); + } + finally { + container.stop(); + } + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private List> create10Messages(String testName) { + return IntStream.range(0, 10).mapToObj(index -> testName + "-payload-" + index) + .map(payload -> MessageBuilder.withPayload(payload).build()).collect(Collectors.toList()); + } + + @Import(SqsBootstrapConfiguration.class) + @Configuration + static class SQSConfiguration { + + @Bean + SqsTemplate sqsTemplate() { + return SqsTemplate.builder().sqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()).build(); + } + } +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsIntegrationTests.java index 4aa28b665..95920deb9 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsIntegrationTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsIntegrationTests.java @@ -27,16 +27,10 @@ import io.awspring.cloud.sqs.config.SqsBootstrapConfiguration; import io.awspring.cloud.sqs.config.SqsListenerConfigurer; import io.awspring.cloud.sqs.config.SqsMessageListenerContainerFactory; -import io.awspring.cloud.sqs.listener.BackPressureHandler; -import io.awspring.cloud.sqs.listener.BackPressureMode; -import io.awspring.cloud.sqs.listener.BatchAwareBackPressureHandler; import io.awspring.cloud.sqs.listener.BatchVisibility; -import io.awspring.cloud.sqs.listener.CompositeBackPressureHandler; import io.awspring.cloud.sqs.listener.ContainerComponentFactory; -import io.awspring.cloud.sqs.listener.IdentifiableContainerComponent; import io.awspring.cloud.sqs.listener.MessageListenerContainer; import io.awspring.cloud.sqs.listener.QueueAttributes; -import io.awspring.cloud.sqs.listener.SemaphoreBackPressureHandler; import io.awspring.cloud.sqs.listener.SqsContainerOptions; import io.awspring.cloud.sqs.listener.SqsContainerOptionsBuilder; import io.awspring.cloud.sqs.listener.SqsHeaders; @@ -66,37 +60,23 @@ import io.micrometer.observation.tck.TestObservationRegistry; import io.micrometer.observation.tck.TestObservationRegistryAssert; import java.lang.reflect.Method; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; import java.time.Duration; -import java.time.Instant; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.Queue; -import java.util.Random; import java.util.UUID; import java.util.concurrent.BrokenBarrierException; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; -import java.util.concurrent.Semaphore; -import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.IntUnaryOperator; import java.util.stream.Collectors; import java.util.stream.IntStream; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -493,444 +473,6 @@ void maxConcurrentMessages() { assertDoesNotThrow(() -> latchContainer.maxConcurrentMessagesBarrier.await(10, TimeUnit.SECONDS)); } - static final class NonBlockingExternalConcurrencyLimiterBackPressureHandler implements BackPressureHandler { - private final AtomicInteger limit; - private final AtomicInteger inFlight = new AtomicInteger(0); - private final AtomicBoolean draining = new AtomicBoolean(false); - - NonBlockingExternalConcurrencyLimiterBackPressureHandler(int max) { - limit = new AtomicInteger(max); - } - - public void setLimit(int value) { - logger.info("adjusting limit from {} to {}", limit.get(), value); - limit.set(value); - } - - @Override - public int request(int amount) { - if (draining.get()) { - return 0; - } - int permits = Math.max(0, Math.min(limit.get() - inFlight.get(), amount)); - inFlight.addAndGet(permits); - return permits; - } - - @Override - public void release(int amount, ReleaseReason reason) { - inFlight.addAndGet(-amount); - } - - @Override - public boolean drain(Duration timeout) { - Duration drainingTimeout = Duration.ofSeconds(10L); - Duration drainingPollingIntervalCheck = Duration.ofMillis(50L); - draining.set(true); - limit.set(0); - Instant start = Instant.now(); - while (Duration.between(start, Instant.now()).compareTo(drainingTimeout) < 0) { - if (inFlight.get() == 0) { - return true; - } - sleep(drainingPollingIntervalCheck.toMillis()); - } - return false; - } - } - - @ParameterizedTest - @CsvSource({ "2,2", "4,4", "5,5", "20,5" }) - void staticBackPressureLimitShouldCapQueueProcessingCapacity(int staticLimit, int expectedMaxConcurrentRequests) - throws Exception { - AtomicInteger concurrentRequest = new AtomicInteger(); - AtomicInteger maxConcurrentRequest = new AtomicInteger(); - NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter = new NonBlockingExternalConcurrencyLimiterBackPressureHandler( - staticLimit); - String queueName = "BACK_PRESSURE_LIMITER_QUEUE_NAME_STATIC_LIMIT_" + staticLimit; - IntStream.range(0, 10).forEach(index -> { - List> messages = create10Messages("staticBackPressureLimit" + staticLimit); - sqsTemplate.sendMany(queueName, messages); - }); - logger.debug("Sent 100 messages to queue {}", queueName); - var latch = new CountDownLatch(100); - var container = SqsMessageListenerContainer - .builder().sqsAsyncClient( - BaseSqsIntegrationTest.createAsyncClient()) - .queueNames( - queueName) - .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler(List.of(limiter, - SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) - .acquireTimeout(Duration.ofSeconds(1L)) - .throughputConfiguration(BackPressureMode.AUTO).build()), - 5))) - .messageListener(msg -> { - int concurrentRqs = concurrentRequest.incrementAndGet(); - maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); - sleep(50L); - logger.debug("concurrent rq {}, max concurrent rq {}, latch count {}", concurrentRequest.get(), - maxConcurrentRequest.get(), latch.getCount()); - latch.countDown(); - concurrentRequest.decrementAndGet(); - }).build(); - container.start(); - assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); - assertThat(maxConcurrentRequest.get()).isEqualTo(expectedMaxConcurrentRequests); - container.stop(); - } - - @Test - void zeroBackPressureLimitShouldStopQueueProcessing() throws Exception { - AtomicInteger concurrentRequest = new AtomicInteger(); - AtomicInteger maxConcurrentRequest = new AtomicInteger(); - NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter = new NonBlockingExternalConcurrencyLimiterBackPressureHandler( - 0); - String queueName = "BACK_PRESSURE_LIMITER_QUEUE_NAME_STATIC_LIMIT_0"; - IntStream.range(0, 10).forEach(index -> { - List> messages = create10Messages("staticBackPressureLimit0"); - sqsTemplate.sendMany(queueName, messages); - }); - logger.debug("Sent 100 messages to queue {}", queueName); - var latch = new CountDownLatch(100); - var container = SqsMessageListenerContainer - .builder().sqsAsyncClient( - BaseSqsIntegrationTest.createAsyncClient()) - .queueNames( - queueName) - .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler(List.of(limiter, - SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) - .acquireTimeout(Duration.ofSeconds(1L)) - .throughputConfiguration(BackPressureMode.AUTO).build()), - 5))) - .messageListener(msg -> { - int concurrentRqs = concurrentRequest.incrementAndGet(); - maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); - sleep(50L); - logger.debug("concurrent rq {}, max concurrent rq {}, latch count {}", concurrentRequest.get(), - maxConcurrentRequest.get(), latch.getCount()); - latch.countDown(); - concurrentRequest.decrementAndGet(); - }).build(); - container.start(); - assertThat(latch.await(2, TimeUnit.SECONDS)).isFalse(); - assertThat(maxConcurrentRequest.get()).isZero(); - assertThat(latch.getCount()).isEqualTo(100L); - container.stop(); - } - - @Test - void changeInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Exception { - AtomicInteger concurrentRequest = new AtomicInteger(); - AtomicInteger maxConcurrentRequest = new AtomicInteger(); - NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter = new NonBlockingExternalConcurrencyLimiterBackPressureHandler( - 5); - String queueName = "BACK_PRESSURE_LIMITER_QUEUE_NAME_SYNC_ADAPTIVE_LIMIT"; - int nbMessages = 280; - IntStream.range(0, nbMessages / 10).forEach(index -> { - List> messages = create10Messages("syncAdaptiveBackPressureLimit"); - sqsTemplate.sendMany(queueName, messages); - }); - logger.debug("Sent {} messages to queue {}", nbMessages, queueName); - var latch = new CountDownLatch(nbMessages); - var controlSemaphore = new Semaphore(0); - var advanceSemaphore = new Semaphore(0); - var container = SqsMessageListenerContainer - .builder().sqsAsyncClient( - BaseSqsIntegrationTest.createAsyncClient()) - .queueNames( - queueName) - .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler(List.of(limiter, - SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) - .acquireTimeout(Duration.ofSeconds(1L)) - .throughputConfiguration(BackPressureMode.AUTO).build()), - 5))) - .messageListener(msg -> { - try { - controlSemaphore.acquire(); - } - catch (InterruptedException e) { - throw new RuntimeException(e); - } - int concurrentRqs = concurrentRequest.incrementAndGet(); - maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); - latch.countDown(); - logger.debug("concurrent rq {}, max concurrent rq {}, latch count {}", concurrentRequest.get(), - maxConcurrentRequest.get(), latch.getCount()); - sleep(10L); - concurrentRequest.decrementAndGet(); - advanceSemaphore.release(); - }).build(); - class Controller { - private final Semaphore advanceSemaphore; - private final Semaphore controlSemaphore; - private final NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter; - private final AtomicInteger maxConcurrentRequest; - - Controller(Semaphore advanceSemaphore, Semaphore controlSemaphore, - NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter, - AtomicInteger maxConcurrentRequest) { - this.advanceSemaphore = advanceSemaphore; - this.controlSemaphore = controlSemaphore; - this.limiter = limiter; - this.maxConcurrentRequest = maxConcurrentRequest; - } - - public void updateLimit(int newLimit) { - limiter.setLimit(newLimit); - } - - void updateLimitAndWaitForReset(int newLimit) throws InterruptedException { - updateLimit(newLimit); - int atLeastTwoPollingCycles = 2 * 5; - controlSemaphore.release(atLeastTwoPollingCycles); - waitForAdvance(atLeastTwoPollingCycles); - maxConcurrentRequest.set(0); - } - - void advance(int permits) { - controlSemaphore.release(permits); - } - - void waitForAdvance(int permits) throws InterruptedException { - assertThat(advanceSemaphore.tryAcquire(permits, 5, TimeUnit.SECONDS)) - .withFailMessage(() -> "Waiting for %d permits timed out. Only %d permits available" - .formatted(permits, advanceSemaphore.availablePermits())) - .isTrue(); - } - } - var controller = new Controller(advanceSemaphore, controlSemaphore, limiter, maxConcurrentRequest); - try { - container.start(); - - controller.advance(50); - controller.waitForAdvance(50); - // not limiting queue processing capacity - assertThat(controller.maxConcurrentRequest.get()).isEqualTo(5); - controller.updateLimitAndWaitForReset(2); - controller.advance(50); - - controller.waitForAdvance(50); - // limiting queue processing capacity - assertThat(controller.maxConcurrentRequest.get()).isEqualTo(2); - controller.updateLimitAndWaitForReset(7); - controller.advance(50); - - controller.waitForAdvance(50); - // not limiting queue processing capacity - assertThat(controller.maxConcurrentRequest.get()).isEqualTo(5); - controller.updateLimitAndWaitForReset(3); - controller.advance(50); - sleep(10L); - limiter.setLimit(1); - sleep(10L); - limiter.setLimit(2); - sleep(10L); - limiter.setLimit(3); - - controller.waitForAdvance(50); - assertThat(controller.maxConcurrentRequest.get()).isEqualTo(3); - // stopping processing of the queue - controller.updateLimit(0); - controller.advance(50); - assertThat(advanceSemaphore.tryAcquire(10, 5, TimeUnit.SECONDS)) - .withFailMessage("Acquiring semaphore should have timed out as limit was set to 0").isFalse(); - - // resume queue processing - controller.updateLimit(6); - - controller.waitForAdvance(50); - assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); - assertThat(controller.maxConcurrentRequest.get()).isEqualTo(5); - } - finally { - container.stop(); - } - } - - static class EventsCsvWriter { - private final Queue events = new ConcurrentLinkedQueue<>(List.of("event,time,value")); - - void registerEvent(String event, int value) { - events.add("%s,%s,%d".formatted(event, Instant.now(), value)); - } - - void write(Path path) throws Exception { - Files.writeString(path, String.join("\n", events), StandardCharsets.UTF_8, StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING); - } - } - - static class StatisticsBphDecorator implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { - private final BatchAwareBackPressureHandler delegate; - private final EventsCsvWriter eventCsv; - private String id; - - StatisticsBphDecorator(BatchAwareBackPressureHandler delegate, EventsCsvWriter eventsCsvWriter) { - this.delegate = delegate; - this.eventCsv = eventsCsvWriter; - } - - @Override - public int requestBatch() throws InterruptedException { - int permits = delegate.requestBatch(); - if (permits > 0) { - eventCsv.registerEvent("obtained_permits", permits); - } - return permits; - } - - @Override - public int request(int amount) throws InterruptedException { - int permits = delegate.request(amount); - if (permits > 0) { - eventCsv.registerEvent("obtained_permits", permits); - } - return permits; - } - - @Override - public void release(int amount, ReleaseReason reason) { - if (amount > 0) { - eventCsv.registerEvent("release_" + reason, amount); - } - delegate.release(amount, reason); - } - - @Override - public boolean drain(Duration timeout) { - eventCsv.registerEvent("drain", 1); - return delegate.drain(timeout); - } - - @Override - public void setId(String id) { - this.id = id; - if (delegate instanceof IdentifiableContainerComponent icc) { - icc.setId("delegate-" + id); - } - } - - @Override - public String getId() { - return id; - } - } - - /** - * This test simulates a progressive change in the back pressure limit. Unlike - * {@link #changeInBackPressureLimitShouldAdaptQueueProcessingCapacity()}, this test does not block message - * consumption while updating the limit. - *

- * The limit is updated in a loop until all messages are consumed. The update follows a triangle wave pattern with a - * minimum of 0, a maximum of 15, and a period of 30 iterations. After each update of the limit, the test waits up - * to 10ms and samples the maximum number of concurrent messages that were processed since the update. This number - * can be higher than the defined limit during the adaptation period of the decreasing limit wave. For the - * increasing limit wave, it is usually lower due to the adaptation delay. In both cases, the maximum number of - * concurrent messages being processed rapidly converges toward the defined limit. - *

- * The test passes if the sum of the sampled maximum number of concurrently processed messages is lower than the sum - * of the limits at those points in time. - */ - @Test - void unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Exception { - AtomicInteger concurrentRequest = new AtomicInteger(); - AtomicInteger maxConcurrentRequest = new AtomicInteger(); - NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter = new NonBlockingExternalConcurrencyLimiterBackPressureHandler( - 0); - String queueName = "REACTIVE_BACK_PRESSURE_LIMITER_QUEUE_NAME_ADAPTIVE_LIMIT"; - int nbMessages = 1000; - Semaphore advanceSemaphore = new Semaphore(0); - IntStream.range(0, nbMessages / 10).forEach(index -> { - List> messages = create10Messages("reactAdaptiveBackPressureLimit"); - sqsTemplate.sendMany(queueName, messages); - }); - logger.debug("Sent {} messages to queue {}", nbMessages, queueName); - var latch = new CountDownLatch(nbMessages); - EventsCsvWriter eventsCsvWriter = new EventsCsvWriter(); - var container = SqsMessageListenerContainer - .builder().sqsAsyncClient( - BaseSqsIntegrationTest.createAsyncClient()) - .queueNames(queueName) - .configure( - options -> options.pollTimeout(Duration.ofSeconds(1)) - .standbyLimitPollingInterval( - Duration.ofMillis(1)) - .backPressureHandlerSupplier(() -> new StatisticsBphDecorator( - new CompositeBackPressureHandler(List.of(limiter, - SemaphoreBackPressureHandler.builder().batchSize(10).totalPermits(10) - .acquireTimeout(Duration.ofSeconds(1L)) - .throughputConfiguration(BackPressureMode.AUTO).build()), - 10), - eventsCsvWriter))) - .messageListener(msg -> { - int currentConcurrentRq = concurrentRequest.incrementAndGet(); - maxConcurrentRequest.updateAndGet(max -> Math.max(max, currentConcurrentRq)); - sleep(ThreadLocalRandom.current().nextInt(10)); - latch.countDown(); - logger.debug("concurrent rq {}, max concurrent rq {}, latch count {}", concurrentRequest.get(), - maxConcurrentRequest.get(), latch.getCount()); - concurrentRequest.decrementAndGet(); - advanceSemaphore.release(); - }).build(); - IntUnaryOperator progressiveLimitChange = (int x) -> { - int period = 30; - int halfPeriod = period / 2; - if (x % period < halfPeriod) { - return (x % halfPeriod); - } - else { - return (halfPeriod - (x % halfPeriod)); - } - }; - try { - container.start(); - Random random = new Random(); - int limitsSum = 0; - int maxConcurrentRqSum = 0; - int changeLimitCount = 0; - while (latch.getCount() > 0 && changeLimitCount < nbMessages) { - changeLimitCount++; - int limit = progressiveLimitChange.applyAsInt(changeLimitCount); - int expectedMax = Math.min(10, limit); - limiter.setLimit(limit); - maxConcurrentRequest.set(0); - sleep(random.nextInt(20)); - int actualLimit = Math.min(10, limit); - int max = maxConcurrentRequest.get(); - if (max > 0) { - // Ignore iterations where nothing was polled (messages consumption slower than iteration) - limitsSum += actualLimit; - maxConcurrentRqSum += max; - } - eventsCsvWriter.registerEvent("max_concurrent_rq", max); - eventsCsvWriter.registerEvent("concurrent_rq", concurrentRequest.get()); - eventsCsvWriter.registerEvent("limit", limit); - eventsCsvWriter.registerEvent("in_flight", limiter.inFlight.get()); - eventsCsvWriter.registerEvent("expected_max", expectedMax); - eventsCsvWriter.registerEvent("max_minus_expected_max", max - expectedMax); - } - eventsCsvWriter.write(Path.of( - "target/0-stats-unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity.csv")); - assertThat(maxConcurrentRqSum).isLessThanOrEqualTo(limitsSum); - assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); - } - finally { - container.stop(); - } - } - - private static void sleep(long millis) { - try { - Thread.sleep(millis); - } - catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - static class ReceivesMessageListener { @Autowired From 2558c5d1241d0b61a2cce21d90971f6366abe2f1 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Wed, 12 Feb 2025 17:42:11 +0100 Subject: [PATCH 06/29] Add a wait condition to the CompositeBPH in case 0 permits were returned (#1251) The wait can be interrupted when permits are returned. --- .../CompositeBackPressureHandler.java | 54 +++++++- .../SqsBackPressureIntegrationTests.java | 131 ++++++------------ 2 files changed, 95 insertions(+), 90 deletions(-) diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java index 42202438b..92a1e5295 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java @@ -17,18 +17,33 @@ import java.time.Duration; import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class CompositeBackPressureHandler implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { + private static final Logger logger = LoggerFactory.getLogger(CompositeBackPressureHandler.class); + private final List backPressureHandlers; private final int batchSize; + private final ReentrantLock noPermitsReturnedWaitLock = new ReentrantLock(); + + private final Condition permitsReleasedCondition = noPermitsReturnedWaitLock.newCondition(); + + private final Duration noPermitsReturnedWaitTimeout; + private String id; - public CompositeBackPressureHandler(List backPressureHandlers, int batchSize) { + public CompositeBackPressureHandler(List backPressureHandlers, int batchSize, + Duration waitTimeout) { this.backPressureHandlers = backPressureHandlers; this.batchSize = batchSize; + this.noPermitsReturnedWaitTimeout = waitTimeout; } @Override @@ -63,6 +78,9 @@ public int request(int amount) throws InterruptedException { backPressureHandlers.get(i).release(obtainedForBph - obtained, ReleaseReason.LIMITED); } } + if (obtained == 0) { + waitForPermitsToBeReleased(); + } return obtained; } @@ -71,14 +89,48 @@ public void release(int amount, ReleaseReason reason) { for (BackPressureHandler handler : backPressureHandlers) { handler.release(amount, reason); } + if (amount > 0) { + signalPermitsWereReleased(); + } + } + + /** + * Waits for permits to be released up to {@link #noPermitsReturnedWaitTimeout}. If no permits were released within + * the configured {@link #noPermitsReturnedWaitTimeout}, returns immediately. This allows {@link #request(int)} to + * return {@code 0} permits and will trigger another round of back-pressure handling. + * + * @throws InterruptedException if the Thread is interrupted while waiting for permits. + */ + @SuppressWarnings({ "java:S899" // we are not interested in the await return value here + }) + private void waitForPermitsToBeReleased() throws InterruptedException { + noPermitsReturnedWaitLock.lock(); + try { + permitsReleasedCondition.await(noPermitsReturnedWaitTimeout.toMillis(), TimeUnit.MILLISECONDS); + } + finally { + noPermitsReturnedWaitLock.unlock(); + } + } + + private void signalPermitsWereReleased() { + noPermitsReturnedWaitLock.lock(); + try { + permitsReleasedCondition.signal(); + } + finally { + noPermitsReturnedWaitLock.unlock(); + } } @Override public boolean drain(Duration timeout) { + logger.info("Draining back-pressure handlers initiated"); boolean result = true; for (BackPressureHandler handler : backPressureHandlers) { result &= !handler.drain(timeout); } + logger.info("Draining back-pressure handlers completed"); return result; } } diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java index 7fc18e308..6decea11f 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java @@ -15,7 +15,6 @@ */ package io.awspring.cloud.sqs.integration; -import static java.util.Collections.singletonMap; import static org.assertj.core.api.Assertions.assertThat; import io.awspring.cloud.sqs.config.SqsBootstrapConfiguration; @@ -36,7 +35,6 @@ import java.util.List; import java.util.Queue; import java.util.Random; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Semaphore; @@ -47,7 +45,6 @@ import java.util.function.IntUnaryOperator; import java.util.stream.Collectors; import java.util.stream.IntStream; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -60,8 +57,6 @@ import org.springframework.context.annotation.Import; import org.springframework.messaging.Message; import org.springframework.messaging.support.MessageBuilder; -import software.amazon.awssdk.services.sqs.SqsAsyncClient; -import software.amazon.awssdk.services.sqs.model.QueueAttributeName; /** * Integration tests for SQS containers back pressure management. @@ -73,65 +68,6 @@ class SqsBackPressureIntegrationTests extends BaseSqsIntegrationTest { private static final Logger logger = LoggerFactory.getLogger(SqsBackPressureIntegrationTests.class); - static final String RECEIVES_MESSAGE_QUEUE_NAME = "receives_message_test_queue"; - - static final String RECEIVES_MESSAGE_BATCH_QUEUE_NAME = "receives_message_batch_test_queue"; - - static final String RECEIVES_MESSAGE_ASYNC_QUEUE_NAME = "receives_message_async_test_queue"; - - static final String DOES_NOT_ACK_ON_ERROR_QUEUE_NAME = "does_not_ack_test_queue"; - - static final String DOES_NOT_ACK_ON_ERROR_ASYNC_QUEUE_NAME = "does_not_ack_async_test_queue"; - - static final String DOES_NOT_ACK_ON_ERROR_BATCH_QUEUE_NAME = "does_not_ack_batch_test_queue"; - - static final String DOES_NOT_ACK_ON_ERROR_BATCH_ASYNC_QUEUE_NAME = "does_not_ack_batch_async_test_queue"; - - static final String RESOLVES_PARAMETER_TYPES_QUEUE_NAME = "resolves_parameter_type_test_queue"; - - static final String MANUALLY_START_CONTAINER = "manually_start_container_test_queue"; - - static final String MANUALLY_CREATE_CONTAINER_QUEUE_NAME = "manually_create_container_test_queue"; - - static final String MANUALLY_CREATE_INACTIVE_CONTAINER_QUEUE_NAME = "manually_create_inactive_container_test_queue"; - - static final String MANUALLY_CREATE_FACTORY_QUEUE_NAME = "manually_create_factory_test_queue"; - - static final String CONSUMES_ONE_MESSAGE_AT_A_TIME_QUEUE_NAME = "consumes_one_message_test_queue"; - - static final String MAX_CONCURRENT_MESSAGES_QUEUE_NAME = "max_concurrent_messages_test_queue"; - - static final String LOW_RESOURCE_FACTORY = "lowResourceFactory"; - - static final String MANUAL_ACK_FACTORY = "manualAcknowledgementFactory"; - - static final String MANUAL_ACK_BATCH_FACTORY = "manualAcknowledgementBatchFactory"; - - static final String ACK_AFTER_SECOND_ERROR_FACTORY = "ackAfterSecondErrorFactory"; - - @BeforeAll - static void beforeTests() { - SqsAsyncClient client = createAsyncClient(); - CompletableFuture.allOf(createQueue(client, RECEIVES_MESSAGE_QUEUE_NAME), - createQueue(client, DOES_NOT_ACK_ON_ERROR_QUEUE_NAME, - singletonMap(QueueAttributeName.VISIBILITY_TIMEOUT, "1")), - createQueue(client, DOES_NOT_ACK_ON_ERROR_ASYNC_QUEUE_NAME, - singletonMap(QueueAttributeName.VISIBILITY_TIMEOUT, "1")), - createQueue(client, DOES_NOT_ACK_ON_ERROR_BATCH_QUEUE_NAME, - singletonMap(QueueAttributeName.VISIBILITY_TIMEOUT, "1")), - createQueue(client, DOES_NOT_ACK_ON_ERROR_BATCH_ASYNC_QUEUE_NAME, - singletonMap(QueueAttributeName.VISIBILITY_TIMEOUT, "1")), - createQueue(client, RECEIVES_MESSAGE_ASYNC_QUEUE_NAME), - createQueue(client, RECEIVES_MESSAGE_BATCH_QUEUE_NAME), - createQueue(client, RESOLVES_PARAMETER_TYPES_QUEUE_NAME, - singletonMap(QueueAttributeName.VISIBILITY_TIMEOUT, "20")), - createQueue(client, MANUALLY_CREATE_CONTAINER_QUEUE_NAME), - createQueue(client, MANUALLY_CREATE_INACTIVE_CONTAINER_QUEUE_NAME), - createQueue(client, MANUALLY_CREATE_FACTORY_QUEUE_NAME), - createQueue(client, CONSUMES_ONE_MESSAGE_AT_A_TIME_QUEUE_NAME), - createQueue(client, MAX_CONCURRENT_MESSAGES_QUEUE_NAME)).join(); - } - @Autowired SqsTemplate sqsTemplate; @@ -202,11 +138,12 @@ void staticBackPressureLimitShouldCapQueueProcessingCapacity(int staticLimit, in .queueNames( queueName) .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler(List.of(limiter, - SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) - .acquireTimeout(Duration.ofSeconds(1L)) - .throughputConfiguration(BackPressureMode.AUTO).build()), - 5))) + .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler( + List.of(limiter, + SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) + .acquireTimeout(Duration.ofSeconds(1L)) + .throughputConfiguration(BackPressureMode.AUTO).build()), + 5, Duration.ofMillis(50L)))) .messageListener(msg -> { int concurrentRqs = concurrentRequest.incrementAndGet(); maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); @@ -241,11 +178,12 @@ void zeroBackPressureLimitShouldStopQueueProcessing() throws Exception { .queueNames( queueName) .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler(List.of(limiter, - SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) - .acquireTimeout(Duration.ofSeconds(1L)) - .throughputConfiguration(BackPressureMode.AUTO).build()), - 5))) + .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler( + List.of(limiter, + SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) + .acquireTimeout(Duration.ofSeconds(1L)) + .throughputConfiguration(BackPressureMode.AUTO).build()), + 5, Duration.ofMillis(50L)))) .messageListener(msg -> { int concurrentRqs = concurrentRequest.incrementAndGet(); maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); @@ -278,23 +216,33 @@ void changeInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Except var latch = new CountDownLatch(nbMessages); var controlSemaphore = new Semaphore(0); var advanceSemaphore = new Semaphore(0); + var processingFailed = new AtomicBoolean(false); + var isDraining = new AtomicBoolean(false); var container = SqsMessageListenerContainer .builder().sqsAsyncClient( BaseSqsIntegrationTest.createAsyncClient()) .queueNames( queueName) .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler(List.of(limiter, - SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) - .acquireTimeout(Duration.ofSeconds(1L)) - .throughputConfiguration(BackPressureMode.AUTO).build()), - 5))) + .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler( + List.of(limiter, + SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) + .acquireTimeout(Duration.ofSeconds(1L)) + .throughputConfiguration(BackPressureMode.AUTO).build()), + 5, Duration.ofMillis(50L)))) .messageListener(msg -> { try { - controlSemaphore.acquire(); + if (!controlSemaphore.tryAcquire(5, TimeUnit.SECONDS) && !isDraining.get()) { + processingFailed.set(true); + throw new IllegalStateException("Failed to wait for control semaphore"); + } } catch (InterruptedException e) { - throw new RuntimeException(e); + if (!isDraining.get()) { + processingFailed.set(true); + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } } int concurrentRqs = concurrentRequest.incrementAndGet(); maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); @@ -310,14 +258,16 @@ class Controller { private final Semaphore controlSemaphore; private final NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter; private final AtomicInteger maxConcurrentRequest; + private final AtomicBoolean processingFailed; Controller(Semaphore advanceSemaphore, Semaphore controlSemaphore, NonBlockingExternalConcurrencyLimiterBackPressureHandler limiter, - AtomicInteger maxConcurrentRequest) { + AtomicInteger maxConcurrentRequest, AtomicBoolean processingFailed) { this.advanceSemaphore = advanceSemaphore; this.controlSemaphore = controlSemaphore; this.limiter = limiter; this.maxConcurrentRequest = maxConcurrentRequest; + this.processingFailed = processingFailed; } public void updateLimit(int newLimit) { @@ -341,9 +291,11 @@ void waitForAdvance(int permits) throws InterruptedException { .withFailMessage(() -> "Waiting for %d permits timed out. Only %d permits available" .formatted(permits, advanceSemaphore.availablePermits())) .isTrue(); + assertThat(processingFailed.get()).isFalse(); } } - var controller = new Controller(advanceSemaphore, controlSemaphore, limiter, maxConcurrentRequest); + var controller = new Controller(advanceSemaphore, controlSemaphore, limiter, maxConcurrentRequest, + processingFailed); try { container.start(); @@ -386,8 +338,10 @@ void waitForAdvance(int permits) throws InterruptedException { controller.waitForAdvance(50); assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); assertThat(controller.maxConcurrentRequest.get()).isEqualTo(5); + assertThat(processingFailed.get()).isFalse(); } finally { + isDraining.set(true); container.stop(); } } @@ -500,13 +454,12 @@ void unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity( options -> options.pollTimeout(Duration.ofSeconds(1)) .standbyLimitPollingInterval( Duration.ofMillis(1)) - .backPressureHandlerSupplier(() -> new StatisticsBphDecorator( - new CompositeBackPressureHandler(List.of(limiter, - SemaphoreBackPressureHandler.builder().batchSize(10).totalPermits(10) - .acquireTimeout(Duration.ofSeconds(1L)) + .backPressureHandlerSupplier( + () -> new StatisticsBphDecorator(new CompositeBackPressureHandler( + List.of(limiter, SemaphoreBackPressureHandler.builder().batchSize(10) + .totalPermits(10).acquireTimeout(Duration.ofSeconds(1L)) .throughputConfiguration(BackPressureMode.AUTO).build()), - 10), - eventsCsvWriter))) + 10, Duration.ofMillis(50L)), eventsCsvWriter))) .messageListener(msg -> { int currentConcurrentRq = concurrentRequest.incrementAndGet(); maxConcurrentRequest.updateAndGet(max -> Math.max(max, currentConcurrentRq)); From bd81aea0c5d9da635408062e26dd467189d2ad5d Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Thu, 13 Feb 2025 14:51:10 +0100 Subject: [PATCH 07/29] Enhance default methods for backward compatibility (#1251) --- .../io/awspring/cloud/sqs/listener/BackPressureHandler.java | 5 ++++- .../cloud/sqs/listener/BatchAwareBackPressureHandler.java | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java index a5921de68..55e5a25f0 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java @@ -55,7 +55,9 @@ public interface BackPressureHandler { * @param amount the amount of permits to release. * @param reason the reason why the permits were released. */ - void release(int amount, ReleaseReason reason); + default void release(int amount, ReleaseReason reason) { + release(amount); + } /** * Release the specified amount of permits. Each message that has been processed should release one permit, whether @@ -67,6 +69,7 @@ public interface BackPressureHandler { */ @Deprecated default void release(int amount) { + release(amount, ReleaseReason.PROCESSED); } /** diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java index 06387976c..c5ccf0ba4 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java @@ -41,6 +41,7 @@ public interface BatchAwareBackPressureHandler extends BackPressureHandler { */ @Deprecated default void releaseBatch() { + release(getBatchSize(), ReleaseReason.NONE_FETCHED); } /** From 1e991593322718dd17d71044aed4625cec8696c8 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Mon, 17 Feb 2025 14:06:49 +0100 Subject: [PATCH 08/29] Split SemaphoreBackPressureHandler into a ConcurrencyLimiterBlocking and a Throughput BackPressureHandler(s) (#1251) --- ...tractPipelineMessageListenerContainer.java | 18 +- .../CompositeBackPressureHandler.java | 19 +- ...ncyLimiterBlockingBackPressureHandler.java | 163 +++++++++++ .../SemaphoreBackPressureHandler.java | 252 ------------------ .../ThroughputBackPressureHandler.java | 154 +++++++++++ .../SqsBackPressureIntegrationTests.java | 45 ++-- .../AbstractPollingMessageSourceTests.java | 186 +++++++++---- 7 files changed, 499 insertions(+), 338 deletions(-) create mode 100644 spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java delete mode 100644 spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java create mode 100644 spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java index 2de091faf..8a1ab1f8e 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java @@ -237,9 +237,23 @@ protected BackPressureHandler createBackPressureHandler() { } Duration acquireTimeout = containerOptions.getMaxDelayBetweenPolls(); int batchSize = containerOptions.getMaxMessagesPerPoll(); - return SemaphoreBackPressureHandler.builder().batchSize(batchSize) - .totalPermits(containerOptions.getMaxConcurrentMessages()).acquireTimeout(acquireTimeout) + int maxConcurrentMessages = containerOptions.getMaxConcurrentMessages(); + var concurrencyLimiterBlockingBackPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler.builder() + .batchSize(batchSize).totalPermits(maxConcurrentMessages).acquireTimeout(acquireTimeout) .throughputConfiguration(containerOptions.getBackPressureMode()).build(); + if (maxConcurrentMessages == batchSize) { + return concurrencyLimiterBlockingBackPressureHandler; + } + return switch (containerOptions.getBackPressureMode()) { + case FIXED_HIGH_THROUGHPUT -> concurrencyLimiterBlockingBackPressureHandler; + case ALWAYS_POLL_MAX_MESSAGES, + AUTO -> { + var throughputBackPressureHandler = ThroughputBackPressureHandler.builder().batchSize(batchSize).build(); + yield new CompositeBackPressureHandler( + List.of(concurrencyLimiterBlockingBackPressureHandler, throughputBackPressureHandler), + batchSize, containerOptions.getStandbyLimitPollingInterval()); + } + }; } protected TaskExecutor createSourcesTaskExecutor() { diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java index 92a1e5295..930f7dc6e 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java @@ -16,6 +16,7 @@ package io.awspring.cloud.sqs.listener; import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Condition; @@ -66,6 +67,7 @@ public int requestBatch() throws InterruptedException { @Override public int request(int amount) throws InterruptedException { + logger.debug("[{}] Requesting {} permits", this.id, amount); int obtained = amount; int[] obtainedPerBph = new int[backPressureHandlers.size()]; for (int i = 0; i < backPressureHandlers.size() && obtained > 0; i++) { @@ -81,11 +83,13 @@ public int request(int amount) throws InterruptedException { if (obtained == 0) { waitForPermitsToBeReleased(); } + logger.debug("[{}] Obtained {} permits ({} requested)", this.id, obtained, amount); return obtained; } @Override public void release(int amount, ReleaseReason reason) { + logger.debug("[{}] Releasing {} permits ({})", this.id, amount, reason); for (BackPressureHandler handler : backPressureHandlers) { handler.release(amount, reason); } @@ -106,6 +110,8 @@ public void release(int amount, ReleaseReason reason) { private void waitForPermitsToBeReleased() throws InterruptedException { noPermitsReturnedWaitLock.lock(); try { + logger.trace("[{}] No permits were obtained, waiting for a release up to {}", this.id, + noPermitsReturnedWaitTimeout); permitsReleasedCondition.await(noPermitsReturnedWaitTimeout.toMillis(), TimeUnit.MILLISECONDS); } finally { @@ -125,12 +131,19 @@ private void signalPermitsWereReleased() { @Override public boolean drain(Duration timeout) { - logger.info("Draining back-pressure handlers initiated"); + logger.debug("[{}] Draining back-pressure handlers initiated", this.id); boolean result = true; + Instant start = Instant.now(); for (BackPressureHandler handler : backPressureHandlers) { - result &= !handler.drain(timeout); + Duration remainingTimeout = maxDuration(timeout.minus(Duration.between(start, Instant.now())), + Duration.ZERO); + result &= handler.drain(remainingTimeout); } - logger.info("Draining back-pressure handlers completed"); + logger.debug("[{}] Draining back-pressure handlers completed", this.id); return result; } + + private static Duration maxDuration(Duration first, Duration second) { + return first.compareTo(second) > 0 ? first : second; + } } diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java new file mode 100644 index 000000000..e389ba7c3 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java @@ -0,0 +1,163 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.listener; + +import java.time.Duration; +import java.util.Arrays; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.Assert; + +/** + * {@link BackPressureHandler} implementation that uses a {@link Semaphore} for handling backpressure. + * + * @author Tomaz Fernandes + * @see io.awspring.cloud.sqs.listener.source.PollingMessageSource + * @since 3.0 + */ +public class ConcurrencyLimiterBlockingBackPressureHandler + implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { + + private static final Logger logger = LoggerFactory.getLogger(ConcurrencyLimiterBlockingBackPressureHandler.class); + + private final Semaphore semaphore; + + private final int batchSize; + + private final int totalPermits; + + private final Duration acquireTimeout; + + private final boolean alwaysPollMasMessages; + + private String id = getClass().getSimpleName(); + + private ConcurrencyLimiterBlockingBackPressureHandler(Builder builder) { + this.batchSize = builder.batchSize; + this.totalPermits = builder.totalPermits; + this.acquireTimeout = builder.acquireTimeout; + this.alwaysPollMasMessages = BackPressureMode.ALWAYS_POLL_MAX_MESSAGES.equals(builder.backPressureMode); + this.semaphore = new Semaphore(totalPermits); + logger.debug( + "ConcurrencyLimiterBlockingBackPressureHandler created with configuration " + + "totalPermits: {}, batchSize: {}, acquireTimeout: {}, an alwaysPollMasMessages: {}", + this.totalPermits, this.batchSize, this.acquireTimeout, this.alwaysPollMasMessages); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public void setId(String id) { + this.id = id; + } + + @Override + public String getId() { + return this.id; + } + + @Override + public int requestBatch() throws InterruptedException { + return request(this.batchSize); + } + + @Override + public int request(int amount) throws InterruptedException { + int acquiredPermits = tryAcquire(amount, this.acquireTimeout); + if (alwaysPollMasMessages || acquiredPermits > 0) { + return acquiredPermits; + } + int availablePermits = Math.min(this.semaphore.availablePermits(), amount); + if (availablePermits > 0) { + return tryAcquire(availablePermits, this.acquireTimeout); + } + return 0; + } + + private int tryAcquire(int amount, Duration duration) throws InterruptedException { + if (this.semaphore.tryAcquire(amount, duration.toMillis(), TimeUnit.MILLISECONDS)) { + logger.debug("[{}] Acquired {} permits ({} / {} available)", this.id, amount, + this.semaphore.availablePermits(), this.totalPermits); + return amount; + } + return 0; + } + + @Override + public void release(int amount, ReleaseReason reason) { + this.semaphore.release(amount); + logger.debug("[{}] Released {} permits ({}) ({} / {} available)", this.id, amount, reason, + this.semaphore.availablePermits(), this.totalPermits); + } + + @Override + public boolean drain(Duration timeout) { + logger.debug("[{}] Waiting for up to {} for approx. {} permits to be released", this.id, timeout, + this.totalPermits - this.semaphore.availablePermits()); + try { + return tryAcquire(this.totalPermits, timeout) > 0; + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.debug("[{}] Draining interrupted", this.id); + return false; + } + } + + public static class Builder { + + private int batchSize; + + private int totalPermits; + + private Duration acquireTimeout; + + private BackPressureMode backPressureMode; + + public Builder batchSize(int batchSize) { + this.batchSize = batchSize; + return this; + } + + public Builder totalPermits(int totalPermits) { + this.totalPermits = totalPermits; + return this; + } + + public Builder acquireTimeout(Duration acquireTimeout) { + this.acquireTimeout = acquireTimeout; + return this; + } + + public Builder throughputConfiguration(BackPressureMode backPressureConfiguration) { + this.backPressureMode = backPressureConfiguration; + return this; + } + + public ConcurrencyLimiterBlockingBackPressureHandler build() { + Assert.noNullElements( + Arrays.asList(this.batchSize, this.totalPermits, this.acquireTimeout, this.backPressureMode), + "Missing configuration"); + Assert.isTrue(this.batchSize > 0, "The batch size must be greater than 0"); + Assert.isTrue(this.totalPermits >= this.batchSize, "Total permits must be greater than the batch size"); + return new ConcurrencyLimiterBlockingBackPressureHandler(this); + } + } +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java deleted file mode 100644 index 70ed3f306..000000000 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java +++ /dev/null @@ -1,252 +0,0 @@ -/* - * Copyright 2013-2022 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License 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 io.awspring.cloud.sqs.listener; - -import java.time.Duration; -import java.util.Arrays; -import java.util.concurrent.Semaphore; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.util.Assert; - -/** - * {@link BackPressureHandler} implementation that uses a {@link Semaphore} for handling backpressure. - * - * @author Tomaz Fernandes - * @since 3.0 - * @see io.awspring.cloud.sqs.listener.source.PollingMessageSource - */ -public class SemaphoreBackPressureHandler implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { - - private static final Logger logger = LoggerFactory.getLogger(SemaphoreBackPressureHandler.class); - - private final Semaphore semaphore; - - private final int batchSize; - - private final int totalPermits; - - private final Duration acquireTimeout; - - private final BackPressureMode backPressureConfiguration; - - private volatile CurrentThroughputMode currentThroughputMode; - - private final AtomicInteger lowThroughputPermitsAcquired = new AtomicInteger(0); - - private String id; - - private SemaphoreBackPressureHandler(Builder builder) { - this.batchSize = builder.batchSize; - this.totalPermits = builder.totalPermits; - this.acquireTimeout = builder.acquireTimeout; - this.backPressureConfiguration = builder.backPressureMode; - this.semaphore = new Semaphore(totalPermits); - this.currentThroughputMode = BackPressureMode.FIXED_HIGH_THROUGHPUT.equals(backPressureConfiguration) - ? CurrentThroughputMode.HIGH - : CurrentThroughputMode.LOW; - logger.debug("SemaphoreBackPressureHandler created with configuration {} and {} total permits", - backPressureConfiguration, totalPermits); - } - - public static Builder builder() { - return new Builder(); - } - - @Override - public void setId(String id) { - this.id = id; - } - - @Override - public String getId() { - return this.id; - } - - @Override - public int requestBatch() throws InterruptedException { - return request(batchSize); - } - - // @formatter:off - @Override - public int request(int amount) throws InterruptedException { - return CurrentThroughputMode.LOW.equals(this.currentThroughputMode) - ? requestInLowThroughputMode(amount) - : requestInHighThroughputMode(amount); - } - - private int requestInHighThroughputMode(int amount) throws InterruptedException { - return tryAcquire(amount, CurrentThroughputMode.HIGH) - ? amount - : tryAcquirePartial(amount); - } - // @formatter:on - - private int tryAcquirePartial(int max) throws InterruptedException { - int availablePermits = this.semaphore.availablePermits(); - if (availablePermits == 0 || BackPressureMode.ALWAYS_POLL_MAX_MESSAGES.equals(this.backPressureConfiguration)) { - return 0; - } - int permitsToRequest = Math.min(availablePermits, max); - CurrentThroughputMode currentThroughputModeNow = this.currentThroughputMode; - logger.trace("Trying to acquire partial batch of {} permits from {} available for {} in TM {}", - permitsToRequest, availablePermits, this.id, currentThroughputModeNow); - boolean hasAcquiredPartial = tryAcquire(permitsToRequest, currentThroughputModeNow); - return hasAcquiredPartial ? permitsToRequest : 0; - } - - private int requestInLowThroughputMode(int amount) throws InterruptedException { - // Although LTM can be set / unset by many processes, only the MessageSource thread gets here, - // so no actual concurrency - logger.debug("Trying to acquire full permits for {}. Permits left: {}", this.id, - this.semaphore.availablePermits()); - boolean hasAcquired = tryAcquire(this.totalPermits, CurrentThroughputMode.LOW); - if (hasAcquired) { - logger.debug("Acquired full permits for {}. Permits left: {}", this.id, this.semaphore.availablePermits()); - // We've acquired all permits - there's no other process currently processing messages - if (this.lowThroughputPermitsAcquired.getAndSet(amount) != 0) { - logger.warn("hasAcquiredFullPermits was already true. Permits left: {}", - this.semaphore.availablePermits()); - } - return amount; - } - else { - return 0; - } - } - - private boolean tryAcquire(int amount, CurrentThroughputMode currentThroughputModeNow) throws InterruptedException { - logger.trace("Acquiring {} permits for {} in TM {}", amount, this.id, this.currentThroughputMode); - boolean hasAcquired = this.semaphore.tryAcquire(amount, this.acquireTimeout.toMillis(), TimeUnit.MILLISECONDS); - if (hasAcquired) { - logger.trace("{} permits acquired for {} in TM {}. Permits left: {}", amount, this.id, - currentThroughputModeNow, this.semaphore.availablePermits()); - } - else { - logger.trace("Not able to acquire {} permits in {} milliseconds for {} in TM {}. Permits left: {}", amount, - this.acquireTimeout.toMillis(), this.id, currentThroughputModeNow, - this.semaphore.availablePermits()); - } - return hasAcquired; - } - - @Override - public void release(int amount, ReleaseReason reason) { - logger.trace("Releasing {} permits ({}) for {}. Permits left: {}", amount, reason, this.id, - this.semaphore.availablePermits()); - switch (reason) { - case NONE_FETCHED -> maybeSwitchToLowThroughputMode(); - case PARTIAL_FETCH -> maybeSwitchToHighThroughputMode(amount); - case PROCESSED, LIMITED -> { - // No need to switch throughput mode - } - } - int permitsToRelease = getPermitsToRelease(amount); - this.semaphore.release(permitsToRelease); - logger.debug("Released {} permits ({}) for {}. Permits left: {}", permitsToRelease, reason, this.id, - this.semaphore.availablePermits()); - } - - private void maybeSwitchToLowThroughputMode() { - if (!BackPressureMode.FIXED_HIGH_THROUGHPUT.equals(this.backPressureConfiguration) - && CurrentThroughputMode.HIGH.equals(this.currentThroughputMode)) { - logger.debug("Entire batch of permits released for {}, setting TM LOW. Permits left: {}", this.id, - this.semaphore.availablePermits()); - this.currentThroughputMode = CurrentThroughputMode.LOW; - } - } - - private void maybeSwitchToHighThroughputMode(int amount) { - if (CurrentThroughputMode.LOW.equals(this.currentThroughputMode)) { - logger.debug("{} unused permit(s), setting TM HIGH for {}. Permits left: {}", amount, this.id, - this.semaphore.availablePermits()); - this.currentThroughputMode = CurrentThroughputMode.HIGH; - } - } - - private int getPermitsToRelease(int amount) { - int lowThroughputPermits = this.lowThroughputPermitsAcquired.getAndSet(0); - return lowThroughputPermits > 0 - // The first process that gets here should release all permits except for inflight messages - // We can have only one batch of messages at this point since we have all permits - ? this.totalPermits - (lowThroughputPermits - amount) - : amount; - } - - @Override - public boolean drain(Duration timeout) { - logger.debug("Waiting for up to {} seconds for approx. {} permits to be released for {}", timeout.getSeconds(), - this.totalPermits - this.semaphore.availablePermits(), this.id); - try { - return this.semaphore.tryAcquire(this.totalPermits, (int) timeout.getSeconds(), TimeUnit.SECONDS); - } - catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Interrupted while waiting to acquire permits", e); - } - } - - private enum CurrentThroughputMode { - - HIGH, - - LOW; - - } - - public static class Builder { - - private int batchSize; - - private int totalPermits; - - private Duration acquireTimeout; - - private BackPressureMode backPressureMode; - - public Builder batchSize(int batchSize) { - this.batchSize = batchSize; - return this; - } - - public Builder totalPermits(int totalPermits) { - this.totalPermits = totalPermits; - return this; - } - - public Builder acquireTimeout(Duration acquireTimeout) { - this.acquireTimeout = acquireTimeout; - return this; - } - - public Builder throughputConfiguration(BackPressureMode backPressureConfiguration) { - this.backPressureMode = backPressureConfiguration; - return this; - } - - public SemaphoreBackPressureHandler build() { - Assert.noNullElements( - Arrays.asList(this.batchSize, this.totalPermits, this.acquireTimeout, this.backPressureMode), - "Missing configuration"); - return new SemaphoreBackPressureHandler(this); - } - - } - -} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java new file mode 100644 index 000000000..3ef1410d9 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java @@ -0,0 +1,154 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.listener; + +import io.awspring.cloud.sqs.listener.source.PollingMessageSource; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.Assert; + +/** + * {@link BackPressureHandler} implementation that uses a switches between high and low throughput modes. + *

+ * The initial throughput mode is low, which means, only one batch at a time can be requested. If some messages are + * fetched, then the throughput mode is switched to high, which means, the multiple batches can be requested (i.e. there + * is no need to wait for the previous batch's processing to complete before requesting a new one). If no messages are + * returned fetched by a poll, the throughput mode is switched back to low. + *

+ * This {@link BackPressureHandler} is designed to be used in combination with another {@link BackPressureHandler} like + * the {@link ConcurrencyLimiterBlockingBackPressureHandler} that will handle the maximum concurrency level within the + * application. + * + * @author Tomaz Fernandes + * @see PollingMessageSource + * @since 3.0 + */ +public class ThroughputBackPressureHandler implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { + + private static final Logger logger = LoggerFactory.getLogger(ThroughputBackPressureHandler.class); + + private final int batchSize; + + private final AtomicReference currentThroughputMode = new AtomicReference<>( + CurrentThroughputMode.LOW); + + private final AtomicInteger inFlightRequests = new AtomicInteger(0); + + private final AtomicBoolean drained = new AtomicBoolean(false); + + private String id = getClass().getSimpleName(); + + private ThroughputBackPressureHandler(Builder builder) { + this.batchSize = builder.batchSize; + logger.debug("ThroughputBackPressureHandler created with batchSize {}", this.batchSize); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public void setId(String id) { + this.id = id; + } + + @Override + public String getId() { + return this.id; + } + + @Override + public int requestBatch() throws InterruptedException { + return request(this.batchSize); + } + + @Override + public int request(int amount) throws InterruptedException { + if (drained.get()) { + return 0; + } + int permits; + int inFlight = inFlightRequests.get(); + if (CurrentThroughputMode.LOW == this.currentThroughputMode.get()) { + permits = Math.max(0, Math.min(amount, this.batchSize - inFlight)); + logger.debug("[{}] Acquired {} permits (low throughput mode), in flight: {}", this.id, amount, inFlight); + } + else { + permits = amount; + logger.debug("[{}] Acquired {} permits (high throughput mode), in flight: {}", this.id, amount, inFlight); + } + inFlightRequests.addAndGet(permits); + return permits; + } + + @Override + public void release(int amount, ReleaseReason reason) { + if (drained.get()) { + return; + } + logger.debug("[{}] Releasing {} permits ({})", this.id, amount, reason); + inFlightRequests.addAndGet(-amount); + switch (reason) { + case NONE_FETCHED -> updateThroughputMode(CurrentThroughputMode.HIGH, CurrentThroughputMode.LOW); + case PARTIAL_FETCH -> updateThroughputMode(CurrentThroughputMode.LOW, CurrentThroughputMode.HIGH); + case LIMITED, PROCESSED -> { + // No need to switch throughput mode + } + } + } + + private void updateThroughputMode(CurrentThroughputMode currentTarget, CurrentThroughputMode newTarget) { + if (this.currentThroughputMode.compareAndSet(currentTarget, newTarget)) { + logger.debug("[{}] throughput mode updated to {}", this.id, newTarget); + } + } + + @Override + public boolean drain(Duration timeout) { + logger.debug("[{}] Draining", this.id); + drained.set(true); + return true; + } + + private enum CurrentThroughputMode { + + HIGH, + + LOW; + + } + + public static class Builder { + + private int batchSize; + + public Builder batchSize(int batchSize) { + this.batchSize = batchSize; + return this; + } + + public ThroughputBackPressureHandler build() { + Assert.noNullElements(List.of(this.batchSize), "Missing configuration"); + Assert.isTrue(this.batchSize > 0, "batch size must be greater than 0"); + return new ThroughputBackPressureHandler(this); + } + } +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java index 6decea11f..8038f70d2 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java @@ -18,13 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; import io.awspring.cloud.sqs.config.SqsBootstrapConfiguration; -import io.awspring.cloud.sqs.listener.BackPressureHandler; -import io.awspring.cloud.sqs.listener.BackPressureMode; -import io.awspring.cloud.sqs.listener.BatchAwareBackPressureHandler; -import io.awspring.cloud.sqs.listener.CompositeBackPressureHandler; -import io.awspring.cloud.sqs.listener.IdentifiableContainerComponent; -import io.awspring.cloud.sqs.listener.SemaphoreBackPressureHandler; -import io.awspring.cloud.sqs.listener.SqsMessageListenerContainer; +import io.awspring.cloud.sqs.listener.*; import io.awspring.cloud.sqs.operations.SqsTemplate; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -140,8 +134,8 @@ void staticBackPressureLimitShouldCapQueueProcessingCapacity(int staticLimit, in .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler( List.of(limiter, - SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) - .acquireTimeout(Duration.ofSeconds(1L)) + ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(5) + .totalPermits(5).acquireTimeout(Duration.ofSeconds(1L)) .throughputConfiguration(BackPressureMode.AUTO).build()), 5, Duration.ofMillis(50L)))) .messageListener(msg -> { @@ -180,8 +174,8 @@ void zeroBackPressureLimitShouldStopQueueProcessing() throws Exception { .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler( List.of(limiter, - SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) - .acquireTimeout(Duration.ofSeconds(1L)) + ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(5) + .totalPermits(5).acquireTimeout(Duration.ofSeconds(1L)) .throughputConfiguration(BackPressureMode.AUTO).build()), 5, Duration.ofMillis(50L)))) .messageListener(msg -> { @@ -226,8 +220,8 @@ void changeInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Except .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler( List.of(limiter, - SemaphoreBackPressureHandler.builder().batchSize(5).totalPermits(5) - .acquireTimeout(Duration.ofSeconds(1L)) + ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(5) + .totalPermits(5).acquireTimeout(Duration.ofSeconds(1L)) .throughputConfiguration(BackPressureMode.AUTO).build()), 5, Duration.ofMillis(50L)))) .messageListener(msg -> { @@ -446,20 +440,16 @@ void unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity( logger.debug("Sent {} messages to queue {}", nbMessages, queueName); var latch = new CountDownLatch(nbMessages); EventsCsvWriter eventsCsvWriter = new EventsCsvWriter(); - var container = SqsMessageListenerContainer - .builder().sqsAsyncClient( - BaseSqsIntegrationTest.createAsyncClient()) + var container = SqsMessageListenerContainer.builder().sqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()) .queueNames(queueName) - .configure( - options -> options.pollTimeout(Duration.ofSeconds(1)) - .standbyLimitPollingInterval( - Duration.ofMillis(1)) - .backPressureHandlerSupplier( - () -> new StatisticsBphDecorator(new CompositeBackPressureHandler( - List.of(limiter, SemaphoreBackPressureHandler.builder().batchSize(10) - .totalPermits(10).acquireTimeout(Duration.ofSeconds(1L)) - .throughputConfiguration(BackPressureMode.AUTO).build()), - 10, Duration.ofMillis(50L)), eventsCsvWriter))) + .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) + .standbyLimitPollingInterval(Duration.ofMillis(1)) + .backPressureHandlerSupplier(() -> new StatisticsBphDecorator(new CompositeBackPressureHandler( + List.of(limiter, + ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(10) + .totalPermits(10).acquireTimeout(Duration.ofSeconds(1L)) + .throughputConfiguration(BackPressureMode.AUTO).build()), + 10, Duration.ofMillis(50L)), eventsCsvWriter))) .messageListener(msg -> { int currentConcurrentRq = concurrentRequest.incrementAndGet(); maxConcurrentRequest.updateAndGet(max -> Math.max(max, currentConcurrentRq)); @@ -507,8 +497,7 @@ void unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity( eventsCsvWriter.registerEvent("expected_max", expectedMax); eventsCsvWriter.registerEvent("max_minus_expected_max", max - expectedMax); } - eventsCsvWriter.write(Path.of( - "target/0-stats-unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity.csv")); + eventsCsvWriter.write(Path.of("target/stats-%s.csv".formatted(queueName))); assertThat(maxConcurrentRqSum).isLessThanOrEqualTo(limitsSum); assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); } diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java index b03b308c6..0d83aca27 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java @@ -23,27 +23,17 @@ import static org.mockito.Mockito.times; import io.awspring.cloud.sqs.MessageExecutionThreadFactory; -import io.awspring.cloud.sqs.listener.BackPressureMode; -import io.awspring.cloud.sqs.listener.SemaphoreBackPressureHandler; -import io.awspring.cloud.sqs.listener.SqsContainerOptions; +import io.awspring.cloud.sqs.listener.*; import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback; import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementProcessor; import io.awspring.cloud.sqs.support.converter.MessageConversionContext; import io.awspring.cloud.sqs.support.converter.SqsMessagingMessageConverter; import java.time.Duration; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Semaphore; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; +import java.util.*; +import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.IntStream; import org.assertj.core.api.InstanceOfAssertFactories; import org.awaitility.Awaitility; @@ -69,13 +59,77 @@ class AbstractPollingMessageSourceTests { void shouldAcquireAndReleaseFullPermits() { String testName = "shouldAcquireAndReleaseFullPermits"; - SemaphoreBackPressureHandler backPressureHandler = SemaphoreBackPressureHandler.builder() + BackPressureHandler backPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler.builder() .acquireTimeout(Duration.ofMillis(200)).batchSize(10).totalPermits(10) .throughputConfiguration(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES).build(); ExecutorService threadPool = Executors.newCachedThreadPool(); CountDownLatch pollingCounter = new CountDownLatch(3); CountDownLatch processingCounter = new CountDownLatch(1); + AbstractPollingMessageSource source = new AbstractPollingMessageSource<>() { + + private final AtomicBoolean hasReceived = new AtomicBoolean(false); + + @Override + protected CompletableFuture> doPollForMessages(int messagesToRequest) { + return CompletableFuture.supplyAsync(() -> { + try { + // Since BackPressureMode.ALWAYS_POLL_MAX_MESSAGES, should always be 10. + assertThat(messagesToRequest).isEqualTo(10); + assertAvailablePermits(backPressureHandler, 0); + boolean firstPoll = hasReceived.compareAndSet(false, true); + return firstPoll + ? (Collection) List.of(Message.builder() + .messageId(UUID.randomUUID().toString()).body("message").build()) + : Collections. emptyList(); + } + catch (Throwable t) { + logger.error("Error", t); + throw new RuntimeException(t); + } + }, threadPool).whenComplete((v, t) -> { + if (t == null) { + pollingCounter.countDown(); + } + }); + } + }; + + source.setBackPressureHandler(backPressureHandler); + source.setMessageSink((msgs, context) -> { + assertAvailablePermits(backPressureHandler, 9); + msgs.forEach(msg -> context.runBackPressureReleaseCallback()); + return CompletableFuture.runAsync(processingCounter::countDown); + }); + + source.setId(testName + " source"); + source.configure(SqsContainerOptions.builder().build()); + source.setTaskExecutor(createTaskExecutor(testName)); + source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); + source.start(); + assertThat(doAwait(pollingCounter)).isTrue(); + assertThat(doAwait(processingCounter)).isTrue(); + } + + @Test + void shouldAdaptThroughputMode() { + String testName = "shouldAdaptThroughputMode"; + + int totalPermits = 20; + int batchSize = 10; + var concurrencyLimiterBlockingBackPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler.builder() + .batchSize(batchSize).totalPermits(totalPermits) + .throughputConfiguration(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) + .acquireTimeout(Duration.ofSeconds(5L)).build(); + var throughputBackPressureHandler = ThroughputBackPressureHandler.builder().batchSize(batchSize).build(); + var backPressureHandler = new CompositeBackPressureHandler( + List.of(concurrencyLimiterBlockingBackPressureHandler, throughputBackPressureHandler), batchSize, + Duration.ofMillis(100L)); + ExecutorService threadPool = Executors.newCachedThreadPool(); + CountDownLatch pollingCounter = new CountDownLatch(3); + CountDownLatch processingCounter = new CountDownLatch(1); + Collection errors = new ConcurrentLinkedQueue<>(); + AbstractPollingMessageSource source = new AbstractPollingMessageSource<>() { private final AtomicBoolean hasReceived = new AtomicBoolean(false); @@ -88,20 +142,20 @@ protected CompletableFuture> doPollForMessages(int messagesT try { // Since BackPressureMode.ALWAYS_POLL_MAX_MESSAGES, should always be 10. assertThat(messagesToRequest).isEqualTo(10); - assertAvailablePermits(backPressureHandler, 0); + // assertAvailablePermits(backPressureHandler, 10); boolean firstPoll = hasReceived.compareAndSet(false, true); if (firstPoll) { - logger.debug("First poll"); + logger.warn("First poll"); // No permits released yet, should be TM low assertThroughputMode(backPressureHandler, "low"); } else if (hasMadeSecondPoll.compareAndSet(false, true)) { - logger.debug("Second poll"); + logger.warn("Second poll"); // Permits returned, should be high assertThroughputMode(backPressureHandler, "high"); } else { - logger.debug("Third poll"); + logger.warn("Third poll"); // Already returned full permits, should be low assertThroughputMode(backPressureHandler, "low"); } @@ -111,20 +165,24 @@ else if (hasMadeSecondPoll.compareAndSet(false, true)) { : Collections. emptyList(); } catch (Throwable t) { - logger.error("Error", t); + logger.error("Error (not expecting it)", t); throw new RuntimeException(t); } }, threadPool).whenComplete((v, t) -> { if (t == null) { + logger.warn("pas boom", t); pollingCounter.countDown(); } + else { + logger.warn("BOOOOOOOM", t); + errors.add(t); + } }); } }; source.setBackPressureHandler(backPressureHandler); source.setMessageSink((msgs, context) -> { - assertAvailablePermits(backPressureHandler, 9); msgs.forEach(msg -> context.runBackPressureReleaseCallback()); return CompletableFuture.runAsync(processingCounter::countDown); }); @@ -133,9 +191,16 @@ else if (hasMadeSecondPoll.compareAndSet(false, true)) { source.configure(SqsContainerOptions.builder().build()); source.setTaskExecutor(createTaskExecutor(testName)); source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); - source.start(); - assertThat(doAwait(pollingCounter)).isTrue(); - assertThat(doAwait(processingCounter)).isTrue(); + try { + logger.warn("Yolo, let's start"); + source.start(); + assertThat(doAwait(pollingCounter)).isTrue(); + assertThat(doAwait(processingCounter)).isTrue(); + assertThat(errors).isEmpty(); + } + finally { + source.stop(); + } } private static final AtomicInteger testCounter = new AtomicInteger(); @@ -143,8 +208,8 @@ else if (hasMadeSecondPoll.compareAndSet(false, true)) { @Test void shouldAcquireAndReleasePartialPermits() { String testName = "shouldAcquireAndReleasePartialPermits"; - SemaphoreBackPressureHandler backPressureHandler = SemaphoreBackPressureHandler.builder() - .acquireTimeout(Duration.ofMillis(150)).batchSize(10).totalPermits(10) + ConcurrencyLimiterBlockingBackPressureHandler backPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler + .builder().acquireTimeout(Duration.ofMillis(150)).batchSize(10).totalPermits(10) .throughputConfiguration(BackPressureMode.AUTO).build(); ExecutorService threadPool = Executors .newCachedThreadPool(new MessageExecutionThreadFactory("test " + testCounter.incrementAndGet())); @@ -159,8 +224,6 @@ void shouldAcquireAndReleasePartialPermits() { private final AtomicBoolean hasAcquired9 = new AtomicBoolean(false); - private final AtomicBoolean hasMadeThirdPoll = new AtomicBoolean(false); - @Override protected CompletableFuture> doPollForMessages(int messagesToRequest) { return CompletableFuture.supplyAsync(() -> { @@ -176,31 +239,20 @@ protected CompletableFuture> doPollForMessages(int messagesT assertThat(messagesToRequest).isEqualTo(10); assertAvailablePermits(backPressureHandler, 0); // No permits have been released yet - assertThroughputMode(backPressureHandler, "low"); } else if (hasAcquired9.compareAndSet(false, true)) { // Second poll, should have 9 logger.debug("Second poll - should request 9 messages"); assertThat(messagesToRequest).isEqualTo(9); assertAvailablePermitsLessThanOrEqualTo(backPressureHandler, 1); - // Has released 9 permits, should be TM HIGH - assertThroughputMode(backPressureHandler, "high"); + // Has released 9 permits processingLatch.countDown(); // Release processing now } else { - boolean thirdPoll = hasMadeThirdPoll.compareAndSet(false, true); // Third poll or later, should have 10 again logger.debug("Third poll - should request 10 messages"); assertThat(messagesToRequest).isEqualTo(10); assertAvailablePermits(backPressureHandler, 0); - if (thirdPoll) { - // Hasn't yet returned a full batch, should be TM High - assertThroughputMode(backPressureHandler, "high"); - } - else { - // Has returned all permits in third poll - assertThroughputMode(backPressureHandler, "low"); - } } if (shouldReturnMessage) { logger.debug("shouldReturnMessage, returning one message"); @@ -241,8 +293,8 @@ else if (hasAcquired9.compareAndSet(false, true)) { @Test void shouldReleasePermitsOnConversionErrors() { String testName = "shouldReleasePermitsOnConversionErrors"; - SemaphoreBackPressureHandler backPressureHandler = SemaphoreBackPressureHandler.builder() - .acquireTimeout(Duration.ofMillis(150)).batchSize(10).totalPermits(10) + ConcurrencyLimiterBlockingBackPressureHandler backPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler + .builder().acquireTimeout(Duration.ofMillis(150)).batchSize(10).totalPermits(10) .throughputConfiguration(BackPressureMode.AUTO).build(); AtomicInteger convertedMessages = new AtomicInteger(0); @@ -304,9 +356,16 @@ void shouldBackOffIfPollingThrowsAnError() { var testName = "shouldBackOffIfPollingThrowsAnError"; - var backPressureHandler = SemaphoreBackPressureHandler.builder().acquireTimeout(Duration.ofMillis(200)) - .batchSize(10).totalPermits(40).throughputConfiguration(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) - .build(); + int totalPermits = 40; + int batchSize = 10; + var concurrencyLimiterBlockingBackPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler.builder() + .batchSize(batchSize).totalPermits(totalPermits) + .throughputConfiguration(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) + .acquireTimeout(Duration.ofMillis(200)).build(); + var throughputBackPressureHandler = ThroughputBackPressureHandler.builder().batchSize(batchSize).build(); + var backPressureHandler = new CompositeBackPressureHandler( + List.of(concurrencyLimiterBlockingBackPressureHandler, throughputBackPressureHandler), batchSize, + Duration.ofSeconds(5L)); var currentPoll = new AtomicInteger(0); var waitThirdPollLatch = new CountDownLatch(4); @@ -363,24 +422,45 @@ private static boolean doAwait(CountDownLatch processingLatch) { } } - private void assertThroughputMode(SemaphoreBackPressureHandler backPressureHandler, String expectedThroughputMode) { - assertThat(ReflectionTestUtils.getField(backPressureHandler, "currentThroughputMode")) - .extracting(Object::toString).extracting(String::toLowerCase) + private void assertThroughputMode(BackPressureHandler backPressureHandler, String expectedThroughputMode) { + var bph = extractBackPressureHandler(backPressureHandler, ThroughputBackPressureHandler.class); + assertThat(getThroughputModeValue(bph, "currentThroughputMode")) .isEqualTo(expectedThroughputMode.toLowerCase()); } - private void assertAvailablePermits(SemaphoreBackPressureHandler backPressureHandler, int expectedPermits) { - assertThat(ReflectionTestUtils.getField(backPressureHandler, "semaphore")).asInstanceOf(type(Semaphore.class)) + private static String getThroughputModeValue(ThroughputBackPressureHandler bph, String targetThroughputMode) { + return ((AtomicReference) ReflectionTestUtils.getField(bph, targetThroughputMode)).get().toString() + .toLowerCase(Locale.ROOT); + } + + private void assertAvailablePermits(BackPressureHandler backPressureHandler, int expectedPermits) { + var bph = extractBackPressureHandler(backPressureHandler, ConcurrencyLimiterBlockingBackPressureHandler.class); + assertThat(ReflectionTestUtils.getField(bph, "semaphore")).asInstanceOf(type(Semaphore.class)) .extracting(Semaphore::availablePermits).isEqualTo(expectedPermits); } - private void assertAvailablePermitsLessThanOrEqualTo(SemaphoreBackPressureHandler backPressureHandler, - int maxExpectedPermits) { - assertThat(ReflectionTestUtils.getField(backPressureHandler, "semaphore")).asInstanceOf(type(Semaphore.class)) + private void assertAvailablePermitsLessThanOrEqualTo( + ConcurrencyLimiterBlockingBackPressureHandler backPressureHandler, int maxExpectedPermits) { + var bph = extractBackPressureHandler(backPressureHandler, ConcurrencyLimiterBlockingBackPressureHandler.class); + assertThat(ReflectionTestUtils.getField(bph, "semaphore")).asInstanceOf(type(Semaphore.class)) .extracting(Semaphore::availablePermits).asInstanceOf(InstanceOfAssertFactories.INTEGER) .isLessThanOrEqualTo(maxExpectedPermits); } + private T extractBackPressureHandler(BackPressureHandler bph, Class type) { + if (type.isInstance(bph)) { + return type.cast(bph); + } + if (bph instanceof CompositeBackPressureHandler cbph) { + List backPressureHandlers = (List) ReflectionTestUtils + .getField(cbph, "backPressureHandlers"); + return extractBackPressureHandler( + backPressureHandlers.stream().filter(type::isInstance).map(type::cast).findFirst().orElseThrow(), + type); + } + throw new NoSuchElementException("%s not found in %s".formatted(type.getSimpleName(), bph)); + } + // Used to slow down tests while developing private void doSleep(int time) { try { From 74dc4303af3380ab647ae735e04304abeb69c9ad Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Mon, 3 Mar 2025 15:58:38 +0100 Subject: [PATCH 09/29] Revert changes to SemaphoreBackPressureHandler not to change default behavior (#1251) --- ...tractPipelineMessageListenerContainer.java | 23 +- .../sqs/listener/ContainerOptionsBuilder.java | 56 +++- .../SemaphoreBackPressureHandler.java | 269 ++++++++++++++++++ .../AbstractPollingMessageSourceTests.java | 1 + 4 files changed, 329 insertions(+), 20 deletions(-) create mode 100644 spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java index 8a1ab1f8e..7966ae82a 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java @@ -235,25 +235,10 @@ protected BackPressureHandler createBackPressureHandler() { if (containerOptions.getBackPressureHandlerSupplier() != null) { return containerOptions.getBackPressureHandlerSupplier().get(); } - Duration acquireTimeout = containerOptions.getMaxDelayBetweenPolls(); - int batchSize = containerOptions.getMaxMessagesPerPoll(); - int maxConcurrentMessages = containerOptions.getMaxConcurrentMessages(); - var concurrencyLimiterBlockingBackPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler.builder() - .batchSize(batchSize).totalPermits(maxConcurrentMessages).acquireTimeout(acquireTimeout) - .throughputConfiguration(containerOptions.getBackPressureMode()).build(); - if (maxConcurrentMessages == batchSize) { - return concurrencyLimiterBlockingBackPressureHandler; - } - return switch (containerOptions.getBackPressureMode()) { - case FIXED_HIGH_THROUGHPUT -> concurrencyLimiterBlockingBackPressureHandler; - case ALWAYS_POLL_MAX_MESSAGES, - AUTO -> { - var throughputBackPressureHandler = ThroughputBackPressureHandler.builder().batchSize(batchSize).build(); - yield new CompositeBackPressureHandler( - List.of(concurrencyLimiterBlockingBackPressureHandler, throughputBackPressureHandler), - batchSize, containerOptions.getStandbyLimitPollingInterval()); - } - }; + return SemaphoreBackPressureHandler.builder().batchSize(getContainerOptions().getMaxMessagesPerPoll()) + .totalPermits(getContainerOptions().getMaxConcurrentMessages()) + .acquireTimeout(getContainerOptions().getMaxDelayBetweenPolls()) + .throughputConfiguration(getContainerOptions().getBackPressureMode()).build(); } protected TaskExecutor createSourcesTaskExecutor() { diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java index 08ffad263..21a8b0de8 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java @@ -157,7 +157,61 @@ default B pollBackOffPolicy(BackOffPolicy pollBackOffPolicy) { B backPressureMode(BackPressureMode backPressureMode); /** - * Set the {@link Supplier} of {@link BackPressureHandler} for this container. Default is {@code null}. + * Sets the {@link Supplier} of {@link BackPressureHandler} for this container. Default is {@code null} which + * results in a default {@link SemaphoreBackPressureHandler} to be instantiated. In case a supplier is provided, the + * {@link BackPressureHandler} will be instantiated by the supplier. + *

+ * NOTE: it is important for the supplier to always return a new instance as otherwise it might + * result in a BackPressureHandler internal resources (counters, semaphores, ...) to be shared by multiple + * containers which is very likely not the desired behavior. + *

+ * Spring Cloud AWS provides the following {@link BackPressureHandler} implementations: + *

    + *
  • {@link ConcurrencyLimiterBlockingBackPressureHandler}: Limits the maximum number of messages that can be + * processed concurrently by the application.
  • + *
  • {@link ThroughputBackPressureHandler}: Adapts the throughput dynamically between high and low modes in order + * to reduce SQS pull costs when few messages are coming in.
  • + *
  • {@link CompositeBackPressureHandler}: Allows combining multiple {@link BackPressureHandler} together and + * ensures they cooperate.
  • + *
+ *

+ * Below are a few examples of how common use cases can be achieved. Keep in mind you can always create your own + * {@link BackPressureHandler} implementation and if needed combine it with the provided ones thanks to the + * {@link CompositeBackPressureHandler}. + * + *

A BackPressureHandler limiting the max concurrency with high throughput

+ * + *
{@code
+	 * containerOptionsBuilder.backPressureHandlerSupplier(() -> {
+	 * 		return ConcurrencyLimiterBlockingBackPressureHandler.builder()
+	 * 			.batchSize(batchSize)
+	 * 			.totalPermits(maxConcurrentMessages)
+	 * 			.acquireTimeout(acquireTimeout)
+	 * 			.throughputConfiguration(BackPressureMode.FIXED_HIGH_THROUGHPUT)
+	 * 			.build()
+	 * }}
+ * + *

A BackPressureHandler limiting the max concurrency with dynamic throughput

+ * + *
{@code
+	 * containerOptionsBuilder.backPressureHandlerSupplier(() -> {
+	 * 		var concurrencyLimiterBlockingBackPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler.builder()
+	 * 			.batchSize(batchSize)
+	 * 			.totalPermits(maxConcurrentMessages)
+	 * 			.acquireTimeout(acquireTimeout)
+	 * 			.throughputConfiguration(BackPressureMode.AUTO)
+	 * 			.build()
+	 * 		var throughputBackPressureHandler = ThroughputBackPressureHandler.builder()
+	 * 			.batchSize(batchSize)
+	 * 			.build();
+	 * 		return new CompositeBackPressureHandler(List.of(
+	 * 				concurrencyLimiterBlockingBackPressureHandler,
+	 * 				throughputBackPressureHandler
+	 * 			),
+	 * 			batchSize,
+	 * 			standbyLimitPollingInterval
+	 * 		);
+	 * }}
* * @param backPressureHandlerSupplier the BackPressureHandler supplier. * @return this instance. diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java new file mode 100644 index 000000000..31617c405 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java @@ -0,0 +1,269 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.listener; + +import java.time.Duration; +import java.util.Arrays; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.Assert; + +/** + * {@link BackPressureHandler} implementation that uses a {@link Semaphore} for handling backpressure. + * + * @author Tomaz Fernandes + * @since 3.0 + * @see io.awspring.cloud.sqs.listener.source.PollingMessageSource + */ +public class SemaphoreBackPressureHandler implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { + + private static final Logger logger = LoggerFactory.getLogger(SemaphoreBackPressureHandler.class); + + private final Semaphore semaphore; + + private final int batchSize; + + private final int totalPermits; + + private final Duration acquireTimeout; + + private final BackPressureMode backPressureConfiguration; + + private volatile CurrentThroughputMode currentThroughputMode; + + private final AtomicBoolean hasAcquiredFullPermits = new AtomicBoolean(false); + + private String id; + + private SemaphoreBackPressureHandler(Builder builder) { + this.batchSize = builder.batchSize; + this.totalPermits = builder.totalPermits; + this.acquireTimeout = builder.acquireTimeout; + this.backPressureConfiguration = builder.backPressureMode; + this.semaphore = new Semaphore(totalPermits); + this.currentThroughputMode = BackPressureMode.FIXED_HIGH_THROUGHPUT.equals(backPressureConfiguration) + ? CurrentThroughputMode.HIGH + : CurrentThroughputMode.LOW; + logger.debug("SemaphoreBackPressureHandler created with configuration {} and {} total permits", + backPressureConfiguration, totalPermits); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public void setId(String id) { + this.id = id; + } + + @Override + public String getId() { + return this.id; + } + + @Override + public int request(int amount) throws InterruptedException { + return tryAcquire(amount, this.currentThroughputMode) ? amount : 0; + } + + // @formatter:off + @Override + public int requestBatch() throws InterruptedException { + return CurrentThroughputMode.LOW.equals(this.currentThroughputMode) + ? requestInLowThroughputMode() + : requestInHighThroughputMode(); + } + + private int requestInHighThroughputMode() throws InterruptedException { + return tryAcquire(this.batchSize, CurrentThroughputMode.HIGH) + ? this.batchSize + : tryAcquirePartial(); + } + // @formatter:on + + private int tryAcquirePartial() throws InterruptedException { + int availablePermits = this.semaphore.availablePermits(); + if (availablePermits == 0 || BackPressureMode.ALWAYS_POLL_MAX_MESSAGES.equals(this.backPressureConfiguration)) { + return 0; + } + int permitsToRequest = Math.min(availablePermits, this.batchSize); + CurrentThroughputMode currentThroughputModeNow = this.currentThroughputMode; + logger.trace("Trying to acquire partial batch of {} permits from {} available for {} in TM {}", + permitsToRequest, availablePermits, this.id, currentThroughputModeNow); + boolean hasAcquiredPartial = tryAcquire(permitsToRequest, currentThroughputModeNow); + return hasAcquiredPartial ? permitsToRequest : 0; + } + + private int requestInLowThroughputMode() throws InterruptedException { + // Although LTM can be set / unset by many processes, only the MessageSource thread gets here, + // so no actual concurrency + logger.debug("Trying to acquire full permits for {}. Permits left: {}", this.id, + this.semaphore.availablePermits()); + boolean hasAcquired = tryAcquire(this.totalPermits, CurrentThroughputMode.LOW); + if (hasAcquired) { + logger.debug("Acquired full permits for {}. Permits left: {}", this.id, this.semaphore.availablePermits()); + // We've acquired all permits - there's no other process currently processing messages + if (!this.hasAcquiredFullPermits.compareAndSet(false, true)) { + logger.warn("hasAcquiredFullPermits was already true. Permits left: {}", + this.semaphore.availablePermits()); + } + return this.batchSize; + } + else { + return 0; + } + } + + private boolean tryAcquire(int amount, CurrentThroughputMode currentThroughputModeNow) throws InterruptedException { + logger.trace("Acquiring {} permits for {} in TM {}", amount, this.id, this.currentThroughputMode); + boolean hasAcquired = this.semaphore.tryAcquire(amount, this.acquireTimeout.toMillis(), TimeUnit.MILLISECONDS); + if (hasAcquired) { + logger.trace("{} permits acquired for {} in TM {}. Permits left: {}", amount, this.id, + currentThroughputModeNow, this.semaphore.availablePermits()); + } + else { + logger.trace("Not able to acquire {} permits in {} milliseconds for {} in TM {}. Permits left: {}", amount, + this.acquireTimeout.toMillis(), this.id, currentThroughputModeNow, + this.semaphore.availablePermits()); + } + return hasAcquired; + } + + @Override + public void releaseBatch() { + maybeSwitchToLowThroughputMode(); + int permitsToRelease = getPermitsToRelease(this.batchSize); + this.semaphore.release(permitsToRelease); + logger.trace("Released {} permits for {}. Permits left: {}", permitsToRelease, this.id, + this.semaphore.availablePermits()); + } + + @Override + public int getBatchSize() { + return this.batchSize; + } + + private void maybeSwitchToLowThroughputMode() { + if (!BackPressureMode.FIXED_HIGH_THROUGHPUT.equals(this.backPressureConfiguration) + && CurrentThroughputMode.HIGH.equals(this.currentThroughputMode)) { + logger.debug("Entire batch of permits released for {}, setting TM LOW. Permits left: {}", this.id, + this.semaphore.availablePermits()); + this.currentThroughputMode = CurrentThroughputMode.LOW; + } + } + + @Override + public void release(int amount) { + logger.trace("Releasing {} permits for {}. Permits left: {}", amount, this.id, + this.semaphore.availablePermits()); + maybeSwitchToHighThroughputMode(amount); + int permitsToRelease = getPermitsToRelease(amount); + this.semaphore.release(permitsToRelease); + logger.trace("Released {} permits for {}. Permits left: {}", permitsToRelease, this.id, + this.semaphore.availablePermits()); + } + + @Override + public void release(int amount, ReleaseReason reason) { + if (amount == this.batchSize && reason == ReleaseReason.NONE_FETCHED) { + releaseBatch(); + } + else { + release(amount); + } + } + + private int getPermitsToRelease(int amount) { + return this.hasAcquiredFullPermits.compareAndSet(true, false) + // The first process that gets here should release all permits except for inflight messages + // We can have only one batch of messages at this point since we have all permits + ? this.totalPermits - (this.batchSize - amount) + : amount; + } + + private void maybeSwitchToHighThroughputMode(int amount) { + if (CurrentThroughputMode.LOW.equals(this.currentThroughputMode)) { + logger.debug("{} unused permit(s), setting TM HIGH for {}. Permits left: {}", amount, this.id, + this.semaphore.availablePermits()); + this.currentThroughputMode = CurrentThroughputMode.HIGH; + } + } + + @Override + public boolean drain(Duration timeout) { + logger.debug("Waiting for up to {} seconds for approx. {} permits to be released for {}", timeout.getSeconds(), + this.totalPermits - this.semaphore.availablePermits(), this.id); + try { + return this.semaphore.tryAcquire(this.totalPermits, (int) timeout.getSeconds(), TimeUnit.SECONDS); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting to acquire permits", e); + } + } + + private enum CurrentThroughputMode { + + HIGH, + + LOW; + + } + + public static class Builder { + + private int batchSize; + + private int totalPermits; + + private Duration acquireTimeout; + + private BackPressureMode backPressureMode; + + public Builder batchSize(int batchSize) { + this.batchSize = batchSize; + return this; + } + + public Builder totalPermits(int totalPermits) { + this.totalPermits = totalPermits; + return this; + } + + public Builder acquireTimeout(Duration acquireTimeout) { + this.acquireTimeout = acquireTimeout; + return this; + } + + public Builder throughputConfiguration(BackPressureMode backPressureConfiguration) { + this.backPressureMode = backPressureConfiguration; + return this; + } + + public SemaphoreBackPressureHandler build() { + Assert.noNullElements( + Arrays.asList(this.batchSize, this.totalPermits, this.acquireTimeout, this.backPressureMode), + "Missing configuration"); + return new SemaphoreBackPressureHandler(this); + } + + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java index 0d83aca27..14e80cb07 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java @@ -166,6 +166,7 @@ else if (hasMadeSecondPoll.compareAndSet(false, true)) { } catch (Throwable t) { logger.error("Error (not expecting it)", t); + errors.add(t); throw new RuntimeException(t); } }, threadPool).whenComplete((v, t) -> { From 7ed460745e53183deb08bf9178d572dd04f0946f Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Tue, 4 Mar 2025 15:29:05 +0100 Subject: [PATCH 10/29] Move SemaphoreBackPressureHandler#release(amount, reason) implementation to BatchAwareBackPressureHandler interface (#1251) --- .../sqs/listener/BatchAwareBackPressureHandler.java | 10 ++++++++++ .../sqs/listener/SemaphoreBackPressureHandler.java | 10 ---------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java index c5ccf0ba4..661b7731b 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java @@ -44,6 +44,16 @@ default void releaseBatch() { release(getBatchSize(), ReleaseReason.NONE_FETCHED); } + @Override + default void release(int amount, ReleaseReason reason) { + if (amount == getBatchSize() && reason == ReleaseReason.NONE_FETCHED) { + releaseBatch(); + } + else { + release(amount); + } + } + /** * Return the configured batch size for this handler. * @return the batch size. diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java index 31617c405..310b64519 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java @@ -180,16 +180,6 @@ public void release(int amount) { this.semaphore.availablePermits()); } - @Override - public void release(int amount, ReleaseReason reason) { - if (amount == this.batchSize && reason == ReleaseReason.NONE_FETCHED) { - releaseBatch(); - } - else { - release(amount); - } - } - private int getPermitsToRelease(int amount) { return this.hasAcquiredFullPermits.compareAndSet(true, false) // The first process that gets here should release all permits except for inflight messages From dbe37d94692a946fad4717a83e0ca7a311416253 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Thu, 8 May 2025 10:40:23 +0200 Subject: [PATCH 11/29] Address review comments --- .../listener/AbstractContainerOptions.java | 19 ------------------- ...ncyLimiterBlockingBackPressureHandler.java | 10 +++++----- .../cloud/sqs/listener/ContainerOptions.java | 6 ------ .../sqs/listener/ContainerOptionsBuilder.java | 9 --------- .../ThroughputBackPressureHandler.java | 3 +-- 5 files changed, 6 insertions(+), 41 deletions(-) diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java index c85297cbd..98e723b92 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java @@ -50,8 +50,6 @@ public abstract class AbstractContainerOptions, private final Duration maxDelayBetweenPolls; - private final Duration standbyLimitPollingInterval; - private final Duration listenerShutdownTimeout; private final Duration acknowledgementShutdownTimeout; @@ -91,7 +89,6 @@ protected AbstractContainerOptions(Builder builder) { this.autoStartup = builder.autoStartup; this.pollTimeout = builder.pollTimeout; this.pollBackOffPolicy = builder.pollBackOffPolicy; - this.standbyLimitPollingInterval = builder.standbyLimitPollingInterval; this.maxDelayBetweenPolls = builder.maxDelayBetweenPolls; this.listenerShutdownTimeout = builder.listenerShutdownTimeout; this.acknowledgementShutdownTimeout = builder.acknowledgementShutdownTimeout; @@ -137,11 +134,6 @@ public BackOffPolicy getPollBackOffPolicy() { return this.pollBackOffPolicy; } - @Override - public Duration getStandbyLimitPollingInterval() { - return this.standbyLimitPollingInterval; - } - @Override public Duration getMaxDelayBetweenPolls() { return this.maxDelayBetweenPolls; @@ -241,8 +233,6 @@ protected abstract static class Builder, private static final BackOffPolicy DEFAULT_POLL_BACK_OFF_POLICY = buildDefaultBackOffPolicy(); - private static final Duration DEFAULT_STANDBY_LIMIT_POLLING_INTERVAL = Duration.ofMillis(100); - private static final Duration DEFAULT_SEMAPHORE_TIMEOUT = Duration.ofSeconds(10); private static final Duration DEFAULT_LISTENER_SHUTDOWN_TIMEOUT = Duration.ofSeconds(20); @@ -271,8 +261,6 @@ protected abstract static class Builder, private BackOffPolicy pollBackOffPolicy = DEFAULT_POLL_BACK_OFF_POLICY; - private Duration standbyLimitPollingInterval = DEFAULT_STANDBY_LIMIT_POLLING_INTERVAL; - private Duration maxDelayBetweenPolls = DEFAULT_SEMAPHORE_TIMEOUT; private BackPressureMode backPressureMode = DEFAULT_THROUGHPUT_CONFIGURATION; @@ -367,13 +355,6 @@ public B pollBackOffPolicy(BackOffPolicy pollBackOffPolicy) { return self(); } - @Override - public B standbyLimitPollingInterval(Duration standbyLimitPollingInterval) { - Assert.notNull(standbyLimitPollingInterval, "standbyLimitPollingInterval cannot be null"); - this.standbyLimitPollingInterval = standbyLimitPollingInterval; - return self(); - } - @Override public B maxDelayBetweenPolls(Duration maxDelayBetweenPolls) { Assert.notNull(maxDelayBetweenPolls, "semaphoreAcquireTimeout cannot be null"); diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java index e389ba7c3..99a78fe04 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java @@ -43,7 +43,7 @@ public class ConcurrencyLimiterBlockingBackPressureHandler private final Duration acquireTimeout; - private final boolean alwaysPollMasMessages; + private final boolean alwaysPollMaxMessages; private String id = getClass().getSimpleName(); @@ -51,12 +51,12 @@ private ConcurrencyLimiterBlockingBackPressureHandler(Builder builder) { this.batchSize = builder.batchSize; this.totalPermits = builder.totalPermits; this.acquireTimeout = builder.acquireTimeout; - this.alwaysPollMasMessages = BackPressureMode.ALWAYS_POLL_MAX_MESSAGES.equals(builder.backPressureMode); + this.alwaysPollMaxMessages = BackPressureMode.ALWAYS_POLL_MAX_MESSAGES.equals(builder.backPressureMode); this.semaphore = new Semaphore(totalPermits); logger.debug( "ConcurrencyLimiterBlockingBackPressureHandler created with configuration " - + "totalPermits: {}, batchSize: {}, acquireTimeout: {}, an alwaysPollMasMessages: {}", - this.totalPermits, this.batchSize, this.acquireTimeout, this.alwaysPollMasMessages); + + "totalPermits: {}, batchSize: {}, acquireTimeout: {}, an alwaysPollMaxMessages: {}", + this.totalPermits, this.batchSize, this.acquireTimeout, this.alwaysPollMaxMessages); } public static Builder builder() { @@ -81,7 +81,7 @@ public int requestBatch() throws InterruptedException { @Override public int request(int amount) throws InterruptedException { int acquiredPermits = tryAcquire(amount, this.acquireTimeout); - if (alwaysPollMasMessages || acquiredPermits > 0) { + if (alwaysPollMaxMessages || acquiredPermits > 0) { return acquiredPermits; } int availablePermits = Math.min(this.semaphore.availablePermits(), amount); diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java index 82dc85644..b12d7ebb7 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java @@ -61,12 +61,6 @@ public interface ContainerOptions, B extends Co */ boolean isAutoStartup(); - /** - * {@return the amount of time to wait before checking again for the current limit when the queue processing is on - * standby} Default is 100 milliseconds. - */ - Duration getStandbyLimitPollingInterval(); - /** * Sets the maximum time the polling thread should wait for a full batch of permits to be available before trying to * acquire a partial batch if so configured. A poll is only actually executed if at least one permit is available. diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java index 21a8b0de8..5335b202e 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java @@ -58,15 +58,6 @@ public interface ContainerOptionsBuilder */ B autoStartup(boolean autoStartup); - /** - * Sets the amount of time to wait before checking again for the current limit when the queue processing is on - * standby. - * - * @param standbyLimitPollingInterval the limit polling interval when the queue processing is on standby. - * @return this instance. - */ - B standbyLimitPollingInterval(Duration standbyLimitPollingInterval); - /** * Set the maximum time the polling thread should wait for a full batch of permits to be available before trying to * acquire a partial batch if so configured. A poll is only actually executed if at least one permit is available. diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java index 3ef1410d9..2dfeacdd8 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java @@ -17,7 +17,6 @@ import io.awspring.cloud.sqs.listener.source.PollingMessageSource; import java.time.Duration; -import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -146,7 +145,7 @@ public Builder batchSize(int batchSize) { } public ThroughputBackPressureHandler build() { - Assert.noNullElements(List.of(this.batchSize), "Missing configuration"); + Assert.notNull(this.batchSize, "Missing configuration"); Assert.isTrue(this.batchSize > 0, "batch size must be greater than 0"); return new ThroughputBackPressureHandler(this); } From df7a9af45b7a72b8b88fbe60b10c6e48fe173fc3 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Thu, 8 May 2025 17:11:12 +0200 Subject: [PATCH 12/29] Introduce a BackPressureHandlerFactory for configuring SQS back pressure (#1251) --- .../listener/AbstractContainerOptions.java | 25 +- ...tractPipelineMessageListenerContainer.java | 9 +- .../listener/BackPressureHandlerFactory.java | 85 ++++ .../cloud/sqs/listener/ContainerOptions.java | 7 +- .../sqs/listener/ContainerOptionsBuilder.java | 63 +-- .../ThroughputBackPressureHandler.java | 8 +- .../SqsBackPressureIntegrationTests.java | 11 +- .../AbstractPollingMessageSourceTests.java | 72 ++- ...dlerAbstractPollingMessageSourceTests.java | 445 ++++++++++++++++++ 9 files changed, 594 insertions(+), 131 deletions(-) create mode 100644 spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java create mode 100644 spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/SemaphoreBackPressureHandlerAbstractPollingMessageSourceTests.java diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java index 98e723b92..8c6e3bcb9 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java @@ -22,7 +22,6 @@ import io.micrometer.observation.ObservationConvention; import io.micrometer.observation.ObservationRegistry; import java.time.Duration; -import java.util.function.Supplier; import org.springframework.core.task.TaskExecutor; import org.springframework.lang.Nullable; import org.springframework.retry.backoff.BackOffPolicy; @@ -56,7 +55,7 @@ public abstract class AbstractContainerOptions, private final BackPressureMode backPressureMode; - private final Supplier backPressureHandlerSupplier; + private final BackPressureHandlerFactory backPressureHandlerFactory; private final ListenerMode listenerMode; @@ -93,7 +92,7 @@ protected AbstractContainerOptions(Builder builder) { this.listenerShutdownTimeout = builder.listenerShutdownTimeout; this.acknowledgementShutdownTimeout = builder.acknowledgementShutdownTimeout; this.backPressureMode = builder.backPressureMode; - this.backPressureHandlerSupplier = builder.backPressureHandlerSupplier; + this.backPressureHandlerFactory = builder.backPressureHandlerFactory; this.listenerMode = builder.listenerMode; this.messageConverter = builder.messageConverter; this.acknowledgementMode = builder.acknowledgementMode; @@ -167,8 +166,8 @@ public BackPressureMode getBackPressureMode() { } @Override - public Supplier getBackPressureHandlerSupplier() { - return this.backPressureHandlerSupplier; + public BackPressureHandlerFactory getBackPressureHandlerFactory() { + return this.backPressureHandlerFactory; } @Override @@ -241,7 +240,7 @@ protected abstract static class Builder, private static final BackPressureMode DEFAULT_THROUGHPUT_CONFIGURATION = BackPressureMode.AUTO; - private static final Supplier DEFAULT_BACKPRESSURE_LIMITER = null; + private static final BackPressureHandlerFactory DEFAULT_BACKPRESSURE_FACTORY = buildDefaultBackPressureHandlerFactory(); private static final ListenerMode DEFAULT_MESSAGE_DELIVERY_STRATEGY = ListenerMode.SINGLE_MESSAGE; @@ -265,7 +264,7 @@ protected abstract static class Builder, private BackPressureMode backPressureMode = DEFAULT_THROUGHPUT_CONFIGURATION; - private Supplier backPressureHandlerSupplier = DEFAULT_BACKPRESSURE_LIMITER; + private BackPressureHandlerFactory backPressureHandlerFactory = DEFAULT_BACKPRESSURE_FACTORY; private Duration listenerShutdownTimeout = DEFAULT_LISTENER_SHUTDOWN_TIMEOUT; @@ -309,7 +308,7 @@ protected Builder(AbstractContainerOptions options) { this.listenerShutdownTimeout = options.listenerShutdownTimeout; this.acknowledgementShutdownTimeout = options.acknowledgementShutdownTimeout; this.backPressureMode = options.backPressureMode; - this.backPressureHandlerSupplier = options.backPressureHandlerSupplier; + this.backPressureHandlerFactory = options.backPressureHandlerFactory; this.listenerMode = options.listenerMode; this.messageConverter = options.messageConverter; this.acknowledgementMode = options.acknowledgementMode; @@ -405,8 +404,8 @@ public B backPressureMode(BackPressureMode backPressureMode) { } @Override - public B backPressureHandlerSupplier(Supplier backPressureHandlerSupplier) { - this.backPressureHandlerSupplier = backPressureHandlerSupplier; + public B backPressureHandlerFactory(BackPressureHandlerFactory backPressureHandlerFactory) { + this.backPressureHandlerFactory = backPressureHandlerFactory; return self(); } @@ -468,6 +467,12 @@ private static BackOffPolicy buildDefaultBackOffPolicy() { return BackOffPolicyBuilder.newBuilder().multiplier(DEFAULT_BACK_OFF_MULTIPLIER) .delay(DEFAULT_BACK_OFF_DELAY).maxDelay(DEFAULT_BACK_OFF_MAX_DELAY).build(); } + + private static BackPressureHandlerFactory buildDefaultBackPressureHandlerFactory() { + return options -> SemaphoreBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()) + .totalPermits(options.getMaxConcurrentMessages()).acquireTimeout(options.getMaxDelayBetweenPolls()) + .throughputConfiguration(options.getBackPressureMode()).build(); + } } } diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java index 7966ae82a..47b0bc8ff 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java @@ -232,13 +232,8 @@ private TaskExecutor validateCustomExecutor(TaskExecutor taskExecutor) { protected BackPressureHandler createBackPressureHandler() { O containerOptions = getContainerOptions(); - if (containerOptions.getBackPressureHandlerSupplier() != null) { - return containerOptions.getBackPressureHandlerSupplier().get(); - } - return SemaphoreBackPressureHandler.builder().batchSize(getContainerOptions().getMaxMessagesPerPoll()) - .totalPermits(getContainerOptions().getMaxConcurrentMessages()) - .acquireTimeout(getContainerOptions().getMaxDelayBetweenPolls()) - .throughputConfiguration(getContainerOptions().getBackPressureMode()).build(); + BackPressureHandlerFactory factory = containerOptions.getBackPressureHandlerFactory(); + return factory.createBackPressureHandler(containerOptions); } protected TaskExecutor createSourcesTaskExecutor() { diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java new file mode 100644 index 000000000..599fa9b49 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java @@ -0,0 +1,85 @@ +/* + * Copyright 2013-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.listener; + +/** + * A factory for creating {@link BackPressureHandler} for managing queue consumption backpressure. Implementations can + * configure each the {@link BackPressureHandler} according to its strategies, using the provided + * {@link ContainerOptions}. + *

+ * Spring Cloud AWS provides the following {@link BackPressureHandler} implementations: + *

    + *
  • {@link ConcurrencyLimiterBlockingBackPressureHandler}: Limits the maximum number of messages that can be + * processed concurrently by the application.
  • + *
  • {@link ThroughputBackPressureHandler}: Adapts the throughput dynamically between high and low modes in order to + * reduce SQS pull costs when few messages are coming in.
  • + *
  • {@link CompositeBackPressureHandler}: Allows combining multiple {@link BackPressureHandler} together and ensures + * they cooperate.
  • + *
+ *

+ * Below are a few examples of how common use cases can be achieved. Keep in mind you can always create your own + * {@link BackPressureHandler} implementation and if needed combine it with the provided ones thanks to the + * {@link CompositeBackPressureHandler}. + * + *

A BackPressureHandler limiting the max concurrency with high throughput

+ * + *
{@code
+ * containerOptionsBuilder.backPressureHandlerFactory(containerOptions -> {
+ * 		return ConcurrencyLimiterBlockingBackPressureHandler.builder()
+ * 			.batchSize(containerOptions.getMaxMessagesPerPoll())
+ * 			.totalPermits(containerOptions.getMaxConcurrentMessages())
+ * 			.acquireTimeout(containerOptions.getMaxDelayBetweenPolls())
+ * 			.throughputConfiguration(BackPressureMode.FIXED_HIGH_THROUGHPUT)
+ * 			.build()
+ * }}
+ * + *

A BackPressureHandler limiting the max concurrency with dynamic throughput

+ * + *
{@code
+ * containerOptionsBuilder.backPressureHandlerFactory(containerOptions -> {
+ * 		int batchSize = containerOptions.getMaxMessagesPerPoll();
+ * 		var concurrencyLimiterBlockingBackPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler.builder()
+ * 			.batchSize(batchSize)
+ * 			.totalPermits(containerOptions.getMaxConcurrentMessages())
+ * 			.acquireTimeout(containerOptions.getMaxDelayBetweenPolls())
+ * 			.throughputConfiguration(BackPressureMode.AUTO)
+ * 			.build()
+ * 		var throughputBackPressureHandler = ThroughputBackPressureHandler.builder()
+ * 			.batchSize(batchSize)
+ * 			.build();
+ * 		return new CompositeBackPressureHandler(List.of(
+ * 				concurrencyLimiterBlockingBackPressureHandler,
+ * 				throughputBackPressureHandler
+ * 			),
+ * 			batchSize,
+ * 			standbyLimitPollingInterval
+ * 		);
+ * }}
+ */ +public interface BackPressureHandlerFactory { + + /** + * Creates a new {@link BackPressureHandler} instance based on the provided {@link ContainerOptions}. + *

+ * NOTE: it is important for the factory to always return a new instance as otherwise it might + * result in a BackPressureHandler internal resources (counters, semaphores, ...) to be shared by multiple + * containers which is very likely not the desired behavior. + * + * @param containerOptions the container options to use for creating the BackPressureHandler. + * @return the created BackPressureHandler + */ + BackPressureHandler createBackPressureHandler(ContainerOptions containerOptions); +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java index b12d7ebb7..f1a324a30 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java @@ -22,7 +22,6 @@ import io.micrometer.observation.ObservationRegistry; import java.time.Duration; import java.util.Collection; -import java.util.function.Supplier; import org.springframework.core.task.TaskExecutor; import org.springframework.lang.Nullable; import org.springframework.retry.backoff.BackOffPolicy; @@ -131,10 +130,10 @@ default BackOffPolicy getPollBackOffPolicy() { BackPressureMode getBackPressureMode(); /** - * Return the a {@link Supplier} to create a {@link BackPressureHandler} for this container. - * @return the BackPressureHandler supplier. + * Return the a {@link BackPressureHandlerFactory} to create a {@link BackPressureHandler} for this container. + * @return the BackPressureHandlerFactory. */ - Supplier getBackPressureHandlerSupplier(); + BackPressureHandlerFactory getBackPressureHandlerFactory(); /** * Return the {@link ListenerMode} mode for this container. diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java index 5335b202e..2a9ee9ee5 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptionsBuilder.java @@ -20,7 +20,6 @@ import io.awspring.cloud.sqs.support.converter.MessagingMessageConverter; import io.micrometer.observation.ObservationRegistry; import java.time.Duration; -import java.util.function.Supplier; import org.springframework.core.task.TaskExecutor; import org.springframework.retry.backoff.BackOffPolicy; @@ -148,66 +147,14 @@ default B pollBackOffPolicy(BackOffPolicy pollBackOffPolicy) { B backPressureMode(BackPressureMode backPressureMode); /** - * Sets the {@link Supplier} of {@link BackPressureHandler} for this container. Default is {@code null} which - * results in a default {@link SemaphoreBackPressureHandler} to be instantiated. In case a supplier is provided, the - * {@link BackPressureHandler} will be instantiated by the supplier. - *

- * NOTE: it is important for the supplier to always return a new instance as otherwise it might - * result in a BackPressureHandler internal resources (counters, semaphores, ...) to be shared by multiple - * containers which is very likely not the desired behavior. - *

- * Spring Cloud AWS provides the following {@link BackPressureHandler} implementations: - *

    - *
  • {@link ConcurrencyLimiterBlockingBackPressureHandler}: Limits the maximum number of messages that can be - * processed concurrently by the application.
  • - *
  • {@link ThroughputBackPressureHandler}: Adapts the throughput dynamically between high and low modes in order - * to reduce SQS pull costs when few messages are coming in.
  • - *
  • {@link CompositeBackPressureHandler}: Allows combining multiple {@link BackPressureHandler} together and - * ensures they cooperate.
  • - *
- *

- * Below are a few examples of how common use cases can be achieved. Keep in mind you can always create your own - * {@link BackPressureHandler} implementation and if needed combine it with the provided ones thanks to the - * {@link CompositeBackPressureHandler}. + * Sets the {@link BackPressureHandlerFactory} for this container. Default is + * {@code AbstractContainerOptions.DEFAULT_BACKPRESSURE_FACTORY} which results in a default + * {@link SemaphoreBackPressureHandler} to be instantiated. * - *

A BackPressureHandler limiting the max concurrency with high throughput

- * - *
{@code
-	 * containerOptionsBuilder.backPressureHandlerSupplier(() -> {
-	 * 		return ConcurrencyLimiterBlockingBackPressureHandler.builder()
-	 * 			.batchSize(batchSize)
-	 * 			.totalPermits(maxConcurrentMessages)
-	 * 			.acquireTimeout(acquireTimeout)
-	 * 			.throughputConfiguration(BackPressureMode.FIXED_HIGH_THROUGHPUT)
-	 * 			.build()
-	 * }}
- * - *

A BackPressureHandler limiting the max concurrency with dynamic throughput

- * - *
{@code
-	 * containerOptionsBuilder.backPressureHandlerSupplier(() -> {
-	 * 		var concurrencyLimiterBlockingBackPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler.builder()
-	 * 			.batchSize(batchSize)
-	 * 			.totalPermits(maxConcurrentMessages)
-	 * 			.acquireTimeout(acquireTimeout)
-	 * 			.throughputConfiguration(BackPressureMode.AUTO)
-	 * 			.build()
-	 * 		var throughputBackPressureHandler = ThroughputBackPressureHandler.builder()
-	 * 			.batchSize(batchSize)
-	 * 			.build();
-	 * 		return new CompositeBackPressureHandler(List.of(
-	 * 				concurrencyLimiterBlockingBackPressureHandler,
-	 * 				throughputBackPressureHandler
-	 * 			),
-	 * 			batchSize,
-	 * 			standbyLimitPollingInterval
-	 * 		);
-	 * }}
- * - * @param backPressureHandlerSupplier the BackPressureHandler supplier. + * @param backPressureHandlerFactory the BackPressureHandler supplier. * @return this instance. */ - B backPressureHandlerSupplier(Supplier backPressureHandlerSupplier); + B backPressureHandlerFactory(BackPressureHandlerFactory backPressureHandlerFactory); /** * Set the maximum interval between acknowledgements for batch acknowledgements. The default depends on the specific diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java index 2dfeacdd8..d8e6abd20 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java @@ -25,12 +25,12 @@ import org.springframework.util.Assert; /** - * {@link BackPressureHandler} implementation that uses a switches between high and low throughput modes. + * {@link BackPressureHandler} implementation that uses a switch between high and low throughput modes. *

* The initial throughput mode is low, which means, only one batch at a time can be requested. If some messages are - * fetched, then the throughput mode is switched to high, which means, the multiple batches can be requested (i.e. there - * is no need to wait for the previous batch's processing to complete before requesting a new one). If no messages are - * returned fetched by a poll, the throughput mode is switched back to low. + * fetched, then the throughput mode is switched to high, which means that multiple batches can be requested (i.e., + * there is no need to wait for the previous batch's processing to complete before requesting a new one). If no messages + * are returned fetched by a poll, the throughput mode is switched back to low. *

* This {@link BackPressureHandler} is designed to be used in combination with another {@link BackPressureHandler} like * the {@link ConcurrencyLimiterBlockingBackPressureHandler} that will handle the maximum concurrency level within the diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java index 8038f70d2..2ef745185 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java @@ -132,7 +132,7 @@ void staticBackPressureLimitShouldCapQueueProcessingCapacity(int staticLimit, in .queueNames( queueName) .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler( + .backPressureHandlerFactory(containerOptions -> new CompositeBackPressureHandler( List.of(limiter, ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(5) .totalPermits(5).acquireTimeout(Duration.ofSeconds(1L)) @@ -172,7 +172,7 @@ void zeroBackPressureLimitShouldStopQueueProcessing() throws Exception { .queueNames( queueName) .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler( + .backPressureHandlerFactory(containerOptions -> new CompositeBackPressureHandler( List.of(limiter, ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(5) .totalPermits(5).acquireTimeout(Duration.ofSeconds(1L)) @@ -218,7 +218,7 @@ void changeInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Except .queueNames( queueName) .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .backPressureHandlerSupplier(() -> new CompositeBackPressureHandler( + .backPressureHandlerFactory(containerOptions -> new CompositeBackPressureHandler( List.of(limiter, ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(5) .totalPermits(5).acquireTimeout(Duration.ofSeconds(1L)) @@ -442,9 +442,8 @@ void unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity( EventsCsvWriter eventsCsvWriter = new EventsCsvWriter(); var container = SqsMessageListenerContainer.builder().sqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()) .queueNames(queueName) - .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .standbyLimitPollingInterval(Duration.ofMillis(1)) - .backPressureHandlerSupplier(() -> new StatisticsBphDecorator(new CompositeBackPressureHandler( + .configure(options -> options.pollTimeout(Duration.ofSeconds(1)).backPressureHandlerFactory( + containerOptions -> new StatisticsBphDecorator(new CompositeBackPressureHandler( List.of(limiter, ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(10) .totalPermits(10).acquireTimeout(Duration.ofSeconds(1L)) diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java index 14e80cb07..ba0cb8728 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java @@ -115,10 +115,9 @@ protected CompletableFuture> doPollForMessages(int messagesT void shouldAdaptThroughputMode() { String testName = "shouldAdaptThroughputMode"; - int totalPermits = 20; int batchSize = 10; var concurrencyLimiterBlockingBackPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler.builder() - .batchSize(batchSize).totalPermits(totalPermits) + .batchSize(batchSize).totalPermits(batchSize) .throughputConfiguration(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) .acquireTimeout(Duration.ofSeconds(5L)).build(); var throughputBackPressureHandler = ThroughputBackPressureHandler.builder().batchSize(batchSize).build(); @@ -132,37 +131,34 @@ void shouldAdaptThroughputMode() { AbstractPollingMessageSource source = new AbstractPollingMessageSource<>() { - private final AtomicBoolean hasReceived = new AtomicBoolean(false); - - private final AtomicBoolean hasMadeSecondPoll = new AtomicBoolean(false); + private final AtomicInteger pollAttemptCounter = new AtomicInteger(0); @Override protected CompletableFuture> doPollForMessages(int messagesToRequest) { return CompletableFuture.supplyAsync(() -> { try { - // Since BackPressureMode.ALWAYS_POLL_MAX_MESSAGES, should always be 10. - assertThat(messagesToRequest).isEqualTo(10); - // assertAvailablePermits(backPressureHandler, 10); - boolean firstPoll = hasReceived.compareAndSet(false, true); - if (firstPoll) { - logger.warn("First poll"); - // No permits released yet, should be TM low + int pollAttempt = pollAttemptCounter.incrementAndGet(); + logger.debug("Poll attempt {}", pollAttempt); + if (pollAttempt == 1) { + // Initial poll; throughput mode should be low assertThroughputMode(backPressureHandler, "low"); + // Since no permits were acquired yet, should be 10 + assertThat(messagesToRequest).isEqualTo(10); + return (Collection) List.of( + Message.builder().messageId(UUID.randomUUID().toString()).body("message").build()); } - else if (hasMadeSecondPoll.compareAndSet(false, true)) { - logger.warn("Second poll"); - // Permits returned, should be high + else if (pollAttempt == 2) { + // Messages returned in the previous poll; throughput mode should be high assertThroughputMode(backPressureHandler, "high"); + // Since throughput mode is high, should be 10 + assertThat(messagesToRequest).isEqualTo(10); + return Collections. emptyList(); } else { - logger.warn("Third poll"); - // Already returned full permits, should be low + // No Messages returned in the previous poll; throughput mode should be low assertThroughputMode(backPressureHandler, "low"); + return Collections. emptyList(); } - return firstPoll - ? (Collection) List.of(Message.builder() - .messageId(UUID.randomUUID().toString()).body("message").build()) - : Collections. emptyList(); } catch (Throwable t) { logger.error("Error (not expecting it)", t); @@ -171,11 +167,11 @@ else if (hasMadeSecondPoll.compareAndSet(false, true)) { } }, threadPool).whenComplete((v, t) -> { if (t == null) { - logger.warn("pas boom", t); + logger.warn("Polling succeeded", t); pollingCounter.countDown(); } else { - logger.warn("BOOOOOOOM", t); + logger.warn("Polling failed with error", t); errors.add(t); } }); @@ -193,7 +189,6 @@ else if (hasMadeSecondPoll.compareAndSet(false, true)) { source.setTaskExecutor(createTaskExecutor(testName)); source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); try { - logger.warn("Yolo, let's start"); source.start(); assertThat(doAwait(pollingCounter)).isTrue(); assertThat(doAwait(processingCounter)).isTrue(); @@ -201,6 +196,7 @@ else if (hasMadeSecondPoll.compareAndSet(false, true)) { } finally { source.stop(); + threadPool.shutdownNow(); } } @@ -221,47 +217,38 @@ void shouldAcquireAndReleasePartialPermits() { AbstractPollingMessageSource source = new AbstractPollingMessageSource<>() { - private final AtomicBoolean hasReceived = new AtomicBoolean(false); - - private final AtomicBoolean hasAcquired9 = new AtomicBoolean(false); + private final AtomicInteger pollAttemptCounter = new AtomicInteger(0); @Override protected CompletableFuture> doPollForMessages(int messagesToRequest) { return CompletableFuture.supplyAsync(() -> { try { - // Give it some time between returning empty and polling again - // doSleep(100); - - // Will only be true the first time it sets hasReceived to true - boolean shouldReturnMessage = hasReceived.compareAndSet(false, true); - if (shouldReturnMessage) { + int pollAttempt = pollAttemptCounter.incrementAndGet(); + if (pollAttempt == 1) { // First poll, should have 10 logger.debug("First poll - should request 10 messages"); assertThat(messagesToRequest).isEqualTo(10); assertAvailablePermits(backPressureHandler, 0); // No permits have been released yet + return (Collection) List.of( + Message.builder().messageId(UUID.randomUUID().toString()).body("message").build()); } - else if (hasAcquired9.compareAndSet(false, true)) { + else if (pollAttempt == 2) { // Second poll, should have 9 logger.debug("Second poll - should request 9 messages"); assertThat(messagesToRequest).isEqualTo(9); assertAvailablePermitsLessThanOrEqualTo(backPressureHandler, 1); // Has released 9 permits processingLatch.countDown(); // Release processing now + return Collections. emptyList(); } else { // Third poll or later, should have 10 again - logger.debug("Third poll - should request 10 messages"); + logger.debug("Third (or later) poll - should request 10 messages"); assertThat(messagesToRequest).isEqualTo(10); assertAvailablePermits(backPressureHandler, 0); + return Collections. emptyList(); } - if (shouldReturnMessage) { - logger.debug("shouldReturnMessage, returning one message"); - return (Collection) List.of( - Message.builder().messageId(UUID.randomUUID().toString()).body("message").build()); - } - logger.debug("should not return message, returning empty list"); - return Collections. emptyList(); } catch (Error e) { hasThrownError.set(true); @@ -289,6 +276,7 @@ else if (hasAcquired9.compareAndSet(false, true)) { assertThat(doAwait(pollingCounter)).isTrue(); source.stop(); assertThat(hasThrownError.get()).isFalse(); + threadPool.shutdownNow(); } @Test diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/SemaphoreBackPressureHandlerAbstractPollingMessageSourceTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/SemaphoreBackPressureHandlerAbstractPollingMessageSourceTests.java new file mode 100644 index 000000000..4f3457914 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/SemaphoreBackPressureHandlerAbstractPollingMessageSourceTests.java @@ -0,0 +1,445 @@ +/* + * Copyright 2013-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.listener.source; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; + +import io.awspring.cloud.sqs.MessageExecutionThreadFactory; +import io.awspring.cloud.sqs.listener.BackPressureMode; +import io.awspring.cloud.sqs.listener.SemaphoreBackPressureHandler; +import io.awspring.cloud.sqs.listener.SqsContainerOptions; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementProcessor; +import io.awspring.cloud.sqs.support.converter.MessageConversionContext; +import io.awspring.cloud.sqs.support.converter.SqsMessagingMessageConverter; +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.task.TaskExecutor; +import org.springframework.lang.Nullable; +import org.springframework.retry.backoff.BackOffContext; +import org.springframework.retry.backoff.BackOffPolicy; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.test.util.ReflectionTestUtils; +import software.amazon.awssdk.services.sqs.model.Message; + +/** + * @author Tomaz Fernandes + */ +class SemaphoreBackPressureHandlerAbstractPollingMessageSourceTests { + + private static final Logger logger = LoggerFactory.getLogger(AbstractPollingMessageSourceTests.class); + + @Test + void shouldAcquireAndReleaseFullPermits() { + String testName = "shouldAcquireAndReleaseFullPermits"; + + SemaphoreBackPressureHandler backPressureHandler = SemaphoreBackPressureHandler.builder() + .acquireTimeout(Duration.ofMillis(200)).batchSize(10).totalPermits(10) + .throughputConfiguration(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES).build(); + ExecutorService threadPool = Executors.newCachedThreadPool(); + CountDownLatch pollingCounter = new CountDownLatch(3); + CountDownLatch processingCounter = new CountDownLatch(1); + + AbstractPollingMessageSource source = new AbstractPollingMessageSource<>() { + + private final AtomicBoolean hasReceived = new AtomicBoolean(false); + + private final AtomicBoolean hasMadeSecondPoll = new AtomicBoolean(false); + + @Override + protected CompletableFuture> doPollForMessages(int messagesToRequest) { + return CompletableFuture.supplyAsync(() -> { + try { + // Since BackPressureMode.ALWAYS_POLL_MAX_MESSAGES, should always be 10. + assertThat(messagesToRequest).isEqualTo(10); + assertAvailablePermits(backPressureHandler, 0); + boolean firstPoll = hasReceived.compareAndSet(false, true); + if (firstPoll) { + logger.debug("First poll"); + // No permits released yet, should be TM low + assertThroughputMode(backPressureHandler, "low"); + } + else if (hasMadeSecondPoll.compareAndSet(false, true)) { + logger.debug("Second poll"); + // Permits returned, should be high + assertThroughputMode(backPressureHandler, "high"); + } + else { + logger.debug("Third poll"); + // Already returned full permits, should be low + assertThroughputMode(backPressureHandler, "low"); + } + return firstPoll + ? (Collection) List.of(Message.builder() + .messageId(UUID.randomUUID().toString()).body("message").build()) + : Collections. emptyList(); + } + catch (Throwable t) { + logger.error("Error", t); + throw new RuntimeException(t); + } + }, threadPool).whenComplete((v, t) -> { + if (t == null) { + pollingCounter.countDown(); + } + }); + } + }; + + source.setBackPressureHandler(backPressureHandler); + source.setMessageSink((msgs, context) -> { + assertAvailablePermits(backPressureHandler, 9); + msgs.forEach(msg -> context.runBackPressureReleaseCallback()); + return CompletableFuture.runAsync(processingCounter::countDown); + }); + + source.setId(testName + " source"); + source.configure(SqsContainerOptions.builder().build()); + source.setTaskExecutor(createTaskExecutor(testName)); + source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); + source.start(); + assertThat(doAwait(pollingCounter)).isTrue(); + assertThat(doAwait(processingCounter)).isTrue(); + } + + private static final AtomicInteger testCounter = new AtomicInteger(); + + @Test + void shouldAcquireAndReleasePartialPermits() { + String testName = "shouldAcquireAndReleasePartialPermits"; + SemaphoreBackPressureHandler backPressureHandler = SemaphoreBackPressureHandler.builder() + .acquireTimeout(Duration.ofMillis(150)).batchSize(10).totalPermits(10) + .throughputConfiguration(BackPressureMode.AUTO).build(); + ExecutorService threadPool = Executors + .newCachedThreadPool(new MessageExecutionThreadFactory("test " + testCounter.incrementAndGet())); + CountDownLatch pollingCounter = new CountDownLatch(4); + CountDownLatch processingCounter = new CountDownLatch(1); + CountDownLatch processingLatch = new CountDownLatch(1); + AtomicBoolean hasThrownError = new AtomicBoolean(false); + + AbstractPollingMessageSource source = new AbstractPollingMessageSource<>() { + + private final AtomicBoolean hasReceived = new AtomicBoolean(false); + + private final AtomicBoolean hasAcquired9 = new AtomicBoolean(false); + + private final AtomicBoolean hasMadeThirdPoll = new AtomicBoolean(false); + + @Override + protected CompletableFuture> doPollForMessages(int messagesToRequest) { + return CompletableFuture.supplyAsync(() -> { + try { + // Give it some time between returning empty and polling again + // doSleep(100); + + // Will only be true the first time it sets hasReceived to true + boolean shouldReturnMessage = hasReceived.compareAndSet(false, true); + if (shouldReturnMessage) { + // First poll, should have 10 + logger.debug("First poll - should request 10 messages"); + assertThat(messagesToRequest).isEqualTo(10); + assertAvailablePermits(backPressureHandler, 0); + // No permits have been released yet + assertThroughputMode(backPressureHandler, "low"); + } + else if (hasAcquired9.compareAndSet(false, true)) { + // Second poll, should have 9 + logger.debug("Second poll - should request 9 messages"); + assertThat(messagesToRequest).isEqualTo(9); + assertAvailablePermitsLessThanOrEqualTo(backPressureHandler, 1); + // Has released 9 permits, should be TM HIGH + assertThroughputMode(backPressureHandler, "high"); + processingLatch.countDown(); // Release processing now + } + else { + boolean thirdPoll = hasMadeThirdPoll.compareAndSet(false, true); + // Third poll or later, should have 10 again + logger.debug("Third poll - should request 10 messages"); + assertThat(messagesToRequest).isEqualTo(10); + assertAvailablePermits(backPressureHandler, 0); + if (thirdPoll) { + // Hasn't yet returned a full batch, should be TM High + assertThroughputMode(backPressureHandler, "high"); + } + else { + // Has returned all permits in third poll + assertThroughputMode(backPressureHandler, "low"); + } + } + if (shouldReturnMessage) { + logger.debug("shouldReturnMessage, returning one message"); + return (Collection) List.of( + Message.builder().messageId(UUID.randomUUID().toString()).body("message").build()); + } + logger.debug("should not return message, returning empty list"); + return Collections. emptyList(); + } + catch (Error e) { + hasThrownError.set(true); + throw new RuntimeException("Error polling for messages", e); + } + }, threadPool).whenComplete((v, t) -> pollingCounter.countDown()); + } + }; + + source.setBackPressureHandler(backPressureHandler); + source.setMessageSink((msgs, context) -> { + logger.debug("Processing {} messages", msgs.size()); + assertAvailablePermits(backPressureHandler, 9); + assertThat(doAwait(processingLatch)).isTrue(); + logger.debug("Finished processing {} messages", msgs.size()); + msgs.forEach(msg -> context.runBackPressureReleaseCallback()); + return CompletableFuture.completedFuture(null).thenRun(processingCounter::countDown); + }); + source.setId(testName + " source"); + source.configure(SqsContainerOptions.builder().build()); + source.setTaskExecutor(createTaskExecutor(testName)); + source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); + source.start(); + assertThat(doAwait(processingCounter)).isTrue(); + assertThat(doAwait(pollingCounter)).isTrue(); + source.stop(); + assertThat(hasThrownError.get()).isFalse(); + } + + @Test + void shouldReleasePermitsOnConversionErrors() { + String testName = "shouldReleasePermitsOnConversionErrors"; + SemaphoreBackPressureHandler backPressureHandler = SemaphoreBackPressureHandler.builder() + .acquireTimeout(Duration.ofMillis(150)).batchSize(10).totalPermits(10) + .throughputConfiguration(BackPressureMode.AUTO).build(); + + AtomicInteger convertedMessages = new AtomicInteger(0); + AtomicInteger messagesInSink = new AtomicInteger(0); + AtomicBoolean hasFailed = new AtomicBoolean(false); + + var converter = new SqsMessagingMessageConverter() { + @Override + public org.springframework.messaging.Message toMessagingMessage(Message source, + @Nullable MessageConversionContext context) { + var converted = convertedMessages.incrementAndGet(); + logger.trace("Messages converted: {}", converted); + if (converted % 9 == 0) { + throw new RuntimeException("Expected error"); + } + return super.toMessagingMessage(source, context); + } + }; + + AbstractPollingMessageSource source = new AbstractPollingMessageSource<>() { + + @Override + protected CompletableFuture> doPollForMessages(int messagesToRequest) { + if (messagesToRequest != 10) { + logger.error("Expected 10 messages to requesst, received {}", messagesToRequest); + hasFailed.set(true); + } + return convertedMessages.get() < 30 ? CompletableFuture.completedFuture(create10Messages()) + : CompletableFuture.completedFuture(List.of()); + } + + private Collection create10Messages() { + return IntStream.range(0, 10).mapToObj( + index -> Message.builder().messageId(UUID.randomUUID().toString()).body("test-message").build()) + .toList(); + } + }; + + source.setBackPressureHandler(backPressureHandler); + source.setMessageSink((msgs, context) -> { + msgs.forEach(message -> messagesInSink.incrementAndGet()); + msgs.forEach(msg -> context.runBackPressureReleaseCallback()); + return CompletableFuture.completedFuture(null); + }); + source.setId(testName + " source"); + source.configure(SqsContainerOptions.builder().messageConverter(converter).build()); + source.setPollingEndpointName("shouldReleasePermitsOnConversionErrors-queue"); + source.setTaskExecutor(createTaskExecutor(testName)); + source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); + source.start(); + Awaitility.waitAtMost(Duration.ofSeconds(10)).until(() -> convertedMessages.get() == 30); + assertThat(hasFailed).isFalse(); + assertThat(messagesInSink).hasValue(27); + source.stop(); + } + + @Test + void shouldBackOffIfPollingThrowsAnError() { + + var testName = "shouldBackOffIfPollingThrowsAnError"; + + var backPressureHandler = SemaphoreBackPressureHandler.builder().acquireTimeout(Duration.ofMillis(200)) + .batchSize(10).totalPermits(40).throughputConfiguration(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) + .build(); + var currentPoll = new AtomicInteger(0); + var waitThirdPollLatch = new CountDownLatch(4); + + AbstractPollingMessageSource source = new AbstractPollingMessageSource<>() { + @Override + protected CompletableFuture> doPollForMessages(int messagesToRequest) { + waitThirdPollLatch.countDown(); + if (currentPoll.compareAndSet(0, 1)) { + logger.debug("First poll - returning empty list"); + return CompletableFuture.completedFuture(List.of()); + } + else if (currentPoll.compareAndSet(1, 2)) { + logger.debug("Second poll - returning error"); + return CompletableFuture.failedFuture(new RuntimeException("Expected exception on second poll")); + } + else if (currentPoll.compareAndSet(2, 3)) { + logger.debug("Third poll - returning error"); + return CompletableFuture.failedFuture(new RuntimeException("Expected exception on third poll")); + } + else { + logger.debug("Fourth poll - returning empty list"); + return CompletableFuture.completedFuture(List.of()); + } + } + }; + + var policy = mock(BackOffPolicy.class); + var backOffContext = mock(BackOffContext.class); + given(policy.start(null)).willReturn(backOffContext); + + source.setBackPressureHandler(backPressureHandler); + source.setMessageSink((msgs, context) -> CompletableFuture.completedFuture(null)); + source.setId(testName + " source"); + source.configure(SqsContainerOptions.builder().pollBackOffPolicy(policy).build()); + + source.setTaskExecutor(createTaskExecutor(testName)); + source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); + source.start(); + + doAwait(waitThirdPollLatch); + + then(policy).should().start(null); + then(policy).should(times(2)).backOff(backOffContext); + + } + + private static boolean doAwait(CountDownLatch processingLatch) { + try { + return processingLatch.await(4, TimeUnit.SECONDS); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for latch", e); + } + } + + private void assertThroughputMode(SemaphoreBackPressureHandler backPressureHandler, String expectedThroughputMode) { + assertThat(ReflectionTestUtils.getField(backPressureHandler, "currentThroughputMode")) + .extracting(Object::toString).extracting(String::toLowerCase) + .isEqualTo(expectedThroughputMode.toLowerCase()); + } + + private void assertAvailablePermits(SemaphoreBackPressureHandler backPressureHandler, int expectedPermits) { + assertThat(ReflectionTestUtils.getField(backPressureHandler, "semaphore")).asInstanceOf(type(Semaphore.class)) + .extracting(Semaphore::availablePermits).isEqualTo(expectedPermits); + } + + private void assertAvailablePermitsLessThanOrEqualTo(SemaphoreBackPressureHandler backPressureHandler, + int maxExpectedPermits) { + assertThat(ReflectionTestUtils.getField(backPressureHandler, "semaphore")).asInstanceOf(type(Semaphore.class)) + .extracting(Semaphore::availablePermits).asInstanceOf(InstanceOfAssertFactories.INTEGER) + .isLessThanOrEqualTo(maxExpectedPermits); + } + + // Used to slow down tests while developing + private void doSleep(int time) { + try { + Thread.sleep(time); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + protected TaskExecutor createTaskExecutor(String testName) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + int poolSize = 10; + executor.setMaxPoolSize(poolSize); + executor.setCorePoolSize(10); + executor.setQueueCapacity(poolSize); + executor.setAllowCoreThreadTimeOut(true); + executor.setThreadFactory(createThreadFactory(testName)); + executor.afterPropertiesSet(); + return executor; + } + + protected ThreadFactory createThreadFactory(String testName) { + MessageExecutionThreadFactory threadFactory = new MessageExecutionThreadFactory(); + threadFactory.setThreadNamePrefix(testName + "-thread" + "-"); + return threadFactory; + } + + private AcknowledgementProcessor getNoOpsAcknowledgementProcessor() { + return new AcknowledgementProcessor<>() { + @Override + public AcknowledgementCallback getAcknowledgementCallback() { + return new AcknowledgementCallback<>() { + }; + } + + @Override + public void setId(String id) { + } + + @Override + public String getId() { + return "test processor"; + } + + @Override + public void start() { + } + + @Override + public void stop() { + } + + @Override + public boolean isRunning() { + return false; + } + }; + } + +} From 8deea58cbb48e2aed81336030be09c93fd706f5b Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Fri, 9 May 2025 12:30:02 +0200 Subject: [PATCH 13/29] Introduce factory methods for creating back-pressure handlers (#1251) --- .../listener/AbstractContainerOptions.java | 8 +- .../listener/BackPressureHandlerFactory.java | 99 +++++++++++++++++ .../CompositeBackPressureHandler.java | 26 ++++- ...ncyLimiterBlockingBackPressureHandler.java | 19 ++-- .../FullBatchBackPressureHandler.java | 100 ++++++++++++++++++ .../ThroughputBackPressureHandler.java | 51 ++++++--- .../SqsBackPressureIntegrationTests.java | 83 +++++++-------- .../AbstractPollingMessageSourceTests.java | 95 ++++++++--------- ...dlerAbstractPollingMessageSourceTests.java | 38 +++---- 9 files changed, 373 insertions(+), 146 deletions(-) create mode 100644 spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/FullBatchBackPressureHandler.java diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java index 8c6e3bcb9..03959303d 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java @@ -240,7 +240,7 @@ protected abstract static class Builder, private static final BackPressureMode DEFAULT_THROUGHPUT_CONFIGURATION = BackPressureMode.AUTO; - private static final BackPressureHandlerFactory DEFAULT_BACKPRESSURE_FACTORY = buildDefaultBackPressureHandlerFactory(); + private static final BackPressureHandlerFactory DEFAULT_BACKPRESSURE_FACTORY = BackPressureHandlerFactory::semaphoreBackPressureHandler; private static final ListenerMode DEFAULT_MESSAGE_DELIVERY_STRATEGY = ListenerMode.SINGLE_MESSAGE; @@ -467,12 +467,6 @@ private static BackOffPolicy buildDefaultBackOffPolicy() { return BackOffPolicyBuilder.newBuilder().multiplier(DEFAULT_BACK_OFF_MULTIPLIER) .delay(DEFAULT_BACK_OFF_DELAY).maxDelay(DEFAULT_BACK_OFF_MAX_DELAY).build(); } - - private static BackPressureHandlerFactory buildDefaultBackPressureHandlerFactory() { - return options -> SemaphoreBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()) - .totalPermits(options.getMaxConcurrentMessages()).acquireTimeout(options.getMaxDelayBetweenPolls()) - .throughputConfiguration(options.getBackPressureMode()).build(); - } } } diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java index 599fa9b49..eb88faff6 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java @@ -15,6 +15,10 @@ */ package io.awspring.cloud.sqs.listener; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + /** * A factory for creating {@link BackPressureHandler} for managing queue consumption backpressure. Implementations can * configure each the {@link BackPressureHandler} according to its strategies, using the provided @@ -82,4 +86,99 @@ public interface BackPressureHandlerFactory { * @return the created BackPressureHandler */ BackPressureHandler createBackPressureHandler(ContainerOptions containerOptions); + + /** + * Creates a new {@link SemaphoreBackPressureHandler} instance based on the provided {@link ContainerOptions}. + * + * @param options the container options. + * @return the created SemaphoreBackPressureHandler. + */ + static BatchAwareBackPressureHandler semaphoreBackPressureHandler(ContainerOptions options) { + return SemaphoreBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()) + .totalPermits(options.getMaxConcurrentMessages()).acquireTimeout(options.getMaxDelayBetweenPolls()) + .throughputConfiguration(options.getBackPressureMode()).build(); + } + + /** + * Creates a new {@link BackPressureHandler} instance based on the provided {@link ContainerOptions} combining a + * {@link ConcurrencyLimiterBlockingBackPressureHandler}, a {@link ThroughputBackPressureHandler} and a + * {@link FullBatchBackPressureHandler}. The exact combination of depends on the given {@link ContainerOptions}. + * + * @param options the container options. + * @param maxIdleWaitTime the maximum amount of time to wait for a permit to be released in case no permits were + * obtained. + * @return the created SemaphoreBackPressureHandler. + */ + static BatchAwareBackPressureHandler concurrencyLimiterBackPressureHandler(ContainerOptions options, + Duration maxIdleWaitTime) { + BackPressureMode backPressureMode = options.getBackPressureMode(); + + var concurrencyLimiterBlockingBackPressureHandler = concurrencyLimiterBackPressureHandler2(options); + if (backPressureMode == BackPressureMode.FIXED_HIGH_THROUGHPUT) { + return concurrencyLimiterBlockingBackPressureHandler; + } + var backPressureHandlers = new ArrayList(); + backPressureHandlers.add(concurrencyLimiterBlockingBackPressureHandler); + + // The ThroughputBackPressureHandler should run second in the chain as it is non-blocking. + // Running it first would result in more polls as it would potentially limit the + // ConcurrencyLimiterBlockingBackPressureHandler to a lower amount of requested permits + // which means the ConcurrencyLimiterBlockingBackPressureHandler blocking behavior would + // not be optimally leveraged. + if (backPressureMode == BackPressureMode.AUTO + || backPressureMode == BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) { + backPressureHandlers.add(throughputBackPressureHandler(options)); + } + + // The FullBatchBackPressureHandler should run last in the chain to ensure that a full batch is requested or not + if (backPressureMode == BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) { + backPressureHandlers.add(fullBatchBackPressureHandler(options)); + } + return compositeBackPressureHandler(options, maxIdleWaitTime, backPressureHandlers); + } + + /** + * Creates a new {@link ConcurrencyLimiterBlockingBackPressureHandler} instance based on the provided + * {@link ContainerOptions}. + * + * @param options the container options. + * @return the created ConcurrencyLimiterBlockingBackPressureHandler. + */ + static CompositeBackPressureHandler compositeBackPressureHandler(ContainerOptions options, + Duration maxIdleWaitTime, List backPressureHandlers) { + return new CompositeBackPressureHandler(List.copyOf(backPressureHandlers), options.getMaxMessagesPerPoll(), + maxIdleWaitTime); + } + + /** + * Creates a new {@link ConcurrencyLimiterBlockingBackPressureHandler} instance based on the provided + * {@link ContainerOptions}. + * @param options the container options. + * @return the created ConcurrencyLimiterBlockingBackPressureHandler. + */ + static ConcurrencyLimiterBlockingBackPressureHandler concurrencyLimiterBackPressureHandler2( + ContainerOptions options) { + return ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()) + .totalPermits(options.getMaxConcurrentMessages()).throughputConfiguration(options.getBackPressureMode()) + .acquireTimeout(options.getMaxDelayBetweenPolls()).build(); + } + + /** + * Creates a new {@link ThroughputBackPressureHandler} instance based on the provided {@link ContainerOptions}. + * @param options the container options. + * @return the created ThroughputBackPressureHandler. + */ + static ThroughputBackPressureHandler throughputBackPressureHandler(ContainerOptions options) { + return ThroughputBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()) + .totalPermits(options.getMaxConcurrentMessages()).build(); + } + + /** + * Creates a new {@link FullBatchBackPressureHandler} instance based on the provided {@link ContainerOptions}. + * @param options the container options. + * @return the created FullBatchBackPressureHandler. + */ + static FullBatchBackPressureHandler fullBatchBackPressureHandler(ContainerOptions options) { + return FullBatchBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()).build(); + } } diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java index 930f7dc6e..a53722f17 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java @@ -24,6 +24,28 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Composite {@link BackPressureHandler} implementation that delegates the back-pressure handling to a list of + * {@link BackPressureHandler}s. + *

+ * This class is used to combine multiple back-pressure handlers into a single one. It allows for more complex + * back-pressure handling strategies by combining different implementations. + *

+ * The order in which the back-pressure handlers are registered in the {@link CompositeBackPressureHandler} is important + * as it will affect the blocking and limiting behaviour of the back-pressure handling. + *

+ * When {@link #request(int amount)} is called, the first back-pressure handler in the list is called with + * {@code amount} as the requested amount of permits. The returned amount of permits (which is less than or equal to the + * initial amount) is then passed to the next back-pressure handler in the list. This process of reducing the amount to + * request for the next handlers in the chain is called "limiting". This process continues until all back-pressure + * handlers have been called or {@literal 0} permits has been returned. + *

+ * Once the final amount of available permits have been computed, unused acquired permits on back-pressure handlers (due + * to later limiting happening in the chain) are released. + *

+ * If no permits were obtained, the {@link #request(int)} method will wait up to {@code noPermitsReturnedWaitTimeout} + * for a release of permits before returning. + */ public class CompositeBackPressureHandler implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { private static final Logger logger = LoggerFactory.getLogger(CompositeBackPressureHandler.class); @@ -41,10 +63,10 @@ public class CompositeBackPressureHandler implements BatchAwareBackPressureHandl private String id; public CompositeBackPressureHandler(List backPressureHandlers, int batchSize, - Duration waitTimeout) { + Duration noPermitsReturnedWaitTimeout) { this.backPressureHandlers = backPressureHandlers; this.batchSize = batchSize; - this.noPermitsReturnedWaitTimeout = waitTimeout; + this.noPermitsReturnedWaitTimeout = noPermitsReturnedWaitTimeout; } @Override diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java index 99a78fe04..51129f183 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java @@ -15,6 +15,7 @@ */ package io.awspring.cloud.sqs.listener; +import io.awspring.cloud.sqs.listener.source.PollingMessageSource; import java.time.Duration; import java.util.Arrays; import java.util.concurrent.Semaphore; @@ -24,11 +25,10 @@ import org.springframework.util.Assert; /** - * {@link BackPressureHandler} implementation that uses a {@link Semaphore} for handling backpressure. + * Blocking {@link BackPressureHandler} implementation that uses a {@link Semaphore} for handling the number of + * concurrent messages being processed. * - * @author Tomaz Fernandes - * @see io.awspring.cloud.sqs.listener.source.PollingMessageSource - * @since 3.0 + * @see PollingMessageSource */ public class ConcurrencyLimiterBlockingBackPressureHandler implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { @@ -43,20 +43,17 @@ public class ConcurrencyLimiterBlockingBackPressureHandler private final Duration acquireTimeout; - private final boolean alwaysPollMaxMessages; - private String id = getClass().getSimpleName(); private ConcurrencyLimiterBlockingBackPressureHandler(Builder builder) { this.batchSize = builder.batchSize; this.totalPermits = builder.totalPermits; this.acquireTimeout = builder.acquireTimeout; - this.alwaysPollMaxMessages = BackPressureMode.ALWAYS_POLL_MAX_MESSAGES.equals(builder.backPressureMode); - this.semaphore = new Semaphore(totalPermits); logger.debug( "ConcurrencyLimiterBlockingBackPressureHandler created with configuration " - + "totalPermits: {}, batchSize: {}, acquireTimeout: {}, an alwaysPollMaxMessages: {}", - this.totalPermits, this.batchSize, this.acquireTimeout, this.alwaysPollMaxMessages); + + "totalPermits: {}, batchSize: {}, acquireTimeout: {}", + this.totalPermits, this.batchSize, this.acquireTimeout); + this.semaphore = new Semaphore(totalPermits); } public static Builder builder() { @@ -81,7 +78,7 @@ public int requestBatch() throws InterruptedException { @Override public int request(int amount) throws InterruptedException { int acquiredPermits = tryAcquire(amount, this.acquireTimeout); - if (alwaysPollMaxMessages || acquiredPermits > 0) { + if (acquiredPermits > 0) { return acquiredPermits; } int availablePermits = Math.min(this.semaphore.availablePermits(), amount); diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/FullBatchBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/FullBatchBackPressureHandler.java new file mode 100644 index 000000000..aa83921ab --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/FullBatchBackPressureHandler.java @@ -0,0 +1,100 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.listener; + +import io.awspring.cloud.sqs.listener.source.PollingMessageSource; +import java.time.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.Assert; + +/** + * Non-blocking {@link BackPressureHandler} implementation that ensures the exact batch size is requested. + *

+ * If the amount of permits being requested is not equal to the batch size, permits will be limited to {@literal 0}. For + * this limiting mechanism to work, the {@link FullBatchBackPressureHandler} must be used in combination with another + * {@link BackPressureHandler} and be the last one in the chain of the {@link CompositeBackPressureHandler} + * + * @see PollingMessageSource + */ +public class FullBatchBackPressureHandler implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { + + private static final Logger logger = LoggerFactory.getLogger(FullBatchBackPressureHandler.class); + + private final int batchSize; + + private String id = getClass().getSimpleName(); + + private FullBatchBackPressureHandler(Builder builder) { + this.batchSize = builder.batchSize; + logger.debug("FullBatchBackPressureHandler created with configuration: batchSize: {}", this.batchSize); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public void setId(String id) { + this.id = id; + } + + @Override + public String getId() { + return this.id; + } + + @Override + public int requestBatch() throws InterruptedException { + return request(this.batchSize); + } + + @Override + public int request(int amount) throws InterruptedException { + if (amount == batchSize) { + return amount; + } + logger.warn("[{}] Could not acquire a full batch ({} / {}), cancelling current poll", this.id, amount, + this.batchSize); + return 0; + } + + @Override + public void release(int amount, ReleaseReason reason) { + // NO-OP + } + + @Override + public boolean drain(Duration timeout) { + return true; + } + + public static class Builder { + + private int batchSize; + + public Builder batchSize(int batchSize) { + this.batchSize = batchSize; + return this; + } + + public FullBatchBackPressureHandler build() { + Assert.notNull(this.batchSize, "Missing configuration for batch size"); + Assert.isTrue(this.batchSize > 0, "The batch size must be greater than 0"); + return new FullBatchBackPressureHandler(this); + } + } +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java index d8e6abd20..ec2525ef4 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java @@ -25,26 +25,34 @@ import org.springframework.util.Assert; /** - * {@link BackPressureHandler} implementation that uses a switch between high and low throughput modes. + * Non-blocking {@link BackPressureHandler} implementation that uses a switch between high and low throughput modes. *

- * The initial throughput mode is low, which means, only one batch at a time can be requested. If some messages are - * fetched, then the throughput mode is switched to high, which means that multiple batches can be requested (i.e., - * there is no need to wait for the previous batch's processing to complete before requesting a new one). If no messages - * are returned fetched by a poll, the throughput mode is switched back to low. + * Throughput modes + *

    + *
  • In low-throughput mode, a single batch can be requested at a time. The number of permits that will be delivered + * is adjusted so that the number of in flight messages will not exceed the batch size.
  • + *
  • In high-throughput mode, multiple batches can be requested at a time. The number of permits that will be + * delivered is adjusted so that the number of in flight messages will not exceed the maximum number of concurrent + * messages. Note that for a single poll the maximum number of permits that will be delivered will not exceed the batch + * size.
  • + *
+ *

+ * Throughput mode switch: The initial throughput mode is the low-throughput mode. If some messages are + * fetched, then the throughput mode is switched to high-throughput mode. If no messages are returned fetched by a poll, + * the throughput mode is switched back to low-throughput mode. *

* This {@link BackPressureHandler} is designed to be used in combination with another {@link BackPressureHandler} like * the {@link ConcurrencyLimiterBlockingBackPressureHandler} that will handle the maximum concurrency level within the - * application. + * application in a blocking way. * - * @author Tomaz Fernandes * @see PollingMessageSource - * @since 3.0 */ public class ThroughputBackPressureHandler implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { private static final Logger logger = LoggerFactory.getLogger(ThroughputBackPressureHandler.class); private final int batchSize; + private final int maxConcurrentMessages; private final AtomicReference currentThroughputMode = new AtomicReference<>( CurrentThroughputMode.LOW); @@ -57,6 +65,7 @@ public class ThroughputBackPressureHandler implements BatchAwareBackPressureHand private ThroughputBackPressureHandler(Builder builder) { this.batchSize = builder.batchSize; + this.maxConcurrentMessages = builder.maxConcurrentMessages; logger.debug("ThroughputBackPressureHandler created with batchSize {}", this.batchSize); } @@ -84,15 +93,22 @@ public int request(int amount) throws InterruptedException { if (drained.get()) { return 0; } + int amountCappedAtBatchSize = Math.min(amount, this.batchSize); int permits; int inFlight = inFlightRequests.get(); if (CurrentThroughputMode.LOW == this.currentThroughputMode.get()) { - permits = Math.max(0, Math.min(amount, this.batchSize - inFlight)); - logger.debug("[{}] Acquired {} permits (low throughput mode), in flight: {}", this.id, amount, inFlight); + // In low-throughput mode, we only acquire one batch at a time, + // so we need to limit the available permits to the batchSize - inFlight messages. + permits = Math.max(0, Math.min(amountCappedAtBatchSize, this.batchSize - inFlight)); + logger.debug("[{}] Acquired {} permits (low-throughput mode), requested: {}, in flight: {}", this.id, + permits, amount, inFlight); } else { - permits = amount; - logger.debug("[{}] Acquired {} permits (high throughput mode), in flight: {}", this.id, amount, inFlight); + // In high-throughput mode, we can acquire more permits than the batch size, + // but we need to limit the available permits to the maxConcurrentMessages - inFlight messages. + permits = Math.max(0, Math.min(amountCappedAtBatchSize, this.maxConcurrentMessages - inFlight)); + logger.debug("[{}] Acquired {} permits (high-throughput mode), requested: {}, in flight: {}", this.id, + permits, amount, inFlight); } inFlightRequests.addAndGet(permits); return permits; @@ -138,15 +154,24 @@ private enum CurrentThroughputMode { public static class Builder { private int batchSize; + private int maxConcurrentMessages; public Builder batchSize(int batchSize) { this.batchSize = batchSize; return this; } + public Builder totalPermits(int maxConcurrentMessages) { + this.maxConcurrentMessages = maxConcurrentMessages; + return this; + } + public ThroughputBackPressureHandler build() { - Assert.notNull(this.batchSize, "Missing configuration"); + Assert.notNull(this.batchSize, "Missing batchSize configuration"); Assert.isTrue(this.batchSize > 0, "batch size must be greater than 0"); + Assert.notNull(this.maxConcurrentMessages, "Missing maxConcurrentMessages configuration"); + Assert.notNull(this.maxConcurrentMessages >= this.batchSize, + "maxConcurrentMessages must be greater than or equal to batchSize"); return new ThroughputBackPressureHandler(this); } } diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java index 2ef745185..2c4d7fd51 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java @@ -126,18 +126,16 @@ void staticBackPressureLimitShouldCapQueueProcessingCapacity(int staticLimit, in }); logger.debug("Sent 100 messages to queue {}", queueName); var latch = new CountDownLatch(100); - var container = SqsMessageListenerContainer - .builder().sqsAsyncClient( - BaseSqsIntegrationTest.createAsyncClient()) - .queueNames( - queueName) - .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .backPressureHandlerFactory(containerOptions -> new CompositeBackPressureHandler( - List.of(limiter, - ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(5) - .totalPermits(5).acquireTimeout(Duration.ofSeconds(1L)) - .throughputConfiguration(BackPressureMode.AUTO).build()), - 5, Duration.ofMillis(50L)))) + var container = SqsMessageListenerContainer.builder().sqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()) + .queueNames(queueName) + .configure( + options -> options.maxMessagesPerPoll(5).maxConcurrentMessages(5) + .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofSeconds(1)) + .pollTimeout(Duration.ofSeconds(1)) + .backPressureHandlerFactory(containerOptions -> BackPressureHandlerFactory + .compositeBackPressureHandler(containerOptions, Duration.ofMillis(50L), + List.of(limiter, BackPressureHandlerFactory + .concurrencyLimiterBackPressureHandler2(containerOptions))))) .messageListener(msg -> { int concurrentRqs = concurrentRequest.incrementAndGet(); maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); @@ -166,18 +164,16 @@ void zeroBackPressureLimitShouldStopQueueProcessing() throws Exception { }); logger.debug("Sent 100 messages to queue {}", queueName); var latch = new CountDownLatch(100); - var container = SqsMessageListenerContainer - .builder().sqsAsyncClient( - BaseSqsIntegrationTest.createAsyncClient()) - .queueNames( - queueName) - .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .backPressureHandlerFactory(containerOptions -> new CompositeBackPressureHandler( - List.of(limiter, - ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(5) - .totalPermits(5).acquireTimeout(Duration.ofSeconds(1L)) - .throughputConfiguration(BackPressureMode.AUTO).build()), - 5, Duration.ofMillis(50L)))) + var container = SqsMessageListenerContainer.builder().sqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()) + .queueNames(queueName) + .configure( + options -> options.maxMessagesPerPoll(5).maxConcurrentMessages(5) + .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofSeconds(1)) + .pollTimeout(Duration.ofSeconds(1)) + .backPressureHandlerFactory(containerOptions -> BackPressureHandlerFactory + .compositeBackPressureHandler(containerOptions, Duration.ofMillis(50L), + List.of(limiter, BackPressureHandlerFactory + .concurrencyLimiterBackPressureHandler2(containerOptions))))) .messageListener(msg -> { int concurrentRqs = concurrentRequest.incrementAndGet(); maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); @@ -212,18 +208,16 @@ void changeInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Except var advanceSemaphore = new Semaphore(0); var processingFailed = new AtomicBoolean(false); var isDraining = new AtomicBoolean(false); - var container = SqsMessageListenerContainer - .builder().sqsAsyncClient( - BaseSqsIntegrationTest.createAsyncClient()) - .queueNames( - queueName) - .configure(options -> options.pollTimeout(Duration.ofSeconds(1)) - .backPressureHandlerFactory(containerOptions -> new CompositeBackPressureHandler( - List.of(limiter, - ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(5) - .totalPermits(5).acquireTimeout(Duration.ofSeconds(1L)) - .throughputConfiguration(BackPressureMode.AUTO).build()), - 5, Duration.ofMillis(50L)))) + var container = SqsMessageListenerContainer.builder().sqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()) + .queueNames(queueName) + .configure( + options -> options.maxMessagesPerPoll(5).maxConcurrentMessages(5) + .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofSeconds(1)) + .pollTimeout(Duration.ofSeconds(1)) + .backPressureHandlerFactory(containerOptions -> BackPressureHandlerFactory + .compositeBackPressureHandler(containerOptions, Duration.ofMillis(50L), + List.of(limiter, BackPressureHandlerFactory + .concurrencyLimiterBackPressureHandler2(containerOptions))))) .messageListener(msg -> { try { if (!controlSemaphore.tryAcquire(5, TimeUnit.SECONDS) && !isDraining.get()) { @@ -442,13 +436,16 @@ void unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity( EventsCsvWriter eventsCsvWriter = new EventsCsvWriter(); var container = SqsMessageListenerContainer.builder().sqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()) .queueNames(queueName) - .configure(options -> options.pollTimeout(Duration.ofSeconds(1)).backPressureHandlerFactory( - containerOptions -> new StatisticsBphDecorator(new CompositeBackPressureHandler( - List.of(limiter, - ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(10) - .totalPermits(10).acquireTimeout(Duration.ofSeconds(1L)) - .throughputConfiguration(BackPressureMode.AUTO).build()), - 10, Duration.ofMillis(50L)), eventsCsvWriter))) + .configure( + options -> options.maxMessagesPerPoll(10).maxConcurrentMessages(10) + .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofSeconds(1)) + .pollTimeout(Duration.ofSeconds(1)) + .backPressureHandlerFactory(containerOptions -> new StatisticsBphDecorator( + BackPressureHandlerFactory.compositeBackPressureHandler(containerOptions, + Duration.ofMillis(50L), + List.of(limiter, BackPressureHandlerFactory + .concurrencyLimiterBackPressureHandler2(containerOptions))), + eventsCsvWriter))) .messageListener(msg -> { int currentConcurrentRq = concurrentRequest.incrementAndGet(); maxConcurrentRequest.updateAndGet(max -> Math.max(max, currentConcurrentRq)); diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java index ba0cb8728..f54dfb9f9 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java @@ -58,10 +58,12 @@ class AbstractPollingMessageSourceTests { @Test void shouldAcquireAndReleaseFullPermits() { String testName = "shouldAcquireAndReleaseFullPermits"; + SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) + .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) + .maxDelayBetweenPolls(Duration.ofMillis(200)).build(); + BackPressureHandler backPressureHandler = BackPressureHandlerFactory + .concurrencyLimiterBackPressureHandler(options, Duration.ofMillis(100L)); - BackPressureHandler backPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler.builder() - .acquireTimeout(Duration.ofMillis(200)).batchSize(10).totalPermits(10) - .throughputConfiguration(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES).build(); ExecutorService threadPool = Executors.newCachedThreadPool(); CountDownLatch pollingCounter = new CountDownLatch(3); CountDownLatch processingCounter = new CountDownLatch(1); @@ -103,7 +105,7 @@ protected CompletableFuture> doPollForMessages(int messagesT }); source.setId(testName + " source"); - source.configure(SqsContainerOptions.builder().build()); + source.configure(options); source.setTaskExecutor(createTaskExecutor(testName)); source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); source.start(); @@ -114,16 +116,12 @@ protected CompletableFuture> doPollForMessages(int messagesT @Test void shouldAdaptThroughputMode() { String testName = "shouldAdaptThroughputMode"; + SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) + .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) + .maxDelayBetweenPolls(Duration.ofMillis(150)).build(); + BackPressureHandler backPressureHandler = BackPressureHandlerFactory + .concurrencyLimiterBackPressureHandler(options, Duration.ofMillis(100L)); - int batchSize = 10; - var concurrencyLimiterBlockingBackPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler.builder() - .batchSize(batchSize).totalPermits(batchSize) - .throughputConfiguration(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) - .acquireTimeout(Duration.ofSeconds(5L)).build(); - var throughputBackPressureHandler = ThroughputBackPressureHandler.builder().batchSize(batchSize).build(); - var backPressureHandler = new CompositeBackPressureHandler( - List.of(concurrencyLimiterBlockingBackPressureHandler, throughputBackPressureHandler), batchSize, - Duration.ofMillis(100L)); ExecutorService threadPool = Executors.newCachedThreadPool(); CountDownLatch pollingCounter = new CountDownLatch(3); CountDownLatch processingCounter = new CountDownLatch(1); @@ -138,7 +136,7 @@ protected CompletableFuture> doPollForMessages(int messagesT return CompletableFuture.supplyAsync(() -> { try { int pollAttempt = pollAttemptCounter.incrementAndGet(); - logger.debug("Poll attempt {}", pollAttempt); + logger.warn("Poll attempt {}", pollAttempt); if (pollAttempt == 1) { // Initial poll; throughput mode should be low assertThroughputMode(backPressureHandler, "low"); @@ -185,7 +183,7 @@ else if (pollAttempt == 2) { }); source.setId(testName + " source"); - source.configure(SqsContainerOptions.builder().build()); + source.configure(options); source.setTaskExecutor(createTaskExecutor(testName)); source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); try { @@ -205,9 +203,11 @@ else if (pollAttempt == 2) { @Test void shouldAcquireAndReleasePartialPermits() { String testName = "shouldAcquireAndReleasePartialPermits"; - ConcurrencyLimiterBlockingBackPressureHandler backPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler - .builder().acquireTimeout(Duration.ofMillis(150)).batchSize(10).totalPermits(10) - .throughputConfiguration(BackPressureMode.AUTO).build(); + SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) + .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofMillis(150)).build(); + BackPressureHandler backPressureHandler = BackPressureHandlerFactory + .concurrencyLimiterBackPressureHandler(options, Duration.ofMillis(200L)); + ExecutorService threadPool = Executors .newCachedThreadPool(new MessageExecutionThreadFactory("test " + testCounter.incrementAndGet())); CountDownLatch pollingCounter = new CountDownLatch(4); @@ -228,17 +228,14 @@ protected CompletableFuture> doPollForMessages(int messagesT // First poll, should have 10 logger.debug("First poll - should request 10 messages"); assertThat(messagesToRequest).isEqualTo(10); - assertAvailablePermits(backPressureHandler, 0); - // No permits have been released yet - return (Collection) List.of( - Message.builder().messageId(UUID.randomUUID().toString()).body("message").build()); + Message message = Message.builder().messageId(UUID.randomUUID().toString()).body("message") + .build(); + return (Collection) List.of(message); } else if (pollAttempt == 2) { // Second poll, should have 9 logger.debug("Second poll - should request 9 messages"); assertThat(messagesToRequest).isEqualTo(9); - assertAvailablePermitsLessThanOrEqualTo(backPressureHandler, 1); - // Has released 9 permits processingLatch.countDown(); // Release processing now return Collections. emptyList(); } @@ -246,7 +243,6 @@ else if (pollAttempt == 2) { // Third poll or later, should have 10 again logger.debug("Third (or later) poll - should request 10 messages"); assertThat(messagesToRequest).isEqualTo(10); - assertAvailablePermits(backPressureHandler, 0); return Collections. emptyList(); } } @@ -268,7 +264,7 @@ else if (pollAttempt == 2) { return CompletableFuture.completedFuture(null).thenRun(processingCounter::countDown); }); source.setId(testName + " source"); - source.configure(SqsContainerOptions.builder().build()); + source.configure(options); source.setTaskExecutor(createTaskExecutor(testName)); source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); source.start(); @@ -282,14 +278,8 @@ else if (pollAttempt == 2) { @Test void shouldReleasePermitsOnConversionErrors() { String testName = "shouldReleasePermitsOnConversionErrors"; - ConcurrencyLimiterBlockingBackPressureHandler backPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler - .builder().acquireTimeout(Duration.ofMillis(150)).batchSize(10).totalPermits(10) - .throughputConfiguration(BackPressureMode.AUTO).build(); AtomicInteger convertedMessages = new AtomicInteger(0); - AtomicInteger messagesInSink = new AtomicInteger(0); - AtomicBoolean hasFailed = new AtomicBoolean(false); - var converter = new SqsMessagingMessageConverter() { @Override public org.springframework.messaging.Message toMessagingMessage(Message source, @@ -303,6 +293,15 @@ public org.springframework.messaging.Message toMessagingMessage(Message sourc } }; + SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) + .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) + .maxDelayBetweenPolls(Duration.ofMillis(150)).messageConverter(converter).build(); + BackPressureHandler backPressureHandler = BackPressureHandlerFactory + .concurrencyLimiterBackPressureHandler(options, Duration.ofMillis(100L)); + + AtomicInteger messagesInSink = new AtomicInteger(0); + AtomicBoolean hasFailed = new AtomicBoolean(false); + AbstractPollingMessageSource source = new AbstractPollingMessageSource<>() { @Override @@ -329,7 +328,7 @@ private Collection create10Messages() { return CompletableFuture.completedFuture(null); }); source.setId(testName + " source"); - source.configure(SqsContainerOptions.builder().messageConverter(converter).build()); + source.configure(options); source.setPollingEndpointName("shouldReleasePermitsOnConversionErrors-queue"); source.setTaskExecutor(createTaskExecutor(testName)); source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); @@ -342,19 +341,17 @@ private Collection create10Messages() { @Test void shouldBackOffIfPollingThrowsAnError() { - var testName = "shouldBackOffIfPollingThrowsAnError"; - int totalPermits = 40; - int batchSize = 10; - var concurrencyLimiterBlockingBackPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler.builder() - .batchSize(batchSize).totalPermits(totalPermits) - .throughputConfiguration(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) - .acquireTimeout(Duration.ofMillis(200)).build(); - var throughputBackPressureHandler = ThroughputBackPressureHandler.builder().batchSize(batchSize).build(); - var backPressureHandler = new CompositeBackPressureHandler( - List.of(concurrencyLimiterBlockingBackPressureHandler, throughputBackPressureHandler), batchSize, - Duration.ofSeconds(5L)); + var policy = mock(BackOffPolicy.class); + var backOffContext = mock(BackOffContext.class); + given(policy.start(null)).willReturn(backOffContext); + SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(40) + .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) + .maxDelayBetweenPolls(Duration.ofMillis(200)).pollBackOffPolicy(policy).build(); + BackPressureHandler backPressureHandler = BackPressureHandlerFactory + .concurrencyLimiterBackPressureHandler(options, Duration.ofMillis(100L)); + var currentPoll = new AtomicInteger(0); var waitThirdPollLatch = new CountDownLatch(4); @@ -381,14 +378,10 @@ else if (currentPoll.compareAndSet(2, 3)) { } }; - var policy = mock(BackOffPolicy.class); - var backOffContext = mock(BackOffContext.class); - given(policy.start(null)).willReturn(backOffContext); - source.setBackPressureHandler(backPressureHandler); source.setMessageSink((msgs, context) -> CompletableFuture.completedFuture(null)); source.setId(testName + " source"); - source.configure(SqsContainerOptions.builder().pollBackOffPolicy(policy).build()); + source.configure(options); source.setTaskExecutor(createTaskExecutor(testName)); source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); @@ -428,8 +421,8 @@ private void assertAvailablePermits(BackPressureHandler backPressureHandler, int .extracting(Semaphore::availablePermits).isEqualTo(expectedPermits); } - private void assertAvailablePermitsLessThanOrEqualTo( - ConcurrencyLimiterBlockingBackPressureHandler backPressureHandler, int maxExpectedPermits) { + private void assertAvailablePermitsLessThanOrEqualTo(BackPressureHandler backPressureHandler, + int maxExpectedPermits) { var bph = extractBackPressureHandler(backPressureHandler, ConcurrencyLimiterBlockingBackPressureHandler.class); assertThat(ReflectionTestUtils.getField(bph, "semaphore")).asInstanceOf(type(Semaphore.class)) .extracting(Semaphore::availablePermits).asInstanceOf(InstanceOfAssertFactories.INTEGER) diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/SemaphoreBackPressureHandlerAbstractPollingMessageSourceTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/SemaphoreBackPressureHandlerAbstractPollingMessageSourceTests.java index 4f3457914..94cb76959 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/SemaphoreBackPressureHandlerAbstractPollingMessageSourceTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/SemaphoreBackPressureHandlerAbstractPollingMessageSourceTests.java @@ -23,9 +23,7 @@ import static org.mockito.Mockito.times; import io.awspring.cloud.sqs.MessageExecutionThreadFactory; -import io.awspring.cloud.sqs.listener.BackPressureMode; -import io.awspring.cloud.sqs.listener.SemaphoreBackPressureHandler; -import io.awspring.cloud.sqs.listener.SqsContainerOptions; +import io.awspring.cloud.sqs.listener.*; import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback; import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementProcessor; import io.awspring.cloud.sqs.support.converter.MessageConversionContext; @@ -68,10 +66,11 @@ class SemaphoreBackPressureHandlerAbstractPollingMessageSourceTests { @Test void shouldAcquireAndReleaseFullPermits() { String testName = "shouldAcquireAndReleaseFullPermits"; + BackPressureHandler backPressureHandler = BackPressureHandlerFactory + .semaphoreBackPressureHandler(SqsContainerOptions.builder().maxMessagesPerPoll(10) + .maxConcurrentMessages(10).backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) + .maxDelayBetweenPolls(Duration.ofMillis(200)).build()); - SemaphoreBackPressureHandler backPressureHandler = SemaphoreBackPressureHandler.builder() - .acquireTimeout(Duration.ofMillis(200)).batchSize(10).totalPermits(10) - .throughputConfiguration(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES).build(); ExecutorService threadPool = Executors.newCachedThreadPool(); CountDownLatch pollingCounter = new CountDownLatch(3); CountDownLatch processingCounter = new CountDownLatch(1); @@ -143,9 +142,10 @@ else if (hasMadeSecondPoll.compareAndSet(false, true)) { @Test void shouldAcquireAndReleasePartialPermits() { String testName = "shouldAcquireAndReleasePartialPermits"; - SemaphoreBackPressureHandler backPressureHandler = SemaphoreBackPressureHandler.builder() - .acquireTimeout(Duration.ofMillis(150)).batchSize(10).totalPermits(10) - .throughputConfiguration(BackPressureMode.AUTO).build(); + BackPressureHandler backPressureHandler = BackPressureHandlerFactory.semaphoreBackPressureHandler( + SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) + .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofMillis(150)).build()); + ExecutorService threadPool = Executors .newCachedThreadPool(new MessageExecutionThreadFactory("test " + testCounter.incrementAndGet())); CountDownLatch pollingCounter = new CountDownLatch(4); @@ -241,9 +241,9 @@ else if (hasAcquired9.compareAndSet(false, true)) { @Test void shouldReleasePermitsOnConversionErrors() { String testName = "shouldReleasePermitsOnConversionErrors"; - SemaphoreBackPressureHandler backPressureHandler = SemaphoreBackPressureHandler.builder() - .acquireTimeout(Duration.ofMillis(150)).batchSize(10).totalPermits(10) - .throughputConfiguration(BackPressureMode.AUTO).build(); + BackPressureHandler backPressureHandler = BackPressureHandlerFactory.semaphoreBackPressureHandler( + SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) + .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofMillis(150)).build()); AtomicInteger convertedMessages = new AtomicInteger(0); AtomicInteger messagesInSink = new AtomicInteger(0); @@ -301,12 +301,12 @@ private Collection create10Messages() { @Test void shouldBackOffIfPollingThrowsAnError() { - var testName = "shouldBackOffIfPollingThrowsAnError"; + BackPressureHandler backPressureHandler = BackPressureHandlerFactory + .semaphoreBackPressureHandler(SqsContainerOptions.builder().maxMessagesPerPoll(10) + .maxConcurrentMessages(40).backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) + .maxDelayBetweenPolls(Duration.ofMillis(200)).build()); - var backPressureHandler = SemaphoreBackPressureHandler.builder().acquireTimeout(Duration.ofMillis(200)) - .batchSize(10).totalPermits(40).throughputConfiguration(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) - .build(); var currentPoll = new AtomicInteger(0); var waitThirdPollLatch = new CountDownLatch(4); @@ -363,18 +363,18 @@ private static boolean doAwait(CountDownLatch processingLatch) { } } - private void assertThroughputMode(SemaphoreBackPressureHandler backPressureHandler, String expectedThroughputMode) { + private void assertThroughputMode(BackPressureHandler backPressureHandler, String expectedThroughputMode) { assertThat(ReflectionTestUtils.getField(backPressureHandler, "currentThroughputMode")) .extracting(Object::toString).extracting(String::toLowerCase) .isEqualTo(expectedThroughputMode.toLowerCase()); } - private void assertAvailablePermits(SemaphoreBackPressureHandler backPressureHandler, int expectedPermits) { + private void assertAvailablePermits(BackPressureHandler backPressureHandler, int expectedPermits) { assertThat(ReflectionTestUtils.getField(backPressureHandler, "semaphore")).asInstanceOf(type(Semaphore.class)) .extracting(Semaphore::availablePermits).isEqualTo(expectedPermits); } - private void assertAvailablePermitsLessThanOrEqualTo(SemaphoreBackPressureHandler backPressureHandler, + private void assertAvailablePermitsLessThanOrEqualTo(BackPressureHandler backPressureHandler, int maxExpectedPermits) { assertThat(ReflectionTestUtils.getField(backPressureHandler, "semaphore")).asInstanceOf(type(Semaphore.class)) .extracting(Semaphore::availablePermits).asInstanceOf(InstanceOfAssertFactories.INTEGER) From ea3c65ab6bbc6114b3fd3ce441d9ead55a14b373 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Fri, 9 May 2025 16:25:16 +0200 Subject: [PATCH 14/29] Rename BackPressureHandlerFactory methods --- .../cloud/sqs/listener/BackPressureHandlerFactory.java | 6 +++--- .../integration/SqsBackPressureIntegrationTests.java | 8 ++++---- .../source/AbstractPollingMessageSourceTests.java | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java index eb88faff6..a72bd12f1 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java @@ -109,11 +109,11 @@ static BatchAwareBackPressureHandler semaphoreBackPressureHandler(ContainerOptio * obtained. * @return the created SemaphoreBackPressureHandler. */ - static BatchAwareBackPressureHandler concurrencyLimiterBackPressureHandler(ContainerOptions options, + static BatchAwareBackPressureHandler adaptativeThroughputBackPressureHandler(ContainerOptions options, Duration maxIdleWaitTime) { BackPressureMode backPressureMode = options.getBackPressureMode(); - var concurrencyLimiterBlockingBackPressureHandler = concurrencyLimiterBackPressureHandler2(options); + var concurrencyLimiterBlockingBackPressureHandler = concurrencyLimiterBackPressureHandler(options); if (backPressureMode == BackPressureMode.FIXED_HIGH_THROUGHPUT) { return concurrencyLimiterBlockingBackPressureHandler; } @@ -156,7 +156,7 @@ static CompositeBackPressureHandler compositeBackPressureHandler(ContainerOption * @param options the container options. * @return the created ConcurrencyLimiterBlockingBackPressureHandler. */ - static ConcurrencyLimiterBlockingBackPressureHandler concurrencyLimiterBackPressureHandler2( + static ConcurrencyLimiterBlockingBackPressureHandler concurrencyLimiterBackPressureHandler( ContainerOptions options) { return ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()) .totalPermits(options.getMaxConcurrentMessages()).throughputConfiguration(options.getBackPressureMode()) diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java index 2c4d7fd51..8fc1fec03 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java @@ -135,7 +135,7 @@ void staticBackPressureLimitShouldCapQueueProcessingCapacity(int staticLimit, in .backPressureHandlerFactory(containerOptions -> BackPressureHandlerFactory .compositeBackPressureHandler(containerOptions, Duration.ofMillis(50L), List.of(limiter, BackPressureHandlerFactory - .concurrencyLimiterBackPressureHandler2(containerOptions))))) + .concurrencyLimiterBackPressureHandler(containerOptions))))) .messageListener(msg -> { int concurrentRqs = concurrentRequest.incrementAndGet(); maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); @@ -173,7 +173,7 @@ void zeroBackPressureLimitShouldStopQueueProcessing() throws Exception { .backPressureHandlerFactory(containerOptions -> BackPressureHandlerFactory .compositeBackPressureHandler(containerOptions, Duration.ofMillis(50L), List.of(limiter, BackPressureHandlerFactory - .concurrencyLimiterBackPressureHandler2(containerOptions))))) + .concurrencyLimiterBackPressureHandler(containerOptions))))) .messageListener(msg -> { int concurrentRqs = concurrentRequest.incrementAndGet(); maxConcurrentRequest.updateAndGet(max -> Math.max(max, concurrentRqs)); @@ -217,7 +217,7 @@ void changeInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Except .backPressureHandlerFactory(containerOptions -> BackPressureHandlerFactory .compositeBackPressureHandler(containerOptions, Duration.ofMillis(50L), List.of(limiter, BackPressureHandlerFactory - .concurrencyLimiterBackPressureHandler2(containerOptions))))) + .concurrencyLimiterBackPressureHandler(containerOptions))))) .messageListener(msg -> { try { if (!controlSemaphore.tryAcquire(5, TimeUnit.SECONDS) && !isDraining.get()) { @@ -444,7 +444,7 @@ void unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity( BackPressureHandlerFactory.compositeBackPressureHandler(containerOptions, Duration.ofMillis(50L), List.of(limiter, BackPressureHandlerFactory - .concurrencyLimiterBackPressureHandler2(containerOptions))), + .concurrencyLimiterBackPressureHandler(containerOptions))), eventsCsvWriter))) .messageListener(msg -> { int currentConcurrentRq = concurrentRequest.incrementAndGet(); diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java index f54dfb9f9..df3b5a1bc 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java @@ -62,7 +62,7 @@ void shouldAcquireAndReleaseFullPermits() { .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) .maxDelayBetweenPolls(Duration.ofMillis(200)).build(); BackPressureHandler backPressureHandler = BackPressureHandlerFactory - .concurrencyLimiterBackPressureHandler(options, Duration.ofMillis(100L)); + .adaptativeThroughputBackPressureHandler(options, Duration.ofMillis(100L)); ExecutorService threadPool = Executors.newCachedThreadPool(); CountDownLatch pollingCounter = new CountDownLatch(3); @@ -120,7 +120,7 @@ void shouldAdaptThroughputMode() { .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) .maxDelayBetweenPolls(Duration.ofMillis(150)).build(); BackPressureHandler backPressureHandler = BackPressureHandlerFactory - .concurrencyLimiterBackPressureHandler(options, Duration.ofMillis(100L)); + .adaptativeThroughputBackPressureHandler(options, Duration.ofMillis(100L)); ExecutorService threadPool = Executors.newCachedThreadPool(); CountDownLatch pollingCounter = new CountDownLatch(3); @@ -206,7 +206,7 @@ void shouldAcquireAndReleasePartialPermits() { SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofMillis(150)).build(); BackPressureHandler backPressureHandler = BackPressureHandlerFactory - .concurrencyLimiterBackPressureHandler(options, Duration.ofMillis(200L)); + .adaptativeThroughputBackPressureHandler(options, Duration.ofMillis(200L)); ExecutorService threadPool = Executors .newCachedThreadPool(new MessageExecutionThreadFactory("test " + testCounter.incrementAndGet())); @@ -297,7 +297,7 @@ public org.springframework.messaging.Message toMessagingMessage(Message sourc .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) .maxDelayBetweenPolls(Duration.ofMillis(150)).messageConverter(converter).build(); BackPressureHandler backPressureHandler = BackPressureHandlerFactory - .concurrencyLimiterBackPressureHandler(options, Duration.ofMillis(100L)); + .adaptativeThroughputBackPressureHandler(options, Duration.ofMillis(100L)); AtomicInteger messagesInSink = new AtomicInteger(0); AtomicBoolean hasFailed = new AtomicBoolean(false); @@ -350,7 +350,7 @@ void shouldBackOffIfPollingThrowsAnError() { .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) .maxDelayBetweenPolls(Duration.ofMillis(200)).pollBackOffPolicy(policy).build(); BackPressureHandler backPressureHandler = BackPressureHandlerFactory - .concurrencyLimiterBackPressureHandler(options, Duration.ofMillis(100L)); + .adaptativeThroughputBackPressureHandler(options, Duration.ofMillis(100L)); var currentPoll = new AtomicInteger(0); var waitThirdPollLatch = new CountDownLatch(4); From a1c6b44a4ac967c2a9e7dc48352654f0648a42cf Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Tue, 27 May 2025 17:38:13 +0200 Subject: [PATCH 15/29] Simplify ThroughputBackPressureHandler not to count in flight messages (#1251) --- .../listener/BackPressureHandlerFactory.java | 3 +- .../ThroughputBackPressureHandler.java | 67 ++++--------------- 2 files changed, 13 insertions(+), 57 deletions(-) diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java index a72bd12f1..c479b607d 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java @@ -169,8 +169,7 @@ static ConcurrencyLimiterBlockingBackPressureHandler concurrencyLimiterBackPress * @return the created ThroughputBackPressureHandler. */ static ThroughputBackPressureHandler throughputBackPressureHandler(ContainerOptions options) { - return ThroughputBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()) - .totalPermits(options.getMaxConcurrentMessages()).build(); + return ThroughputBackPressureHandler.builder().build(); } /** diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java index ec2525ef4..c18ee3de4 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java @@ -18,23 +18,18 @@ import io.awspring.cloud.sqs.listener.source.PollingMessageSource; import java.time.Duration; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.util.Assert; /** * Non-blocking {@link BackPressureHandler} implementation that uses a switch between high and low throughput modes. *

* Throughput modes *

    - *
  • In low-throughput mode, a single batch can be requested at a time. The number of permits that will be delivered - * is adjusted so that the number of in flight messages will not exceed the batch size.
  • + *
  • In low-throughput mode, a single batch can be requested at a time.
  • *
  • In high-throughput mode, multiple batches can be requested at a time. The number of permits that will be - * delivered is adjusted so that the number of in flight messages will not exceed the maximum number of concurrent - * messages. Note that for a single poll the maximum number of permits that will be delivered will not exceed the batch - * size.
  • + * delivered is the requested amount. *
*

* Throughput mode switch: The initial throughput mode is the low-throughput mode. If some messages are @@ -47,26 +42,21 @@ * * @see PollingMessageSource */ -public class ThroughputBackPressureHandler implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { +public class ThroughputBackPressureHandler implements BackPressureHandler, IdentifiableContainerComponent { private static final Logger logger = LoggerFactory.getLogger(ThroughputBackPressureHandler.class); - private final int batchSize; - private final int maxConcurrentMessages; - private final AtomicReference currentThroughputMode = new AtomicReference<>( CurrentThroughputMode.LOW); - private final AtomicInteger inFlightRequests = new AtomicInteger(0); + private final AtomicBoolean occupied = new AtomicBoolean(false); private final AtomicBoolean drained = new AtomicBoolean(false); private String id = getClass().getSimpleName(); private ThroughputBackPressureHandler(Builder builder) { - this.batchSize = builder.batchSize; - this.maxConcurrentMessages = builder.maxConcurrentMessages; - logger.debug("ThroughputBackPressureHandler created with batchSize {}", this.batchSize); + logger.debug("ThroughputBackPressureHandler created"); } public static Builder builder() { @@ -83,35 +73,21 @@ public String getId() { return this.id; } - @Override - public int requestBatch() throws InterruptedException { - return request(this.batchSize); - } - @Override public int request(int amount) throws InterruptedException { if (drained.get()) { return 0; } - int amountCappedAtBatchSize = Math.min(amount, this.batchSize); - int permits; - int inFlight = inFlightRequests.get(); - if (CurrentThroughputMode.LOW == this.currentThroughputMode.get()) { - // In low-throughput mode, we only acquire one batch at a time, - // so we need to limit the available permits to the batchSize - inFlight messages. - permits = Math.max(0, Math.min(amountCappedAtBatchSize, this.batchSize - inFlight)); - logger.debug("[{}] Acquired {} permits (low-throughput mode), requested: {}, in flight: {}", this.id, - permits, amount, inFlight); + CurrentThroughputMode throughputMode = this.currentThroughputMode.get(); + if (throughputMode == CurrentThroughputMode.LOW && this.occupied.get()) { + logger.debug("[{}] No permits acquired because a batch already being processed in low throughput mode", + this.id); + return 0; } else { - // In high-throughput mode, we can acquire more permits than the batch size, - // but we need to limit the available permits to the maxConcurrentMessages - inFlight messages. - permits = Math.max(0, Math.min(amountCappedAtBatchSize, this.maxConcurrentMessages - inFlight)); - logger.debug("[{}] Acquired {} permits (high-throughput mode), requested: {}, in flight: {}", this.id, - permits, amount, inFlight); + logger.debug("[{}] Acquired {} permits ({} mode)", this.id, amount, throughputMode); + return amount; } - inFlightRequests.addAndGet(permits); - return permits; } @Override @@ -120,7 +96,6 @@ public void release(int amount, ReleaseReason reason) { return; } logger.debug("[{}] Releasing {} permits ({})", this.id, amount, reason); - inFlightRequests.addAndGet(-amount); switch (reason) { case NONE_FETCHED -> updateThroughputMode(CurrentThroughputMode.HIGH, CurrentThroughputMode.LOW); case PARTIAL_FETCH -> updateThroughputMode(CurrentThroughputMode.LOW, CurrentThroughputMode.HIGH); @@ -153,25 +128,7 @@ private enum CurrentThroughputMode { public static class Builder { - private int batchSize; - private int maxConcurrentMessages; - - public Builder batchSize(int batchSize) { - this.batchSize = batchSize; - return this; - } - - public Builder totalPermits(int maxConcurrentMessages) { - this.maxConcurrentMessages = maxConcurrentMessages; - return this; - } - public ThroughputBackPressureHandler build() { - Assert.notNull(this.batchSize, "Missing batchSize configuration"); - Assert.isTrue(this.batchSize > 0, "batch size must be greater than 0"); - Assert.notNull(this.maxConcurrentMessages, "Missing maxConcurrentMessages configuration"); - Assert.notNull(this.maxConcurrentMessages >= this.batchSize, - "maxConcurrentMessages must be greater than or equal to batchSize"); return new ThroughputBackPressureHandler(this); } } From fd36d45f8d28778fa5f04abe078edf16bc6df507 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Wed, 28 May 2025 13:46:58 +0200 Subject: [PATCH 16/29] Introduce BlockerBackPressureHandler marker interface (#1251) --- .../listener/BlockingBackPressureHandler.java | 25 +++++++++++++++++++ ...ncyLimiterBlockingBackPressureHandler.java | 2 +- .../SemaphoreBackPressureHandler.java | 3 ++- 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BlockingBackPressureHandler.java diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BlockingBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BlockingBackPressureHandler.java new file mode 100644 index 000000000..5d5ecf6d0 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BlockingBackPressureHandler.java @@ -0,0 +1,25 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.listener; + +/** + * Marker interface for a blocking {@link BackPressureHandler}. This handler is used to control the flow of messages in + * a blocking manner. It is recommended to have at least one blocking back pressure handler in a + * {@link CompositeBackPressureHandler} in order to enable more resource efficient polling. + */ +public interface BlockingBackPressureHandler extends BackPressureHandler { + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java index 51129f183..e611af27b 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java @@ -31,7 +31,7 @@ * @see PollingMessageSource */ public class ConcurrencyLimiterBlockingBackPressureHandler - implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { + implements BlockingBackPressureHandler, BatchAwareBackPressureHandler, IdentifiableContainerComponent { private static final Logger logger = LoggerFactory.getLogger(ConcurrencyLimiterBlockingBackPressureHandler.class); diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java index 310b64519..f682400a9 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java @@ -31,7 +31,8 @@ * @since 3.0 * @see io.awspring.cloud.sqs.listener.source.PollingMessageSource */ -public class SemaphoreBackPressureHandler implements BatchAwareBackPressureHandler, IdentifiableContainerComponent { +public class SemaphoreBackPressureHandler + implements BlockingBackPressureHandler, BatchAwareBackPressureHandler, IdentifiableContainerComponent { private static final Logger logger = LoggerFactory.getLogger(SemaphoreBackPressureHandler.class); From 9316ce33992b0032bf2251de72293f063b83e4a6 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Wed, 28 May 2025 14:34:08 +0200 Subject: [PATCH 17/29] Move BackPressureHandler factory methods to BackPressureHandlerFactories class (#1251) --- .../listener/AbstractContainerOptions.java | 2 +- .../BackPressureHandlerFactories.java | 173 ++++++++++++++++++ .../listener/BackPressureHandlerFactory.java | 147 +-------------- .../SqsBackPressureIntegrationTests.java | 16 +- .../AbstractPollingMessageSourceTests.java | 10 +- ...dlerAbstractPollingMessageSourceTests.java | 8 +- 6 files changed, 192 insertions(+), 164 deletions(-) create mode 100644 spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java index 03959303d..b882cd3fe 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractContainerOptions.java @@ -240,7 +240,7 @@ protected abstract static class Builder, private static final BackPressureMode DEFAULT_THROUGHPUT_CONFIGURATION = BackPressureMode.AUTO; - private static final BackPressureHandlerFactory DEFAULT_BACKPRESSURE_FACTORY = BackPressureHandlerFactory::semaphoreBackPressureHandler; + private static final BackPressureHandlerFactory DEFAULT_BACKPRESSURE_FACTORY = BackPressureHandlerFactories::semaphoreBackPressureHandler; private static final ListenerMode DEFAULT_MESSAGE_DELIVERY_STRATEGY = ListenerMode.SINGLE_MESSAGE; diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java new file mode 100644 index 000000000..1241bf360 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java @@ -0,0 +1,173 @@ +/* + * Copyright 2013-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.listener; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * Spring Cloud AWS provides the following {@link BackPressureHandler} implementations: + *

    + *
  • {@link ConcurrencyLimiterBlockingBackPressureHandler}: Limits the maximum number of messages that can be * + * processed concurrently by the application.
  • * + *
  • {@link ThroughputBackPressureHandler}: Adapts the throughput dynamically between high and low modes in order to * + * reduce SQS pull costs when few messages are coming in.
  • * + *
  • {@link CompositeBackPressureHandler}: Allows combining multiple {@link BackPressureHandler} together and ensures + * * they cooperate.
  • * + *
+ *

+ * Below are a few examples of how common use cases can be achieved. Keep in mind you can always create your own * + * {@link BackPressureHandler} implementation and if needed combine it with the provided ones thanks to the * + * {@link CompositeBackPressureHandler}. * * + *

A BackPressureHandler limiting the max concurrency with high throughput

* * + * + *
{@code
+ * containerOptionsBuilder.backPressureHandlerFactory(containerOptions -> {
+ * 		return ConcurrencyLimiterBlockingBackPressureHandler.builder()
+ * 			.batchSize(containerOptions.getMaxMessagesPerPoll())
+ * 			.totalPermits(containerOptions.getMaxConcurrentMessages())
+ * 			.acquireTimeout(containerOptions.getMaxDelayBetweenPolls())
+ * 			.throughputConfiguration(BackPressureMode.FIXED_HIGH_THROUGHPUT)
+ * 			.build()
+ * }}
+ *

+ * * * + *

A BackPressureHandler limiting the max concurrency with dynamic throughput

* * + * + *
{@code
+ * containerOptionsBuilder.backPressureHandlerFactory(containerOptions -> {
+ * 		int batchSize = containerOptions.getMaxMessagesPerPoll();
+ * 		var concurrencyLimiterBlockingBackPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler.builder()
+ * 			.batchSize(batchSize)
+ * 			.totalPermits(containerOptions.getMaxConcurrentMessages())
+ * 			.acquireTimeout(containerOptions.getMaxDelayBetweenPolls())
+ * 			.throughputConfiguration(BackPressureMode.AUTO)
+ * 			.build()
+ * 		var throughputBackPressureHandler = ThroughputBackPressureHandler.builder()
+ * 			.batchSize(batchSize)
+ * 			.build();
+ * 		return new CompositeBackPressureHandler(List.of(
+ * 				concurrencyLimiterBlockingBackPressureHandler,
+ * 				throughputBackPressureHandler
+ * 			),
+ * 			batchSize,
+ * 			standbyLimitPollingInterval
+ * 		);
+ * }}
+ */ +public class BackPressureHandlerFactories { + + private BackPressureHandlerFactories() { + } + + /** + * Creates a new {@link SemaphoreBackPressureHandler} instance based on the provided {@link ContainerOptions}. + * + * @param options the container options. + * @return the created SemaphoreBackPressureHandler. + */ + public static BatchAwareBackPressureHandler semaphoreBackPressureHandler(ContainerOptions options) { + return SemaphoreBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()) + .totalPermits(options.getMaxConcurrentMessages()).acquireTimeout(options.getMaxDelayBetweenPolls()) + .throughputConfiguration(options.getBackPressureMode()).build(); + } + + /** + * Creates a new {@link BackPressureHandler} instance based on the provided {@link ContainerOptions} combining a + * {@link ConcurrencyLimiterBlockingBackPressureHandler}, a {@link ThroughputBackPressureHandler} and a + * {@link FullBatchBackPressureHandler}. The exact combination of depends on the given {@link ContainerOptions}. + * + * @param options the container options. + * @param maxIdleWaitTime the maximum amount of time to wait for a permit to be released in case no permits were + * obtained. + * @return the created SemaphoreBackPressureHandler. + */ + public static BatchAwareBackPressureHandler adaptativeThroughputBackPressureHandler(ContainerOptions options, + Duration maxIdleWaitTime) { + BackPressureMode backPressureMode = options.getBackPressureMode(); + + var concurrencyLimiterBlockingBackPressureHandler = concurrencyLimiterBackPressureHandler(options); + if (backPressureMode == BackPressureMode.FIXED_HIGH_THROUGHPUT) { + return concurrencyLimiterBlockingBackPressureHandler; + } + var backPressureHandlers = new ArrayList(); + backPressureHandlers.add(concurrencyLimiterBlockingBackPressureHandler); + + // The ThroughputBackPressureHandler should run second in the chain as it is non-blocking. + // Running it first would result in more polls as it would potentially limit the + // ConcurrencyLimiterBlockingBackPressureHandler to a lower amount of requested permits + // which means the ConcurrencyLimiterBlockingBackPressureHandler blocking behavior would + // not be optimally leveraged. + if (backPressureMode == BackPressureMode.AUTO + || backPressureMode == BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) { + backPressureHandlers.add(throughputBackPressureHandler(options)); + } + + // The FullBatchBackPressureHandler should run last in the chain to ensure that a full batch is requested or not + if (backPressureMode == BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) { + backPressureHandlers.add(fullBatchBackPressureHandler(options)); + } + return compositeBackPressureHandler(options, maxIdleWaitTime, backPressureHandlers); + } + + /** + * Creates a new {@link ConcurrencyLimiterBlockingBackPressureHandler} instance based on the provided + * {@link ContainerOptions}. + * + * @param options the container options. + * @return the created ConcurrencyLimiterBlockingBackPressureHandler. + */ + public static CompositeBackPressureHandler compositeBackPressureHandler(ContainerOptions options, + Duration maxIdleWaitTime, List backPressureHandlers) { + return new CompositeBackPressureHandler(List.copyOf(backPressureHandlers), options.getMaxMessagesPerPoll(), + maxIdleWaitTime); + } + + /** + * Creates a new {@link ConcurrencyLimiterBlockingBackPressureHandler} instance based on the provided + * {@link ContainerOptions}. + * + * @param options the container options. + * @return the created ConcurrencyLimiterBlockingBackPressureHandler. + */ + public static ConcurrencyLimiterBlockingBackPressureHandler concurrencyLimiterBackPressureHandler( + ContainerOptions options) { + return ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()) + .totalPermits(options.getMaxConcurrentMessages()).throughputConfiguration(options.getBackPressureMode()) + .acquireTimeout(options.getMaxDelayBetweenPolls()).build(); + } + + /** + * Creates a new {@link ThroughputBackPressureHandler} instance based on the provided {@link ContainerOptions}. + * + * @param options the container options. + * @return the created ThroughputBackPressureHandler. + */ + public static ThroughputBackPressureHandler throughputBackPressureHandler(ContainerOptions options) { + return ThroughputBackPressureHandler.builder().build(); + } + + /** + * Creates a new {@link FullBatchBackPressureHandler} instance based on the provided {@link ContainerOptions}. + * + * @param options the container options. + * @return the created FullBatchBackPressureHandler. + */ + public static FullBatchBackPressureHandler fullBatchBackPressureHandler(ContainerOptions options) { + return FullBatchBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()).build(); + } +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java index c479b607d..58b9d20e9 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java @@ -15,63 +15,12 @@ */ package io.awspring.cloud.sqs.listener; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; - /** * A factory for creating {@link BackPressureHandler} for managing queue consumption backpressure. Implementations can * configure each the {@link BackPressureHandler} according to its strategies, using the provided * {@link ContainerOptions}. *

- * Spring Cloud AWS provides the following {@link BackPressureHandler} implementations: - *

    - *
  • {@link ConcurrencyLimiterBlockingBackPressureHandler}: Limits the maximum number of messages that can be - * processed concurrently by the application.
  • - *
  • {@link ThroughputBackPressureHandler}: Adapts the throughput dynamically between high and low modes in order to - * reduce SQS pull costs when few messages are coming in.
  • - *
  • {@link CompositeBackPressureHandler}: Allows combining multiple {@link BackPressureHandler} together and ensures - * they cooperate.
  • - *
- *

- * Below are a few examples of how common use cases can be achieved. Keep in mind you can always create your own - * {@link BackPressureHandler} implementation and if needed combine it with the provided ones thanks to the - * {@link CompositeBackPressureHandler}. - * - *

A BackPressureHandler limiting the max concurrency with high throughput

- * - *
{@code
- * containerOptionsBuilder.backPressureHandlerFactory(containerOptions -> {
- * 		return ConcurrencyLimiterBlockingBackPressureHandler.builder()
- * 			.batchSize(containerOptions.getMaxMessagesPerPoll())
- * 			.totalPermits(containerOptions.getMaxConcurrentMessages())
- * 			.acquireTimeout(containerOptions.getMaxDelayBetweenPolls())
- * 			.throughputConfiguration(BackPressureMode.FIXED_HIGH_THROUGHPUT)
- * 			.build()
- * }}
- * - *

A BackPressureHandler limiting the max concurrency with dynamic throughput

- * - *
{@code
- * containerOptionsBuilder.backPressureHandlerFactory(containerOptions -> {
- * 		int batchSize = containerOptions.getMaxMessagesPerPoll();
- * 		var concurrencyLimiterBlockingBackPressureHandler = ConcurrencyLimiterBlockingBackPressureHandler.builder()
- * 			.batchSize(batchSize)
- * 			.totalPermits(containerOptions.getMaxConcurrentMessages())
- * 			.acquireTimeout(containerOptions.getMaxDelayBetweenPolls())
- * 			.throughputConfiguration(BackPressureMode.AUTO)
- * 			.build()
- * 		var throughputBackPressureHandler = ThroughputBackPressureHandler.builder()
- * 			.batchSize(batchSize)
- * 			.build();
- * 		return new CompositeBackPressureHandler(List.of(
- * 				concurrencyLimiterBlockingBackPressureHandler,
- * 				throughputBackPressureHandler
- * 			),
- * 			batchSize,
- * 			standbyLimitPollingInterval
- * 		);
- * }}
+ * A set of default implementations are provided by the {@link BackPressureHandlerFactories} class. */ public interface BackPressureHandlerFactory { @@ -86,98 +35,4 @@ public interface BackPressureHandlerFactory { * @return the created BackPressureHandler */ BackPressureHandler createBackPressureHandler(ContainerOptions containerOptions); - - /** - * Creates a new {@link SemaphoreBackPressureHandler} instance based on the provided {@link ContainerOptions}. - * - * @param options the container options. - * @return the created SemaphoreBackPressureHandler. - */ - static BatchAwareBackPressureHandler semaphoreBackPressureHandler(ContainerOptions options) { - return SemaphoreBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()) - .totalPermits(options.getMaxConcurrentMessages()).acquireTimeout(options.getMaxDelayBetweenPolls()) - .throughputConfiguration(options.getBackPressureMode()).build(); - } - - /** - * Creates a new {@link BackPressureHandler} instance based on the provided {@link ContainerOptions} combining a - * {@link ConcurrencyLimiterBlockingBackPressureHandler}, a {@link ThroughputBackPressureHandler} and a - * {@link FullBatchBackPressureHandler}. The exact combination of depends on the given {@link ContainerOptions}. - * - * @param options the container options. - * @param maxIdleWaitTime the maximum amount of time to wait for a permit to be released in case no permits were - * obtained. - * @return the created SemaphoreBackPressureHandler. - */ - static BatchAwareBackPressureHandler adaptativeThroughputBackPressureHandler(ContainerOptions options, - Duration maxIdleWaitTime) { - BackPressureMode backPressureMode = options.getBackPressureMode(); - - var concurrencyLimiterBlockingBackPressureHandler = concurrencyLimiterBackPressureHandler(options); - if (backPressureMode == BackPressureMode.FIXED_HIGH_THROUGHPUT) { - return concurrencyLimiterBlockingBackPressureHandler; - } - var backPressureHandlers = new ArrayList(); - backPressureHandlers.add(concurrencyLimiterBlockingBackPressureHandler); - - // The ThroughputBackPressureHandler should run second in the chain as it is non-blocking. - // Running it first would result in more polls as it would potentially limit the - // ConcurrencyLimiterBlockingBackPressureHandler to a lower amount of requested permits - // which means the ConcurrencyLimiterBlockingBackPressureHandler blocking behavior would - // not be optimally leveraged. - if (backPressureMode == BackPressureMode.AUTO - || backPressureMode == BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) { - backPressureHandlers.add(throughputBackPressureHandler(options)); - } - - // The FullBatchBackPressureHandler should run last in the chain to ensure that a full batch is requested or not - if (backPressureMode == BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) { - backPressureHandlers.add(fullBatchBackPressureHandler(options)); - } - return compositeBackPressureHandler(options, maxIdleWaitTime, backPressureHandlers); - } - - /** - * Creates a new {@link ConcurrencyLimiterBlockingBackPressureHandler} instance based on the provided - * {@link ContainerOptions}. - * - * @param options the container options. - * @return the created ConcurrencyLimiterBlockingBackPressureHandler. - */ - static CompositeBackPressureHandler compositeBackPressureHandler(ContainerOptions options, - Duration maxIdleWaitTime, List backPressureHandlers) { - return new CompositeBackPressureHandler(List.copyOf(backPressureHandlers), options.getMaxMessagesPerPoll(), - maxIdleWaitTime); - } - - /** - * Creates a new {@link ConcurrencyLimiterBlockingBackPressureHandler} instance based on the provided - * {@link ContainerOptions}. - * @param options the container options. - * @return the created ConcurrencyLimiterBlockingBackPressureHandler. - */ - static ConcurrencyLimiterBlockingBackPressureHandler concurrencyLimiterBackPressureHandler( - ContainerOptions options) { - return ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()) - .totalPermits(options.getMaxConcurrentMessages()).throughputConfiguration(options.getBackPressureMode()) - .acquireTimeout(options.getMaxDelayBetweenPolls()).build(); - } - - /** - * Creates a new {@link ThroughputBackPressureHandler} instance based on the provided {@link ContainerOptions}. - * @param options the container options. - * @return the created ThroughputBackPressureHandler. - */ - static ThroughputBackPressureHandler throughputBackPressureHandler(ContainerOptions options) { - return ThroughputBackPressureHandler.builder().build(); - } - - /** - * Creates a new {@link FullBatchBackPressureHandler} instance based on the provided {@link ContainerOptions}. - * @param options the container options. - * @return the created FullBatchBackPressureHandler. - */ - static FullBatchBackPressureHandler fullBatchBackPressureHandler(ContainerOptions options) { - return FullBatchBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()).build(); - } } diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java index 8fc1fec03..933e94173 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java @@ -132,9 +132,9 @@ void staticBackPressureLimitShouldCapQueueProcessingCapacity(int staticLimit, in options -> options.maxMessagesPerPoll(5).maxConcurrentMessages(5) .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofSeconds(1)) .pollTimeout(Duration.ofSeconds(1)) - .backPressureHandlerFactory(containerOptions -> BackPressureHandlerFactory + .backPressureHandlerFactory(containerOptions -> BackPressureHandlerFactories .compositeBackPressureHandler(containerOptions, Duration.ofMillis(50L), - List.of(limiter, BackPressureHandlerFactory + List.of(limiter, BackPressureHandlerFactories .concurrencyLimiterBackPressureHandler(containerOptions))))) .messageListener(msg -> { int concurrentRqs = concurrentRequest.incrementAndGet(); @@ -170,9 +170,9 @@ void zeroBackPressureLimitShouldStopQueueProcessing() throws Exception { options -> options.maxMessagesPerPoll(5).maxConcurrentMessages(5) .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofSeconds(1)) .pollTimeout(Duration.ofSeconds(1)) - .backPressureHandlerFactory(containerOptions -> BackPressureHandlerFactory + .backPressureHandlerFactory(containerOptions -> BackPressureHandlerFactories .compositeBackPressureHandler(containerOptions, Duration.ofMillis(50L), - List.of(limiter, BackPressureHandlerFactory + List.of(limiter, BackPressureHandlerFactories .concurrencyLimiterBackPressureHandler(containerOptions))))) .messageListener(msg -> { int concurrentRqs = concurrentRequest.incrementAndGet(); @@ -214,9 +214,9 @@ void changeInBackPressureLimitShouldAdaptQueueProcessingCapacity() throws Except options -> options.maxMessagesPerPoll(5).maxConcurrentMessages(5) .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofSeconds(1)) .pollTimeout(Duration.ofSeconds(1)) - .backPressureHandlerFactory(containerOptions -> BackPressureHandlerFactory + .backPressureHandlerFactory(containerOptions -> BackPressureHandlerFactories .compositeBackPressureHandler(containerOptions, Duration.ofMillis(50L), - List.of(limiter, BackPressureHandlerFactory + List.of(limiter, BackPressureHandlerFactories .concurrencyLimiterBackPressureHandler(containerOptions))))) .messageListener(msg -> { try { @@ -441,9 +441,9 @@ void unsynchronizedChangesInBackPressureLimitShouldAdaptQueueProcessingCapacity( .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofSeconds(1)) .pollTimeout(Duration.ofSeconds(1)) .backPressureHandlerFactory(containerOptions -> new StatisticsBphDecorator( - BackPressureHandlerFactory.compositeBackPressureHandler(containerOptions, + BackPressureHandlerFactories.compositeBackPressureHandler(containerOptions, Duration.ofMillis(50L), - List.of(limiter, BackPressureHandlerFactory + List.of(limiter, BackPressureHandlerFactories .concurrencyLimiterBackPressureHandler(containerOptions))), eventsCsvWriter))) .messageListener(msg -> { diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java index df3b5a1bc..6ea1f6a0e 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java @@ -61,7 +61,7 @@ void shouldAcquireAndReleaseFullPermits() { SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) .maxDelayBetweenPolls(Duration.ofMillis(200)).build(); - BackPressureHandler backPressureHandler = BackPressureHandlerFactory + BackPressureHandler backPressureHandler = BackPressureHandlerFactories .adaptativeThroughputBackPressureHandler(options, Duration.ofMillis(100L)); ExecutorService threadPool = Executors.newCachedThreadPool(); @@ -119,7 +119,7 @@ void shouldAdaptThroughputMode() { SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) .maxDelayBetweenPolls(Duration.ofMillis(150)).build(); - BackPressureHandler backPressureHandler = BackPressureHandlerFactory + BackPressureHandler backPressureHandler = BackPressureHandlerFactories .adaptativeThroughputBackPressureHandler(options, Duration.ofMillis(100L)); ExecutorService threadPool = Executors.newCachedThreadPool(); @@ -205,7 +205,7 @@ void shouldAcquireAndReleasePartialPermits() { String testName = "shouldAcquireAndReleasePartialPermits"; SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofMillis(150)).build(); - BackPressureHandler backPressureHandler = BackPressureHandlerFactory + BackPressureHandler backPressureHandler = BackPressureHandlerFactories .adaptativeThroughputBackPressureHandler(options, Duration.ofMillis(200L)); ExecutorService threadPool = Executors @@ -296,7 +296,7 @@ public org.springframework.messaging.Message toMessagingMessage(Message sourc SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) .maxDelayBetweenPolls(Duration.ofMillis(150)).messageConverter(converter).build(); - BackPressureHandler backPressureHandler = BackPressureHandlerFactory + BackPressureHandler backPressureHandler = BackPressureHandlerFactories .adaptativeThroughputBackPressureHandler(options, Duration.ofMillis(100L)); AtomicInteger messagesInSink = new AtomicInteger(0); @@ -349,7 +349,7 @@ void shouldBackOffIfPollingThrowsAnError() { SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(40) .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) .maxDelayBetweenPolls(Duration.ofMillis(200)).pollBackOffPolicy(policy).build(); - BackPressureHandler backPressureHandler = BackPressureHandlerFactory + BackPressureHandler backPressureHandler = BackPressureHandlerFactories .adaptativeThroughputBackPressureHandler(options, Duration.ofMillis(100L)); var currentPoll = new AtomicInteger(0); diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/SemaphoreBackPressureHandlerAbstractPollingMessageSourceTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/SemaphoreBackPressureHandlerAbstractPollingMessageSourceTests.java index 94cb76959..52232a19f 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/SemaphoreBackPressureHandlerAbstractPollingMessageSourceTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/SemaphoreBackPressureHandlerAbstractPollingMessageSourceTests.java @@ -66,7 +66,7 @@ class SemaphoreBackPressureHandlerAbstractPollingMessageSourceTests { @Test void shouldAcquireAndReleaseFullPermits() { String testName = "shouldAcquireAndReleaseFullPermits"; - BackPressureHandler backPressureHandler = BackPressureHandlerFactory + BackPressureHandler backPressureHandler = BackPressureHandlerFactories .semaphoreBackPressureHandler(SqsContainerOptions.builder().maxMessagesPerPoll(10) .maxConcurrentMessages(10).backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) .maxDelayBetweenPolls(Duration.ofMillis(200)).build()); @@ -142,7 +142,7 @@ else if (hasMadeSecondPoll.compareAndSet(false, true)) { @Test void shouldAcquireAndReleasePartialPermits() { String testName = "shouldAcquireAndReleasePartialPermits"; - BackPressureHandler backPressureHandler = BackPressureHandlerFactory.semaphoreBackPressureHandler( + BackPressureHandler backPressureHandler = BackPressureHandlerFactories.semaphoreBackPressureHandler( SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofMillis(150)).build()); @@ -241,7 +241,7 @@ else if (hasAcquired9.compareAndSet(false, true)) { @Test void shouldReleasePermitsOnConversionErrors() { String testName = "shouldReleasePermitsOnConversionErrors"; - BackPressureHandler backPressureHandler = BackPressureHandlerFactory.semaphoreBackPressureHandler( + BackPressureHandler backPressureHandler = BackPressureHandlerFactories.semaphoreBackPressureHandler( SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofMillis(150)).build()); @@ -302,7 +302,7 @@ private Collection create10Messages() { @Test void shouldBackOffIfPollingThrowsAnError() { var testName = "shouldBackOffIfPollingThrowsAnError"; - BackPressureHandler backPressureHandler = BackPressureHandlerFactory + BackPressureHandler backPressureHandler = BackPressureHandlerFactories .semaphoreBackPressureHandler(SqsContainerOptions.builder().maxMessagesPerPoll(10) .maxConcurrentMessages(40).backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) .maxDelayBetweenPolls(Duration.ofMillis(200)).build()); From 77488f142b6b58ce49e76fd4d913dca4fa85d5ad Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Tue, 10 Jun 2025 16:29:44 +0200 Subject: [PATCH 18/29] Improve javadoc clarity --- .../cloud/sqs/listener/BackPressureHandler.java | 10 +++++----- .../cloud/sqs/listener/BackPressureHandlerFactory.java | 10 ++++++---- .../awspring/cloud/sqs/listener/ContainerOptions.java | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java index 55e5a25f0..dd73c28b8 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java @@ -41,9 +41,9 @@ public interface BackPressureHandler { * Releases the specified amount of permits for processed messages. Each message that has been processed should * release one permit, whether processing was successful or not. *

- * This method can is called in the following use cases: + * This method can be called in the following use cases: *

    - *
  • {@link ReleaseReason#LIMITED}: permits were not used because another BackPressureHandler has a lower permits + *
  • {@link ReleaseReason#LIMITED}: all/some permits were not used because another BackPressureHandler has a lower permits * limit and the difference in permits needs to be returned.
  • *
  • {@link ReleaseReason#NONE_FETCHED}: none of the permits were actually used because no messages were retrieved * from SQS. Permits need to be returned.
  • @@ -82,8 +82,8 @@ default void release(int amount) { enum ReleaseReason { /** - * Permits were not used because another BackPressureHandler has a lower permits limit and the difference need - * to be aligned across all handlers. + * All/Some permits were not used because another BackPressureHandler has a lower permits limit and the + * permits difference need to be aligned across all handlers. */ LIMITED, /** @@ -91,7 +91,7 @@ enum ReleaseReason { */ NONE_FETCHED, /** - * Some messages were fetched from SQS. Unused permits need to be returned. + * Some messages were fetched from SQS. Unused permits if any need to be returned. */ PARTIAL_FETCH, /** diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java index 58b9d20e9..5c00d685a 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java @@ -16,11 +16,13 @@ package io.awspring.cloud.sqs.listener; /** - * A factory for creating {@link BackPressureHandler} for managing queue consumption backpressure. Implementations can - * configure each the {@link BackPressureHandler} according to its strategies, using the provided - * {@link ContainerOptions}. + * Factory interface for creating {@link BackPressureHandler} instances to manage queue consumption backpressure. *

    - * A set of default implementations are provided by the {@link BackPressureHandlerFactories} class. + * Implementations of this interface are responsible for producing a new {@link BackPressureHandler} for each + * container, configured according to the provided {@link ContainerOptions}. This ensures that internal resources + * (such as counters or semaphores) are not shared across containers, which could lead to unintended side effects. + *

    + * Default factory implementations can be found in the {@link BackPressureHandlerFactories} class. */ public interface BackPressureHandlerFactory { diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java index f1a324a30..e6173f8ca 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java @@ -130,7 +130,7 @@ default BackOffPolicy getPollBackOffPolicy() { BackPressureMode getBackPressureMode(); /** - * Return the a {@link BackPressureHandlerFactory} to create a {@link BackPressureHandler} for this container. + * Return the {@link BackPressureHandlerFactory} to create a {@link BackPressureHandler} for this container. * @return the BackPressureHandlerFactory. */ BackPressureHandlerFactory getBackPressureHandlerFactory(); From af9d6ce1d5ba6723e06dc137da545625813e304a Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Tue, 10 Jun 2025 16:56:48 +0200 Subject: [PATCH 19/29] Add tests for ThroughputBackPressureHandler (#1251) --- .../sqs/listener/BackPressureHandler.java | 8 +- .../listener/BackPressureHandlerFactory.java | 6 +- .../ThroughputBackPressureHandler.java | 34 +++++--- .../ThroughputBackPressureHandlerTest.java | 85 +++++++++++++++++++ 4 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandlerTest.java diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java index dd73c28b8..b41e11245 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java @@ -43,8 +43,8 @@ public interface BackPressureHandler { *

    * This method can be called in the following use cases: *

      - *
    • {@link ReleaseReason#LIMITED}: all/some permits were not used because another BackPressureHandler has a lower permits - * limit and the difference in permits needs to be returned.
    • + *
    • {@link ReleaseReason#LIMITED}: all/some permits were not used because another BackPressureHandler has a lower + * permits limit and the difference in permits needs to be returned.
    • *
    • {@link ReleaseReason#NONE_FETCHED}: none of the permits were actually used because no messages were retrieved * from SQS. Permits need to be returned.
    • *
    • {@link ReleaseReason#PARTIAL_FETCH}: some of the permits were used (some messages were retrieved from SQS). @@ -82,8 +82,8 @@ default void release(int amount) { enum ReleaseReason { /** - * All/Some permits were not used because another BackPressureHandler has a lower permits limit and the - * permits difference need to be aligned across all handlers. + * All/Some permits were not used because another BackPressureHandler has a lower permits limit and the permits + * difference need to be aligned across all handlers. */ LIMITED, /** diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java index 5c00d685a..5ec35cc3e 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactory.java @@ -18,9 +18,9 @@ /** * Factory interface for creating {@link BackPressureHandler} instances to manage queue consumption backpressure. *

      - * Implementations of this interface are responsible for producing a new {@link BackPressureHandler} for each - * container, configured according to the provided {@link ContainerOptions}. This ensures that internal resources - * (such as counters or semaphores) are not shared across containers, which could lead to unintended side effects. + * Implementations of this interface are responsible for producing a new {@link BackPressureHandler} for each container, + * configured according to the provided {@link ContainerOptions}. This ensures that internal resources (such as counters + * or semaphores) are not shared across containers, which could lead to unintended side effects. *

      * Default factory implementations can be found in the {@link BackPressureHandlerFactories} class. */ diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java index c18ee3de4..e2169404a 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java @@ -27,7 +27,8 @@ *

      * Throughput modes *

        - *
      • In low-throughput mode, a single batch can be requested at a time.
      • + *
      • In low-throughput mode, a single batch can be requested at a time. The number of permits that will be * delivered + * is the requested amount or 0 is a batch is already in-flight.
      • *
      • In high-throughput mode, multiple batches can be requested at a time. The number of permits that will be * delivered is the requested amount.
      • *
      @@ -55,7 +56,7 @@ public class ThroughputBackPressureHandler implements BackPressureHandler, Ident private String id = getClass().getSimpleName(); - private ThroughputBackPressureHandler(Builder builder) { + private ThroughputBackPressureHandler() { logger.debug("ThroughputBackPressureHandler created"); } @@ -79,15 +80,16 @@ public int request(int amount) throws InterruptedException { return 0; } CurrentThroughputMode throughputMode = this.currentThroughputMode.get(); - if (throughputMode == CurrentThroughputMode.LOW && this.occupied.get()) { - logger.debug("[{}] No permits acquired because a batch already being processed in low throughput mode", - this.id); - return 0; - } - else { - logger.debug("[{}] Acquired {} permits ({} mode)", this.id, amount, throughputMode); - return amount; + if (throughputMode == CurrentThroughputMode.LOW) { + if (this.occupied.get()) { + logger.debug("[{}] No permits acquired because a batch already being processed in low throughput mode", + this.id); + return 0; + } + this.occupied.set(true); } + logger.debug("[{}] Acquired {} permits ({} mode)", this.id, amount, throughputMode); + return amount; } @Override @@ -97,8 +99,14 @@ public void release(int amount, ReleaseReason reason) { } logger.debug("[{}] Releasing {} permits ({})", this.id, amount, reason); switch (reason) { - case NONE_FETCHED -> updateThroughputMode(CurrentThroughputMode.HIGH, CurrentThroughputMode.LOW); - case PARTIAL_FETCH -> updateThroughputMode(CurrentThroughputMode.LOW, CurrentThroughputMode.HIGH); + case NONE_FETCHED -> { + this.occupied.compareAndSet(true, false); + updateThroughputMode(CurrentThroughputMode.HIGH, CurrentThroughputMode.LOW); + } + case PARTIAL_FETCH -> { + this.occupied.compareAndSet(true, false); + updateThroughputMode(CurrentThroughputMode.LOW, CurrentThroughputMode.HIGH); + } case LIMITED, PROCESSED -> { // No need to switch throughput mode } @@ -129,7 +137,7 @@ private enum CurrentThroughputMode { public static class Builder { public ThroughputBackPressureHandler build() { - return new ThroughputBackPressureHandler(this); + return new ThroughputBackPressureHandler(); } } } diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandlerTest.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandlerTest.java new file mode 100644 index 000000000..4b136e614 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandlerTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2013-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.listener; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class ThroughputBackPressureHandlerTest { + + private ThroughputBackPressureHandler handler; + + @BeforeEach + void setUp() { + handler = new ThroughputBackPressureHandler.Builder().build(); + } + + @ParameterizedTest + @CsvSource({ "LIMITED,0", "PROCESSED,0", "NONE_FETCHED,5", "PARTIAL_FETCH,5", }) + void lowThroughputMode_shouldReturnZeroUntilRelease(BackPressureHandler.ReleaseReason releaseReason, + int expectedPermitsAfterRelease) throws InterruptedException { + // Given a first batch + int batchSize = 5; + assertThat(handler.request(batchSize)).isEqualTo(batchSize); + // When a second batch is requested, it should return zero permits (because low throughput mode) + assertThat(handler.request(batchSize)).isEqualTo(0); + // When a batch is requested after a release, the expected permits should be + // returned depending on the release reason + handler.release(1, releaseReason); + assertThat(handler.request(batchSize)).isEqualTo(expectedPermitsAfterRelease); + } + + @Test + void highThroughputMode_shouldAllowMultipleConcurrentRequests() throws InterruptedException { + // Given a first batch with polled messages + int batchSize = 5; + assertThat(handler.request(batchSize)).isEqualTo(batchSize); + handler.release(0, BackPressureHandler.ReleaseReason.PARTIAL_FETCH); // switch to HIGH + // Then subsequent requests should return the same batch size + // because we are in high throughput mode + assertThat(handler.request(batchSize)).isEqualTo(batchSize); + assertThat(handler.request(batchSize)).isEqualTo(batchSize); + handler.release(0, BackPressureHandler.ReleaseReason.PARTIAL_FETCH); + handler.release(0, BackPressureHandler.ReleaseReason.PARTIAL_FETCH); + // When a fetch returns no messages, throughput mode should switch to LOW + assertThat(handler.request(batchSize)).isEqualTo(batchSize); + handler.release(5, BackPressureHandler.ReleaseReason.NONE_FETCHED); + assertThat(handler.request(batchSize)).isEqualTo(batchSize); + // And subsequent requests should return zero permits until the current batch finishes with NONE_FETCHED + assertThat(handler.request(batchSize)).isEqualTo(0); + assertThat(handler.request(batchSize)).isEqualTo(0); + handler.release(5, BackPressureHandler.ReleaseReason.NONE_FETCHED); + assertThat(handler.request(batchSize)).isEqualTo(5); + // or until it (the current batch) finishes with PARTIAL_FETCH + assertThat(handler.request(batchSize)).isEqualTo(0); + assertThat(handler.request(batchSize)).isEqualTo(0); + handler.release(3, BackPressureHandler.ReleaseReason.PARTIAL_FETCH); + assertThat(handler.request(batchSize)).isEqualTo(5); + } + + @Test + void drain_shouldSetDrainedAndReturnTrue() throws InterruptedException { + boolean result = handler.drain(Duration.ofSeconds(1)); + assertThat(result).isTrue(); + assertThat(handler.request(5)).isEqualTo(0); + } + +} From 0da12abc6654180d0f7b8f4d30ad0f7b0497704d Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Tue, 10 Jun 2025 17:12:55 +0200 Subject: [PATCH 20/29] Add tests for ConcurrencyLimiterBlockingBackPressureHandler (#1251) --- .../BackPressureHandlerFactories.java | 2 +- ...ncyLimiterBlockingBackPressureHandler.java | 9 +--- ...imiterBlockingBackPressureHandlerTest.java | 46 +++++++++++++++++++ .../ThroughputBackPressureHandlerTest.java | 12 ++--- 4 files changed, 54 insertions(+), 15 deletions(-) create mode 100644 spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandlerTest.java diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java index 1241bf360..637120b78 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java @@ -147,7 +147,7 @@ public static CompositeBackPressureHandler compositeBackPressureHandler(Containe public static ConcurrencyLimiterBlockingBackPressureHandler concurrencyLimiterBackPressureHandler( ContainerOptions options) { return ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()) - .totalPermits(options.getMaxConcurrentMessages()).throughputConfiguration(options.getBackPressureMode()) + .totalPermits(options.getMaxConcurrentMessages()) .acquireTimeout(options.getMaxDelayBetweenPolls()).build(); } diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java index e611af27b..072d507ca 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java @@ -126,8 +126,6 @@ public static class Builder { private Duration acquireTimeout; - private BackPressureMode backPressureMode; - public Builder batchSize(int batchSize) { this.batchSize = batchSize; return this; @@ -143,14 +141,9 @@ public Builder acquireTimeout(Duration acquireTimeout) { return this; } - public Builder throughputConfiguration(BackPressureMode backPressureConfiguration) { - this.backPressureMode = backPressureConfiguration; - return this; - } - public ConcurrencyLimiterBlockingBackPressureHandler build() { Assert.noNullElements( - Arrays.asList(this.batchSize, this.totalPermits, this.acquireTimeout, this.backPressureMode), + Arrays.asList(this.batchSize, this.totalPermits, this.acquireTimeout), "Missing configuration"); Assert.isTrue(this.batchSize > 0, "The batch size must be greater than 0"); Assert.isTrue(this.totalPermits >= this.batchSize, "Total permits must be greater than the batch size"); diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandlerTest.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandlerTest.java new file mode 100644 index 000000000..f3f2fcb54 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandlerTest.java @@ -0,0 +1,46 @@ +package io.awspring.cloud.sqs.listener; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConcurrencyLimiterBlockingBackPressureHandlerTest { + + private static final int BATCH_SIZE = 5; + private static final int TOTAL_PERMITS = 10; + + private ConcurrencyLimiterBlockingBackPressureHandler handler; + + @BeforeEach + void setUp() { + handler = ConcurrencyLimiterBlockingBackPressureHandler.builder() + .totalPermits(TOTAL_PERMITS) + .batchSize(BATCH_SIZE) + .acquireTimeout(Duration.ofMillis(100)) + .build(); + } + + @Test + void request_shouldAcquirePermits() throws InterruptedException { + // Requesting a first batch should acquire the permits + assertThat(handler.request(BATCH_SIZE)).isEqualTo(BATCH_SIZE); + // Requesting a second batch should acquire the remaining permits + assertThat(handler.request(BATCH_SIZE)).isEqualTo(BATCH_SIZE); + // No permits left + assertThat(handler.request(1)).isZero(); + } + + @Test + void release_shouldAllowFurtherRequests() throws InterruptedException { + // Given all permits are acquired + assertThat(handler.request(TOTAL_PERMITS)).isEqualTo(TOTAL_PERMITS); + assertThat(handler.request(1)).isZero(); + // When releasing some permits, new requests should be allowed + handler.release(3, BackPressureHandler.ReleaseReason.PROCESSED); + assertThat(handler.request(5)).isEqualTo(3); // Only 3 permits were released so far + } +} + diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandlerTest.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandlerTest.java index 4b136e614..e6ea93145 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandlerTest.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandlerTest.java @@ -40,7 +40,7 @@ void lowThroughputMode_shouldReturnZeroUntilRelease(BackPressureHandler.ReleaseR int batchSize = 5; assertThat(handler.request(batchSize)).isEqualTo(batchSize); // When a second batch is requested, it should return zero permits (because low throughput mode) - assertThat(handler.request(batchSize)).isEqualTo(0); + assertThat(handler.request(batchSize)).isZero(); // When a batch is requested after a release, the expected permits should be // returned depending on the release reason handler.release(1, releaseReason); @@ -64,13 +64,13 @@ void highThroughputMode_shouldAllowMultipleConcurrentRequests() throws Interrupt handler.release(5, BackPressureHandler.ReleaseReason.NONE_FETCHED); assertThat(handler.request(batchSize)).isEqualTo(batchSize); // And subsequent requests should return zero permits until the current batch finishes with NONE_FETCHED - assertThat(handler.request(batchSize)).isEqualTo(0); - assertThat(handler.request(batchSize)).isEqualTo(0); + assertThat(handler.request(batchSize)).isZero(); + assertThat(handler.request(batchSize)).isZero(); handler.release(5, BackPressureHandler.ReleaseReason.NONE_FETCHED); assertThat(handler.request(batchSize)).isEqualTo(5); // or until it (the current batch) finishes with PARTIAL_FETCH - assertThat(handler.request(batchSize)).isEqualTo(0); - assertThat(handler.request(batchSize)).isEqualTo(0); + assertThat(handler.request(batchSize)).isZero(); + assertThat(handler.request(batchSize)).isZero(); handler.release(3, BackPressureHandler.ReleaseReason.PARTIAL_FETCH); assertThat(handler.request(batchSize)).isEqualTo(5); } @@ -79,7 +79,7 @@ void highThroughputMode_shouldAllowMultipleConcurrentRequests() throws Interrupt void drain_shouldSetDrainedAndReturnTrue() throws InterruptedException { boolean result = handler.drain(Duration.ofSeconds(1)); assertThat(result).isTrue(); - assertThat(handler.request(5)).isEqualTo(0); + assertThat(handler.request(5)).isZero(); } } From 4d3c13dc82bf9203f1bcf32e428539abd5447fe1 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Tue, 10 Jun 2025 17:16:11 +0200 Subject: [PATCH 21/29] Add tests for FullBatchBackPressureHandler (#1251) --- .../FullBatchBackPressureHandlerTest.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/FullBatchBackPressureHandlerTest.java diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/FullBatchBackPressureHandlerTest.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/FullBatchBackPressureHandlerTest.java new file mode 100644 index 000000000..3c01a51f9 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/FullBatchBackPressureHandlerTest.java @@ -0,0 +1,39 @@ +package io.awspring.cloud.sqs.listener; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class FullBatchBackPressureHandlerTest { + + private FullBatchBackPressureHandler handler; + + private final int batchSize = 10; + + @BeforeEach + void setUp() { + handler = FullBatchBackPressureHandler.builder() + .batchSize(batchSize) + .build(); + } + + @Test + void request_withExactBatchSize_shouldReturnBatchSize() throws InterruptedException { + assertThat(handler.request(batchSize)).isEqualTo(batchSize); + } + + @Test + void request_withNonBatchSize_shouldReturnZero() throws InterruptedException { + int permits = handler.request(batchSize - 1); + assertThat(permits).isZero(); + permits = handler.request(batchSize + 1); + assertThat(permits).isZero(); + } + + @Test + void requestBatch_shouldReturnBatchSize() throws InterruptedException { + assertThat(handler.requestBatch()).isEqualTo(batchSize); + } +} + From cf3f56794daafa983f5b3988ee37113c56a02515 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Wed, 11 Jun 2025 14:49:57 +0200 Subject: [PATCH 22/29] Add tests for CompositeBackPressureHandlerTest (#1251) --- ...tractPipelineMessageListenerContainer.java | 1 - .../BackPressureHandlerFactories.java | 8 +- .../CompositeBackPressureHandler.java | 52 +++++- ...ncyLimiterBlockingBackPressureHandler.java | 3 +- .../CompositeBackPressureHandlerTest.java | 158 ++++++++++++++++++ ...imiterBlockingBackPressureHandlerTest.java | 62 ++++--- .../FullBatchBackPressureHandlerTest.java | 66 +++++--- 7 files changed, 281 insertions(+), 69 deletions(-) create mode 100644 spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandlerTest.java diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java index 47b0bc8ff..66645f02b 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java @@ -36,7 +36,6 @@ import io.awspring.cloud.sqs.listener.source.MessageSource; import io.awspring.cloud.sqs.listener.source.PollingMessageSource; import io.awspring.cloud.sqs.support.observation.AbstractListenerObservation; -import java.time.Duration; import java.util.ArrayList; import java.util.Collection; import java.util.List; diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java index 637120b78..68688ad4e 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java @@ -133,8 +133,8 @@ public static BatchAwareBackPressureHandler adaptativeThroughputBackPressureHand */ public static CompositeBackPressureHandler compositeBackPressureHandler(ContainerOptions options, Duration maxIdleWaitTime, List backPressureHandlers) { - return new CompositeBackPressureHandler(List.copyOf(backPressureHandlers), options.getMaxMessagesPerPoll(), - maxIdleWaitTime); + return CompositeBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()) + .noPermitsReturnedWaitTimeout(maxIdleWaitTime).backPressureHandlers(backPressureHandlers).build(); } /** @@ -147,8 +147,8 @@ public static CompositeBackPressureHandler compositeBackPressureHandler(Containe public static ConcurrencyLimiterBlockingBackPressureHandler concurrencyLimiterBackPressureHandler( ContainerOptions options) { return ConcurrencyLimiterBlockingBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()) - .totalPermits(options.getMaxConcurrentMessages()) - .acquireTimeout(options.getMaxDelayBetweenPolls()).build(); + .totalPermits(options.getMaxConcurrentMessages()).acquireTimeout(options.getMaxDelayBetweenPolls()) + .build(); } /** diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java index a53722f17..9bfaf5bb4 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandler.java @@ -23,6 +23,7 @@ import java.util.concurrent.locks.ReentrantLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.util.Assert; /** * Composite {@link BackPressureHandler} implementation that delegates the back-pressure handling to a list of @@ -50,23 +51,23 @@ public class CompositeBackPressureHandler implements BatchAwareBackPressureHandl private static final Logger logger = LoggerFactory.getLogger(CompositeBackPressureHandler.class); - private final List backPressureHandlers; + private String id; private final int batchSize; + private final Duration noPermitsReturnedWaitTimeout; + + private final List backPressureHandlers; + private final ReentrantLock noPermitsReturnedWaitLock = new ReentrantLock(); private final Condition permitsReleasedCondition = noPermitsReturnedWaitLock.newCondition(); - private final Duration noPermitsReturnedWaitTimeout; - - private String id; + private CompositeBackPressureHandler(Builder builder) { + this.batchSize = builder.batchSize; + this.noPermitsReturnedWaitTimeout = builder.noPermitsReturnedWaitTimeout; + this.backPressureHandlers = List.copyOf(builder.backPressureHandlers); - public CompositeBackPressureHandler(List backPressureHandlers, int batchSize, - Duration noPermitsReturnedWaitTimeout) { - this.backPressureHandlers = backPressureHandlers; - this.batchSize = batchSize; - this.noPermitsReturnedWaitTimeout = noPermitsReturnedWaitTimeout; } @Override @@ -168,4 +169,37 @@ public boolean drain(Duration timeout) { private static Duration maxDuration(Duration first, Duration second) { return first.compareTo(second) > 0 ? first : second; } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private int batchSize; + private Duration noPermitsReturnedWaitTimeout; + private List backPressureHandlers; + + public Builder backPressureHandlers(List backPressureHandlers) { + this.backPressureHandlers = backPressureHandlers; + return this; + } + + public Builder batchSize(int batchSize) { + this.batchSize = batchSize; + return this; + } + + public Builder noPermitsReturnedWaitTimeout(Duration noPermitsReturnedWaitTimeout) { + this.noPermitsReturnedWaitTimeout = noPermitsReturnedWaitTimeout; + return this; + } + + public CompositeBackPressureHandler build() { + Assert.notNull(this.batchSize, "Missing configuration for batch size"); + Assert.notNull(this.noPermitsReturnedWaitTimeout, "Missing configuration for noPermitsReturnedWaitTimeout"); + Assert.noNullElements(this.backPressureHandlers, "backPressureHandlers must not be null"); + return new CompositeBackPressureHandler(this); + } + } } diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java index 072d507ca..4e6dd5855 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandler.java @@ -142,8 +142,7 @@ public Builder acquireTimeout(Duration acquireTimeout) { } public ConcurrencyLimiterBlockingBackPressureHandler build() { - Assert.noNullElements( - Arrays.asList(this.batchSize, this.totalPermits, this.acquireTimeout), + Assert.noNullElements(Arrays.asList(this.batchSize, this.totalPermits, this.acquireTimeout), "Missing configuration"); Assert.isTrue(this.batchSize > 0, "The batch size must be greater than 0"); Assert.isTrue(this.totalPermits >= this.batchSize, "Total permits must be greater than the batch size"); diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandlerTest.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandlerTest.java new file mode 100644 index 000000000..ff75647ac --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/CompositeBackPressureHandlerTest.java @@ -0,0 +1,158 @@ +/* + * Copyright 2013-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.listener; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class CompositeBackPressureHandlerTest { + + private BackPressureHandler handler1; + private BackPressureHandler handler2; + + @BeforeEach + void setUp() { + handler1 = mock(BackPressureHandler.class); + handler2 = mock(BackPressureHandler.class); + } + + @Test + void request_shouldDelegateToHandlersAndReturnMinPermits() throws InterruptedException { + // given + CompositeBackPressureHandler compositeHandler = compositeHandlerBuilder() + .noPermitsReturnedWaitTimeout(Duration.ofSeconds(30)).backPressureHandlers(List.of(handler1, handler2)) + .build(); + when(handler1.request(5)).thenReturn(5); + when(handler2.request(5)).thenReturn(3); + // when + int permits = compositeHandler.request(5); + // then + assertThat(permits).isEqualTo(3); + verify(handler1).request(5); + verify(handler2).request(5); + } + + @Test + void release_shouldDelegateToHandlers() { + // given + CompositeBackPressureHandler compositeHandler = compositeHandlerBuilder() + .noPermitsReturnedWaitTimeout(Duration.ofSeconds(30)).backPressureHandlers(List.of(handler1, handler2)) + .build(); + // when + compositeHandler.release(2, BackPressureHandler.ReleaseReason.PROCESSED); + // then + verify(handler1).release(2, BackPressureHandler.ReleaseReason.PROCESSED); + verify(handler2).release(2, BackPressureHandler.ReleaseReason.PROCESSED); + } + + @Test + void request_shouldWaitIfNoPermitsAndTimeout() throws InterruptedException { + // given + CompositeBackPressureHandler compositeHandler = compositeHandlerBuilder() + .noPermitsReturnedWaitTimeout(Duration.ofSeconds(5)).backPressureHandlers(List.of(handler1, handler2)) + .build(); + when(handler1.request(5)).thenReturn(0); + when(handler2.request(5)).thenReturn(0); + // when + long start = System.nanoTime(); + int permits = compositeHandler.request(5); + Duration duration = Duration.ofNanos(System.nanoTime() - start); + // then + assertThat(permits).isZero(); + assertThat(duration).isGreaterThanOrEqualTo(Duration.ofSeconds(1L)); + } + + @Test + void request_shouldPassReducedPermitsToSubsequentHandlers() throws InterruptedException { + // given + CompositeBackPressureHandler compositeHandler = compositeHandlerBuilder() + .noPermitsReturnedWaitTimeout(Duration.ofSeconds(30)).backPressureHandlers(List.of(handler1, handler2)) + .build(); + when(handler1.request(10)).thenReturn(5); + when(handler2.request(5)).thenReturn(5); + // when + int permits = compositeHandler.request(10); + // then + assertThat(permits).isEqualTo(5); + verify(handler1).request(10); + verify(handler2).request(5); + } + + @Test + void request_whenLaterHandlerReturnsLessPermits_shouldReleaseDiffWithLimitedOnPreviousHandlers() + throws InterruptedException { + // given + BackPressureHandler handler3 = mock(BackPressureHandler.class); + CompositeBackPressureHandler compositeHandler = compositeHandlerBuilder() + .noPermitsReturnedWaitTimeout(Duration.ofMillis(50)) + .backPressureHandlers(List.of(handler1, handler2, handler3)).build(); + when(handler1.request(5)).thenReturn(4); + when(handler2.request(4)).thenReturn(2); + when(handler3.request(2)).thenReturn(1); + // when + int permits = compositeHandler.request(5); + // then + assertThat(permits).isEqualTo(1); + verify(handler1).request(5); + verify(handler2).request(4); + verify(handler3).request(2); + verify(handler1).release(3, BackPressureHandler.ReleaseReason.LIMITED); + verify(handler2).release(1, BackPressureHandler.ReleaseReason.LIMITED); + verify(handler3, never()).release(anyInt(), any()); + } + + @Test + void request_shouldUnblockWhenPermitsAreReleased() throws InterruptedException { + // given + CompositeBackPressureHandler compositeHandler = compositeHandlerBuilder() + .noPermitsReturnedWaitTimeout(Duration.ofSeconds(30)).backPressureHandlers(List.of(handler1, handler2)) + .build(); + when(handler1.request(5)).thenReturn(0, 5); + when(handler2.request(5)).thenReturn(5); + + AtomicInteger result = new AtomicInteger(-1); + Thread requester = new Thread(() -> { + try { + // when + result.set(compositeHandler.request(5)); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + requester.start(); + Thread.sleep(200); // Ensure requester is waiting + assertThat(requester.isAlive()).isTrue(); + // when + compositeHandler.release(5, BackPressureHandler.ReleaseReason.PROCESSED); + requester.join(2000); + // then + assertThat(requester.isAlive()).isFalse(); + assertThat(result.get()).isZero(); + assertThat(compositeHandler.request(5)).isEqualTo(5); + } + + private static CompositeBackPressureHandler.@NotNull Builder compositeHandlerBuilder() { + return CompositeBackPressureHandler.builder().batchSize(5); + } +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandlerTest.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandlerTest.java index f3f2fcb54..f46aef313 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandlerTest.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ConcurrencyLimiterBlockingBackPressureHandlerTest.java @@ -1,11 +1,25 @@ +/* + * Copyright 2013-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.listener; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; import java.time.Duration; - -import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; class ConcurrencyLimiterBlockingBackPressureHandlerTest { @@ -14,33 +28,29 @@ class ConcurrencyLimiterBlockingBackPressureHandlerTest { private ConcurrencyLimiterBlockingBackPressureHandler handler; - @BeforeEach - void setUp() { - handler = ConcurrencyLimiterBlockingBackPressureHandler.builder() - .totalPermits(TOTAL_PERMITS) - .batchSize(BATCH_SIZE) - .acquireTimeout(Duration.ofMillis(100)) - .build(); - } - - @Test - void request_shouldAcquirePermits() throws InterruptedException { + @BeforeEach + void setUp() { + handler = ConcurrencyLimiterBlockingBackPressureHandler.builder().totalPermits(TOTAL_PERMITS) + .batchSize(BATCH_SIZE).acquireTimeout(Duration.ofMillis(100)).build(); + } + + @Test + void request_shouldAcquirePermits() throws InterruptedException { // Requesting a first batch should acquire the permits - assertThat(handler.request(BATCH_SIZE)).isEqualTo(BATCH_SIZE); + assertThat(handler.request(BATCH_SIZE)).isEqualTo(BATCH_SIZE); // Requesting a second batch should acquire the remaining permits - assertThat(handler.request(BATCH_SIZE)).isEqualTo(BATCH_SIZE); - // No permits left - assertThat(handler.request(1)).isZero(); - } + assertThat(handler.request(BATCH_SIZE)).isEqualTo(BATCH_SIZE); + // No permits left + assertThat(handler.request(1)).isZero(); + } - @Test - void release_shouldAllowFurtherRequests() throws InterruptedException { + @Test + void release_shouldAllowFurtherRequests() throws InterruptedException { // Given all permits are acquired assertThat(handler.request(TOTAL_PERMITS)).isEqualTo(TOTAL_PERMITS); - assertThat(handler.request(1)).isZero(); + assertThat(handler.request(1)).isZero(); // When releasing some permits, new requests should be allowed - handler.release(3, BackPressureHandler.ReleaseReason.PROCESSED); + handler.release(3, BackPressureHandler.ReleaseReason.PROCESSED); assertThat(handler.request(5)).isEqualTo(3); // Only 3 permits were released so far - } + } } - diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/FullBatchBackPressureHandlerTest.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/FullBatchBackPressureHandlerTest.java index 3c01a51f9..a6d25a96f 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/FullBatchBackPressureHandlerTest.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/FullBatchBackPressureHandlerTest.java @@ -1,39 +1,51 @@ +/* + * Copyright 2013-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.awspring.cloud.sqs.listener; +import static org.assertj.core.api.Assertions.assertThat; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; - class FullBatchBackPressureHandlerTest { private FullBatchBackPressureHandler handler; private final int batchSize = 10; - @BeforeEach - void setUp() { - handler = FullBatchBackPressureHandler.builder() - .batchSize(batchSize) - .build(); - } - - @Test - void request_withExactBatchSize_shouldReturnBatchSize() throws InterruptedException { - assertThat(handler.request(batchSize)).isEqualTo(batchSize); - } - - @Test - void request_withNonBatchSize_shouldReturnZero() throws InterruptedException { - int permits = handler.request(batchSize - 1); - assertThat(permits).isZero(); - permits = handler.request(batchSize + 1); - assertThat(permits).isZero(); - } - - @Test - void requestBatch_shouldReturnBatchSize() throws InterruptedException { - assertThat(handler.requestBatch()).isEqualTo(batchSize); - } + @BeforeEach + void setUp() { + handler = FullBatchBackPressureHandler.builder().batchSize(batchSize).build(); + } + + @Test + void request_withExactBatchSize_shouldReturnBatchSize() throws InterruptedException { + assertThat(handler.request(batchSize)).isEqualTo(batchSize); + } + + @Test + void request_withNonBatchSize_shouldReturnZero() throws InterruptedException { + int permits = handler.request(batchSize - 1); + assertThat(permits).isZero(); + permits = handler.request(batchSize + 1); + assertThat(permits).isZero(); + } + + @Test + void requestBatch_shouldReturnBatchSize() throws InterruptedException { + assertThat(handler.requestBatch()).isEqualTo(batchSize); + } } - From ad707da8dc209cf3cf0e478d6463f583f48fdc68 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Tue, 1 Jul 2025 10:22:59 +0200 Subject: [PATCH 23/29] Limit requested permits to batch size for ThroughputBackPressureHandler (#1251) --- .../BackPressureHandlerFactories.java | 2 +- .../ThroughputBackPressureHandler.java | 20 +++++++++++++++---- .../ThroughputBackPressureHandlerTest.java | 10 +++++++--- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java index 68688ad4e..bfcee238e 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandlerFactories.java @@ -158,7 +158,7 @@ public static ConcurrencyLimiterBlockingBackPressureHandler concurrencyLimiterBa * @return the created ThroughputBackPressureHandler. */ public static ThroughputBackPressureHandler throughputBackPressureHandler(ContainerOptions options) { - return ThroughputBackPressureHandler.builder().build(); + return ThroughputBackPressureHandler.builder().batchSize(options.getMaxMessagesPerPoll()).build(); } /** diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java index e2169404a..9ef8a6e11 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandler.java @@ -21,6 +21,7 @@ import java.util.concurrent.atomic.AtomicReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.util.Assert; /** * Non-blocking {@link BackPressureHandler} implementation that uses a switch between high and low throughput modes. @@ -54,10 +55,13 @@ public class ThroughputBackPressureHandler implements BackPressureHandler, Ident private final AtomicBoolean drained = new AtomicBoolean(false); + private final int batchSize; + private String id = getClass().getSimpleName(); - private ThroughputBackPressureHandler() { - logger.debug("ThroughputBackPressureHandler created"); + private ThroughputBackPressureHandler(Builder builder) { + this.batchSize = builder.batchSize; + logger.debug("ThroughputBackPressureHandler created with batch size: {}", this.batchSize); } public static Builder builder() { @@ -89,7 +93,7 @@ public int request(int amount) throws InterruptedException { this.occupied.set(true); } logger.debug("[{}] Acquired {} permits ({} mode)", this.id, amount, throughputMode); - return amount; + return Math.min(amount, this.batchSize); } @Override @@ -136,8 +140,16 @@ private enum CurrentThroughputMode { public static class Builder { + private int batchSize; + + public Builder batchSize(int batchSize) { + this.batchSize = batchSize; + return this; + } + public ThroughputBackPressureHandler build() { - return new ThroughputBackPressureHandler(); + Assert.isTrue(this.batchSize > 0, "The batch size must be greater than 0"); + return new ThroughputBackPressureHandler(this); } } } diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandlerTest.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandlerTest.java index e6ea93145..ce1e6c6fe 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandlerTest.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ThroughputBackPressureHandlerTest.java @@ -24,12 +24,17 @@ import org.junit.jupiter.params.provider.CsvSource; class ThroughputBackPressureHandlerTest { - private ThroughputBackPressureHandler handler; @BeforeEach void setUp() { - handler = new ThroughputBackPressureHandler.Builder().build(); + handler = new ThroughputBackPressureHandler.Builder().batchSize(5).build(); + } + + @ParameterizedTest + @CsvSource({ "4,4", "5,5", "6,5", }) + void amountIsCappedAtBatchSize(int requestedAmount, int expectedPermits) throws InterruptedException { + assertThat(handler.request(requestedAmount)).isEqualTo(expectedPermits); } @ParameterizedTest @@ -81,5 +86,4 @@ void drain_shouldSetDrainedAndReturnTrue() throws InterruptedException { assertThat(result).isTrue(); assertThat(handler.request(5)).isZero(); } - } From c7e63295f595a88c7a1010256c9ef9047c971b44 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Tue, 1 Jul 2025 14:58:59 +0200 Subject: [PATCH 24/29] Update BlockingBackPressureHandler javadoc (#1251) --- .../cloud/sqs/listener/BlockingBackPressureHandler.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BlockingBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BlockingBackPressureHandler.java index 5d5ecf6d0..1ad50431f 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BlockingBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BlockingBackPressureHandler.java @@ -17,8 +17,7 @@ /** * Marker interface for a blocking {@link BackPressureHandler}. This handler is used to control the flow of messages in - * a blocking manner. It is recommended to have at least one blocking back pressure handler in a - * {@link CompositeBackPressureHandler} in order to enable more resource efficient polling. + * a blocking manner. */ public interface BlockingBackPressureHandler extends BackPressureHandler { From b1a1f562f4a583c8e902d1baa3f2874aaabfa303 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Tue, 1 Jul 2025 17:25:52 +0200 Subject: [PATCH 25/29] Improve SQS tests stability (#1251) --- .../SqsBackPressureIntegrationTests.java | 12 +-- .../AbstractPollingMessageSourceTests.java | 81 ++++++++++++------- 2 files changed, 58 insertions(+), 35 deletions(-) diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java index 933e94173..ae5c35828 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsBackPressureIntegrationTests.java @@ -147,7 +147,7 @@ void staticBackPressureLimitShouldCapQueueProcessingCapacity(int staticLimit, in }).build(); container.start(); assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); - assertThat(maxConcurrentRequest.get()).isEqualTo(expectedMaxConcurrentRequests); + assertThat(maxConcurrentRequest.get()).isLessThanOrEqualTo(expectedMaxConcurrentRequests); container.stop(); } @@ -290,19 +290,19 @@ void waitForAdvance(int permits) throws InterruptedException { controller.advance(50); controller.waitForAdvance(50); // not limiting queue processing capacity - assertThat(controller.maxConcurrentRequest.get()).isEqualTo(5); + assertThat(controller.maxConcurrentRequest.get()).isLessThanOrEqualTo(5); controller.updateLimitAndWaitForReset(2); controller.advance(50); controller.waitForAdvance(50); // limiting queue processing capacity - assertThat(controller.maxConcurrentRequest.get()).isEqualTo(2); + assertThat(controller.maxConcurrentRequest.get()).isLessThanOrEqualTo(2); controller.updateLimitAndWaitForReset(7); controller.advance(50); controller.waitForAdvance(50); // not limiting queue processing capacity - assertThat(controller.maxConcurrentRequest.get()).isEqualTo(5); + assertThat(controller.maxConcurrentRequest.get()).isLessThanOrEqualTo(5); controller.updateLimitAndWaitForReset(3); controller.advance(50); sleep(10L); @@ -313,7 +313,7 @@ void waitForAdvance(int permits) throws InterruptedException { limiter.setLimit(3); controller.waitForAdvance(50); - assertThat(controller.maxConcurrentRequest.get()).isEqualTo(3); + assertThat(controller.maxConcurrentRequest.get()).isLessThanOrEqualTo(3); // stopping processing of the queue controller.updateLimit(0); controller.advance(50); @@ -325,7 +325,7 @@ void waitForAdvance(int permits) throws InterruptedException { controller.waitForAdvance(50); assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); - assertThat(controller.maxConcurrentRequest.get()).isEqualTo(5); + assertThat(controller.maxConcurrentRequest.get()).isLessThanOrEqualTo(5); assertThat(processingFailed.get()).isFalse(); } finally { diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java index 6ea1f6a0e..faefba3ab 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSourceTests.java @@ -40,7 +40,6 @@ import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.core.task.TaskExecutor; import org.springframework.lang.Nullable; import org.springframework.retry.backoff.BackOffContext; import org.springframework.retry.backoff.BackOffPolicy; @@ -60,7 +59,7 @@ void shouldAcquireAndReleaseFullPermits() { String testName = "shouldAcquireAndReleaseFullPermits"; SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) - .maxDelayBetweenPolls(Duration.ofMillis(200)).build(); + .maxDelayBetweenPolls(Duration.ofMillis(200)).listenerShutdownTimeout(Duration.ZERO).build(); BackPressureHandler backPressureHandler = BackPressureHandlerFactories .adaptativeThroughputBackPressureHandler(options, Duration.ofMillis(100L)); @@ -108,9 +107,15 @@ protected CompletableFuture> doPollForMessages(int messagesT source.configure(options); source.setTaskExecutor(createTaskExecutor(testName)); source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); - source.start(); - assertThat(doAwait(pollingCounter)).isTrue(); - assertThat(doAwait(processingCounter)).isTrue(); + try { + source.start(); + assertThat(doAwait(pollingCounter)).isTrue(); + assertThat(doAwait(processingCounter)).isTrue(); + } + finally { + source.stop(); + threadPool.shutdownNow(); + } } @Test @@ -118,7 +123,7 @@ void shouldAdaptThroughputMode() { String testName = "shouldAdaptThroughputMode"; SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) - .maxDelayBetweenPolls(Duration.ofMillis(150)).build(); + .maxDelayBetweenPolls(Duration.ofMillis(150)).listenerShutdownTimeout(Duration.ZERO).build(); BackPressureHandler backPressureHandler = BackPressureHandlerFactories .adaptativeThroughputBackPressureHandler(options, Duration.ofMillis(100L)); @@ -204,10 +209,10 @@ else if (pollAttempt == 2) { void shouldAcquireAndReleasePartialPermits() { String testName = "shouldAcquireAndReleasePartialPermits"; SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) - .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofMillis(150)).build(); + .backPressureMode(BackPressureMode.AUTO).maxDelayBetweenPolls(Duration.ofMillis(150)) + .listenerShutdownTimeout(Duration.ZERO).build(); BackPressureHandler backPressureHandler = BackPressureHandlerFactories .adaptativeThroughputBackPressureHandler(options, Duration.ofMillis(200L)); - ExecutorService threadPool = Executors .newCachedThreadPool(new MessageExecutionThreadFactory("test " + testCounter.incrementAndGet())); CountDownLatch pollingCounter = new CountDownLatch(4); @@ -267,12 +272,16 @@ else if (pollAttempt == 2) { source.configure(options); source.setTaskExecutor(createTaskExecutor(testName)); source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); - source.start(); - assertThat(doAwait(processingCounter)).isTrue(); - assertThat(doAwait(pollingCounter)).isTrue(); - source.stop(); - assertThat(hasThrownError.get()).isFalse(); - threadPool.shutdownNow(); + try { + source.start(); + assertThat(doAwait(processingCounter)).isTrue(); + assertThat(doAwait(pollingCounter)).isTrue(); + assertThat(hasThrownError.get()).isFalse(); + } + finally { + threadPool.shutdownNow(); + source.stop(); + } } @Test @@ -295,7 +304,8 @@ public org.springframework.messaging.Message toMessagingMessage(Message sourc SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(10) .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) - .maxDelayBetweenPolls(Duration.ofMillis(150)).messageConverter(converter).build(); + .maxDelayBetweenPolls(Duration.ofMillis(150)).messageConverter(converter) + .listenerShutdownTimeout(Duration.ZERO).build(); BackPressureHandler backPressureHandler = BackPressureHandlerFactories .adaptativeThroughputBackPressureHandler(options, Duration.ofMillis(100L)); @@ -330,13 +340,19 @@ private Collection create10Messages() { source.setId(testName + " source"); source.configure(options); source.setPollingEndpointName("shouldReleasePermitsOnConversionErrors-queue"); - source.setTaskExecutor(createTaskExecutor(testName)); + ThreadPoolTaskExecutor taskExecutor = createTaskExecutor(testName); + source.setTaskExecutor(taskExecutor); source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); - source.start(); - Awaitility.waitAtMost(Duration.ofSeconds(10)).until(() -> convertedMessages.get() == 30); - assertThat(hasFailed).isFalse(); - assertThat(messagesInSink).hasValue(27); - source.stop(); + try { + source.start(); + Awaitility.waitAtMost(Duration.ofSeconds(10)).until(() -> convertedMessages.get() == 30); + assertThat(hasFailed).isFalse(); + assertThat(messagesInSink).hasValue(27); + } + finally { + source.stop(); + taskExecutor.shutdown(); + } } @Test @@ -348,7 +364,8 @@ void shouldBackOffIfPollingThrowsAnError() { given(policy.start(null)).willReturn(backOffContext); SqsContainerOptions options = SqsContainerOptions.builder().maxMessagesPerPoll(10).maxConcurrentMessages(40) .backPressureMode(BackPressureMode.ALWAYS_POLL_MAX_MESSAGES) - .maxDelayBetweenPolls(Duration.ofMillis(200)).pollBackOffPolicy(policy).build(); + .maxDelayBetweenPolls(Duration.ofMillis(200)).pollBackOffPolicy(policy) + .listenerShutdownTimeout(Duration.ZERO).build(); BackPressureHandler backPressureHandler = BackPressureHandlerFactories .adaptativeThroughputBackPressureHandler(options, Duration.ofMillis(100L)); @@ -383,15 +400,21 @@ else if (currentPoll.compareAndSet(2, 3)) { source.setId(testName + " source"); source.configure(options); - source.setTaskExecutor(createTaskExecutor(testName)); + ThreadPoolTaskExecutor taskExecutor = createTaskExecutor(testName); + source.setTaskExecutor(taskExecutor); source.setAcknowledgementProcessor(getNoOpsAcknowledgementProcessor()); - source.start(); - - doAwait(waitThirdPollLatch); + try { + source.start(); - then(policy).should().start(null); - then(policy).should(times(2)).backOff(backOffContext); + doAwait(waitThirdPollLatch); + then(policy).should().start(null); + then(policy).should(times(2)).backOff(backOffContext); + } + finally { + source.stop(); + taskExecutor.shutdown(); + } } private static boolean doAwait(CountDownLatch processingLatch) { @@ -454,7 +477,7 @@ private void doSleep(int time) { } } - protected TaskExecutor createTaskExecutor(String testName) { + protected ThreadPoolTaskExecutor createTaskExecutor(String testName) { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); int poolSize = 10; executor.setMaxPoolSize(poolSize); From 6c2e37d08699fea981bcc6a327ed12bb66e20eac Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Mon, 14 Jul 2025 10:48:48 +0200 Subject: [PATCH 26/29] Remove BatchAwareBackPressureHandler#releaseBatch() default implementation code (#1251) --- .../cloud/sqs/listener/BatchAwareBackPressureHandler.java | 1 - 1 file changed, 1 deletion(-) diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java index 661b7731b..1d1c93698 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java @@ -41,7 +41,6 @@ public interface BatchAwareBackPressureHandler extends BackPressureHandler { */ @Deprecated default void releaseBatch() { - release(getBatchSize(), ReleaseReason.NONE_FETCHED); } @Override From 0e995535f3b91cb51909dfe7b94ed27dfcfa00d3 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Thu, 17 Jul 2025 14:08:54 +0200 Subject: [PATCH 27/29] Document backpressure management (#1251) --- docs/src/main/asciidoc/sqs.adoc | 90 +++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/docs/src/main/asciidoc/sqs.adoc b/docs/src/main/asciidoc/sqs.adoc index 048c02a65..41747d08a 100644 --- a/docs/src/main/asciidoc/sqs.adoc +++ b/docs/src/main/asciidoc/sqs.adoc @@ -762,6 +762,96 @@ NOTE: The same factory can be used to create both `single message` and `batch` c IMPORTANT: In case the same factory is shared by both delivery methods, any supplied `ErrorHandler`, `MessageInterceptor` or `MessageListener` should implement the proper methods. +[[container-backpressure-handler-factory]] +==== Backpressure management + +Backpressure is a mechanism to control the rate at which messages are polled and processed, helping to avoid overwhelming downstream systems or exceeding resource limits. +The `ContainerOptions` class provides the `backPressureHandlerFactory` property, which allows you to select which `BackPressureHandler` to use. + +By default, the framework provides sensible backpressure strategies (see `BackPressureHandlerFactories#adaptativeThroughputBackPressureHandler`). +But you can supply your own to customize how backpressure is applied to SQS message consumption. +This is achieved by setting a custom `BackPressureHandlerFactory` in the `ContainerOptions`. + +===== What is a BackPressureHandler? + +A `BackPressureHandler` is an interface that determines whether the container should apply backpressure (i.e., slow down or pause polling) based on the current state of the system. It is invoked before each poll to SQS and can prevent polling or poll for less messages if certain conditions are met (e.g., too many inflight messages, custom resource constraints, etc.). + +===== Creating a custom BackPressureHandler + +To implement your own backpressure logic, create a class that implements the `BackPressureHandler` interface: + +```java +public class CustomBackPressureHandler implements BackPressureHandler { + + @Override + public int request(int amount) throws InterruptedException { + // Custom logic to determine how many permits to grant for polling. + // Returning 0 will prevent the polling of messages from SQS. + // Returning a positive number (<= amount) will allow that many messages to be polled. + // This #request(amount) method is called before each poll. + } + + @Override + public void release(int amount, ReleaseReason reason) { + // Custom logic to handle the release of permits. + // This logic depends on the request method and should most of the time be symmetric. + // For example, if semaphore permits were acquired in request, they should be released here. + } + + @Override + public boolean drain(Duration timeout) { + // Custom logic to determine if the backpressure handler is ready to drain. + } +} +``` + +===== Registering a custom BackPressureHandler + +A SqsMessageListenerContainer can be configured to use the desired `BackPressureHandler` by setting the `backPressureHandlerFactory` on the `ContainerOptions`. + +```java +SqsMessageListenerContainer container = SqsMessageListenerContainer.builder() + .configure(options -> options + .backPressureHandlerFactory(containerOptions -> new CustomBackPressureHandler()) + // ... other options + ) + // ... other container settings ... + .build(); +``` + +===== Combining Multiple BackPressureHandlers + +If you want to combine several backpressure strategies, use the `CompositeBackPressureHandler`. +Each of the `BackPressureHandler` (which we'll call delegates) are chained in the order they are provided. +The first delegate will be requested the initial amount of permits and will return the number of permits it accepts to grant. +The second delegate will get that potentially reduced number of permits as a request and might in turn reduce it further. +The process continues until all delegates have been requested or one of them returns 0, which will prevent the polling of messages from SQS. + +For example, to combine a concurrency limiter, an adaptative throughput handler, and a full batch only handler, you can use the `CompositeBackPressureHandler` as below: + +```java +Duration maxIdleWaitTime = Duration.ofMillis(50L); +List backPressureHandlers = List.of( + BackPressureHandlerFactories.concurrencyLimiterBackPressureHandler(options), + BackPressureHandlerFactories.throughputBackPressureHandler(options), + BackPressureHandlerFactories.fullBatchBackPressureHandler(options) +); +CompositeBackPressureHandler composite = BackPressureHandlerFactories.compositeBackPressureHandler( + options, maxIdleWaitTime, backPressureHandlers); +``` + +===== Built-in BackPressureHandlers + +Spring Cloud AWS provides several built-in `BackPressureHandler` implementations: + +- `ConcurrencyLimiterBackPressureHandler`: Limits the number of messages being processed concurrently. +- `ThroughputBackPressureHandler`: Switch between high and low throughput modes. In high throughput mode, multiple polls do be done parallel. In low throughput mode, only one poll is done at a time. +- `FullBatchBackPressureHandler`: Ensure polls will always be done with a full batch of messages, meaning that the number of messages polled will always be equal to `maxMessagesPerPoll` if possible or `0` if not possible. + +The `BackPressureHandlerFactories` class provides factory methods to create these handlers easily. + + +You can use these handlers directly or combine them with your custom ones using the `CompositeBackPressureHandler` to fit your application's needs. ==== Container Options From a8f158b164bde07f53cea5eaaa09e3f39bf823d3 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Thu, 17 Jul 2025 15:04:35 +0200 Subject: [PATCH 28/29] Move backpressure management documentation to under '8.9. Message Processing Throughput' (#1251) --- docs/src/main/asciidoc/sqs.adoc | 156 +++++++++++++------------------- 1 file changed, 65 insertions(+), 91 deletions(-) diff --git a/docs/src/main/asciidoc/sqs.adoc b/docs/src/main/asciidoc/sqs.adoc index 41747d08a..923b84627 100644 --- a/docs/src/main/asciidoc/sqs.adoc +++ b/docs/src/main/asciidoc/sqs.adoc @@ -762,97 +762,6 @@ NOTE: The same factory can be used to create both `single message` and `batch` c IMPORTANT: In case the same factory is shared by both delivery methods, any supplied `ErrorHandler`, `MessageInterceptor` or `MessageListener` should implement the proper methods. -[[container-backpressure-handler-factory]] -==== Backpressure management - -Backpressure is a mechanism to control the rate at which messages are polled and processed, helping to avoid overwhelming downstream systems or exceeding resource limits. -The `ContainerOptions` class provides the `backPressureHandlerFactory` property, which allows you to select which `BackPressureHandler` to use. - -By default, the framework provides sensible backpressure strategies (see `BackPressureHandlerFactories#adaptativeThroughputBackPressureHandler`). -But you can supply your own to customize how backpressure is applied to SQS message consumption. -This is achieved by setting a custom `BackPressureHandlerFactory` in the `ContainerOptions`. - -===== What is a BackPressureHandler? - -A `BackPressureHandler` is an interface that determines whether the container should apply backpressure (i.e., slow down or pause polling) based on the current state of the system. It is invoked before each poll to SQS and can prevent polling or poll for less messages if certain conditions are met (e.g., too many inflight messages, custom resource constraints, etc.). - -===== Creating a custom BackPressureHandler - -To implement your own backpressure logic, create a class that implements the `BackPressureHandler` interface: - -```java -public class CustomBackPressureHandler implements BackPressureHandler { - - @Override - public int request(int amount) throws InterruptedException { - // Custom logic to determine how many permits to grant for polling. - // Returning 0 will prevent the polling of messages from SQS. - // Returning a positive number (<= amount) will allow that many messages to be polled. - // This #request(amount) method is called before each poll. - } - - @Override - public void release(int amount, ReleaseReason reason) { - // Custom logic to handle the release of permits. - // This logic depends on the request method and should most of the time be symmetric. - // For example, if semaphore permits were acquired in request, they should be released here. - } - - @Override - public boolean drain(Duration timeout) { - // Custom logic to determine if the backpressure handler is ready to drain. - } -} -``` - -===== Registering a custom BackPressureHandler - -A SqsMessageListenerContainer can be configured to use the desired `BackPressureHandler` by setting the `backPressureHandlerFactory` on the `ContainerOptions`. - -```java -SqsMessageListenerContainer container = SqsMessageListenerContainer.builder() - .configure(options -> options - .backPressureHandlerFactory(containerOptions -> new CustomBackPressureHandler()) - // ... other options - ) - // ... other container settings ... - .build(); -``` - -===== Combining Multiple BackPressureHandlers - -If you want to combine several backpressure strategies, use the `CompositeBackPressureHandler`. -Each of the `BackPressureHandler` (which we'll call delegates) are chained in the order they are provided. -The first delegate will be requested the initial amount of permits and will return the number of permits it accepts to grant. -The second delegate will get that potentially reduced number of permits as a request and might in turn reduce it further. -The process continues until all delegates have been requested or one of them returns 0, which will prevent the polling of messages from SQS. - -For example, to combine a concurrency limiter, an adaptative throughput handler, and a full batch only handler, you can use the `CompositeBackPressureHandler` as below: - -```java -Duration maxIdleWaitTime = Duration.ofMillis(50L); -List backPressureHandlers = List.of( - BackPressureHandlerFactories.concurrencyLimiterBackPressureHandler(options), - BackPressureHandlerFactories.throughputBackPressureHandler(options), - BackPressureHandlerFactories.fullBatchBackPressureHandler(options) -); -CompositeBackPressureHandler composite = BackPressureHandlerFactories.compositeBackPressureHandler( - options, maxIdleWaitTime, backPressureHandlers); -``` - -===== Built-in BackPressureHandlers - -Spring Cloud AWS provides several built-in `BackPressureHandler` implementations: - -- `ConcurrencyLimiterBackPressureHandler`: Limits the number of messages being processed concurrently. -- `ThroughputBackPressureHandler`: Switch between high and low throughput modes. In high throughput mode, multiple polls do be done parallel. In low throughput mode, only one poll is done at a time. -- `FullBatchBackPressureHandler`: Ensure polls will always be done with a full batch of messages, meaning that the number of messages polled will always be equal to `maxMessagesPerPoll` if possible or `0` if not possible. - -The `BackPressureHandlerFactories` class provides factory methods to create these handlers easily. - - -You can use these handlers directly or combine them with your custom ones using the `CompositeBackPressureHandler` to fit your application's needs. - ==== Container Options Each `MessageListenerContainer` can have a different set of options. @@ -1847,6 +1756,7 @@ If after the 5 seconds for `maxDelayBetweenPolls` 6 messages have been processed If the queue is depleted and a poll returns no messages, it'll enter `low throughput` mode again and perform only one poll at a time. ==== Configuring BackPressureMode +The default `BackPressureHandler` can be configured to optimize the polling behavior based on the application's throughput requirements. The following `BackPressureMode` values can be set in `SqsContainerOptions` to configure polling behavior: * `AUTO` - The default mode, as described in the previous section. @@ -1857,6 +1767,70 @@ Useful for really high throughput scenarios where the risk of making parallel po NOTE: The `AUTO` setting should be balanced for most use cases, including high throughput ones. +==== Advanced Backpressure management + +Even though the default `BackPressureHandler` should be enough for most use cases, there are scenarios where more fine-grained control over message consumption is required not to overwhelm downstream systems or exceed resource limits. +In such a case, it is necessary to replace the default `BackPressureHandler` with a custom one that implements the `BackPressureHandler` interface. +A `backPressureHandlerFactory` can be set in `SqsContainerOptions` to configure which `BackPressureHandler` to use. + +===== What is a BackPressureHandler? + +A `BackPressureHandler` is an interface that determines whether the container should apply backpressure (i.e., slow down or pause polling) based on the current state of the system. +It is invoked before each poll to SQS and can prevent polling or poll for fewer messages if certain conditions are met, e.g., too many inflight messages, custom resource constraints, etc. + +===== Creating a custom BackPressureHandler + +To implement a custom backpressure logic, the `BackPressureHandler` interface must be implemented. + +A `SqsMessageListenerContainer` can be configured to use the desired `BackPressureHandler` by setting the `backPressureHandlerFactory` on the `ContainerOptions`. + +```java +SqsMessageListenerContainer container = SqsMessageListenerContainer.builder() + .configure(options -> options + .backPressureHandlerFactory(containerOptions -> new CustomBackPressureHandler()) + // ... other options + ) + // ... other container settings ... + .build(); +``` + +===== Combining Multiple BackPressureHandlers + +If necessary, multiple `BackPressureHandler` can be combined by using the `CompositeBackPressureHandler`. +Each of the `BackPressureHandler` (which we'll call delegates) are chained in the order they are provided. +The first delegate will be requested the initial amount of permits and will return the number of permits it accepts to grant. +The second delegate will get that potentially reduced number of permits as a request and might in turn reduce it further. +The process continues until all delegates have been called or one of them returns 0, which will prevent the polling of messages from SQS. + +For example, to implement the `BackPressureMode.ALWAYS_POLL_MAX_MESSAGES` strategy, we can combine a concurrency limiter, an adaptative throughput handler, and a "full batch only" handler. +The resulting `CompositeBackPressureHandler` looks like this: + +```java +Duration maxIdleWaitTime = Duration.ofMillis(50L); +List backPressureHandlers = List.of( + BackPressureHandlerFactories.concurrencyLimiterBackPressureHandler(options), + BackPressureHandlerFactories.throughputBackPressureHandler(options), + BackPressureHandlerFactories.fullBatchBackPressureHandler(options) +); +CompositeBackPressureHandler backPressureHandler = BackPressureHandlerFactories.compositeBackPressureHandler( + options, maxIdleWaitTime, backPressureHandlers); +``` + +===== Built-in BackPressureHandlers + +Spring Cloud AWS provides several built-in `BackPressureHandler` implementations: + +- `ConcurrencyLimiterBackPressureHandler`: Limits the number of messages being processed concurrently. +- `ThroughputBackPressureHandler`: Switches between high and low throughput modes. In high throughput mode, multiple polls can be done in parallel. +In low throughput mode, only one poll is done at a time. +- `FullBatchBackPressureHandler`: Ensure polls will always be done with a full batch of messages, meaning that the number of messages polled will always be equal to `maxMessagesPerPoll` if possible or `0` if not possible. +This `FullBatchBackPressureHandler` must always be the last in the chain for it to work properly. + +The `BackPressureHandlerFactories` class provides factory methods to create these handlers easily. +These handlers can be used directly or combined with custom ones using the `CompositeBackPressureHandler` to fit the application's needs. + +Additionally, the `BackPressureHandlerFactories#adaptativeThroughputBackPressureHandler` factory method combines the `ConcurrencyLimiterBackPressureHandler`, `ThroughputBackPressureHandler`, and `FullBatchBackPressureHandler` as per the desired `BackPressureMode`. + === Blocking and Non-Blocking (Async) Components The SQS integration leverages the `CompletableFuture`-based async capabilities of `AWS SDK 2.0` to deliver a fully non-blocking infrastructure. From a4e2f1e6d41b62edb9520d550dda89e4bc0cd1f1 Mon Sep 17 00:00:00 2001 From: Loic Rouchon Date: Fri, 18 Jul 2025 10:57:32 +0200 Subject: [PATCH 29/29] Remove default implementation of deprecated methods (#1251) --- .../io/awspring/cloud/sqs/listener/BackPressureHandler.java | 3 ++- .../cloud/sqs/listener/BatchAwareBackPressureHandler.java | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java index b41e11245..9260c7ecf 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java @@ -69,7 +69,8 @@ default void release(int amount, ReleaseReason reason) { */ @Deprecated default void release(int amount) { - release(amount, ReleaseReason.PROCESSED); + // Do not implement this method. It is not called anymore outside of backward compatibility use cases. + // Implement `#release(int amount, ReleaseReason reason)` instead. } /** diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java index 1d1c93698..00d6d895d 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java @@ -41,6 +41,8 @@ public interface BatchAwareBackPressureHandler extends BackPressureHandler { */ @Deprecated default void releaseBatch() { + // Do not implement this method. It is not called anymore outside of backward compatibility use cases. + // Implement `#release(int amount, ReleaseReason reason)` instead. } @Override