diff --git a/docs/pom.xml b/docs/pom.xml index b34953abb..0121f6cf6 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -62,6 +62,11 @@ org.asciidoctor asciidoctor-maven-plugin + + + highlight.js + + org.apache.maven.plugins diff --git a/docs/src/main/asciidoc/_configprops.adoc b/docs/src/main/asciidoc/_configprops.adoc index f8033639f..d29a1c39a 100644 --- a/docs/src/main/asciidoc/_configprops.adoc +++ b/docs/src/main/asciidoc/_configprops.adoc @@ -53,5 +53,10 @@ |spring.cloud.aws.sns.enabled | `true` | Enables SNS integration. |spring.cloud.aws.sns.endpoint | | Overrides the default endpoint. |spring.cloud.aws.sns.region | | Overrides the default region. +|spring.cloud.aws.sqs.endpoint | | Overrides the default endpoint. +|spring.cloud.aws.sqs.listener.max-inflight-messages-per-queue | | The maximum number of simultaneous inflight messages in a queue. +|spring.cloud.aws.sqs.listener.max-messages-per-poll | | The maximum number of messages to be retrieved in a single poll to SQS. +|spring.cloud.aws.sqs.listener.poll-timeout | | The maximum amount of time for a poll to SQS. +|spring.cloud.aws.sqs.region | | Overrides the default region. |=== \ No newline at end of file diff --git a/docs/src/main/asciidoc/index.adoc b/docs/src/main/asciidoc/index.adoc index e106fba2a..1cf27b3ec 100644 --- a/docs/src/main/asciidoc/index.adoc +++ b/docs/src/main/asciidoc/index.adoc @@ -94,6 +94,10 @@ A summary of these artifacts are provided below. | Provides integrations with SNS | io.awspring.cloud:spring-cloud-aws-starter-sns +| SQS +| Provides integrations with SQS +| io.awspring.cloud:spring-cloud-aws-starter-sqs + | Parameter Store | Provides integrations with AWS Parameter Store | io.awspring.cloud:spring-cloud-aws-starter-parameter-store @@ -135,6 +139,8 @@ include::ses.adoc[] include::sns.adoc[] +include::sqs.adoc[] + include::secrets-manager.adoc[] include::parameter-store.adoc[] diff --git a/docs/src/main/asciidoc/sqs.adoc b/docs/src/main/asciidoc/sqs.adoc index 620352c4c..3df441369 100644 --- a/docs/src/main/asciidoc/sqs.adoc +++ b/docs/src/main/asciidoc/sqs.adoc @@ -1,342 +1,1324 @@ -=== SQS support -Amazon SQS is a hosted messaging service on the Amazon Web Service platform that provides point-to-point communication -with queues. Compared to JMS or other message services Amazon SQS has several features and limitations that should be -taken into consideration. +== SQS Support +:source-highlighter: highlight.js +:highlightjs-theme: googlecode + +Amazon `Simple Queue Service` is a messaging service that provides point-to-point communication with queues. +Spring Cloud AWS SQS integration offers support to receive and send messages using common `Spring` abstractions such as `@SqsListener`, `MessageListenerContainer` and `MessageListenerContainerFactory`. + +Compared to JMS or other message services Amazon SQS has limitations that should be taken into consideration. * Amazon SQS allows only `String` payloads, so any `Object` must be transformed into a String representation. -Spring Cloud AWS has dedicated support to transfer Java objects with Amazon SQS messages by converting them to JSON. -* Amazon SQS has no transaction support, so messages might therefore be retrieved twice. Application have to be written in -an idempotent way so that they can receive a message twice. +Spring Cloud AWS has dedicated support to transfer Java objects with Amazon SQS messages by converting them to JSON. * Amazon SQS has a maximum message size of 256kb per message, so bigger messages will fail to be sent. -==== Sending a message -The `QueueMessagingTemplate` contains many convenience methods to send a message. There are send methods that specify the -destination using a `QueueMessageChannel` object and those that specify the destination using a string which is going to -be resolved against the SQS API. The send method that takes no destination argument uses the default destination. +A Spring Boot starter is provided to auto-configure SQS integration beans. +Maven coordinates, using <>: + +[source,xml] +---- + + io.awspring.cloud + spring-cloud-aws-starter-sqs + +---- + +=== Sample Listener Application + +Below is a minimal sample application leveraging auto-configuration from `Spring Boot`. + +[source,java] +---- +@SpringBootApplication +public class SqsApplication { + + public static void main(String[] args) { + SpringApplication.run(SqsApplication.class, args); + } + + @SqsListener("myQueue") + public void listen(String message) { + System.out.println(message); + } +} +---- + +Without Spring Boot, it's necessary to import the `SqsBootstrapConfiguration` class in a `@Configuration`, as well as declare a `SqsMessageListenerContainerFactory` bean. + +[source, java] +---- +public class Listener { + + @SqsListener("myQueue") + public void listen(String message) { + System.out.println(message); + } + +} + +@Import(SqsBootstrapConfiguration.class) +@Configuration +public class SQSConfiguration { + + @Bean + public SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory() { + return SqsMessageListenerContainerFactory + .builder() + .sqsAsyncClient(sqsAsyncClient()) + .build(); + } + + @Bean + public SqsAsyncClient sqsAsyncClient() { + return SqsAsyncClient.builder().build(); + } + + @Bean + public Listener listener() { + return new Listener(); + } + +} +---- + +=== Sending Messages +Spring Cloud AWS SQS autoconfigures a `SqsAsyncClient` bean that can be used for sending messages. +Note that the payload has to be converted to a JSON String to be sent. +A `SqsTemplate` should be included in a future milestone simplifying this process. + +Currently, a lightweight producer abstraction can be created to facilitate sending messages, such as: + +[source,java] +---- +@Component +public class SqsSampleProducer { + + private final SqsAsyncClient sqsAsyncClient; + + private final ObjectMapper objectMapper; + + public SqsSampleProducer(SqsAsyncClient sqsAsyncClient, ObjectMapper objectMapper) { + this.sqsAsyncClient = sqsAsyncClient; + this.objectMapper = objectMapper; + } + + public CompletableFuture sendToUrl(String queueUrl, Object payload) { + return this.sqsAsyncClient.sendMessage(request -> request.messageBody(getMessageBodyAsJson(payload)).queueUrl(queueUrl)) + .thenRun(() -> {}); + } -[source,java,index=0] + public CompletableFuture send(String queueName, Object payload) { + return this.sqsAsyncClient.getQueueUrl(request -> request.queueName(queueName)) + .thenApply(GetQueueUrlResponse::queueUrl) + .thenCompose(queueUrl -> sendToUrl(queueUrl, payload)); + } + + private String getMessageBodyAsJson(Object payload) { + try { + return objectMapper.writeValueAsString(payload); + } + catch (JsonProcessingException e) { + throw new IllegalArgumentException("Error converting payload: " + payload, e); + } + } + +} ---- -import com.amazonaws.services.sqs.AmazonSQSAsync; -import org.springframework.beans.factory.annotation.Autowired; -import io.awspring.cloud.messaging.core.QueueMessagingTemplate; -import org.springframework.messaging.support.MessageBuilder; -class SqsQueueSender { +Modifications can be made to enable adding other attributes such as `MessageGroupId`, `MessageAttributes` and `MessageDeduplicationId`. + +It can then be autowired and used such as: + +`sampleProducer.send(queueName, new MyPojo("My value", "My Other Value")).join();` + +IMPORTANT: The `join()` method blocks the thread and throws any error from the operation. If a `CompletableFuture` chain is being used, simply return the value instead. + + +=== Receiving Messages + +The framework offers the following options to receive messages from a queue. - private final QueueMessagingTemplate queueMessagingTemplate; +==== Message Listeners - @Autowired - public SqsQueueSender(AmazonSQSAsync amazonSQSAsync) { - this.queueMessagingTemplate = new QueueMessagingTemplate(amazonSQSAsync); +To receive messages in a manually created container, a `MessageListener` or `AsyncMessageListener` must be provided. +Both interfaces come with `single message` and a `batch` methods. +These are functional interfaces and a lambda or method reference can be provided for the single message methods. + +Single message / batch modes and message payload conversion can be configured via `ContainerOptions`. +See <> for more information. + +[source, java] +---- +@FunctionalInterface +public interface MessageListener { + + void onMessage(Message message); + + default void onMessage(Collection> messages) { + throw new UnsupportedOperationException("Batch not implemented by this MessageListener"); } - public void send(String message) { - this.queueMessagingTemplate.send("physicalQueueName", MessageBuilder.withPayload(message).build()); +} +---- + +[source, java] +---- +@FunctionalInterface +public interface AsyncMessageListener { + + CompletableFuture onMessage(Message message); + + default CompletableFuture onMessage(Collection> messages) { + return CompletableFutures + .failedFuture(new UnsupportedOperationException("Batch not implemented by this AsyncMessageListener")); } + } ---- -This example uses the `MessageBuilder` class to create a message with a string payload. The `QueueMessagingTemplate` is -constructed by passing a reference to the `AmazonSQSAsync` client. The destination in the send method is a string value that -must match the queue name defined on AWS. This value will be resolved at runtime by the Amazon SQS client. Optionally -a `ResourceIdResolver` implementation can be passed to the `QueueMessagingTemplate` constructor to resolve resources by -logical name when running inside a CloudFormation stack (see <> for more information about -resource name resolution). -With the messaging namespace a `QueueMessagingTemplate` can be defined in an XML configuration file. +==== SqsMessageListenerContainer + +The `MessageListenerContainer` manages the entire messages` lifecycle, from polling, to processing, to acknowledging. + +It can be instantiated directly, using a `SqsMessageListenerContainerFactory`, or using `@SqsListener` annotations. +If declared as a `@Bean`, the `Spring` context will manage its lifecycle, starting the container on application startup and stopping it on application shutdown. +See <> for more information. -[source,xml,indent=0] +It implements the `MessageListenerContainer` interface: + +[source,java] ---- - +public interface MessageListenerContainer extends SmartLifecycle { + + String getId(); + + void setId(String id); - - - + void setMessageListener(MessageListener messageListener); - + void setAsyncMessageListener(AsyncMessageListener asyncMessageListener); - +} ---- -In this example the messaging namespace handler constructs a new `QueueMessagingTemplate`. The `AmazonSQSAsync` client -is automatically created and passed to the template's constructor based on the provided credentials. If the -application runs inside a configured CloudFormation stack a `ResourceIdResolver` is passed to the constructor (see -<> for more information about resource name resolution). +NOTE: The generic parameter `` stands for the `payload type` of messages to be consumed by this container. +This allows ensuring at compile-time that all components used with the container are for the same type. +If more than one payload type is to be used by the same container or factory, simply type it as `Object`. +This type is not considered for payload conversion. -===== Using message converters -In order to facilitate the sending of domain model objects, the `QueueMessagingTemplate` has various send methods that -take a Java object as an argument for a message’s data content. The overloaded methods `convertAndSend()` and -`receiveAndConvert()` in `QueueMessagingTemplate` delegate the conversion process to an instance of the `MessageConverter` -interface. This interface defines a simple contract to convert between Java objects and SQS messages. The default -implementation `SimpleMessageConverter` simply unwraps the message payload as long as it matches the target type. By -using the converter, you and your application code can focus on the business object that is being sent or received via -SQS and not be concerned with the details of how it is represented as an SQS message. +A container can be instantiated in a familiar Spring way in a `@Configuration` annotated class. +For example: -[NOTE] -==== -As SQS is only able to send `String` payloads the default converter `SimpleMessageConverter` should only be used -to send `String` payloads. For more complex objects a custom converter should be used like the one created by the -messaging namespace handler. -==== +[source,java] +---- +@Bean +MessageListenerContainer listenerContainer(SqsAsyncClient sqsAsyncClient) { + SqsMessageListenerContainer container = new SqsMessageListenerContainer<>(sqsAsyncClient); + container.setMessageListener(System.out::println); + container.setQueueNames("myTestQueue"); + return container; +} +---- -It is recommended to use the XML messaging namespace to create `QueueMessagingTemplate` as it will set a more -sophisticated `MessageConverter` that converts objects into JSON when Jackson is on the classpath. +This framework also provides a convenient `Builder` that allows a different approach, such as: -[source,xml,indent=0] +[source,java] ---- - +@Bean +MessageListenerContainer listenerContainer(SqsAsyncClient sqsAsyncClient) { + return SqsMessageListenerContainer + .builder() + .sqsAsyncClient(sqsAsyncClient) + .messageListener(System.out::println) + .queueNames("myTestQueue") + .build(); +} ---- -[source,java,indent=0] +The container's lifecycle can also be managed manually: + +[source,java] ---- -this.queueMessagingTemplate.convertAndSend("queueName", new Person("John, "Doe")); +void myMethod(SqsAsyncClient sqsAsyncClient) { + SqsMessageListenerContainer container = SqsMessageListenerContainer + .builder() + .sqsAsyncClient(sqsAsyncClient) + .messageListener(System.out::println) + .queueNames("myTestQueue") + .build(); + container.start(); + container.stop(); +} ---- -In this example a `QueueMessagingTemplate` is created using the messaging namespace. The `convertAndSend` method -converts the payload `Person` using the configured `MessageConverter` and sends the message. +==== SqsMessageListenerContainerFactory + +A `MessageListenerContainerFactory` can be used to create `MessageListenerContainer` instances, both directly or through `@SqsListener` annotations. + +It can be created in a familiar `Spring` way, such as: + +[source, java,indent=0] +---- +@Bean +SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory(SqsAsyncClient sqsAsyncClient) { + SqsMessageListenerContainerFactory factory = new SqsMessageListenerContainerFactory<>(); + factory.setSqsAsyncClient(sqsAsyncClient); + return factory; +} +---- -==== Receiving a message -There are two ways for receiving SQS messages, either use the `receive` methods of the `QueueMessagingTemplate` or with -annotation-driven listener endpoints. The latter is by far the more convenient way to receive messages. +Or through the `Builder`: -[source,java,indent=0] +[source,java] ---- -Person person = this.queueMessagingTemplate.receiveAndConvert("queueName", Person.class); +@Bean +SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory(SqsAsyncClient sqsAsyncClient) { + return SqsMessageListenerContainerFactory + .builder() + .sqsAsyncClient(sqsAsyncClient) + .build(); +} ---- -In this example the `QueueMessagingTemplate` will get one message from the SQS queue and convert it to the target class -passed as argument. +IMPORTANT: Using this method for setting the `SqsAsyncClient` instance in the factory, all containers created by this factory will share the same `SqsAsyncClient` instance. +For high-throughput applications, a `Supplier` can be provided instead through the factory's `setSqsAsyncClientSupplier` or the builder's `sqsAsyncSupplier` methods. +In this case each container will receive a `SqsAsyncClient` instance. +Alternatively, a single `SqsAsyncClient` instance can be configured for higher throughput. See the AWS documentation for more information on tradeoffs of each approach. -==== Annotation-driven listener endpoints -Annotation-driven listener endpoints are the easiest way for listening on SQS messages. Simply annotate methods with -`MessageMapping` and the `QueueMessageHandler` will route the messages to the annotated methods. +The factory can also be used to create a container directly, such as: -[source,xml,indent=0] +[source,java] ---- - +@Bean +MessageListenerContainer myListenerContainer(SqsAsyncClient sqsAsyncClient) { + return SqsMessageListenerContainerFactory + .builder() + .sqsAsyncClient(sqsAsyncClient) + .messageListener(System.out::println) + .build() + .createContainer("myQueue"); +} ---- -[source,java,indent=0] +==== @SqsListener Annotation + +The simplest way to consume `SQS` messages is by annotating a method in a `@Component` class with the `@SqsListener` annotation. +The framework will then create the `MessageListenerContainer` and set a `MessagingMessageListenerAdapter` to invoke the method when a message is received. + +When using `Spring Boot` with `auto-configuration`, no configuration is necessary. + +Most attributes on the annotation can be resolved from SpEL `(#{...})` or property placeholders `(${...})`. + +===== Queue Names + +One or more queues can be specified in the annotation through the `queueNames` or `value` properties - there's no distinction between the two properties. + +Instead of queue names, queue urls can also be provided. +Using urls instead of queue names can result in slightly faster startup times since it prevents the framework from looking up the queue url when the containers start. + +[source, java] ---- -@SqsListener("queueName") -public void queueListener(Person person) { - // ... +@SqsListener({"${my.queue.url}", "myOtherQueue"}) +public void listenTwoQueues(String message) { + System.out.println(message); } ---- -In this example a queue listener container is started that polls the SQS `queueName` passed to the `MessageMapping` -annotation. The incoming messages are converted to the target type and then the annotated method `queueListener` is invoked. +Any number of `@SqsListener` annotations can be used in a bean class, and each annotated method will be handled by a separate `MessageListenerContainer`. -In addition to the payload, headers can be injected in the listener methods with the `@Header` or `@Headers` -annotations. `@Header` is used to inject a specific header value while `@Headers` injects a `Map` -containing all headers. +NOTE: Queues declared in the same annotation will share the container, though each will have separate throughput and acknowledgement controls. -Only the link:https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_Message.html[standard -message attributes] sent with an SQS message are fully supported. -Custom message attributes are handled as if custom type label (everything after '.' in type name) wasn't present in attribute key. -It is silently omitted during conversion, but still can be accessed as long as original message is present in 'sourceData' header. +===== Specifying a MessageListenerContainerFactory +A `MessageListenerContainerFactory` can be specified through the `factory` property. +Such factory will then be used to create the container for the annotated method. -In addition to the provided argument resolvers, custom ones can be registered on the -`aws-messaging:annotation-driven-queue-listener` element using the `aws-messaging:argument-resolvers` attribute (see example below). +If not specified, a factory with the `defaultSqsListenerContainerFactory` name will be looked up. +For changing this default name, see <>. -[source,xml,indent=0] +[source,java] ---- - - - - - +@SqsListener(queueNames = "myQueue", factory = "myFactory") +public void listen(String message) { + System.out.println(message); +} ---- -By default the `SimpleMessageListenerContainer` creates a `ThreadPoolTaskExecutor` with computed values for the core and -max pool sizes. The core pool size is set to twice the number of queues and the max pool size is obtained by multiplying -the number of queues by the value of the `maxNumberOfMessages` field. If these default values do not meet the need of -the application, a custom task executor can be set with the `task-executor` attribute (see example below). +When using a `Spring Boot` application with `auto-configuration`, a default factory is provided if there are no other factory beans declared in the context. + + +===== Other Annotation Properties + +The following properties can be specified in the `@SqsListener` annotation. +Such properties override the equivalent `ContainerOptions` for the resulting `MessageListenerContainer`. + +- `id` - Specify the resulting container's id. +This can be used for fetching the container from the `MessageListenerContainerRegistry`, and is used by the container and its components for general logging and thread naming. +- `maxInflightMessagesPerQueue` - Set the maximum number of messages that can be `inflight` at any given moment. +See <> for more information. +- `pollTimeoutSeconds` - Set the maximum time to wait before a poll returns from SQS. +Note that if there are messages available the call may return earlier than this setting. +- `messageVisibilitySeconds` - Set the minimum visibility for the messages retrieved in a poll. +Note that for `FIFO` single message listener methods, this visibility is applied to the whole batch before each message is sent to the listener. +See <> for more information. + + +===== Listener Method Arguments + +A number of possible argument types are allowed in the listener method's signature. + +- `MyPojo` - POJO types are automatically deserialized from JSON. +- `Message` - Provides a `Message` instance with the deserialized payload and `MessageHeaders`. +- `List` - Enables batch mode and receives the batch that was polled from SQS. +- `List>` - Enables batch mode and receives the batch that was polled from SQS along with headers. +- `@Header(String headerName)` - provides the specified header. +- `@Headers` - provides the `MessageHeaders` or a `Map` +- `Acknowledgement` - provides methods for manually acknowledging messages for single message listeners. +AcknowledgementMode must be set to `MANUAL` (see <>) +- `BatchAcknowledgement` - provides methods for manually acknowledging partial or whole message batches for batch listeners. +AcknowledgementMode must be set to `MANUAL` (see <>) +- `Visibility` - provides the `changeTo()` method that enables changing the message's visibility to the provided value. +- `QueueAttributes` - provides queue attributes for the queue that received the message. +See <> for how to specify the queue attributes that will be fetched from `SQS` +- `software.amazon.awssdk.services.sqs.model.Message` - provides the original `Message` from `SQS` + +Here's a sample with many arguments: -[source,xml,indent=0] +[source, java] ---- - +@SqsListener("${my-queue-name}") +public void listen(Message message, MyPojo pojo, MessageHeaders headers, Acknowledgement ack, Visibility visibility, QueueAttributes queueAttributes, software.amazon.awssdk.services.sqs.model.Message originalMessage) { + Assert.notNull(message); + Assert.notNull(pojo); + Assert.notNull(headers); + Assert.notNull(ack); + Assert.notNull(visibility); + Assert.notNull(queueAttributes); + Assert.notNull(originalMessage); +} ---- -==== FIFO queue support +IMPORTANT: Batch listeners support a single `List` and `List>` method arguments, and an optional `BatchAcknowledgement` or `AsyncBatchAcknowledgement` arguments. +`MessageHeaders` should be extracted from the `Message` instances through the `getHeaders()` method. -`AmazonSQSBufferedAsyncClient` that Spring Cloud AWS uses by default to communicate with SQS is not compatible with FIFO queues. -To use FIFO queues with Spring Cloud SQS it is recommended to overwrite default SQS client bean with a custom one that is not based on `AmazonSQSBufferedAsyncClient`. -For example: +==== Batch Processing + +All message processing interfaces have both `single message` and `batch` methods. +This means the same set of components can be used to process both single and batch methods, and share logic where applicable. + +When batch mode is enabled, the framework will serve the entire result of a poll to the listener. +If a value greater than 10 is provided for `maxMessagesPerPoll`, the result of multiple polls will be combined and up to the respective amount of messages will be served to the listener. + +To enable batch processing using `@SqsListener`, a single `List` or `List>` method argument should be provided in the listener method. +The listener method can also have an optional `BatchAcknowledgement` argument for `AcknowledgementMode.MANUAL`. + +Alternatively, `ContainerOptions` can be set to `ListenerMode.BATCH` in the `ContainerOptions` in the factory or container. + +NOTE: The same factory can be used to create both `single message` and `batch` containers for `@SqsListener` methods. + +IMPORTANT: In case the same factory is shared by both delivery methods, any supplied `ErrorHandler`, `MessageInterceptor` or `MessageListener` should implement the proper methods. + + +==== Container Options + +Each `MessageListenerContainer` can have a different set of options. +`MessageListenerContainerFactory` instances have a `ContainerOptions.Builder` instance property that is used as a template for the containers it creates. -[source,java,index=0] +Both factory and container offer a `configure` method that can be used to change the options: + +[source, java] +---- +@Bean +SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory(SqsAsyncClient sqsAsyncClient) { + return SqsMessageListenerContainerFactory + .builder() + .configure(options -> options + .messagesPerPoll(5) + .pollTimeout(Duration.ofSeconds(10))) + .sqsAsyncClient(sqsAsyncClient) + .build(); +} ---- -import com.amazonaws.ClientConfiguration; -import com.amazonaws.auth.AWSCredentialsProvider; -import com.amazonaws.services.sqs.AmazonSQSAsync; -import com.amazonaws.services.sqs.AmazonSQSAsyncClientBuilder; -import io.awspring.cloud.core.region.RegionProvider; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; +[source, java] +---- +@Bean +MessageListenerContainer listenerContainer(SqsAsyncClient sqsAsyncClient) { + return SqsMessageListenerContainer + .builder() + .configure(options -> options + .messagesPerPoll(5) + .pollTimeout(Duration.ofSeconds(10))) + .sqsAsyncClient(sqsAsyncClient) + .messageListener(System.out::println) + .queueNames("myTestQueue") + .build(); +} +---- -@Configuration -public class AppConfig { +The `ContainerOptions` instance is immutable and can be retrieved via the `container.getContainerOptions()` method. +If more complex configurations are necessary, the `toBuilder` and `fromBuilder` methods provide ways to create a new copy of the options, and then set it back to the factory or container: - @Bean - AmazonSQSAsync amazonSQS(AWSCredentialsProvider awsCredentialsProvider, RegionProvider regionProvider, - ClientConfiguration clientConfiguration) { - return AmazonSQSAsyncClientBuilder.standard().withCredentials(awsCredentialsProvider) - .withClientConfiguration(clientConfiguration).withRegion(regionProvider.getRegion().getName()).build(); - } +[source, java] +---- +void myMethod(MessageListenerContainer container) { + ContainerOptions.Builder modifiedOptions = container.getContainerOptions() + .toBuilder() + .pollTimeout(Duration.ofSeconds(5)) + .shutdownTimeout(Duration.ofSeconds(20)); + container.configure(options -> options.fromBuilder(modifiedOptions)); } ---- -===== Deletion policies -SQS integration supports deletion policies that are used when processing messages. -Note that SQS messages that were not deleted successfully are logged as an error. +A copy of the options can also be created with `containerOptions.createCopy()` or `containerOptionsBuilder.createCopy()`. -[cols="2"] -|=== -| `ALWAYS` -| Always deletes a message in case of success (no exception thrown) or failure (exception thrown) +===== Using Auto-Configuration -| `NEVER` -| When using this policy method listening must take care of deleting messages. +The Spring Boot Starter for SQS provides the following auto-configuration properties: -| `NO_REDRIVE` -| Deletes a message if no redrive policy is defined +[cols="2,3,1,1"] +|=== +| Name | Description | Required | Default value +| `spring.cloud.aws.sqs.enabled` | Enables the SQS integration. | No | `true` +| `spring.cloud.aws.sqs.endpoint` | Configures endpoint used by `SqsAsyncClient`. | No | `http://localhost:4566` +| `spring.cloud.aws.sqs.region` | Configures region used by `SqsAsyncClient`. | No | `eu-west-1` +| <> | Maximum number of inflight messages per queue. | No | 10 +| <> | Maximum number of messages to be received per poll. | No | 10 +| <> | Maximum amount of time to wait for messages in a poll. | No | 10 seconds +|=== -| `ON_SUCCESS` -| Deletes a message only when successfully executed by listener method (no exception thrown) -| `DEFAULT` -| Default if not changed is set to `NO_REDRIVE`. +===== ContainerOptions Descriptions +[cols="13,9,9,16", options="header"] +|=== +| Property +| Range +| Default +| Description + +|<> +|1 - `Integer.MAX_VALUE` +|10 +|The maximum number of messages from each queue that can be processed simultaneously in this container. +This number will be used for defining the thread pool size for the container following (maxInflightMessagesPerQueue * number of queues). +For batching acknowledgements a message is considered as no longer inflight when it's handed to the acknowledgement queue. +See <>. + +|<> +|1 - `Integer.MAX_VALUE` +|10 +|The maximum number of messages that will be received by a poll to a SQS queue in this container. +If a value greater than 10 is provided, the result of multiple polls +will be combined, which can be useful for batch listeners. + +See AWS documentation for more information. + +|<> +|1 - 10 seconds +|10 seconds +|The maximum duration for a poll to a SQS queue before returning empty. +Longer polls decrease the chance of empty polls when messages are available. +See AWS documentation for more information. + +|<> +|1 - 10 seconds +|10 seconds +|The maximum time the framework will wait for permits to be available for a queue before attempting the next poll. +After that period, the framework will try to perform a partial acquire with the available permits, resulting in a poll for less than `maxMessagesPerPoll` messages, unless otherwise configured. +See <>. + +|`shutdownTimeout` +|0 - undefined +|10 seconds +|The amount of time the container will wait for a queue to complete its operations before attempting to forcefully shutdown. +See <>. + +|`backPressureMode` +|`AUTO`, `ALWAYS_POLL_MAX_MESSAGES`, `FIXED_HIGH_THROUGHPUT` +|`AUTO` +|Configures the backpressure strategy to be used by the container. +See <>. + +|`listenerMode` +|`SINGLE_MESSAGE`, `BATCH` +|`SINGLE_MESSAGE` +|Configures whether this container will use `single message` or `batch` listeners. +This value is overriden by `@SqsListener` depending on whether the listener method contains a `List` argument. +See <>. + +|`queueAttributeNames` +|`Collection` +|Empty list +|Configures the `QueueAttributes` that will be retrieved from SQS when a container starts. +See <>. + +|`messageAttributeNames` +|`Collection` +|`ALL` +|Configures the `MessageAttributes` that will be retrieved from SQS for each message. +See <>. + +|`messageSystemAttributeNames` +|`Collection` +|`ALL` +|Configures the `MessageSystemAttribute` that will be retrieved from SQS for each message. +See <>. + +|`messageConverter` +|`MessagingMessageConverter` +|`SqsMessagingMessageConverter` +|Configures the `MessagingMessageConverter` that will be used to convert SQS messages into Spring Messaging Messages. +See <>. + +|`acknowledgementMode` +|`ON_SUCCESS`, `ALWAYS`, `MANUAL` +|`ON_SUCCESS` +|Configures the processing outcomes that will trigger automatic acknowledging of messages. +See <>. + +|`acknowledgementInterval` +|0 - undefined +|`1 second` for `Standard SQS`, `Duration.ZERO` for `FIFO SQS` +|Configures the interval between acknowledges for batching. +Set to `Duration.ZERO` along with `acknowledgementThreshold` to zero to enable `immediate acknowledgement` +See <>. + +|`acknowledgementThreshold` +|0 - undefined +|`10` for `Standard SQS`, `0` for `FIFO SQS` +|Configures the minimal amount of messages in the acknowledgement queue to trigger acknowledgement of a batch. +Set to zero along with `acknowledgementInterval` to `Duration.ZERO` to enable `immediate acknowledgement`. +See <>. + +|`acknowledgementOrdering` +|`PARALLEL`, `ORDERED` +|`PARALLEL` for `Standard SQS` and `FIFO` queues with immediate acknowledgement, `ORDERED` for `FIFO` queues with acknowledgement batching enabled. +|Configures the order acknowledgements should be made. +Fifo queues can be acknowledged in parallel for immediate acknowledgement since the next message for a message group will only start being processed after the previous one has been acknowledged. +See <>. + +|`containerComponentsTaskExecutor` +|`TaskExecutor` +|`null` +|Provides a `TaskExecutor` instance to be used by the `MessageListenerContainer` internal components. +See <>. + +|`messageVisibility` +|`Duration` +|`null` +|Specify the message visibility duration for messages polled in this container. +For `FIFO` queues, visibility is extended for all messages in a message group before each message is processed. +See <>. +Otherwise, visibility is specified once when polling SQS. + +|`queueNotFoundStrategy` +|`FAIL`, `CREATE` +|`CREATE` +|Configures the behavior when a queue is not found at container startup. +See <>. |=== -Deletion policy can be specified inside of `@SqsListener`. When policy is explicitly used in `@SqsListener` it takes priority over the global deletion policy. +==== Retrieving Attributes from SQS + +`QueueAttributes`, `MessageAttributes` and `MessageSystemAttributes` can be retrieved from SQS. +These can be configured using the `ContainerOptions` `queueAttributeNames`, `messageAttributeNames` and `messageSystemAttributeNames` methods. + +`QueueAttributes` for a queue are retrieved when containers start, and can be looked up by adding the `QueueAttributes` method parameter in a `@SqsListener` method, or by getting the `SqsHeaders.SQS_QUEUE_ATTRIBUTES_HEADER` header. + +`MessageAttributes` and `MessageSystemAttributes` are retrieved with each message, and are mapped to message headers. +Those can be retrieved with `@Header` parameters, or directly in the `Message`. +The message headers are prefixed with `SqsHeaders.SQS_MA_HEADER_PREFIX` ("Sqs_MA_") for message attributes and +`SqsHeaders.SQS_MSA_HEADER_PREFIX` ("Sqs_MSA_") for message system attributes. + +NOTE: By default, no `QueueAttributes` and `ALL` `MessageAttributes` and `MessageSystemAttributes` are retrieved. + +==== Container Lifecycle + +The `MessageListenerContainer` interface extends `SmartLifecycle`, which provides methods to control the container's lifecycle. -[source,java,indent=0] +Containers created from `@SqsListener` annotations are registered in a `MessageListenerContainerRegistry` bean that is registered by the framework. +The containers themselves are not Spring-managed beans, and the registry is responsible for managing these containers` lifecycle in application startup and shutdown. + +At startup, the containers will make requests to `SQS` to retrieve the queues` urls for the provided queue names, and for retrieving `QueueAttributes` if so configured. +Providing queue urls instead of names and not requesting queue attributes can result in slightly better startup times since there's no need for such requests. + +NOTE: If retrieving the queue url fails due to the queue not existing, the framework can be configured to either create the queue or fail. +If a URL is provided instead of a queue name the framework will not make this request at startup, and thus if the queue does not exist it will fail at runtime. +This configuration is available in `ContainerOptions` `queueNotFoundStrategy.` + +At shutdown, by default containers will wait for all polling, processing and acknowledging operations to finish, up to `ContainerOptions.getShutdownTimeout()`. +After this period, operations will be canceled and the container will attempt to forcefully shutdown. + +===== Containers as Spring Beans + +Manually created containers can be registered as beans, e.g. by declaring a `@Bean` in a `@Configuration` annotated class. +In these cases the containers lifecycle will be managed by the `Spring` context at application startup and shutdown. + +[source, java] ---- -@SqsListener(value = "queueName", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS) +@Bean +MessageListenerContainer listenerContainer(SqsAsyncClient sqsAsyncClient) { + return SqsMessageListenerContainer + .builder() + .sqsAsyncClient(sqsAsyncClient) + .messageListener(System.out::println) + .queueNames("myTestQueue") + .build(); +} ---- -Global deletion policy which will be used by all `@SqsListener` can be set by using a property: +===== Retrieving Containers from the Registry + +Containers can be retrieved by fetching the `MessageListenerContainer` bean from the container and using the `getListenerContainers` and `getContainerById` methods. +Then lifecycle methods can be used to start and stop instances. -[source,properties,indent=0] +[source,java] ---- -cloud.aws.sqs.handler.default-deletion-policy=ON_SUCCESS +@Autowired +MessageListenerContainerRegistry registry; + +public void myLifecycleMethod() { + MessageListenerContainer container = registry.getContainerById("myId"); + container.stop(); + container.start(); +} ---- +===== Lifecycle Execution + +By default, all lifecycle actions performed by the `MessageListenerContainerRegistry` and internally by the `MessageListenerContainer` instances are executed in parallel. + +This behavior can be disabled by setting `LifecycleHandler.get().setParallelLifecycle(false)`. + +NOTE: Spring-managed `MessageListenerContainer` beans' lifecycle actions are always performed sequentially. + + +==== FIFO Support + +`FIFO` SQS queues are fully supported for receiving messages - queues with names that ends in `.fifo` will automatically be setup as such. + +* Messages are polled with a `receiveRequestAttemptId`, and the received batch of messages is split according to the message`s `MessageGroupId`. +* Each message from a given group will then be processed in order, while each group is processed in parallel. +* If processing fails for a message, the following messages from the same message group are discarded so they will be served again after their `message visibility` +expires. +* Messages which were already successfully processed and acknowledged will not be served again. +* If a `batch` listener is used, each message group from a poll will be served as a batch to the listener method. +* `FIFO` queues also have different defaults for acknowledging messages, see <> for more information. +* If a `message visibility` is set through `@SqsListener` or `ContainerOptions`, visibility will be extended for all messages in the message group before each message is processed. + +IMPORTANT: A `MessageListenerContainer` can either have only `Standard` queues or `FIFO` queues - not both. +This is valid both for manually created containers and `@SqsListener` annotated methods. + +=== Message Interceptor + +The framework offers the `MessageInterceptor` and the `AsyncMessageInterceptor` interfaces: + +[source, java] +---- +public interface MessageInterceptor { + + default Message intercept(Message message) { + return message; + } + + default Collection> intercept(Collection> messages) { + return messages; + } + + default void afterProcessing(Message message, Throwable t) { + } + + default void afterProcessing(Collection> messages, Throwable t) { + } + +} +---- + +[source, java] +---- +public interface AsyncMessageInterceptor { + + default CompletableFuture> intercept(Message message) { + return CompletableFuture.completedFuture(message); + } + + default CompletableFuture>> intercept(Collection> messages) { + return CompletableFuture.completedFuture(messages); + } + + default CompletableFuture afterProcessing(Message message, Throwable t) { + return CompletableFuture.completedFuture(null); + } + + default CompletableFuture afterProcessing(Collection> messages, Throwable t) { + return CompletableFuture.completedFuture(null); + } + +} +---- -===== Message reply -Message listener methods can be annotated with `@SendTo` to send their return value to another channel. The -`SendToHandlerMethodReturnValueHandler` uses the defined messaging template set on the -`aws-messaging:annotation-driven-queue-listener` element to send the return value. The messaging template must implement -the `DestinationResolvingMessageSendingOperations` interface. +When using the auto-configured factory, simply declare a `@Bean` and the interceptor will be set: -[source,xml,indent=0] +[source, java] ---- - +@Bean +public MessageInterceptor messageInterceptor() { + return new MessageInterceptor() { + @Override + public Message intercept(Message message) { + return MessageBuilder + .fromMessage(message) + .setHeader("newHeader", "newValue") + .build(); + } + }; +} ---- -[source,java,indent=0] +Alternatively, implementations can be set in the `MessageListenerContainerFactory` or directly in the `MessageListenerContainer`: + +[source, java] ---- -@SqsListener("treeQueue") -@SendTo("leafsQueue") -public List extractLeafs(Tree tree) { - // ... +@Bean +public SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory() { + return SqsMessageListenerContainerFactory + .builder() + .sqsAsyncClientSupplier(BaseSqsIntegrationTest::createAsyncClient) + .messageInterceptor(new MessageInterceptor() { + @Override + public Message intercept(Message message) { + return MessageBuilder + .fromMessage(message) + .setHeader("newHeader", "newValue") + .build(); + } + }) + .build(); } ---- -In this example the `extractLeafs` method will receive messages coming from the `treeQueue` and then return a -`List` of ``Leaf``s which is going to be sent to the `leafsQueue`. Note that on the -`aws-messaging:annotation-driven-queue-listener` XML element there is an attribute `send-to-message-template` -that specifies `QueueMessagingTemplate` as the messaging template to be used to send the return value of the message -listener method. +NOTE: Multiple interceptors can be added to the same factory or container. + +The `intercept` methods are executed `before` a message is processed, and a different message can be returned. + +IMPORTANT: In case a different message is returned, it's important to add the `SqsHeaders.SQS_RECEIPT_HANDLE_HEADER` with the value of the original handler so the original message is acknowledged after processing. +Also, a `SqsHeaders.SQS_MESSAGE_ID_HEADER` must always be present. + +IMPORTANT: The `intercept` methods must not return null. -===== Handling Exceptions +The `afterProcessing` methods are executed after message is processed and the `ErrorHandler` is invoked, but before the message is acknowledged. -Exception thrown inside `@SqsListener` annotated methods can be handled by methods annotated with `@MessageExceptionHandler`. +=== Error Handling -[source,java,indent=0] +By default, messages that have an error thrown by the listener will not be acknowledged, and the message can be polled again after `visibility timeout` expires. + +Alternatively, the framework offers the `ErrorHandler` and `AsyncErrorHandler` interfaces, which are invoked after a listener execution fails. + +[source, java] ---- -import io.awspring.cloud.messaging.listener.annotation.SqsListener; -import org.springframework.messaging.handler.annotation.MessageExceptionHandler; -import org.springframework.stereotype.Component; +public interface ErrorHandler { -@Component -public class MyMessageHandler { + default void handle(Message message, Throwable t) { + } + + default void handle(Collection> messages, Throwable t) { + } + +} +---- + +[source, java] +---- +public interface AsyncErrorHandler { - @SqsListener("queueName") - void handle(String message) { - ... - throw new MyException("something went wrong"); + default CompletableFuture handle(Message message, Throwable t) { + return CompletableFutures.failedFuture(t); } - @MessageExceptionHandler(MyException.class) - void handleException(MyException e) { - ... + default CompletableFuture handle(Collection> messages, Throwable t) { + return CompletableFutures.failedFuture(t); } + } ---- -==== The SimpleMessageListenerContainerFactory -The `SimpleMessageListenerContainer` can also be configured with Java by creating a bean of type `SimpleMessageListenerContainerFactory`. +When using the auto-configured factory, simply declare a `@Bean` and the error handler will be set: -[source,java,indent=0] +[source, java] ---- @Bean -public SimpleMessageListenerContainerFactory simpleMessageListenerContainerFactory(AmazonSQSAsync amazonSqs) { - SimpleMessageListenerContainerFactory factory = new SimpleMessageListenerContainerFactory(); - factory.setAmazonSqs(amazonSqs); - factory.setAutoStartup(false); - factory.setMaxNumberOfMessages(5); - // ... +public ErrorHandler errorHandler() { + return new ErrorHandler() { + @Override + public void handle(Message message, Throwable t) { + // error handling logic + // throw if the message should not be acknowledged + } + }} +---- + +Alternatively, implementations can be set in the `MessageListenerContainerFactory` or directly in the `MessageListenerContainer`: + +[source, java] +---- +@Bean +public SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory() { + return SqsMessageListenerContainerFactory + .builder() + .sqsAsyncClientSupplier(BaseSqsIntegrationTest::createAsyncClient) + .errorHandler(new ErrorHandler() { + @Override + public void handle(Message message, Throwable t) { + // error handling logic + } + }) + .build(); +} +---- + +If the error handler execution succeeds, i.e. no error is thrown from the error handler, the message is considered to be recovered and is acknowledged according to the acknowledgement configuration. + +IMPORTANT: If the message should not be acknowledged and the `ON_SUCCESS` acknowledgement mode is set, it's important to propagate the error. +For simply executing an action in case of errors, an `interceptor` should be used instead, checking the presence of the `throwable` argument for detecting a failed execution. + + +=== Message Conversion and Payload Deserialization + +Payloads are automatically deserialized from `JSON` for `@SqsListener` annotated methods using a `MappingJackson2MessageConverter`. + +NOTE: When using Spring Boot's auto-configuration, if there's a single `ObjectMapper` in Spring Context, such object mapper will be used for converting messages. +This includes the one provided by Spring Boot's auto-configuration itself. +For configuring a different `ObjectMapper`, see <>. + +For manually created `MessageListeners`, `MessageInterceptor` and `ErrorHandler` components, or more fine-grained conversion such as using `interfaces` or `inheritance` in listener methods, type mapping is required for payload deserialization. + +By default, the framework looks for a `MessageHeader` named `Sqs_MA_JavaType` containing the fully qualified class name (`FQCN`) for which the payload should be deserialized to. +If such header is found, the message is automatically deserialized to the provided class. + +Further configuration can be achieved by providing a configured `MessagingMessageConverter` instance in the `ContainerOptions`. + +NOTE: If type mapping is setup or type information is added to the headers, payloads are deserialized right after the message is polled. +Otherwise, for `@SqsListener` annotated methods, payloads are deserialized right before the message is sent to the listener. +For providing custom `MessageConverter` instances to be used by `@SqsListener` methods, see <> + +==== Configuring a MessagingMessageConverter + +The framework provides the `SqsMessagingMessageConverter`, which implements the `MessagingMessageConverter` interface. + +[source, java] +---- +public interface MessagingMessageConverter { + + Message toMessagingMessage(S source); + + S fromMessagingMessage(Message message); + +} +---- + +The default header-based type mapping can be configured to use a different header name by using the `setPayloadTypeHeader` method. + +More complex mapping can be achieved by using the `setPayloadTypeMapper` method, which overrides the default header-based mapping. +This method receives a `Function, Class> payloadTypeMapper` that will be applied to incoming messages. + +The default `MappingJackson2MessageConverter` can be replaced by using the `setPayloadMessageConverter` method. + +The framework also provides the `SqsHeaderMapper`, which implements the `HeaderMapper` interface and is invoked by the `SqsMessagingMessageConverter`. +To provide a different `HeaderMapper` implementation, use the `setHeaderMapper` method. + +An example of such configuration follows: - return factory; +[source, java] +---- +// Create converter instance +SqsMessagingMessageConverter messageConverter = new SqsMessagingMessageConverter(); + +// Configure Type Header +messageConverter.setPayloadTypeHeader("myTypeHeader"); + +// Configure Header Mapper +SqsHeaderMapper headerMapper = new SqsHeaderMapper(); +headerMapper.setAdditionalHeadersFunction(((sqsMessage, accessor) -> { + accessor.setHeader("myCustomHeader", "myValue"); + return accessor.toMessageHeaders(); +})); +messageConverter.setHeaderMapper(headerMapper); + +// Configure Payload Converter +MappingJackson2MessageConverter payloadConverter = new MappingJackson2MessageConverter(); +payloadConverter.setPrettyPrint(true); +messageConverter.setPayloadMessageConverter(payloadConverter); + +// Set MessageConverter to the factory or container +factory.configure(options -> options.messageConverter(messageConverter)); +---- + +==== Interfaces and Subclasses in Listener Methods + +Interfaces and subclasses can be used in `@SqsListener` annotated methods by configuring a `type mapper`: + +[source, java] +---- +messageConverter.setPayloadTypeMapper(message -> { + String eventTypeHeader = message.getHeaders().get("myEventTypeHeader", String.class); + return "eventTypeA".equals(eventTypeHeader) + ? MyTypeA.class + : MyTypeB.class; +}); +---- + +And then, in the listener method: + +[source, java] +---- +@SpringBootApplication +public class SqsApplication { + + public static void main(String[] args) { + SpringApplication.run(SqsApplication.class, args); + } + + // Retrieve the converted payload + @SqsListener("myQueue") + public void listen(MyInterface message) { + System.out.println(message); + } + + // Or retrieve a Message with the converted payload + @SqsListener("myOtherQueue") + public void listen(Message message) { + System.out.println(message); + } } ---- -==== Consuming AWS Event messages with Amazon SQS -It is also possible to receive AWS generated event messages with the SQS message listeners. Because -AWS messages does not contain the mime-type header, the Jackson message converter has to be configured -with the `strictContentTypeMatch` property false to also parse message without the proper mime type. +=== Acknowledging Messages + +In `SQS` acknowledging a message is the same as deleting the message from the queue. +A number of `Acknowledgement` strategies are available and can be configured via `ContainerOptions`. +Optionally, a callback action can be added to be executed after either a successful or failed acknowledgement. -The next code shows the configuration of the message converter using the `QueueMessageHandlerFactory` -and re-configuring the `MappingJackson2MessageConverter` +Here's an example of a possible configuration: -[source,java,indent=0] +[source, java] ---- @Bean -public QueueMessageHandlerFactory queueMessageHandlerFactory() { - QueueMessageHandlerFactory factory = new QueueMessageHandlerFactory(); - MappingJackson2MessageConverter messageConverter = new MappingJackson2MessageConverter(); +SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory(SqsAsyncClient sqsAsyncClient) { + return SqsMessageListenerContainerFactory + .builder() + .configure(options -> options + .acknowledgementMode(AcknowledgementMode.ALWAYS) + .acknowledgementInterval(Duration.ofSeconds(3)) + .acknowledgementThreshold(5) + .acknowledgementOrdering(AcknowledgementOrdering.ORDERED) + ) + .sqsAsyncClient(sqsAsyncClient) + .build(); +} +---- + +Each option is explained in the following sections. + +NOTE: All options are available for both `single message` and `batch` message listeners. + +==== Acknowledgement Mode + +- `ON_SUCCESS` - Acknowledges a message or batch of messages after successful processing. +- `ALWAYS` - Acknowledges a message or batch of messages after processing returns success or error. +- `MANUAL` - The framework won't acknowledge messages automatically and `Acknowledgement` objects can be received in the listener method. + +==== Acknowledgement Batching + +The `acknowledgementInterval` and `acknowledgementThreshold` options enable acknowledgement batching. +Acknowledgements will be executed after either the amount of time specified in the `interval` or the number of messages to acknowledge reaches the `threshold`. + +Setting `acknowledgementInterval` to `Duration.ZERO` will disable the periodic acknowledgement, which will be executed only when the number of messages to acknowledge reaches the specified `acknowledgementThreshold`. + +Setting `acknowledgementThreshold` to `0` will disable acknowledging per number of messages, and messages will be acknowledged only on the specified `acknowldgementInterval` + +IMPORTANT: When using acknowledgement batching messages stay inflight for SQS purposes until their respective batch is acknowledged. `MessageVisibility` should be taken into consideration when configuring this strategy. + +===== Immediate Acknowledging + +Setting both `acknowledgementInterval` and `acknowledgementThreshold` to `Duration.ZERO` and `0` respectively enables `Immediate Acknowledging`. + +With this configuration, messages are acknowledged sequentially after being processed, and the message is only considered processed after the message is successfully acknowledged. + +IMPORTANT: If an immediate acknowledging triggers an error, message processing is considered failed and will be retried after the specified `visibilityTimeout`. + +==== Manual Acknowledgement + +Acknowledgements can be handled manually by setting `AcknowledgementMode.MANUAL` in the `ContainerOptions`. + +Manual acknowledgement can be used in conjunction with acknowledgement batching - the message will be queued for acknowledgement but won't be executed until one of the above criteria is met. + +It can also be used in conjunction with immediate acknowledgement. + +The following arguments can be used in listener methods to manually acknowledge: + +===== `Acknowledgement` + +The `Acknowledgement` interface can be used to acknowledge messages in `ListenerMode.SINGLE_MESSAGE`. + +```java +public interface Acknowledgement { + + /** + * Acknowledge the message. + */ + void acknowledge(); + + /** + * Asynchronously acknowledge the message. + */ + CompletableFuture acknowledgeAsync(); + +} +``` + +===== `BatchAcknowledgement` + +The `BatchAcknowledgement` interface can be used to acknowledge messages in `ListenerMode.BATCH`. + +The `acknowledge(Collection)` method enables acknowledging partial batches. + +```java +public interface BatchAcknowledgement { + + /** + * Acknowledge all messages from the batch. + */ + void acknowledge(); + + /** + * Asynchronously acknowledge all messages from the batch. + */ + CompletableFuture acknowledgeAsync(); + + /** + * Acknowledge the provided messages. + */ + void acknowledge(Collection> messagesToAcknowledge); + + /** + * Asynchronously acknowledge the provided messages. + */ + CompletableFuture acknowledgeAsync(Collection> messagesToAcknowledge); + +} +``` + +==== Acknowledgement Ordering + +- `PARALLEL` - Acknowledges the messages as soon as one of the above criteria is met - many acknowledgement calls can be made in parallel. +- `ORDERED` - One batch of acknowledgements will be executed after the previous one is completed, ensuring `FIFO` ordering for `batching` acknowledgements. +- `ORDERED_BY_GROUP` - One batch of acknowledgements will be executed after the previous one for the same group is completed, ensuring `FIFO` ordering of acknowledgements with parallelism between message groups. +Only available for `FIFO` queues. + + +==== Acknowledgement Defaults + +The defaults for acknowledging differ for `Standard` and `FIFO` SQS queues. + +===== Standard SQS +- Acknowledgement Interval: One second +- Acknowledgement Threshold: Ten messages +- Acknowledgement Ordering: `PARALLEL` + +===== FIFO SQS +- Acknowledgement Interval: Zero (Immediate) +- Acknowledgement Threshold: Zero (Immediate) +- Acknowledgement Ordering: `PARALLEL` if immediate acknowledgement, `ORDERED` if batching is enabled (one or both above defaults are overridden). + +NOTE: PARALLEL is the default for FIFO because ordering is guaranteed for processing. +This assures no messages from a given `MessageGroup` will be polled until the previous batch is acknowledged. +Implementations of this interface will be executed after an acknowledgement execution completes with either success or failure. + +==== Acknowledgement Result Callback + +The framework offers the `AcknowledgementResultCallback` and `AsyncAcknowledgementCallback` interfaces that can be added to a `SqsMessageListenerContainer` or `SqsMessageListenerContainerFactory`. + +```java +public interface AcknowledgementResultCallback { + + default void onSuccess(Collection> messages) { + } + + default void onFailure(Collection> messages, Throwable t) { + } + +} +``` + +```java +public interface AsyncAcknowledgementResultCallback { + + default CompletableFuture onSuccess(Collection> messages) { + return CompletableFuture.completedFuture(null); + } + + default CompletableFuture onFailure(Collection> messages, Throwable t) { + return CompletableFuture.completedFuture(null); + } - //set strict content type match to false - messageConverter.setStrictContentTypeMatch(false); - factory.setArgumentResolvers(Collections.singletonList(new PayloadArgumentResolver(messageConverter))); - return factory; } +``` + +```java +@Bean +public SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory(SqsAsyncClient sqsAsyncClient) { + return SqsMessageListenerContainerFactory + .builder() + .sqsAsyncClient(sqsAsyncClient) + .acknowledgementResultCallback(getAcknowledgementResultCallback()) + .build(); +} +``` + +NOTE: When `immediate acknowledgement` is set, as is the default for `FIFO` queues, the callback will be executed **before** the next message in the batch is processed, and next message processing will wait for the callback completion. +This can be useful for taking action such as retrying to delete the messages, or stopping the container to prevent duplicate processing in case an acknowledgement fails in a FIFO queue. +For `batch parallel processing`, as is the default for `Standard` queues the callback execution happens asynchronously. + + +=== Global Configuration for @SqsListeners + +A set of configurations can be set for all containers from `@SqsListener` by providing `SqsListenerConfigurer` beans. + +[source, java] ---- +@FunctionalInterface +public interface SqsListenerConfigurer { -With the configuration above, it is possible to receive event notification for S3 buckets (and also other -event notifications like elastic transcoder messages) inside `@SqsListener` annotated methods s shown below. + void configure(EndpointRegistrar registrar); -[source,java,indent=0] +} ---- -@SqsListener("testQueue") -public void receive(S3EventNotification s3EventNotificationRecord) { - S3EventNotification.S3Entity s3Entity = s3EventNotificationRecord.getRecords().get(0).getS3(); + +The following attributes can be configured in the registrar: + +- `setMessageHandlerMethodFactory` - provide a different factory to be used to create the `invocableHandlerMethod` instances that wrap the listener methods. +- `setListenerContainerRegistry` - provide a different `MessageListenerContainerRegistry` implementation to be used to register the `MessageListenerContainers` +- `setMessageListenerContainerRegistryBeanName` - provide a different bean name to be used to retrieve the `MessageListenerContainerRegistry` +- `setObjectMapper` - set the `ObjectMapper` instance that will be used to deserialize payloads in listener methods. +See <> for more information on where this is used. +- `manageMessageConverters` - gives access to the list of message converters that will be used to convert messages. +By default, `StringMessageConverter`, `SimpleMessageConverter` and `MappingJackson2MessageConverter` are used. + +- `manageArgumentResolvers` - gives access to the list of argument resolvers that will be used to resolve the listener method arguments. +The order of resolvers is important - `PayloadMethodArgumentResolver` should generally be last since it's used as default. + +A simple example would be: + +[source, java] +---- +@Bean +SqsListenerConfigurer configurer(ObjectMapper objectMapper) { + return registrar -> registrar.setObjectMapper(objectMapper); } ---- -==== IAM Permissions -Following IAM permissions are required by Spring Cloud AWS: +NOTE: Any number of `SqsListenerConfigurer` beans can be registered in the context. +All instances will be looked up at application startup and iterated through. + +=== Message Processing Throughput + +The following options are available for tuning the application's throughput. +When a configuration is available both in the `ContainerOptions` and `@SqsListener` annotation, the annotation value takes precedence, if any. + +==== ContainerOptions and `@SqsListener` properties + +===== maxInflightMessagesPerQueue +Can be set in either the `ContainerOptions` or the `@SqsListener` annotation. +Represents the maximum number of messages being processed by the container at a given time. +Defaults to 10. + +This value is enforced per queue, meaning the number of inflight messages in a container can be up to (number of queues in container * maxInflightMessagesPerQueue). + +NOTE: When using acknowledgement batching, a message is considered as no longer inflight when it's delivered to the acknowledgement queue. In this case, the actual number of inflight messages on AWS SQS console can be higher than the configured value. +When using immediate acknowledgement, a message is considered as no longer inflight after it's been acknowledged or throws an error. + + +===== maxMessagesPerPoll +Set in `ContainerOptions` or the `@SqsListener` annotation. +Represents the maximum number of messages returned by a single poll to a SQS queue, to a maximum of 10. +This value has to be less than or equal to `maxInflightMessagesPerQueue`. +Defaults to 10. + +Note that even if the queue has more messages, a poll can return less messages than specified. See the AWS documentation for more information. + +===== pollTimeout +Can be set in either the `ContainerOptions` or the `@SqsListener` annotation. +Represents the maximum duration of a poll. +Higher values represent `long polls` and increase the probability of receiving full batches of messages. +Defaults to 10 seconds. + +===== permitAcquireTimeout +Set in `ContainerOptions`. +Represents the maximum amount of time the container will wait for `maxMessagesPerPoll` permits to be available before trying to acquire a partial batch. +This wait is applied per queue and one queue has no interference in another in this regard. +Defaults to 10 seconds. + +==== Default Polling Behavior +By default, the framework starts all queues in `low throughput mode`, where it will perform one poll for messages at a time. +When a poll returns at least one message, the queue enters a `high throughput mode` where it will try to fulfill `maxInflightMessagesPerQueue` messages by making (maxInflightMessagesPerQueue / maxMessagesPerPoll) parallel polls to the queue. +Any poll that returns no messages will trigger a `low throughput mode` again, until at least one message is returned, triggering `high throughput mode` again, and so forth. + +After `permitAcquireTimeout`, if `maxMessagesPerPoll` permits are not available, it'll poll for the difference, i.e. as many messages as have been processed so far, if any. + +E.g. Let's consider a scenario where the container is configured for: `maxInflightMessagesPerQueue` = 20, `maxMessagesPerPoll` = 10, `permitAcquireTimeout` = 5 seconds, and a `pollTimeout` = 10 seconds. + +The container starts in `low throughput mode`, meaning it'll attempt a single poll for 10 messages. +If any messages are returned, it'll switch to `high throughput mode`, and will make up to 2 simultaneous polls for 10 messages each. +If all 20 messages are retrieved, it'll not attempt any more polls until messages are processed. +If after the 5 seconds for `permitAcquireTimeout` 6 messages have been processed, the framework will poll for the 6 messages. +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 following `BackPressureMode` values can be set in `ContainerOptions` to configure polling behavior: + +* `AUTO` - The default mode, as described in the previous section. +* `ALWAYS_POLL_MAX_MESSAGES` - Disables partial batch polling, i.e. if the container is configured for 10 messages per poll, it'll wait for 10 messages to be processed before attempting to poll for the next 10 messages. +Useful for optimizing for fewer polls at the expense of throughput. +* `FIXED_HIGH_THROUGHPUT` - Disables `low throughput mode`, while still attempting partial batch polling as described in the previous section. +Useful for really high throughput scenarios where the risk of making parallel polls to an idle queue is preferable to an eventual switch to `low throughput mode` . + +NOTE: The `AUTO` setting should be balanced for most use cases, including high throughput ones. + +=== 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. +All processing involved in polling for messages, changing message visibilities and acknowledging messages is done in an async, non-blocking fashion. This allows a higher overall throughput for the application. + +When a `MessageListener`, `MessageInterceptor`, and `ErrorHandler` implementation is set to a `MesssageListenerContainer` or `MesssageListenerContainerFactory` these are adapted by the framework. This way, blocking and non-blocking components can be used in conjunction with each other. + +Listener methods annotated with `@SqsListener` can either return a simple value, e.g. `void`, or a `CompletableFuture`. +The listener method will then be wrapped in either a `MessagingMessageListenerAdapter` or a `AsyncMessagingMessageListenerAdapter` respectively. + +NOTE: In order to achieve higher throughput, it's encouraged that, at least for simpler logic in message listeners, `interceptors` and `error handlers`, the async variants are used. + +==== Threading and Blocking Components + +Message processing always starts in a framework thread from the default or provided `TaskExecutor`. + +If an async component is invoked and the execution returns to the framework on a different thread, such thread will be used until a `blocking` component is found, when the execution switches back to a `TaskExecutor` thread to avoid blocking i.e. `SqsAsyncClient` or `HttpClient` threads. + +If by the time the execution reaches a `blocking` component it's already on a framework thread, it remains in the same thread to avoid excessive thread allocation and hopping. + +IMPORTANT: When using `async` methods it's critical not to block the incoming thread, which might be very detrimental to overall performance. +If thread-blocking logic has to be used, the blocking logic should be executed on another thread, e.g. using `CompletableFuture.supplyAsync(() -> myLogic(), myExecutor)`. +Otherwise, a `sync` interface should be used. + +==== Providing a TaskExecutor + +The default `TaskExecutor` is a `ThreadPoolTaskExecutor`, and a different `componentTaskExecutor` supplier can be set in the `ContainerOptions`. + +When providing a custom executor, it's important that it's configured to support all threads that will be created, which should be (maxInflightMessagesPerQueue * total number of queues). +Also, to avoid unnecessary thread hopping between blocking components, a `MessageExecutionThreadFactory` should be set to the executor. + +If setting the `ThreadFactory` is not possible, it's advisable to allow for extra threads in the thread pool to account for the time between a new thread is requested and the previous thread is released. + + +=== IAM Permissions +Following IAM permissions are required by Spring Cloud AWS SQS: [cols="2"] |=== diff --git a/pom.xml b/pom.xml index 075eebda7..ab6e7ca66 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,7 @@ spring-cloud-aws-secrets-manager spring-cloud-aws-ses spring-cloud-aws-sns + spring-cloud-aws-sqs spring-cloud-aws-dynamodb spring-cloud-aws-s3-parent spring-cloud-aws-starters diff --git a/spring-cloud-aws-autoconfigure/pom.xml b/spring-cloud-aws-autoconfigure/pom.xml index 55f1576fd..c7727901a 100644 --- a/spring-cloud-aws-autoconfigure/pom.xml +++ b/spring-cloud-aws-autoconfigure/pom.xml @@ -60,6 +60,12 @@ spring-cloud-aws-sns true + + io.awspring.cloud + spring-cloud-aws-sqs + 3.0.0-SNAPSHOT + true + io.awspring.cloud spring-cloud-aws-dynamodb diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sqs/SqsAutoConfiguration.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sqs/SqsAutoConfiguration.java new file mode 100644 index 000000000..5b88e09f1 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sqs/SqsAutoConfiguration.java @@ -0,0 +1,109 @@ +/* + * 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.autoconfigure.sqs; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.awspring.cloud.autoconfigure.core.AwsClientBuilderConfigurer; +import io.awspring.cloud.autoconfigure.core.AwsClientCustomizer; +import io.awspring.cloud.autoconfigure.core.CredentialsProviderAutoConfiguration; +import io.awspring.cloud.autoconfigure.core.RegionProviderAutoConfiguration; +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.ContainerOptions; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.errorhandler.ErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import io.awspring.cloud.sqs.listener.interceptor.MessageInterceptor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.SqsAsyncClientBuilder; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for SQS integration. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(SqsAsyncClient.class) +@EnableConfigurationProperties(SqsProperties.class) +@Import(SqsBootstrapConfiguration.class) +@AutoConfigureAfter({ CredentialsProviderAutoConfiguration.class, RegionProviderAutoConfiguration.class }) +@ConditionalOnProperty(name = "spring.cloud.aws.sqs.enabled", havingValue = "true", matchIfMissing = true) +public class SqsAutoConfiguration { + + private final SqsProperties sqsProperties; + + public SqsAutoConfiguration(SqsProperties sqsProperties) { + this.sqsProperties = sqsProperties; + } + + @ConditionalOnMissingBean + @Bean + public SqsAsyncClient sqsAsyncClient(AwsClientBuilderConfigurer awsClientBuilderConfigurer, + ObjectProvider> configurer) { + return awsClientBuilderConfigurer + .configure(SqsAsyncClient.builder(), this.sqsProperties, configurer.getIfAvailable()).build(); + } + + @ConditionalOnMissingBean + @Bean + public SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory( + ObjectProvider sqsAsyncClient, ObjectProvider> asyncErrorHandler, + ObjectProvider> errorHandler, + ObjectProvider> asyncInterceptors, + ObjectProvider> interceptors) { + + SqsMessageListenerContainerFactory factory = new SqsMessageListenerContainerFactory<>(); + factory.configure(this::configureContainerOptions); + sqsAsyncClient.ifAvailable(factory::setSqsAsyncClient); + asyncErrorHandler.ifAvailable(factory::setErrorHandler); + errorHandler.ifAvailable(factory::setErrorHandler); + interceptors.forEach(factory::addMessageInterceptor); + asyncInterceptors.forEach(factory::addMessageInterceptor); + return factory; + } + + private void configureContainerOptions(ContainerOptions.Builder options) { + PropertyMapper mapper = PropertyMapper.get().alwaysApplyingWhenNonNull(); + mapper.from(this.sqsProperties.getListener().getMaxInflightMessagesPerQueue()) + .to(options::maxInflightMessagesPerQueue); + mapper.from(this.sqsProperties.getListener().getMaxMessagesPerPoll()).to(options::maxMessagesPerPoll); + mapper.from(this.sqsProperties.getListener().getPollTimeout()).to(options::pollTimeout); + } + + @Bean + public SqsListenerConfigurer objectMapperCustomizer(ObjectProvider objectMapperProvider) { + ObjectMapper objectMapper = objectMapperProvider.getIfUnique(); + return registrar -> { + if (registrar.getObjectMapper() == null && objectMapper != null) { + registrar.setObjectMapper(objectMapper); + } + }; + } + +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sqs/SqsProperties.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sqs/SqsProperties.java new file mode 100644 index 000000000..406cb9898 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sqs/SqsProperties.java @@ -0,0 +1,94 @@ +/* + * 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.autoconfigure.sqs; + +import io.awspring.cloud.autoconfigure.AwsClientProperties; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.lang.Nullable; + +/** + * Properties related to AWS SQS. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +@ConfigurationProperties(prefix = SqsProperties.PREFIX) +public class SqsProperties extends AwsClientProperties { + + /** + * The prefix used for AWS SQS configuration. + */ + public static final String PREFIX = "spring.cloud.aws.sqs"; + + private Listener listener = new Listener(); + + public Listener getListener() { + return this.listener; + } + + public void setListener(Listener listener) { + this.listener = listener; + } + + public static class Listener { + /** + * The maximum number of simultaneous inflight messages in a queue. + */ + @Nullable + private Integer maxInflightMessagesPerQueue; + + /** + * The maximum number of messages to be retrieved in a single poll to SQS. + */ + @Nullable + private Integer maxMessagesPerPoll; + + /** + * The maximum amount of time for a poll to SQS. + */ + @Nullable + private Duration pollTimeout; + + @Nullable + public Integer getMaxInflightMessagesPerQueue() { + return this.maxInflightMessagesPerQueue; + } + + public void setMaxInflightMessagesPerQueue(Integer maxInflightMessagesPerQueue) { + this.maxInflightMessagesPerQueue = maxInflightMessagesPerQueue; + } + + @Nullable + public Integer getMaxMessagesPerPoll() { + return this.maxMessagesPerPoll; + } + + public void setMaxMessagesPerPoll(Integer maxMessagesPerPoll) { + this.maxMessagesPerPoll = maxMessagesPerPoll; + } + + @Nullable + public Duration getPollTimeout() { + return this.pollTimeout; + } + + public void setPollTimeout(Duration pollTimeout) { + this.pollTimeout = pollTimeout; + } + } + +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sqs/package-info.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sqs/package-info.java new file mode 100644 index 000000000..dba5a3506 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sqs/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/** + * Auto-configuration for Amazon SQS (Simple Queue Service) integrations. + */ +@org.springframework.lang.NonNullApi +@org.springframework.lang.NonNullFields +package io.awspring.cloud.autoconfigure.sqs; diff --git a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring.factories index d8cb47a45..8cc2c8970 100644 --- a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring.factories @@ -7,6 +7,7 @@ io.awspring.cloud.autoconfigure.ses.SesAutoConfiguration,\ io.awspring.cloud.autoconfigure.s3.S3TransferManagerAutoConfiguration,\ io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration,\ io.awspring.cloud.autoconfigure.sns.SnsAutoConfiguration,\ +io.awspring.cloud.autoconfigure.sqs.SqsAutoConfiguration,\ io.awspring.cloud.autoconfigure.dynamodb.DynamoDbAutoConfiguration # ConfigData Location Resolvers diff --git a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/sqs/SqsAutoConfigurationTest.java b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/sqs/SqsAutoConfigurationTest.java new file mode 100644 index 000000000..226b47a2f --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/sqs/SqsAutoConfigurationTest.java @@ -0,0 +1,199 @@ +/* + * 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.autoconfigure.sqs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.map; +import static org.assertj.core.api.InstanceOfAssertFactories.type; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.awspring.cloud.autoconfigure.ConfiguredAwsClient; +import io.awspring.cloud.autoconfigure.core.AwsAutoConfiguration; +import io.awspring.cloud.autoconfigure.core.AwsClientCustomizer; +import io.awspring.cloud.autoconfigure.core.CredentialsProviderAutoConfiguration; +import io.awspring.cloud.autoconfigure.core.RegionProviderAutoConfiguration; +import io.awspring.cloud.sqs.annotation.SqsListenerAnnotationBeanPostProcessor; +import io.awspring.cloud.sqs.config.EndpointRegistrar; +import io.awspring.cloud.sqs.config.SqsMessageListenerContainerFactory; +import io.awspring.cloud.sqs.listener.ContainerOptions; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import java.net.URI; +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.lang.Nullable; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.core.client.config.SdkClientOption; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; +import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.SqsAsyncClientBuilder; + +/** + * Tests for class {@link SqsAutoConfiguration}. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +class SqsAutoConfigurationTest { + + private static final String CUSTOM_OBJECT_MAPPER_BEAN_NAME = "customObjectMapper"; + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.cloud.aws.region.static:eu-west-1") + .withConfiguration(AutoConfigurations.of(RegionProviderAutoConfiguration.class, + CredentialsProviderAutoConfiguration.class, SqsAutoConfiguration.class, + AwsAutoConfiguration.class)); + + @Test + void sqsAutoConfigurationIsDisabled() { + this.contextRunner.withPropertyValues("spring.cloud.aws.sqs.enabled:false") + .run(context -> assertThat(context).doesNotHaveBean(SqsAsyncClient.class)); + } + + @Test + void sqsAutoConfigurationIsEnabled() { + this.contextRunner.withPropertyValues("spring.cloud.aws.sqs.enabled:true").run(context -> { + assertThat(context).hasSingleBean(SqsAsyncClient.class); + assertThat(context).hasSingleBean(SqsMessageListenerContainerFactory.class); + assertThat(context).hasBean(EndpointRegistrar.DEFAULT_LISTENER_CONTAINER_FACTORY_BEAN_NAME); + assertThat(context).hasBean("sqsAsyncClient"); + ConfiguredAwsClient client = new ConfiguredAwsClient(context.getBean(SqsAsyncClient.class)); + assertThat(client.getEndpoint()).isEqualTo(URI.create("https://sqs.eu-west-1.amazonaws.com")); + }); + } + + @Test + void withCustomEndpoint() { + this.contextRunner.withPropertyValues("spring.cloud.aws.sqs.endpoint:http://localhost:8090").run(context -> { + assertThat(context).hasSingleBean(SqsAsyncClient.class); + ConfiguredAwsClient client = new ConfiguredAwsClient(context.getBean(SqsAsyncClient.class)); + assertThat(client.getEndpoint()).isEqualTo(URI.create("http://localhost:8090")); + assertThat(client.isEndpointOverridden()).isTrue(); + }); + } + + // @formatter:off + @Test + void customSqsClientConfigurer() { + this.contextRunner.withUserConfiguration(CustomAwsAsyncClientConfig.class).run(context -> { + SqsAsyncClient sqsAsyncClient = context.getBean(SqsAsyncClient.class); + assertThat(sqsAsyncClient) + .extracting("clientConfiguration") + .extracting("attributes") + .extracting("attributes") + .asInstanceOf(map(Object.class, Object.class)) + .isInstanceOfSatisfying(Map.class, attributes -> { + assertThat(attributes.get(SdkClientOption.API_CALL_TIMEOUT)) + .isEqualTo(Duration.ofMillis(1999)); + assertThat(attributes.get(SdkClientOption.ASYNC_HTTP_CLIENT)) + .isNotNull(); + }); + }); + } + + @Test + void configuresFactoryComponentsAndOptions() { + this.contextRunner + .withPropertyValues("spring.cloud.aws.sqs.enabled:true", + "spring.cloud.aws.sqs.listener.max-inflight-messages-per-queue:19", + "spring.cloud.aws.sqs.listener.max-messages-per-poll:8", + "spring.cloud.aws.sqs.listener.poll-timeout:6s") + .withUserConfiguration(CustomComponentsConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(SqsMessageListenerContainerFactory.class); + SqsMessageListenerContainerFactory factory = context + .getBean(SqsMessageListenerContainerFactory.class); + assertThat(factory) + .hasFieldOrProperty("errorHandler") + .extracting("asyncMessageInterceptors").asList().isNotEmpty(); + assertThat(factory) + .extracting("containerOptionsBuilder") + .asInstanceOf(type(ContainerOptions.Builder.class)) + .extracting(ContainerOptions.Builder::build) + .isInstanceOfSatisfying(ContainerOptions.class, options -> { + assertThat(options.getMaxInFlightMessagesPerQueue()).isEqualTo(19); + assertThat(options.getMaxMessagesPerPoll()).isEqualTo(8); + assertThat(options.getPollTimeout()).isEqualTo(Duration.ofSeconds(6)); + }); + }); + } + // @formatter:on + + @Test + void configuresObjectMapper() { + this.contextRunner.withPropertyValues("spring.cloud.aws.sqs.enabled:true") + .withUserConfiguration(ObjectMapperConfiguration.class).run(context -> { + SqsListenerAnnotationBeanPostProcessor bpp = context + .getBean(SqsListenerAnnotationBeanPostProcessor.class); + ObjectMapper objectMapper = context.getBean(CUSTOM_OBJECT_MAPPER_BEAN_NAME, ObjectMapper.class); + assertThat(bpp).extracting("endpointRegistrar").asInstanceOf(type(EndpointRegistrar.class)) + .extracting(EndpointRegistrar::getObjectMapper).isEqualTo(objectMapper); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomComponentsConfiguration { + + @Bean + AsyncErrorHandler asyncErrorHandler() { + return new AsyncErrorHandler() { + }; + } + + @Bean + AsyncMessageInterceptor asyncMessageInterceptor() { + return new AsyncMessageInterceptor() { + }; + } + + } + + @Configuration(proxyBeanMethods = false) + static class ObjectMapperConfiguration { + + @Bean(name = CUSTOM_OBJECT_MAPPER_BEAN_NAME) + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomAwsAsyncClientConfig { + + @Bean + AwsClientCustomizer sqsClientBuilderAwsClientConfigurer() { + return new AwsClientCustomizer() { + @Override + @Nullable + public ClientOverrideConfiguration overrideConfiguration() { + return ClientOverrideConfiguration.builder().apiCallTimeout(Duration.ofMillis(1999)).build(); + } + + @Override + @Nullable + public SdkAsyncHttpClient asyncHttpClient() { + return NettyNioAsyncHttpClient.builder().connectionTimeout(Duration.ofMillis(1542)).build(); + } + }; + } + } + +} diff --git a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/sqs/it/SqsAutoConfigurationIntegrationTest.java b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/sqs/it/SqsAutoConfigurationIntegrationTest.java new file mode 100644 index 000000000..06196bb79 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/sqs/it/SqsAutoConfigurationIntegrationTest.java @@ -0,0 +1,105 @@ +/* + * 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.autoconfigure.sqs.it; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.testcontainers.containers.localstack.LocalStackContainer.Service.SQS; + +import io.awspring.cloud.autoconfigure.core.AwsAutoConfiguration; +import io.awspring.cloud.autoconfigure.core.CredentialsProviderAutoConfiguration; +import io.awspring.cloud.autoconfigure.core.RegionProviderAutoConfiguration; +import io.awspring.cloud.autoconfigure.sqs.SqsAutoConfiguration; +import io.awspring.cloud.sqs.annotation.SqsListener; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; + +/** + * Integration tests for SQS. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +@SpringBootTest +@Testcontainers +class SqsAutoConfigurationIntegrationTest { + + private static final String QUEUE_NAME = "my_queue_name"; + + private static final String PAYLOAD = "Test"; + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.cloud.aws.sqs.region=eu-west-1", + "spring.cloud.aws.sqs.endpoint=" + localstack.getEndpointOverride(SQS).toString(), + "spring.cloud.aws.credentials.access-key=noop", "spring.cloud.aws.credentials.secret-key=noop", + "spring.cloud.aws.region.static=eu-west-1") + .withConfiguration(AutoConfigurations.of(RegionProviderAutoConfiguration.class, + CredentialsProviderAutoConfiguration.class, SqsAutoConfiguration.class, AwsAutoConfiguration.class, + ListenerConfiguration.class)); + + @Container + static LocalStackContainer localstack = new LocalStackContainer( + DockerImageName.parse("localstack/localstack:1.0.3")).withServices(SQS); + + @Test + void sendsAndReceivesMessage() { + this.contextRunner.run(context -> { + SqsAsyncClient sqsAsyncClient = context.getBean(SqsAsyncClient.class); + String queueUrl = sqsAsyncClient.getQueueUrl(req -> req.queueName(QUEUE_NAME)).get().queueUrl(); + sqsAsyncClient.sendMessage(req -> req.queueUrl(queueUrl).messageBody(PAYLOAD)); + CountDownLatch latch = context.getBean(CountDownLatch.class); + assertThat(latch.await(30, TimeUnit.SECONDS)).isTrue(); + }); + } + + static class Listener { + + @Autowired + CountDownLatch messageLatch; + + @SqsListener(QUEUE_NAME) + void listen(String message) { + assertThat(message).isEqualTo(PAYLOAD); + messageLatch.countDown(); + } + } + + @Configuration + static class ListenerConfiguration { + + @Bean + CountDownLatch messageLatch() { + return new CountDownLatch(1); + } + + @Bean + Listener listener() { + return new Listener(); + } + } + +} diff --git a/spring-cloud-aws-dependencies/pom.xml b/spring-cloud-aws-dependencies/pom.xml index 55e6dc1f9..f2548835f 100644 --- a/spring-cloud-aws-dependencies/pom.xml +++ b/spring-cloud-aws-dependencies/pom.xml @@ -99,6 +99,12 @@ ${project.version} + + io.awspring.cloud + spring-cloud-aws-sqs + ${project.version} + + io.awspring.cloud spring-cloud-aws-ses diff --git a/spring-cloud-aws-sqs/pom.xml b/spring-cloud-aws-sqs/pom.xml new file mode 100644 index 000000000..75c0fe786 --- /dev/null +++ b/spring-cloud-aws-sqs/pom.xml @@ -0,0 +1,64 @@ + + + + spring-cloud-aws + io.awspring.cloud + 3.0.0-SNAPSHOT + + 4.0.0 + + spring-cloud-aws-sqs + Spring Cloud AWS SQS + Spring Cloud AWS Simple Queue Service + + + + software.amazon.awssdk + sqs + + + software.amazon.awssdk + arns + + + org.springframework + spring-messaging + + + org.springframework + spring-context + + + com.fasterxml.jackson.core + jackson-databind + + + io.awspring.cloud + spring-cloud-aws-core + + + org.springframework + spring-test + test + + + org.testcontainers + localstack + test + + + org.testcontainers + junit-jupiter + test + + + + com.amazonaws + aws-java-sdk-core + test + + + + diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/CompletableFutures.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/CompletableFutures.java new file mode 100644 index 000000000..f0fd25736 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/CompletableFutures.java @@ -0,0 +1,76 @@ +/* + * 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; + +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; +import java.util.function.Function; + +/** + * Utility methods for using convenient {@link CompletableFuture} methods from later JDK versions in Java 8. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public class CompletableFutures { + + private CompletableFutures() { + } + + /** + * Create an exceptionally completed {@link CompletableFuture}. + * @param t the throwable. + * @param the future type. + * @return the completable future instance. + */ + public static CompletableFuture failedFuture(Throwable t) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(t); + return future; + } + + /** + * Compose the provided future with a function that returns another completable future that is executed + * exceptionally. + * @param future the future to compose with. + * @param composingFunction the function for handling the exception. + * @param the future type. + * @return the completable future. + */ + // @formatter:off + public static CompletableFuture exceptionallyCompose(CompletableFuture future, + Function> composingFunction) { + return future.thenApply(CompletableFuture::completedFuture) + .exceptionally(composingFunction) + .thenCompose(Function.identity()); + } + // @formatter:on + + /** + * Compose the provided future with a function to handle the result, taking a value, a throwable and providing a + * completable future as a result. + * @param future the future to compose with. + * @param composingFunction the composing function. + * @param the future type. + * @param the result type of the composing function. + * @return the completable future. + */ + public static CompletableFuture handleCompose(CompletableFuture future, + BiFunction> composingFunction) { + return future.handle(composingFunction).thenCompose(Function.identity()); + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/ConfigUtils.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/ConfigUtils.java new file mode 100644 index 000000000..da873f0c8 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/ConfigUtils.java @@ -0,0 +1,93 @@ +/* + * 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; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Objects; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import org.springframework.util.CollectionUtils; + +/** + * Utility class for conditional configurations. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public class ConfigUtils { + + public static final ConfigUtils INSTANCE = new ConfigUtils(); + + private ConfigUtils() { + } + + public ConfigUtils acceptIfNotNull(T value, Consumer consumer) { + if (value != null) { + consumer.accept(value); + } + return this; + } + + public ConfigUtils acceptIfNotNullOrElse(Consumer consumer, T value, T fallback) { + if (value != null) { + consumer.accept(value); + } + else { + consumer.accept(fallback); + } + return this; + } + + public ConfigUtils acceptBothIfNoneNull(T firstValue, V secondValue, BiConsumer consumer) { + if (firstValue != null && secondValue != null) { + consumer.accept(firstValue, secondValue); + } + return this; + } + + @SuppressWarnings("varargs") + @SafeVarargs + public final ConfigUtils acceptFirstNonNull(Consumer consumer, T... values) { + Arrays.stream(values).filter(Objects::nonNull).findFirst().ifPresent(consumer); + return this; + } + + public ConfigUtils acceptIfNotEmpty(Collection value, Consumer> consumer) { + if (!CollectionUtils.isEmpty(value)) { + consumer.accept(value); + } + return this; + } + + public ConfigUtils acceptIfInstance(Object value, Class clazz, Consumer consumer) { + if (value != null && clazz.isAssignableFrom(value.getClass())) { + consumer.accept(clazz.cast(value)); + } + return this; + } + + public ConfigUtils acceptManyIfInstance(Collection values, Class clazz, Consumer consumer) { + values.forEach(value -> acceptIfInstance(value, clazz, consumer)); + return this; + } + + public ConfigUtils acceptMany(Collection values, Consumer consumer) { + values.forEach(consumer); + return this; + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/LifecycleHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/LifecycleHandler.java new file mode 100644 index 000000000..2c9903ebc --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/LifecycleHandler.java @@ -0,0 +1,134 @@ +/* + * 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; + +import java.util.Arrays; +import java.util.Collection; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.context.SmartLifecycle; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; + +/** + * Handler for lifecycle methods. The singleton instance should be retrieved by using the {@link LifecycleHandler#get()} + * method. This class is thread-safe. + * + * Methods accept {@link Object} instances and apply lifecycle methods then the {@link SmartLifecycle} interface is + * implemented. Can handle lifecycle actions either sequentially or in parallel, according to the + * {@link #parallelLifecycle} property. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public class LifecycleHandler { + + private static final LifecycleHandler INSTANCE = new LifecycleHandler(); + + /** + * Get the singleton instance for this class. + * @return the instance. + */ + public static LifecycleHandler get() { + return INSTANCE; + } + + private final TaskExecutor taskExecutor; + + private boolean parallelLifecycle = true; + + private LifecycleHandler() { + SimpleAsyncTaskExecutor sate = new SimpleAsyncTaskExecutor(); + sate.setThreadNamePrefix("lifecycle-thread-"); + this.taskExecutor = sate; + } + + /** + * Set whether lifecycle management should be handled in parallel or sequentially. + * @param parallelLifecycle false to disable parallel lifecycle management. + */ + public void setParallelLifecycle(boolean parallelLifecycle) { + this.parallelLifecycle = parallelLifecycle; + } + + /** + * Execute the provided action if the provided objects are {@link SmartLifecycle} instances. + * @param action the action. + * @param objects the objects. + */ + public void manageLifecycle(Consumer action, Object... objects) { + Arrays.stream(objects).forEach(object -> { + if (object instanceof SmartLifecycle) { + action.accept((SmartLifecycle) object); + } + else if (object instanceof Collection) { + if (this.parallelLifecycle) { + CompletableFuture.allOf(((Collection) object).stream().map( + obj -> CompletableFuture.runAsync(() -> manageLifecycle(action, obj), this.taskExecutor)) + .toArray(CompletableFuture[]::new)).join(); + } + else { + ((Collection) object).forEach(obj -> manageLifecycle(action, obj)); + } + } + }); + } + + /** + * Starts the provided objects that are a {@link SmartLifecycle} instance. + * @param objects the objects. + */ + public void start(Object... objects) { + manageLifecycle(SmartLifecycle::start, objects); + } + + /** + * Starts the provided objects that are a {@link SmartLifecycle} instance. + * @param objects the objects. + */ + public void stop(Object... objects) { + manageLifecycle(SmartLifecycle::stop, objects); + } + + /** + * Check whether a object is running if it's an instance of {@link SmartLifecycle}. + * @param object the object to check. + * @return whether the object is running, or true if it's not a {@link SmartLifecycle} instance. + */ + public boolean isRunning(Object object) { + if (object instanceof SmartLifecycle) { + return ((SmartLifecycle) object).isRunning(); + } + return true; + } + + /** + * Execute the {@link DisposableBean#destroy()} method if the provided object is a {@link DisposableBean} instance. + * @param destroyable the object to destroy. + */ + public void dispose(Object destroyable) { + if (destroyable instanceof DisposableBean) { + try { + ((DisposableBean) destroyable).destroy(); + } + catch (Exception e) { + throw new IllegalStateException("Error destroying disposable " + destroyable); + } + } + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/MessageExecutionThread.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/MessageExecutionThread.java new file mode 100644 index 000000000..0a78eeb5e --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/MessageExecutionThread.java @@ -0,0 +1,43 @@ +/* + * 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; + +/** + * A {@link Thread} implementation for processing messages. + * @author Tomaz Fernandes + * @since 3.0 + * @see MessageExecutionThreadFactory + * @see io.awspring.cloud.sqs.listener.AsyncComponentAdapters + */ +public class MessageExecutionThread extends Thread { + + /** + * Create an instance with the provided arguments. See {@link Thread} javadoc. + * @param threadGroup see {@link Thread} javadoc. + * @param runnable see {@link Thread} javadoc. + * @param nextThreadName see {@link Thread} javadoc. + */ + public MessageExecutionThread(ThreadGroup threadGroup, Runnable runnable, String nextThreadName) { + super(threadGroup, runnable, nextThreadName); + } + + /** + * Create an instance. See {@link Thread} javadoc. + */ + public MessageExecutionThread() { + super(); + } +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/MessageExecutionThreadFactory.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/MessageExecutionThreadFactory.java new file mode 100644 index 000000000..ab9aee1a4 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/MessageExecutionThreadFactory.java @@ -0,0 +1,40 @@ +/* + * 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; + +import io.awspring.cloud.sqs.listener.ContainerOptions; +import org.springframework.scheduling.concurrent.CustomizableThreadFactory; + +/** + * {@link CustomizableThreadFactory} implementation for creating {@link MessageExecutionThread} instances. This should + * be set to {@link org.springframework.core.task.TaskExecutor} instances provided by + * {@link ContainerOptions#getComponentsTaskExecutor()} to avoid excessive thread hopping for blocking components. + * @author Tomaz Fernandes + * @since 3.0 + * @see MessageExecutionThread + * @see io.awspring.cloud.sqs.listener.AsyncComponentAdapters + */ +public class MessageExecutionThreadFactory extends CustomizableThreadFactory { + + @Override + public Thread createThread(Runnable runnable) { + MessageExecutionThread thread = new MessageExecutionThread(getThreadGroup(), runnable, nextThreadName()); + thread.setDaemon(false); + thread.setPriority(Thread.NORM_PRIORITY); + return thread; + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/MessageHeaderUtils.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/MessageHeaderUtils.java new file mode 100644 index 000000000..4bc09e113 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/MessageHeaderUtils.java @@ -0,0 +1,91 @@ +/* + * 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; + +import java.util.Collection; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Collectors; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +/** + * Utility class for extracting {@link MessageHeaders} from a {@link Message}. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public class MessageHeaderUtils { + + private MessageHeaderUtils() { + } + + /** + * Return the message's ID as {@link String]. + * @param message the message. + * @return the ID. + */ + public static String getId(Message message) { + return getHeader(message, MessageHeaders.ID, UUID.class).toString(); + } + + /** + * Return the messages' ID as a concatenated {@link String]. + * @param messages the messages. + * @return the IDs. + */ + public static String getId(Collection> messages) { + return messages.stream().map(MessageHeaderUtils::getId).collect(Collectors.joining("; ")); + } + + /** + * Get the specified header or throw an exception if such header is not present. + * @param message the message. + * @param headerName the header name. + * @param classToCast the class to which the header should be cast to. + * @param the class type. + * @return the header value. + */ + public static T getHeader(Message message, String headerName, Class classToCast) { + return Objects.requireNonNull(message.getHeaders().get(headerName, classToCast), + () -> String.format("Header %s not found in message %s", headerName, message)); + } + + /** + * Get the specified header or throw an exception if such header is not present. + * @param messages the messages. + * @param headerName the header name. + * @param classToCast the class to which the header should be cast to. + * @param the header class type. + * @param the messages payload class type. + * @return the header value. + */ + public static Collection getHeader(Collection> messages, String headerName, + Class classToCast) { + return messages.stream().map(msg -> getHeader(msg, headerName, classToCast)).collect(Collectors.toList()); + } + + /** + * Get the provided header as {@link String} or throw if not present. + * @param message the message. + * @param headerName the header name. + * @return the header value. + */ + public static String getHeaderAsString(Message message, String headerName) { + return getHeader(message, headerName, String.class); + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/QueueAttributesResolvingException.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/QueueAttributesResolvingException.java new file mode 100644 index 000000000..1e8286ea8 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/QueueAttributesResolvingException.java @@ -0,0 +1,35 @@ +/* + * 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; + +/** + * Exception thrown when a {@link io.awspring.cloud.sqs.listener.QueueAttributesResolver} fails. + * + * @author Tomaz Fernandes + * @since 3.0 + * @see io.awspring.cloud.sqs.listener.QueueNotFoundStrategy + */ +public class QueueAttributesResolvingException extends RuntimeException { + + /** + * Create an instance with the message and throwable cause. + * @param message the error message. + * @param cause the cause. + */ + public QueueAttributesResolvingException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/SqsException.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/SqsException.java new file mode 100644 index 000000000..3730e4983 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/SqsException.java @@ -0,0 +1,37 @@ +/* + * 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; + +import org.springframework.core.NestedRuntimeException; + +/** + * Top-level exception for Sqs {@link RuntimeException} instances. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public class SqsException extends NestedRuntimeException { + + /** + * Construct an instance with the supplied message and cause. + * @param msg the message. + * @param cause the cause. + */ + public SqsException(String msg, Throwable cause) { + super(msg, cause); + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/annotation/AbstractListenerAnnotationBeanPostProcessor.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/annotation/AbstractListenerAnnotationBeanPostProcessor.java new file mode 100644 index 000000000..9a1cd8ed0 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/annotation/AbstractListenerAnnotationBeanPostProcessor.java @@ -0,0 +1,265 @@ +/* + * 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.annotation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.awspring.cloud.sqs.ConfigUtils; +import io.awspring.cloud.sqs.config.Endpoint; +import io.awspring.cloud.sqs.config.EndpointRegistrar; +import io.awspring.cloud.sqs.config.HandlerMethodEndpoint; +import io.awspring.cloud.sqs.config.SqsEndpoint; +import io.awspring.cloud.sqs.config.SqsListenerConfigurer; +import io.awspring.cloud.sqs.support.resolver.AcknowledgmentHandlerMethodArgumentResolver; +import io.awspring.cloud.sqs.support.resolver.BatchAcknowledgmentArgumentResolver; +import io.awspring.cloud.sqs.support.resolver.BatchPayloadMethodArgumentResolver; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.EmbeddedValueResolverAware; +import org.springframework.core.MethodIntrospector; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.lang.Nullable; +import org.springframework.messaging.converter.CompositeMessageConverter; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.converter.SimpleMessageConverter; +import org.springframework.messaging.converter.StringMessageConverter; +import org.springframework.messaging.handler.annotation.support.DefaultMessageHandlerMethodFactory; +import org.springframework.messaging.handler.annotation.support.HeaderMethodArgumentResolver; +import org.springframework.messaging.handler.annotation.support.HeadersMethodArgumentResolver; +import org.springframework.messaging.handler.annotation.support.MessageHandlerMethodFactory; +import org.springframework.messaging.handler.annotation.support.MessageMethodArgumentResolver; +import org.springframework.messaging.handler.annotation.support.PayloadMethodArgumentResolver; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.util.StringValueResolver; + +/** + * {@link BeanPostProcessor} implementation that scans beans for a {@link SqsListener @SqsListener} annotation, extracts + * information to a {@link SqsEndpoint}, and registers it in the {@link EndpointRegistrar}. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public abstract class AbstractListenerAnnotationBeanPostProcessor + implements BeanPostProcessor, BeanFactoryAware, SmartInitializingSingleton, EmbeddedValueResolverAware { + + private final AtomicInteger counter = new AtomicInteger(); + + private final Collection> nonAnnotatedClasses = Collections.synchronizedSet(new HashSet<>()); + + private final EndpointRegistrar endpointRegistrar = createEndpointRegistrar(); + + private final DelegatingMessageHandlerMethodFactory delegatingHandlerMethodFactory = new DelegatingMessageHandlerMethodFactory(); + + private BeanFactory beanFactory; + + private StringValueResolver resolver; + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + Class targetClass = AopUtils.getTargetClass(bean); + if (this.nonAnnotatedClasses.contains(targetClass)) { + return bean; + } + detectAnnotationsAndRegisterEndpoints(bean, targetClass); + return bean; + } + + @Nullable + protected ConfigurableBeanFactory getConfigurableBeanFactory() { + return this.beanFactory instanceof ConfigurableBeanFactory ? (ConfigurableBeanFactory) this.beanFactory : null; + } + + protected BeanFactory getBeanFactory() { + return this.beanFactory; + } + + protected void detectAnnotationsAndRegisterEndpoints(Object bean, Class targetClass) { + Map annotatedMethods = MethodIntrospector.selectMethods(targetClass, + (MethodIntrospector.MetadataLookup) method -> AnnotatedElementUtils.findMergedAnnotation(method, + getAnnotationClass())); + if (annotatedMethods.isEmpty()) { + this.nonAnnotatedClasses.add(targetClass); + } + annotatedMethods.entrySet().stream() + .map(entry -> createAndConfigureEndpoint(bean, entry.getKey(), entry.getValue())) + .forEach(this.endpointRegistrar::registerEndpoint); + } + + protected abstract Class getAnnotationClass(); + + private Endpoint createAndConfigureEndpoint(Object bean, Method method, A annotation) { + Endpoint endpoint = createEndpoint(annotation); + ConfigUtils.INSTANCE.acceptIfInstance(endpoint, HandlerMethodEndpoint.class, hme -> { + hme.setBean(bean); + hme.setMethod(method); + hme.setHandlerMethodFactory(this.delegatingHandlerMethodFactory); + }); + return endpoint; + } + + protected abstract Endpoint createEndpoint(A sqsListenerAnnotation); + + protected String resolveAsString(String value, String propertyName) { + try { + return getValueResolver().resolveStringValue(value); + } + catch (Exception e) { + throw new IllegalArgumentException("Error resolving property " + propertyName, e); + } + } + + protected StringValueResolver getValueResolver() { + return this.resolver; + } + + protected Integer resolveAsInteger(String value, String propertyName) { + String resolvedValue = resolveAsString(value, propertyName); + return StringUtils.hasText(resolvedValue) ? Integer.parseInt(resolvedValue) : null; + } + + protected Set resolveStringArray(String[] destinationNames, String propertyName) { + return Arrays.stream(destinationNames).map(destinationName -> resolveAsString(destinationName, propertyName)) + .collect(Collectors.toSet()); + } + + protected String getEndpointId(String id) { + if (StringUtils.hasText(id)) { + return resolveAsString(id, "id"); + } + else { + return getGeneratedIdPrefix() + this.counter.getAndIncrement(); + } + } + + protected abstract String getGeneratedIdPrefix(); + + @Override + public void afterSingletonsInstantiated() { + if (this.beanFactory instanceof ListableBeanFactory) { + ((ListableBeanFactory) this.beanFactory).getBeansOfType(SqsListenerConfigurer.class).values() + .forEach(customizer -> customizer.configure(this.endpointRegistrar)); + } + this.endpointRegistrar.setBeanFactory(getBeanFactory()); + initializeHandlerMethodFactory(); + this.endpointRegistrar.afterSingletonsInstantiated(); + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + } + + protected void initializeHandlerMethodFactory() { + MessageHandlerMethodFactory handlerMethodFactory = this.endpointRegistrar.getMessageHandlerMethodFactory(); + ConfigUtils.INSTANCE.acceptIfInstance(handlerMethodFactory, DefaultMessageHandlerMethodFactory.class, + this::configureDefaultHandlerMethodFactory); + this.delegatingHandlerMethodFactory.setDelegate(handlerMethodFactory); + } + + protected void configureDefaultHandlerMethodFactory(DefaultMessageHandlerMethodFactory handlerMethodFactory) { + CompositeMessageConverter compositeMessageConverter = createCompositeMessageConverter(); + + List methodArgumentResolvers = new ArrayList<>( + createAdditionalArgumentResolvers()); + methodArgumentResolvers.addAll(createArgumentResolvers(compositeMessageConverter)); + this.endpointRegistrar.getMethodArgumentResolversConsumer().accept(methodArgumentResolvers); + handlerMethodFactory.setArgumentResolvers(methodArgumentResolvers); + handlerMethodFactory.afterPropertiesSet(); + } + + protected Collection createAdditionalArgumentResolvers() { + return Collections.emptyList(); + } + + protected CompositeMessageConverter createCompositeMessageConverter() { + List messageConverters = new ArrayList<>(); + messageConverters.add(new StringMessageConverter()); + messageConverters.add(new SimpleMessageConverter()); + messageConverters.add(createDefaultMappingJackson2MessageConverter(this.endpointRegistrar.getObjectMapper())); + this.endpointRegistrar.getMessageConverterConsumer().accept(messageConverters); + return new CompositeMessageConverter(messageConverters); + } + + // @formatter:off + protected List createArgumentResolvers(MessageConverter messageConverter) { + return Arrays.asList( + new AcknowledgmentHandlerMethodArgumentResolver(), + new BatchAcknowledgmentArgumentResolver(), + new HeaderMethodArgumentResolver(new DefaultConversionService(), getConfigurableBeanFactory()), + new HeadersMethodArgumentResolver(), + new BatchPayloadMethodArgumentResolver(messageConverter), + new MessageMethodArgumentResolver(messageConverter), + new PayloadMethodArgumentResolver(messageConverter)); + } + // @formatter:on + + protected MappingJackson2MessageConverter createDefaultMappingJackson2MessageConverter(ObjectMapper objectMapper) { + MappingJackson2MessageConverter jacksonMessageConverter = new MappingJackson2MessageConverter(); + jacksonMessageConverter.setSerializedPayloadClass(String.class); + jacksonMessageConverter.setStrictContentTypeMatch(false); + if (objectMapper != null) { + jacksonMessageConverter.setObjectMapper(objectMapper); + } + return jacksonMessageConverter; + } + + protected EndpointRegistrar createEndpointRegistrar() { + return new EndpointRegistrar(); + } + + @Override + public void setEmbeddedValueResolver(StringValueResolver resolver) { + this.resolver = resolver; + } + + private static class DelegatingMessageHandlerMethodFactory implements MessageHandlerMethodFactory { + + private MessageHandlerMethodFactory delegate; + + @Override + public InvocableHandlerMethod createInvocableHandlerMethod(Object bean, Method method) { + Assert.notNull(this.delegate, "No delegate MessageHandlerMethodFactory set."); + return this.delegate.createInvocableHandlerMethod(bean, method); + } + + public void setDelegate(MessageHandlerMethodFactory delegate) { + this.delegate = delegate; + } + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/annotation/SqsListener.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/annotation/SqsListener.java new file mode 100644 index 000000000..b9edac3e1 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/annotation/SqsListener.java @@ -0,0 +1,140 @@ +/* + * 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.annotation; + +import io.awspring.cloud.sqs.config.SqsListenerConfigurer; +import io.awspring.cloud.sqs.config.SqsMessageListenerContainerFactory; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.core.annotation.AliasFor; + +/** + * Methods with this annotation will be wrapped by a {@link io.awspring.cloud.sqs.listener.MessageListener} or + * {@link io.awspring.cloud.sqs.listener.AsyncMessageListener} and set to a + * {@link io.awspring.cloud.sqs.listener.MessageListenerContainer}. + *

+ * Each method will be handled by a different container instance, created by the specified {@link #factory()} property. + * If not specified, a default factory will be looked up in the context. + *

+ * When used in conjunction with Spring Boot and auto configuration, the framework supplies a default + * {@link io.awspring.cloud.sqs.config.SqsMessageListenerContainerFactory} and a + * {@link software.amazon.awssdk.services.sqs.SqsAsyncClient}, unless such beans are already found in the + * {@link org.springframework.context.ApplicationContext}. + *

+ * For more complex configurations, {@link io.awspring.cloud.sqs.config.SqsMessageListenerContainerFactory} instances + * can be created and configured. See {@link SqsMessageListenerContainerFactory#builder()} for more information on + * creating and configuring a factory. + *

+ * Further configuration for containers created using this annotation can be achieved by declaring + * {@link SqsListenerConfigurer} beans. + *

+ * Methods with this annotation can have flexible signatures, including arguments of the following types: + *

    + *
  • {@link org.springframework.messaging.handler.annotation.Header}
  • + *
  • {@link org.springframework.messaging.handler.annotation.Headers}
  • + *
  • {@link org.springframework.messaging.Message}
  • + *
  • {@link io.awspring.cloud.sqs.listener.Visibility}
  • + *
  • {@link io.awspring.cloud.sqs.listener.QueueAttributes}
  • + *
  • {@link software.amazon.awssdk.services.sqs.model.Message}
  • + *
  • {@link io.awspring.cloud.sqs.listener.acknowledgement.Acknowledgement}
  • + *
  • {@link io.awspring.cloud.sqs.listener.acknowledgement.AsyncAcknowledgement}
  • + *
+ * Method signatures also accept {@link java.util.List}<Pojo> and + * {@link java.util.List}{@link org.springframework.messaging.Message}<Pojo> arguments . Such arguments will + * configure the container to batch mode. When using List arguments, no other arguments can be provided. Metadata can be + * retrieved by inspecting the {@link org.springframework.messaging.Message} instances' + * {@link org.springframework.messaging.MessageHeaders}. + *

+ * To support {@link io.awspring.cloud.sqs.listener.acknowledgement.Acknowledgement} and + * {@link io.awspring.cloud.sqs.listener.acknowledgement.AsyncAcknowledgement} arguments, the factory used to create the + * containers must be set to {@link io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementMode#MANUAL}. + *

+ * Properties in this annotation support property placeholders ("${...}") and SpEL ("#{...}"). + * + * @see SqsMessageListenerContainerFactory + * @see io.awspring.cloud.sqs.listener.SqsMessageListenerContainer + * @see SqsListenerConfigurer + * + * @author Alain Sahli + * @author Matej Nedic + * @author Tomaz Fernandes + * @since 1.1 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface SqsListener { + + /** + * Array of queue names or urls. Queues declared in the same annotation will be handled by the same + * {@link io.awspring.cloud.sqs.listener.MessageListenerContainer}. + * @return list of queue names or urls. + */ + String[] value() default {}; + + /** + * Alias for {@link #value()} + * @return list of queue names or urls. + */ + @AliasFor("value") + String[] queueNames() default {}; + + /** + * The {@link io.awspring.cloud.sqs.config.MessageListenerContainerFactory} bean name to be used to process this + * endpoint. + * @return the factory bean name. + */ + String factory() default ""; + + /** + * An id for the {@link io.awspring.cloud.sqs.listener.MessageListenerContainer} that will be created to handle this + * endpoint. If none provided a default ID will be created. + * @return the container id. + */ + String id() default ""; + + /** + * The maximum number of inflight messages that should be processed simultaneously for each queue declared in this + * annotation. + * @return the maximum number of inflight messages. + */ + String maxInflightMessagesPerQueue() default ""; + + /** + * The maximum number of seconds to wait for messages in a poll to SQS. + * @return the poll timeout. + */ + String pollTimeoutSeconds() default ""; + + /** + * The maximum amount of time to wait for a poll to SQS. If a value greater than 10 is provided, the result of + * multiple polls will be combined, which can be useful for + * {@link io.awspring.cloud.sqs.listener.ListenerMode#BATCH} + * @return the maximum messages per poll. + */ + String maxMessagesPerPoll() default ""; + + /** + * The message visibility to be applied to messages received from the provided queues. For Standard SQS queues and + * batch listeners, visibility will be applied at polling. For single message FIFO queues, visibility is changed + * before each remaining message from the same message group is processed. + */ + String messageVisibilitySeconds() default ""; + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/annotation/SqsListenerAnnotationBeanPostProcessor.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/annotation/SqsListenerAnnotationBeanPostProcessor.java new file mode 100644 index 000000000..badfd646b --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/annotation/SqsListenerAnnotationBeanPostProcessor.java @@ -0,0 +1,70 @@ +/* + * 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.annotation; + +import io.awspring.cloud.sqs.config.Endpoint; +import io.awspring.cloud.sqs.config.EndpointRegistrar; +import io.awspring.cloud.sqs.config.SqsEndpoint; +import io.awspring.cloud.sqs.listener.SqsHeaders; +import io.awspring.cloud.sqs.support.resolver.QueueAttributesMethodArgumentResolver; +import io.awspring.cloud.sqs.support.resolver.SqsMessageMethodArgumentResolver; +import io.awspring.cloud.sqs.support.resolver.VisibilityHandlerMethodArgumentResolver; +import java.util.Arrays; +import java.util.Collection; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; + +/** + * {@link BeanPostProcessor} implementation that scans beans for a {@link SqsListener @SqsListener} annotation, extracts + * information to a {@link SqsEndpoint}, and registers it in the {@link EndpointRegistrar}. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public class SqsListenerAnnotationBeanPostProcessor extends AbstractListenerAnnotationBeanPostProcessor { + + private static final String GENERATED_ID_PREFIX = "io.awspring.cloud.sqs.sqsListenerEndpointContainer#"; + + @Override + protected Class getAnnotationClass() { + return SqsListener.class; + } + + protected Endpoint createEndpoint(SqsListener sqsListenerAnnotation) { + return SqsEndpoint.builder().queueNames(resolveStringArray(sqsListenerAnnotation.value(), "queueNames")) + .factoryBeanName(resolveAsString(sqsListenerAnnotation.factory(), "factory")) + .id(getEndpointId(sqsListenerAnnotation.id())) + .pollTimeoutSeconds(resolveAsInteger(sqsListenerAnnotation.pollTimeoutSeconds(), "pollTimeoutSeconds")) + .maxMessagesPerPoll(resolveAsInteger(sqsListenerAnnotation.maxMessagesPerPoll(), "maxMessagesPerPoll")) + .maxInflightMessagesPerQueue(resolveAsInteger(sqsListenerAnnotation.maxInflightMessagesPerQueue(), + "maxInflightMessagesPerQueue")) + .messageVisibility( + resolveAsInteger(sqsListenerAnnotation.messageVisibilitySeconds(), "messageVisibility")) + .build(); + } + + @Override + protected String getGeneratedIdPrefix() { + return GENERATED_ID_PREFIX; + } + + @Override + protected Collection createAdditionalArgumentResolvers() { + return Arrays.asList(new VisibilityHandlerMethodArgumentResolver(SqsHeaders.SQS_VISIBILITY_HEADER), + new SqsMessageMethodArgumentResolver(), new QueueAttributesMethodArgumentResolver()); + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/annotation/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/annotation/package-info.java new file mode 100644 index 000000000..aabc0e17e --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/annotation/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/** + * Auto-configuration for Amazon SQS (Simple Queue Service) integrations. + */ +@org.springframework.lang.NonNullApi +@org.springframework.lang.NonNullFields +package io.awspring.cloud.sqs.annotation; diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/AbstractEndpoint.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/AbstractEndpoint.java new file mode 100644 index 000000000..dbb273c75 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/AbstractEndpoint.java @@ -0,0 +1,173 @@ +/* + * 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.config; + +import io.awspring.cloud.sqs.listener.AsyncMessageListener; +import io.awspring.cloud.sqs.listener.ListenerMode; +import io.awspring.cloud.sqs.listener.MessageListener; +import io.awspring.cloud.sqs.listener.MessageListenerContainer; +import io.awspring.cloud.sqs.listener.acknowledgement.BatchAcknowledgement; +import io.awspring.cloud.sqs.listener.adapter.AsyncMessagingMessageListenerAdapter; +import io.awspring.cloud.sqs.listener.adapter.MessagingMessageListenerAdapter; +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CompletionStage; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.springframework.core.BridgeMethodResolver; +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.handler.annotation.support.MessageHandlerMethodFactory; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; +import org.springframework.util.Assert; + +/** + * Base class for implementing a {@link HandlerMethodEndpoint}. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public abstract class AbstractEndpoint implements HandlerMethodEndpoint { + + private final Collection logicalNames; + + private final String listenerContainerFactoryName; + + private final String id; + + private Object bean; + + private Method method; + + private MessageHandlerMethodFactory handlerMethodFactory; + + protected AbstractEndpoint(Collection queueNames, @Nullable String listenerContainerFactoryName, + String id) { + Assert.notEmpty(queueNames, "queueNames cannot be empty."); + this.id = id; + this.logicalNames = queueNames; + this.listenerContainerFactoryName = listenerContainerFactoryName; + } + + @Override + public Collection getLogicalNames() { + return this.logicalNames; + } + + @Override + public String getListenerContainerFactoryName() { + return this.listenerContainerFactoryName; + } + + @Override + public String getId() { + return this.id; + } + + /** + * Set the bean instance to be used when handling a message for this endpoint. + * @param bean the bean instance. + */ + @Override + public void setBean(Object bean) { + this.bean = bean; + } + + /** + * Set the method to be used when handling a message for this endpoint. + * @param method the method. + */ + @Override + public void setMethod(Method method) { + this.method = method; + } + + /** + * Set the {@link MessageHandlerMethodFactory} to be used for handling messages in this endpoint. + * @param handlerMethodFactory the factory. + */ + @Override + public void setHandlerMethodFactory(MessageHandlerMethodFactory handlerMethodFactory) { + Assert.notNull(handlerMethodFactory, "handlerMethodFactory cannot be null"); + this.handlerMethodFactory = handlerMethodFactory; + } + + @Override + public void configureListenerMode(Consumer consumer) { + List parameters = getMethodParameters(); + boolean batch = hasParameterOfType(parameters, List.class); + boolean batchAckParameter = hasParameterOfType(parameters, BatchAcknowledgement.class); + Assert.isTrue(hasValidParameters(batch, batchAckParameter, parameters.size()), getInvalidParametersMessage()); + consumer.accept(batch ? ListenerMode.BATCH : ListenerMode.SINGLE_MESSAGE); + } + + private boolean hasValidParameters(boolean batch, boolean batchAckParameter, int size) { + return hasValidSingleMessageParameters(batch, batchAckParameter) + || hasValidBatchParameters(batchAckParameter, size); + } + + private boolean hasValidSingleMessageParameters(boolean batch, boolean batchAckParameter) { + return !batch && !batchAckParameter; + } + + private boolean hasValidBatchParameters(boolean batchAckParameter, int size) { + return size == 1 || (size == 2 && batchAckParameter); + } + + private String getInvalidParametersMessage() { + return String.format( + "Method %s from class %s in endpoint %s has invalid parameters for batch processing. " + + "Batch methods must have a single List parameter, either of Message or T types," + + "and optionally a BatchAcknowledgement or AsyncAcknowledgement parameter.", + this.method.getName(), this.method.getDeclaringClass(), this.id); + } + + private boolean hasParameterOfType(List parameters, Class clazz) { + return parameters.stream().anyMatch(param -> clazz.isAssignableFrom(param.getParameterType())); + } + + private List getMethodParameters() { + return IntStream.range(0, BridgeMethodResolver.findBridgedMethod(this.method).getParameterCount()) + .mapToObj(index -> new MethodParameter(this.method, index)).collect(Collectors.toList()); + } + + /** + * Configure the provided container for this endpoint. + * @param container the container to be configured. + */ + public void setupContainer(MessageListenerContainer container) { + Assert.notNull(this.handlerMethodFactory, "No handlerMethodFactory has been set"); + InvocableHandlerMethod handlerMethod = this.handlerMethodFactory.createInvocableHandlerMethod(this.bean, + this.method); + if (CompletionStage.class.isAssignableFrom(handlerMethod.getReturnType().getParameterType())) { + container.setAsyncMessageListener(createAsyncMessageListenerInstance(handlerMethod)); + } + else { + container.setMessageListener(createMessageListenerInstance(handlerMethod)); + } + } + + protected MessageListener createMessageListenerInstance(InvocableHandlerMethod handlerMethod) { + return new MessagingMessageListenerAdapter<>(handlerMethod); + } + + protected AsyncMessageListener createAsyncMessageListenerInstance(InvocableHandlerMethod handlerMethod) { + return new AsyncMessagingMessageListenerAdapter<>(handlerMethod); + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/AbstractMessageListenerContainerFactory.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/AbstractMessageListenerContainerFactory.java new file mode 100644 index 000000000..383d1019a --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/AbstractMessageListenerContainerFactory.java @@ -0,0 +1,256 @@ +/* + * 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.config; + +import io.awspring.cloud.sqs.ConfigUtils; +import io.awspring.cloud.sqs.listener.AbstractMessageListenerContainer; +import io.awspring.cloud.sqs.listener.AsyncComponentAdapters; +import io.awspring.cloud.sqs.listener.AsyncMessageListener; +import io.awspring.cloud.sqs.listener.ContainerComponentFactory; +import io.awspring.cloud.sqs.listener.ContainerOptions; +import io.awspring.cloud.sqs.listener.MessageListener; +import io.awspring.cloud.sqs.listener.MessageListenerContainer; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.AsyncAcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.errorhandler.ErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import io.awspring.cloud.sqs.listener.interceptor.MessageInterceptor; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.function.Consumer; +import org.springframework.messaging.Message; +import org.springframework.util.Assert; + +/** + * Base implementation for a {@link MessageListenerContainerFactory}. Contains the components and + * {@link ContainerOptions} that will be used as a template for {@link MessageListenerContainer} instances created by + * this factory. + * + * @param the {@link Message}'s payload type to be consumed by the {@link AbstractMessageListenerContainer}. + * @param the type of {@link AbstractMessageListenerContainer} instances that will be created by this container. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public abstract class AbstractMessageListenerContainerFactory> + implements MessageListenerContainerFactory { + + private final ContainerOptions.Builder containerOptionsBuilder; + + private final Collection> asyncMessageInterceptors = new ArrayList<>(); + + private final Collection> messageInterceptors = new ArrayList<>(); + + private AsyncErrorHandler asyncErrorHandler; + + private ErrorHandler errorHandler; + + private AsyncMessageListener asyncMessageListener; + + private MessageListener messageListener; + + private AsyncAcknowledgementResultCallback asyncAcknowledgementResultCallback; + + private AcknowledgementResultCallback acknowledgementResultCallback; + + private Collection> containerComponentFactories; + + protected AbstractMessageListenerContainerFactory() { + this.containerOptionsBuilder = ContainerOptions.builder(); + } + + /** + * Set the {@link ErrorHandler} instance to be used by containers created with this factory. The component will be + * adapted to an {@link AsyncErrorHandler}. + * @param errorHandler the error handler instance. + * @see AsyncComponentAdapters + */ + public void setErrorHandler(ErrorHandler errorHandler) { + Assert.notNull(errorHandler, "errorHandler cannot be null"); + this.errorHandler = errorHandler; + } + + /** + * Set the {@link AsyncErrorHandler} instance to be used by containers created with this factory. + * @param errorHandler the error handler instance. + */ + public void setErrorHandler(AsyncErrorHandler errorHandler) { + Assert.notNull(errorHandler, "errorHandler cannot be null"); + this.asyncErrorHandler = errorHandler; + } + + /** + * Add a {@link MessageInterceptor} to be used by containers created with this factory. Interceptors will be applied + * just before method invocation. The component will be adapted to an {@link AsyncMessageInterceptor}. + * @param messageInterceptor the message interceptor instance. + * @see AsyncComponentAdapters + */ + public void addMessageInterceptor(MessageInterceptor messageInterceptor) { + Assert.notNull(messageInterceptor, "messageInterceptor cannot be null"); + this.messageInterceptors.add(messageInterceptor); + } + + /** + * Add a {@link AsyncMessageInterceptor} to be used by containers created with this factory. Interceptors will be + * applied just before method invocation. + * @param messageInterceptor the message interceptor instance. + */ + public void addMessageInterceptor(AsyncMessageInterceptor messageInterceptor) { + Assert.notNull(messageInterceptor, "messageInterceptor cannot be null"); + this.asyncMessageInterceptors.add(messageInterceptor); + } + + /** + * Set the {@link MessageListener} instance to be used by containers created with this factory. If none is provided, + * a default one will be created according to the endpoint's configuration. The component will be adapted to an + * {@link AsyncMessageListener}. + * @param messageListener the message listener instance. + * @see AsyncComponentAdapters + */ + public void setMessageListener(MessageListener messageListener) { + Assert.notNull(messageListener, "messageListener cannot be null"); + this.messageListener = messageListener; + } + + /** + * Set the {@link AsyncMessageListener} instance to be used by containers created with this factory. If none is + * provided, a default one will be created according to the endpoint's configuration. + * @param messageListener the message listener instance. + */ + public void setAsyncMessageListener(AsyncMessageListener messageListener) { + Assert.notNull(messageListener, "messageListener cannot be null"); + this.asyncMessageListener = messageListener; + } + + /** + * Set the {@link AsyncAcknowledgementResultCallback} instance to be used by containers created by this factory. + * @param acknowledgementResultCallback the instance. + */ + public void setAcknowledgementResultCallback(AsyncAcknowledgementResultCallback acknowledgementResultCallback) { + Assert.notNull(acknowledgementResultCallback, "acknowledgementResultCallback cannot be null"); + this.asyncAcknowledgementResultCallback = acknowledgementResultCallback; + } + + /** + * Set the {@link AcknowledgementResultCallback} instance to be used by containers created by this factory. + * @param acknowledgementResultCallback the instance. + */ + public void setAcknowledgementResultCallback(AcknowledgementResultCallback acknowledgementResultCallback) { + Assert.notNull(acknowledgementResultCallback, "acknowledgementResultCallback cannot be null"); + this.acknowledgementResultCallback = acknowledgementResultCallback; + } + + /** + * Set the {@link ContainerComponentFactory} instances that will be used to create components for listener + * containers created by this factory. + * @param containerComponentFactories the factory instances. + */ + public void setContainerComponentFactories(Collection> containerComponentFactories) { + Assert.notEmpty(containerComponentFactories, "containerComponentFactories cannot be null or empty"); + this.containerComponentFactories = containerComponentFactories; + } + + /** + * Allows configuring this factories' {@link ContainerOptions.Builder}. + */ + public void configure(Consumer options) { + options.accept(this.containerOptionsBuilder); + } + + @Override + public C createContainer(Endpoint endpoint) { + Assert.notNull(endpoint, "endpoint cannot be null"); + ContainerOptions.Builder options = this.containerOptionsBuilder.createCopy(); + configure(endpoint, options); + C container = createContainerInstance(endpoint, options.build()); + endpoint.setupContainer(container); + configureContainer(container, endpoint); + return container; + } + + private void configure(Endpoint endpoint, ContainerOptions.Builder options) { + ConfigUtils.INSTANCE.acceptIfInstance(endpoint, HandlerMethodEndpoint.class, + abstractEndpoint -> abstractEndpoint.configureListenerMode(options::listenerMode)); + configureContainerOptions(endpoint, options); + } + + protected void configureContainerOptions(Endpoint endpoint, ContainerOptions.Builder containerOptions) { + } + + @Override + public C createContainer(String... logicalEndpointNames) { + Assert.notEmpty(logicalEndpointNames, "endpointNames cannot be empty"); + return createContainer(new EndpointAdapter(Arrays.asList(logicalEndpointNames))); + } + + @SuppressWarnings("unchecked") + protected void configureContainer(C container, Endpoint endpoint) { + ConfigUtils.INSTANCE.acceptIfInstance(container, AbstractMessageListenerContainer.class, + abstractContainer -> configureAbstractContainer(abstractContainer, endpoint)); + } + + protected void configureAbstractContainer(AbstractMessageListenerContainer container, Endpoint endpoint) { + container.setQueueNames(endpoint.getLogicalNames()); + ConfigUtils.INSTANCE.acceptIfNotNull(endpoint.getId(), container::setId) + .acceptIfNotNull(this.containerComponentFactories, container::setComponentFactories) + .acceptIfNotNull(this.messageListener, container::setMessageListener) + .acceptIfNotNull(this.asyncMessageListener, container::setAsyncMessageListener) + .acceptIfNotNull(this.errorHandler, container::setErrorHandler) + .acceptIfNotNull(this.asyncErrorHandler, container::setErrorHandler) + .acceptIfNotNull(this.asyncAcknowledgementResultCallback, container::setAcknowledgementResultCallback) + .acceptIfNotNull(this.acknowledgementResultCallback, container::setAcknowledgementResultCallback) + .acceptIfNotEmpty(this.messageInterceptors, + interceptors -> interceptors.forEach(container::addMessageInterceptor)) + .acceptIfNotEmpty(this.asyncMessageInterceptors, + interceptors -> interceptors.forEach(container::addMessageInterceptor)); + } + + protected abstract C createContainerInstance(Endpoint endpoint, ContainerOptions containerOptions); + + private static class EndpointAdapter implements Endpoint { + + private final Collection endpointNames; + + public EndpointAdapter(Collection endpointNames) { + this.endpointNames = endpointNames; + } + + @SuppressWarnings("rawtypes") + @Override + public void setupContainer(MessageListenerContainer container) { + // No ops - container should be setup manually. + } + + @Override + public Collection getLogicalNames() { + return this.endpointNames; + } + + @Override + public String getListenerContainerFactoryName() { + // we're already in the factory + return null; + } + + @Override + public String getId() { + // Container will setup its own id + return null; + } + } +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/Endpoint.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/Endpoint.java new file mode 100644 index 000000000..fd3fcef45 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/Endpoint.java @@ -0,0 +1,56 @@ +/* + * 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.config; + +import io.awspring.cloud.sqs.listener.MessageListenerContainer; +import java.util.Collection; +import org.springframework.lang.Nullable; + +/** + * Represents a messaging endpoint from which messages can be consumed by a {@link MessageListenerContainer}. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public interface Endpoint { + + /** + * The logical names for this endpoint. + * @return the logical names. + */ + Collection getLogicalNames(); + + /** + * The name of the factory bean that will process this endpoint. + * @return the factory bean name. + */ + @Nullable + String getListenerContainerFactoryName(); + + /** + * An optional id for this endpoint. + * @return the endpoint id. + */ + @Nullable + String getId(); + + /** + * Set up the necessary attributes for the container to process this endpoint. + * @param container the container to be configured. + */ + void setupContainer(MessageListenerContainer container); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/EndpointRegistrar.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/EndpointRegistrar.java new file mode 100644 index 000000000..abeba6b6d --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/EndpointRegistrar.java @@ -0,0 +1,214 @@ +/* + * 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.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.awspring.cloud.sqs.listener.MessageListenerContainer; +import io.awspring.cloud.sqs.listener.MessageListenerContainerRegistry; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.handler.annotation.support.DefaultMessageHandlerMethodFactory; +import org.springframework.messaging.handler.annotation.support.MessageHandlerMethodFactory; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Processes the registered {@link Endpoint} instances using the appropriate {@link MessageListenerContainerFactory}. + * Contains configurations that will be applied to all {@link io.awspring.cloud.sqs.annotation.SqsListener @SqsListener} + * containers. Such configurations can be set by declaring {@link SqsListenerConfigurer} beans. + * + * @author Tomaz Fernandes + * @since 3.0 + * @see SqsListenerConfigurer + */ +public class EndpointRegistrar implements BeanFactoryAware, SmartInitializingSingleton { + + private static final Logger logger = LoggerFactory.getLogger(EndpointRegistrar.class); + + public static final String DEFAULT_LISTENER_CONTAINER_FACTORY_BEAN_NAME = "defaultSqsListenerContainerFactory"; + + private BeanFactory beanFactory; + + private MessageHandlerMethodFactory messageHandlerMethodFactory = new DefaultMessageHandlerMethodFactory(); + + private MessageListenerContainerRegistry listenerContainerRegistry; + + private String messageListenerContainerRegistryBeanName = SqsBeanNames.ENDPOINT_REGISTRY_BEAN_NAME; + + private String defaultListenerContainerFactoryBeanName = DEFAULT_LISTENER_CONTAINER_FACTORY_BEAN_NAME; + + private final Collection endpoints = new ArrayList<>(); + + private Consumer> messageConvertersConsumer = converters -> { + }; + + private Consumer> methodArgumentResolversConsumer = resolvers -> { + }; + + private ObjectMapper objectMapper; + + /** + * Set a custom {@link MessageHandlerMethodFactory} implementation. + * @param messageHandlerMethodFactory the instance. + */ + public void setMessageHandlerMethodFactory(MessageHandlerMethodFactory messageHandlerMethodFactory) { + Assert.notNull(messageHandlerMethodFactory, "messageHandlerMethodFactory cannot be null"); + this.messageHandlerMethodFactory = messageHandlerMethodFactory; + } + + /** + * Set a custom {@link MessageListenerContainerRegistry}. + * @param listenerContainerRegistry the instance. + */ + public void setListenerContainerRegistry(MessageListenerContainerRegistry listenerContainerRegistry) { + Assert.notNull(listenerContainerRegistry, "listenerContainerRegistry cannot be null"); + this.listenerContainerRegistry = listenerContainerRegistry; + } + + /** + * Set the bean name for the default {@link MessageListenerContainerFactory}. + * @param defaultListenerContainerFactoryBeanName the bean name. + */ + public void setDefaultListenerContainerFactoryBeanName(String defaultListenerContainerFactoryBeanName) { + Assert.isTrue(StringUtils.hasText(defaultListenerContainerFactoryBeanName), + "defaultListenerContainerFactoryBeanName must have text"); + this.defaultListenerContainerFactoryBeanName = defaultListenerContainerFactoryBeanName; + } + + /** + * Set the bean name for the {@link MessageListenerContainerRegistry}. + * @param messageListenerContainerRegistryBeanName the bean name. + */ + public void setMessageListenerContainerRegistryBeanName(String messageListenerContainerRegistryBeanName) { + Assert.isTrue(StringUtils.hasText(messageListenerContainerRegistryBeanName), + "messageListenerContainerRegistryBeanName must have text"); + this.messageListenerContainerRegistryBeanName = messageListenerContainerRegistryBeanName; + } + + /** + * Set the object mapper to be used to deserialize payloads fot SqsListener endpoints. + * @param objectMapper the object mapper instance. + */ + public void setObjectMapper(ObjectMapper objectMapper) { + Assert.notNull(objectMapper, "objectMapper cannot be null."); + this.objectMapper = objectMapper; + } + + /** + * Manage the list of {@link MessageConverter} instances to be used to convert payloads. + * @param convertersConsumer a consumer for the converters list. + */ + public void manageMessageConverters(Consumer> convertersConsumer) { + Assert.notNull(convertersConsumer, "convertersConsumer cannot be null"); + this.messageConvertersConsumer = convertersConsumer; + } + + /** + * Manage the list of {@link HandlerMethodArgumentResolver} instances to be used for resolving method arguments. + * @param resolversConsumer a consumer for the resolvers list. + */ + public void manageMethodArgumentResolvers(Consumer> resolversConsumer) { + Assert.notNull(resolversConsumer, "resolversConsumer cannot be null"); + this.methodArgumentResolversConsumer = resolversConsumer; + } + + /** + * Get the message converters list consumer. + * @return the consumer. + */ + public Consumer> getMessageConverterConsumer() { + return this.messageConvertersConsumer; + } + + /** + * Get the method argument resolvers list consumer. + * @return the consumer. + */ + public Consumer> getMethodArgumentResolversConsumer() { + return this.methodArgumentResolversConsumer; + } + + /** + * Get the object mapper used to deserialize payloads. + * @return the object mapper instance. + */ + public ObjectMapper getObjectMapper() { + return this.objectMapper; + } + + /** + * Return the {@link MessageHandlerMethodFactory} to be used to create {@link MessageHandler} instances for the + * {@link Endpoint}s. + * @return the factory instance. + */ + public MessageHandlerMethodFactory getMessageHandlerMethodFactory() { + return this.messageHandlerMethodFactory; + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + } + + /** + * Register an {@link Endpoint} within this registrar for later processing. + * @param endpoint the endpoint. + */ + public void registerEndpoint(Endpoint endpoint) { + this.endpoints.add(endpoint); + } + + @Override + public void afterSingletonsInstantiated() { + if (this.listenerContainerRegistry == null) { + this.listenerContainerRegistry = beanFactory.getBean(this.messageListenerContainerRegistryBeanName, + MessageListenerContainerRegistry.class); + } + this.endpoints.forEach(this::process); + } + + private void process(Endpoint endpoint) { + logger.debug("Processing endpoint {}", endpoint.getId()); + this.listenerContainerRegistry.registerListenerContainer(createContainerFor(endpoint)); + } + + private MessageListenerContainer createContainerFor(Endpoint endpoint) { + String factoryBeanName = getListenerContainerFactoryName(endpoint); + Assert.isTrue(this.beanFactory.containsBean(factoryBeanName), + () -> "No MessageListenerContainerFactory bean with name " + factoryBeanName + + " found for endpoint names " + endpoint.getLogicalNames()); + return this.beanFactory.getBean(factoryBeanName, MessageListenerContainerFactory.class) + .createContainer(endpoint); + } + + private String getListenerContainerFactoryName(Endpoint endpoint) { + return StringUtils.hasText(endpoint.getListenerContainerFactoryName()) + ? endpoint.getListenerContainerFactoryName() + : this.defaultListenerContainerFactoryBeanName; + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/HandlerMethodEndpoint.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/HandlerMethodEndpoint.java new file mode 100644 index 000000000..2aeeb320d --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/HandlerMethodEndpoint.java @@ -0,0 +1,57 @@ +/* + * 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.config; + +import io.awspring.cloud.sqs.listener.ListenerMode; +import java.lang.reflect.Method; +import java.util.function.Consumer; +import org.springframework.messaging.handler.annotation.support.MessageHandlerMethodFactory; + +/** + * {@link Endpoint} specialization that indicates that {@link org.springframework.messaging.Message} instances coming + * from this endpoint will be handled by a {@link org.springframework.messaging.handler.HandlerMethod}. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public interface HandlerMethodEndpoint extends Endpoint { + + /** + * Set the bean containing the method to be invoked with the incoming messages. + * @param bean the bean. + */ + void setBean(Object bean); + + /** + * Set the method to be used when handling messages for this endpoint. + * @param method the method. + */ + void setMethod(Method method); + + /** + * Set the {@link MessageHandlerMethodFactory} to be used for creating the + * {@link org.springframework.messaging.handler.HandlerMethod}. + * @param handlerMethodFactory the factory. + */ + void setHandlerMethodFactory(MessageHandlerMethodFactory handlerMethodFactory); + + /** + * Allows configuring the {@link ListenerMode} for this endpoint. + * @param consumer a consumer for the strategy used by this endpoint. + */ + void configureListenerMode(Consumer consumer); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/MessageListenerContainerFactory.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/MessageListenerContainerFactory.java new file mode 100644 index 000000000..88679e617 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/MessageListenerContainerFactory.java @@ -0,0 +1,47 @@ +/* + * 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.config; + +import io.awspring.cloud.sqs.listener.MessageListenerContainer; + +/** + * Creates {@link MessageListenerContainer} instances for given {@link Endpoint} instances or endpoint names. + * + * @param the {@link MessageListenerContainer} type. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +@FunctionalInterface +public interface MessageListenerContainerFactory> { + + /** + * Create a container instance for the given endpoint names. + * @param logicalEndpointNames the names. + * @return the container instance. + */ + C createContainer(String... logicalEndpointNames); + + /** + * Create a container instance for the given {@link Endpoint}. + * @param endpoint the endpoint. + * @return the container instance. + */ + default C createContainer(Endpoint endpoint) { + throw new UnsupportedOperationException("This factory is not capable of processing Endpoint instances."); + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/SqsBeanNames.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/SqsBeanNames.java new file mode 100644 index 000000000..239f8a0e8 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/SqsBeanNames.java @@ -0,0 +1,41 @@ +/* + * 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.config; + +/*** + * Utility class containing the bean names used for the framework's bean registration. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public class SqsBeanNames { + + private SqsBeanNames() { + } + + /** + * The bean name of the {@link io.awspring.cloud.sqs.listener.DefaultListenerContainerRegistry} registered by + * {@link SqsBootstrapConfiguration}. + */ + public static final String ENDPOINT_REGISTRY_BEAN_NAME = "io.awspring.cloud.messaging.internalEndpointRegistryBeanName"; + + /** + * The bean name of the {@link io.awspring.cloud.sqs.annotation.SqsListenerAnnotationBeanPostProcessor} registered + * by {@link SqsBootstrapConfiguration}. + */ + public static final String SQS_LISTENER_ANNOTATION_BEAN_POST_PROCESSOR_BEAN_NAME = "io.awspring.cloud.messaging.internalSqsListenerAnnotationBeanPostProcessor"; + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/SqsBootstrapConfiguration.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/SqsBootstrapConfiguration.java new file mode 100644 index 000000000..b6adb689d --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/SqsBootstrapConfiguration.java @@ -0,0 +1,47 @@ +/* + * 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.config; + +import io.awspring.cloud.sqs.annotation.SqsListenerAnnotationBeanPostProcessor; +import io.awspring.cloud.sqs.listener.DefaultListenerContainerRegistry; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.type.AnnotationMetadata; + +/** + * Registers the {@link DefaultListenerContainerRegistry} and {@link EndpointRegistrar} that will be used to bootstrap + * the AWS SQS integration. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public class SqsBootstrapConfiguration implements ImportBeanDefinitionRegistrar { + + @Override + public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { + if (!registry.containsBeanDefinition(SqsBeanNames.SQS_LISTENER_ANNOTATION_BEAN_POST_PROCESSOR_BEAN_NAME)) { + registry.registerBeanDefinition(SqsBeanNames.SQS_LISTENER_ANNOTATION_BEAN_POST_PROCESSOR_BEAN_NAME, + new RootBeanDefinition(SqsListenerAnnotationBeanPostProcessor.class)); + } + + if (!registry.containsBeanDefinition(SqsBeanNames.ENDPOINT_REGISTRY_BEAN_NAME)) { + registry.registerBeanDefinition(SqsBeanNames.ENDPOINT_REGISTRY_BEAN_NAME, + new RootBeanDefinition(DefaultListenerContainerRegistry.class)); + } + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/SqsEndpoint.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/SqsEndpoint.java new file mode 100644 index 000000000..7aa8b1f93 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/SqsEndpoint.java @@ -0,0 +1,149 @@ +/* + * 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.config; + +import io.awspring.cloud.sqs.annotation.SqsListener; +import java.time.Duration; +import java.util.Collection; +import org.springframework.lang.Nullable; + +/** + * {@link Endpoint} implementation for SQS endpoints. + * + * Contains properties that should be mapped from {@link SqsListener @SqsListener} annotations. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public class SqsEndpoint extends AbstractEndpoint { + + private final Integer maxInflightMessagesPerQueue; + + private final Integer pollTimeoutSeconds; + + private final Integer messageVisibility; + + private final Integer maxMessagesPerPoll; + + protected SqsEndpoint(SqsEndpointBuilder builder) { + super(builder.queueNames, builder.factoryName, builder.id); + this.maxInflightMessagesPerQueue = builder.maxInflightMessagesPerQueue; + this.pollTimeoutSeconds = builder.pollTimeoutSeconds; + this.messageVisibility = builder.messageVisibility; + this.maxMessagesPerPoll = builder.maxMessagesPerPoll; + } + + /** + * Return a {@link SqsEndpointBuilder} instance with the provided queue names. + * @return the builder instance. + */ + public static SqsEndpointBuilder builder() { + return new SqsEndpointBuilder(); + } + + /** + * The maximum number of inflight messages each queue in this endpoint can process simultaneously. + * @return the maximum number of inflight messages. + */ + @Nullable + public Integer getMaxInflightMessagesPerQueue() { + return this.maxInflightMessagesPerQueue; + } + + /** + * The maximum duration to wait for messages in a given poll. + * @return the poll timeout. + */ + @Nullable + public Duration getPollTimeout() { + return this.pollTimeoutSeconds != null ? Duration.ofSeconds(this.pollTimeoutSeconds) : null; + } + + /** + * Return the maximum amount of messages that should be returned in a poll. + * @return the maximum amount of messages. + */ + @Nullable + public Integer getMaxMessagesPerPoll() { + return this.maxMessagesPerPoll; + } + + /** + * Return the message visibility for this endpoint. + * @return the message visibility. + */ + @Nullable + public Duration getMessageVisibility() { + return this.messageVisibility != null ? Duration.ofSeconds(this.messageVisibility) : null; + } + + public static class SqsEndpointBuilder { + + private Collection queueNames; + + private Integer maxInflightMessagesPerQueue; + + private Integer pollTimeoutSeconds; + + private String factoryName; + + private Integer messageVisibility; + + private String id; + + private Integer maxMessagesPerPoll; + + public SqsEndpointBuilder queueNames(Collection queueNames) { + this.queueNames = queueNames; + return this; + } + + public SqsEndpointBuilder factoryBeanName(String factoryName) { + this.factoryName = factoryName; + return this; + } + + public SqsEndpointBuilder maxInflightMessagesPerQueue(Integer maxInflightMessagesPerQueue) { + this.maxInflightMessagesPerQueue = maxInflightMessagesPerQueue; + return this; + } + + public SqsEndpointBuilder pollTimeoutSeconds(Integer pollTimeoutSeconds) { + this.pollTimeoutSeconds = pollTimeoutSeconds; + return this; + } + + public SqsEndpointBuilder maxMessagesPerPoll(Integer maxMessagesPerPoll) { + this.maxMessagesPerPoll = maxMessagesPerPoll; + return this; + } + + public SqsEndpointBuilder messageVisibility(Integer messageVisibility) { + this.messageVisibility = messageVisibility; + return this; + } + + public SqsEndpointBuilder id(String id) { + this.id = id; + return this; + } + + public SqsEndpoint build() { + return new SqsEndpoint(this); + } + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/SqsListenerConfigurer.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/SqsListenerConfigurer.java new file mode 100644 index 000000000..8a5cbe7dc --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/SqsListenerConfigurer.java @@ -0,0 +1,36 @@ +/* + * 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.config; + +/** + * Beans implementing this interface can configure the {@link EndpointRegistrar} instance used to process + * {@link Endpoint} instances and change general settings for processing all + * {@link io.awspring.cloud.sqs.annotation.SqsListener} annotations. + * + * @author Tomaz Fernandes + * @since 3.0 + * @see io.awspring.cloud.sqs.annotation.SqsListenerAnnotationBeanPostProcessor + */ +@FunctionalInterface +public interface SqsListenerConfigurer { + + /** + * Configures the {@link EndpointRegistrar} instance that will handle the {@link Endpoint} instances. + * @param registrar the registrar instance. + */ + void configure(EndpointRegistrar registrar); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/SqsMessageListenerContainerFactory.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/SqsMessageListenerContainerFactory.java new file mode 100644 index 000000000..e1940ad25 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/SqsMessageListenerContainerFactory.java @@ -0,0 +1,312 @@ +/* + * 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.config; + +import io.awspring.cloud.sqs.ConfigUtils; +import io.awspring.cloud.sqs.annotation.SqsListener; +import io.awspring.cloud.sqs.listener.AsyncMessageListener; +import io.awspring.cloud.sqs.listener.ContainerComponentFactory; +import io.awspring.cloud.sqs.listener.ContainerOptions; +import io.awspring.cloud.sqs.listener.MessageListener; +import io.awspring.cloud.sqs.listener.SqsMessageListenerContainer; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.AsyncAcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.errorhandler.ErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import io.awspring.cloud.sqs.listener.interceptor.MessageInterceptor; +import java.util.ArrayList; +import java.util.Collection; +import java.util.function.Consumer; +import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.messaging.Message; +import org.springframework.util.Assert; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; + +/** + * {@link MessageListenerContainerFactory} implementation for creating {@link SqsMessageListenerContainer} instances. A + * factory can be assigned to a {@link io.awspring.cloud.sqs.annotation.SqsListener @SqsListener} by using the + * {@link SqsListener#factory()} property. The factory can also be used to create container instances manually. + *

+ * To create an instance, both the default constructor or the {@link #builder()} method can be used, and further + * configuration can be achieved by using the {@link #configure(Consumer)} method. + *

+ * The {@link SqsAsyncClient} instance to be used by the containers created by this factory can be set using either the + * {@link #setSqsAsyncClient} or {@link #setSqsAsyncClientSupplier} methods, or their builder counterparts. The former + * will result in the containers sharing the supplied instance, where the later will result in a different instance + * being used by each container. + *

+ * The factory also accepts the following components: + *

    + *
  • {@link MessageInterceptor}
  • + *
  • {@link MessageListener}
  • + *
  • {@link ErrorHandler}
  • + *
  • {@link AsyncMessageInterceptor}
  • + *
  • {@link AsyncMessageListener}
  • + *
  • {@link AsyncErrorHandler}
  • + *
+ * The non-async components will be adapted to their async counterparts. When using Spring Boot and auto-configuration, + * beans implementing these interfaces will be set to the default factory. + *

+ * Example using the builder: + * + *

+ * 
+ * @Bean
+ * SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory(SqsAsyncClient sqsAsyncClient) {
+ *     return SqsMessageListenerContainerFactory
+ *             .builder()
+ *             .configure(options -> options
+ *                     .messagesPerPoll(5)
+ *                     .pollTimeout(Duration.ofSeconds(10)))
+ *             .sqsAsyncClient(sqsAsyncClient)
+ *             .build();
+ * }
+ * 
+ * 
+ *
+ * 

+ * Example using the default constructor: + * + *

+ * 
+ * @Bean
+ * SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory(SqsAsyncClient sqsAsyncClient) {
+ *     SqsMessageListenerContainerFactory factory = new SqsMessageListenerContainerFactory<>();
+ *     factory.setSqsAsyncClient(sqsAsyncClient);
+ *     factory.configure(options -> options
+ *             .messagesPerPoll(5)
+ *             .pollTimeout(Duration.ofSeconds(10)));
+ *     return factory;
+ * }
+ * 
+ * 
+ * 

+ * Example creating a container manually: + * + *

+ * 
+ * @Bean
+ * SqsMessageListenerContainer defaultSqsListenerContainerFactory(SqsAsyncClient sqsAsyncClient) {
+ *     return SqsMessageListenerContainerFactory
+ *             .builder()
+ *             .configure(options -> options
+ *                     .messagesPerPoll(5)
+ *                     .pollTimeout(Duration.ofSeconds(10)))
+ *             .sqsAsyncClient(sqsAsyncClient)
+ *             .build()
+ *             .create("myQueue");
+ * }
+ * 
+ * 
+ *
+ * @param  the {@link Message} payload type. This type is used to ensure at compile time that all components in this
+ *     factory expect the same payload type. If the factory will be used with many payload types, {@link Object} can be
+ *     used.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see SqsMessageListenerContainer
+ * @see ContainerOptions
+ * @see io.awspring.cloud.sqs.listener.AsyncComponentAdapters
+ */
+public class SqsMessageListenerContainerFactory
+		extends AbstractMessageListenerContainerFactory> {
+
+	private static final Logger logger = LoggerFactory.getLogger(SqsMessageListenerContainerFactory.class);
+
+	private Supplier sqsAsyncClientSupplier;
+
+	@Override
+	protected SqsMessageListenerContainer createContainerInstance(Endpoint endpoint,
+			ContainerOptions containerOptions) {
+		logger.debug("Creating {} for endpoint {}", SqsMessageListenerContainer.class.getSimpleName(),
+				endpoint.getId() != null ? endpoint.getId() : endpoint.getLogicalNames());
+		Assert.notNull(this.sqsAsyncClientSupplier, "asyncClientSupplier not set");
+		SqsAsyncClient asyncClient = getSqsAsyncClientInstance();
+		return new SqsMessageListenerContainer<>(asyncClient, containerOptions);
+	}
+
+	protected SqsAsyncClient getSqsAsyncClientInstance() {
+		return this.sqsAsyncClientSupplier.get();
+	}
+
+	protected void configureContainerOptions(Endpoint endpoint, ContainerOptions.Builder options) {
+		ConfigUtils.INSTANCE.acceptIfInstance(endpoint, SqsEndpoint.class,
+				sqsEndpoint -> configureFromSqsEndpoint(sqsEndpoint, options));
+	}
+
+	private void configureFromSqsEndpoint(SqsEndpoint sqsEndpoint, ContainerOptions.Builder options) {
+		ConfigUtils.INSTANCE
+				.acceptIfNotNull(sqsEndpoint.getMaxInflightMessagesPerQueue(), options::maxInflightMessagesPerQueue)
+				.acceptIfNotNull(sqsEndpoint.getMaxMessagesPerPoll(), options::maxMessagesPerPoll)
+				.acceptIfNotNull(sqsEndpoint.getPollTimeout(), options::pollTimeout)
+				.acceptIfNotNull(sqsEndpoint.getMessageVisibility(), options::messageVisibility);
+	}
+
+	/**
+	 * Set a supplier for {@link SqsAsyncClient} instances. A new instance will be used for each container created by
+	 * this factory. Useful for high throughput containers where sharing an {@link SqsAsyncClient} would be detrimental
+	 * to performance.
+	 *
+	 * @param sqsAsyncClientSupplier the supplier.
+	 */
+	public void setSqsAsyncClientSupplier(Supplier sqsAsyncClientSupplier) {
+		Assert.notNull(sqsAsyncClientSupplier, "sqsAsyncClientSupplier cannot be null.");
+		this.sqsAsyncClientSupplier = sqsAsyncClientSupplier;
+	}
+
+	/**
+	 * Set the {@link SqsAsyncClient} instance to be shared by the containers. For high throughput scenarios the client
+	 * should be tuned for allowing higher maximum connections.
+	 * @param sqsAsyncClient the client instance.
+	 */
+	public void setSqsAsyncClient(SqsAsyncClient sqsAsyncClient) {
+		Assert.notNull(sqsAsyncClient, "sqsAsyncClient cannot be null.");
+		setSqsAsyncClientSupplier(() -> sqsAsyncClient);
+	}
+
+	public static  Builder builder() {
+		return new Builder<>();
+	}
+
+	public static class Builder {
+
+		private final Collection> asyncMessageInterceptors = new ArrayList<>();
+
+		private final Collection> messageInterceptors = new ArrayList<>();
+
+		private Supplier sqsAsyncClientSupplier;
+
+		private SqsAsyncClient sqsAsyncClient;
+
+		private Collection> containerComponentFactories;
+
+		private AsyncMessageListener asyncMessageListener;
+
+		private MessageListener messageListener;
+
+		private AsyncErrorHandler asyncErrorHandler;
+
+		private ErrorHandler errorHandler;
+
+		private Consumer optionsConsumer = options -> {
+		};
+
+		private AcknowledgementResultCallback acknowledgementResultCallback;
+
+		private AsyncAcknowledgementResultCallback asyncAcknowledgementResultCallback;
+
+		/**
+		 * Set the {@link SqsAsyncClient} instance to be shared by the containers. For high throughput scenarios the
+		 * client should be tuned for allowing higher maximum connections.
+		 * @param sqsAsyncClient the client instance.
+		 */
+		public Builder sqsAsyncClient(SqsAsyncClient sqsAsyncClient) {
+			this.sqsAsyncClient = sqsAsyncClient;
+			return this;
+		}
+
+		/**
+		 * Set a supplier for {@link SqsAsyncClient} instances. A new instance will be used for each container created
+		 * by this factory. Useful for high throughput containers where sharing an {@link SqsAsyncClient} would be
+		 * detrimental to performance.
+		 *
+		 * @param sqsAsyncClientSupplier the supplier.
+		 */
+		public Builder sqsAsyncClientSupplier(Supplier sqsAsyncClientSupplier) {
+			this.sqsAsyncClientSupplier = sqsAsyncClientSupplier;
+			return this;
+		}
+
+		public Builder containerComponentFactories(
+				Collection> containerComponentFactories) {
+			this.containerComponentFactories = containerComponentFactories;
+			return this;
+		}
+
+		public Builder asyncMessageListener(AsyncMessageListener asyncMessageListener) {
+			this.asyncMessageListener = asyncMessageListener;
+			return this;
+		}
+
+		public Builder messageListener(MessageListener messageListener) {
+			this.messageListener = messageListener;
+			return this;
+		}
+
+		public Builder errorHandler(AsyncErrorHandler asyncErrorHandler) {
+			this.asyncErrorHandler = asyncErrorHandler;
+			return this;
+		}
+
+		public Builder errorHandler(ErrorHandler errorHandler) {
+			this.errorHandler = errorHandler;
+			return this;
+		}
+
+		public Builder messageInterceptor(AsyncMessageInterceptor asyncMessageInterceptor) {
+			this.asyncMessageInterceptors.add(asyncMessageInterceptor);
+			return this;
+		}
+
+		public Builder messageInterceptor(MessageInterceptor messageInterceptor) {
+			this.messageInterceptors.add(messageInterceptor);
+			return this;
+		}
+
+		public Builder acknowledgementResultCallback(
+				AsyncAcknowledgementResultCallback asyncAcknowledgementResultCallback) {
+			this.asyncAcknowledgementResultCallback = asyncAcknowledgementResultCallback;
+			return this;
+		}
+
+		public Builder acknowledgementResultCallback(
+				AcknowledgementResultCallback acknowledgementResultCallback) {
+			this.acknowledgementResultCallback = acknowledgementResultCallback;
+			return this;
+		}
+
+		public Builder configure(Consumer options) {
+			this.optionsConsumer = options;
+			return this;
+		}
+
+		// @formatter:off
+		public SqsMessageListenerContainerFactory build() {
+			SqsMessageListenerContainerFactory factory = new SqsMessageListenerContainerFactory<>();
+			ConfigUtils.INSTANCE
+				.acceptIfNotNull(this.messageListener, factory::setMessageListener)
+				.acceptIfNotNull(this.asyncMessageListener, factory::setAsyncMessageListener)
+				.acceptIfNotNull(this.errorHandler, factory::setErrorHandler)
+				.acceptIfNotNull(this.asyncErrorHandler, factory::setErrorHandler)
+				.acceptIfNotNull(this.acknowledgementResultCallback, factory::setAcknowledgementResultCallback)
+				.acceptIfNotNull(this.asyncAcknowledgementResultCallback, factory::setAcknowledgementResultCallback)
+				.acceptIfNotNull(this.containerComponentFactories, factory::setContainerComponentFactories)
+				.acceptIfNotNull(this.sqsAsyncClient, factory::setSqsAsyncClient)
+				.acceptIfNotNull(this.sqsAsyncClientSupplier, factory::setSqsAsyncClientSupplier);
+			this.messageInterceptors.forEach(factory::addMessageInterceptor);
+			this.asyncMessageInterceptors.forEach(factory::addMessageInterceptor);
+			factory.configure(this.optionsConsumer);
+			return factory;
+		}
+		// @formatter:on
+
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/package-info.java
new file mode 100644
index 000000000..5e5bced3f
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/config/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+/**
+ * Auto-configuration for Amazon SQS (Simple Queue Service) integrations.
+ */
+@org.springframework.lang.NonNullApi
+@org.springframework.lang.NonNullFields
+package io.awspring.cloud.sqs.config;
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractMessageListenerContainer.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractMessageListenerContainer.java
new file mode 100644
index 000000000..47a6026d3
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractMessageListenerContainer.java
@@ -0,0 +1,296 @@
+/*
+ * 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.acknowledgement.AcknowledgementResultCallback;
+import io.awspring.cloud.sqs.listener.acknowledgement.AsyncAcknowledgementResultCallback;
+import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler;
+import io.awspring.cloud.sqs.listener.errorhandler.ErrorHandler;
+import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor;
+import io.awspring.cloud.sqs.listener.interceptor.MessageInterceptor;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.function.Consumer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.SmartLifecycle;
+import org.springframework.messaging.Message;
+import org.springframework.util.Assert;
+
+/**
+ * Base implementation for {@link MessageListenerContainer} with {@link SmartLifecycle} and component management
+ * capabilities.
+ *
+ * @param  the {@link Message} type to be consumed by the {@link AbstractMessageListenerContainer}
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public abstract class AbstractMessageListenerContainer implements MessageListenerContainer {
+
+	private static final Logger logger = LoggerFactory.getLogger(AbstractMessageListenerContainer.class);
+
+	private final Object lifecycleMonitor = new Object();
+
+	private boolean isRunning;
+
+	private String id;
+
+	private Collection queueNames = new ArrayList<>();
+
+	private Collection> containerComponentFactories;
+
+	private AsyncMessageListener messageListener;
+
+	private AsyncErrorHandler errorHandler;
+
+	private final Collection> messageInterceptors = new ArrayList<>();
+
+	private ContainerOptions containerOptions;
+
+	private AsyncAcknowledgementResultCallback acknowledgementResultCallback = new AsyncAcknowledgementResultCallback() {
+	};
+
+	/**
+	 * Create an instance with the provided {@link ContainerOptions}
+	 * @param containerOptions the options instance.
+	 */
+	protected AbstractMessageListenerContainer(ContainerOptions containerOptions) {
+		Assert.notNull(containerOptions, "containerOptions cannot be null");
+		this.containerOptions = containerOptions;
+	}
+
+	/**
+	 * Set the id for this container instance.
+	 * @param id the id.
+	 */
+	@Override
+	public void setId(String id) {
+		Assert.notNull(id, "id cannot be null");
+		this.id = id;
+	}
+
+	/**
+	 * Set the {@link ErrorHandler} instance to be used by this container. The component will be adapted to an
+	 * {@link AsyncErrorHandler}.
+	 * @param errorHandler the instance.
+	 */
+	public void setErrorHandler(ErrorHandler errorHandler) {
+		Assert.notNull(errorHandler, "errorHandler cannot be null");
+		this.errorHandler = AsyncComponentAdapters.adapt(errorHandler);
+	}
+
+	/**
+	 * Set the {@link AsyncErrorHandler} instance to be used by this container.
+	 * @param errorHandler the instance.
+	 */
+	public void setErrorHandler(AsyncErrorHandler errorHandler) {
+		Assert.notNull(errorHandler, "errorHandler cannot be null");
+		this.errorHandler = errorHandler;
+	}
+
+	/**
+	 * Add a collection of interceptors that will intercept the message before processing. Interceptors are executed
+	 * sequentially and in order.
+	 * @param messageInterceptor the interceptor instances.
+	 */
+	public void addMessageInterceptor(MessageInterceptor messageInterceptor) {
+		Assert.notNull(messageInterceptor, "messageInterceptor cannot be null");
+		this.messageInterceptors.add(AsyncComponentAdapters.adapt(messageInterceptor));
+	}
+
+	/**
+	 * Add an interceptor that will intercept the message before processing. Interceptors are executed sequentially and
+	 * in order.
+	 * @param messageInterceptor the interceptor instances.
+	 */
+	public void addMessageInterceptor(AsyncMessageInterceptor messageInterceptor) {
+		Assert.notNull(messageInterceptor, "messageInterceptor cannot be null");
+		this.messageInterceptors.add(messageInterceptor);
+	}
+
+	@Override
+	public void setMessageListener(MessageListener messageListener) {
+		Assert.notNull(messageListener, "messageListener cannot be null");
+		this.messageListener = AsyncComponentAdapters.adapt(messageListener);
+	}
+
+	@Override
+	public void setAsyncMessageListener(AsyncMessageListener asyncMessageListener) {
+		Assert.notNull(asyncMessageListener, "asyncMessageListener cannot be null");
+		this.messageListener = asyncMessageListener;
+	}
+
+	/**
+	 * Set the {@link AsyncAcknowledgementResultCallback} instance to be used by this container.
+	 * @param acknowledgementResultCallback the instance.
+	 */
+	public void setAcknowledgementResultCallback(AsyncAcknowledgementResultCallback acknowledgementResultCallback) {
+		Assert.notNull(acknowledgementResultCallback, "acknowledgementResultCallback cannot be null");
+		this.acknowledgementResultCallback = acknowledgementResultCallback;
+	}
+
+	/**
+	 * Set the {@link AcknowledgementResultCallback} instance to be used by this container.
+	 * @param acknowledgementResultCallback the instance.
+	 */
+	public void setAcknowledgementResultCallback(AcknowledgementResultCallback acknowledgementResultCallback) {
+		Assert.notNull(acknowledgementResultCallback, "acknowledgementResultCallback cannot be null");
+		this.acknowledgementResultCallback = AsyncComponentAdapters.adapt(acknowledgementResultCallback);
+	}
+
+	public void setComponentFactories(Collection> containerComponentFactories) {
+		Assert.notEmpty(containerComponentFactories, "containerComponentFactories cannot be null or empty");
+		this.containerComponentFactories = containerComponentFactories;
+	}
+
+	/**
+	 * Returns the {@link ContainerOptions} instance for this container. Changed options will take effect on container
+	 * restart.
+	 */
+	public void configure(Consumer options) {
+		Assert.state(!isRunning(), "Stop the container before making changes to the options");
+		ContainerOptions.Builder builder = this.containerOptions.toBuilder();
+		options.accept(builder);
+		this.containerOptions = builder.build();
+	}
+
+	public ContainerOptions getContainerOptions() {
+		return this.containerOptions;
+	}
+
+	/**
+	 * Return the {@link ContainerComponentFactory} instances to be used for creating this container's components.
+	 * @return the instances.
+	 */
+	public Collection> getContainerComponentFactories() {
+		return this.containerComponentFactories;
+	}
+
+	/**
+	 * Return the {@link AsyncMessageListener} instance used by this container.
+	 * @return the instance.
+	 */
+	public AsyncMessageListener getMessageListener() {
+		return this.messageListener;
+	}
+
+	/**
+	 * Return the {@link AsyncErrorHandler} instance used by this container.
+	 * @return the instance.
+	 */
+	public AsyncErrorHandler getErrorHandler() {
+		return this.errorHandler;
+	}
+
+	/**
+	 * Return the {@link AsyncMessageInterceptor} instances used by this container.
+	 * @return the instances.
+	 */
+	public Collection> getMessageInterceptors() {
+		return Collections.unmodifiableCollection(this.messageInterceptors);
+	}
+
+	/**
+	 * Return the {@link AcknowledgementResultCallback} instance used by this container.
+	 * @return the instance.
+	 */
+	public AsyncAcknowledgementResultCallback getAcknowledgementResultCallback() {
+		return this.acknowledgementResultCallback;
+	}
+
+	@Override
+	public String getId() {
+		return this.id;
+	}
+
+	/**
+	 * Set the queue logical names that will be handled by the container. Required for container start.
+	 * @param queueNames the queue names.
+	 */
+	public void setQueueNames(Collection queueNames) {
+		Assert.notEmpty(queueNames, "queueNames cannot be empty");
+		this.queueNames = queueNames;
+	}
+
+	/**
+	 * Set the queue logical names that will be handled by the container. Required for container start.
+	 * @param queueNames the queue names.
+	 */
+	public void setQueueNames(String... queueNames) {
+		setQueueNames(Arrays.asList(queueNames));
+	}
+
+	/**
+	 * Return the queue names assigned to this container.
+	 * @return the queue names.
+	 */
+	public Collection getQueueNames() {
+		return Collections.unmodifiableCollection(this.queueNames);
+	}
+
+	@Override
+	public boolean isRunning() {
+		return this.isRunning;
+	}
+
+	@Override
+	public void start() {
+		if (this.isRunning) {
+			return;
+		}
+		synchronized (this.lifecycleMonitor) {
+			Assert.state(!this.queueNames.isEmpty(), "Queue names not set");
+			Assert.notNull(this.messageListener, "messageListener cannot be null");
+			this.isRunning = true;
+			if (this.id == null) {
+				this.id = resolveContainerId();
+			}
+			logger.debug("Starting container {}", getId());
+			doStart();
+		}
+		logger.info("Container {} started", this.id);
+	}
+
+	private String resolveContainerId() {
+		String firstQueueName = this.queueNames.iterator().next();
+		return firstQueueName.startsWith("http")
+				? firstQueueName.substring(Math.max(firstQueueName.length() - 10, 0)) + "-container"
+				: firstQueueName.substring(0, Math.min(15, firstQueueName.length())) + "-container";
+	}
+
+	protected void doStart() {
+	}
+
+	@Override
+	public void stop() {
+		if (!this.isRunning) {
+			return;
+		}
+		logger.debug("Stopping container {}", this.id);
+		synchronized (this.lifecycleMonitor) {
+			this.isRunning = false;
+			doStop();
+		}
+		logger.info("Container {} stopped", this.id);
+	}
+
+	protected void doStop() {
+	}
+
+}
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
new file mode 100644
index 000000000..b47be1348
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AbstractPipelineMessageListenerContainer.java
@@ -0,0 +1,255 @@
+/*
+ * 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.ConfigUtils;
+import io.awspring.cloud.sqs.LifecycleHandler;
+import io.awspring.cloud.sqs.MessageExecutionThreadFactory;
+import io.awspring.cloud.sqs.listener.pipeline.AcknowledgementHandlerExecutionStage;
+import io.awspring.cloud.sqs.listener.pipeline.AfterProcessingContextInterceptorExecutionStage;
+import io.awspring.cloud.sqs.listener.pipeline.AfterProcessingInterceptorExecutionStage;
+import io.awspring.cloud.sqs.listener.pipeline.BeforeProcessingContextInterceptorExecutionStage;
+import io.awspring.cloud.sqs.listener.pipeline.BeforeProcessingInterceptorExecutionStage;
+import io.awspring.cloud.sqs.listener.pipeline.ErrorHandlerExecutionStage;
+import io.awspring.cloud.sqs.listener.pipeline.MessageListenerExecutionStage;
+import io.awspring.cloud.sqs.listener.pipeline.MessageProcessingConfiguration;
+import io.awspring.cloud.sqs.listener.pipeline.MessageProcessingPipeline;
+import io.awspring.cloud.sqs.listener.pipeline.MessageProcessingPipelineBuilder;
+import io.awspring.cloud.sqs.listener.sink.MessageProcessingPipelineSink;
+import io.awspring.cloud.sqs.listener.sink.MessageSink;
+import io.awspring.cloud.sqs.listener.source.AcknowledgementProcessingMessageSource;
+import io.awspring.cloud.sqs.listener.source.MessageSource;
+import io.awspring.cloud.sqs.listener.source.PollingMessageSource;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.ThreadFactory;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
+import org.springframework.core.task.TaskExecutor;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+/**
+ * Base {@link MessageListenerContainer} implementation for managing {@link org.springframework.messaging.Message}
+ * instances' lifecycles.
+ *
+ * This container uses a {@link MessageSource} to create the {@link org.springframework.messaging.Message} instances,
+ * which are forwarded to a {@link MessageSink} and finally emitted to a {@link MessageProcessingPipeline}.
+ *
+ * The pipeline has several stages for processing the messages and executing logic in components such as
+ * {@link AsyncMessageListener}, {@link io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler} and
+ * {@link io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor}.
+ *
+ * Such components are created by the {@link ContainerComponentFactory} and the container manages their lifecycles.
+ *
+ * Components and {@link ContainerOptions} can be changed at runtime and such changes will be valid upon container
+ * restart.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public abstract class AbstractPipelineMessageListenerContainer extends AbstractMessageListenerContainer {
+
+	private static final Logger logger = LoggerFactory.getLogger(AbstractPipelineMessageListenerContainer.class);
+
+	private Collection> messageSources;
+
+	private MessageSink messageSink;
+
+	private TaskExecutor componentsTaskExecutor;
+
+	protected AbstractPipelineMessageListenerContainer(ContainerOptions options) {
+		super(options);
+	}
+
+	@Override
+	protected void doStart() {
+		ContainerComponentFactory componentFactory = determineComponentFactory();
+		this.messageSources = createMessageSources(componentFactory);
+		this.messageSink = componentFactory.createMessageSink(getContainerOptions());
+		configureComponents(componentFactory);
+		LifecycleHandler.get().start(this.messageSink, this.messageSources);
+	}
+
+	// @formatter:off
+	private ContainerComponentFactory determineComponentFactory() {
+		return getComponentFactories()
+			.stream()
+			.filter(factory -> factory.supports(getQueueNames(), getContainerOptions()))
+			.findFirst()
+			.orElseThrow(() -> new IllegalArgumentException("No ContainerComponentFactory found for queues " + getQueueNames()));
+	}
+
+	private Collection> getComponentFactories() {
+		return getContainerComponentFactories() != null
+			? getContainerComponentFactories()
+			: getDefaultComponentFactories();
+	}
+
+	protected abstract Collection> getDefaultComponentFactories();
+
+	protected Collection> createMessageSources(ContainerComponentFactory componentFactory) {
+		List queueNames = new ArrayList<>(getQueueNames());
+		return IntStream.range(0, queueNames.size())
+				.mapToObj(index -> createMessageSource(queueNames.get(index), index, componentFactory))
+				.collect(Collectors.toList());
+	}
+
+	protected MessageSource createMessageSource(String queueName, int index,
+			ContainerComponentFactory componentFactory) {
+		MessageSource messageSource = componentFactory.createMessageSource(getContainerOptions());
+		ConfigUtils.INSTANCE
+			.acceptIfInstance(messageSource, PollingMessageSource.class,
+				pms -> pms.setPollingEndpointName(queueName))
+			.acceptIfInstance(messageSource, IdentifiableContainerComponent.class,
+				icc -> icc.setId(getId() + "-" + index));
+		return messageSource;
+	}
+
+	private void configureComponents(ContainerComponentFactory componentFactory) {
+		getContainerOptions()
+			.configure(this.messageSources)
+			.configure(this.messageSink);
+		this.componentsTaskExecutor = resolveComponentsTaskExecutor();
+		configureMessageSources(componentFactory);
+		configureMessageSink(createMessageProcessingPipeline(componentFactory));
+		configureContainerComponents();
+	}
+	// @formatter:on
+
+	@SuppressWarnings("unchecked")
+	protected void configureMessageSources(ContainerComponentFactory componentFactory) {
+		TaskExecutor taskExecutor = createSourcesTaskExecutor();
+		ConfigUtils.INSTANCE.acceptMany(this.messageSources, source -> source.setMessageSink(this.messageSink))
+				.acceptManyIfInstance(this.messageSources, PollingMessageSource.class,
+						pms -> pms.setBackPressureHandler(createBackPressureHandler()))
+				.acceptManyIfInstance(this.messageSources, AcknowledgementProcessingMessageSource.class,
+						ams -> ams.setAcknowledgementProcessor(
+								componentFactory.createAcknowledgementProcessor(getContainerOptions())))
+				.acceptManyIfInstance(this.messageSources, AcknowledgementProcessingMessageSource.class,
+						ams -> ams.setAcknowledgementResultCallback(getAcknowledgementResultCallback()))
+				.acceptManyIfInstance(this.messageSources, TaskExecutorAware.class,
+						teac -> teac.setTaskExecutor(taskExecutor));
+		doConfigureMessageSources(this.messageSources);
+	}
+
+	protected void doConfigureMessageSources(Collection> messageSources) {
+	};
+
+	@SuppressWarnings("unchecked")
+	protected void configureMessageSink(MessageProcessingPipeline messageProcessingPipeline) {
+		ConfigUtils.INSTANCE
+				.acceptIfInstance(this.messageSink, IdentifiableContainerComponent.class, icc -> icc.setId(getId()))
+				.acceptIfInstance(this.messageSink, TaskExecutorAware.class,
+						teac -> teac.setTaskExecutor(getComponentsTaskExecutor()))
+				.acceptIfInstance(this.messageSink, MessageProcessingPipelineSink.class,
+						mls -> mls.setMessagePipeline(messageProcessingPipeline));
+		doConfigureMessageSink(this.messageSink);
+	}
+
+	protected void doConfigureMessageSink(MessageSink messageSink) {
+	}
+
+	protected void configureContainerComponents() {
+		ConfigUtils.INSTANCE
+				.acceptManyIfInstance(getMessageInterceptors(), TaskExecutorAware.class,
+						teac -> teac.setTaskExecutor(getComponentsTaskExecutor()))
+				.acceptIfInstance(getMessageListener(), TaskExecutorAware.class,
+						teac -> teac.setTaskExecutor(getComponentsTaskExecutor()))
+				.acceptIfInstance(getErrorHandler(), TaskExecutorAware.class,
+						teac -> teac.setTaskExecutor(getComponentsTaskExecutor()))
+				.acceptIfInstance(getAcknowledgementResultCallback(), TaskExecutorAware.class,
+						teac -> teac.setTaskExecutor(getComponentsTaskExecutor()));
+	}
+
+	// @formatter:off
+	protected MessageProcessingPipeline createMessageProcessingPipeline(
+			ContainerComponentFactory componentFactory) {
+		return MessageProcessingPipelineBuilder.
+			 first(BeforeProcessingContextInterceptorExecutionStage::new)
+				.then(BeforeProcessingInterceptorExecutionStage::new)
+				.then(MessageListenerExecutionStage::new)
+				.thenInTheFuture(ErrorHandlerExecutionStage::new)
+				.thenInTheFuture(AfterProcessingInterceptorExecutionStage::new)
+				.thenInTheFuture(AfterProcessingContextInterceptorExecutionStage::new)
+				.thenInTheFuture(AcknowledgementHandlerExecutionStage::new)
+				.build(MessageProcessingConfiguration. builder()
+					.interceptors(getMessageInterceptors())
+					.messageListener(getMessageListener())
+					.errorHandler(getErrorHandler())
+					.ackHandler(componentFactory.createAcknowledgementHandler(getContainerOptions()))
+					.build());
+	}
+	// @formatter:on
+
+	private TaskExecutor resolveComponentsTaskExecutor() {
+		return getContainerOptions().getComponentsTaskExecutor() != null
+				? getContainerOptions().getComponentsTaskExecutor()
+				: createComponentsTaskExecutor();
+	}
+
+	protected BackPressureHandler createBackPressureHandler() {
+		return SemaphoreBackPressureHandler.builder().batchSize(getContainerOptions().getMaxMessagesPerPoll())
+				.totalPermits(getContainerOptions().getMaxInFlightMessagesPerQueue())
+				.acquireTimeout(getContainerOptions().getPermitAcquireTimeout())
+				.throughputConfiguration(getContainerOptions().getBackPressureMode()).build();
+	}
+
+	protected TaskExecutor createSourcesTaskExecutor() {
+		SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor();
+		executor.setThreadNamePrefix(getId() + "#message_source-");
+		return executor;
+	}
+
+	protected TaskExecutor createComponentsTaskExecutor() {
+		ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+		int poolSize = getContainerOptions().getMaxInFlightMessagesPerQueue() * this.messageSources.size();
+		executor.setMaxPoolSize(poolSize);
+		executor.setCorePoolSize(getContainerOptions().getMaxMessagesPerPoll());
+		executor.setQueueCapacity(0);
+		executor.setAllowCoreThreadTimeOut(true);
+		executor.setThreadFactory(createThreadFactory());
+		executor.afterPropertiesSet();
+		return executor;
+	}
+
+	protected ThreadFactory createThreadFactory() {
+		MessageExecutionThreadFactory threadFactory = new MessageExecutionThreadFactory();
+		threadFactory.setThreadNamePrefix(getId() + "-");
+		return threadFactory;
+	}
+
+	@Override
+	protected void doStop() {
+		LifecycleHandler.get().stop(this.messageSources, this.messageSink);
+		shutdownComponentsTaskExecutor();
+		logger.debug("Container {} stopped", getId());
+	}
+
+	protected TaskExecutor getComponentsTaskExecutor() {
+		return this.componentsTaskExecutor;
+	}
+
+	private void shutdownComponentsTaskExecutor() {
+		if (!this.componentsTaskExecutor.equals(getContainerOptions().getComponentsTaskExecutor())) {
+			LifecycleHandler.get().dispose(getComponentsTaskExecutor());
+		}
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AsyncAdapterBlockingExecutionFailedException.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AsyncAdapterBlockingExecutionFailedException.java
new file mode 100644
index 000000000..ac9d10f8a
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AsyncAdapterBlockingExecutionFailedException.java
@@ -0,0 +1,34 @@
+/*
+ * 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;
+
+/**
+ * Exception representing a failure on an execution attempted by a blocking adapter.
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see io.awspring.cloud.sqs.listener.AsyncComponentAdapters
+ */
+public class AsyncAdapterBlockingExecutionFailedException extends RuntimeException {
+
+	/**
+	 * Create an instance with the provided error message and cause.
+	 * @param errorMessage the error message.
+	 * @param cause the cause.
+	 */
+	public AsyncAdapterBlockingExecutionFailedException(String errorMessage, Throwable cause) {
+		super(errorMessage, cause);
+	}
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AsyncComponentAdapters.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AsyncComponentAdapters.java
new file mode 100644
index 000000000..c2563002a
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AsyncComponentAdapters.java
@@ -0,0 +1,258 @@
+/*
+ * 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.CompletableFutures;
+import io.awspring.cloud.sqs.MessageExecutionThread;
+import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementResultCallback;
+import io.awspring.cloud.sqs.listener.acknowledgement.AsyncAcknowledgementResultCallback;
+import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler;
+import io.awspring.cloud.sqs.listener.errorhandler.ErrorHandler;
+import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor;
+import io.awspring.cloud.sqs.listener.interceptor.MessageInterceptor;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.function.Supplier;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.task.TaskExecutor;
+import org.springframework.messaging.Message;
+import org.springframework.util.Assert;
+
+/**
+ * Utility class for adapting blocking components to their asynchronous variants.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class AsyncComponentAdapters {
+
+	private static final Logger logger = LoggerFactory.getLogger(AsyncComponentAdapters.class);
+
+	private AsyncComponentAdapters() {
+	}
+
+	/**
+	 * Adapt the provided {@link ErrorHandler} to an {@link AsyncErrorHandler}
+	 * @param errorHandler the handler to be adapted
+	 * @param  the message payload type
+	 * @return the adapted component.
+	 */
+	public static  AsyncErrorHandler adapt(ErrorHandler errorHandler) {
+		return new BlockingErrorHandlerAdapter<>(errorHandler);
+	}
+
+	/**
+	 * Adapt the provided {@link MessageInterceptor} to an {@link AsyncMessageInterceptor}
+	 * @param messageInterceptor the interceptor to be adapted
+	 * @param  the message payload type
+	 * @return the adapted component.
+	 */
+	public static  AsyncMessageInterceptor adapt(MessageInterceptor messageInterceptor) {
+		return new BlockingMessageInterceptorAdapter<>(messageInterceptor);
+	}
+
+	/**
+	 * Adapt the provided {@link MessageListener} to an {@link AsyncMessageListener}
+	 * @param messageListener the listener to be adapted
+	 * @param  the message payload type
+	 * @return the adapted component.
+	 */
+	public static  AsyncMessageListener adapt(MessageListener messageListener) {
+		return new BlockingMessageListenerAdapter<>(messageListener);
+	}
+
+	public static  AsyncAcknowledgementResultCallback adapt(
+			AcknowledgementResultCallback acknowledgementResultCallback) {
+		return new BlockingAcknowledgementResultCallbackAdapter<>(acknowledgementResultCallback);
+	}
+
+	/**
+	 * Base class for BlockingComponentAdapters.
+	 * @see io.awspring.cloud.sqs.MessageExecutionThreadFactory
+	 * @see io.awspring.cloud.sqs.MessageExecutionThread
+	 */
+	protected static class AbstractThreadingComponentAdapter implements TaskExecutorAware {
+
+		private TaskExecutor taskExecutor;
+
+		@Override
+		public void setTaskExecutor(TaskExecutor taskExecutor) {
+			this.taskExecutor = taskExecutor;
+		}
+
+		protected  CompletableFuture execute(Supplier executable) {
+			if (Thread.currentThread() instanceof MessageExecutionThread) {
+				logger.trace("Already in a {}, not switching", MessageExecutionThread.class.getSimpleName());
+				return supplyInSameThread(executable);
+			}
+			logger.trace("Not in a {}, submitting to executor", MessageExecutionThread.class.getSimpleName());
+			Assert.notNull(this.taskExecutor, "Task executor not set");
+			return supplyInNewThread(executable);
+		}
+
+		protected CompletableFuture execute(Runnable executable) {
+			if (Thread.currentThread() instanceof MessageExecutionThread) {
+				logger.trace("Already in a {}, not switching", MessageExecutionThread.class.getSimpleName());
+				return runInSameThread(executable);
+			}
+			logger.trace("Not in a {}, submitting to executor", MessageExecutionThread.class.getSimpleName());
+			Assert.notNull(this.taskExecutor, "Task executor not set");
+			return runInNewThread(executable);
+		}
+
+		private CompletableFuture runInSameThread(Runnable blockingProcess) {
+			try {
+				blockingProcess.run();
+				return CompletableFuture.completedFuture(null);
+			}
+			catch (Exception e) {
+				return CompletableFutures.failedFuture(wrapWithBlockingException(e));
+			}
+		}
+
+		private CompletableFuture runInNewThread(Runnable blockingProcess) {
+			try {
+				return CompletableFutures.exceptionallyCompose(
+						CompletableFuture.runAsync(blockingProcess, this.taskExecutor),
+						t -> CompletableFutures.failedFuture(wrapWithBlockingException(t)));
+			}
+			catch (Exception e) {
+				return CompletableFutures.failedFuture(wrapWithBlockingException(e));
+			}
+		}
+
+		private  CompletableFuture supplyInSameThread(Supplier blockingProcess) {
+			try {
+				return CompletableFuture.completedFuture(blockingProcess.get());
+			}
+			catch (Exception e) {
+				return CompletableFutures.failedFuture(wrapWithBlockingException(e));
+			}
+		}
+
+		private  CompletableFuture supplyInNewThread(Supplier blockingProcess) {
+			try {
+				return CompletableFutures.exceptionallyCompose(
+						CompletableFuture.supplyAsync(blockingProcess, this.taskExecutor),
+						t -> CompletableFutures.failedFuture(wrapWithBlockingException(t)));
+			}
+			catch (Exception e) {
+				return CompletableFutures.failedFuture(wrapWithBlockingException(e));
+			}
+		}
+
+		private AsyncAdapterBlockingExecutionFailedException wrapWithBlockingException(Throwable t) {
+			return new AsyncAdapterBlockingExecutionFailedException(
+					"Error executing action in " + this.getClass().getSimpleName(),
+					t instanceof CompletionException ? t.getCause() : t);
+		}
+
+	}
+
+	private static class BlockingMessageInterceptorAdapter extends AbstractThreadingComponentAdapter
+			implements AsyncMessageInterceptor {
+
+		private final MessageInterceptor blockingMessageInterceptor;
+
+		public BlockingMessageInterceptorAdapter(MessageInterceptor blockingMessageInterceptor) {
+			this.blockingMessageInterceptor = blockingMessageInterceptor;
+		}
+
+		@Override
+		public CompletableFuture> intercept(Message message) {
+			return execute(() -> this.blockingMessageInterceptor.intercept(message));
+		}
+
+		@Override
+		public CompletableFuture>> intercept(Collection> messages) {
+			return execute(() -> this.blockingMessageInterceptor.intercept(messages));
+		}
+
+		@Override
+		public CompletableFuture afterProcessing(Message message, Throwable t) {
+			return execute(() -> this.blockingMessageInterceptor.afterProcessing(message, t));
+		}
+
+		@Override
+		public CompletableFuture afterProcessing(Collection> messages, Throwable t) {
+			return execute(() -> this.blockingMessageInterceptor.afterProcessing(messages, t));
+		}
+	}
+
+	private static class BlockingMessageListenerAdapter extends AbstractThreadingComponentAdapter
+			implements AsyncMessageListener {
+
+		private final MessageListener blockingMessageListener;
+
+		public BlockingMessageListenerAdapter(MessageListener blockingMessageListener) {
+			this.blockingMessageListener = blockingMessageListener;
+		}
+
+		@Override
+		public CompletableFuture onMessage(Message message) {
+			return execute(() -> this.blockingMessageListener.onMessage(message));
+		}
+
+		@Override
+		public CompletableFuture onMessage(Collection> messages) {
+			return execute(() -> this.blockingMessageListener.onMessage(messages));
+		}
+	}
+
+	private static class BlockingErrorHandlerAdapter extends AbstractThreadingComponentAdapter
+			implements AsyncErrorHandler {
+
+		private final ErrorHandler blockingErrorHandler;
+
+		public BlockingErrorHandlerAdapter(ErrorHandler blockingErrorHandler) {
+			this.blockingErrorHandler = blockingErrorHandler;
+		}
+
+		@Override
+		public CompletableFuture handle(Message message, Throwable t) {
+			return execute(() -> this.blockingErrorHandler.handle(message, t));
+		}
+
+		@Override
+		public CompletableFuture handle(Collection> messages, Throwable t) {
+			return execute(() -> this.blockingErrorHandler.handle(messages, t));
+		}
+
+	}
+
+	private static class BlockingAcknowledgementResultCallbackAdapter extends AbstractThreadingComponentAdapter
+			implements AsyncAcknowledgementResultCallback {
+
+		private final AcknowledgementResultCallback blockingAcknowledgementResultCallback;
+
+		public BlockingAcknowledgementResultCallbackAdapter(
+				AcknowledgementResultCallback blockingAcknowledgementResultCallback) {
+			this.blockingAcknowledgementResultCallback = blockingAcknowledgementResultCallback;
+		}
+
+		@Override
+		public CompletableFuture onSuccess(Collection> messages) {
+			return execute(() -> this.blockingAcknowledgementResultCallback.onSuccess(messages));
+		}
+
+		@Override
+		public CompletableFuture onFailure(Collection> messages, Throwable t) {
+			return execute(() -> this.blockingAcknowledgementResultCallback.onFailure(messages, t));
+		}
+	}
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AsyncMessageListener.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AsyncMessageListener.java
new file mode 100644
index 000000000..b0a2e3e6b
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/AsyncMessageListener.java
@@ -0,0 +1,51 @@
+/*
+ * 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.CompletableFutures;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.springframework.messaging.Message;
+
+/**
+ * Async listener to process individual {@link Message} instances.
+ *
+ * @param  the {@link Message} payload type.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+@FunctionalInterface
+public interface AsyncMessageListener {
+
+	/**
+	 * Process the provided message.
+	 * @param message the message.
+	 * @return a completable future.
+	 */
+	CompletableFuture onMessage(Message message);
+
+	/**
+	 * Process the provided messages.
+	 * @param messages the messages.
+	 * @return a completable future.
+	 */
+	default CompletableFuture onMessage(Collection> messages) {
+		return CompletableFutures
+				.failedFuture(new UnsupportedOperationException("Batch not implemented by this AsyncMessageListener"));
+	}
+
+}
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
new file mode 100644
index 000000000..1d76d6589
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureHandler.java
@@ -0,0 +1,55 @@
+/*
+ * 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;
+
+/**
+ * Abstraction to handle backpressure within a {@link io.awspring.cloud.sqs.listener.source.PollingMessageSource}.
+ *
+ * Release methods must be thread-safe so that many messages can be processed asynchronously. Example strategies are
+ * semaphore-based, rate limiter-based, a mix of both, or any other.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public interface BackPressureHandler {
+
+	/**
+	 * Request 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.
+	 * @throws InterruptedException if the Thread is interrupted while waiting for permits.
+	 */
+	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.
+	 * @param amount the amount of permits to release.
+	 */
+	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}.
+	 * @param timeout the maximum amount of time to wait for all permits to be released.
+	 * @return whether all permits were acquired.
+	 */
+	boolean drain(Duration timeout);
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureMode.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureMode.java
new file mode 100644
index 000000000..84933b171
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BackPressureMode.java
@@ -0,0 +1,60 @@
+/*
+ * 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;
+
+/**
+ * Configuration for application throughput.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public enum BackPressureMode {
+
+	/**
+	 * Enable automatic throughput switching and partial batch polling.
+	 * 

+ * Starts in a low throughput mode where only one poll is made at a time. When a message is received, switches to + * HIGH throughput mode. If a poll returns empty and there are no inflight messages, switches back to low throughput + * mode, and so forth. + *

+ * If the current number of inflight messages is close to {@link ContainerOptions#getMaxInFlightMessagesPerQueue()}, + * the framework will try to acquire a partial batch with the remaining value. + *

+ * This is the default setting and should be balanced for most applications. + */ + AUTO, + + /** + * Enable automatic throughput switching and disable partial batch polling. + *

+ * If the current number of inflight messages is close to {@link ContainerOptions#getMaxInFlightMessagesPerQueue()}, + * the framework will wait until a full batch can be polled. + *

+ * Useful for scenarios where the cost of retrieving less messages in a poll, and consequentially making more polls, + * is higher than the cost of waiting for more messages to be processed. + */ + ALWAYS_POLL_MAX_MESSAGES, + + /** + * Set fixed high throughput mode. In this mode up to (maxInflightMessages / messagesPerPoll) simultaneous polls + * will be made until maxInflightMessages is achieved. + *

+ * Useful for really high-throughput scenarios where the occasional automatic switch to a lower throughput would be + * costly. + */ + FIXED_HIGH_THROUGHPUT + +} 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 new file mode 100644 index 000000000..bce0e1d73 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/BatchAwareBackPressureHandler.java @@ -0,0 +1,41 @@ +/* + * 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; + +/** + * {@link BackPressureHandler} specialization that allows requesting and releasing batches. Batch size should be + * configured by the implementations. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public interface BatchAwareBackPressureHandler extends BackPressureHandler { + + /** + * Request a batch of permits. + * @return the number of permits acquired. + * @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(); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConfigurableContainerComponent.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConfigurableContainerComponent.java new file mode 100644 index 000000000..36ed632ba --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ConfigurableContainerComponent.java @@ -0,0 +1,34 @@ +/* + * 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; + +/** + * Representation of a {@link MessageListenerContainer} component that can be configured using a + * {@link ContainerOptions} instance. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public interface ConfigurableContainerComponent { + + /** + * Configure the component with the provided {@link ContainerOptions} instance + * @param containerOptions + */ + default void configure(ContainerOptions containerOptions) { + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerComponentFactory.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerComponentFactory.java new file mode 100644 index 000000000..5e9bcd647 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerComponentFactory.java @@ -0,0 +1,88 @@ +/* + * 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.acknowledgement.AcknowledgementProcessor; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementHandler; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementMode; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.AlwaysAcknowledgementHandler; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.NeverAcknowledgementHandler; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.OnSuccessAcknowledgementHandler; +import io.awspring.cloud.sqs.listener.sink.MessageSink; +import io.awspring.cloud.sqs.listener.source.MessageSource; +import java.util.Collection; + +/** + * A factory for creating components for the {@link MessageListenerContainer}. Implementations can instantiate and + * configure each component according to its strategies, using the provided {@link ContainerOptions}. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public interface ContainerComponentFactory { + + /** + * Whether this factory supports the given queues based on the queue names. + * @param queueNames the queueNames. + * @param options {@link ContainerOptions} instance for evaluating support. + * @return true if the queues are supported. + */ + default boolean supports(Collection queueNames, ContainerOptions options) { + return true; + } + + /** + * Create a {@link MessageSource} instance. + * @param options {@link ContainerOptions} instance for determining instance type and configuring. + * @return the instance. + */ + MessageSource createMessageSource(ContainerOptions options); + + /** + * Create a {@link MessageSink} instance. + * @param options {@link ContainerOptions} instance for determining instance type and configuring. + * @return the instance. + */ + MessageSink createMessageSink(ContainerOptions options); + + /** + * Create an {@link AcknowledgementProcessor} instance. + * @param options {@link ContainerOptions} instance for determining instance type and configuring. + * @return the instance. + */ + default AcknowledgementProcessor createAcknowledgementProcessor(ContainerOptions options) { + throw new UnsupportedOperationException("AcknowledgementProcessor support not implemented by this " + + ContainerComponentFactory.class.getSimpleName()); + } + + // @formatter:off + + /** + * Create a {@link AcknowledgementHandler} instance based on the given {@link ContainerOptions} + * @param options the {@link ContainerOptions} instance + * @return the instance. + */ + default AcknowledgementHandler createAcknowledgementHandler(ContainerOptions options) { + AcknowledgementMode mode = options.getAcknowledgementMode(); + return AcknowledgementMode.ON_SUCCESS.equals(mode) + ? new OnSuccessAcknowledgementHandler<>() + : AcknowledgementMode.ALWAYS.equals(mode) + ? new AlwaysAcknowledgementHandler<>() + : new NeverAcknowledgementHandler<>(); + } + // @formatter:on + +} 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 new file mode 100644 index 000000000..75d716171 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ContainerOptions.java @@ -0,0 +1,450 @@ +/* + * 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.acknowledgement.AcknowledgementOrdering; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementMode; +import io.awspring.cloud.sqs.support.converter.MessagingMessageConverter; +import io.awspring.cloud.sqs.support.converter.SqsMessagingMessageConverter; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.core.task.TaskExecutor; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; +import software.amazon.awssdk.services.sqs.model.MessageSystemAttributeName; +import software.amazon.awssdk.services.sqs.model.QueueAttributeName; + +/** + * Contains the options to be used by the {@link MessageListenerContainer} at runtime. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public class ContainerOptions { + + private final int maxInflightMessagesPerQueue; + + private final int maxMessagesPerPoll; + + private final Duration pollTimeout; + + private final Duration permitAcquireTimeout; + + private final Duration shutdownTimeout; + + private final BackPressureMode backPressureMode; + + private final ListenerMode listenerMode; + + private final Collection queueAttributeNames; + + private final Collection messageAttributeNames; + + private final Collection messageSystemAttributeNames; + + private final MessagingMessageConverter messageConverter; + + private final AcknowledgementMode acknowledgementMode; + + private final QueueNotFoundStrategy queueNotFoundStrategy; + + private final AcknowledgementOrdering acknowledgementOrdering; + + private final Duration acknowledgementInterval; + + private final Integer acknowledgementThreshold; + + private final TaskExecutor componentsTaskExecutor; + + private final Duration messageVisibility; + + protected ContainerOptions(Builder builder) { + this.maxInflightMessagesPerQueue = builder.maxInflightMessagesPerQueue; + this.maxMessagesPerPoll = builder.maxMessagesPerPoll; + this.pollTimeout = builder.pollTimeout; + this.permitAcquireTimeout = builder.permitAcquireTimeout; + this.shutdownTimeout = builder.shutdownTimeout; + this.backPressureMode = builder.backPressureMode; + this.listenerMode = builder.listenerMode; + this.queueAttributeNames = builder.queueAttributeNames; + this.messageAttributeNames = builder.messageAttributeNames; + this.messageSystemAttributeNames = builder.messageSystemAttributeNames; + this.messageConverter = builder.messageConverter; + this.acknowledgementMode = builder.acknowledgementMode; + this.queueNotFoundStrategy = builder.queueNotFoundStrategy; + this.acknowledgementOrdering = builder.acknowledgementOrdering; + this.acknowledgementInterval = builder.acknowledgementInterval; + this.acknowledgementThreshold = builder.acknowledgementThreshold; + this.componentsTaskExecutor = builder.componentsTaskExecutor; + this.messageVisibility = builder.messageVisibility; + } + + public static ContainerOptions.Builder builder() { + return new ContainerOptions.Builder(); + } + + /** + * Return the maximum allowed number of inflight messages for each queue. + * @return the number. + */ + public int getMaxInFlightMessagesPerQueue() { + return this.maxInflightMessagesPerQueue; + } + + /** + * Return the number of messages that should be returned per poll. + * @return the number. + */ + public int getMaxMessagesPerPoll() { + return this.maxMessagesPerPoll; + } + + /** + * Return the timeout for polling messages for this endpoint. + * @return the timeout duration. + */ + public Duration getPollTimeout() { + return this.pollTimeout; + } + + /** + * Return the maximum time the polling thread should wait for permits. + * @return the timeout. + */ + public Duration getPermitAcquireTimeout() { + return this.permitAcquireTimeout; + } + + public TaskExecutor getComponentsTaskExecutor() { + return this.componentsTaskExecutor; + } + + public Duration getShutdownTimeout() { + return this.shutdownTimeout; + } + + public BackPressureMode getBackPressureMode() { + return this.backPressureMode; + } + + public ListenerMode getListenerMode() { + return this.listenerMode; + } + + public Collection getQueueAttributeNames() { + return this.queueAttributeNames; + } + + public Collection getMessageAttributeNames() { + return this.messageAttributeNames; + } + + public Collection getMessageSystemAttributeNames() { + return this.messageSystemAttributeNames; + } + + public Duration getMessageVisibility() { + return this.messageVisibility; + } + + public MessagingMessageConverter getMessageConverter() { + return this.messageConverter; + } + + public Duration getAcknowledgementInterval() { + return this.acknowledgementInterval; + } + + public Integer getAcknowledgementThreshold() { + return this.acknowledgementThreshold; + } + + public AcknowledgementMode getAcknowledgementMode() { + return this.acknowledgementMode; + } + + public AcknowledgementOrdering getAcknowledgementOrdering() { + return this.acknowledgementOrdering; + } + + public QueueNotFoundStrategy getQueueNotFoundStrategy() { + return this.queueNotFoundStrategy; + } + + public ContainerOptions configure(ConfigurableContainerComponent configurable) { + configurable.configure(this); + return this; + } + + public ContainerOptions configure(Collection configurables) { + configurables.forEach(this::configure); + return this; + } + + public ContainerOptions createCopy() { + ContainerOptions newCopy = ContainerOptions.builder().build(); + ReflectionUtils.shallowCopyFieldState(this, newCopy); + return newCopy; + } + + public Builder toBuilder() { + return new Builder(this); + } + + public static class Builder { + + private static final int DEFAULT_MAX_INFLIGHT_MSG_PER_QUEUE = 10; + + private static final int DEFAULT_MAX_MESSAGES_PER_POLL = 10; + + private static final Duration DEFAULT_POLL_TIMEOUT = Duration.ofSeconds(10); + + private static final Duration DEFAULT_SEMAPHORE_TIMEOUT = Duration.ofSeconds(10); + + private static final Duration DEFAULT_SHUTDOWN_TIMEOUT = Duration.ofSeconds(20); + + private static final BackPressureMode DEFAULT_THROUGHPUT_CONFIGURATION = BackPressureMode.AUTO; + + private static final ListenerMode DEFAULT_MESSAGE_DELIVERY_STRATEGY = ListenerMode.SINGLE_MESSAGE; + + private static final List DEFAULT_QUEUE_ATTRIBUTES_NAMES = Collections.emptyList(); + + private static final List DEFAULT_MESSAGE_ATTRIBUTES_NAMES = Collections + .singletonList(QueueAttributeName.ALL.toString()); + + private static final List DEFAULT_MESSAGE_SYSTEM_ATTRIBUTES = Collections + .singletonList(QueueAttributeName.ALL.toString()); + + private static final MessagingMessageConverter DEFAULT_MESSAGE_CONVERTER = new SqsMessagingMessageConverter(); + + private static final AcknowledgementMode DEFAULT_ACKNOWLEDGEMENT_MODE = AcknowledgementMode.ON_SUCCESS; + + private static final QueueNotFoundStrategy DEFAULT_QUEUE_NOT_FOUND_STRATEGY = QueueNotFoundStrategy.CREATE; + + private int maxInflightMessagesPerQueue = DEFAULT_MAX_INFLIGHT_MSG_PER_QUEUE; + + private int maxMessagesPerPoll = DEFAULT_MAX_MESSAGES_PER_POLL; + + private Duration pollTimeout = DEFAULT_POLL_TIMEOUT; + + private Duration permitAcquireTimeout = DEFAULT_SEMAPHORE_TIMEOUT; + + private BackPressureMode backPressureMode = DEFAULT_THROUGHPUT_CONFIGURATION; + + private Duration shutdownTimeout = DEFAULT_SHUTDOWN_TIMEOUT; + + private ListenerMode listenerMode = DEFAULT_MESSAGE_DELIVERY_STRATEGY; + + private Collection queueAttributeNames = DEFAULT_QUEUE_ATTRIBUTES_NAMES; + + private Collection messageAttributeNames = DEFAULT_MESSAGE_ATTRIBUTES_NAMES; + + private Collection messageSystemAttributeNames = DEFAULT_MESSAGE_SYSTEM_ATTRIBUTES; + + private MessagingMessageConverter messageConverter = DEFAULT_MESSAGE_CONVERTER; + + private QueueNotFoundStrategy queueNotFoundStrategy = DEFAULT_QUEUE_NOT_FOUND_STRATEGY; + + private AcknowledgementMode acknowledgementMode = DEFAULT_ACKNOWLEDGEMENT_MODE; + + private AcknowledgementOrdering acknowledgementOrdering; + + private Duration acknowledgementInterval; + + private Integer acknowledgementThreshold; + + private TaskExecutor componentsTaskExecutor; + + private Duration messageVisibility; + + protected Builder() { + } + + protected Builder(ContainerOptions options) { + this.maxInflightMessagesPerQueue = options.maxInflightMessagesPerQueue; + this.maxMessagesPerPoll = options.maxMessagesPerPoll; + this.pollTimeout = options.pollTimeout; + this.permitAcquireTimeout = options.permitAcquireTimeout; + this.shutdownTimeout = options.shutdownTimeout; + this.backPressureMode = options.backPressureMode; + this.listenerMode = options.listenerMode; + this.queueAttributeNames = options.queueAttributeNames; + this.messageAttributeNames = options.messageAttributeNames; + this.messageSystemAttributeNames = options.messageSystemAttributeNames; + this.messageConverter = options.messageConverter; + this.acknowledgementMode = options.acknowledgementMode; + this.queueNotFoundStrategy = options.queueNotFoundStrategy; + this.acknowledgementOrdering = options.acknowledgementOrdering; + this.acknowledgementInterval = options.acknowledgementInterval; + this.acknowledgementThreshold = options.acknowledgementThreshold; + this.componentsTaskExecutor = options.componentsTaskExecutor; + this.messageVisibility = options.messageVisibility; + } + + /** + * Set the maximum allowed number of inflight messages for each queue. + * @return this instance. + */ + public Builder maxInflightMessagesPerQueue(int maxInflightMessagesPerQueue) { + Assert.isTrue(maxInflightMessagesPerQueue > 0, "maxInflightMessagesPerQueue must be greater than zero"); + this.maxInflightMessagesPerQueue = maxInflightMessagesPerQueue; + return this; + } + + /** + * Set the number of messages that should be returned per poll. If a value greater than 10 is provided, the + * result of multiple polls will be combined, which can be useful for + * {@link io.awspring.cloud.sqs.listener.ListenerMode#BATCH} + * @param maxMessagesPerPoll the number of messages. + * @return this instance. + */ + public Builder maxMessagesPerPoll(int maxMessagesPerPoll) { + this.maxMessagesPerPoll = maxMessagesPerPoll; + return this; + } + + /** + * Set the timeout for polling messages for this endpoint. + * @param pollTimeout the poll timeout. + * @return this instance. + */ + public Builder pollTimeout(Duration pollTimeout) { + Assert.notNull(pollTimeout, "pollTimeout cannot be null"); + this.pollTimeout = pollTimeout; + return this; + } + + /** + * Set the maximum time the polling thread should wait for permits. + * @param permitAcquireTimeout the timeout. + * @return this instance. + */ + public Builder permitAcquireTimeout(Duration permitAcquireTimeout) { + Assert.notNull(permitAcquireTimeout, "semaphoreAcquireTimeout cannot be null"); + this.permitAcquireTimeout = permitAcquireTimeout; + return this; + } + + public Builder listenerMode(ListenerMode listenerMode) { + Assert.notNull(listenerMode, "listenerMode cannot be null"); + this.listenerMode = listenerMode; + return this; + } + + public Builder componentsTaskExecutor(TaskExecutor taskExecutor) { + Assert.notNull(taskExecutor, "taskExecutor cannot be null"); + this.componentsTaskExecutor = taskExecutor; + return this; + } + + public Builder shutdownTimeout(Duration shutdownTimeout) { + Assert.notNull(shutdownTimeout, "shutdownTimeout cannot be null"); + this.shutdownTimeout = shutdownTimeout; + return this; + } + + public Builder backPressureMode(BackPressureMode backPressureMode) { + Assert.notNull(backPressureMode, "backPressureMode cannot be null"); + this.backPressureMode = backPressureMode; + return this; + } + + public Builder queueAttributeNames(Collection queueAttributeNames) { + Assert.notEmpty(queueAttributeNames, "queueAttributeNames cannot be empty"); + this.queueAttributeNames = Collections.unmodifiableCollection(new ArrayList<>(queueAttributeNames)); + return this; + } + + public Builder messageAttributeNames(Collection messageAttributeNames) { + Assert.notEmpty(messageAttributeNames, "messageAttributeNames cannot be empty"); + this.messageAttributeNames = Collections.unmodifiableCollection(new ArrayList<>(messageAttributeNames)); + return this; + } + + public Builder messageSystemAttributeNames(Collection messageSystemAttributeNames) { + Assert.notEmpty(messageSystemAttributeNames, "messageSystemAttributeNames cannot be empty"); + this.messageSystemAttributeNames = messageSystemAttributeNames.stream() + .map(MessageSystemAttributeName::toString).collect(Collectors.toList()); + return this; + } + + public Builder messageVisibility(Duration messageVisibility) { + Assert.notNull(messageVisibility, "messageVisibility cannot be null"); + this.messageVisibility = messageVisibility; + return this; + } + + public Builder acknowledgementInterval(Duration acknowledgementInterval) { + Assert.notNull(acknowledgementInterval, "acknowledgementInterval cannot be null"); + this.acknowledgementInterval = acknowledgementInterval; + return this; + } + + public Builder acknowledgementThreshold(int acknowledgementThreshold) { + Assert.isTrue(acknowledgementThreshold >= 0, + "acknowledgementThreshold must be greater than or equal to zero"); + this.acknowledgementThreshold = acknowledgementThreshold; + return this; + } + + public Builder acknowledgementMode(AcknowledgementMode acknowledgementMode) { + Assert.notNull(acknowledgementMode, "acknowledgementMode cannot be null"); + this.acknowledgementMode = acknowledgementMode; + return this; + } + + public Builder acknowledgementOrdering(AcknowledgementOrdering acknowledgementOrdering) { + Assert.notNull(acknowledgementOrdering, "acknowledgementOrdering cannot be null"); + this.acknowledgementOrdering = acknowledgementOrdering; + return this; + } + + public Builder messageConverter(MessagingMessageConverter messageConverter) { + Assert.notNull(messageConverter, "messageConverter cannot be null"); + this.messageConverter = messageConverter; + return this; + } + + public Builder queueNotFoundStrategy(QueueNotFoundStrategy queueNotFoundStrategy) { + Assert.notNull(queueNotFoundStrategy, "queueNotFoundStrategy cannot be null"); + this.queueNotFoundStrategy = queueNotFoundStrategy; + return this; + } + + public ContainerOptions build() { + Assert.isTrue(this.maxMessagesPerPoll <= maxInflightMessagesPerQueue, String.format( + "messagesPerPoll should be less than or equal to maxInflightMessagesPerQueue. Values provided: %s and %s respectively", + this.maxMessagesPerPoll, this.maxInflightMessagesPerQueue)); + return new ContainerOptions(this); + } + + public ContainerOptions.Builder createCopy() { + ContainerOptions.Builder newCopy = ContainerOptions.builder(); + ReflectionUtils.shallowCopyFieldState(this, newCopy); + return newCopy; + } + + public void fromBuilder(Builder builder) { + ReflectionUtils.shallowCopyFieldState(builder, this); + } + + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/DefaultListenerContainerRegistry.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/DefaultListenerContainerRegistry.java new file mode 100644 index 000000000..1bfed9a8b --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/DefaultListenerContainerRegistry.java @@ -0,0 +1,97 @@ +/* + * 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.LifecycleHandler; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * {@link MessageListenerContainerRegistry} implementation that registers the {@link MessageListenerContainer} instances + * and manage their lifecycle. + * + * This bean can be autowired and used to lookup container instances at runtime, which can be useful to e.g. manually + * manage their lifecycle. + * + * A {@link LifecycleHandler} is used to manage the containers' lifecycle. + * + * Only containers created via {@link io.awspring.cloud.sqs.annotation.SqsListener} annotations are registered by the + * framework. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public class DefaultListenerContainerRegistry implements MessageListenerContainerRegistry { + + private static final Logger logger = LoggerFactory.getLogger(DefaultListenerContainerRegistry.class); + + private final Map> listenerContainers = new ConcurrentHashMap<>(); + + private final Object lifecycleMonitor = new Object(); + + private volatile boolean running = false; + + @Override + public void registerListenerContainer(MessageListenerContainer listenerContainer) { + Assert.isTrue(getContainerById(listenerContainer.getId()) == null, + () -> "Already registered container with id " + listenerContainer.getId()); + logger.debug("Registering listener container {}", listenerContainer.getId()); + this.listenerContainers.put(listenerContainer.getId(), listenerContainer); + } + + @Override + public Collection> getListenerContainers() { + return Collections.unmodifiableCollection(this.listenerContainers.values()); + } + + @Nullable + @Override + public MessageListenerContainer getContainerById(String id) { + Assert.notNull(id, "id cannot be null."); + return this.listenerContainers.get(id); + } + + @Override + public void start() { + synchronized (this.lifecycleMonitor) { + logger.debug("Starting {}", getClass().getSimpleName()); + LifecycleHandler.get().start(this.listenerContainers.values()); + this.running = true; + logger.debug("{} started", getClass().getSimpleName()); + } + } + + @Override + public void stop() { + synchronized (this.lifecycleMonitor) { + logger.debug("Stopping {}", getClass().getSimpleName()); + this.running = false; + LifecycleHandler.get().stop(this.listenerContainers.values()); + } + } + + @Override + public boolean isRunning() { + return this.running; + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/FifoSqsComponentFactory.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/FifoSqsComponentFactory.java new file mode 100644 index 000000000..fe6e44493 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/FifoSqsComponentFactory.java @@ -0,0 +1,171 @@ +/* + * 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.ConfigUtils; +import io.awspring.cloud.sqs.MessageHeaderUtils; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementOrdering; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementProcessor; +import io.awspring.cloud.sqs.listener.acknowledgement.BatchingAcknowledgementProcessor; +import io.awspring.cloud.sqs.listener.acknowledgement.ImmediateAcknowledgementProcessor; +import io.awspring.cloud.sqs.listener.sink.BatchMessageSink; +import io.awspring.cloud.sqs.listener.sink.MessageSink; +import io.awspring.cloud.sqs.listener.sink.OrderedMessageSink; +import io.awspring.cloud.sqs.listener.sink.adapter.MessageGroupingSinkAdapter; +import io.awspring.cloud.sqs.listener.sink.adapter.MessageVisibilityExtendingSinkAdapter; +import io.awspring.cloud.sqs.listener.source.MessageSource; +import io.awspring.cloud.sqs.listener.source.SqsMessageSource; +import java.time.Duration; +import java.util.Collection; +import java.util.function.Function; +import org.springframework.messaging.Message; +import org.springframework.util.Assert; +import software.amazon.awssdk.services.sqs.model.MessageSystemAttributeName; +import software.amazon.awssdk.services.sqs.model.QueueAttributeName; + +/** + * {@link ContainerComponentFactory} implementation for creating components for FIFO queues. + * + * @author Tomaz Fernandes + * @since 3.0 + * @see StandardSqsComponentFactory + */ +public class FifoSqsComponentFactory implements ContainerComponentFactory { + + // Defaults to immediate (sync) ack + private static final Duration DEFAULT_FIFO_SQS_ACK_INTERVAL = Duration.ZERO; + + private static final Integer DEFAULT_FIFO_SQS_ACK_THRESHOLD = 0; + + // Since immediate acks hold the thread until done, we can execute in parallel and use processing order + private static final AcknowledgementOrdering DEFAULT_FIFO_SQS_ACK_ORDERING_IMMEDIATE = AcknowledgementOrdering.PARALLEL; + + private static final AcknowledgementOrdering DEFAULT_FIFO_SQS_ACK_ORDERING_BATCHING = AcknowledgementOrdering.ORDERED; + + @Override + public boolean supports(Collection queueNames, ContainerOptions options) { + return queueNames.stream().allMatch(name -> name.endsWith(".fifo")); + } + + @Override + public MessageSource createMessageSource(ContainerOptions options) { + return new SqsMessageSource<>(); + } + + @Override + public MessageSink createMessageSink(ContainerOptions options) { + MessageSink deliverySink = createDeliverySink(options.getListenerMode()); + return new MessageGroupingSinkAdapter<>( + maybeWrapWithVisibilityAdapter(deliverySink, options.getMessageVisibility()), + getMessageGroupingFunction()); + } + + // @formatter:off + private MessageSink createDeliverySink(ListenerMode listenerMode) { + return ListenerMode.SINGLE_MESSAGE.equals(listenerMode) + ? new OrderedMessageSink<>() + : new BatchMessageSink<>(); + } + + private MessageSink maybeWrapWithVisibilityAdapter(MessageSink deliverySink, Duration messageVisibility) { + return messageVisibility != null + ? addMessageVisibilityExtendingSinkAdapter(deliverySink, messageVisibility) + : deliverySink; + } + + private MessageVisibilityExtendingSinkAdapter addMessageVisibilityExtendingSinkAdapter( + MessageSink deliverySink, Duration messageVisibility) { + MessageVisibilityExtendingSinkAdapter visibilityAdapter = new MessageVisibilityExtendingSinkAdapter<>( + deliverySink); + visibilityAdapter.setMessageVisibility(messageVisibility); + return visibilityAdapter; + } + + private Function, String> getMessageGroupingFunction() { + return message -> MessageHeaderUtils.getHeaderAsString(message, SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER); + } + + @Override + public AcknowledgementProcessor createAcknowledgementProcessor(ContainerOptions options) { + validateFifoOptions(options); + return hasNoAcknowledgementIntervalSet(options) && hasNoAcknowledgementThresholdSet(options) + ? createAndConfigureImmediateProcessor(options) + : createAndConfigureBatchingAckProcessor(options); + } + + private void validateFifoOptions(ContainerOptions options) { + Assert.isTrue(options.getMessageSystemAttributeNames().contains(QueueAttributeName.ALL.toString()) + || options.getMessageSystemAttributeNames().contains(MessageSystemAttributeName.MESSAGE_GROUP_ID.toString()) + , "MessageSystemAttributeName.MESSAGE_GROUP_ID is required for FIFO queues."); + } + + private boolean hasNoAcknowledgementThresholdSet(ContainerOptions options) { + return options.getAcknowledgementThreshold() == null || DEFAULT_FIFO_SQS_ACK_THRESHOLD.equals(options.getAcknowledgementThreshold()); + } + + private boolean hasNoAcknowledgementIntervalSet(ContainerOptions options) { + return options.getAcknowledgementInterval() == null || DEFAULT_FIFO_SQS_ACK_INTERVAL.equals(options.getAcknowledgementInterval()); + } + // @formatter:on + + private ImmediateAcknowledgementProcessor createAndConfigureImmediateProcessor(ContainerOptions options) { + return configureImmediateProcessor(createImmediateProcessorInstance(), options); + } + + private BatchingAcknowledgementProcessor createAndConfigureBatchingAckProcessor(ContainerOptions options) { + return configureBatchingAckProcessor(options, createBatchingProcessorInstance()); + } + + protected ImmediateAcknowledgementProcessor createImmediateProcessorInstance() { + return new ImmediateAcknowledgementProcessor<>(); + } + + protected BatchingAcknowledgementProcessor createBatchingProcessorInstance() { + return new BatchingAcknowledgementProcessor<>(); + } + + protected ImmediateAcknowledgementProcessor configureImmediateProcessor( + ImmediateAcknowledgementProcessor processor, ContainerOptions options) { + processor.setMaxAcknowledgementsPerBatch(10); + if (AcknowledgementOrdering.ORDERED_BY_GROUP.equals(options.getAcknowledgementOrdering())) { + processor.setMessageGroupingFunction(getMessageGroupingFunction()); + } + ContainerOptions.Builder builder = options.toBuilder(); + ConfigUtils.INSTANCE.acceptIfNotNullOrElse(builder::acknowledgementOrdering, + options.getAcknowledgementOrdering(), DEFAULT_FIFO_SQS_ACK_ORDERING_IMMEDIATE); + processor.configure(builder.build()); + return processor; + } + + protected BatchingAcknowledgementProcessor configureBatchingAckProcessor(ContainerOptions options, + BatchingAcknowledgementProcessor processor) { + ContainerOptions.Builder builder = options.toBuilder(); + ConfigUtils.INSTANCE + .acceptIfNotNullOrElse(builder::acknowledgementInterval, options.getAcknowledgementInterval(), + DEFAULT_FIFO_SQS_ACK_INTERVAL) + .acceptIfNotNullOrElse(builder::acknowledgementThreshold, options.getAcknowledgementThreshold(), + DEFAULT_FIFO_SQS_ACK_THRESHOLD) + .acceptIfNotNullOrElse(builder::acknowledgementOrdering, options.getAcknowledgementOrdering(), + DEFAULT_FIFO_SQS_ACK_ORDERING_BATCHING); + processor.setMaxAcknowledgementsPerBatch(10); + if (AcknowledgementOrdering.ORDERED_BY_GROUP.equals(options.getAcknowledgementOrdering())) { + processor.setMessageGroupingFunction(getMessageGroupingFunction()); + } + processor.configure(builder.build()); + return processor; + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/IdentifiableContainerComponent.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/IdentifiableContainerComponent.java new file mode 100644 index 000000000..17ab3dd98 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/IdentifiableContainerComponent.java @@ -0,0 +1,38 @@ +/* + * 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; + +/** + * Representation of a component that can be assigned an id. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public interface IdentifiableContainerComponent { + + /** + * Set the component id. + * @param id the id. + */ + void setId(String id); + + /** + * Get the component id. + * @return the id. + */ + String getId(); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ListenerExecutionFailedException.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ListenerExecutionFailedException.java new file mode 100644 index 000000000..3ad0e57c1 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ListenerExecutionFailedException.java @@ -0,0 +1,122 @@ +/* + * 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.util.Collection; +import java.util.Collections; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.util.Assert; + +/** + * Exception thrown when the {@link AsyncMessageListener} completes with an exception. Contains the {@link Message} + * instance or instances which execution failed, as well as some convenience methods for handling such messages. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public class ListenerExecutionFailedException extends RuntimeException { + + private static final Logger logger = LoggerFactory.getLogger(ListenerExecutionFailedException.class); + + private final Collection> failedMessages; + + public ListenerExecutionFailedException(String message, Throwable cause, Message failedMessage) { + super(message, cause); + this.failedMessages = Collections.singletonList(failedMessage); + } + + public ListenerExecutionFailedException(String message, Throwable cause, + Collection> failedMessages) { + super(message, cause); + this.failedMessages = failedMessages.stream().map(msg -> (Message) msg).collect(Collectors.toList()); + } + + /** + * Return the message which listener execution failed. + * @return the message. + */ + public Message getFailedMessage() { + Assert.isTrue(this.failedMessages.size() == 1, () -> "Not a unique failed message: " + this.failedMessages); + return this.failedMessages.iterator().next(); + } + + /** + * Return the messages which listener execution failed. + * @return the messages. + */ + public Collection> getFailedMessages() { + return this.failedMessages; + } + + /** + * Look for a potentially nested {@link ListenerExecutionFailedException} and if found return the wrapped + * {@link Message} instance. + * @param t the throwable + * @param the message type. + * @return the message. + */ + // @formatter:off + @SuppressWarnings("unchecked") + @Nullable + public static Message unwrapMessage(Throwable t) { + Throwable exception = findListenerException(t); + return t == null + ? null + : exception != null + ? (Message) ((ListenerExecutionFailedException) exception).getFailedMessage() + : (Message) wrapAndRethrowError(t); + } + + /** + * Look for a potentially nested {@link ListenerExecutionFailedException} and if found return the wrapped {@link Message} instances. + * @param t the throwable + * @param the message type. + * @return the messages. + */ + @SuppressWarnings("unchecked") + @Nullable + public static Collection> unwrapMessages(Throwable t) { + Throwable exception = findListenerException(t); + return t == null + ? null + : exception != null + ? ((ListenerExecutionFailedException) exception).getFailedMessages().stream().map(msg -> (Message) msg).collect(Collectors.toList()) + : (Collection>) wrapAndRethrowError(t); + } + + @Nullable + private static Throwable findListenerException(Throwable t) { + return t == null + ? null + : t instanceof ListenerExecutionFailedException + ? t + : findListenerException(t.getCause()); + } + // @formatter:on + + private static Object wrapAndRethrowError(Throwable t) { + throw new IllegalArgumentException("No ListenerExecutionFailedException found to unwrap messages.", t); + } + + public static boolean hasListenerException(Throwable t) { + return findListenerException(t) != null; + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ListenerMode.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ListenerMode.java new file mode 100644 index 000000000..5cd8d84f5 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/ListenerMode.java @@ -0,0 +1,39 @@ +/* + * 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; + +/** + * Configure the delivery strategy to be used by a {@link MessageListenerContainer}. + * + * @author Tomaz Fernandes + * @since 3.0 + * @see io.awspring.cloud.sqs.listener.sink.FanOutMessageSink + * @see io.awspring.cloud.sqs.listener.sink.OrderedMessageSink + * @see io.awspring.cloud.sqs.listener.sink.BatchMessageSink + */ +public enum ListenerMode { + + /** + * Configure the container to receive one message at a time in its components. + */ + SINGLE_MESSAGE, + + /** + * Configure the container to receive the whole batch of messages in its components. + */ + BATCH + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/MessageListener.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/MessageListener.java new file mode 100644 index 000000000..4c5542d10 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/MessageListener.java @@ -0,0 +1,38 @@ +/* + * 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.util.Collection; +import org.springframework.messaging.Message; + +/** + * Interface to process incoming {@link Message}s. + * + * @param the {@link Message} payload type. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +@FunctionalInterface +public interface MessageListener { + + void onMessage(Message message); + + default void onMessage(Collection> messages) { + throw new UnsupportedOperationException("Batch not implemented by this MessageListener"); + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/MessageListenerContainer.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/MessageListenerContainer.java new file mode 100644 index 000000000..02bc862bc --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/MessageListenerContainer.java @@ -0,0 +1,51 @@ +/* + * 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 org.springframework.context.SmartLifecycle; +import org.springframework.messaging.Message; + +/** + * A container for an {@link AsyncMessageListener} with {@link SmartLifecycle} capabilities. + * + * @param the {@link Message} payload type. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public interface MessageListenerContainer extends SmartLifecycle { + + /** + * Get the container id. + * @return the id. + */ + String getId(); + + void setId(String id); + + /** + * Set the listener to be used to process messages. + * @param messageListener the instance. + */ + void setMessageListener(MessageListener messageListener); + + /** + * Set the listener to be used to receive messages. + * @param asyncMessageListener the message listener instance. + */ + void setAsyncMessageListener(AsyncMessageListener asyncMessageListener); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/MessageListenerContainerRegistry.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/MessageListenerContainerRegistry.java new file mode 100644 index 000000000..cc91559fe --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/MessageListenerContainerRegistry.java @@ -0,0 +1,52 @@ +/* + * 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.util.Collection; +import org.springframework.context.SmartLifecycle; +import org.springframework.lang.Nullable; + +/** + * Interface for registering and looking up containers at startup and runtime. + * + * @author Tomaz Fernandes + * @since 3.0 + * @see DefaultListenerContainerRegistry + */ +public interface MessageListenerContainerRegistry extends SmartLifecycle { + + /** + * Register a {@link MessageListenerContainer} instance with this registry. + * @param listenerContainer the instance. + */ + void registerListenerContainer(MessageListenerContainer listenerContainer); + + /** + * Return the {@link MessageListenerContainer} instances registered within this registry. + * @return the container instances. + */ + Collection> getListenerContainers(); + + /** + * Return the {@link MessageListenerContainer} instance registered within this registry with the provided id, or + * null if none. + * @param id the id. + * @return the container instance. + */ + @Nullable + MessageListenerContainer getContainerById(String id); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/MessageProcessingContext.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/MessageProcessingContext.java new file mode 100644 index 000000000..569887e14 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/MessageProcessingContext.java @@ -0,0 +1,80 @@ +/* + * 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.acknowledgement.AcknowledgementCallback; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A message processing context that can be used for communication between components. This class must be immutable and + * thread-safe. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public class MessageProcessingContext { + + private final List> interceptors; + + private final Runnable backPressureReleaseCallback; + + private final AcknowledgementCallback acknowledgementCallback; + + private MessageProcessingContext(List> interceptors, + Runnable backPressureReleaseCallback, AcknowledgementCallback acknowledgementCallback) { + this.interceptors = Collections.unmodifiableList(interceptors); + this.backPressureReleaseCallback = backPressureReleaseCallback; + this.acknowledgementCallback = acknowledgementCallback; + } + + public MessageProcessingContext addInterceptor(AsyncMessageInterceptor interceptor) { + List> contextInterceptors = new ArrayList<>(this.interceptors); + contextInterceptors.add(interceptor); + return new MessageProcessingContext<>(contextInterceptors, this.backPressureReleaseCallback, + this.acknowledgementCallback); + } + + public MessageProcessingContext setBackPressureReleaseCallback(Runnable backPressureReleaseCallback) { + return new MessageProcessingContext<>(this.interceptors, backPressureReleaseCallback, + this.acknowledgementCallback); + } + + public MessageProcessingContext setAcknowledgmentCallback(AcknowledgementCallback acknowledgementCallback) { + return new MessageProcessingContext<>(this.interceptors, this.backPressureReleaseCallback, + acknowledgementCallback); + } + + public List> getInterceptors() { + return this.interceptors; + } + + public void runBackPressureReleaseCallback() { + this.backPressureReleaseCallback.run(); + } + + public AcknowledgementCallback getAcknowledgmentCallback() { + return this.acknowledgementCallback; + } + + public static MessageProcessingContext create() { + return new MessageProcessingContext<>(Collections.emptyList(), () -> { + }, new AcknowledgementCallback() { + }); + } +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueAttributes.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueAttributes.java new file mode 100644 index 000000000..2c56a1d9c --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueAttributes.java @@ -0,0 +1,88 @@ +/* + * 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.util.HashMap; +import java.util.Map; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import software.amazon.awssdk.services.sqs.model.QueueAttributeName; + +/** + * Queue attributes extracted from SQS, as well as the queue name and url. + * + * @author Tomaz Fernandes + * @since 3.0 + * @see ContainerOptions#getQueueAttributeNames(). + */ +public class QueueAttributes { + + private final String queueName; + + private final String queueUrl; + + private final Map attributes; + + /** + * Create an instance with the provided arguments. + * @param queueName the queue name. + * @param queueUrl the queue url. + * @param attributes the queue attributes retrieved from SQS. + */ + public QueueAttributes(String queueName, String queueUrl, Map attributes) { + Assert.notNull(queueName, "queueName cannot be null"); + Assert.notNull(queueUrl, "queueUrl cannot be null"); + Assert.notNull(attributes, "attributes cannot be null"); + this.queueName = queueName; + this.queueUrl = queueUrl; + this.attributes = attributes; + } + + /** + * Return the queue url. + * @return the url. + */ + public String getQueueUrl() { + return this.queueUrl; + } + + /** + * Return the queue name. + * @return the queue name. + */ + public String getQueueName() { + return this.queueName; + } + + /** + * Return the attributes for this queue. + * @return the queue attributes. + */ + public Map getQueueAttributes() { + return new HashMap<>(this.attributes); + } + + /** + * Return a specific attribute for this queue, if present. + * @param queueAttributeName the attribute name. + * @return the queue attribute, or null if no such attribute is present. + */ + @Nullable + public String getQueueAttribute(QueueAttributeName queueAttributeName) { + return this.attributes.get(queueAttributeName); + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueAttributesAware.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueAttributesAware.java new file mode 100644 index 000000000..6173d49d5 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueAttributesAware.java @@ -0,0 +1,32 @@ +/* + * 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; + +/** + * Implementations are enabled to receive a {@link QueueAttributes} instance. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public interface QueueAttributesAware { + + /** + * Set the {@link QueueAttributes} instance. + * @param queueAttributes the instance. + */ + void setQueueAttributes(QueueAttributes queueAttributes); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueAttributesResolver.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueAttributesResolver.java new file mode 100644 index 000000000..7f0c002e8 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueAttributesResolver.java @@ -0,0 +1,215 @@ +/* + * 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.CompletableFutures; +import io.awspring.cloud.sqs.QueueAttributesResolvingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.Assert; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.model.CreateQueueResponse; +import software.amazon.awssdk.services.sqs.model.GetQueueAttributesResponse; +import software.amazon.awssdk.services.sqs.model.GetQueueUrlResponse; +import software.amazon.awssdk.services.sqs.model.QueueAttributeName; +import software.amazon.awssdk.services.sqs.model.QueueDoesNotExistException; + +/** + * Resolves {@link QueueAttributes} for the specified queue. Fetchs the queue url for a queue name, unless a url is + * specified as name. If such queue is not found, either creates the queue or fails according to the specified + * {@link QueueNotFoundStrategy}. After the url is resolved, retrieves the queue attributes specified in the + * {@link QueueAttributeName} collection. + * + * @author Tomaz Fernandes + * @since 3.0 + * @see ContainerOptions#getQueueAttributeNames() + * @see ContainerOptions#getQueueNotFoundStrategy() + */ +public class QueueAttributesResolver { + + private static final Logger logger = LoggerFactory.getLogger(QueueAttributesResolver.class); + + private final String queueName; + + private final SqsAsyncClient sqsAsyncClient; + + private final Collection queueAttributeNames; + + private final QueueNotFoundStrategy queueNotFoundStrategy; + + private QueueAttributesResolver(Builder builder) { + this.queueName = builder.queueName; + this.sqsAsyncClient = builder.sqsAsyncClient; + this.queueAttributeNames = builder.queueAttributeNames; + this.queueNotFoundStrategy = builder.queueNotFoundStrategy; + } + + public static Builder builder() { + return new Builder(); + } + + // @formatter:off + public CompletableFuture resolveQueueAttributes() { + logger.debug("Resolving attributes for queue {}", this.queueName); + return CompletableFutures.exceptionallyCompose(resolveQueueUrl() + .thenCompose(queueUrl -> getQueueAttributes(queueUrl) + .thenApply(queueAttributes -> new QueueAttributes(this.queueName, queueUrl, queueAttributes))) + , this::wrapException); + } + + private CompletableFuture wrapException(Throwable t) { + return CompletableFutures.failedFuture(new QueueAttributesResolvingException("Error resolving attributes for queue " + + this.queueName + " with strategy " + this.queueNotFoundStrategy + " and queueAttributesNames " + this.queueAttributeNames, + t instanceof CompletionException ? t.getCause() : t)); + } + + private CompletableFuture resolveQueueUrl() { + return isValidQueueUrl(this.queueName) + ? CompletableFuture.completedFuture(this.queueName) + : doResolveQueueUrl(); + } + + private CompletableFuture doResolveQueueUrl() { + return CompletableFutures + .exceptionallyCompose(this.sqsAsyncClient.getQueueUrl(req -> req.queueName(this.queueName)) + .thenApply(GetQueueUrlResponse::queueUrl), this::handleException); + } + + private CompletableFuture handleException(Throwable t) { + return t.getCause() instanceof QueueDoesNotExistException + && QueueNotFoundStrategy.CREATE.equals(this.queueNotFoundStrategy) + ? createQueue() + : CompletableFutures.failedFuture(t); + } + + private CompletableFuture createQueue() { + return this.sqsAsyncClient.createQueue(req -> req.queueName(this.queueName).build()) + .thenApply(CreateQueueResponse::queueUrl) + .whenComplete(this::logCreateQueueResult); + } + + private void logCreateQueueResult(String v, Throwable t) { + if (t != null) { + logger.debug("Error creating queue {}", this.queueName, t); + return; + } + logger.debug("Created queue {} with url {}", this.queueName, v); + } + + private CompletableFuture> getQueueAttributes(String queueUrl) { + return this.queueAttributeNames.isEmpty() + ? CompletableFuture.completedFuture(Collections.emptyMap()) + : doGetAttributes(queueUrl); + } + + private CompletableFuture> doGetAttributes(String queueUrl) { + logger.debug("Resolving attributes {} for queue {}", this.queueAttributeNames, this.queueName); + return this.sqsAsyncClient + .getQueueAttributes(req -> req.queueUrl(queueUrl).attributeNames(this.queueAttributeNames)) + .thenApply(GetQueueAttributesResponse::attributes) + .whenComplete((v, t) -> logger.debug("Attributes for queue {} resolved", this.queueName)); + } + // @formatter:on + + private boolean isValidQueueUrl(String name) { + try { + URI candidate = new URI(name); + return ("http".equals(candidate.getScheme()) || "https".equals(candidate.getScheme())); + } + catch (URISyntaxException e) { + return false; + } + } + + /** + * A builder for creating {@link QueueAttributesResolver} instances. + */ + public static class Builder { + + private String queueName; + + private SqsAsyncClient sqsAsyncClient; + + private Collection queueAttributeNames; + + private QueueNotFoundStrategy queueNotFoundStrategy; + + /** + * The queue name. The queue url can also be specified. + * @param queueName the queue name. + * @return the builder instance. + */ + public Builder queueName(String queueName) { + Assert.notNull(queueName, "queueName cannot be null"); + this.queueName = queueName; + return this; + } + + /** + * The {@link SqsAsyncClient} to be used to resolve the queue attributes. + * @param sqsAsyncClient the client instance. + * @return the builder instance. + */ + public Builder sqsAsyncClient(SqsAsyncClient sqsAsyncClient) { + Assert.notNull(sqsAsyncClient, "sqsAsyncClient cannot be null"); + this.sqsAsyncClient = sqsAsyncClient; + return this; + } + + /** + * The {@link QueueAttributeName}s to be retrieved. + * @param queueAttributeNames the attributes names. + * @return the builder instance. + */ + public Builder queueAttributeNames(Collection queueAttributeNames) { + Assert.notNull(queueAttributeNames, "queueAttributeNames cannot be null"); + this.queueAttributeNames = queueAttributeNames; + return this; + } + + /** + * The strategy to be used in case a queue does not exist. + * @param queueNotFoundStrategy the strategy. + * @return the builder instance. + */ + public Builder queueNotFoundStrategy(QueueNotFoundStrategy queueNotFoundStrategy) { + Assert.notNull(queueNotFoundStrategy, "queueNotFoundStrategy cannot be null"); + this.queueNotFoundStrategy = queueNotFoundStrategy; + return this; + } + + /** + * Build the {@link QueueAttributesResolver} instance with the provided settings. + * @return the created instance. + */ + public QueueAttributesResolver build() { + Assert.noNullElements( + Arrays.asList(this.queueAttributeNames, this.queueNotFoundStrategy, this.queueName, + this.sqsAsyncClient), + "Incomplete configuration for QueueAttributesResolver - null attributes found"); + return new QueueAttributesResolver(this); + } + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueMessageVisibility.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueMessageVisibility.java new file mode 100644 index 000000000..768759943 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueMessageVisibility.java @@ -0,0 +1,60 @@ +/* + * Copyright 2013-2019 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.util.concurrent.CompletableFuture; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; + +/** + * {@link Visibility} implementation for SQS messages. + * + * @author Szymon Dembek + * @author Tomaz Fernandes + * @since 1.3 + */ +public class QueueMessageVisibility implements Visibility { + + private static final Logger logger = LoggerFactory.getLogger(QueueMessageVisibility.class); + + private final SqsAsyncClient sqsAsyncClient; + + private final String queueUrl; + + private final String receiptHandle; + + /** + * Create an instance for changing the visibility for the provided queue. + * @param amazonSqsAsync the client to be used. + * @param queueUrl the queue url. + * @param receiptHandle the message receipt handle. + */ + public QueueMessageVisibility(SqsAsyncClient amazonSqsAsync, String queueUrl, String receiptHandle) { + this.sqsAsyncClient = amazonSqsAsync; + this.queueUrl = queueUrl; + this.receiptHandle = receiptHandle; + } + + @Override + public CompletableFuture changeToAsync(int seconds) { + return this.sqsAsyncClient + .changeMessageVisibility( + req -> req.queueUrl(this.queueUrl).receiptHandle(this.receiptHandle).visibilityTimeout(seconds)) + .thenRun(() -> logger.trace("Changed the visibility of message {} to {} seconds", this.receiptHandle, + seconds)); + } +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueNotFoundStrategy.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueNotFoundStrategy.java new file mode 100644 index 000000000..b86937615 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueNotFoundStrategy.java @@ -0,0 +1,36 @@ +/* + * 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; + +/** + * Configure the strategy to be used when a specified queue is not found at container startup. + * @author Tomaz Fernandes + * @since 3.0 + * @see ContainerOptions#getQueueNotFoundStrategy() + */ +public enum QueueNotFoundStrategy { + + /** + * Throw an exception and stop application startup if a queue is not found. + */ + FAIL, + + /** + * Create queues that are not found at startup. + */ + CREATE + +} 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..8b2c3582b --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SemaphoreBackPressureHandler.java @@ -0,0 +1,250 @@ +/* + * 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) ? 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) + ? 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); + logger.trace("Trying to acquire partial batch of {} permits from {} available for {} in TM {}", + permitsToRequest, availablePermits, this.id, this.currentThroughputMode); + boolean hasAcquiredPartial = tryAcquire(permitsToRequest); + 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); + 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"); + } + return this.batchSize; + } + else { + return 0; + } + } + + private boolean tryAcquire(int amount) throws InterruptedException { + logger.trace("Acquiring {} permits for {} in TM {}", amount, this.id, this.currentThroughputMode); + boolean hasAcquired = this.semaphore.tryAcquire(amount, this.acquireTimeout.getSeconds(), TimeUnit.SECONDS); + if (hasAcquired) { + logger.trace("{} permits acquired for {} in TM {}. Permits left: {}", amount, this.id, + this.currentThroughputMode, this.semaphore.availablePermits()); + } + else { + logger.trace("Not able to acquire {} permits in {} seconds for {} in TM {}. Permits left: {}", amount, + this.acquireTimeout.getSeconds(), this.id, this.currentThroughputMode, + 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()); + } + + 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 low throughput mode. Permits left: {}", + this.id, this.semaphore.availablePermits()); + this.currentThroughputMode = CurrentThroughputMode.LOW; + } + } + + @Override + public void release(int amount) { + 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("{} permit(s) returned, setting high throughput mode 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/main/java/io/awspring/cloud/sqs/listener/SqsAsyncClientAware.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SqsAsyncClientAware.java new file mode 100644 index 000000000..e583ce692 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SqsAsyncClientAware.java @@ -0,0 +1,34 @@ +/* + * 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 software.amazon.awssdk.services.sqs.SqsAsyncClient; + +/** + * Implementations are enabled to receive a {@link SqsAsyncClient} instance. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public interface SqsAsyncClientAware { + + /** + * Set the {@link SqsAsyncClient} instance. + * @param sqsAsyncClient the instance. + */ + void setSqsAsyncClient(SqsAsyncClient sqsAsyncClient); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SqsHeaders.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SqsHeaders.java new file mode 100644 index 000000000..66a8c0079 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SqsHeaders.java @@ -0,0 +1,140 @@ +/* + * 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; + +/** + * The {@link org.springframework.messaging.MessageHeaders} names used for {@link org.springframework.messaging.Message} + * instances created from SQS messages. Can be used to retrieve headers from messages either through + * {@link org.springframework.messaging.MessageHeaders#get} or + * {@link org.springframework.messaging.handler.annotation.Header} parameter annotations. + * @author Tomaz Fernandes + * @since 3.0 + * @see io.awspring.cloud.sqs.support.converter.SqsHeaderMapper + */ +public class SqsHeaders { + + private SqsHeaders() { + } + + /** + * SQS Headers prefix to be used by all headers added by the framework. + */ + public static final String SQS_HEADER_PREFIX = "Sqs_"; + + /** + * MessageAttributes prefix to be used by all headers mapped from SQS Message Attributes. + */ + public static final String SQS_MA_HEADER_PREFIX = SQS_HEADER_PREFIX + "Ma_"; + + /** + * Header for the queue name. + */ + public static final String SQS_QUEUE_NAME_HEADER = SQS_HEADER_PREFIX + "QueueName"; + + /** + * Header for the queue url. + */ + public static final String SQS_QUEUE_URL_HEADER = SQS_HEADER_PREFIX + "QueueUrl"; + + /** + * Header for the SQS Message's receipt handle. + */ + public static final String SQS_RECEIPT_HANDLE_HEADER = SQS_HEADER_PREFIX + "ReceiptHandle"; + + /** + * Header for the original SQS {@link software.amazon.awssdk.services.sqs.model.Message}. + */ + public static final String SQS_SOURCE_DATA_HEADER = SQS_HEADER_PREFIX + "SourceData"; + + /** + * Header for the {@link Visibility} object for this message. + */ + public static final String SQS_VISIBILITY_HEADER = SQS_HEADER_PREFIX + "Visibility"; + + /** + * Header for the received at attribute. + */ + public static final String SQS_RECEIVED_AT_HEADER = SQS_HEADER_PREFIX + "ReceivedAt"; + + /** + * Header for a {@link io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback} for this message. + */ + public static final String SQS_ACKNOWLEDGMENT_CALLBACK_HEADER = SQS_HEADER_PREFIX + "Acknowledgement"; + + /** + * Header for the {@link QueueAttributes} for this message. + */ + public static final String SQS_QUEUE_ATTRIBUTES_HEADER = SQS_HEADER_PREFIX + "QueueAttributes"; + + /** + * Header containing the FQCN of the {@link Class} that the message's payload should be deserialized to. + */ + public static final String SQS_DEFAULT_TYPE_HEADER = "JavaType"; + + public static class MessageSystemAttribute { + + private MessageSystemAttribute() { + } + + /** + * MessageSystemAttributes prefix + */ + public static final String SQS_MSA_HEADER_PREFIX = SQS_HEADER_PREFIX + "Msa_"; + + /** + * Group id header in a SQS message. + */ + public static final String SQS_MESSAGE_GROUP_ID_HEADER = SQS_MSA_HEADER_PREFIX + "MessageGroupId"; + + /** + * Deduplication header in a SQS message. + */ + public static final String SQS_DEDUPLICATION_ID_HEADER = SQS_MSA_HEADER_PREFIX + "MessageDeduplicationId"; + + /** + * ApproximateFirstReceiveTimestamp header in a SQS message. + */ + public static final String SQS_APPROXIMATE_FIRST_RECEIVE_TIMESTAMP = SQS_MSA_HEADER_PREFIX + + "ApproximateFirstReceiveTimestamp"; + + /** + * ApproximateReceiveCount header in a SQS message. + */ + public static final String SQS_APPROXIMATE_RECEIVE_COUNT = SQS_MSA_HEADER_PREFIX + "ApproximateReceiveCount"; + + /** + * SentTimestamp header in a SQS message. + */ + public static final String SQS_SENT_TIMESTAMP = SQS_MSA_HEADER_PREFIX + "SentTimestamp"; + + /** + * SenderId header in a SQS message. + */ + public static final String SQS_SENDER_ID = SQS_MSA_HEADER_PREFIX + "SenderId"; + + /** + * SenderId header in a SQS message. + */ + public static final String SQS_SEQUENCE_NUMBER = SQS_MSA_HEADER_PREFIX + "SequenceNumber"; + + /** + * SenderId header in a SQS message. + */ + public static final String SQS_AWS_TRACE_HEADER = SQS_MSA_HEADER_PREFIX + "AWSTraceHeader"; + + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SqsMessageListenerContainer.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SqsMessageListenerContainer.java new file mode 100644 index 000000000..2e5853b80 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/SqsMessageListenerContainer.java @@ -0,0 +1,280 @@ +/* + * 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.ConfigUtils; +import io.awspring.cloud.sqs.annotation.SqsListener; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.AsyncAcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.errorhandler.ErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import io.awspring.cloud.sqs.listener.interceptor.MessageInterceptor; +import io.awspring.cloud.sqs.listener.sink.MessageSink; +import io.awspring.cloud.sqs.listener.source.MessageSource; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.messaging.Message; +import org.springframework.util.Assert; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; + +/** + * {@link MessageListenerContainer} implementation for SQS queues. To create an instance, both constructors or the + * {@link #builder()} method can be used, and further configuration can be achieved by using the + * {@link #configure(Consumer)} method. + *

+ * The {@link SqsAsyncClient} instance to be used by this container must be set through the constructor or the + * {@link Builder#sqsAsyncClient} method. + *

+ * The container also accepts the following components: + *

    + *
  • {@link MessageInterceptor}
  • + *
  • {@link MessageListener}
  • + *
  • {@link ErrorHandler}
  • + *
  • {@link AsyncMessageInterceptor}
  • + *
  • {@link AsyncMessageListener}
  • + *
  • {@link AsyncErrorHandler}
  • + *
+ * The non-async components will be adapted to their async counterparts. Components and {@link ContainerOptions} can be + * changed when the container is stopped. Such changes will be effective upon container restart. + *

+ * Containers created through the {@link SqsListener} annotation will be registered in a + * {@link MessageListenerContainerRegistry} which will be responsible for managing their lifecycle. Containers created + * manually and declared as beans will have their lifecycle managed by Spring Context. + *

+ * Example using the builder: + * + *

+ * 
+ * @Bean
+ * public SqsMessageListenerContainer mySqsListenerContainer(SqsAsyncClient sqsAsyncClient) {
+ *     return SqsMessageListenerContainer
+ *             .builder()
+ *             .configure(options -> options
+ *                     .messagesPerPoll(5)
+ *                     .pollTimeout(Duration.ofSeconds(10)))
+ *             .sqsAsyncClient(sqsAsyncClient)
+ *             .build();
+ * }
+ * 
+ * 
+ *
+ * 

+ * Example using the constructor: + * + *

+ * 
+ * @Bean
+ * public SqsMessageListenerContainer myListenerContainer(SqsAsyncClient sqsAsyncClient) {
+ *     SqsMessageListenerContainer container = new SqsMessageListenerContainer<>(sqsAsyncClient);
+ *     container.configure(options -> options
+ *             .messagesPerPoll(5)
+ *             .pollTimeout(Duration.ofSeconds(10)));
+ *     return container;
+ * }
+ * 
+ * 
+ *
+ * @param  the {@link Message} payload type. This type is used to ensure at compile time that all components in this
+ *     container expect the same payload type. If the factory will be used with many payload types, {@link Object} can
+ *     be used.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class SqsMessageListenerContainer extends AbstractPipelineMessageListenerContainer {
+
+	private static final Logger logger = LoggerFactory.getLogger(SqsMessageListenerContainer.class);
+
+	private static final List> DEFAULT_CONTAINER_COMPONENT_FACTORIES = Arrays
+			.asList(new FifoSqsComponentFactory<>(), new StandardSqsComponentFactory<>());
+
+	private final SqsAsyncClient sqsAsyncClient;
+
+	public SqsMessageListenerContainer(SqsAsyncClient sqsAsyncClient, ContainerOptions options) {
+		super(options);
+		Assert.notNull(sqsAsyncClient, "sqsAsyncClient cannot be null");
+		this.sqsAsyncClient = sqsAsyncClient;
+	}
+
+	public SqsMessageListenerContainer(SqsAsyncClient sqsAsyncClient) {
+		this(sqsAsyncClient, ContainerOptions.builder().build());
+	}
+
+	@SuppressWarnings("unchecked")
+	@Override
+	protected Collection> getDefaultComponentFactories() {
+		Assert.isTrue(allQueuesSameType(),
+				"SqsMessageListenerContainer must contain either all FIFO or all Standard queues.");
+		return DEFAULT_CONTAINER_COMPONENT_FACTORIES.stream()
+				.map(componentFactory -> (ContainerComponentFactory) componentFactory).collect(Collectors.toList());
+	}
+
+	private boolean allQueuesSameType() {
+		return getQueueNames().stream().allMatch(this::isFifoQueue)
+				|| getQueueNames().stream().noneMatch(this::isFifoQueue);
+	}
+
+	private boolean isFifoQueue(String name) {
+		return name.endsWith(".fifo");
+	}
+
+	@Override
+	protected void doConfigureMessageSources(Collection> messageSources) {
+		ConfigUtils.INSTANCE.acceptManyIfInstance(messageSources, SqsAsyncClientAware.class,
+				asca -> asca.setSqsAsyncClient(this.sqsAsyncClient));
+	}
+
+	@Override
+	protected void doConfigureMessageSink(MessageSink messageSink) {
+		ConfigUtils.INSTANCE.acceptIfInstance(messageSink, SqsAsyncClientAware.class,
+				asca -> asca.setSqsAsyncClient(this.sqsAsyncClient));
+	}
+
+	public static  Builder builder() {
+		return new Builder<>();
+	}
+
+	public static class Builder {
+
+		private final Collection queueNames = new ArrayList<>();
+
+		private final Collection> asyncMessageInterceptors = new ArrayList<>();
+
+		private final Collection> messageInterceptors = new ArrayList<>();
+
+		private SqsAsyncClient sqsAsyncClient;
+
+		private Collection> containerComponentFactories;
+
+		private AsyncMessageListener asyncMessageListener;
+
+		private MessageListener messageListener;
+
+		private String id;
+
+		private AsyncErrorHandler asyncErrorHandler;
+
+		private ErrorHandler errorHandler;
+
+		private Consumer optionsConsumer = options -> {
+		};
+
+		private AsyncAcknowledgementResultCallback asyncAcknowledgementResultCallback;
+
+		private AcknowledgementResultCallback acknowledgementResultCallback;
+
+		public Builder id(String id) {
+			this.id = id;
+			return this;
+		}
+
+		public Builder sqsAsyncClient(SqsAsyncClient sqsAsyncClient) {
+			this.sqsAsyncClient = sqsAsyncClient;
+			return this;
+		}
+
+		public Builder queueNames(String... queueNames) {
+			this.queueNames.addAll(Arrays.asList(queueNames));
+			return this;
+		}
+
+		public Builder queueNames(Collection queueNames) {
+			this.queueNames.addAll(queueNames);
+			return this;
+		}
+
+		public Builder componentFactories(Collection> containerComponentFactories) {
+			this.containerComponentFactories = containerComponentFactories;
+			return this;
+		}
+
+		public Builder asyncMessageListener(AsyncMessageListener asyncMessageListener) {
+			this.asyncMessageListener = asyncMessageListener;
+			return this;
+		}
+
+		public Builder messageListener(MessageListener messageListener) {
+			this.messageListener = messageListener;
+			return this;
+		}
+
+		public Builder errorHandler(AsyncErrorHandler asyncErrorHandler) {
+			this.asyncErrorHandler = asyncErrorHandler;
+			return this;
+		}
+
+		public Builder errorHandler(ErrorHandler errorHandler) {
+			this.errorHandler = errorHandler;
+			return this;
+		}
+
+		public Builder messageInterceptor(AsyncMessageInterceptor asyncMessageInterceptor) {
+			this.asyncMessageInterceptors.add(asyncMessageInterceptor);
+			return this;
+		}
+
+		public Builder messageInterceptor(MessageInterceptor messageInterceptor) {
+			this.messageInterceptors.add(messageInterceptor);
+			return this;
+		}
+
+		public Builder acknowledgementResultCallback(
+				AsyncAcknowledgementResultCallback asyncAcknowledgementResultCallback) {
+			this.asyncAcknowledgementResultCallback = asyncAcknowledgementResultCallback;
+			return this;
+		}
+
+		public Builder acknowledgementResultCallback(
+				AcknowledgementResultCallback acknowledgementResultCallback) {
+			this.acknowledgementResultCallback = acknowledgementResultCallback;
+			return this;
+		}
+
+		public Builder configure(Consumer options) {
+			this.optionsConsumer = options;
+			return this;
+		}
+
+		// @formatter:off
+		public SqsMessageListenerContainer build() {
+			SqsMessageListenerContainer container = new SqsMessageListenerContainer<>(this.sqsAsyncClient);
+			ConfigUtils.INSTANCE
+					.acceptIfNotNull(this.id, container::setId)
+					.acceptIfNotNull(this.messageListener, container::setMessageListener)
+					.acceptIfNotNull(this.asyncMessageListener, container::setAsyncMessageListener)
+					.acceptIfNotNull(this.errorHandler, container::setErrorHandler)
+					.acceptIfNotNull(this.asyncErrorHandler, container::setErrorHandler)
+					.acceptIfNotNull(this.acknowledgementResultCallback, container::setAcknowledgementResultCallback)
+					.acceptIfNotNull(this.asyncAcknowledgementResultCallback, container::setAcknowledgementResultCallback)
+					.acceptIfNotNull(this.containerComponentFactories, container::setComponentFactories)
+					.acceptIfNotEmpty(this.queueNames, container::setQueueNames);
+			this.messageInterceptors.forEach(container::addMessageInterceptor);
+			this.asyncMessageInterceptors.forEach(container::addMessageInterceptor);
+			container.configure(this.optionsConsumer);
+			return container;
+		}
+		// @formatter:on
+
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/StandardSqsComponentFactory.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/StandardSqsComponentFactory.java
new file mode 100644
index 000000000..bd6796856
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/StandardSqsComponentFactory.java
@@ -0,0 +1,119 @@
+/*
+ * 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.ConfigUtils;
+import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementOrdering;
+import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementProcessor;
+import io.awspring.cloud.sqs.listener.acknowledgement.BatchingAcknowledgementProcessor;
+import io.awspring.cloud.sqs.listener.acknowledgement.ImmediateAcknowledgementProcessor;
+import io.awspring.cloud.sqs.listener.sink.BatchMessageSink;
+import io.awspring.cloud.sqs.listener.sink.FanOutMessageSink;
+import io.awspring.cloud.sqs.listener.sink.MessageSink;
+import io.awspring.cloud.sqs.listener.source.MessageSource;
+import io.awspring.cloud.sqs.listener.source.SqsMessageSource;
+import java.time.Duration;
+import java.util.Collection;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link ContainerComponentFactory} implementation for Standard SQS queues.
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see FifoSqsComponentFactory
+ */
+public class StandardSqsComponentFactory implements ContainerComponentFactory {
+
+	private static final Duration DEFAULT_STANDARD_SQS_ACK_INTERVAL = Duration.ofSeconds(1);
+
+	private static final Integer DEFAULT_STANDARD_SQS_ACK_THRESHOLD = 10;
+
+	private static final AcknowledgementOrdering DEFAULT_STANDARD_SQS_ACK_ORDERING = AcknowledgementOrdering.PARALLEL;
+
+	@Override
+	public boolean supports(Collection queueNames, ContainerOptions options) {
+		return queueNames.stream().noneMatch(name -> name.endsWith(".fifo"));
+	}
+
+	@Override
+	public MessageSource createMessageSource(ContainerOptions options) {
+		return new SqsMessageSource<>();
+	}
+
+	// @formatter:off
+	@Override
+	public MessageSink createMessageSink(ContainerOptions options) {
+		return ListenerMode.SINGLE_MESSAGE.equals(options.getListenerMode())
+			? new FanOutMessageSink<>()
+			: new BatchMessageSink<>();
+	}
+	// @formatter:on
+
+	@Override
+	public AcknowledgementProcessor createAcknowledgementProcessor(ContainerOptions options) {
+		validateAcknowledgementOrdering(options);
+		return options.getAcknowledgementInterval() == Duration.ZERO && options.getAcknowledgementThreshold() == 0
+				? createAndConfigureImmediateProcessor(options)
+				: createAndConfigureBatchingProcessor(options);
+	}
+
+	private void validateAcknowledgementOrdering(ContainerOptions options) {
+		Assert.isTrue(!AcknowledgementOrdering.ORDERED_BY_GROUP.equals(options.getAcknowledgementOrdering()),
+				"Standard SQS queues are not compatible with " + AcknowledgementOrdering.ORDERED_BY_GROUP);
+	}
+
+	private AcknowledgementProcessor createAndConfigureBatchingProcessor(ContainerOptions options) {
+		return configureBatchingAcknowledgementProcessor(options, createBatchingProcessorInstance());
+	}
+
+	protected ImmediateAcknowledgementProcessor createAndConfigureImmediateProcessor(ContainerOptions options) {
+		return configureImmediateAcknowledgementProcessor(createImmediateProcessorInstance(), options);
+	}
+
+	protected ImmediateAcknowledgementProcessor createImmediateProcessorInstance() {
+		return new ImmediateAcknowledgementProcessor<>();
+	}
+
+	protected BatchingAcknowledgementProcessor createBatchingProcessorInstance() {
+		return new BatchingAcknowledgementProcessor<>();
+	}
+
+	protected ImmediateAcknowledgementProcessor configureImmediateAcknowledgementProcessor(
+			ImmediateAcknowledgementProcessor processor, ContainerOptions options) {
+		processor.setMaxAcknowledgementsPerBatch(10);
+		ContainerOptions.Builder builder = options.toBuilder();
+		ConfigUtils.INSTANCE.acceptIfNotNullOrElse(builder::acknowledgementOrdering,
+				options.getAcknowledgementOrdering(), DEFAULT_STANDARD_SQS_ACK_ORDERING);
+		processor.configure(builder.build());
+		return processor;
+	}
+
+	protected BatchingAcknowledgementProcessor configureBatchingAcknowledgementProcessor(ContainerOptions options,
+			BatchingAcknowledgementProcessor processor) {
+		processor.setMaxAcknowledgementsPerBatch(10);
+		ContainerOptions.Builder builder = options.toBuilder();
+		ConfigUtils.INSTANCE
+				.acceptIfNotNullOrElse(builder::acknowledgementInterval, options.getAcknowledgementInterval(),
+						DEFAULT_STANDARD_SQS_ACK_INTERVAL)
+				.acceptIfNotNullOrElse(builder::acknowledgementThreshold, options.getAcknowledgementThreshold(),
+						DEFAULT_STANDARD_SQS_ACK_THRESHOLD)
+				.acceptIfNotNullOrElse(builder::acknowledgementOrdering, options.getAcknowledgementOrdering(),
+						DEFAULT_STANDARD_SQS_ACK_ORDERING);
+		processor.configure(builder.build());
+		return processor;
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/TaskExecutorAware.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/TaskExecutorAware.java
new file mode 100644
index 000000000..083c0ddc9
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/TaskExecutorAware.java
@@ -0,0 +1,34 @@
+/*
+ * 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 org.springframework.core.task.TaskExecutor;
+
+/**
+ * Enables a class to receive a container managed {@link TaskExecutor}.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public interface TaskExecutorAware {
+
+	/**
+	 * Set the task executor.
+	 * @param taskExecutor the task executor.
+	 */
+	void setTaskExecutor(TaskExecutor taskExecutor);
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/Visibility.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/Visibility.java
new file mode 100644
index 000000000..32c967925
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/Visibility.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2013-2019 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.util.concurrent.CompletableFuture;
+
+/**
+ * Visibility interface that can be injected as parameter into a listener method. The purpose of this interface is to
+ * provide a way for the listener methods to extend the visibility timeout of the message being currently processed.
+ *
+ * @author Szymon Dembek
+ * @author Tomaz Fernandes
+ * @since 1.3
+ */
+public interface Visibility {
+
+	/**
+	 * Asynchronously changes the message visibility to the provided value.
+	 * @param seconds number of seconds to set the visibility of the message to.
+	 * @return a completable future.
+	 */
+	CompletableFuture changeToAsync(int seconds);
+
+	/**
+	 * Changes the message visibility to the provided value.
+	 * @param seconds number of seconds to set the visibility of the message to.
+	 */
+	default void changeTo(int seconds) {
+		changeToAsync(seconds).join();
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AbstractOrderingAcknowledgementProcessor.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AbstractOrderingAcknowledgementProcessor.java
new file mode 100644
index 000000000..8775da219
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AbstractOrderingAcknowledgementProcessor.java
@@ -0,0 +1,305 @@
+/*
+ * 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.acknowledgement;
+
+import io.awspring.cloud.sqs.CompletableFutures;
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.ContainerOptions;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.messaging.Message;
+import org.springframework.util.Assert;
+import org.springframework.util.StopWatch;
+
+/**
+ * Base implementation for a {@link AcknowledgementProcessor} with {@link org.springframework.context.SmartLifecycle}
+ * capabilities. Also provides acknowledgement ordering capabilities for {@link AcknowledgementOrdering#ORDERED}.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public abstract class AbstractOrderingAcknowledgementProcessor
+		implements ExecutingAcknowledgementProcessor, AcknowledgementCallback {
+
+	private static final Logger logger = LoggerFactory.getLogger(AbstractOrderingAcknowledgementProcessor.class);
+
+	private final Object lifecycleMonitor = new Object();
+
+	private static final String DEFAULT_MESSAGE_GROUP = "default";
+
+	private final Lock orderedExecutionLock = new ReentrantLock(true);
+
+	private int maxAcknowledgementsPerBatch;
+
+	private AcknowledgementExecutor acknowledgementExecutor;
+
+	private AcknowledgementOrdering acknowledgementOrdering;
+
+	private final Map> lastAcknowledgementFutureMap = new ConcurrentHashMap<>();
+
+	private AsyncAcknowledgementResultCallback acknowledgementResultCallback = new AsyncAcknowledgementResultCallback() {
+	};
+
+	private boolean running;
+
+	private String id;
+
+	private Function, String> messageGroupingFunction;
+
+	@Override
+	public AcknowledgementCallback getAcknowledgementCallback() {
+		return this;
+	}
+
+	@Override
+	public void configure(ContainerOptions containerOptions) {
+		this.acknowledgementOrdering = containerOptions.getAcknowledgementOrdering();
+		doConfigure(containerOptions);
+	}
+
+	protected void doConfigure(ContainerOptions containerOptions) {
+	}
+
+	@Override
+	public void setAcknowledgementExecutor(AcknowledgementExecutor acknowledgementExecutor) {
+		Assert.notNull(acknowledgementExecutor, "acknowledgementExecutor cannot be null");
+		this.acknowledgementExecutor = acknowledgementExecutor;
+	}
+
+	@Override
+	public void setAcknowledgementResultCallback(AsyncAcknowledgementResultCallback acknowledgementResultCallback) {
+		Assert.notNull(acknowledgementResultCallback, "acknowledgementResultCallback cannot be null");
+		this.acknowledgementResultCallback = acknowledgementResultCallback;
+	}
+
+	public void setMaxAcknowledgementsPerBatch(int maxAcknowledgementsPerBatch) {
+		Assert.isTrue(maxAcknowledgementsPerBatch > 0, "maxAcknowledgementsPerBatch must be greater than zero");
+		this.maxAcknowledgementsPerBatch = maxAcknowledgementsPerBatch;
+	}
+
+	public void setMessageGroupingFunction(Function, String> messageGroupingFunction) {
+		Assert.notNull(messageGroupingFunction, "messageGroupingFunction cannot be null");
+		this.messageGroupingFunction = messageGroupingFunction;
+	}
+
+	@Override
+	public void setId(String id) {
+		Assert.notNull(id, "id cannot be null");
+		this.id = id;
+	}
+
+	@Override
+	public String getId() {
+		return this.id;
+	}
+
+	@Override
+	public void start() {
+		synchronized (this.lifecycleMonitor) {
+			Assert.notNull(this.acknowledgementExecutor, "acknowledgementExecutor not set");
+			Assert.notNull(this.acknowledgementOrdering, "acknowledgementOrdering not set");
+			Assert.notNull(this.id, "id not set");
+			logger.debug("Starting {} with ordering {} and batch size {}", this.id, this.acknowledgementOrdering,
+					this.maxAcknowledgementsPerBatch);
+			this.running = true;
+			validateAndInitializeMessageGrouping();
+			doStart();
+		}
+	}
+
+	private void validateAndInitializeMessageGrouping() {
+		Assert.isTrue(isValidOrderedByGroup() || isValidNotOrderedByGroup(),
+				"Invalid configuration for acknowledgement ordering.");
+		if (this.messageGroupingFunction == null) {
+			this.messageGroupingFunction = msg -> DEFAULT_MESSAGE_GROUP;
+		}
+	}
+
+	private boolean isValidOrderedByGroup() {
+		return AcknowledgementOrdering.ORDERED_BY_GROUP.equals(this.acknowledgementOrdering)
+				&& this.messageGroupingFunction != null;
+	}
+
+	private boolean isValidNotOrderedByGroup() {
+		return !AcknowledgementOrdering.ORDERED_BY_GROUP.equals(this.acknowledgementOrdering)
+				&& this.messageGroupingFunction == null;
+	}
+
+	protected void doStart() {
+	}
+
+	@Override
+	public CompletableFuture onAcknowledge(Message message) {
+		if (!isRunning()) {
+			logger.debug("{} not running, returning for message {}", this.id, MessageHeaderUtils.getId(message));
+			return CompletableFuture.completedFuture(null);
+		}
+		logger.trace("Received message {} to acknowledge.", MessageHeaderUtils.getId(message));
+		return doOnAcknowledge(message);
+	}
+
+	@Override
+	public CompletableFuture onAcknowledge(Collection> messages) {
+		logger.trace("Received messages {} to acknowledge.", MessageHeaderUtils.getId(messages));
+		if (!isRunning()) {
+			logger.debug("{} not running, returning for messages {}", this.id, MessageHeaderUtils.getId(messages));
+			return CompletableFuture.completedFuture(null);
+		}
+		return doOnAcknowledge(messages);
+	}
+
+	@Override
+	public void stop() {
+		if (!isRunning()) {
+			logger.debug("{} already stopped", this.id);
+			return;
+		}
+		synchronized (this.lifecycleMonitor) {
+			logger.debug("Stopping {}", this.id);
+			this.running = false;
+			doStop();
+		}
+	}
+
+	protected void doStop() {
+	}
+
+	@Override
+	public boolean isRunning() {
+		return this.running;
+	}
+
+	protected Function, String> getMessageGroupingFunction() {
+		return this.messageGroupingFunction;
+	}
+
+	protected CompletableFuture sendToExecutor(Collection> messagesToAck) {
+		StopWatch watch = new StopWatch();
+		watch.start();
+		return CompletableFutures
+				.exceptionallyCompose(sendToExecutorParallelOrOrdered(messagesToAck),
+						t -> logAcknowledgementError(messagesToAck, t))
+				.whenComplete(logExecutionTime(messagesToAck, watch));
+	}
+
+	private BiConsumer logExecutionTime(Collection> messagesToAck, StopWatch watch) {
+		return (v, t) -> {
+			watch.stop();
+			logger.trace("Took {}ms to acknowledge messages {}", watch.getTotalTimeMillis(),
+					MessageHeaderUtils.getId(messagesToAck));
+		};
+	}
+
+	private CompletableFuture sendToExecutorParallelOrOrdered(Collection> messagesToAck) {
+		return AcknowledgementOrdering.PARALLEL.equals(this.acknowledgementOrdering)
+				? sendToExecutorParallel(messagesToAck)
+				: sendToExecutorOrdered(messagesToAck);
+	}
+
+	private CompletableFuture sendToExecutorParallel(Collection> messagesToAck) {
+		return CompletableFuture.allOf(partitionMessages(messagesToAck).stream().map(this::doSendToExecutor)
+				.toArray(CompletableFuture[]::new));
+	}
+
+	private CompletableFuture sendToExecutorOrdered(Collection> messagesToAck) {
+		this.orderedExecutionLock.lock();
+		try {
+			return CompletableFuture.allOf(partitionMessages(messagesToAck).stream().map(this::doSendToExecutorOrdered)
+					.flatMap(Collection::stream).toArray(CompletableFuture[]::new));
+		}
+		finally {
+			this.orderedExecutionLock.unlock();
+		}
+	}
+
+	private Collection> doSendToExecutorOrdered(Collection> messagesToAck) {
+		return messagesToAck.stream().collect(Collectors.groupingBy(this.messageGroupingFunction)).entrySet().stream()
+				.filter(entry -> entry.getValue().size() > 0)
+				.map(entry -> sendGroupToExecutor(entry.getKey(), entry.getValue())).collect(Collectors.toList());
+	}
+
+	private CompletableFuture sendGroupToExecutor(String group, List> messages) {
+		CompletableFuture nextFuture = this.lastAcknowledgementFutureMap
+				.computeIfAbsent(group, newGroup -> CompletableFuture.completedFuture(null)).exceptionally(t -> null)
+				.thenCompose(theVoid -> doSendToExecutor(messages));
+		this.lastAcknowledgementFutureMap.put(group, nextFuture);
+		removeCompletedFutures();
+		return nextFuture;
+	}
+
+	private void removeCompletedFutures() {
+		List completedFutures = this.lastAcknowledgementFutureMap.entrySet().stream()
+				.filter(entry -> entry.getValue().isDone()).map(Map.Entry::getKey).collect(Collectors.toList());
+		logger.trace("Removing completed futures from groups {}", completedFutures);
+		completedFutures.forEach(this.lastAcknowledgementFutureMap::remove);
+	}
+
+	private CompletableFuture doSendToExecutor(Collection> messagesToAck) {
+		return CompletableFutures.handleCompose(this.acknowledgementExecutor.execute(messagesToAck), (v, t) -> t == null
+				? executeResultCallback(messagesToAck, null)
+				: executeResultCallback(messagesToAck, t).thenCompose(theVoid -> CompletableFutures.failedFuture(t)));
+	}
+
+	private CompletableFuture executeResultCallback(Collection> messagesToAck,
+			Throwable ackThrowable) {
+		return CompletableFutures.exceptionallyCompose(doExecuteResultCallback(messagesToAck, ackThrowable),
+				t -> CompletableFutures.failedFuture(new AcknowledgementResultCallbackException(
+						"Error executing acknowledgement result callback", t)));
+	}
+
+	private CompletableFuture doExecuteResultCallback(Collection> messagesToAck, Throwable t) {
+		logger.trace("Executing result callback for {} in {}", MessageHeaderUtils.getId(messagesToAck), this.id);
+		return t == null ? this.acknowledgementResultCallback.onSuccess(messagesToAck)
+				: this.acknowledgementResultCallback.onFailure(messagesToAck, t);
+	}
+
+	private CompletableFuture logAcknowledgementError(Collection> messagesToAck, Throwable t) {
+		logger.error("Acknowledgement processing has thrown an error for messages {} in {}",
+				MessageHeaderUtils.getId(messagesToAck), this.id, t);
+		return CompletableFutures.failedFuture(t);
+	}
+
+	private Collection>> partitionMessages(Collection> messagesToAck) {
+		logger.trace("Partitioning {} messages in {}", messagesToAck.size(), this.id);
+		List> messagesToUse = getMessagesAsList(messagesToAck);
+		int totalSize = messagesToUse.size();
+		return IntStream.rangeClosed(0, (totalSize - 1) / this.maxAcknowledgementsPerBatch)
+				.mapToObj(index -> messagesToUse.subList(index * this.maxAcknowledgementsPerBatch,
+						Math.min((index + 1) * this.maxAcknowledgementsPerBatch, totalSize)))
+				.collect(Collectors.toList());
+	}
+
+	private List> getMessagesAsList(Collection> messagesToAck) {
+		return messagesToAck instanceof List ? (List>) messagesToAck : new ArrayList<>(messagesToAck);
+	}
+
+	protected abstract CompletableFuture doOnAcknowledge(Message message);
+
+	protected abstract CompletableFuture doOnAcknowledge(Collection> messages);
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/Acknowledgement.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/Acknowledgement.java
new file mode 100644
index 000000000..43d326a4a
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/Acknowledgement.java
@@ -0,0 +1,39 @@
+/*
+ * 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.acknowledgement;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Interface representing a message acknowledgement. For this interface to be used as a listener method parameter,
+ * {@link io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementMode#MANUAL} has to be set.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public interface Acknowledgement {
+
+	/**
+	 * Acknowledge the message.
+	 */
+	void acknowledge();
+
+	/**
+	 * Asynchronously acknowledge the message.
+	 */
+	CompletableFuture acknowledgeAsync();
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementCallback.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementCallback.java
new file mode 100644
index 000000000..2acefa155
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementCallback.java
@@ -0,0 +1,50 @@
+/*
+ * 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.acknowledgement;
+
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.springframework.messaging.Message;
+
+/**
+ * Interface representing an acknowledgement callback to be executed, usually by a
+ * {@link io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementHandler} implementation.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see AcknowledgementProcessor
+ */
+public interface AcknowledgementCallback {
+
+	/**
+	 * Triggers acknowledgement for the given message.
+	 * @param message the message.
+	 * @return a completable future.
+	 */
+	default CompletableFuture onAcknowledge(Message message) {
+		return CompletableFuture.completedFuture(null);
+	}
+
+	/**
+	 * Triggers acknowledgement for the given messages.
+	 * @param messages the messages.
+	 * @return a completable future.
+	 */
+	default CompletableFuture onAcknowledge(Collection> messages) {
+		return CompletableFuture.completedFuture(null);
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementExecutor.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementExecutor.java
new file mode 100644
index 000000000..239363dae
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementExecutor.java
@@ -0,0 +1,39 @@
+/*
+ * 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.acknowledgement;
+
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.springframework.messaging.Message;
+
+/**
+ * Allows executing acknowledgements for a batch of messages.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see ExecutingAcknowledgementProcessor
+ */
+@FunctionalInterface
+public interface AcknowledgementExecutor {
+
+	/**
+	 * Executes acknowledgements for the provided batch of messages.
+	 * @param messages the messages.
+	 * @return a completable future.
+	 */
+	CompletableFuture execute(Collection> messages);
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementOrdering.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementOrdering.java
new file mode 100644
index 000000000..21e554b20
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementOrdering.java
@@ -0,0 +1,45 @@
+/*
+ * 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.acknowledgement;
+
+/**
+ * Configures the ordering of acknowledgements.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see AcknowledgementExecutor
+ * @see AbstractOrderingAcknowledgementProcessor
+ */
+public enum AcknowledgementOrdering {
+
+	/**
+	 * Acknowledgements will be executed sequentially. The next batch of messages will be acknowledged after the
+	 * previous one is completed.
+	 */
+	ORDERED,
+
+	/**
+	 * Acknowledgements will be executed sequentially within its group, and in parallel between groups. The next batch
+	 * of messages will be acknowledged after the previous one from the same group is completed.
+	 */
+	ORDERED_BY_GROUP,
+
+	/**
+	 * Acknowledgements will be executed in parallel.
+	 */
+	PARALLEL
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementProcessor.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementProcessor.java
new file mode 100644
index 000000000..4bd907e29
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementProcessor.java
@@ -0,0 +1,49 @@
+/*
+ * 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.acknowledgement;
+
+import io.awspring.cloud.sqs.listener.ConfigurableContainerComponent;
+import io.awspring.cloud.sqs.listener.ContainerOptions;
+import io.awspring.cloud.sqs.listener.IdentifiableContainerComponent;
+import org.springframework.context.SmartLifecycle;
+
+/**
+ * Top-level interface for a component capable of processing acknowledgements. Provides the
+ * {@link #getAcknowledgementCallback()} method that allows offering messages to the processor.
+ *
+ * The timing of the execution of the acknowledgements depends on many factors sucha as
+ * {@link ContainerOptions#getAcknowledgementInterval()}, {@link ContainerOptions#getAcknowledgementThreshold()},
+ * {@link ContainerOptions#getAcknowledgementOrdering()}.
+ *
+ * The actual execution is usually handled by an {@link AcknowledgementExecutor}.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see ImmediateAcknowledgementProcessor
+ * @see BatchingAcknowledgementProcessor
+ * @see ExecutingAcknowledgementProcessor
+ * @see SqsAcknowledgementExecutor
+ */
+public interface AcknowledgementProcessor
+		extends SmartLifecycle, IdentifiableContainerComponent, ConfigurableContainerComponent {
+
+	/**
+	 * Retrieve an acknowledgement callback that can be used to offer messages to be acknowledged by this processor.
+	 * @return the callback.
+	 */
+	AcknowledgementCallback getAcknowledgementCallback();
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementResultCallback.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementResultCallback.java
new file mode 100644
index 000000000..4348d7636
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementResultCallback.java
@@ -0,0 +1,45 @@
+/*
+ * 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.acknowledgement;
+
+import java.util.Collection;
+import org.springframework.messaging.Message;
+
+/**
+ * Provides actions to be executed after a message acknowledgement completes with either success or failure.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see AbstractOrderingAcknowledgementProcessor
+ */
+public interface AcknowledgementResultCallback {
+
+	/**
+	 * Execute an action after the messages are successfully acknowledged.
+	 * @param messages the messages.
+	 */
+	default void onSuccess(Collection> messages) {
+	}
+
+	/**
+	 * Execute an action if message acknowledgement fails.
+	 * @param messages the messages.
+	 * @param t the error thrown by the acknowledgement.
+	 */
+	default void onFailure(Collection> messages, Throwable t) {
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementResultCallbackException.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementResultCallbackException.java
new file mode 100644
index 000000000..f6039fd1c
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AcknowledgementResultCallbackException.java
@@ -0,0 +1,35 @@
+/*
+ * 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.acknowledgement;
+
+/**
+ * Exception representing a failure to execute a {@link AcknowledgementResultCallback}.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class AcknowledgementResultCallbackException extends RuntimeException {
+
+	/**
+	 * Create an instance with the supplied message and cause.
+	 * @param errorMessage the error message.
+	 * @param cause the cause.
+	 */
+	public AcknowledgementResultCallbackException(String errorMessage, Throwable cause) {
+		super(errorMessage, cause);
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AsyncAcknowledgementResultCallback.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AsyncAcknowledgementResultCallback.java
new file mode 100644
index 000000000..7ff73d0dc
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/AsyncAcknowledgementResultCallback.java
@@ -0,0 +1,48 @@
+/*
+ * 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.acknowledgement;
+
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.springframework.messaging.Message;
+
+/**
+ * Provides actions to be executed after a message acknowledgement completes with either success or failure.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see AbstractOrderingAcknowledgementProcessor
+ */
+public interface AsyncAcknowledgementResultCallback {
+
+	/**
+	 * Execute an action after the messages are successfully acknowledged.
+	 * @param messages the messages.
+	 */
+	default CompletableFuture onSuccess(Collection> messages) {
+		return CompletableFuture.completedFuture(null);
+	}
+
+	/**
+	 * Execute an action if message acknowledgement fails.
+	 * @param messages the messages.
+	 * @param t the error thrown by the acknowledgement.
+	 */
+	default CompletableFuture onFailure(Collection> messages, Throwable t) {
+		return CompletableFuture.completedFuture(null);
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/BatchAcknowledgement.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/BatchAcknowledgement.java
new file mode 100644
index 000000000..9f8708791
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/BatchAcknowledgement.java
@@ -0,0 +1,52 @@
+/*
+ * 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.acknowledgement;
+
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.springframework.messaging.Message;
+
+/**
+ * Enables acknowledging messages for {@link io.awspring.cloud.sqs.listener.ListenerMode#BATCH}. Either the entire batch
+ * or a partial batch can be acknowledged.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see io.awspring.cloud.sqs.listener.ListenerMode
+ */
+public interface BatchAcknowledgement {
+
+	/**
+	 * Acknowledge all messages from the batch.
+	 */
+	void acknowledge();
+
+	/**
+	 * Asynchronously acknowledge all messages from the batch.
+	 */
+	CompletableFuture acknowledgeAsync();
+
+	/**
+	 * Acknowledge the provided messages.
+	 */
+	void acknowledge(Collection> messagesToAcknowledge);
+
+	/**
+	 * Asynchronously acknowledge the provided messages.
+	 */
+	CompletableFuture acknowledgeAsync(Collection> messagesToAcknowledge);
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/BatchingAcknowledgementProcessor.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/BatchingAcknowledgementProcessor.java
new file mode 100644
index 000000000..186712dfc
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/BatchingAcknowledgementProcessor.java
@@ -0,0 +1,453 @@
+/*
+ * 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.acknowledgement;
+
+import io.awspring.cloud.sqs.LifecycleHandler;
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.ContainerOptions;
+import io.awspring.cloud.sqs.listener.TaskExecutorAware;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.SmartLifecycle;
+import org.springframework.core.task.TaskExecutor;
+import org.springframework.messaging.Message;
+import org.springframework.scheduling.TaskScheduler;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+import org.springframework.util.Assert;
+
+/**
+ * {@link io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementProcessor} implementation that adds the messages
+ * to a {@link BlockingQueue} to be acknowledged according to {@link ContainerOptions#getAcknowledgementInterval()} and
+ * {@link ContainerOptions#getAcknowledgementThreshold()}.
+ *
+ * The messages are constantly polled from the queue and added to a buffer. When a message is polled, the processor
+ * checks the queue size against the configured threshold and sends batches for execution if the threshold is breached.
+ *
+ * A separate scheduled thread is activated when the configured amount of time has passed between the last
+ * acknowledgement execution. This thread then empties the buffer and sends all messages to execution.
+ *
+ * All buffer access must be synchronized by the {@link Lock}.
+ *
+ * When this processor is signaled to {@link SmartLifecycle#stop()}, it waits for up to 20 seconds for ongoing
+ * acknowledgement executions to complete. After that time, it will cancel all executions and return the flow to the
+ * caller.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class BatchingAcknowledgementProcessor extends AbstractOrderingAcknowledgementProcessor
+		implements TaskExecutorAware {
+
+	private static final Logger logger = LoggerFactory.getLogger(BatchingAcknowledgementProcessor.class);
+
+	private BufferingAcknowledgementProcessor acknowledgementProcessor;
+
+	private BlockingQueue> acks;
+
+	private Integer ackThreshold;
+
+	private Duration ackInterval;
+
+	private TaskExecutor taskExecutor;
+
+	private TaskScheduler taskScheduler;
+
+	private Duration shutdownTimeout;
+
+	@Override
+	protected void doConfigure(ContainerOptions containerOptions) {
+		this.ackInterval = containerOptions.getAcknowledgementInterval();
+		this.ackThreshold = containerOptions.getAcknowledgementThreshold();
+		this.shutdownTimeout = containerOptions.getShutdownTimeout();
+	}
+
+	@Override
+	public void setTaskExecutor(TaskExecutor taskExecutor) {
+		Assert.notNull(taskExecutor, "taskExecutor cannot be null");
+		this.taskExecutor = taskExecutor;
+	}
+
+	@Override
+	protected CompletableFuture doOnAcknowledge(Message message) {
+		if (!this.acks.offer(message)) {
+			logger.warn("Acknowledgement queue full, dropping acknowledgement for message {}",
+					MessageHeaderUtils.getId(message));
+		}
+		logger.trace("Received message {} to ack in {}.", MessageHeaderUtils.getId(message), getId());
+		return CompletableFuture.completedFuture(null);
+	}
+
+	@Override
+	protected CompletableFuture doOnAcknowledge(Collection> messages) {
+		messages.forEach(this::onAcknowledge);
+		return CompletableFuture.completedFuture(null);
+	}
+
+	@Override
+	public void doStart() {
+		Assert.notNull(this.ackInterval, "ackInterval not set");
+		Assert.notNull(this.ackThreshold, "ackThreshold not set");
+		Assert.notNull(this.taskExecutor, "executor not set");
+		Assert.state(this.ackInterval != Duration.ZERO || this.ackThreshold > 0,
+				() -> getClass().getSimpleName() + " cannot be used with Duration.ZERO and acknowledgement threshold 0."
+						+ "Consider using a " + ImmediateAcknowledgementProcessor.class + "instead");
+		this.acks = new LinkedBlockingQueue<>();
+		this.taskScheduler = createTaskScheduler();
+		this.acknowledgementProcessor = createAcknowledgementProcessor();
+		this.taskExecutor.execute(this.acknowledgementProcessor);
+	}
+
+	protected TaskScheduler createTaskScheduler() {
+		ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
+		scheduler.setThreadNamePrefix(getId() + "-");
+		scheduler.initialize();
+		return scheduler;
+	}
+
+	protected BufferingAcknowledgementProcessor createAcknowledgementProcessor() {
+		return new BufferingAcknowledgementProcessor<>(this);
+	}
+
+	@Override
+	public void doStop() {
+		this.acknowledgementProcessor.waitAcknowledgementsToFinish();
+		LifecycleHandler.get().dispose(this.taskScheduler);
+	}
+
+	private static class BufferingAcknowledgementProcessor implements Runnable {
+
+		private final BlockingQueue> acks;
+
+		private final Integer ackThreshold;
+
+		private final BatchingAcknowledgementProcessor parent;
+
+		private final Map>> acksBuffer;
+
+		private final Duration ackShutdownTimeout;
+
+		private final AcknowledgementExecutionContext context;
+
+		private final ScheduledAcknowledgementExecution scheduledExecution;
+
+		private final ThresholdAcknowledgementExecutor thresholdAcknowledgementExecution;
+
+		private final Function, String> messageGroupingFunction;
+
+		private BufferingAcknowledgementProcessor(BatchingAcknowledgementProcessor parent) {
+			this.acks = parent.acks;
+			this.ackThreshold = parent.ackThreshold;
+			this.ackShutdownTimeout = parent.shutdownTimeout;
+			this.parent = parent;
+			this.acksBuffer = new ConcurrentHashMap<>();
+			this.messageGroupingFunction = parent.getMessageGroupingFunction();
+			this.context = new AcknowledgementExecutionContext<>(parent.getId(), this.acksBuffer, new ReentrantLock(),
+					parent::isRunning, parent::sendToExecutor);
+			this.scheduledExecution = new ScheduledAcknowledgementExecution<>(parent.ackInterval, parent.taskScheduler,
+					this.context);
+			this.thresholdAcknowledgementExecution = new ThresholdAcknowledgementExecutor<>(parent.ackThreshold,
+					this.context);
+
+		}
+
+		@Override
+		public void run() {
+			logger.debug("Starting acknowledgement processor thread with batchSize: {}", this.ackThreshold);
+			this.scheduledExecution.start();
+			while (this.parent.isRunning()) {
+				try {
+					Message polledMessage = this.acks.poll(1, TimeUnit.SECONDS);
+					if (polledMessage != null) {
+						this.acksBuffer.computeIfAbsent(this.messageGroupingFunction.apply(polledMessage),
+								newGroup -> new LinkedBlockingQueue<>()).add(polledMessage);
+						this.thresholdAcknowledgementExecution.checkAndExecute();
+					}
+				}
+				catch (Exception e) {
+					logger.error("Error while handling acknowledgements for {}, resuming.", this.parent.getId(), e);
+				}
+			}
+			logger.debug("Acknowledgement processor thread stopped");
+		}
+
+		public void waitAcknowledgementsToFinish() {
+			try {
+				CompletableFuture.allOf(this.context.runningAcks.toArray(new CompletableFuture[] {}))
+						.get(this.ackShutdownTimeout.toMillis(), TimeUnit.MILLISECONDS);
+			}
+			catch (InterruptedException e) {
+				Thread.currentThread().interrupt();
+				throw new IllegalStateException("Interrupted while waiting for acks to finish");
+			}
+			catch (TimeoutException e) {
+				logger.warn("Acknowledgements did not finish in {} ms. Proceeding with shutdown.",
+						this.ackShutdownTimeout.toMillis());
+			}
+			catch (Exception e) {
+				logger.warn(
+						"Error thrown when waiting for acknowledgement tasks to finish in {}. Continuing with shutdown.",
+						this.parent.getId(), e);
+			}
+			if (!this.context.runningAcks.isEmpty()) {
+				this.context.runningAcks.forEach(future -> future.cancel(true));
+			}
+		}
+
+	}
+
+	private static class AcknowledgementExecutionContext {
+
+		private final String id;
+
+		private final Lock ackLock;
+
+		private final Supplier runningFunction;
+
+		private final Map>> acksBuffer;
+
+		private final Function>, CompletableFuture> executingFunction;
+
+		private final Collection> runningAcks = Collections.synchronizedSet(new HashSet<>());
+
+		private Instant lastAcknowledgement = Instant.now();
+
+		public AcknowledgementExecutionContext(String id, Map>> acksBuffer,
+				Lock ackLock, Supplier runningFunction,
+				Function>, CompletableFuture> executingFunction) {
+			this.id = id;
+			this.acksBuffer = acksBuffer;
+			this.ackLock = ackLock;
+			this.runningFunction = runningFunction;
+			this.executingFunction = executingFunction;
+		}
+
+		private List> executeAcksUpTo(int minSize, int maxSize) {
+			verifyLock();
+			List> futures = this.acksBuffer.entrySet().stream()
+					.filter(entry -> entry.getValue().size() >= minSize)
+					.map(entry -> doExecute(entry.getKey(), entry.getValue(),
+							maxSize == Integer.MAX_VALUE ? entry.getValue().size() : maxSize))
+					.collect(Collectors.toList());
+			if (!futures.isEmpty()) {
+				purgeEmptyBuffers();
+				return Collections.singletonList(null);
+			}
+			return Collections.emptyList();
+		}
+
+		private List> executeAllAcks() {
+			verifyLock();
+			List> futures = this.acksBuffer.entrySet().stream()
+					.filter(entry -> entry.getValue().size() > 0)
+					.map(entry -> doExecute(entry.getKey(), entry.getValue(), entry.getValue().size()))
+					.collect(Collectors.toList());
+			if (!futures.isEmpty()) {
+				purgeEmptyBuffers();
+			}
+			return futures;
+		}
+
+		private void verifyLock() {
+			if (this.ackLock instanceof ReentrantLock) {
+				Assert.isTrue(((ReentrantLock) this.ackLock).isHeldByCurrentThread(),
+						"no lock for executing acknowledgements");
+			}
+		}
+
+		private CompletableFuture doExecute(String groupKey, BlockingQueue> messages, int maxSize) {
+			logger.trace("Executing acknowledgement for up to {} messages {} of group {} in {}.", maxSize,
+					MessageHeaderUtils.getId(messages), groupKey, this.id);
+			List> messagesToAck = pollUpToThreshold(groupKey, messages, maxSize);
+			CompletableFuture future = manageFuture(execute(messagesToAck));
+			this.lastAcknowledgement = Instant.now();
+			return future;
+		}
+
+		private List> pollUpToThreshold(String groupKey, BlockingQueue> messages, int maxSize) {
+			return IntStream.range(0, maxSize).mapToObj(index -> pollMessage(groupKey, messages))
+					.collect(Collectors.toList());
+		}
+
+		private Message pollMessage(String groupKey, BlockingQueue> messages) {
+			Message polledMessage = messages.poll();
+			Assert.notNull(polledMessage, "poll should never return null");
+			logger.trace("Retrieved message {} from the queue for group {}. Queue size: {}",
+					MessageHeaderUtils.getId(polledMessage), groupKey, messages.size());
+			return polledMessage;
+		}
+
+		private CompletableFuture execute(Collection> messages) {
+			Assert.notEmpty(messages, "empty collection sent for acknowledgement");
+			logger.trace("Executing {} acknowledgements for {}", messages.size(), this.id);
+			return this.executingFunction.apply(messages);
+		}
+
+		private CompletableFuture manageFuture(CompletableFuture future) {
+			this.runningAcks.add(future);
+			future.whenComplete((v, t) -> {
+				if (isRunning()) {
+					this.runningAcks.remove(future);
+				}
+			});
+			return future;
+		}
+
+		private boolean isRunning() {
+			return runningFunction.get();
+		}
+
+		private void purgeEmptyBuffers() {
+			lock();
+			try {
+				List emptyAcks = this.acksBuffer.entrySet().stream().filter(entry -> entry.getValue().isEmpty())
+						.map(Map.Entry::getKey).collect(Collectors.toList());
+				logger.trace("Removing groups {} from buffer in {}", emptyAcks, this.id);
+				emptyAcks.forEach(this.acksBuffer::remove);
+			}
+			finally {
+				unlock();
+			}
+		}
+
+		private void lock() {
+			this.ackLock.lock();
+		}
+
+		private void unlock() {
+			this.ackLock.unlock();
+		}
+
+	}
+
+	private static class ThresholdAcknowledgementExecutor {
+
+		private final AcknowledgementExecutionContext context;
+
+		private final int ackThreshold;
+
+		public ThresholdAcknowledgementExecutor(int ackThreshold, AcknowledgementExecutionContext context) {
+			this.context = context;
+			this.ackThreshold = ackThreshold;
+		}
+
+		private void checkAndExecute() {
+			if (this.ackThreshold == 0) {
+				return;
+			}
+			// Will eventually finish since we're not buffering new messages
+			while (!executeThresholdAcks().isEmpty())
+				;
+		}
+
+		private List> executeThresholdAcks() {
+			this.context.lock();
+			try {
+				logger.trace("Executing acknowledgement for threshold in {}.", this.context.id);
+				return this.context.executeAcksUpTo(this.ackThreshold, this.ackThreshold);
+			}
+			finally {
+				this.context.unlock();
+			}
+		}
+	}
+
+	private static class ScheduledAcknowledgementExecution {
+
+		private final AcknowledgementExecutionContext context;
+
+		private final TaskScheduler taskScheduler;
+
+		private final Duration ackInterval;
+
+		public ScheduledAcknowledgementExecution(Duration ackInterval, TaskScheduler taskScheduler,
+				AcknowledgementExecutionContext context) {
+			this.ackInterval = ackInterval;
+			this.taskScheduler = taskScheduler;
+			this.context = context;
+		}
+
+		private void start() {
+			if (this.ackInterval != Duration.ZERO) {
+				logger.debug("Starting scheduled thread with interval of {}ms for {}", this.ackInterval.toMillis(),
+						this.context.id);
+				scheduleNextExecution(Instant.now().plus(this.ackInterval));
+			}
+		}
+
+		private void scheduleNextExecution(Instant nextExecutionDelay) {
+			if (!this.context.isRunning()) {
+				logger.debug("AcknowledgementProcessor {} stopped, not scheduling next acknowledgement execution.",
+						this.context.id);
+				return;
+			}
+			try {
+				logger.trace("Scheduling next acknowledgement execution in {}ms",
+						nextExecutionDelay.toEpochMilli() - Instant.now().toEpochMilli());
+				this.taskScheduler.schedule(this::executeScheduledAcknowledgement, nextExecutionDelay);
+			}
+			catch (Exception e) {
+				if (this.context.isRunning()) {
+					logger.warn("Error thrown when scheduling next execution in {}. Resuming.", this.context.id, e);
+				}
+				scheduleNextExecution(this.context.lastAcknowledgement.plus(this.ackInterval));
+			}
+		}
+
+		private void executeScheduledAcknowledgement() {
+			this.context.lock();
+			try {
+				pollAndExecuteScheduled();
+				scheduleNextExecution(this.context.lastAcknowledgement.plus(this.ackInterval));
+			}
+			catch (Exception e) {
+				logger.error("Error executing scheduled acknowledgement in {}. Resuming.", this.context.id, e);
+				scheduleNextExecution(this.context.lastAcknowledgement.plus(this.ackInterval));
+			}
+			finally {
+				this.context.unlock();
+			}
+		}
+
+		private void pollAndExecuteScheduled() {
+			if (Instant.now().isAfter(this.context.lastAcknowledgement.plus(this.ackInterval))) {
+				List> executionFutures = this.context.executeAllAcks();
+				if (executionFutures.isEmpty()) {
+					this.context.lastAcknowledgement = Instant.now();
+				}
+			}
+		}
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/ExecutingAcknowledgementProcessor.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/ExecutingAcknowledgementProcessor.java
new file mode 100644
index 000000000..3869eb754
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/ExecutingAcknowledgementProcessor.java
@@ -0,0 +1,37 @@
+/*
+ * 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.acknowledgement;
+
+/**
+ * {@link AcknowledgementProcessor} specialization that allows for delegating acknowledgement execution to a
+ * {@link AcknowledgementExecutor}.
+ *
+ * Such execution can be ordered according to an {@link AcknowledgementOrdering}.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see AbstractOrderingAcknowledgementProcessor
+ * @see BatchingAcknowledgementProcessor
+ * @see ImmediateAcknowledgementProcessor
+ * @see SqsAcknowledgementExecutor
+ */
+public interface ExecutingAcknowledgementProcessor extends AcknowledgementProcessor {
+
+	void setAcknowledgementExecutor(AcknowledgementExecutor acknowledgementExecutor);
+
+	void setAcknowledgementResultCallback(AsyncAcknowledgementResultCallback acknowledgementResultCallback);
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/ImmediateAcknowledgementProcessor.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/ImmediateAcknowledgementProcessor.java
new file mode 100644
index 000000000..77ab5c07c
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/ImmediateAcknowledgementProcessor.java
@@ -0,0 +1,39 @@
+/*
+ * 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.acknowledgement;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.concurrent.CompletableFuture;
+import org.springframework.messaging.Message;
+
+/**
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class ImmediateAcknowledgementProcessor extends AbstractOrderingAcknowledgementProcessor {
+
+	@Override
+	protected CompletableFuture doOnAcknowledge(Message message) {
+		return sendToExecutor(Collections.singletonList(message));
+	}
+
+	@Override
+	protected CompletableFuture doOnAcknowledge(Collection> messages) {
+		return sendToExecutor(messages);
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/SqsAcknowledgementException.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/SqsAcknowledgementException.java
new file mode 100644
index 000000000..c5d18910b
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/SqsAcknowledgementException.java
@@ -0,0 +1,68 @@
+/*
+ * 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.acknowledgement;
+
+import io.awspring.cloud.sqs.SqsException;
+import java.util.Collection;
+import java.util.stream.Collectors;
+import org.springframework.messaging.Message;
+
+/**
+ * {@link RuntimeException} that wraps an error thrown during acknowledgement execution.
+ *
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class SqsAcknowledgementException extends SqsException {
+
+	private final Collection> failedAcknowledgementMessages;
+
+	private final String queueUrl;
+
+	/**
+	 * Contruct an instance with the given parameters.
+	 * @param errorMessage the error message.
+	 * @param failedAcknowledgementMessages the messages that failed to be acknowledged.
+	 * @param queueUrl the url for the queue from which the messages were polled from.
+	 * @param e the exception cause.
+	 * @param  the messages payload type.
+	 */
+	public  SqsAcknowledgementException(String errorMessage, Collection> failedAcknowledgementMessages,
+			String queueUrl, Throwable e) {
+		super(errorMessage, e);
+		this.queueUrl = queueUrl;
+		this.failedAcknowledgementMessages = failedAcknowledgementMessages.stream().map(msg -> (Message) msg)
+				.collect(Collectors.toList());
+	}
+
+	/**
+	 * Return the messages that failed to be acknowledged.
+	 * @return the messages.
+	 */
+	public Collection> getFailedAcknowledgementMessages() {
+		return this.failedAcknowledgementMessages;
+	}
+
+	/**
+	 * Return the url for the queue from which the messages were polled from.
+	 * @return the queue url.
+	 */
+	public String getQueueUrl() {
+		return this.queueUrl;
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/SqsAcknowledgementExecutor.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/SqsAcknowledgementExecutor.java
new file mode 100644
index 000000000..12ce14502
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/SqsAcknowledgementExecutor.java
@@ -0,0 +1,137 @@
+/*
+ * 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.acknowledgement;
+
+import io.awspring.cloud.sqs.CompletableFutures;
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.QueueAttributes;
+import io.awspring.cloud.sqs.listener.QueueAttributesAware;
+import io.awspring.cloud.sqs.listener.SqsAsyncClientAware;
+import io.awspring.cloud.sqs.listener.SqsHeaders;
+import java.util.Collection;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.stream.Collectors;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.messaging.Message;
+import org.springframework.util.Assert;
+import org.springframework.util.StopWatch;
+import software.amazon.awssdk.services.sqs.SqsAsyncClient;
+import software.amazon.awssdk.services.sqs.model.DeleteMessageBatchRequest;
+import software.amazon.awssdk.services.sqs.model.DeleteMessageBatchRequestEntry;
+
+/**
+ * {@link AcknowledgementExecutor} implementation for SQS queues. Handle the messages deletion, usually requested by an
+ * {@link ExecutingAcknowledgementProcessor}.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see ExecutingAcknowledgementProcessor
+ */
+public class SqsAcknowledgementExecutor
+		implements AcknowledgementExecutor, SqsAsyncClientAware, QueueAttributesAware {
+
+	private static final Logger logger = LoggerFactory.getLogger(SqsAcknowledgementExecutor.class);
+
+	private SqsAsyncClient sqsAsyncClient;
+
+	private String queueUrl;
+
+	private String queueName;
+
+	@Override
+	public void setQueueAttributes(QueueAttributes queueAttributes) {
+		Assert.notNull(queueAttributes, "queueAttributes cannot be null");
+		this.queueUrl = queueAttributes.getQueueUrl();
+		this.queueName = queueAttributes.getQueueName();
+	}
+
+	@Override
+	public void setSqsAsyncClient(SqsAsyncClient sqsAsyncClient) {
+		Assert.notNull(sqsAsyncClient, "sqsAsyncClient cannot be null");
+		this.sqsAsyncClient = sqsAsyncClient;
+	}
+
+	@Override
+	public CompletableFuture execute(Collection> messagesToAck) {
+		try {
+			logger.debug("Executing acknowledgement for {} messages", messagesToAck.size());
+			Assert.notEmpty(messagesToAck, () -> "empty collection sent to acknowledge in queue " + this.queueName);
+			return deleteMessages(messagesToAck);
+		}
+		catch (Exception e) {
+			return CompletableFutures.failedFuture(createAcknowledgementException(messagesToAck, e));
+		}
+	}
+
+	private SqsAcknowledgementException createAcknowledgementException(Collection> messagesToAck,
+			Throwable e) {
+		return new SqsAcknowledgementException(
+				"Error acknowledging messages " + MessageHeaderUtils.getId(messagesToAck), messagesToAck, this.queueUrl,
+				e);
+	}
+
+	// @formatter:off
+	private CompletableFuture deleteMessages(Collection> messagesToAck) {
+		logger.trace("Acknowledging messages for queue {}: {}", this.queueName,
+				MessageHeaderUtils.getId(messagesToAck));
+		StopWatch watch = new StopWatch();
+		watch.start();
+		return CompletableFutures.exceptionallyCompose(this.sqsAsyncClient
+			.deleteMessageBatch(createDeleteMessageBatchRequest(messagesToAck))
+			.thenRun(() -> {}),
+				t -> CompletableFutures.failedFuture(createAcknowledgementException(messagesToAck, t)))
+			.whenComplete((v, t) -> logAckResult(messagesToAck, t, watch));
+	}
+
+	private DeleteMessageBatchRequest createDeleteMessageBatchRequest(Collection> messagesToAck) {
+		return DeleteMessageBatchRequest
+			.builder()
+			.queueUrl(this.queueUrl)
+			.entries(messagesToAck.stream().map(this::toDeleteMessageEntry).collect(Collectors.toList()))
+			.build();
+	}
+
+	private DeleteMessageBatchRequestEntry toDeleteMessageEntry(Message message) {
+		return DeleteMessageBatchRequestEntry
+			.builder()
+			.receiptHandle(MessageHeaderUtils.getHeaderAsString(message, SqsHeaders.SQS_RECEIPT_HANDLE_HEADER))
+			.id(UUID.randomUUID().toString())
+			.build();
+	}
+	// @formatter:on
+
+	private void logAckResult(Collection> messagesToAck, Throwable t, StopWatch watch) {
+		watch.stop();
+		long totalTimeMillis = watch.getTotalTimeMillis();
+		if (totalTimeMillis > 10000) {
+			logger.warn("Acknowledgement operation took {} seconds to finish in queue {} for messages {}",
+					totalTimeMillis, this.queueName, MessageHeaderUtils.getId(messagesToAck));
+		}
+		if (t != null) {
+			logger.error("Error acknowledging in queue {} messages {} in {}ms", this.queueName,
+					MessageHeaderUtils.getId(messagesToAck), totalTimeMillis,
+					t instanceof CompletionException ? t.getCause() : t);
+		}
+		else {
+			logger.trace("Done acknowledging in queue {} messages: {} in {}ms", this.queueName,
+					MessageHeaderUtils.getId(messagesToAck), totalTimeMillis);
+		}
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/AcknowledgementHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/AcknowledgementHandler.java
new file mode 100644
index 000000000..a1b25baab
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/AcknowledgementHandler.java
@@ -0,0 +1,72 @@
+/*
+ * 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.acknowledgement.handler;
+
+import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.springframework.messaging.Message;
+
+/**
+ * Interface for managing acknowledgement in success and failure scenarios.
+ *
+ * @param  the {@link Message} payload type.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public interface AcknowledgementHandler {
+
+	/**
+	 * Invoked when message processing completes successfully for a single message.
+	 * @param message the message.
+	 * @return a completable future signaling acknowledgement completion.
+	 */
+	default CompletableFuture onSuccess(Message message, AcknowledgementCallback callback) {
+		return CompletableFuture.completedFuture(null);
+	}
+
+	/**
+	 * Invoked when message processing completes successfully for a batch of messages.
+	 * @param messages the messages.
+	 * @return a completable future signaling acknowledgement completion.
+	 */
+	default CompletableFuture onSuccess(Collection> messages, AcknowledgementCallback callback) {
+		return CompletableFuture.completedFuture(null);
+	}
+
+	/**
+	 * Invoked when message processing completes with an error for a single message.
+	 * @param message the message.
+	 * @param t the error thrown by the listener.
+	 * @return a completable future signaling acknowledgement completion.
+	 */
+	default CompletableFuture onError(Message message, Throwable t, AcknowledgementCallback callback) {
+		return CompletableFuture.completedFuture(null);
+	}
+
+	/**
+	 * Invoked when message processing completes with an error for a batch of messages.
+	 * @param messages the messages.
+	 * @param t the error thrown by the listener.
+	 * @return a completable future signaling acknowledgement completion.
+	 */
+	default CompletableFuture onError(Collection> messages, Throwable t,
+			AcknowledgementCallback callback) {
+		return CompletableFuture.completedFuture(null);
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/AcknowledgementMode.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/AcknowledgementMode.java
new file mode 100644
index 000000000..f1b9e9339
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/AcknowledgementMode.java
@@ -0,0 +1,47 @@
+/*
+ * 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.acknowledgement.handler;
+
+/**
+ * Configures the acknowledgement behavior for this container.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see OnSuccessAcknowledgementHandler
+ * @see AlwaysAcknowledgementHandler
+ * @see NeverAcknowledgementHandler
+ * @see io.awspring.cloud.sqs.listener.ContainerOptions
+ */
+public enum AcknowledgementMode {
+
+	/**
+	 * Messages will be acknowledged when message processing is successful.
+	 */
+	ON_SUCCESS,
+
+	/**
+	 * Messages will be acknowledged whether processing was completed successfully or with an error.
+	 */
+	ALWAYS,
+
+	/**
+	 * Messages will not be acknowledged automatically by the container.
+	 * @see io.awspring.cloud.sqs.listener.acknowledgement.Acknowledgement
+	 * @see io.awspring.cloud.sqs.listener.acknowledgement.AsyncAcknowledgement
+	 */
+	MANUAL
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/AlwaysAcknowledgementHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/AlwaysAcknowledgementHandler.java
new file mode 100644
index 000000000..b9f9facdb
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/AlwaysAcknowledgementHandler.java
@@ -0,0 +1,59 @@
+/*
+ * 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.acknowledgement.handler;
+
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.messaging.Message;
+
+/**
+ * {@link AcknowledgementHandler} implementation that acknowledges on both success and error. * @author Tomaz Fernandes
+ * @since 3.0
+ * @see AcknowledgementMode#ALWAYS
+ */
+public class AlwaysAcknowledgementHandler implements AcknowledgementHandler {
+
+	private static final Logger logger = LoggerFactory.getLogger(AlwaysAcknowledgementHandler.class);
+
+	@Override
+	public CompletableFuture onSuccess(Message message, AcknowledgementCallback callback) {
+		logger.trace("Acknowledging message {}", MessageHeaderUtils.getId(message));
+		return callback.onAcknowledge(message);
+	}
+
+	@Override
+	public CompletableFuture onSuccess(Collection> messages, AcknowledgementCallback callback) {
+		logger.trace("Acknowledging messages {}", MessageHeaderUtils.getId(messages));
+		return callback.onAcknowledge(messages);
+	}
+
+	@Override
+	public CompletableFuture onError(Message message, Throwable t, AcknowledgementCallback callback) {
+		logger.trace("Acknowledging message {} on error {}", MessageHeaderUtils.getId(message), t.getMessage());
+		return callback.onAcknowledge(message);
+	}
+
+	@Override
+	public CompletableFuture onError(Collection> messages, Throwable t,
+			AcknowledgementCallback callback) {
+		logger.trace("Acknowledging messages {} on error {}", MessageHeaderUtils.getId(messages), t);
+		return callback.onAcknowledge(messages);
+	}
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/NeverAcknowledgementHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/NeverAcknowledgementHandler.java
new file mode 100644
index 000000000..4512ee070
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/NeverAcknowledgementHandler.java
@@ -0,0 +1,61 @@
+/*
+ * 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.acknowledgement.handler;
+
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.messaging.Message;
+
+/**
+ * {@link AcknowledgementHandler} implementation that never acknowledges regardless of the result.
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see AcknowledgementMode#MANUAL
+ */
+public class NeverAcknowledgementHandler implements AcknowledgementHandler {
+
+	private static final Logger logger = LoggerFactory.getLogger(NeverAcknowledgementHandler.class);
+
+	@Override
+	public CompletableFuture onSuccess(Message message, AcknowledgementCallback callback) {
+		logger.trace("Skipping ack for message {}", MessageHeaderUtils.getId(message));
+		return CompletableFuture.completedFuture(null);
+	}
+
+	@Override
+	public CompletableFuture onSuccess(Collection> messages, AcknowledgementCallback callback) {
+		logger.trace("Skipping acks for messages {}", MessageHeaderUtils.getId(messages));
+		return CompletableFuture.completedFuture(null);
+	}
+
+	@Override
+	public CompletableFuture onError(Message message, Throwable t, AcknowledgementCallback callback) {
+		logger.trace("Skipping ack for message {}", MessageHeaderUtils.getId(message));
+		return CompletableFuture.completedFuture(null);
+	}
+
+	@Override
+	public CompletableFuture onError(Collection> messages, Throwable t,
+			AcknowledgementCallback callback) {
+		logger.trace("Skipping acks for messages {}", MessageHeaderUtils.getId(messages));
+		return CompletableFuture.completedFuture(null);
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/OnSuccessAcknowledgementHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/OnSuccessAcknowledgementHandler.java
new file mode 100644
index 000000000..cee13814e
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/OnSuccessAcknowledgementHandler.java
@@ -0,0 +1,61 @@
+/*
+ * 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.acknowledgement.handler;
+
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.messaging.Message;
+
+/**
+ * {@link AcknowledgementHandler} implementation that only acknowledges on success.
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see AcknowledgementMode#ON_SUCCESS
+ */
+public class OnSuccessAcknowledgementHandler implements AcknowledgementHandler {
+
+	private static final Logger logger = LoggerFactory.getLogger(OnSuccessAcknowledgementHandler.class);
+
+	@Override
+	public CompletableFuture onSuccess(Message message, AcknowledgementCallback callback) {
+		logger.trace("Acknowledging message {}", MessageHeaderUtils.getId(message));
+		return callback.onAcknowledge(message);
+	}
+
+	@Override
+	public CompletableFuture onSuccess(Collection> messages, AcknowledgementCallback callback) {
+		logger.trace("Acknowledging messages {}", MessageHeaderUtils.getId(messages));
+		return callback.onAcknowledge(messages);
+	}
+
+	@Override
+	public CompletableFuture onError(Message message, Throwable t, AcknowledgementCallback callback) {
+		logger.trace("Skipping ack for message {}", MessageHeaderUtils.getId(message));
+		return CompletableFuture.completedFuture(null);
+	}
+
+	@Override
+	public CompletableFuture onError(Collection> messages, Throwable t,
+			AcknowledgementCallback callback) {
+		logger.trace("Skipping acks for messages {}", MessageHeaderUtils.getId(messages));
+		return CompletableFuture.completedFuture(null);
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/package-info.java
new file mode 100644
index 000000000..0cf0052d2
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+/**
+ * Auto-configuration for Amazon SQS (Simple Queue Service) integrations.
+ */
+@org.springframework.lang.NonNullApi
+@org.springframework.lang.NonNullFields
+package io.awspring.cloud.sqs.listener.acknowledgement.handler;
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/package-info.java
new file mode 100644
index 000000000..353ad9167
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/acknowledgement/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+/**
+ * Auto-configuration for Amazon SQS (Simple Queue Service) integrations.
+ */
+@org.springframework.lang.NonNullApi
+@org.springframework.lang.NonNullFields
+package io.awspring.cloud.sqs.listener.acknowledgement;
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/adapter/AbstractMethodInvokingListenerAdapter.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/adapter/AbstractMethodInvokingListenerAdapter.java
new file mode 100644
index 000000000..5ab62712b
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/adapter/AbstractMethodInvokingListenerAdapter.java
@@ -0,0 +1,86 @@
+/*
+ * 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.adapter;
+
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.ListenerExecutionFailedException;
+import java.util.Collection;
+import java.util.Collections;
+import org.springframework.messaging.Message;
+import org.springframework.messaging.handler.invocation.InvocableHandlerMethod;
+import org.springframework.messaging.support.MessageBuilder;
+import org.springframework.util.Assert;
+
+/**
+ *
+ * Base class for invoking an {@link InvocableHandlerMethod}.
+ *
+ * Also handles wrapping the failed messages into a {@link ListenerExecutionFailedException}.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public abstract class AbstractMethodInvokingListenerAdapter {
+
+	private final InvocableHandlerMethod handlerMethod;
+
+	/**
+	 * Create an instance with the provided {@link InvocableHandlerMethod}.
+	 * @param handlerMethod the handler method.
+	 */
+	protected AbstractMethodInvokingListenerAdapter(InvocableHandlerMethod handlerMethod) {
+		Assert.notNull(handlerMethod, "handlerMethod cannot be null");
+		this.handlerMethod = handlerMethod;
+	}
+
+	/**
+	 * Invokes the handler for the provided message.
+	 * @param message the message.
+	 * @return the invocation result.
+	 */
+	protected final Object invokeHandler(Message message) {
+		try {
+			return this.handlerMethod.invoke(message);
+		}
+		catch (Exception e) {
+			throw createListenerException(message, e);
+		}
+	}
+
+	/**
+	 * Invokes the handler for the provided messages.
+	 * @param messages the messages.
+	 * @return the invocation result.
+	 */
+	protected final Object invokeHandler(Collection> messages) {
+		try {
+			return this.handlerMethod.invoke(MessageBuilder.withPayload(messages).build());
+		}
+		catch (Exception e) {
+			throw createListenerException(messages, e);
+		}
+	}
+
+	protected ListenerExecutionFailedException createListenerException(Collection> messages, Throwable t) {
+		return new ListenerExecutionFailedException(
+				"Listener failed to process messages " + MessageHeaderUtils.getId(messages), t, messages);
+	}
+
+	protected ListenerExecutionFailedException createListenerException(Message message, Throwable t) {
+		return createListenerException(Collections.singletonList(message), t);
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/adapter/AsyncMessagingMessageListenerAdapter.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/adapter/AsyncMessagingMessageListenerAdapter.java
new file mode 100644
index 000000000..8c3909715
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/adapter/AsyncMessagingMessageListenerAdapter.java
@@ -0,0 +1,80 @@
+/*
+ * 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.adapter;
+
+import io.awspring.cloud.sqs.CompletableFutures;
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.AsyncMessageListener;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.springframework.messaging.Message;
+import org.springframework.messaging.handler.invocation.InvocableHandlerMethod;
+
+/**
+ * {@link AsyncMessageListener} implementation to handle a message by invoking a method handler.
+ *
+ * @param  the {@link Message} payload type.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+@SuppressWarnings("unchecked")
+public class AsyncMessagingMessageListenerAdapter extends AbstractMethodInvokingListenerAdapter
+		implements AsyncMessageListener {
+
+	public AsyncMessagingMessageListenerAdapter(InvocableHandlerMethod handlerMethod) {
+		super(handlerMethod);
+	}
+
+	@Override
+	public CompletableFuture onMessage(Message message) {
+		try {
+			return CompletableFutures.exceptionallyCompose(invokeAsync(message),
+					t -> CompletableFutures.failedFuture(createListenerException(message, t)));
+		}
+		catch (ClassCastException e) {
+			return CompletableFutures.failedFuture(new IllegalArgumentException(
+					"Invalid return type for message " + MessageHeaderUtils.getId(message), e));
+		}
+		catch (Exception e) {
+			return CompletableFutures.failedFuture(e);
+		}
+	}
+
+	private CompletableFuture invokeAsync(Message message) {
+		return (CompletableFuture) super.invokeHandler(message);
+	}
+
+	@Override
+	public CompletableFuture onMessage(Collection> messages) {
+		try {
+			return CompletableFutures.exceptionallyCompose(invokeAsync(messages),
+					t -> CompletableFutures.failedFuture(createListenerException(messages, t)));
+		}
+		catch (ClassCastException e) {
+			return CompletableFutures.failedFuture(new IllegalArgumentException(
+					"Invalid return type for messages " + MessageHeaderUtils.getId(messages), e));
+		}
+		catch (Exception e) {
+			return CompletableFutures.failedFuture(e);
+		}
+	}
+
+	private CompletableFuture invokeAsync(Collection> messages) {
+		return (CompletableFuture) super.invokeHandler(messages);
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/adapter/MessagingMessageListenerAdapter.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/adapter/MessagingMessageListenerAdapter.java
new file mode 100644
index 000000000..b91a0a240
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/adapter/MessagingMessageListenerAdapter.java
@@ -0,0 +1,48 @@
+/*
+ * 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.adapter;
+
+import io.awspring.cloud.sqs.listener.MessageListener;
+import java.util.Collection;
+import org.springframework.messaging.Message;
+import org.springframework.messaging.handler.invocation.InvocableHandlerMethod;
+
+/**
+ * {@link io.awspring.cloud.sqs.listener.MessageListener} implementation to handle a message by invoking a method
+ * handler.
+ *
+ * @param  the {@link Message} payload type.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class MessagingMessageListenerAdapter extends AbstractMethodInvokingListenerAdapter
+		implements MessageListener {
+
+	public MessagingMessageListenerAdapter(InvocableHandlerMethod handlerMethod) {
+		super(handlerMethod);
+	}
+
+	@Override
+	public void onMessage(Message message) {
+		super.invokeHandler(message);
+	}
+
+	@Override
+	public void onMessage(Collection> messages) {
+		super.invokeHandler(messages);
+	}
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/adapter/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/adapter/package-info.java
new file mode 100644
index 000000000..212d82a16
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/adapter/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+/**
+ * Auto-configuration for Amazon SQS (Simple Queue Service) integrations.
+ */
+@org.springframework.lang.NonNullApi
+@org.springframework.lang.NonNullFields
+package io.awspring.cloud.sqs.listener.adapter;
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/errorhandler/AsyncErrorHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/errorhandler/AsyncErrorHandler.java
new file mode 100644
index 000000000..7ddf46084
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/errorhandler/AsyncErrorHandler.java
@@ -0,0 +1,57 @@
+/*
+ * 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.errorhandler;
+
+import io.awspring.cloud.sqs.CompletableFutures;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.springframework.messaging.Message;
+
+/**
+ * Interface for handling message processing errors async. If the error handler completes normally, the message or
+ * messages will be considered recovered for further processing purposes. If the message should not be considered
+ * recovered, an exception must be returned from the error handler.
+ *
+ * @param  the {@link Message} payload type.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public interface AsyncErrorHandler {
+
+	/**
+	 * Asynchronously handle the errors thrown processing the given {@link Message}.
+	 * @param message the message.
+	 * @param t the thrown exception.
+	 * @return a completable future.
+	 */
+	default CompletableFuture handle(Message message, Throwable t) {
+		return CompletableFutures
+				.failedFuture(new UnsupportedOperationException("Single message error handling not implemented"));
+	}
+
+	/**
+	 * Asynchronously handle the errors thrown processing the given {@link Message} instances.
+	 * @param messages the messages.
+	 * @param t the thrown exception.
+	 * @return a completable future.
+	 */
+	default CompletableFuture handle(Collection> messages, Throwable t) {
+		return CompletableFutures
+				.failedFuture(new UnsupportedOperationException("Batch error handling not implemented"));
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/errorhandler/ErrorHandler.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/errorhandler/ErrorHandler.java
new file mode 100644
index 000000000..71c8f7122
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/errorhandler/ErrorHandler.java
@@ -0,0 +1,49 @@
+/*
+ * 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.errorhandler;
+
+import java.util.Collection;
+import org.springframework.messaging.Message;
+
+/**
+ * Interface for handling errors. If the error handler completes normally, the message or messages will be considered
+ * recovered for further processing purposes. If the message should not be considered recovered, an exception must be
+ * thrown from the error handler.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public interface ErrorHandler {
+
+	/**
+	 * Handle errors thrown when processing a {@link Message}.
+	 * @param message the message.
+	 * @param t the thrown exception.
+	 */
+	default void handle(Message message, Throwable t) {
+		throw new UnsupportedOperationException("Single message error handling not implemented");
+	}
+
+	/**
+	 * Handle errors thrown when processing a batch of {@link Message}s.
+	 * @param messages the messages.
+	 * @param t the thrown exception.
+	 */
+	default void handle(Collection> messages, Throwable t) {
+		throw new UnsupportedOperationException("Batch error handling not implemented");
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/errorhandler/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/errorhandler/package-info.java
new file mode 100644
index 000000000..cd992db06
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/errorhandler/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+/**
+ * Auto-configuration for Amazon SQS (Simple Queue Service) integrations.
+ */
+@org.springframework.lang.NonNullApi
+@org.springframework.lang.NonNullFields
+package io.awspring.cloud.sqs.listener.errorhandler;
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/interceptor/AsyncMessageInterceptor.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/interceptor/AsyncMessageInterceptor.java
new file mode 100644
index 000000000..4cbf87915
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/interceptor/AsyncMessageInterceptor.java
@@ -0,0 +1,71 @@
+/*
+ * 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.interceptor;
+
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.springframework.lang.Nullable;
+import org.springframework.messaging.Message;
+
+/**
+ * Async interface for intercepting messages before and after execution.
+ *
+ * @param  the {@link Message} payload type.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public interface AsyncMessageInterceptor {
+
+	/**
+	 * Perform an action on the message or return a different one before processing. Executed before processing. This
+	 * method must not return a CompletableFuture.completedFuture(null).
+	 * @param message the message to be intercepted.
+	 * @return a completable future containing the resulting message.
+	 */
+	default CompletableFuture> intercept(Message message) {
+		return CompletableFuture.completedFuture(message);
+	}
+
+	/**
+	 * Perform an action on the messages or return different ones before processing. This method must not return a
+	 * CompletableFuture.completedFuture(null) or empty collection.
+	 * @param messages the messages to be intercepted.
+	 * @return a completable future containing the resulting messages.
+	 */
+	default CompletableFuture>> intercept(Collection> messages) {
+		return CompletableFuture.completedFuture(messages);
+	}
+
+	/**
+	 * Perform an action after the listener completes either with success or error.
+	 * @param message the message to be intercepted.
+	 * @return a completable future.
+	 */
+	default CompletableFuture afterProcessing(Message message, @Nullable Throwable t) {
+		return CompletableFuture.completedFuture(null);
+	}
+
+	/**
+	 * Perform an action after the listener completes either with success or error.
+	 * @param messages the messages to be intercepted.
+	 * @return a completable future.
+	 */
+	default CompletableFuture afterProcessing(Collection> messages, @Nullable Throwable t) {
+		return CompletableFuture.completedFuture(null);
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/interceptor/MessageInterceptor.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/interceptor/MessageInterceptor.java
new file mode 100644
index 000000000..5d11a9260
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/interceptor/MessageInterceptor.java
@@ -0,0 +1,66 @@
+/*
+ * 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.interceptor;
+
+import java.util.Collection;
+import org.springframework.lang.Nullable;
+import org.springframework.messaging.Message;
+
+/**
+ * Interface for intercepting messages before and after execution.
+ *
+ * @param  the {@link Message} payload type.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public interface MessageInterceptor {
+
+	/**
+	 * Perform an action on the message or return a different one before processing. Executed before processing. This
+	 * method must not return null.
+	 * @param message the message to be intercepted.
+	 * @return a completable future containing the resulting message.
+	 */
+	default Message intercept(Message message) {
+		return message;
+	}
+
+	/**
+	 * Perform an action on the messages or return a different ones before processing. Executed before processing. This
+	 * method must not return null or an empty collection.
+	 * @param messages the message to be intercepted.
+	 * @return a completable future containing the resulting message.
+	 */
+	default Collection> intercept(Collection> messages) {
+		return messages;
+	}
+
+	/**
+	 * Perform an action after the listener completes either with success or error.
+	 * @param message the message to be intercepted.
+	 */
+	default void afterProcessing(Message message, @Nullable Throwable t) {
+	}
+
+	/**
+	 * Perform an action after the listener completes either with success or error.
+	 * @param messages the messages to be intercepted.
+	 */
+	default void afterProcessing(Collection> messages, @Nullable Throwable t) {
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/interceptor/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/interceptor/package-info.java
new file mode 100644
index 000000000..3654afd0a
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/interceptor/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+/**
+ * Auto-configuration for Amazon SQS (Simple Queue Service) integrations.
+ */
+@org.springframework.lang.NonNullApi
+@org.springframework.lang.NonNullFields
+package io.awspring.cloud.sqs.listener.interceptor;
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/package-info.java
new file mode 100644
index 000000000..e47c13d9d
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+/**
+ * Auto-configuration for Amazon SQS (Simple Queue Service) integrations.
+ */
+@org.springframework.lang.NonNullApi
+@org.springframework.lang.NonNullFields
+package io.awspring.cloud.sqs.listener;
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/AbstractAfterProcessingInterceptorExecutionStage.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/AbstractAfterProcessingInterceptorExecutionStage.java
new file mode 100644
index 000000000..5e4f38431
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/AbstractAfterProcessingInterceptorExecutionStage.java
@@ -0,0 +1,80 @@
+/*
+ * 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.pipeline;
+
+import io.awspring.cloud.sqs.CompletableFutures;
+import io.awspring.cloud.sqs.listener.ListenerExecutionFailedException;
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.messaging.Message;
+
+/**
+ * Stage responsible for executing the {@link AsyncMessageInterceptor}s after message processing.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public abstract class AbstractAfterProcessingInterceptorExecutionStage implements MessageProcessingPipeline {
+
+	private static final Logger logger = LoggerFactory
+			.getLogger(AbstractAfterProcessingInterceptorExecutionStage.class);
+
+	// @formatter:off
+	@Override
+	public CompletableFuture> process(CompletableFuture> messageFuture,
+			MessageProcessingContext context) {
+		return CompletableFutures.handleCompose(messageFuture,
+			(v, t) -> t == null
+				? applyInterceptors(v, null, getMessageInterceptors(context))
+				: applyInterceptors(ListenerExecutionFailedException.unwrapMessage(t), t, getMessageInterceptors(context))
+				.thenCompose(msg -> CompletableFutures.failedFuture(t)));
+	}
+
+	protected abstract Collection> getMessageInterceptors(MessageProcessingContext context);
+
+	private CompletableFuture> applyInterceptors(Message message, Throwable t,
+			Collection> messageInterceptors) {
+		return messageInterceptors.stream()
+			.reduce(CompletableFuture. completedFuture(null),
+				(voidFuture, interceptor) -> voidFuture.thenCompose(theVoid -> interceptor.afterProcessing(message, t)),
+				(a, b) -> a)
+			.thenApply(theVoid -> message);
+	}
+
+	@Override
+	public CompletableFuture>> processMany(
+			CompletableFuture>> messagesFuture, MessageProcessingContext context) {
+		return CompletableFutures.handleCompose(messagesFuture,
+			(v, t) -> t == null
+				? applyInterceptors(v, null, getMessageInterceptors(context))
+				: applyInterceptors(ListenerExecutionFailedException.unwrapMessages(t), t, getMessageInterceptors(context))
+				.thenCompose(msg -> CompletableFutures.failedFuture(t)));
+	}
+
+	private CompletableFuture>> applyInterceptors(Collection> messages, Throwable t,
+			Collection> messageInterceptors) {
+		return messageInterceptors.stream()
+			.reduce(CompletableFuture.completedFuture(null),
+				(voidFuture, interceptor) -> voidFuture.thenCompose(theVoid -> interceptor.afterProcessing(messages, t)),
+				(a, b) -> a)
+			.thenApply(theVoid -> messages);
+	}
+	// @formatter:on
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/AbstractBeforeProcessingInterceptorExecutionStage.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/AbstractBeforeProcessingInterceptorExecutionStage.java
new file mode 100644
index 000000000..6f60ceeeb
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/AbstractBeforeProcessingInterceptorExecutionStage.java
@@ -0,0 +1,75 @@
+/*
+ * 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.pipeline;
+
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Function;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.messaging.Message;
+import org.springframework.util.Assert;
+
+/**
+ * Stage responsible for executing the {@link AsyncMessageInterceptor}s before message processing.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public abstract class AbstractBeforeProcessingInterceptorExecutionStage implements MessageProcessingPipeline {
+
+	private static final Logger logger = LoggerFactory
+			.getLogger(AbstractBeforeProcessingInterceptorExecutionStage.class);
+
+	@Override
+	public CompletableFuture> process(Message message, MessageProcessingContext context) {
+		logger.trace("Processing message {}", MessageHeaderUtils.getId(message));
+		return getInterceptors(context).stream().reduce(CompletableFuture.completedFuture(message), (messageFuture,
+				interceptor) -> messageFuture.thenCompose(interceptor::intercept).thenApply(validateMessageNotNull()),
+				(a, b) -> a);
+	}
+
+	@Override
+	public CompletableFuture>> process(Collection> messages,
+			MessageProcessingContext context) {
+		logger.trace("Processing messages {}", MessageHeaderUtils.getId(messages));
+		return getInterceptors(context)
+				.stream().reduce(
+						CompletableFuture.completedFuture(messages), (messageFuture, interceptor) -> messageFuture
+								.thenCompose(interceptor::intercept).thenApply(validateMessagesNotEmpty()),
+						(a, b) -> a);
+	}
+
+	protected abstract Collection> getInterceptors(MessageProcessingContext context);
+
+	private Function, Message> validateMessageNotNull() {
+		return msg -> {
+			Assert.notNull(msg, "Interceptor must not return null messages");
+			return msg;
+		};
+	}
+
+	private Function>, Collection>> validateMessagesNotEmpty() {
+		return msgs -> {
+			Assert.notEmpty(msgs, "Interceptor must not return null or empty collection");
+			return msgs;
+		};
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/AcknowledgementHandlerExecutionStage.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/AcknowledgementHandlerExecutionStage.java
new file mode 100644
index 000000000..4e2753b17
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/AcknowledgementHandlerExecutionStage.java
@@ -0,0 +1,68 @@
+/*
+ * 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.pipeline;
+
+import io.awspring.cloud.sqs.CompletableFutures;
+import io.awspring.cloud.sqs.listener.ListenerExecutionFailedException;
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementHandler;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.messaging.Message;
+
+/**
+ * Stage responsible for executing the {@link AcknowledgementHandler}.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class AcknowledgementHandlerExecutionStage implements MessageProcessingPipeline {
+
+	private static final Logger logger = LoggerFactory.getLogger(AcknowledgementHandlerExecutionStage.class);
+
+	private final AcknowledgementHandler acknowledgementHandler;
+
+	public AcknowledgementHandlerExecutionStage(MessageProcessingConfiguration configuration) {
+		this.acknowledgementHandler = configuration.getAckHandler();
+	}
+
+	@Override
+	public CompletableFuture> process(CompletableFuture> messageFuture,
+			MessageProcessingContext context) {
+		return CompletableFutures.handleCompose(messageFuture, (v, t) -> t == null
+				? this.acknowledgementHandler.onSuccess(v, context.getAcknowledgmentCallback()).thenApply(theVoid -> v)
+				: this.acknowledgementHandler
+						.onError(ListenerExecutionFailedException.unwrapMessage(t), t,
+								context.getAcknowledgmentCallback())
+						.thenCompose(theVoid -> CompletableFutures.failedFuture(t)));
+	}
+
+	@Override
+	public CompletableFuture>> processMany(
+			CompletableFuture>> messagesFuture, MessageProcessingContext context) {
+		return CompletableFutures.handleCompose(messagesFuture, (v, t) -> {
+			Collection> originalMessages = ListenerExecutionFailedException.unwrapMessages(t);
+			return t == null
+					? this.acknowledgementHandler.onSuccess(v, context.getAcknowledgmentCallback())
+							.thenApply(theVoid -> v)
+					: this.acknowledgementHandler.onError(originalMessages, t, context.getAcknowledgmentCallback())
+							.thenCompose(theVoid -> CompletableFutures.failedFuture(t));
+		});
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/AfterProcessingContextInterceptorExecutionStage.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/AfterProcessingContextInterceptorExecutionStage.java
new file mode 100644
index 000000000..cf681e5a5
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/AfterProcessingContextInterceptorExecutionStage.java
@@ -0,0 +1,40 @@
+/*
+ * 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.pipeline;
+
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor;
+import java.util.Collection;
+
+/**
+ * Stage responsible for executing the {@link AsyncMessageInterceptor} instances from the
+ * {@link MessageProcessingContext} after message processing.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class AfterProcessingContextInterceptorExecutionStage
+		extends AbstractAfterProcessingInterceptorExecutionStage {
+
+	public AfterProcessingContextInterceptorExecutionStage(MessageProcessingConfiguration configuration) {
+	}
+
+	@Override
+	protected Collection> getMessageInterceptors(MessageProcessingContext context) {
+		return context.getInterceptors();
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/AfterProcessingInterceptorExecutionStage.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/AfterProcessingInterceptorExecutionStage.java
new file mode 100644
index 000000000..b567c01c2
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/AfterProcessingInterceptorExecutionStage.java
@@ -0,0 +1,40 @@
+/*
+ * 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.pipeline;
+
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor;
+import java.util.Collection;
+
+/**
+ * Stage responsible for executing the {@link AsyncMessageInterceptor}s after message processing.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class AfterProcessingInterceptorExecutionStage extends AbstractAfterProcessingInterceptorExecutionStage {
+
+	private final Collection> messageInterceptors;
+
+	public AfterProcessingInterceptorExecutionStage(MessageProcessingConfiguration configuration) {
+		this.messageInterceptors = configuration.getMessageInterceptors();
+	}
+
+	@Override
+	protected Collection> getMessageInterceptors(MessageProcessingContext context) {
+		return this.messageInterceptors;
+	}
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/BeforeProcessingContextInterceptorExecutionStage.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/BeforeProcessingContextInterceptorExecutionStage.java
new file mode 100644
index 000000000..9301af9cb
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/BeforeProcessingContextInterceptorExecutionStage.java
@@ -0,0 +1,39 @@
+/*
+ * 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.pipeline;
+
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor;
+import java.util.Collection;
+
+/**
+ * Stage responsible for executing the {@link AsyncMessageInterceptor}s from the {@link MessageProcessingContext} before
+ * message processing.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class BeforeProcessingContextInterceptorExecutionStage
+		extends AbstractBeforeProcessingInterceptorExecutionStage {
+
+	public BeforeProcessingContextInterceptorExecutionStage(MessageProcessingConfiguration configuration) {
+	}
+
+	@Override
+	protected Collection> getInterceptors(MessageProcessingContext context) {
+		return context.getInterceptors();
+	}
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/BeforeProcessingInterceptorExecutionStage.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/BeforeProcessingInterceptorExecutionStage.java
new file mode 100644
index 000000000..3c725fbb3
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/BeforeProcessingInterceptorExecutionStage.java
@@ -0,0 +1,41 @@
+/*
+ * 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.pipeline;
+
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor;
+import java.util.Collection;
+
+/**
+ * Stage responsible for executing the {@link AsyncMessageInterceptor}s before message processing.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class BeforeProcessingInterceptorExecutionStage extends AbstractBeforeProcessingInterceptorExecutionStage {
+
+	private final Collection> messageInterceptors;
+
+	public BeforeProcessingInterceptorExecutionStage(MessageProcessingConfiguration configuration) {
+		this.messageInterceptors = configuration.getMessageInterceptors();
+	}
+
+	@Override
+	protected Collection> getInterceptors(MessageProcessingContext context) {
+		return this.messageInterceptors;
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/ErrorHandlerExecutionStage.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/ErrorHandlerExecutionStage.java
new file mode 100644
index 000000000..68eca2eba
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/ErrorHandlerExecutionStage.java
@@ -0,0 +1,86 @@
+/*
+ * 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.pipeline;
+
+import io.awspring.cloud.sqs.CompletableFutures;
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.ListenerExecutionFailedException;
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.messaging.Message;
+
+/**
+ * Stage responsible for executing the {@link AsyncErrorHandler} after a failed
+ * {@link io.awspring.cloud.sqs.listener.MessageListener} execution.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class ErrorHandlerExecutionStage implements MessageProcessingPipeline {
+
+	private static final Logger logger = LoggerFactory.getLogger(ErrorHandlerExecutionStage.class);
+
+	private final AsyncErrorHandler errorHandler;
+
+	public ErrorHandlerExecutionStage(MessageProcessingConfiguration context) {
+		this.errorHandler = context.getErrorHandler();
+	}
+
+	@Override
+	public CompletableFuture> process(CompletableFuture> messageFuture,
+			MessageProcessingContext context) {
+		return this.errorHandler == null ? messageFuture
+				: CompletableFutures.exceptionallyCompose(messageFuture,
+						t -> handleError(ListenerExecutionFailedException.unwrapMessage(t), t));
+	}
+
+	private CompletableFuture> handleError(Message failedMessage, Throwable t) {
+		logger.debug("Handling error {} for message {}", t, MessageHeaderUtils.getId(failedMessage));
+		return CompletableFutures.exceptionallyCompose(
+				this.errorHandler.handle(failedMessage, t).thenApply(theVoid -> failedMessage),
+				eht -> CompletableFutures.failedFuture(maybeWrap(failedMessage, eht)));
+	}
+
+	private Throwable maybeWrap(Message failedMessage, Throwable eht) {
+		return ListenerExecutionFailedException.hasListenerException(eht) ? eht
+				: new ListenerExecutionFailedException("Error handler returned an exception", eht, failedMessage);
+	}
+
+	@Override
+	public CompletableFuture>> processMany(
+			CompletableFuture>> messagesFuture, MessageProcessingContext context) {
+		return this.errorHandler == null ? messagesFuture
+				: CompletableFutures.exceptionallyCompose(messagesFuture,
+						t -> handleErrors(ListenerExecutionFailedException.unwrapMessages(t), t));
+	}
+
+	private CompletableFuture>> handleErrors(Collection> failedMessages, Throwable t) {
+		logger.debug("Handling error {} for message {}", t, MessageHeaderUtils.getId(failedMessages));
+		return CompletableFutures.exceptionallyCompose(
+				this.errorHandler.handle(failedMessages, t).thenApply(theVoid -> failedMessages),
+				eht -> CompletableFutures.failedFuture(maybeWrap(failedMessages, eht)));
+	}
+
+	private Throwable maybeWrap(Collection> failedMessages, Throwable eht) {
+		return ListenerExecutionFailedException.hasListenerException(eht) ? eht
+				: new ListenerExecutionFailedException("Error handler returned an exception", eht, failedMessages);
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/MessageListenerExecutionStage.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/MessageListenerExecutionStage.java
new file mode 100644
index 000000000..80666b4a2
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/MessageListenerExecutionStage.java
@@ -0,0 +1,64 @@
+/*
+ * 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.pipeline;
+
+import io.awspring.cloud.sqs.CompletableFutures;
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.AsyncMessageListener;
+import io.awspring.cloud.sqs.listener.ListenerExecutionFailedException;
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.messaging.Message;
+
+/**
+ * Stage responsible for executing the {@link AsyncMessageListener}.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class MessageListenerExecutionStage implements MessageProcessingPipeline {
+
+	private static final Logger logger = LoggerFactory.getLogger(MessageListenerExecutionStage.class);
+
+	private final AsyncMessageListener messageListener;
+
+	public MessageListenerExecutionStage(MessageProcessingConfiguration configuration) {
+		this.messageListener = configuration.getMessageListener();
+	}
+
+	@Override
+	public CompletableFuture> process(Message message, MessageProcessingContext context) {
+		logger.trace("Processing message {}", MessageHeaderUtils.getId(message));
+		return CompletableFutures.exceptionallyCompose(
+				this.messageListener.onMessage(message).thenApply(theVoid -> message),
+				t -> CompletableFutures.failedFuture(ListenerExecutionFailedException.hasListenerException(t) ? t
+						: new ListenerExecutionFailedException("Listener failed to process message", t, message)));
+	}
+
+	@Override
+	public CompletableFuture>> process(Collection> messages,
+			MessageProcessingContext context) {
+		logger.trace("Processing messages {}", MessageHeaderUtils.getId(messages));
+		return CompletableFutures.exceptionallyCompose(
+				this.messageListener.onMessage(messages).thenApply(theVoid -> messages),
+				t -> CompletableFutures.failedFuture(ListenerExecutionFailedException.hasListenerException(t) ? t
+						: new ListenerExecutionFailedException("Listener failed to process messages", t, messages)));
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/MessageProcessingConfiguration.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/MessageProcessingConfiguration.java
new file mode 100644
index 000000000..31f91d025
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/MessageProcessingConfiguration.java
@@ -0,0 +1,106 @@
+/*
+ * 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.pipeline;
+
+import io.awspring.cloud.sqs.listener.AsyncMessageListener;
+import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementHandler;
+import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler;
+import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor;
+import java.util.Collection;
+import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
+
+/**
+ * Holds the components that will be used on the {@link MessageProcessingPipeline}. Every stage will receive it in its
+ * constructor, so it can properly configure itself.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class MessageProcessingConfiguration {
+
+	private final Collection> messageInterceptors;
+
+	private final AsyncMessageListener messageListener;
+
+	private final AsyncErrorHandler errorHandler;
+
+	private final AcknowledgementHandler acknowledgementHandler;
+
+	private MessageProcessingConfiguration(Builder builder) {
+		this.messageInterceptors = builder.messageInterceptors;
+		this.messageListener = builder.messageListener;
+		this.errorHandler = builder.errorHandler;
+		this.acknowledgementHandler = builder.acknowledgementHandler;
+	}
+
+	public static  MessageProcessingConfiguration.Builder builder() {
+		return new Builder<>();
+	}
+
+	public Collection> getMessageInterceptors() {
+		return this.messageInterceptors;
+	}
+
+	public AsyncMessageListener getMessageListener() {
+		return this.messageListener;
+	}
+
+	@Nullable
+	public AsyncErrorHandler getErrorHandler() {
+		return this.errorHandler;
+	}
+
+	public AcknowledgementHandler getAckHandler() {
+		return this.acknowledgementHandler;
+	}
+
+	public static class Builder {
+
+		private Collection> messageInterceptors;
+		private AsyncMessageListener messageListener;
+		private AsyncErrorHandler errorHandler;
+		private AcknowledgementHandler acknowledgementHandler;
+
+		public Builder interceptors(Collection> messageInterceptors) {
+			this.messageInterceptors = messageInterceptors;
+			return this;
+		}
+
+		public Builder messageListener(AsyncMessageListener messageListener) {
+			this.messageListener = messageListener;
+			return this;
+		}
+
+		public Builder errorHandler(AsyncErrorHandler errorHandler) {
+			this.errorHandler = errorHandler;
+			return this;
+		}
+
+		public Builder ackHandler(AcknowledgementHandler acknowledgementHandler) {
+			this.acknowledgementHandler = acknowledgementHandler;
+			return this;
+		}
+
+		public MessageProcessingConfiguration build() {
+			Assert.notNull(this.messageListener, "messageListener cannot be null");
+			Assert.notNull(this.acknowledgementHandler, "ackHandler cannot be null");
+			Assert.notNull(this.messageInterceptors, "messageInterceptors cannot be null");
+			return new MessageProcessingConfiguration<>(this);
+		}
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/MessageProcessingPipeline.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/MessageProcessingPipeline.java
new file mode 100644
index 000000000..923edff24
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/MessageProcessingPipeline.java
@@ -0,0 +1,58 @@
+/*
+ * 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.pipeline;
+
+import io.awspring.cloud.sqs.CompletableFutures;
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.springframework.messaging.Message;
+
+/**
+ * Represents a stage in the processing pipeline that will be used to process {@link Message} instances.
+ *
+ * Errors should be propagated to the next stage unless the stage recovers from it, such as in
+ * {@link ErrorHandlerExecutionStage}.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public interface MessageProcessingPipeline {
+
+	default CompletableFuture> process(Message message, MessageProcessingContext context) {
+		return CompletableFutures.failedFuture(new UnsupportedOperationException(
+				"Single message handling not implemented by pipeline " + getClass().getSimpleName()));
+	}
+
+	default CompletableFuture>> process(Collection> messages,
+			MessageProcessingContext context) {
+		return CompletableFutures.failedFuture(new UnsupportedOperationException(
+				"Batch handling not implemented by pipeline " + getClass().getSimpleName()));
+	}
+
+	default CompletableFuture> process(CompletableFuture> message,
+			MessageProcessingContext context) {
+		return CompletableFutures.failedFuture(new UnsupportedOperationException(
+				"CompletableFuture single message handling not implemented by pipeline " + getClass().getSimpleName()));
+	}
+
+	default CompletableFuture>> processMany(CompletableFuture>> messages,
+			MessageProcessingContext context) {
+		return CompletableFutures.failedFuture(new UnsupportedOperationException(
+				"CompletableFuture batch handling not implemented by pipeline " + getClass().getSimpleName()));
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/MessageProcessingPipelineBuilder.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/MessageProcessingPipelineBuilder.java
new file mode 100644
index 000000000..e02d492da
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/MessageProcessingPipelineBuilder.java
@@ -0,0 +1,114 @@
+/*
+ * 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.pipeline;
+
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import org.springframework.messaging.Message;
+
+/**
+ * Entrypoint for constructing a {@link MessageProcessingPipeline} {@link ComposingMessagePipelineStage}.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class MessageProcessingPipelineBuilder {
+
+	private final Function, MessageProcessingPipeline> pipelineFactory;
+
+	public MessageProcessingPipelineBuilder(
+			Function, MessageProcessingPipeline> pipelineFactory) {
+		this.pipelineFactory = pipelineFactory;
+	}
+
+	public static  MessageProcessingPipelineBuilder first(
+			Function, MessageProcessingPipeline> pipelineFactory) {
+		return new MessageProcessingPipelineBuilder<>(pipelineFactory);
+	}
+
+	public MessageProcessingPipelineBuilder then(
+			Function, MessageProcessingPipeline> pipelineFactory) {
+		return new MessageProcessingPipelineBuilder<>(configuration -> new ComposingMessagePipelineStage<>(
+				this.pipelineFactory.apply(configuration), pipelineFactory.apply(configuration)));
+	}
+
+	public MessageProcessingPipelineBuilder thenWrapWith(
+			BiFunction, MessageProcessingPipeline, MessageProcessingPipeline> pipelineFactory) {
+		return new MessageProcessingPipelineBuilder<>(
+				configuration -> pipelineFactory.apply(configuration, this.pipelineFactory.apply(configuration)));
+	}
+
+	public MessageProcessingPipelineBuilder thenInTheFuture(
+			Function, MessageProcessingPipeline> pipelineFactory) {
+		return new MessageProcessingPipelineBuilder<>(configuration -> new FutureComposingMessagePipelineStage<>(
+				this.pipelineFactory.apply(configuration), pipelineFactory.apply(configuration)));
+	}
+
+	public MessageProcessingPipeline build(MessageProcessingConfiguration configuration) {
+		return this.pipelineFactory.apply(configuration);
+	}
+
+	private static class ComposingMessagePipelineStage implements MessageProcessingPipeline {
+
+		private final MessageProcessingPipeline first;
+
+		private final MessageProcessingPipeline second;
+
+		private ComposingMessagePipelineStage(MessageProcessingPipeline first, MessageProcessingPipeline second) {
+			this.first = first;
+			this.second = second;
+		}
+
+		@Override
+		public CompletableFuture> process(Message message, MessageProcessingContext context) {
+			return first.process(message, context).thenCompose(msg -> second.process(msg, context));
+		}
+
+		@Override
+		public CompletableFuture>> process(Collection> messages,
+				MessageProcessingContext context) {
+			return first.process(messages, context).thenCompose(msgs -> second.process(msgs, context));
+		}
+	}
+
+	private static class FutureComposingMessagePipelineStage implements MessageProcessingPipeline {
+
+		private final MessageProcessingPipeline first;
+
+		private final MessageProcessingPipeline second;
+
+		private FutureComposingMessagePipelineStage(MessageProcessingPipeline first,
+				MessageProcessingPipeline second) {
+			this.first = first;
+			this.second = second;
+		}
+
+		@Override
+		public CompletableFuture> process(Message message, MessageProcessingContext context) {
+			return second.process(first.process(message, context), context);
+		}
+
+		@Override
+		public CompletableFuture>> process(Collection> messages,
+				MessageProcessingContext context) {
+			return second.processMany(first.process(messages, context), context);
+		}
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/package-info.java
new file mode 100644
index 000000000..86ea908e9
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/pipeline/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+/**
+ * Auto-configuration for Amazon SQS (Simple Queue Service) integrations.
+ */
+@org.springframework.lang.NonNullApi
+@org.springframework.lang.NonNullFields
+package io.awspring.cloud.sqs.listener.pipeline;
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/AbstractMessageProcessingPipelineSink.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/AbstractMessageProcessingPipelineSink.java
new file mode 100644
index 000000000..bac3c116d
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/AbstractMessageProcessingPipelineSink.java
@@ -0,0 +1,184 @@
+/*
+ * 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.sink;
+
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import io.awspring.cloud.sqs.listener.TaskExecutorAware;
+import io.awspring.cloud.sqs.listener.pipeline.MessageProcessingPipeline;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.function.Supplier;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.SmartLifecycle;
+import org.springframework.core.task.TaskExecutor;
+import org.springframework.messaging.Message;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.util.Assert;
+import org.springframework.util.StopWatch;
+
+/**
+ * Base implementation for {@link MessageProcessingPipelineSink} containing {@link SmartLifecycle} features and useful
+ * execution methods that can be used by subclasses.
+ *
+ * @param  the {@link Message} payload type.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public abstract class AbstractMessageProcessingPipelineSink
+		implements MessageProcessingPipelineSink, TaskExecutorAware {
+
+	private static final Logger logger = LoggerFactory.getLogger(AbstractMessageProcessingPipelineSink.class);
+
+	private final Object lifecycleMonitor = new Object();
+
+	private volatile boolean running;
+
+	private Executor taskExecutor;
+
+	private MessageProcessingPipeline messageProcessingPipeline;
+
+	private String id;
+
+	@Override
+	public void setMessagePipeline(MessageProcessingPipeline messageProcessingPipeline) {
+		Assert.notNull(messageProcessingPipeline, "messageProcessingPipeline must not be null.");
+		this.messageProcessingPipeline = messageProcessingPipeline;
+	}
+
+	@Override
+	public void setTaskExecutor(TaskExecutor taskExecutor) {
+		Assert.notNull(taskExecutor, "executor cannot be null");
+		this.taskExecutor = taskExecutor;
+	}
+
+	@Override
+	public CompletableFuture emit(Collection> messages, MessageProcessingContext context) {
+		Assert.notNull(messages, "messages cannot be null");
+		if (!isRunning()) {
+			logger.debug("{} {} not running, returning", getClass().getSimpleName(), this.id);
+			return CompletableFuture.completedFuture(null);
+		}
+		if (messages.size() == 0) {
+			logger.debug("No messages provided for {} {}, returning.", getClass().getSimpleName(), this.id);
+			return CompletableFuture.completedFuture(null);
+		}
+		return doEmit(messages, context);
+	}
+
+	protected abstract CompletableFuture doEmit(Collection> messages,
+			MessageProcessingContext context);
+
+	/**
+	 * Send the provided {@link Message} to the {@link TaskExecutor} as a unit of work.
+	 * @param message the message to be executed.
+	 * @param context the processing context.
+	 * @return the processing result.
+	 */
+	protected CompletableFuture execute(Message message, MessageProcessingContext context) {
+		logger.trace("Executing message {}", MessageHeaderUtils.getId(message));
+		StopWatch watch = getStartedWatch();
+		return doExecute(() -> this.messageProcessingPipeline.process(message, context))
+				.whenComplete((v, t) -> context.runBackPressureReleaseCallback())
+				.whenComplete((v, t) -> measureExecution(watch, Collections.singletonList(message)));
+	}
+
+	/**
+	 * Send the provided {@link Message} instances to the {@link TaskExecutor} as a unit of work.
+	 * @param messages the messages to be executed.
+	 * @param context the processing context.
+	 * @return the processing result.
+	 */
+	protected CompletableFuture execute(Collection> messages, MessageProcessingContext context) {
+		StopWatch watch = getStartedWatch();
+		return doExecute(() -> this.messageProcessingPipeline.process(messages, context))
+				.whenComplete((v, t) -> messages.forEach(msg -> context.runBackPressureReleaseCallback()))
+				.whenComplete((v, t) -> measureExecution(watch, messages));
+	}
+
+	protected Void logError(Throwable t, Message msg) {
+		logger.error("Error processing message {}.", MessageHeaderUtils.getId(msg), t);
+		return null;
+	}
+
+	protected Void logError(Throwable t, Collection> msgs) {
+		logger.error("Error processing message {}.", MessageHeaderUtils.getId(msgs), t);
+		return null;
+	}
+
+	private StopWatch getStartedWatch() {
+		StopWatch watch = new StopWatch();
+		watch.start();
+		return watch;
+	}
+
+	private void measureExecution(StopWatch watch, Collection> messages) {
+		watch.stop();
+		if (logger.isTraceEnabled()) {
+			logger.trace("Messages {} processed in {}ms", MessageHeaderUtils.getId(messages),
+					watch.getTotalTimeMillis());
+		}
+	}
+
+	private CompletableFuture doExecute(Supplier> supplier) {
+		return CompletableFuture.supplyAsync(supplier, this.taskExecutor).thenCompose(x -> x).thenRun(() -> {
+		});
+	}
+
+	@Override
+	public void start() {
+		if (isRunning()) {
+			logger.debug("{} {} already running", getClass().getSimpleName(), this.id);
+			return;
+		}
+		synchronized (this.lifecycleMonitor) {
+			Assert.notNull(this.messageProcessingPipeline, "messageListener not set");
+			Assert.notNull(this.taskExecutor, "taskExecutor not set");
+			this.id = getOrCreateId();
+			logger.debug("Starting {} {}", getClass().getSimpleName(), this.id);
+			this.running = true;
+		}
+	}
+
+	private String getOrCreateId() {
+		return this.taskExecutor instanceof ThreadPoolTaskExecutor
+				? ((ThreadPoolTaskExecutor) this.taskExecutor).getThreadNamePrefix()
+				: UUID.randomUUID().toString();
+	}
+
+	@Override
+	public void stop() {
+		if (!isRunning()) {
+			logger.debug("{} {} already stopped", getClass().getSimpleName(), this.id);
+			return;
+		}
+		synchronized (this.lifecycleMonitor) {
+			logger.debug("Stopping {} {}", this.getClass().getSimpleName(), this.id);
+			this.running = false;
+		}
+	}
+
+	@Override
+	public boolean isRunning() {
+		return this.running;
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/BatchMessageSink.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/BatchMessageSink.java
new file mode 100644
index 000000000..bca81afbd
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/BatchMessageSink.java
@@ -0,0 +1,37 @@
+/*
+ * 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.sink;
+
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.springframework.messaging.Message;
+
+/**
+ * {@link MessageSink} implementation that emits the whole received batch of messages to the configured
+ * {@link io.awspring.cloud.sqs.listener.pipeline.MessageProcessingPipeline}.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class BatchMessageSink extends AbstractMessageProcessingPipelineSink {
+
+	@Override
+	protected CompletableFuture doEmit(Collection> messages, MessageProcessingContext context) {
+		return execute(messages, context).exceptionally(t -> logError(t, messages));
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/FanOutMessageSink.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/FanOutMessageSink.java
new file mode 100644
index 000000000..83406f041
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/FanOutMessageSink.java
@@ -0,0 +1,46 @@
+/*
+ * 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.sink;
+
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.messaging.Message;
+
+/**
+ * {@link MessageProcessingPipelineSink} implementation that executes messages from the provided batch in parallel.
+ *
+ * @param  the {@link Message} payload type.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class FanOutMessageSink extends AbstractMessageProcessingPipelineSink {
+
+	Logger logger = LoggerFactory.getLogger(FanOutMessageSink.class);
+
+	@Override
+	protected CompletableFuture doEmit(Collection> messages, MessageProcessingContext context) {
+		logger.trace("Emitting messages {}", MessageHeaderUtils.getId(messages));
+		return CompletableFuture.allOf(messages.stream().map(msg -> execute(msg, context)
+				// Should log errors individually - no need to propagate upstream
+				.exceptionally(t -> logError(t, msg))).toArray(CompletableFuture[]::new));
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/MessageProcessingPipelineSink.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/MessageProcessingPipelineSink.java
new file mode 100644
index 000000000..c16e666f1
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/MessageProcessingPipelineSink.java
@@ -0,0 +1,38 @@
+/*
+ * 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.sink;
+
+import io.awspring.cloud.sqs.listener.pipeline.MessageProcessingPipeline;
+import org.springframework.context.SmartLifecycle;
+import org.springframework.messaging.Message;
+
+/**
+ * {@link MessageSink} specialization that uses a {@link MessageProcessingPipeline} as the output.
+ *
+ * @param  the {@link Message} payload type.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public interface MessageProcessingPipelineSink extends MessageSink, SmartLifecycle {
+
+	/**
+	 * Set the {@link MessageProcessingPipeline} instance that this sink will emit {@link Message} instances to.
+	 * @param messageProcessingPipeline the pipeline.
+	 */
+	void setMessagePipeline(MessageProcessingPipeline messageProcessingPipeline);
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/MessageSink.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/MessageSink.java
new file mode 100644
index 000000000..571989bf8
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/MessageSink.java
@@ -0,0 +1,46 @@
+/*
+ * 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.sink;
+
+import io.awspring.cloud.sqs.listener.AsyncMessageListener;
+import io.awspring.cloud.sqs.listener.ConfigurableContainerComponent;
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import org.springframework.messaging.Message;
+
+/**
+ * Component that handles the flow of {@link Message}s.
+ *
+ * This interface is non-opinionated regarding strategies or the output to which messages will be emitted to.
+ *
+ * A {@link MessageProcessingContext} can be used to pass additional state to the implementation.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+@FunctionalInterface
+public interface MessageSink extends ConfigurableContainerComponent {
+
+	/**
+	 * Emit the provided {@link Message} instances to the provided {@link AsyncMessageListener}.
+	 * @param messages the messages to emit.
+	 * @return a collection of {@link CompletableFuture} instances, each representing the completion signal of a single
+	 * message processing.
+	 */
+	CompletableFuture emit(Collection> messages, MessageProcessingContext context);
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/OrderedMessageSink.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/OrderedMessageSink.java
new file mode 100644
index 000000000..c498d4467
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/OrderedMessageSink.java
@@ -0,0 +1,63 @@
+/*
+ * 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.sink;
+
+import io.awspring.cloud.sqs.CompletableFutures;
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.BiConsumer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.messaging.Message;
+
+/**
+ * {@link MessageProcessingPipelineSink} implementation that processes provided messages sequentially and in order.
+ *
+ * @param  the {@link Message} payload type.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public class OrderedMessageSink extends AbstractMessageProcessingPipelineSink {
+
+	private static final Logger logger = LoggerFactory.getLogger(OrderedMessageSink.class);
+
+	@Override
+	protected CompletableFuture doEmit(Collection> messages, MessageProcessingContext context) {
+		logger.trace("Emitting messages {}", MessageHeaderUtils.getId(messages));
+		CompletableFuture execution = messages.stream().reduce(CompletableFuture.completedFuture(null),
+				(resultFuture, msg) -> CompletableFutures.handleCompose(resultFuture, (v, t) -> {
+					if (t == null) {
+						return execute(msg, context).whenComplete(logIfError(msg));
+					}
+					// Release backpressure from subsequent interrupted executions in case of errors.
+					context.runBackPressureReleaseCallback();
+					return CompletableFutures.failedFuture(t);
+				}), (a, b) -> a);
+		return execution.exceptionally(t -> null);
+	}
+
+	private BiConsumer logIfError(Message msg) {
+		return (v, t) -> {
+			if (t != null) {
+				logError(t, msg);
+			}
+		};
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/adapter/AbstractDelegatingMessageListeningSinkAdapter.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/adapter/AbstractDelegatingMessageListeningSinkAdapter.java
new file mode 100644
index 000000000..42f583dd9
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/adapter/AbstractDelegatingMessageListeningSinkAdapter.java
@@ -0,0 +1,92 @@
+/*
+ * 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.sink.adapter;
+
+import io.awspring.cloud.sqs.ConfigUtils;
+import io.awspring.cloud.sqs.LifecycleHandler;
+import io.awspring.cloud.sqs.listener.ContainerOptions;
+import io.awspring.cloud.sqs.listener.SqsAsyncClientAware;
+import io.awspring.cloud.sqs.listener.TaskExecutorAware;
+import io.awspring.cloud.sqs.listener.pipeline.MessageProcessingPipeline;
+import io.awspring.cloud.sqs.listener.sink.MessageProcessingPipelineSink;
+import io.awspring.cloud.sqs.listener.sink.MessageSink;
+import org.springframework.core.task.TaskExecutor;
+import org.springframework.util.Assert;
+import software.amazon.awssdk.services.sqs.SqsAsyncClient;
+
+/**
+ * {@link MessageProcessingPipelineSink} implementation that delegates method invocations to the provided delegate.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ */
+public abstract class AbstractDelegatingMessageListeningSinkAdapter
+		implements MessageProcessingPipelineSink, TaskExecutorAware, SqsAsyncClientAware {
+
+	private final MessageSink delegate;
+
+	/**
+	 * Create an instance with the provided delegate.
+	 * @param delegate the delegate.
+	 */
+	protected AbstractDelegatingMessageListeningSinkAdapter(MessageSink delegate) {
+		Assert.notNull(delegate, "delegate cannot be null");
+		this.delegate = delegate;
+	}
+
+	@SuppressWarnings("unchecked")
+	@Override
+	public void setMessagePipeline(MessageProcessingPipeline messageProcessingPipeline) {
+		ConfigUtils.INSTANCE.acceptIfInstance(this.delegate, MessageProcessingPipelineSink.class,
+				mpps -> mpps.setMessagePipeline(messageProcessingPipeline));
+	}
+
+	@Override
+	public void setTaskExecutor(TaskExecutor taskExecutor) {
+		ConfigUtils.INSTANCE.acceptIfInstance(this.delegate, TaskExecutorAware.class,
+				ea -> ea.setTaskExecutor(taskExecutor));
+	}
+
+	@Override
+	public void setSqsAsyncClient(SqsAsyncClient sqsAsyncClient) {
+		ConfigUtils.INSTANCE.acceptIfInstance(this.delegate, SqsAsyncClientAware.class,
+				saca -> saca.setSqsAsyncClient(sqsAsyncClient));
+	}
+
+	@Override
+	public void start() {
+		LifecycleHandler.get().start(this.delegate);
+	}
+
+	@Override
+	public void stop() {
+		LifecycleHandler.get().stop(this.delegate);
+	}
+
+	@Override
+	public boolean isRunning() {
+		return LifecycleHandler.get().isRunning(this.delegate);
+	}
+
+	@Override
+	public void configure(ContainerOptions containerOptions) {
+		this.delegate.configure(containerOptions);
+	}
+
+	protected MessageSink getDelegate() {
+		return this.delegate;
+	}
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/adapter/MessageGroupingSinkAdapter.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/adapter/MessageGroupingSinkAdapter.java
new file mode 100644
index 000000000..77fc632c6
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/adapter/MessageGroupingSinkAdapter.java
@@ -0,0 +1,61 @@
+/*
+ * 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.sink.adapter;
+
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import io.awspring.cloud.sqs.listener.sink.MessageSink;
+import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.messaging.Message;
+import org.springframework.util.Assert;
+
+/**
+ * {@link AbstractDelegatingMessageListeningSinkAdapter} implementation that groups the received batch according to the
+ * provided grouping function and emits each sub batch to the delegate separately.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see io.awspring.cloud.sqs.listener.FifoSqsComponentFactory
+ */
+public class MessageGroupingSinkAdapter extends AbstractDelegatingMessageListeningSinkAdapter {
+
+	private static final Logger logger = LoggerFactory.getLogger(MessageGroupingSinkAdapter.class);
+
+	private final Function, String> groupingFunction;
+
+	public MessageGroupingSinkAdapter(MessageSink delegate, Function, String> groupingFunction) {
+		super(delegate);
+		Assert.notNull(groupingFunction, "groupingFunction cannot be null.");
+		this.groupingFunction = groupingFunction;
+	}
+
+	// @formatter:off
+	@Override
+	public CompletableFuture emit(Collection> messages, MessageProcessingContext context) {
+		logger.trace("Emitting messages {}", MessageHeaderUtils.getId(messages));
+		return CompletableFuture.allOf(messages.stream().collect(Collectors.groupingBy(this.groupingFunction))
+			.values().stream()
+			.map(messageBatch -> getDelegate().emit(messageBatch, context))
+			.toArray(CompletableFuture[]::new));
+	}
+	// @formatter:on
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/adapter/MessageVisibilityExtendingSinkAdapter.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/adapter/MessageVisibilityExtendingSinkAdapter.java
new file mode 100644
index 000000000..eb844cad6
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/adapter/MessageVisibilityExtendingSinkAdapter.java
@@ -0,0 +1,157 @@
+/*
+ * 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.sink.adapter;
+
+import io.awspring.cloud.sqs.MessageHeaderUtils;
+import io.awspring.cloud.sqs.listener.MessageProcessingContext;
+import io.awspring.cloud.sqs.listener.SqsAsyncClientAware;
+import io.awspring.cloud.sqs.listener.SqsHeaders;
+import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor;
+import io.awspring.cloud.sqs.listener.sink.MessageSink;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Collectors;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.messaging.Message;
+import org.springframework.util.Assert;
+import software.amazon.awssdk.services.sqs.SqsAsyncClient;
+import software.amazon.awssdk.services.sqs.model.ChangeMessageVisibilityBatchRequestEntry;
+
+/**
+ * An {@link AbstractDelegatingMessageListeningSinkAdapter} that adds an
+ * {@link OriginalBatchMessageVisibilityExtendingInterceptor} to the {@link MessageProcessingContext}. The interceptor
+ * refreshes the visibility for remaining messages in the batch before each message is processed, except for the first.
+ * After each message is processed, it is removed from the copy of the original batch that the interceptor holds.
+ *
+ * @author Tomaz Fernandes
+ * @since 3.0
+ * @see io.awspring.cloud.sqs.listener.FifoSqsComponentFactory
+ */
+public class MessageVisibilityExtendingSinkAdapter extends AbstractDelegatingMessageListeningSinkAdapter
+		implements SqsAsyncClientAware {
+
+	private static final Logger logger = LoggerFactory.getLogger(MessageVisibilityExtendingSinkAdapter.class);
+
+	private static final Duration DEFAULT_VISIBILITY_TO_SET = Duration.ofSeconds(30);
+
+	private int messageVisibility = (int) DEFAULT_VISIBILITY_TO_SET.getSeconds();
+
+	private SqsAsyncClient sqsAsyncClient;
+
+	public MessageVisibilityExtendingSinkAdapter(MessageSink delegate) {
+		super(delegate);
+	}
+
+	public void setMessageVisibility(Duration messageVisibility) {
+		Assert.notNull(messageVisibility, "visibilityDuration cannot be null");
+		this.messageVisibility = (int) messageVisibility.getSeconds();
+	}
+
+	@Override
+	public void setSqsAsyncClient(SqsAsyncClient sqsAsyncClient) {
+		Assert.notNull(sqsAsyncClient, "sqsAsyncClient cannot be null");
+		super.setSqsAsyncClient(sqsAsyncClient);
+		this.sqsAsyncClient = sqsAsyncClient;
+	}
+
+	@Override
+	public CompletableFuture emit(Collection> messages, MessageProcessingContext context) {
+		logger.trace("Adding visibility interceptor for messages {}", MessageHeaderUtils.getId(messages));
+		return getDelegate().emit(messages,
+				context.addInterceptor(new OriginalBatchMessageVisibilityExtendingInterceptor(messages)));
+	}
+
+	private CompletableFuture>> changeVisibility(Collection> messages) {
+		logger.trace("Changing visibility of messages {} to {} seconds", MessageHeaderUtils.getId(messages),
+				this.messageVisibility);
+		return this.sqsAsyncClient
+				.changeMessageVisibilityBatch(
+						req -> req.entries(getEntries(messages)).queueUrl(getQueueUrl(messages)).build())
+				.whenComplete((v, t) -> logResult(messages, t)).thenApply(theVoid -> messages);
+	}
+
+	private String getQueueUrl(Collection> messages) {
+		return messages.iterator().next().getHeaders().get(SqsHeaders.SQS_QUEUE_URL_HEADER, String.class);
+	}
+
+	// @formatter:off
+	private Collection getEntries(Collection> messages) {
+		return MessageHeaderUtils
+			.getHeader(messages, SqsHeaders.SQS_RECEIPT_HANDLE_HEADER, String.class)
+			.stream()
+			.map(handle -> ChangeMessageVisibilityBatchRequestEntry.builder()
+				.receiptHandle(handle).id(UUID.randomUUID().toString())
+				.visibilityTimeout(this.messageVisibility).build())
+			.collect(Collectors.toList());
+	}
+	// @formatter:on
+
+	private void logResult(Collection> messages, Throwable t) {
+		if (t == null) {
+			logger.trace("Finished changing visibility for messages {}", MessageHeaderUtils.getId(messages));
+		}
+		else {
+			logger.error("Error changing visibility for messages {}", MessageHeaderUtils.getId(messages));
+		}
+	}
+
+	private class OriginalBatchMessageVisibilityExtendingInterceptor implements AsyncMessageInterceptor {
+
+		private final Collection> originalMessageBatchCopy;
+
+		private final int initialBatchSize;
+
+		private OriginalBatchMessageVisibilityExtendingInterceptor(Collection> originalMessageBatch) {
+			this.originalMessageBatchCopy = Collections.synchronizedCollection(new ArrayList<>(originalMessageBatch));
+			this.initialBatchSize = originalMessageBatch.size();
+		}
+
+		// @formatter:off
+		@Override
+		public CompletableFuture> intercept(Message message) {
+			return originalMessageBatchCopy.size() == initialBatchSize
+				? CompletableFuture.completedFuture(message)
+				: changeVisibility(this.originalMessageBatchCopy).thenApply(response -> message);
+		}
+
+		@Override
+		public CompletableFuture>> intercept(Collection> messages) {
+			return originalMessageBatchCopy.size() == initialBatchSize
+				? CompletableFuture.completedFuture(messages)
+				: changeVisibility(this.originalMessageBatchCopy).thenApply(response -> messages);
+		}
+		// @formatter:on
+
+		@Override
+		public CompletableFuture afterProcessing(Collection> messages, Throwable t) {
+			this.originalMessageBatchCopy.removeAll(messages);
+			return CompletableFuture.completedFuture(null);
+		}
+
+		@Override
+		public CompletableFuture afterProcessing(Message message, Throwable t) {
+			this.originalMessageBatchCopy.remove(message);
+			return CompletableFuture.completedFuture(null);
+		}
+
+	}
+
+}
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/adapter/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/adapter/package-info.java
new file mode 100644
index 000000000..ccbbc50ba
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/adapter/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+/**
+ * Auto-configuration for Amazon SQS (Simple Queue Service) integrations.
+ */
+@org.springframework.lang.NonNullApi
+@org.springframework.lang.NonNullFields
+package io.awspring.cloud.sqs.listener.sink.adapter;
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/package-info.java
new file mode 100644
index 000000000..182d0c4cc
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/sink/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+/**
+ * Auto-configuration for Amazon SQS (Simple Queue Service) integrations.
+ */
+@org.springframework.lang.NonNullApi
+@org.springframework.lang.NonNullFields
+package io.awspring.cloud.sqs.listener.sink;
diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/AbstractMessageConvertingMessageSource.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/AbstractMessageConvertingMessageSource.java
new file mode 100644
index 000000000..f101495ee
--- /dev/null
+++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/AbstractMessageConvertingMessageSource.java
@@ -0,0 +1,102 @@
+/*
+ * 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.source;
+
+import io.awspring.cloud.sqs.ConfigUtils;
+import io.awspring.cloud.sqs.listener.ContainerOptions;
+import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback;
+import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementMode;
+import io.awspring.cloud.sqs.support.converter.AcknowledgementAwareMessageConversionContext;
+import io.awspring.cloud.sqs.support.converter.ContextAwareMessagingMessageConverter;
+import io.awspring.cloud.sqs.support.converter.MessageConversionContext;
+import io.awspring.cloud.sqs.support.converter.MessagingMessageConverter;
+import java.util.Collection;
+import java.util.stream.Collectors;
+import org.springframework.lang.Nullable;
+import org.springframework.messaging.Message;
+
+/**
+ * A {@link MessageSource} implementation capable of converting messages from a Source type to a Target type. Subclasses
+ * can use the {@link #convertMessage} or {@link #convertMessages} methods to perform the conversion.
+ * 

+ * The {@link MessagingMessageConverter} can be retrieved from the {@link ContainerOptions} or from a subclass. + *

+ * For converters that implement {@link ContextAwareMessagingMessageConverter}, a {@link MessageConversionContext} will + * be created, which can contain more useful information for message conversion. + *

+ * If such context implements the {@link AcknowledgementAwareMessageConversionContext}, an + * {@link AcknowledgementCallback} can be added to the context by using the {@link #setupAcknowledgementForConversion} + * method/. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public abstract class AbstractMessageConvertingMessageSource implements MessageSource { + + private MessagingMessageConverter messagingMessageConverter; + + private MessageConversionContext messageConversionContext; + + private AcknowledgementMode acknowledgementMode; + + @SuppressWarnings("unchecked") + @Override + public void configure(ContainerOptions containerOptions) { + this.messagingMessageConverter = (MessagingMessageConverter) containerOptions.getMessageConverter(); + this.messageConversionContext = maybeCreateConversionContext(); + this.acknowledgementMode = containerOptions.getAcknowledgementMode(); + configureMessageSource(containerOptions); + } + + protected void configureMessageSource(ContainerOptions containerOptions) { + } + + protected void setupAcknowledgementForConversion(AcknowledgementCallback callback) { + if (this.acknowledgementMode.equals(AcknowledgementMode.MANUAL)) { + ConfigUtils.INSTANCE.acceptIfInstance(this.messageConversionContext, + AcknowledgementAwareMessageConversionContext.class, + aamcc -> aamcc.setAcknowledgementCallback(callback)); + } + } + + @Nullable + private MessageConversionContext maybeCreateConversionContext() { + return this.messagingMessageConverter instanceof ContextAwareMessagingMessageConverter + ? ((ContextAwareMessagingMessageConverter) this.messagingMessageConverter) + .createMessageConversionContext() + : null; + } + + protected Collection> convertMessages(Collection messages) { + return messages.stream().map(this::convertMessage).collect(Collectors.toList()); + } + + @SuppressWarnings("unchecked") + protected Message convertMessage(S msg) { + return this.messagingMessageConverter instanceof ContextAwareMessagingMessageConverter + ? (Message) getContextAwareConverter().toMessagingMessage(msg, this.messageConversionContext) + : (Message) this.messagingMessageConverter.toMessagingMessage(msg); + } + + private ContextAwareMessagingMessageConverter getContextAwareConverter() { + return (ContextAwareMessagingMessageConverter) this.messagingMessageConverter; + } + + protected MessageConversionContext getMessageConversionContext() { + return this.messageConversionContext; + } + +} 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 new file mode 100644 index 000000000..a613c471f --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/AbstractPollingMessageSource.java @@ -0,0 +1,320 @@ +/* + * 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.source; + +import io.awspring.cloud.sqs.ConfigUtils; +import io.awspring.cloud.sqs.listener.BackPressureHandler; +import io.awspring.cloud.sqs.listener.BatchAwareBackPressureHandler; +import io.awspring.cloud.sqs.listener.ContainerOptions; +import io.awspring.cloud.sqs.listener.IdentifiableContainerComponent; +import io.awspring.cloud.sqs.listener.MessageProcessingContext; +import io.awspring.cloud.sqs.listener.TaskExecutorAware; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementProcessor; +import io.awspring.cloud.sqs.listener.acknowledgement.AsyncAcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.ExecutingAcknowledgementProcessor; +import io.awspring.cloud.sqs.listener.sink.MessageSink; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.task.TaskExecutor; +import org.springframework.messaging.Message; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Base {@link PollingMessageSource} implementation with {@link org.springframework.context.SmartLifecycle} + * capabilities. + *

+ * Polling backpressure is handled the provided {@link BackPressureHandler}. The connected {@link MessageSink} should + * use the provided {@link MessageProcessingContext#getAcknowledgmentCallback()} to signal each message processing + * completion and enable further polling. + *

+ * Message conversion capabilities are inherited by the {@link AbstractMessageConvertingMessageSource} superclass. + *

+ * The {@link AcknowledgementProcessor} instance provides the {@link AcknowledgementCallback} to be set in the + * {@link MessageProcessingContext} and executed downstream when applicable. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public abstract class AbstractPollingMessageSource extends AbstractMessageConvertingMessageSource + implements PollingMessageSource, IdentifiableContainerComponent { + + private static final Logger logger = LoggerFactory.getLogger(AbstractPollingMessageSource.class); + + private String pollingEndpointName; + + private Duration shutdownTimeout; + + private TaskExecutor taskExecutor; + + private BatchAwareBackPressureHandler backPressureHandler; + + private AcknowledgementProcessor acknowledgmentProcessor; + + private MessageSink messageSink; + + private volatile boolean running; + + private final Object lifecycleMonitor = new Object(); + + private final Collection> pollingFutures = Collections + .synchronizedCollection(new ArrayList<>()); + + private String id; + + private AsyncAcknowledgementResultCallback acknowledgementResultCallback; + + @Override + protected void configureMessageSource(ContainerOptions containerOptions) { + this.shutdownTimeout = containerOptions.getShutdownTimeout(); + doConfigure(containerOptions); + } + + protected abstract void doConfigure(ContainerOptions containerOptions); + + @Override + public void setId(String id) { + Assert.notNull(id, "id cannot be null"); + this.id = id; + } + + @Override + public void setPollingEndpointName(String pollingEndpointName) { + Assert.isTrue(StringUtils.hasText(pollingEndpointName), "pollingEndpointName must have text"); + this.pollingEndpointName = pollingEndpointName; + } + + @Override + public void setBackPressureHandler(BackPressureHandler backPressureHandler) { + Assert.notNull(backPressureHandler, "backPressureHandler cannot be null"); + Assert.isInstanceOf(BatchAwareBackPressureHandler.class, backPressureHandler, + getClass().getSimpleName() + " requires a " + BatchAwareBackPressureHandler.class); + this.backPressureHandler = (BatchAwareBackPressureHandler) backPressureHandler; + } + + @Override + public void setAcknowledgementProcessor(AcknowledgementProcessor acknowledgementProcessor) { + Assert.notNull(acknowledgementProcessor, "acknowledgementProcessor cannot be null"); + this.acknowledgmentProcessor = acknowledgementProcessor; + } + + @Override + public void setAcknowledgementResultCallback(AsyncAcknowledgementResultCallback acknowledgementResultCallback) { + Assert.notNull(acknowledgementResultCallback, "acknowledgementResultCallback must not be null"); + this.acknowledgementResultCallback = acknowledgementResultCallback; + } + + @Override + public void setTaskExecutor(TaskExecutor taskExecutor) { + Assert.notNull(taskExecutor, "taskExecutor cannot be null"); + this.taskExecutor = taskExecutor; + } + + @Override + public void setMessageSink(MessageSink messageSink) { + Assert.notNull(messageSink, "messageSink cannot be null"); + this.messageSink = messageSink; + } + + @Override + public String getId() { + return this.id; + } + + @Override + public boolean isRunning() { + return this.running; + } + + @SuppressWarnings("unchecked") + @Override + public void start() { + if (isRunning()) { + logger.debug("{} for queue {} already running", getClass().getSimpleName(), this.pollingEndpointName); + return; + } + synchronized (this.lifecycleMonitor) { + Assert.notNull(this.id, "id not set"); + Assert.notNull(this.messageSink, "messageSink not set"); + Assert.notNull(this.backPressureHandler, "backPressureHandler not set"); + Assert.notNull(this.acknowledgmentProcessor, "acknowledgmentProcessor not set"); + logger.debug("Starting {} for queue {}", getClass().getSimpleName(), this.pollingEndpointName); + this.running = true; + ConfigUtils.INSTANCE + .acceptIfInstance(this.backPressureHandler, IdentifiableContainerComponent.class, + icc -> icc.setId(this.id)) + .acceptIfInstance(this.acknowledgmentProcessor, IdentifiableContainerComponent.class, + icc -> icc.setId(this.id)) + .acceptIfInstance(this.acknowledgmentProcessor, ExecutingAcknowledgementProcessor.class, + eap -> eap.setAcknowledgementResultCallback(this.acknowledgementResultCallback)) + .acceptIfInstance(this.acknowledgmentProcessor, TaskExecutorAware.class, + ea -> ea.setTaskExecutor(this.taskExecutor)); + doStart(); + setupAcknowledgementForConversion(this.acknowledgmentProcessor.getAcknowledgementCallback()); + this.acknowledgmentProcessor.start(); + startPollingThread(); + } + } + + protected void doStart() { + } + + private void startPollingThread() { + this.taskExecutor.execute(this::pollAndEmitMessages); + } + + private void pollAndEmitMessages() { + while (isRunning()) { + try { + if (!isRunning()) { + continue; + } + logger.trace("Requesting permits for queue {}", this.pollingEndpointName); + final int acquiredPermits = this.backPressureHandler.requestBatch(); + if (acquiredPermits == 0) { + logger.trace("No permits acquired for queue {}", this.pollingEndpointName); + continue; + } + logger.trace("{} permits acquired for queue {}", acquiredPermits, this.pollingEndpointName); + if (!isRunning()) { + logger.debug("MessageSource was stopped after permits where acquired. Returning {} permits", + acquiredPermits); + this.backPressureHandler.release(acquiredPermits); + continue; + } + // @formatter:off + managePollingFuture(doPollForMessages(acquiredPermits)) + .exceptionally(this::handlePollingException) + .thenApply(msgs -> releaseUnusedPermits(acquiredPermits, msgs)) + .thenApply(this::convertMessages) + .thenCompose(this::emitMessagesToPipeline) + .exceptionally(this::handleSinkException); + // @formatter:on + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "MessageSource thread interrupted for endpoint " + this.pollingEndpointName, e); + } + catch (Exception e) { + logger.error("Error in MessageSource for queue {}. Resuming", this.pollingEndpointName, e); + } + } + logger.debug("Execution thread stopped for queue {}", this.pollingEndpointName); + } + + protected abstract CompletableFuture> doPollForMessages(int messagesToRequest); + + public Collection releaseUnusedPermits(int permits, Collection msgs) { + if (msgs.isEmpty()) { + 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); + } + return msgs; + } + + private CompletableFuture emitMessagesToPipeline(Collection> messages) { + if (messages.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + return this.messageSink.emit(messages, createContext()); + } + + // @formatter:off + protected MessageProcessingContext createContext() { + return MessageProcessingContext. create() + .setBackPressureReleaseCallback(this::releaseBackPressure) + .setAcknowledgmentCallback(getAcknowledgementCallback()); + } + // @formatter:on + + protected AcknowledgementCallback getAcknowledgementCallback() { + return this.acknowledgmentProcessor.getAcknowledgementCallback(); + } + + private void releaseBackPressure() { + logger.debug("Releasing permit for queue {}", this.pollingEndpointName); + this.backPressureHandler.release(1); + } + + private Void handleSinkException(Throwable t) { + logger.error("Error processing message", t instanceof CompletionException ? t.getCause() : t); + return null; + } + + private Collection handlePollingException(Throwable t) { + logger.error("Error polling for messages in queue {}", this.pollingEndpointName, t); + return Collections.emptyList(); + } + + private CompletableFuture managePollingFuture(CompletableFuture pollingFuture) { + this.pollingFutures.add(pollingFuture); + pollingFuture.thenRun(() -> this.pollingFutures.remove(pollingFuture)); + return pollingFuture; + } + + protected String getPollingEndpointName() { + return this.pollingEndpointName; + } + + protected AcknowledgementProcessor getAcknowledgmentProcessor() { + return this.acknowledgmentProcessor; + } + + @Override + public void stop() { + if (!isRunning()) { + logger.debug("{} for queue {} not running", getClass().getSimpleName(), this.pollingEndpointName); + } + synchronized (this.lifecycleMonitor) { + logger.debug("Stopping {} for queue {}", getClass().getSimpleName(), this.pollingEndpointName); + this.running = false; + if (!waitExistingTasksToFinish()) { + logger.warn("Tasks did not finish in {} seconds for queue {}, proceeding with shutdown", + this.shutdownTimeout.getSeconds(), this.pollingEndpointName); + this.pollingFutures.forEach(pollingFuture -> pollingFuture.cancel(true)); + } + doStop(); + this.acknowledgmentProcessor.stop(); + logger.debug("{} for queue {} stopped", getClass().getSimpleName(), this.pollingEndpointName); + } + } + + protected void doStop() { + } + + private boolean waitExistingTasksToFinish() { + if (this.shutdownTimeout.isZero()) { + logger.debug("Shutdown timeout set to zero for queue {} - not waiting for tasks to finish", + this.pollingEndpointName); + return false; + } + return this.backPressureHandler.drain(this.shutdownTimeout); + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/AcknowledgementProcessingMessageSource.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/AcknowledgementProcessingMessageSource.java new file mode 100644 index 000000000..77526a8fc --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/AcknowledgementProcessingMessageSource.java @@ -0,0 +1,45 @@ +/* + * 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.source; + +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementProcessor; +import io.awspring.cloud.sqs.listener.acknowledgement.AsyncAcknowledgementResultCallback; + +/** + * {@link MessageSource} specialization that enables processing acknowledgements for the + * {@link org.springframework.messaging.Message} instances through an + * {@link io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementExecutor} + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public interface AcknowledgementProcessingMessageSource extends MessageSource { + + /** + * Set the {@link AcknowledgementProcessor} instance that will process the message instances and provide the + * {@link io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback}. + * @param acknowledgementProcessor the processor instance. + */ + void setAcknowledgementProcessor(AcknowledgementProcessor acknowledgementProcessor); + + /** + * Set the {@link AsyncAcknowledgementResultCallback} that will be executed after messages are acknowledged, usually + * by a {@link io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementExecutor}. + * @param acknowledgementResultCallback the callback instance. + */ + void setAcknowledgementResultCallback(AsyncAcknowledgementResultCallback acknowledgementResultCallback); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/MessageSource.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/MessageSource.java new file mode 100644 index 000000000..dbb05fa14 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/MessageSource.java @@ -0,0 +1,38 @@ +/* + * 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.source; + +import io.awspring.cloud.sqs.listener.ConfigurableContainerComponent; +import io.awspring.cloud.sqs.listener.sink.MessageSink; +import org.springframework.messaging.Message; + +/** + * A source of {@link Message} instances. Such messages will be sent for emission to a connected {@link MessageSink}. + * + * @param the {@link Message} payload type. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public interface MessageSource extends ConfigurableContainerComponent { + + /** + * Set the {@link MessageSink} to be used as an output for this {@link MessageSource}. + * @param messageSink the message sink. + */ + void setMessageSink(MessageSink messageSink); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/PollingMessageSource.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/PollingMessageSource.java new file mode 100644 index 000000000..e066f9062 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/PollingMessageSource.java @@ -0,0 +1,42 @@ +/* + * 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.source; + +import io.awspring.cloud.sqs.listener.BackPressureHandler; +import io.awspring.cloud.sqs.listener.TaskExecutorAware; +import org.springframework.context.SmartLifecycle; + +/** + * {@link MessageSource} extension that provides polling configurations and {@link SmartLifecycle} capabilities. + * + * @param the message payload type. + */ +public interface PollingMessageSource + extends AcknowledgementProcessingMessageSource, SmartLifecycle, TaskExecutorAware { + + /** + * Set the endpoint name that will be polled by this source. + * @param endpointName the name. + */ + void setPollingEndpointName(String endpointName); + + /** + * Set the {@link BackPressureHandler} that will be use to handle backpressure in this source. + * @param backPressureHandler the handler. + */ + void setBackPressureHandler(BackPressureHandler backPressureHandler); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/SqsMessageSource.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/SqsMessageSource.java new file mode 100644 index 000000000..46d24b3d4 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/SqsMessageSource.java @@ -0,0 +1,217 @@ +/* + * 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.source; + +import io.awspring.cloud.sqs.ConfigUtils; +import io.awspring.cloud.sqs.listener.ContainerOptions; +import io.awspring.cloud.sqs.listener.QueueAttributes; +import io.awspring.cloud.sqs.listener.QueueAttributesAware; +import io.awspring.cloud.sqs.listener.QueueAttributesResolver; +import io.awspring.cloud.sqs.listener.QueueNotFoundStrategy; +import io.awspring.cloud.sqs.listener.SqsAsyncClientAware; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementExecutor; +import io.awspring.cloud.sqs.listener.acknowledgement.ExecutingAcknowledgementProcessor; +import io.awspring.cloud.sqs.listener.acknowledgement.SqsAcknowledgementExecutor; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.Assert; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.model.Message; +import software.amazon.awssdk.services.sqs.model.QueueAttributeName; +import software.amazon.awssdk.services.sqs.model.ReceiveMessageRequest; +import software.amazon.awssdk.services.sqs.model.ReceiveMessageResponse; + +/** + * {@link MessageSource} implementation for polling messages from a SQS queue and converting them to messaging + * {@link Message}. + * + *

+ * A {@link io.awspring.cloud.sqs.listener.MessageListenerContainer} can contain many sources, and each source polls + * from a single queue. + *

+ * + *

+ * Note that currently the payload is not converted here and is returned as String. The actual conversion to the + * {@link io.awspring.cloud.sqs.annotation.SqsListener} argument type happens on + * {@link org.springframework.messaging.handler.invocation.InvocableHandlerMethod} invocation. + *

+ * + * @param the {@link Message} payload type. + * + * @author Tomaz Fernandes + * @since 3.0 + */ +public class SqsMessageSource extends AbstractPollingMessageSource implements SqsAsyncClientAware { + + private static final Logger logger = LoggerFactory.getLogger(SqsMessageSource.class); + + private static final int MESSAGE_VISIBILITY_DISABLED = -1; + + private SqsAsyncClient sqsAsyncClient; + + private String queueUrl; + + private QueueAttributes queueAttributes; + + private QueueNotFoundStrategy queueNotFoundStrategy; + + private Collection queueAttributeNames; + + private Collection messageAttributeNames; + + private Collection messageSystemAttributeNames; + + private int messageVisibility; + + private int pollTimeout; + + @Override + public void setSqsAsyncClient(SqsAsyncClient sqsAsyncClient) { + Assert.notNull(sqsAsyncClient, "sqsAsyncClient cannot be null."); + this.sqsAsyncClient = sqsAsyncClient; + } + + @Override + protected void doConfigure(ContainerOptions containerOptions) { + this.pollTimeout = (int) containerOptions.getPollTimeout().getSeconds(); + this.queueAttributeNames = containerOptions.getQueueAttributeNames(); + this.messageAttributeNames = containerOptions.getMessageAttributeNames(); + this.messageSystemAttributeNames = containerOptions.getMessageSystemAttributeNames(); + this.queueNotFoundStrategy = containerOptions.getQueueNotFoundStrategy(); + this.messageVisibility = containerOptions.getMessageVisibility() != null + ? (int) containerOptions.getMessageVisibility().getSeconds() + : MESSAGE_VISIBILITY_DISABLED; + } + + @Override + protected void doStart() { + Assert.notNull(this.sqsAsyncClient, "sqsAsyncClient not set"); + Assert.notNull(this.queueAttributeNames, "queueAttributeNames not set"); + this.queueAttributes = resolveQueueAttributes(); + this.queueUrl = this.queueAttributes.getQueueUrl(); + configureConversionContextAndAcknowledgement(); + } + + @SuppressWarnings("unchecked") + private void configureConversionContextAndAcknowledgement() { + ConfigUtils.INSTANCE + .acceptIfInstance(getMessageConversionContext(), SqsAsyncClientAware.class, + saca -> saca.setSqsAsyncClient(this.sqsAsyncClient)) + .acceptIfInstance(getMessageConversionContext(), QueueAttributesAware.class, + qaa -> qaa.setQueueAttributes(this.queueAttributes)) + .acceptIfInstance(getAcknowledgmentProcessor(), ExecutingAcknowledgementProcessor.class, eap -> eap + .setAcknowledgementExecutor(createAndConfigureAcknowledgementExecutor(this.queueAttributes))); + } + + // @formatter:off + private QueueAttributes resolveQueueAttributes() { + return QueueAttributesResolver.builder() + .queueName(getPollingEndpointName()) + .sqsAsyncClient(this.sqsAsyncClient) + .queueAttributeNames(this.queueAttributeNames) + .queueNotFoundStrategy(this.queueNotFoundStrategy).build() + .resolveQueueAttributes() + .join(); + } + // @formatter:on + + protected AcknowledgementExecutor createAndConfigureAcknowledgementExecutor(QueueAttributes queueAttributes) { + AcknowledgementExecutor executor = createAcknowledgementExecutorInstance(); + ConfigUtils.INSTANCE + .acceptIfInstance(executor, QueueAttributesAware.class, qaa -> qaa.setQueueAttributes(queueAttributes)) + .acceptIfInstance(executor, SqsAsyncClientAware.class, + saca -> saca.setSqsAsyncClient(this.sqsAsyncClient)); + return executor; + } + + protected AcknowledgementExecutor createAcknowledgementExecutorInstance() { + return new SqsAcknowledgementExecutor<>(); + } + + // @formatter:off + @Override + protected CompletableFuture> doPollForMessages( + int maxNumberOfMessages) { + logger.debug("Polling queue {} for {} messages.", this.queueUrl, maxNumberOfMessages); + return maxNumberOfMessages <= 10 + ? executePoll(maxNumberOfMessages) + : executeMultiplePolls(maxNumberOfMessages); + } + + private CompletableFuture> executePoll(int maxNumberOfMessages) { + return sqsAsyncClient + .receiveMessage(createRequest(maxNumberOfMessages)) + .thenApply(ReceiveMessageResponse::messages) + .thenApply(collectionList -> (Collection) collectionList) + .whenComplete(this::logMessagesReceived); + } + + private ReceiveMessageRequest createRequest(int maxNumberOfMessages) { + ReceiveMessageRequest.Builder builder = ReceiveMessageRequest + .builder() + .queueUrl(this.queueUrl) + .receiveRequestAttemptId(UUID.randomUUID().toString()) + .maxNumberOfMessages(maxNumberOfMessages) + .attributeNamesWithStrings(this.messageSystemAttributeNames) + .messageAttributeNames(this.messageAttributeNames) + .waitTimeSeconds(this.pollTimeout); + + if (this.messageVisibility >= 0) { + builder.visibilityTimeout(this.messageVisibility); + } + return builder.build(); + } + // @formatter:on + + private CompletableFuture> executeMultiplePolls(int maxNumberOfMessages) { + int remainder = maxNumberOfMessages % 10; + return remainder == 0 ? combinePolls(maxNumberOfMessages) + : combinePolls(maxNumberOfMessages).thenCombine(executePoll(remainder), this::combineBatches); + } + + private CompletableFuture> combinePolls(int maxNumberOfMessages) { + return IntStream.range(0, maxNumberOfMessages / 10).mapToObj(index -> executePoll(10)).reduce( + CompletableFuture.completedFuture(Collections.emptyList()), + (first, second) -> first.thenCombine(second, this::combineBatches)); + } + + private Collection combineBatches(Collection firstBatch, Collection secondBatch) { + List combinedBatch = new ArrayList<>(firstBatch); + combinedBatch.addAll(secondBatch); + return combinedBatch; + } + + private void logMessagesReceived(Collection v, Throwable t) { + if (v != null) { + if (logger.isTraceEnabled()) { + logger.trace("Received {} messages {} from queue {}", v.size(), + v.stream().map(Message::messageId).collect(Collectors.toList()), this.queueUrl); + } + else { + logger.debug("Received {} messages from queue {}", v.size(), this.queueUrl); + } + } + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/package-info.java new file mode 100644 index 000000000..0ee942669 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/** + * Auto-configuration for Amazon SQS (Simple Queue Service) integrations. + */ +@org.springframework.lang.NonNullApi +@org.springframework.lang.NonNullFields +package io.awspring.cloud.sqs.listener.source; diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/package-info.java new file mode 100644 index 000000000..5480e25e3 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/** + * Auto-configuration for Amazon SQS (Simple Queue Service) integrations. + */ +@org.springframework.lang.NonNullApi +@org.springframework.lang.NonNullFields +package io.awspring.cloud.sqs; diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/AbstractMessagingMessageConverter.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/AbstractMessagingMessageConverter.java new file mode 100644 index 000000000..a0d945b0f --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/AbstractMessagingMessageConverter.java @@ -0,0 +1,160 @@ +/* + * 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.support.converter; + +import io.awspring.cloud.sqs.listener.SqsHeaders; +import java.util.function.Function; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.support.HeaderMapper; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.util.Assert; + +/** + * {@link MessagingMessageConverter} implementation for converting SQS + * {@link software.amazon.awssdk.services.sqs.model.Message} instances to Spring Messaging {@link Message} instances. + * + * @author Tomaz Fernandes + * @since 3.0 + * @see SqsHeaderMapper + * @see SqsMessageConversionContext + */ +public abstract class AbstractMessagingMessageConverter implements ContextAwareMessagingMessageConverter { + + private static final Logger logger = LoggerFactory.getLogger(AbstractMessagingMessageConverter.class); + + private static final MessageConverter DEFAULT_MESSAGE_CONVERTER = new MappingJackson2MessageConverter(); + + private String typeHeader = SqsHeaders.SQS_MA_HEADER_PREFIX + SqsHeaders.SQS_DEFAULT_TYPE_HEADER; + + private MessageConverter payloadMessageConverter = DEFAULT_MESSAGE_CONVERTER; + + private HeaderMapper headerMapper = getDefaultHeaderMapper(); + + private Function, Class> payloadTypeMapper = this::defaultHeaderTypeMapping; + + /** + * Set the payload type mapper to be used by this converter. {@link Message} payloads will be converted to the + * {@link Class} returned by this function. The {@link #defaultHeaderTypeMapping} uses the {@link #typeHeader} + * property to retrieve the payload class' FQCN. This method replaces the default type mapping for this converter + * instance. + * @param payloadTypeMapper the type mapping function. + */ + public void setPayloadTypeMapper(Function, Class> payloadTypeMapper) { + Assert.notNull(payloadTypeMapper, "payloadTypeMapper cannot be null"); + this.payloadTypeMapper = payloadTypeMapper; + } + + /** + * Set the {@link MessageConverter} to be used for converting the {@link Message} instances payloads. The default is + * {@link #DEFAULT_MESSAGE_CONVERTER}. + * @param messageConverter the converter instances. + */ + public void setPayloadMessageConverter(MessageConverter messageConverter) { + Assert.notNull(messageConverter, "messageConverter cannot be null"); + this.payloadMessageConverter = messageConverter; + } + + /** + * Set the name of the header to be looked up in a {@link Message} instance by the + * {@link #defaultHeaderTypeMapping(Message)}. + * @param typeHeader the header name. + */ + public void setPayloadTypeHeader(String typeHeader) { + Assert.notNull(typeHeader, "typeHeader cannot be null"); + this.typeHeader = SqsHeaders.SQS_MA_HEADER_PREFIX + typeHeader; + } + + /** + * Set the {@link HeaderMapper} to used to convert headers for + * {@link software.amazon.awssdk.services.sqs.model.Message} instances. + * @param headerMapper the header mapper instance. + */ + public void setHeaderMapper(HeaderMapper headerMapper) { + Assert.notNull(headerMapper, "headerMapper cannot be null"); + this.headerMapper = headerMapper; + } + + protected abstract HeaderMapper getDefaultHeaderMapper(); + + @Override + public Message toMessagingMessage(S message, @Nullable MessageConversionContext context) { + MessageHeaders messageHeaders = createMessageHeaders(message, context); + return MessageBuilder.createMessage(convertPayload(message, messageHeaders), messageHeaders); + } + + private MessageHeaders createMessageHeaders(S message, MessageConversionContext context) { + MessageHeaders messageHeaders = this.headerMapper.toHeaders(message); + return context != null && this.headerMapper instanceof ContextAwareHeaderMapper + ? addContextHeaders(message, context, messageHeaders) + : messageHeaders; + } + + private MessageHeaders addContextHeaders(S message, MessageConversionContext context, + MessageHeaders messageHeaders) { + MessageHeaders contextHeaders = getContextHeaders(message, context); + MessageHeaderAccessor accessor = new MessageHeaderAccessor(); + accessor.copyHeaders(messageHeaders); + accessor.copyHeaders(contextHeaders); + return accessor.getMessageHeaders(); + } + + private MessageHeaders getContextHeaders(S message, MessageConversionContext context) { + return ((ContextAwareHeaderMapper) this.headerMapper).createContextHeaders(message, context); + } + + private Object convertPayload(S message, MessageHeaders messageHeaders) { + Message messagingMessage = MessageBuilder.createMessage(getPayloadToConvert(message), messageHeaders); + Class targetType = this.payloadTypeMapper.apply(messagingMessage); + return targetType != null ? this.payloadMessageConverter.fromMessage(messagingMessage, targetType) + : getPayloadToConvert(message); + } + + protected abstract Object getPayloadToConvert(S message); + + @Nullable + private Class defaultHeaderTypeMapping(Message message) { + String header = message.getHeaders().get(this.typeHeader, String.class); + if (header == null) { + return null; + } + try { + return Class.forName(header); + } + catch (ClassNotFoundException e) { + throw new IllegalArgumentException("No class found with name " + header); + } + } + + @Override + public MessageConversionContext createMessageConversionContext() { + return new MessageConversionContext() { + }; + } + + @Override + public S fromMessagingMessage(Message message) { + // To be implemented for `SqsTemplate` + throw new UnsupportedOperationException("fromMessagingMessage not implemented"); + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/AcknowledgementAwareMessageConversionContext.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/AcknowledgementAwareMessageConversionContext.java new file mode 100644 index 000000000..ddeeab805 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/AcknowledgementAwareMessageConversionContext.java @@ -0,0 +1,32 @@ +/* + * 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.support.converter; + +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback; + +/** + * {@link MessageConversionContext} specialization that enables setting an {@link AcknowledgementCallback} to be used + * when mapping acknowledgement related properties. + * @author Tomaz Fernandes + * @since 3.0 + */ +public interface AcknowledgementAwareMessageConversionContext extends MessageConversionContext { + + void setAcknowledgementCallback(AcknowledgementCallback acknowledgementCallback); + + AcknowledgementCallback getAcknowledgementCallback(); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/ContextAwareHeaderMapper.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/ContextAwareHeaderMapper.java new file mode 100644 index 000000000..4fbad0cb5 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/ContextAwareHeaderMapper.java @@ -0,0 +1,32 @@ +/* + * 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.support.converter; + +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.HeaderMapper; + +/** + * A {@link HeaderMapper} specialization that supports receiving a {@link MessageConversionContext} for mapping context + * dependent headers. + * @author Tomaz Fernandes + * @since 3.0 + * @see ContextAwareMessagingMessageConverter + */ +public interface ContextAwareHeaderMapper extends HeaderMapper { + + MessageHeaders createContextHeaders(S source, MessageConversionContext context); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/ContextAwareMessagingMessageConverter.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/ContextAwareMessagingMessageConverter.java new file mode 100644 index 000000000..384981eab --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/ContextAwareMessagingMessageConverter.java @@ -0,0 +1,39 @@ +/* + * 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.support.converter; + +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; + +/** + * A {@link MessagingMessageConverter} specialization that enables receving a {@link MessageConversionContext} that can + * be used to add context specific properties to the converted message. + * @author Tomaz Fernandes + * @since 3.0 + * @see ContextAwareHeaderMapper + */ +public interface ContextAwareMessagingMessageConverter extends MessagingMessageConverter { + + @Override + default Message toMessagingMessage(S source) { + return toMessagingMessage(source, null); + } + + Message toMessagingMessage(S source, @Nullable MessageConversionContext context); + + MessageConversionContext createMessageConversionContext(); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/MessageConversionContext.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/MessageConversionContext.java new file mode 100644 index 000000000..066b88617 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/MessageConversionContext.java @@ -0,0 +1,27 @@ +/* + * 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.support.converter; + +/** + * Marker interface for a message conversion context. + * @author Tomaz Fernandes + * @since 3.0 + * @see ContextAwareMessagingMessageConverter + * @see ContextAwareHeaderMapper + */ +public interface MessageConversionContext { + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/MessagingMessageConverter.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/MessagingMessageConverter.java new file mode 100644 index 000000000..fd8dee19f --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/MessagingMessageConverter.java @@ -0,0 +1,31 @@ +/* + * 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.support.converter; + +import org.springframework.messaging.Message; + +/** + * A converter for converting source or target objects to and from Spring Messaging {@link Message}s. + * @author Tomaz Fernandes + * @since 3.0 + */ +public interface MessagingMessageConverter { + + Message toMessagingMessage(S source); + + S fromMessagingMessage(Message message); + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/MessagingMessageHeaders.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/MessagingMessageHeaders.java new file mode 100644 index 000000000..5f0c00352 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/MessagingMessageHeaders.java @@ -0,0 +1,56 @@ +/* + * 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.support.converter; + +import java.util.Map; +import java.util.UUID; +import org.springframework.lang.Nullable; +import org.springframework.messaging.MessageHeaders; + +/** + * {@link MessageHeaders} implementation that allows providing an external {@link UUID}. + * @author Tomaz Fernandes + * @since 3.0 + */ +public class MessagingMessageHeaders extends MessageHeaders { + + /** + * Create an instance with the provided headers. + * @param headers + */ + public MessagingMessageHeaders(@Nullable Map headers) { + super(headers); + } + + /** + * Create an instance with the provided headers and id. + * @param headers the headers. + * @param id the id. + */ + public MessagingMessageHeaders(@Nullable Map headers, @Nullable UUID id) { + super(headers, id, null); + } + + /** + * Create an instance with the provided arguments. + * @param headers the message headers. + * @param id the id. + * @param timestamp the timestamp. + */ + public MessagingMessageHeaders(@Nullable Map headers, @Nullable UUID id, @Nullable Long timestamp) { + super(headers, id, timestamp); + } +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/SqsHeaderMapper.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/SqsHeaderMapper.java new file mode 100644 index 000000000..6812f0652 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/SqsHeaderMapper.java @@ -0,0 +1,132 @@ +/* + * 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.support.converter; + +import io.awspring.cloud.sqs.ConfigUtils; +import io.awspring.cloud.sqs.listener.QueueAttributes; +import io.awspring.cloud.sqs.listener.QueueMessageVisibility; +import io.awspring.cloud.sqs.listener.SqsHeaders; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import java.util.function.BiFunction; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.util.Assert; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.model.Message; + +/** + * A {@link org.springframework.messaging.support.HeaderMapper} implementation for SQS {@link Message}s. Enables + * creating additional SQS related headers from a {@link SqsMessageConversionContext}. + * @author Tomaz Fernandes + * @since 3.0 + * @see SqsMessagingMessageConverter + */ +public class SqsHeaderMapper implements ContextAwareHeaderMapper { + + private static final Logger logger = LoggerFactory.getLogger(SqsHeaderMapper.class); + + private BiFunction additionalHeadersFunction = ((message, + accessor) -> accessor.toMessageHeaders()); + + public void setAdditionalHeadersFunction( + BiFunction headerFunction) { + Assert.notNull(headerFunction, "headerFunction cannot be null"); + this.additionalHeadersFunction = headerFunction; + } + + @Override + public void fromHeaders(MessageHeaders headers, Message target) { + // We'll probably use this for SqsTemplate later + } + + @Override + public MessageHeaders toHeaders(Message source) { + logger.trace("Mapping headers for message {}", source.messageId()); + MessageHeaderAccessor accessor = new MessageHeaderAccessor(); + accessor.copyHeadersIfAbsent(getMessageSystemAttributesAsHeaders(source)); + accessor.copyHeadersIfAbsent(getMessageAttributesAsHeaders(source)); + accessor.copyHeadersIfAbsent(createDefaultHeaders(source)); + accessor.copyHeadersIfAbsent(createAdditionalHeaders(source, new MessageHeaderAccessor())); + MessageHeaders messageHeaders = accessor.toMessageHeaders(); + logger.trace("Mapped headers {} for message {}", messageHeaders, source.messageId()); + return new MessagingMessageHeaders(messageHeaders, UUID.fromString(source.messageId())); + } + + private MessageHeaders createAdditionalHeaders(Message source, MessageHeaderAccessor accessor) { + return this.additionalHeadersFunction.apply(source, accessor); + } + + private MessageHeaders createDefaultHeaders(Message source) { + MessageHeaderAccessor accessor = new MessageHeaderAccessor(); + accessor.setHeader(SqsHeaders.SQS_RECEIPT_HANDLE_HEADER, source.receiptHandle()); + accessor.setHeader(SqsHeaders.SQS_SOURCE_DATA_HEADER, source); + accessor.setHeader(SqsHeaders.SQS_RECEIVED_AT_HEADER, Instant.now()); + return accessor.toMessageHeaders(); + } + + // @formatter:off + private Map getMessageAttributesAsHeaders(Message source) { + return source + .messageAttributes() + .entrySet() + .stream() + .collect(Collectors.toMap(entry -> SqsHeaders.SQS_MA_HEADER_PREFIX + entry.getKey(), entry -> entry.getValue().stringValue())); + } + + private Map getMessageSystemAttributesAsHeaders(Message source) { + return source + .attributes() + .entrySet() + .stream() + .collect(Collectors.toMap(entry -> SqsHeaders.MessageSystemAttribute.SQS_MSA_HEADER_PREFIX + entry.getKey(), Map.Entry::getValue)); + } + // @formatter:on + + @Override + public MessageHeaders createContextHeaders(Message source, MessageConversionContext context) { + logger.trace("Creating context headers for message {}", source.messageId()); + MessageHeaderAccessor accessor = new MessageHeaderAccessor(); + ConfigUtils.INSTANCE.acceptIfInstance(context, SqsMessageConversionContext.class, + sqsContext -> addSqsContextHeaders(source, sqsContext, accessor)).acceptIfInstance(context, + SqsMessageConversionContext.class, smcc -> maybeAddAcknowledgementHeader(smcc, accessor)); + MessageHeaders messageHeaders = accessor.toMessageHeaders(); + logger.trace("Context headers {} created for message {}", messageHeaders, source.messageId()); + return messageHeaders; + } + + private void addSqsContextHeaders(Message source, SqsMessageConversionContext sqsContext, + MessageHeaderAccessor accessor) { + QueueAttributes queueAttributes = sqsContext.getQueueAttributes(); + SqsAsyncClient sqsAsyncClient = sqsContext.getSqsAsyncClient(); + accessor.setHeader(SqsHeaders.SQS_QUEUE_NAME_HEADER, queueAttributes.getQueueName()); + accessor.setHeader(SqsHeaders.SQS_QUEUE_URL_HEADER, queueAttributes.getQueueUrl()); + accessor.setHeader(SqsHeaders.SQS_QUEUE_ATTRIBUTES_HEADER, queueAttributes); + accessor.setHeader(SqsHeaders.SQS_VISIBILITY_HEADER, + new QueueMessageVisibility(sqsAsyncClient, queueAttributes.getQueueUrl(), source.receiptHandle())); + } + + private void maybeAddAcknowledgementHeader(AcknowledgementAwareMessageConversionContext sqsContext, + MessageHeaderAccessor accessor) { + ConfigUtils.INSTANCE.acceptIfNotNull(sqsContext.getAcknowledgementCallback(), + callback -> accessor.setHeader(SqsHeaders.SQS_ACKNOWLEDGMENT_CALLBACK_HEADER, callback)); + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/SqsMessageConversionContext.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/SqsMessageConversionContext.java new file mode 100644 index 000000000..12aae142a --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/SqsMessageConversionContext.java @@ -0,0 +1,70 @@ +/* + * 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.support.converter; + +import io.awspring.cloud.sqs.listener.QueueAttributes; +import io.awspring.cloud.sqs.listener.QueueAttributesAware; +import io.awspring.cloud.sqs.listener.SqsAsyncClientAware; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; + +/** + * {@link MessageConversionContext} implementation that contains SQS related properties for mapping additional + * {@link org.springframework.messaging.MessageHeaders}. Also contains a {@link AcknowledgementCallback} to be used for + * mapping acknowledgement related headers. + * @author Tomaz Fernandes + * @since 3.0 + * @see SqsHeaderMapper + * @see SqsMessagingMessageConverter + */ +public class SqsMessageConversionContext + implements AcknowledgementAwareMessageConversionContext, SqsAsyncClientAware, QueueAttributesAware { + + private QueueAttributes queueAttributes; + + private SqsAsyncClient sqsAsyncClient; + + private AcknowledgementCallback acknowledgementCallback; + + @Override + public void setQueueAttributes(QueueAttributes queueAttributes) { + this.queueAttributes = queueAttributes; + } + + @Override + public void setSqsAsyncClient(SqsAsyncClient sqsAsyncClient) { + this.sqsAsyncClient = sqsAsyncClient; + } + + @Override + public void setAcknowledgementCallback(AcknowledgementCallback acknowledgementCallback) { + this.acknowledgementCallback = acknowledgementCallback; + } + + public SqsAsyncClient getSqsAsyncClient() { + return this.sqsAsyncClient; + } + + public QueueAttributes getQueueAttributes() { + return this.queueAttributes; + } + + @Override + public AcknowledgementCallback getAcknowledgementCallback() { + return this.acknowledgementCallback; + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/SqsMessagingMessageConverter.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/SqsMessagingMessageConverter.java new file mode 100644 index 000000000..8bbb85754 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/SqsMessagingMessageConverter.java @@ -0,0 +1,48 @@ +/* + * 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.support.converter; + +import org.springframework.messaging.Message; +import org.springframework.messaging.support.HeaderMapper; + +/** + * {@link MessagingMessageConverter} implementation for converting SQS + * {@link software.amazon.awssdk.services.sqs.model.Message} instances to Spring Messaging {@link Message} instances. + * + * @author Tomaz Fernandes + * @since 3.0 + * @see SqsHeaderMapper + * @see SqsMessageConversionContext + */ +public class SqsMessagingMessageConverter + extends AbstractMessagingMessageConverter { + + @Override + protected HeaderMapper getDefaultHeaderMapper() { + return new SqsHeaderMapper(); + } + + @Override + protected Object getPayloadToConvert(software.amazon.awssdk.services.sqs.model.Message message) { + return message.body(); + } + + @Override + public MessageConversionContext createMessageConversionContext() { + return new SqsMessageConversionContext(); + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/package-info.java new file mode 100644 index 000000000..ef77e41b6 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/converter/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/** + * Auto-configuration for Amazon SQS (Simple Queue Service) integrations. + */ +@org.springframework.lang.NonNullApi +@org.springframework.lang.NonNullFields +package io.awspring.cloud.sqs.support.converter; diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/package-info.java new file mode 100644 index 000000000..c2f34a9ad --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/** + * Auto-configuration for Amazon SQS (Simple Queue Service) integrations. + */ +@org.springframework.lang.NonNullApi +@org.springframework.lang.NonNullFields +package io.awspring.cloud.sqs.support; diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/AcknowledgmentHandlerMethodArgumentResolver.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/AcknowledgmentHandlerMethodArgumentResolver.java new file mode 100644 index 000000000..d18b7e912 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/AcknowledgmentHandlerMethodArgumentResolver.java @@ -0,0 +1,61 @@ +/* + * Copyright 2013-2019 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.support.resolver; + +import io.awspring.cloud.sqs.MessageHeaderUtils; +import io.awspring.cloud.sqs.listener.SqsHeaders; +import io.awspring.cloud.sqs.listener.acknowledgement.Acknowledgement; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback; +import java.util.concurrent.CompletableFuture; +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * {@link HandlerMethodArgumentResolver} implementation for resolving {@link Acknowledgement} arguments. + * @author Tomaz Fernandes + * @since 3.0 + */ +public class AcknowledgmentHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return ClassUtils.isAssignable(Acknowledgement.class, parameter.getParameterType()); + } + + @SuppressWarnings("unchecked") + @Override + public Object resolveArgument(MethodParameter parameter, Message message) { + AcknowledgementCallback callback = message.getHeaders() + .get(SqsHeaders.SQS_ACKNOWLEDGMENT_CALLBACK_HEADER, AcknowledgementCallback.class); + Assert.notNull(callback, "No acknowledgement found for message " + MessageHeaderUtils.getId(message) + + ". AcknowledgeMode should be MANUAL."); + return new Acknowledgement() { + @Override + public void acknowledge() { + acknowledgeAsync().join(); + } + + @Override + public CompletableFuture acknowledgeAsync() { + return callback.onAcknowledge((Message) message); + } + }; + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/BatchAcknowledgmentArgumentResolver.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/BatchAcknowledgmentArgumentResolver.java new file mode 100644 index 000000000..5580ede08 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/BatchAcknowledgmentArgumentResolver.java @@ -0,0 +1,81 @@ +/* + * Copyright 2013-2019 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.support.resolver; + +import io.awspring.cloud.sqs.MessageHeaderUtils; +import io.awspring.cloud.sqs.listener.SqsHeaders; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.BatchAcknowledgement; +import java.util.Collection; +import java.util.concurrent.CompletableFuture; +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * {@link HandlerMethodArgumentResolver} implementation for resolving {@link BatchAcknowledgement} arguments. + * @author Tomaz Fernandes + * @since 3.0 + */ +public class BatchAcknowledgmentArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return ClassUtils.isAssignable(BatchAcknowledgement.class, parameter.getParameterType()); + } + + @SuppressWarnings("unchecked") + @Override + public Object resolveArgument(MethodParameter parameter, Message message) { + Object payloadObject = message.getPayload(); + Assert.isInstanceOf(Collection.class, payloadObject, "Payload must be instance of Collection"); + Collection> messages = (Collection>) payloadObject; + AcknowledgementCallback callback = messages.iterator().next().getHeaders() + .get(SqsHeaders.SQS_ACKNOWLEDGMENT_CALLBACK_HEADER, AcknowledgementCallback.class); + Assert.notNull(callback, "No acknowledgement found for messages " + MessageHeaderUtils.getId(messages) + + ". AcknowledgeMode should be MANUAL."); + return createBatchAcknowledgement(messages, callback); + } + + private BatchAcknowledgement createBatchAcknowledgement(Collection> messages, + AcknowledgementCallback callback) { + return new BatchAcknowledgement() { + + @Override + public void acknowledge() { + acknowledgeAsync().join(); + } + + @Override + public CompletableFuture acknowledgeAsync() { + return callback.onAcknowledge(messages); + } + + @Override + public void acknowledge(Collection> messagesToAcknowledge) { + acknowledgeAsync(messagesToAcknowledge).join(); + } + + @Override + public CompletableFuture acknowledgeAsync(Collection> messagesToAcknowledge) { + return callback.onAcknowledge(messagesToAcknowledge); + } + }; + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/BatchPayloadMethodArgumentResolver.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/BatchPayloadMethodArgumentResolver.java new file mode 100644 index 000000000..2789d2b18 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/BatchPayloadMethodArgumentResolver.java @@ -0,0 +1,180 @@ +/* + * 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.support.resolver; + +import java.lang.annotation.Annotation; +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.converter.MessageConversionException; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.SmartValidator; +import org.springframework.validation.Validator; +import org.springframework.validation.annotation.Validated; + +/** + * {@link HandlerMethodArgumentResolver} implementation for resolving {@link java.util.List} arguments. + * @author Tomaz Fernandes + * @since 3.0 + */ +public class BatchPayloadMethodArgumentResolver implements HandlerMethodArgumentResolver { + + private final MessageConverter converter; + + @Nullable + private final Validator validator; + + /** + * Create a new {@code BatchPayloadArgumentResolver} with the given {@link MessageConverter}. + * @param messageConverter the MessageConverter to use (required) + */ + public BatchPayloadMethodArgumentResolver(MessageConverter messageConverter) { + this(messageConverter, null); + } + + /** + * Create a new {@code BatchPayloadArgumentResolver} with the given {@link MessageConverter} and {@link Validator}. + * @param messageConverter the MessageConverter to use (required) + * @param validator the Validator to use (optional) + */ + public BatchPayloadMethodArgumentResolver(MessageConverter messageConverter, @Nullable Validator validator) { + Assert.notNull(messageConverter, "MessageConverter must not be null"); + this.converter = messageConverter; + this.validator = validator; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + Class parameterClass = ResolvableType.forType(parameter.getGenericParameterType()).toClass(); + return Collection.class.isAssignableFrom(parameterClass); + } + + // @formatter:off + @Override + public Object resolveArgument(MethodParameter parameter, Message message) { + Class targetClass = resolveTargetClass(parameter); + boolean isMessageParameter = Message.class + .isAssignableFrom(ResolvableType.forMethodParameter(parameter).getNested(2).toClass()); + return getPayloadAsCollection(message) + .stream() + .filter(msg -> Message.class.isAssignableFrom(msg.getClass())) + .map(Message.class::cast) + .map(msg -> convertAndValidatePayload(parameter, msg, targetClass, isMessageParameter)) + .collect(Collectors.toList()); + } + // @formatter:on + + private Collection getPayloadAsCollection(Message message) { + Object payload = message.getPayload(); + if (payload instanceof Collection && !((Collection) payload).isEmpty()) { + return (Collection) payload; + } + throw new IllegalArgumentException("Payload must be a non-empty Collection: " + message); + } + + private Object convertAndValidatePayload(MethodParameter parameter, Message message, Class targetClass, + boolean isMessageParameter) { + Object convertedPayload = getConvertedPayload(message, targetClass); + if (convertedPayload == null) { + throw new MessageConversionException(message, + "Cannot convert from [" + message.getPayload().getClass().getName() + "] to [" + + targetClass.getName() + "] for " + message); + } + validate(message, parameter, convertedPayload); + return isMessageParameter ? MessageBuilder.createMessage(convertedPayload, message.getHeaders()) + : convertedPayload; + } + + private Object getConvertedPayload(Message message, Class targetClass) { + return this.converter.fromMessage(message, targetClass); + } + + private final Map> targetClassCache = new ConcurrentHashMap<>(); + + private Class resolveTargetClass(MethodParameter parameter) { + return targetClassCache.computeIfAbsent(parameter, param -> { + ResolvableType resolvableType = ResolvableType.forType(parameter.getGenericParameterType()); + Class collectionGenericClass = throwIfObject(resolvableType.getNested(2).toClass(), parameter); + if (Message.class.isAssignableFrom(collectionGenericClass)) { + return throwIfObject(resolvableType.getNested(3).toClass(), parameter); + } + return collectionGenericClass; + }); + } + + private Class throwIfObject(Class classToCompare, MethodParameter parameter) { + if (Object.class.equals(classToCompare)) { + throw new IllegalArgumentException(String.format( + "Could not resolve target for parameter %s in method %s from class %s." + + " Generic types are required.", + parameter.getParameterName(), parameter.getMethod().getName(), parameter.getContainingClass())); + } + return classToCompare; + } + + /** + * Validate the payload if applicable. + *

+ * The default implementation checks for {@code @javax.validation.Valid}, Spring's {@link Validated}, and custom + * annotations whose name starts with "Valid". + * @param message the currently processed message + * @param parameter the method parameter + * @param target the target payload object + * @throws MethodArgumentNotValidException in case of binding errors + */ + protected void validate(Message message, MethodParameter parameter, Object target) { + if (this.validator == null) { + return; + } + for (Annotation ann : parameter.getParameterAnnotations()) { + Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class); + if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) { + Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann)); + Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] { hints }); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, + getParameterName(parameter)); + if (!ObjectUtils.isEmpty(validationHints) && this.validator instanceof SmartValidator) { + ((SmartValidator) this.validator).validate(target, bindingResult, validationHints); + } + else { + this.validator.validate(target, bindingResult); + } + if (bindingResult.hasErrors()) { + throw new MethodArgumentNotValidException(message, parameter, bindingResult); + } + break; + } + } + } + + private String getParameterName(MethodParameter param) { + String paramName = param.getParameterName(); + return (paramName != null ? paramName : "Arg " + param.getParameterIndex()); + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/QueueAttributesMethodArgumentResolver.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/QueueAttributesMethodArgumentResolver.java new file mode 100644 index 000000000..858615cff --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/QueueAttributesMethodArgumentResolver.java @@ -0,0 +1,40 @@ +/* + * 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.support.resolver; + +import io.awspring.cloud.sqs.listener.QueueAttributes; +import io.awspring.cloud.sqs.listener.SqsHeaders; +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; + +/** + * {@link HandlerMethodArgumentResolver} implementation for resolving {@link QueueAttributes} arguments. + * @author Tomaz Fernandes + * @since 3.0 + */ +public class QueueAttributesMethodArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return QueueAttributes.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, Message message) { + return message.getHeaders().get(SqsHeaders.SQS_QUEUE_ATTRIBUTES_HEADER); + } +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/SqsMessageMethodArgumentResolver.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/SqsMessageMethodArgumentResolver.java new file mode 100644 index 000000000..fe18132b2 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/SqsMessageMethodArgumentResolver.java @@ -0,0 +1,40 @@ +/* + * Copyright 2013-2020 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.support.resolver; + +import io.awspring.cloud.sqs.listener.SqsHeaders; +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; + +/** + * Resolves original SQS message object {@link (software.amazon.awssdk.services.sqs.model.Message)} from Spring + * Messaging message object {@link Message}. + * + * @author Maciej Walkowiak + */ +public class SqsMessageMethodArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return software.amazon.awssdk.services.sqs.model.Message.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, Message message) { + return message.getHeaders().get(SqsHeaders.SQS_SOURCE_DATA_HEADER); + } +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/VisibilityHandlerMethodArgumentResolver.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/VisibilityHandlerMethodArgumentResolver.java new file mode 100644 index 000000000..5ddc3ced9 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/VisibilityHandlerMethodArgumentResolver.java @@ -0,0 +1,53 @@ +/* + * Copyright 2013-2019 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.support.resolver; + +import io.awspring.cloud.sqs.listener.Visibility; +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.util.ClassUtils; + +/** + * {@link HandlerMethodArgumentResolver} for {@link Visibility} method parameters. + * + * @author Szymon Dembek + * @since 1.3 + */ +public class VisibilityHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver { + + private final String visibilityHeaderName; + + public VisibilityHandlerMethodArgumentResolver(String visibilityHeaderName) { + this.visibilityHeaderName = visibilityHeaderName; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return ClassUtils.isAssignable(Visibility.class, parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, Message message) { + if (!message.getHeaders().containsKey(this.visibilityHeaderName) + || message.getHeaders().get(this.visibilityHeaderName) == null) { + throw new IllegalArgumentException( + "No visibility object found for message header: '" + this.visibilityHeaderName + "'"); + } + return message.getHeaders().get(this.visibilityHeaderName); + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/package-info.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/package-info.java new file mode 100644 index 000000000..f2899c128 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/support/resolver/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/** + * Auto-configuration for Amazon SQS (Simple Queue Service) integrations. + */ +@org.springframework.lang.NonNullApi +@org.springframework.lang.NonNullFields +package io.awspring.cloud.sqs.support.resolver; diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/annotation/SqsListenerAnnotationBeanPostProcessorTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/annotation/SqsListenerAnnotationBeanPostProcessorTests.java new file mode 100644 index 000000000..b17274697 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/annotation/SqsListenerAnnotationBeanPostProcessorTests.java @@ -0,0 +1,194 @@ +/* + * 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.annotation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.list; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.awspring.cloud.sqs.config.Endpoint; +import io.awspring.cloud.sqs.config.EndpointRegistrar; +import io.awspring.cloud.sqs.config.MessageListenerContainerFactory; +import io.awspring.cloud.sqs.config.SqsListenerConfigurer; +import io.awspring.cloud.sqs.listener.DefaultListenerContainerRegistry; +import io.awspring.cloud.sqs.listener.MessageListenerContainer; +import io.awspring.cloud.sqs.listener.MessageListenerContainerRegistry; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.messaging.converter.CompositeMessageConverter; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.handler.annotation.support.DefaultMessageHandlerMethodFactory; +import org.springframework.messaging.handler.annotation.support.MessageHandlerMethodFactory; +import org.springframework.messaging.handler.annotation.support.PayloadMethodArgumentResolver; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.util.StringValueResolver; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +class SqsListenerAnnotationBeanPostProcessorTests { + + @Test + void shouldCustomizeRegistrar() { + ListableBeanFactory beanFactory = mock(ListableBeanFactory.class); + ObjectMapper objectMapper = new ObjectMapper(); + MessageHandlerMethodFactory methodFactory = new DefaultMessageHandlerMethodFactory(); + DefaultListenerContainerRegistry registry = new DefaultListenerContainerRegistry() { + @Override + public void registerListenerContainer(MessageListenerContainer listenerContainer) { + } + }; + String factoryName = "otherFactory"; + MessageConverter converter = mock(MessageConverter.class); + HandlerMethodArgumentResolver resolver = mock(HandlerMethodArgumentResolver.class); + + SqsListenerConfigurer customizer = registrar -> { + registrar.setDefaultListenerContainerFactoryBeanName(factoryName); + registrar.setListenerContainerRegistry(registry); + registrar.setMessageHandlerMethodFactory(methodFactory); + registrar.setObjectMapper(objectMapper); + registrar.manageMessageConverters(converters -> converters.add(converter)); + registrar.manageMethodArgumentResolvers(resolvers -> resolvers.add(resolver)); + }; + + when(beanFactory.getBeansOfType(SqsListenerConfigurer.class)) + .thenReturn(Collections.singletonMap("customizer", customizer)); + when(beanFactory.getBean(factoryName, MessageListenerContainerFactory.class)) + .thenReturn(mock(MessageListenerContainerFactory.class)); + when(beanFactory.containsBean(factoryName)).thenReturn(true); + + List endpoints = new ArrayList<>(); + + EndpointRegistrar registrar = new EndpointRegistrar() { + @Override + public void registerEndpoint(Endpoint endpoint) { + endpoints.add(endpoint); + super.registerEndpoint(endpoint); + } + }; + SqsListenerAnnotationBeanPostProcessor processor = new SqsListenerAnnotationBeanPostProcessor() { + @Override + protected EndpointRegistrar createEndpointRegistrar() { + return registrar; + } + + }; + + processor.setBeanFactory(beanFactory); + StringValueResolver valueResolver = mock(StringValueResolver.class); + given(valueResolver.resolveStringValue("queueNames")).willReturn("queueNames"); + processor.setEmbeddedValueResolver(valueResolver); + Listener bean = new Listener(); + processor.postProcessAfterInitialization(bean, "listener"); + processor.afterSingletonsInstantiated(); + + Endpoint endpoint = endpoints.get(0); + assertThat(endpoint).extracting("handlerMethodFactory").extracting("delegate").isEqualTo(methodFactory) + .extracting("argumentResolvers").extracting("argumentResolvers") + .asInstanceOf(list(HandlerMethodArgumentResolver.class)).hasSizeGreaterThan(1).contains(resolver) + .filteredOn(thisResolver -> thisResolver instanceof PayloadMethodArgumentResolver).element(0) + .extracting("converter").asInstanceOf(type(CompositeMessageConverter.class)) + .extracting(CompositeMessageConverter::getConverters).asInstanceOf(list(MessageConverter.class)) + .contains(converter) + .filteredOn(thisConverter -> thisConverter instanceof MappingJackson2MessageConverter).element(0) + .extracting("objectMapper").isEqualTo(objectMapper); + + } + + @Test + void shouldChangeListenerRegistryBeanName() { + ListableBeanFactory beanFactory = mock(ListableBeanFactory.class); + MessageListenerContainerRegistry registry = mock(MessageListenerContainerRegistry.class); + MessageListenerContainerFactory factory = mock(MessageListenerContainerFactory.class); + + String registryBeanName = "customRegistry"; + SqsListenerConfigurer customizer = registrar -> registrar + .setMessageListenerContainerRegistryBeanName(registryBeanName); + + when(beanFactory.getBeansOfType(SqsListenerConfigurer.class)) + .thenReturn(Collections.singletonMap("customizer", customizer)); + when(beanFactory.getBean(registryBeanName, MessageListenerContainerRegistry.class)).thenReturn(registry); + when(beanFactory.containsBean(EndpointRegistrar.DEFAULT_LISTENER_CONTAINER_FACTORY_BEAN_NAME)).thenReturn(true); + when(beanFactory.getBean(EndpointRegistrar.DEFAULT_LISTENER_CONTAINER_FACTORY_BEAN_NAME, + MessageListenerContainerFactory.class)).thenReturn(factory); + + EndpointRegistrar registrar = new EndpointRegistrar(); + + SqsListenerAnnotationBeanPostProcessor processor = new SqsListenerAnnotationBeanPostProcessor() { + @Override + protected EndpointRegistrar createEndpointRegistrar() { + return registrar; + } + }; + + Listener bean = new Listener(); + processor.setBeanFactory(beanFactory); + StringValueResolver valueResolver = mock(StringValueResolver.class); + given(valueResolver.resolveStringValue("queueNames")).willReturn("queueNames"); + processor.setEmbeddedValueResolver(valueResolver); + processor.postProcessAfterInitialization(bean, "listener"); + processor.afterSingletonsInstantiated(); + + assertThat(registrar).extracting("listenerContainerRegistry").isEqualTo(registry); + + } + + @Test + void shouldThrowIfFactoryBeanNotFound() { + ListableBeanFactory beanFactory = mock(ListableBeanFactory.class); + MessageListenerContainerRegistry registry = mock(MessageListenerContainerRegistry.class); + + String registryBeanName = "customRegistry"; + SqsListenerConfigurer customizer = registrar -> registrar + .setMessageListenerContainerRegistryBeanName(registryBeanName); + + when(beanFactory.getBeansOfType(SqsListenerConfigurer.class)) + .thenReturn(Collections.singletonMap("customizer", customizer)); + when(beanFactory.getBean(registryBeanName, MessageListenerContainerRegistry.class)).thenReturn(registry); + when(beanFactory.containsBean(EndpointRegistrar.DEFAULT_LISTENER_CONTAINER_FACTORY_BEAN_NAME)) + .thenReturn(false); + + SqsListenerAnnotationBeanPostProcessor processor = new SqsListenerAnnotationBeanPostProcessor(); + + Listener bean = new Listener(); + StringValueResolver valueResolver = mock(StringValueResolver.class); + given(valueResolver.resolveStringValue("queueNames")).willReturn("queueNames"); + processor.setEmbeddedValueResolver(valueResolver); + processor.setBeanFactory(beanFactory); + processor.postProcessAfterInitialization(bean, "listener"); + assertThatThrownBy(processor::afterSingletonsInstantiated).isInstanceOf(IllegalArgumentException.class); + + } + + static class Listener { + + @SqsListener("myQueue") + void listen(String message) { + } + + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/config/AbstractMessageListenerContainerFactoryTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/config/AbstractMessageListenerContainerFactoryTests.java new file mode 100644 index 000000000..8444607f7 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/config/AbstractMessageListenerContainerFactoryTests.java @@ -0,0 +1,122 @@ +/* + * 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.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +import io.awspring.cloud.sqs.listener.AsyncMessageListener; +import io.awspring.cloud.sqs.listener.ContainerComponentFactory; +import io.awspring.cloud.sqs.listener.ContainerOptions; +import io.awspring.cloud.sqs.listener.MessageListener; +import io.awspring.cloud.sqs.listener.SqsMessageListenerContainer; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.AsyncAcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.errorhandler.ErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import io.awspring.cloud.sqs.listener.interceptor.MessageInterceptor; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class AbstractMessageListenerContainerFactoryTests { + + @Test + void shouldSetBlockingComponents() { + SqsMessageListenerContainer container = mock(SqsMessageListenerContainer.class); + + AbstractMessageListenerContainerFactory> factory = new AbstractMessageListenerContainerFactory>() { + + @Override + protected void configureContainerOptions(Endpoint endpoint, ContainerOptions.Builder containerOptions) { + + } + + @Override + protected SqsMessageListenerContainer createContainerInstance(Endpoint endpoint, + ContainerOptions containerOptions) { + return container; + } + }; + MessageListener listener = mock(MessageListener.class); + ErrorHandler errorHandler = mock(ErrorHandler.class); + MessageInterceptor interceptor = mock(MessageInterceptor.class); + AcknowledgementResultCallback callback = mock(AcknowledgementResultCallback.class); + ContainerComponentFactory componentFactory = mock(ContainerComponentFactory.class); + List> componentFactories = Collections.singletonList(componentFactory); + + factory.setMessageListener(listener); + factory.setErrorHandler(errorHandler); + factory.setContainerComponentFactories(componentFactories); + factory.addMessageInterceptor(interceptor); + factory.setAcknowledgementResultCallback(callback); + + SqsMessageListenerContainer createdContainer = factory.createContainer("test-queue"); + assertThat(createdContainer).isEqualTo(container); + then(container).should().setMessageListener(listener); + then(container).should().setErrorHandler(errorHandler); + then(container).should().setComponentFactories(componentFactories); + then(container).should().setAcknowledgementResultCallback(callback); + then(container).should().addMessageInterceptor(interceptor); + + } + + @Test + void shouldSetAsyncComponents() { + SqsMessageListenerContainer container = mock(SqsMessageListenerContainer.class); + AbstractMessageListenerContainerFactory> factory = new AbstractMessageListenerContainerFactory>() { + @Override + protected void configureContainerOptions(Endpoint endpoint, ContainerOptions.Builder containerOptions) { + + } + + @Override + protected SqsMessageListenerContainer createContainerInstance(Endpoint endpoint, + ContainerOptions containerOptions) { + return container; + } + }; + AsyncMessageListener listener = mock(AsyncMessageListener.class); + AsyncErrorHandler errorHandler = mock(AsyncErrorHandler.class); + AsyncMessageInterceptor interceptor = mock(AsyncMessageInterceptor.class); + AsyncAcknowledgementResultCallback callback = mock(AsyncAcknowledgementResultCallback.class); + ContainerComponentFactory componentFactory = mock(ContainerComponentFactory.class); + List> componentFactories = Collections.singletonList(componentFactory); + + factory.setAsyncMessageListener(listener); + factory.setErrorHandler(errorHandler); + factory.setContainerComponentFactories(componentFactories); + factory.addMessageInterceptor(interceptor); + factory.setAcknowledgementResultCallback(callback); + + SqsMessageListenerContainer createdContainer = factory.createContainer("test-queue"); + assertThat(createdContainer).isEqualTo(container); + then(container).should().setAsyncMessageListener(listener); + then(container).should().setErrorHandler(errorHandler); + then(container).should().setComponentFactories(componentFactories); + then(container).should().setAcknowledgementResultCallback(callback); + then(container).should().addMessageInterceptor(interceptor); + + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/config/SqsMessageListenerContainerFactoryTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/config/SqsMessageListenerContainerFactoryTests.java new file mode 100644 index 000000000..035526fea --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/config/SqsMessageListenerContainerFactoryTests.java @@ -0,0 +1,160 @@ +/* + * 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.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.collection; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import io.awspring.cloud.sqs.listener.AsyncMessageListener; +import io.awspring.cloud.sqs.listener.ContainerComponentFactory; +import io.awspring.cloud.sqs.listener.ContainerOptions; +import io.awspring.cloud.sqs.listener.MessageListener; +import io.awspring.cloud.sqs.listener.SqsMessageListenerContainer; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.AsyncAcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.errorhandler.ErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import io.awspring.cloud.sqs.listener.interceptor.MessageInterceptor; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class SqsMessageListenerContainerFactoryTests { + + @Test + void shouldCreateContainerFromEndpointWithOptionsDefaults() { + List queueNames = Collections.singletonList("test-queue"); + String id = "test-id"; + SqsAsyncClient client = mock(SqsAsyncClient.class); + SqsEndpoint endpoint = mock(SqsEndpoint.class); + given(endpoint.getMaxInflightMessagesPerQueue()).willReturn(null); + given(endpoint.getMessageVisibility()).willReturn(null); + given(endpoint.getMaxMessagesPerPoll()).willReturn(null); + given(endpoint.getPollTimeout()).willReturn(null); + given(endpoint.getLogicalNames()).willReturn(queueNames); + given(endpoint.getId()).willReturn(id); + + SqsMessageListenerContainerFactory factory = new SqsMessageListenerContainerFactory<>(); + factory.setSqsAsyncClient(client); + SqsMessageListenerContainer container = factory.createContainer(endpoint); + + assertThat(container.getContainerOptions()).isInstanceOfSatisfying(ContainerOptions.class, options -> { + assertThat(options.getMaxInFlightMessagesPerQueue()).isEqualTo(10); + assertThat(options.getMessageVisibility()).isNull(); + assertThat(options.getPollTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(options.getMaxMessagesPerPoll()).isEqualTo(10); + }); + + assertThat(container.getId()).isEqualTo(id); + assertThat(container.getQueueNames()).containsExactlyElementsOf(queueNames); + + } + + @Test + void shouldCreateContainerFromEndpointOverridingOptions() { + List queueNames = Collections.singletonList("test-queue"); + String id = "test-id"; + SqsAsyncClient client = mock(SqsAsyncClient.class); + SqsEndpoint endpoint = mock(SqsEndpoint.class); + int inflight = 9; + int messagesPerPoll = 7; + Duration pollTimeout = Duration.ofSeconds(6); + Duration visibility = Duration.ofSeconds(8); + given(endpoint.getMaxInflightMessagesPerQueue()).willReturn(inflight); + given(endpoint.getMessageVisibility()).willReturn(visibility); + given(endpoint.getMaxMessagesPerPoll()).willReturn(messagesPerPoll); + given(endpoint.getPollTimeout()).willReturn(pollTimeout); + given(endpoint.getLogicalNames()).willReturn(queueNames); + given(endpoint.getId()).willReturn(id); + + SqsMessageListenerContainerFactory factory = new SqsMessageListenerContainerFactory<>(); + factory.setSqsAsyncClient(client); + SqsMessageListenerContainer container = factory.createContainer(endpoint); + + assertThat(container.getContainerOptions()).isInstanceOfSatisfying(ContainerOptions.class, options -> { + assertThat(options.getMaxInFlightMessagesPerQueue()).isEqualTo(inflight); + assertThat(options.getMessageVisibility()).isEqualTo(visibility); + assertThat(options.getPollTimeout()).isEqualTo(pollTimeout); + assertThat(options.getMaxMessagesPerPoll()).isEqualTo(messagesPerPoll); + }); + + assertThat(container.getId()).isEqualTo(id); + assertThat(container.getQueueNames()).containsExactlyElementsOf(queueNames); + + } + + @Test + void shouldCreateFromBuilderWithBlockingComponents() { + SqsAsyncClient client = mock(SqsAsyncClient.class); + MessageListener listener = mock(MessageListener.class); + ErrorHandler errorHandler = mock(ErrorHandler.class); + MessageInterceptor interceptor1 = mock(MessageInterceptor.class); + MessageInterceptor interceptor2 = mock(MessageInterceptor.class); + AcknowledgementResultCallback callback = mock(AcknowledgementResultCallback.class); + ContainerComponentFactory componentFactory = mock(ContainerComponentFactory.class); + List> componentFactories = Collections.singletonList(componentFactory); + + SqsMessageListenerContainerFactory factory = SqsMessageListenerContainerFactory.builder() + .messageListener(listener).sqsAsyncClient(client).errorHandler(errorHandler) + .containerComponentFactories(componentFactories).acknowledgementResultCallback(callback) + .messageInterceptor(interceptor1).messageInterceptor(interceptor2).build(); + + assertThat(factory).extracting("messageListener").isEqualTo(listener); + assertThat(factory).extracting("errorHandler").isEqualTo(errorHandler); + assertThat(factory).extracting("acknowledgementResultCallback").isEqualTo(callback); + assertThat(factory).extracting("messageInterceptors").asInstanceOf(collection(MessageInterceptor.class)) + .containsExactly(interceptor1, interceptor2); + assertThat(factory).extracting("containerComponentFactories").isEqualTo(componentFactories); + assertThat(factory).extracting("sqsAsyncClientSupplier").asInstanceOf(type(Supplier.class)) + .extracting(Supplier::get).isEqualTo(client); + } + + @Test + void shouldCreateFromBuilderWithAsyncComponents() { + SqsAsyncClient client = mock(SqsAsyncClient.class); + AsyncMessageListener listener = mock(AsyncMessageListener.class); + AsyncErrorHandler errorHandler = mock(AsyncErrorHandler.class); + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncAcknowledgementResultCallback callback = mock(AsyncAcknowledgementResultCallback.class); + ContainerComponentFactory componentFactory = mock(ContainerComponentFactory.class); + List> componentFactories = Collections.singletonList(componentFactory); + + SqsMessageListenerContainerFactory container = SqsMessageListenerContainerFactory.builder() + .asyncMessageListener(listener).sqsAsyncClient(client).errorHandler(errorHandler) + .containerComponentFactories(componentFactories).acknowledgementResultCallback(callback) + .messageInterceptor(interceptor1).messageInterceptor(interceptor2).build(); + + assertThat(container).extracting("asyncMessageListener").isEqualTo(listener); + assertThat(container).extracting("asyncErrorHandler").isEqualTo(errorHandler); + assertThat(container).extracting("asyncAcknowledgementResultCallback").isEqualTo(callback); + assertThat(container).extracting("asyncMessageInterceptors") + .asInstanceOf(collection(AsyncMessageInterceptor.class)).containsExactly(interceptor1, interceptor2); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/BaseSqsIntegrationTest.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/BaseSqsIntegrationTest.java new file mode 100644 index 000000000..c8c0de074 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/BaseSqsIntegrationTest.java @@ -0,0 +1,203 @@ +/* + * 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 org.testcontainers.containers.localstack.LocalStackContainer.Service.SQS; + +import com.amazonaws.auth.AWSCredentials; +import io.awspring.cloud.sqs.CompletableFutures; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.BeforeAll; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.model.CreateQueueRequest; +import software.amazon.awssdk.services.sqs.model.QueueAttributeName; + +@Testcontainers +abstract class BaseSqsIntegrationTest { + + private static final Logger logger = LoggerFactory.getLogger(BaseSqsIntegrationTest.class); + + protected static final boolean useLocalStackClient = true; + + protected static final boolean purgeQueues = true; + + private static final String LOCAL_STACK_VERSION = "localstack/localstack:1.0.3"; + + static LocalStackContainer localstack = new LocalStackContainer(DockerImageName.parse(LOCAL_STACK_VERSION)) + .withServices(SQS); + + static StaticCredentialsProvider credentialsProvider; + + @BeforeAll + static synchronized void beforeAll() { + if (!localstack.isRunning()) { + localstack.start(); + AWSCredentials localstackCredentials = localstack.getDefaultCredentialsProvider().getCredentials(); + credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials + .create(localstackCredentials.getAWSAccessKeyId(), localstackCredentials.getAWSSecretKey())); + } + } + + @DynamicPropertySource + static void registerSqsProperties(DynamicPropertyRegistry registry) { + // overwrite SQS endpoint with one provided by Localstack + registry.add("spring.cloud.aws.endpoint", () -> localstack.getEndpointOverride(SQS).toString()); + } + + protected static CompletableFuture createQueue(SqsAsyncClient client, String queueName) { + return createQueue(client, queueName, Collections.emptyMap()); + } + + protected static CompletableFuture createFifoQueue(SqsAsyncClient client, String queueName) { + return createFifoQueue(client, queueName, Collections.emptyMap()); + } + + protected static CompletableFuture createFifoQueue(SqsAsyncClient client, String queueName, + Map additionalAttributes) { + Map attributes = new HashMap<>(additionalAttributes); + attributes.put(QueueAttributeName.FIFO_QUEUE, "true"); + return createQueue(client, queueName, attributes); + } + + protected static CompletableFuture createQueue(SqsAsyncClient client, String queueName, + Map attributes) { + logger.debug("Creating queue {} with attributes {}", queueName, attributes); + return client.createQueue(req -> getCreateQueueRequest(queueName, attributes, req)).handle((v, t) -> { + if (t != null) { + logger.error("Error creating queue {} with attributes {}", queueName, attributes, t); + return CompletableFutures.failedFuture(t); + } + if (purgeQueues) { + String queueUrl = v.queueUrl(); + logger.debug("Purging queue {}", queueName); + return client.purgeQueue(req -> req.queueUrl(queueUrl).build()); + } + else { + logger.debug("Skipping purge for queue {}", queueName); + return CompletableFuture.completedFuture(null); + } + }).thenCompose(x -> x).whenComplete((v, t) -> { + if (t != null) { + logger.error("Error purging queue {}", queueName, t); + return; + } + logger.debug("Done purging queue {}", queueName); + }); + } + + private static CreateQueueRequest getCreateQueueRequest(String queueName, + Map attributes, CreateQueueRequest.Builder builder) { + if (!attributes.isEmpty()) { + builder.attributes(attributes); + } + return builder.queueName(queueName).build(); + } + + protected static SqsAsyncClient createAsyncClient() { + return useLocalStackClient ? createLocalStackClient() : SqsAsyncClient.builder().build(); + } + + protected static SqsAsyncClient createHighThroughputAsyncClient() { + return useLocalStackClient ? createLocalStackClient() + : SqsAsyncClient.builder().httpClientBuilder(NettyNioAsyncHttpClient.builder().maxConcurrency(6000)) + .build(); + } + + private static SqsAsyncClient createLocalStackClient() { + return SqsAsyncClient.builder().credentialsProvider(credentialsProvider) + .endpointOverride(localstack.getEndpointOverride(SQS)).region(Region.of(localstack.getRegion())) + .build(); + } + + protected static class LoadSimulator { + + private static final Random RANDOM = new Random(); + + private int bound = 1000; + + private boolean loadEnabled = false; + + private boolean random = false; + + public void runLoad() { + runLoad(this.bound); + } + + public void runLoad(int amount) { + if (!this.loadEnabled) { + return; + } + try { + Thread.sleep(getLoadTime(amount)); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + private long getLoadTime(int amount) { + return this.random ? RANDOM.nextInt(amount) : amount; + } + + public LoadSimulator setBound(int bound) { + this.bound = bound; + return this; + } + + public LoadSimulator setLoadEnabled(boolean loadEnabled) { + this.loadEnabled = loadEnabled; + return this; + } + + public LoadSimulator setRandom(boolean random) { + this.random = random; + return this; + } + + @Override + public String toString() { + if (!this.loadEnabled) { + return "no load"; + } + StringBuilder sb = new StringBuilder(); + if (this.random) { + sb.append("random load of up to "); + } + else { + sb.append("load of "); + } + sb.append(this.bound).append("ms"); + return sb.toString(); + } + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/QueueAttributesResolverIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/QueueAttributesResolverIntegrationTests.java new file mode 100644 index 000000000..9bc7a44dc --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/QueueAttributesResolverIntegrationTests.java @@ -0,0 +1,132 @@ +/* + * 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 org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.awspring.cloud.sqs.QueueAttributesResolvingException; +import io.awspring.cloud.sqs.config.SqsBootstrapConfiguration; +import io.awspring.cloud.sqs.listener.QueueAttributes; +import io.awspring.cloud.sqs.listener.QueueAttributesResolver; +import io.awspring.cloud.sqs.listener.QueueNotFoundStrategy; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.model.CreateQueueRequest; +import software.amazon.awssdk.services.sqs.model.DeleteQueueRequest; +import software.amazon.awssdk.services.sqs.model.GetQueueUrlRequest; +import software.amazon.awssdk.services.sqs.model.QueueAttributeName; +import software.amazon.awssdk.services.sqs.model.QueueDoesNotExistException; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SpringBootTest(classes = SqsBootstrapConfiguration.class) +class QueueAttributesResolverIntegrationTests extends BaseSqsIntegrationTest { + + // @formatter:off + @Test + void shouldCreateQueue() { + String queueName = "testQueueName-" + UUID.randomUUID(); + SqsAsyncClient client = createAsyncClient(); + QueueAttributesResolver resolver = QueueAttributesResolver + .builder() + .queueAttributeNames(Collections.emptyList()) + .sqsAsyncClient(client) + .queueName(queueName) + .queueNotFoundStrategy(QueueNotFoundStrategy.CREATE) + .build(); + try { + QueueAttributes attributes = resolver.resolveQueueAttributes().join(); + assertThat(attributes.getQueueName()).isEqualTo(queueName); + assertThat(attributes.getQueueAttribute(QueueAttributeName.QUEUE_ARN)).isNull(); + } + finally { + String queueUrl = client.getQueueUrl(GetQueueUrlRequest.builder().queueName(queueName).build()).join().queueUrl(); + client.deleteQueue(DeleteQueueRequest.builder().queueUrl(queueUrl).build()).join(); + } + } + + @Test + void shouldNotCreateQueue() { + String queueName = "testQueueName-" + UUID.randomUUID(); + SqsAsyncClient client = createAsyncClient(); + QueueAttributesResolver resolver = QueueAttributesResolver + .builder() + .queueAttributeNames(Collections.emptyList()) + .sqsAsyncClient(client) + .queueName(queueName) + .queueNotFoundStrategy(QueueNotFoundStrategy.FAIL) + .build(); + assertThatThrownBy(() -> resolver.resolveQueueAttributes().join()) + .isInstanceOf(CompletionException.class) + .extracting(Throwable::getCause) + .isInstanceOf(QueueAttributesResolvingException.class) + .extracting(Throwable::getCause) + .isInstanceOf(QueueDoesNotExistException.class); + } + + @Test + void shouldGetQueueAttributes() { + String queueName = "should-get-queue-attributes"; + SqsAsyncClient client = createAsyncClient(); + QueueAttributesResolver resolver = QueueAttributesResolver + .builder() + .queueAttributeNames(Collections.singletonList(QueueAttributeName.QUEUE_ARN)) + .sqsAsyncClient(client) + .queueName(queueName) + .queueNotFoundStrategy(QueueNotFoundStrategy.CREATE) + .build(); + try { + QueueAttributes attributes = resolver.resolveQueueAttributes().join(); + assertThat(attributes.getQueueAttribute(QueueAttributeName.QUEUE_ARN)).isNotNull(); + } + finally { + String queueUrl = client.getQueueUrl(GetQueueUrlRequest.builder().queueName(queueName).build()).join().queueUrl(); + client.deleteQueue(DeleteQueueRequest.builder().queueUrl(queueUrl).build()).join(); + } + } + + @Test + void shouldResolveFromUri() { + String queueName = "should-resolve-from-uri"; + SqsAsyncClient client = createAsyncClient(); + String queueUrl = client.createQueue(CreateQueueRequest.builder().queueName(queueName).build()).join().queueUrl(); + QueueAttributesResolver resolver = QueueAttributesResolver + .builder() + .sqsAsyncClient(client) + .queueName(queueUrl) + .queueAttributeNames(Collections.emptyList()) + .queueNotFoundStrategy(QueueNotFoundStrategy.CREATE) + .build(); + try { + QueueAttributes attributes = resolver.resolveQueueAttributes().join(); + assertThat(attributes.getQueueUrl()).isEqualTo(queueUrl); + assertThat(attributes.getQueueName()).isEqualTo(queueUrl); + assertThat(attributes.getQueueAttributes()).isEmpty(); + } + finally { + client.deleteQueue(DeleteQueueRequest.builder().queueUrl(queueUrl).build()).join(); + } + } + // @formatter:on + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsFifoIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsFifoIntegrationTests.java new file mode 100644 index 000000000..0ed7a1fbe --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsFifoIntegrationTests.java @@ -0,0 +1,781 @@ +/* + * 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.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.awspring.cloud.sqs.CompletableFutures; +import io.awspring.cloud.sqs.MessageHeaderUtils; +import io.awspring.cloud.sqs.annotation.SqsListener; +import io.awspring.cloud.sqs.config.SqsBootstrapConfiguration; +import io.awspring.cloud.sqs.config.SqsMessageListenerContainerFactory; +import io.awspring.cloud.sqs.listener.ContainerOptions; +import io.awspring.cloud.sqs.listener.FifoSqsComponentFactory; +import io.awspring.cloud.sqs.listener.ListenerMode; +import io.awspring.cloud.sqs.listener.MessageListener; +import io.awspring.cloud.sqs.listener.MessageListenerContainer; +import io.awspring.cloud.sqs.listener.SqsHeaders; +import io.awspring.cloud.sqs.listener.SqsMessageListenerContainer; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementOrdering; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementHandler; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.OnSuccessAcknowledgementHandler; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +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.handler.annotation.Header; +import org.springframework.util.Assert; +import org.springframework.util.StopWatch; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.model.QueueAttributeName; +import software.amazon.awssdk.services.sqs.model.SendMessageBatchRequestEntry; +import software.amazon.awssdk.services.sqs.model.SendMessageBatchResponse; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SpringBootTest +class SqsFifoIntegrationTests extends BaseSqsIntegrationTest { + + private static final Logger logger = LoggerFactory.getLogger(SqsFifoIntegrationTests.class); + + static final String FIFO_RECEIVES_MESSAGES_IN_ORDER_QUEUE_NAME = "fifo_receives_messages_in_order.fifo"; + + static final String FIFO_RECEIVES_MESSAGE_IN_ORDER_MANY_GROUPS_QUEUE_NAME = "fifo_receives_messages_in_order_many_groups.fifo"; + + static final String FIFO_STOPS_PROCESSING_ON_ERROR_QUEUE_NAME = "fifo_stops_processing_on_error.fifo"; + + static final String FIFO_STOPS_PROCESSING_ON_ACK_ERROR_ERROR_QUEUE_NAME = "fifo_stops_processing_on_ack_error.fifo"; + + static final String FIFO_RECEIVES_BATCHES_MANY_GROUPS_QUEUE_NAME = "fifo_receives_batches_many_groups.fifo"; + + static final String FIFO_MANUALLY_CREATE_CONTAINER_QUEUE_NAME = "fifo_manually_create_container_test_queue.fifo"; + + static final String FIFO_MANUALLY_CREATE_FACTORY_QUEUE_NAME = "fifo_manually_create_factory_test_queue.fifo"; + + static final String FIFO_MANUALLY_CREATE_BATCH_CONTAINER_QUEUE_NAME = "fifo_manually_create_batch_container_test_queue.fifo"; + + static final String FIFO_MANUALLY_CREATE_BATCH_FACTORY_QUEUE_NAME = "fifo_manually_create_batch_factory_test_queue.fifo"; + + private static final String TEST_SQS_ASYNC_CLIENT_BEAN_NAME = "testSqsAsyncClient"; + + private static final String ERROR_ON_ACK_FACTORY = "errorOnAckFactory"; + + @Autowired + LatchContainer latchContainer; + + @Autowired + @Qualifier(TEST_SQS_ASYNC_CLIENT_BEAN_NAME) + SqsAsyncClient sqsAsyncClient; + + @Autowired + ObjectMapper objectMapper; + + @Autowired(required = false) + ReceivesMessageInOrderListener receivesMessageInOrderListener; + + @Autowired(required = false) + ReceivesMessageInOrderManyGroupsListener receivesMessageInOrderManyGroupsListener; + + @Autowired(required = false) + StopsOnErrorListener stopsOnErrorListener; + + @Autowired(required = false) + ReceivesBatchesFromManyGroupsListener receivesBatchesFromManyGroupsListener; + + @Autowired + LoadSimulator loadSimulator; + + @Autowired + Settings settings; + + @Autowired + MessagesContainer messagesContainer; + + @BeforeAll + static void beforeTests() { + SqsAsyncClient client = createAsyncClient(); + CompletableFuture.allOf( + createFifoQueue(client, FIFO_RECEIVES_MESSAGES_IN_ORDER_QUEUE_NAME, getVisibilityAttribute("20")), + createFifoQueue(client, FIFO_RECEIVES_MESSAGE_IN_ORDER_MANY_GROUPS_QUEUE_NAME), + createFifoQueue(client, FIFO_STOPS_PROCESSING_ON_ERROR_QUEUE_NAME, getVisibilityAttribute("2")), + createFifoQueue(client, FIFO_STOPS_PROCESSING_ON_ACK_ERROR_ERROR_QUEUE_NAME, + getVisibilityAttribute("2")), + createFifoQueue(client, FIFO_RECEIVES_BATCHES_MANY_GROUPS_QUEUE_NAME), + createFifoQueue(client, FIFO_MANUALLY_CREATE_CONTAINER_QUEUE_NAME), + createFifoQueue(client, FIFO_MANUALLY_CREATE_FACTORY_QUEUE_NAME), + createFifoQueue(client, FIFO_MANUALLY_CREATE_BATCH_CONTAINER_QUEUE_NAME), + createFifoQueue(client, FIFO_MANUALLY_CREATE_BATCH_FACTORY_QUEUE_NAME)).join(); + } + + private static Map getVisibilityAttribute(String value) { + return Collections.singletonMap(QueueAttributeName.VISIBILITY_TIMEOUT, value); + } + + private static class Settings implements SmartInitializingSingleton { + + @Autowired + LoadSimulator loadSimulator; + + public boolean receiveMessages = true; + + public boolean sendMessages = true; + + private final int messagesPerTest = 5; + + private final int messagesPerMessageGroup = 10; + + private final int latchTimeoutSeconds = messagesPerTest * 10; + + @Override + public void afterSingletonsInstantiated() { + loadSimulator.setLoadEnabled(false); + loadSimulator.setBound(1000); + loadSimulator.setRandom(true); + } + } + + @Test + void receivesMessagesInOrder() throws Exception { + latchContainer.receivesMessageLatch = new CountDownLatch(this.settings.messagesPerTest); + String messageGroupId = UUID.randomUUID().toString(); + List values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf) + .collect(toList()); + String queueUrl = fetchQueueUrl(FIFO_RECEIVES_MESSAGES_IN_ORDER_QUEUE_NAME); + sendMessageTo(queueUrl, values, messageGroupId); + assertThat(latchContainer.receivesMessageLatch.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS)).isTrue(); + assertThat(receivesMessageInOrderListener.receivedMessages).containsExactlyElementsOf(values); + } + + @Test + void receivesMessagesInOrderFromManyMessageGroups() throws Exception { + String queueUrl = fetchQueueUrl(FIFO_RECEIVES_MESSAGE_IN_ORDER_MANY_GROUPS_QUEUE_NAME); + int messagesPerTest = Math.max(this.settings.messagesPerTest, 30); + int numberOfMessageGroups = messagesPerTest / Math.max(this.settings.messagesPerMessageGroup, 10); + int messagesPerMessageGroup = Math.max(messagesPerTest / numberOfMessageGroups, 1); + latchContainer.receivesMessageManyGroupsLatch = new CountDownLatch(messagesPerTest); + latchContainer.manyGroupsAcks = new CountDownLatch(messagesPerTest); + List values = IntStream.range(0, messagesPerMessageGroup).mapToObj(String::valueOf).collect(toList()); + List messageGroups = IntStream.range(0, numberOfMessageGroups) + .mapToObj(index -> UUID.randomUUID().toString()).collect(toList()); + StopWatch watch = new StopWatch(); + watch.start(); + LoadSimulator loadSimulator = new LoadSimulator().setLoadEnabled(true).setRandom(true).setBound(20); + IntStream.range(0, messageGroups.size()).forEach(index -> { + if (this.settings.sendMessages) { + sendMessageTo(queueUrl, values, messageGroups.get(index)); + } + if (index % 10 == 0) { + loadSimulator.runLoad(); + } + }); + assertThat(latchContainer.receivesMessageManyGroupsLatch.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS)) + .isTrue(); + assertThat(latchContainer.manyGroupsAcks.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS)).isTrue(); + watch.stop(); + messageGroups.forEach(group -> { + assertThat(receivesMessageInOrderManyGroupsListener.receivedMessages.get(group)) + .containsExactlyElementsOf(values); + assertThat(messagesContainer.acknowledgesFromManyGroups.get(group)).containsExactlyElementsOf(values); + }); + double totalTimeSeconds = watch.getTotalTimeSeconds(); + logger.debug("{}s for processing {} messages in {} message groups. Messages / seconds: {}", totalTimeSeconds, + messagesPerTest, numberOfMessageGroups, messagesPerTest / totalTimeSeconds); + } + + @Test + void stopsProcessingAfterException() throws Exception { + latchContainer.stopsProcessingOnErrorLatch1 = new CountDownLatch(4); + latchContainer.stopsProcessingOnErrorLatch2 = new CountDownLatch(this.settings.messagesPerTest + 1); + List values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf) + .collect(toList()); + String messageGroupId = UUID.randomUUID().toString(); + String queueUrl = fetchQueueUrl(FIFO_STOPS_PROCESSING_ON_ERROR_QUEUE_NAME); + sendMessageTo(queueUrl, values, messageGroupId); + assertThat(latchContainer.stopsProcessingOnErrorLatch1.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS)) + .isTrue(); + logger.debug("receivedMessagesBeforeException: {}", stopsOnErrorListener.receivedMessagesBeforeException); + assertThat(stopsOnErrorListener.receivedMessagesBeforeException) + .containsExactlyElementsOf(values.stream().limit(4).collect(toList())); + assertThat(latchContainer.stopsProcessingOnErrorLatch2.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS)) + .isTrue(); + logger.debug("receivedMessagesBeforeException: {}", stopsOnErrorListener.receivedMessagesBeforeException); + logger.debug("receivedMessagesAfterException: {}", stopsOnErrorListener.receivedMessagesAfterException); + assertThat(stopsOnErrorListener.receivedMessagesBeforeException) + .containsExactlyElementsOf(values.stream().limit(4).collect(toList())); + assertThat(stopsOnErrorListener.receivedMessagesAfterException) + .containsExactlyElementsOf(values.subList(3, this.settings.messagesPerTest)); + } + + @Test + void stopsProcessingAfterAckException() throws Exception { + latchContainer.stopsProcessingOnAckErrorLatch1 = new CountDownLatch(4); + latchContainer.stopsProcessingOnAckErrorLatch2 = new CountDownLatch(this.settings.messagesPerTest - 3); + List values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf) + .collect(toList()); + String messageGroupId = UUID.randomUUID().toString(); + String queueUrl = fetchQueueUrl(FIFO_STOPS_PROCESSING_ON_ACK_ERROR_ERROR_QUEUE_NAME); + sendMessageTo(queueUrl, values, messageGroupId); + assertThat(latchContainer.stopsProcessingOnAckErrorLatch1.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS)) + .isTrue(); + logger.debug("Messages consumed before error: {}", messagesContainer.stopsProcessingOnAckErrorBeforeThrown); + assertThat(messagesContainer.stopsProcessingOnAckErrorBeforeThrown) + .containsExactlyElementsOf(values.stream().limit(4).collect(toList())); + assertThat(latchContainer.stopsProcessingOnAckErrorLatch2.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS)) + .isTrue(); + logger.debug("Messages consumed before error second latch: {}", + messagesContainer.stopsProcessingOnAckErrorBeforeThrown); + assertThat(messagesContainer.stopsProcessingOnAckErrorBeforeThrown) + .containsExactlyElementsOf(values.stream().limit(4).collect(toList())); + logger.debug("Messages consumed after error: {}", messagesContainer.stopsProcessingOnAckErrorAfterThrown); + assertThat(messagesContainer.stopsProcessingOnAckErrorAfterThrown) + .containsExactlyElementsOf(values.subList(3, this.settings.messagesPerTest)); + } + + @Test + void receivesBatchesManyGroups() throws Exception { + latchContainer.receivesBatchManyGroupsLatch = new CountDownLatch(this.settings.messagesPerTest * 3); + List values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf) + .collect(toList()); + String messageGroupId1 = UUID.randomUUID().toString(); + String messageGroupId2 = UUID.randomUUID().toString(); + String messageGroupId3 = UUID.randomUUID().toString(); + String queueUrl = fetchQueueUrl(FIFO_RECEIVES_BATCHES_MANY_GROUPS_QUEUE_NAME); + sendMessageTo(queueUrl, values, messageGroupId1); + sendMessageTo(queueUrl, values, messageGroupId2); + sendMessageTo(queueUrl, values, messageGroupId3); + assertThat(latchContainer.receivesBatchManyGroupsLatch.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS)) + .isTrue(); + assertThat(receivesBatchesFromManyGroupsListener.receivedMessages.get(messageGroupId1)) + .containsExactlyElementsOf(values); + assertThat(receivesBatchesFromManyGroupsListener.receivedMessages.get(messageGroupId2)) + .containsExactlyElementsOf(values); + assertThat(receivesBatchesFromManyGroupsListener.receivedMessages.get(messageGroupId3)) + .containsExactlyElementsOf(values); + } + + @Test + void manuallyCreatesContainer() throws Exception { + String queueUrl = fetchQueueUrl(FIFO_MANUALLY_CREATE_CONTAINER_QUEUE_NAME); + List values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf) + .collect(toList()); + sendMessageTo(queueUrl, values, UUID.randomUUID().toString()); + assertThat(latchContainer.manuallyCreatedContainerLatch.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS)) + .isTrue(); + assertThat(messagesContainer.manuallyCreatedContainerMessages).containsExactlyElementsOf(values); + } + + @Test + void manuallyCreatesBatchContainer() throws Exception { + String queueUrl = fetchQueueUrl(FIFO_MANUALLY_CREATE_BATCH_CONTAINER_QUEUE_NAME); + List values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf) + .collect(toList()); + sendMessageTo(queueUrl, values, UUID.randomUUID().toString()); + assertThat( + latchContainer.manuallyCreatedBatchContainerLatch.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS)) + .isTrue(); + assertThat(messagesContainer.manuallyCreatedBatchContainerMessages).containsExactlyElementsOf(values); + } + + @Test + void manuallyCreatesFactory() throws Exception { + String queueUrl = fetchQueueUrl(FIFO_MANUALLY_CREATE_FACTORY_QUEUE_NAME); + List values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf) + .collect(toList()); + sendMessageTo(queueUrl, values, UUID.randomUUID().toString()); + assertThat(latchContainer.manuallyCreatedFactoryLatch.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS)) + .isTrue(); + assertThat(messagesContainer.manuallyCreatedFactoryMessages).containsExactlyElementsOf(values); + } + + @Test + void manuallyCreatesBatchFactory() throws Exception { + String queueUrl = fetchQueueUrl(FIFO_MANUALLY_CREATE_BATCH_FACTORY_QUEUE_NAME); + List values = IntStream.range(0, this.settings.messagesPerTest).mapToObj(String::valueOf) + .collect(toList()); + sendMessageTo(queueUrl, values, UUID.randomUUID().toString()); + assertThat( + latchContainer.manuallyCreatedBatchFactoryLatch.await(settings.latchTimeoutSeconds, TimeUnit.SECONDS)) + .isTrue(); + assertThat(messagesContainer.manuallyCreatedBatchFactoryMessages).containsExactlyElementsOf(values); + } + + static class ReceivesMessageInOrderListener { + + List receivedMessages = Collections.synchronizedList(new ArrayList<>()); + + @Autowired + LatchContainer latchContainer; + + @Autowired + LoadSimulator loadSimulator; + + @SqsListener(queueNames = FIFO_RECEIVES_MESSAGES_IN_ORDER_QUEUE_NAME) + void listen(Message message) { + logger.debug("Received message with id {} and payload {} from ReceivesMessageInOrderListener", + MessageHeaderUtils.getId(message), message.getPayload()); + loadSimulator.runLoad(); + receivedMessages.add(message.getPayload()); + latchContainer.receivesMessageLatch.countDown(); + } + } + + static class ReceivesMessageInOrderManyGroupsListener { + + private final AtomicInteger totalMessagesReceived = new AtomicInteger(); + + private final Map> receivedMessages = new ConcurrentHashMap<>(); + + @Autowired + LatchContainer latchContainer; + + @Autowired + LoadSimulator loadSimulator; + + @SqsListener(queueNames = FIFO_RECEIVES_MESSAGE_IN_ORDER_MANY_GROUPS_QUEUE_NAME, id = "receives-in-order-many-groups") + void listen(Message message, + @Header(SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER) String groupId) { + logger.trace("Received message {} in listener method from groupId {}", message.getPayload(), groupId); + loadSimulator.runLoad(); + List messageList = receivedMessages.computeIfAbsent(groupId, newGroupId -> new ArrayList<>()); + if (messageList.contains(message.getPayload())) { + logger.warn("Message {} with id {} already present for group id {}", message.getPayload(), + MessageHeaderUtils.getId(message), groupId); + } + messageList.add(message.getPayload()); + latchContainer.receivesMessageManyGroupsLatch.countDown(); + int received = totalMessagesReceived.incrementAndGet(); + if (received % 1000 == 0) { + logger.debug("{} messages received from {} message groups", received, receivedMessages.size()); + } + // logger.debug("Message {} processed.", message); + } + } + + static class StopsOnErrorListener { + + List receivedMessagesBeforeException = Collections.synchronizedList(new ArrayList<>()); + + List receivedMessagesAfterException = Collections.synchronizedList(new ArrayList<>()); + + AtomicBoolean hasThrown = new AtomicBoolean(false); + + @Autowired + LatchContainer latchContainer; + + @Autowired + LoadSimulator loadSimulator; + + @SqsListener(queueNames = FIFO_STOPS_PROCESSING_ON_ERROR_QUEUE_NAME, messageVisibilitySeconds = "3", id = "stops-processing-on-error") + void listen(String message) { + logger.debug("Received message in listener method: " + message); + loadSimulator.runLoad(500); + if (!hasThrown.get()) { + this.receivedMessagesBeforeException.add(message); + } + else { + this.receivedMessagesAfterException.add(message); + } + latchContainer.stopsProcessingOnErrorLatch1.countDown(); + latchContainer.stopsProcessingOnErrorLatch2.countDown(); + if ("3".equals(message) && this.hasThrown.compareAndSet(false, true)) { + throw new RuntimeException("Expected exception from stops-processing-on-error"); + } + } + } + + static class StopsOnAckErrorListener { + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = FIFO_STOPS_PROCESSING_ON_ACK_ERROR_ERROR_QUEUE_NAME, factory = ERROR_ON_ACK_FACTORY, messageVisibilitySeconds = "2", id = "stops-on-ack-error") + void listen(String message) { + logger.debug("Received message in listener method: " + message); + } + } + + static class ReceivesBatchesFromManyGroupsListener { + + Map> receivedMessages = new ConcurrentHashMap<>(); + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = FIFO_RECEIVES_BATCHES_MANY_GROUPS_QUEUE_NAME, messageVisibilitySeconds = "20") + void listen(List> messages) { + String firstMessage = messages.iterator().next().getPayload();// Make sure we got the right type + Assert.isTrue(MessageHeaderUtils + .getHeader(messages, SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER, String.class) + .stream().distinct().count() == 1, "More than one message group returned in the same batch"); + String messageGroupId = messages.iterator().next().getHeaders() + .get(SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER, String.class); + List values = messages.stream().map(Message::getPayload).collect(toList()); + logger.trace("Started processing messages {} for group id {}", values, messageGroupId); + receivedMessages.computeIfAbsent(messageGroupId, groupId -> Collections.synchronizedList(new ArrayList<>())) + .addAll(values); + messages.forEach(msg -> latchContainer.receivesBatchManyGroupsLatch.countDown()); + logger.trace("Finished processing messages {} for group id {}", values, messageGroupId); + } + } + + private void sendMessageTo(String queueUrl, List messageBodies, String messageGroupId) { + try { + if (useLocalStackClient) { + sendManyTo(queueUrl, messageBodies, messageGroupId).join(); + } + else { + sendManyTo(queueUrl, messageBodies, messageGroupId); + } + } + catch (Exception e) { + logger.error("Error sending messages to queue {}", queueUrl, e); + throw (RuntimeException) e; + } + } + + private CompletableFuture sendManyTo(String queueUrl, List messageBodies, String messageGroupId) { + return IntStream.range(0, (int) Math.ceil(messageBodies.size() / 10.)) + .mapToObj(index -> messageBodies.subList(index * 10, Math.min((index + 1) * 10, messageBodies.size()))) + .reduce(CompletableFuture.completedFuture(null), (previousFuture, messages) -> previousFuture + .thenCompose(theVoid -> doSendMessageTo(queueUrl, messages, messageGroupId).thenRun(() -> { + })), (a, b) -> a); + } + + AtomicInteger messagesSent = new AtomicInteger(); + + private CompletableFuture doSendMessageTo(String queueUrl, List messageBodies, + String messageGroupId) { + return sqsAsyncClient.sendMessageBatch(req -> req + .entries(messageBodies.stream().map(body -> createEntry(body, messageGroupId)).collect(toList())) + .queueUrl(queueUrl).build()).whenComplete((v, t) -> { + if (t != null) { + logger.error("Error sending messages", t); + } + else { + int sent = messagesSent.addAndGet(messageBodies.size()); + if (sent % 1000 == 0) { + logger.debug("Sent {} messages to queue {}", sent, queueUrl); + } + } + }); + } + + private SendMessageBatchRequestEntry createEntry(String body, String messageGroupId) { + return SendMessageBatchRequestEntry.builder().messageBody(body).id(UUID.randomUUID().toString()) + .messageGroupId(messageGroupId).messageDeduplicationId(UUID.randomUUID().toString()).build(); + } + + private String fetchQueueUrl(String receivesMessageQueueName) throws InterruptedException, ExecutionException { + return this.sqsAsyncClient.getQueueUrl(req -> req.queueName(receivesMessageQueueName)).get().queueUrl(); + } + + static class LatchContainer { + + @Autowired + Settings settings; + + final CountDownLatch manuallyCreatedContainerLatch = new CountDownLatch(this.settings.messagesPerTest); + final CountDownLatch manuallyCreatedFactoryLatch = new CountDownLatch(this.settings.messagesPerTest); + final CountDownLatch manuallyCreatedBatchContainerLatch = new CountDownLatch(this.settings.messagesPerTest); + final CountDownLatch manuallyCreatedBatchFactoryLatch = new CountDownLatch(this.settings.messagesPerTest); + + // Lazily initialized + CountDownLatch receivesMessageLatch = new CountDownLatch(1); + CountDownLatch receivesMessageManyGroupsLatch = new CountDownLatch(1); + CountDownLatch manyGroupsAcks = new CountDownLatch(1); + CountDownLatch stopsProcessingOnErrorLatch1 = new CountDownLatch(3); + CountDownLatch stopsProcessingOnErrorLatch2 = new CountDownLatch(1); + CountDownLatch stopsProcessingOnAckErrorLatch1 = new CountDownLatch(1); + CountDownLatch stopsProcessingOnAckErrorLatch2 = new CountDownLatch(1); + CountDownLatch receivesBatchManyGroupsLatch = new CountDownLatch(1); + + } + + static class MessagesContainer { + + Map> acknowledgesFromManyGroups = Collections.synchronizedMap(new HashMap<>()); + List manuallyCreatedContainerMessages = Collections.synchronizedList(new ArrayList<>()); + List manuallyCreatedBatchContainerMessages = Collections.synchronizedList(new ArrayList<>()); + List manuallyCreatedFactoryMessages = Collections.synchronizedList(new ArrayList<>()); + List manuallyCreatedBatchFactoryMessages = Collections.synchronizedList(new ArrayList<>()); + List stopsProcessingOnAckErrorBeforeThrown = Collections.synchronizedList(new ArrayList<>()); + List stopsProcessingOnAckErrorAfterThrown = Collections.synchronizedList(new ArrayList<>()); + + } + + @Import(SqsBootstrapConfiguration.class) + @Configuration + static class SQSConfiguration { + + MessagesContainer messagesContainer = new MessagesContainer(); + + @Bean + public MessagesContainer messagesContainer() { + return this.messagesContainer; + } + + // @formatter:off + @Bean + public SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory() { + SqsMessageListenerContainerFactory factory = new SqsMessageListenerContainerFactory<>(); + factory.configure(options -> options + .maxInflightMessagesPerQueue(10) + .acknowledgementThreshold(10) + .acknowledgementOrdering(AcknowledgementOrdering.ORDERED_BY_GROUP) + .acknowledgementInterval(Duration.ofSeconds(1))); + factory.setSqsAsyncClientSupplier(BaseSqsIntegrationTest::createHighThroughputAsyncClient); + factory.setAcknowledgementResultCallback(new AcknowledgementResultCallback() { + + final AtomicInteger ackedMessages = new AtomicInteger(); + + @Override + public void onSuccess(Collection> messages) { + if (FIFO_RECEIVES_MESSAGE_IN_ORDER_MANY_GROUPS_QUEUE_NAME.equals(MessageHeaderUtils.getHeaderAsString(messages.iterator().next(), SqsHeaders.SQS_QUEUE_NAME_HEADER))) { + messages.stream() + .collect(groupingBy(msg -> MessageHeaderUtils.getHeaderAsString(msg, SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER))) + .forEach((key, value) -> messagesContainer.acknowledgesFromManyGroups.computeIfAbsent(key, + newGroup -> Collections.synchronizedList(new ArrayList<>())).addAll(value.stream().map(Message::getPayload).collect(toList()))); + messages.forEach(msg -> { + int acked = ackedMessages.incrementAndGet(); + if (acked % 1000 == 0) { + logger.debug("Acknowledged {} messages", acked); + } + latchContainer.manyGroupsAcks.countDown(); + }); + } + } + }); + return factory; + } + + @Bean(ERROR_ON_ACK_FACTORY) + public SqsMessageListenerContainerFactory errorOnAckSqsListenerContainerFactory() { + SqsMessageListenerContainerFactory factory = new SqsMessageListenerContainerFactory<>(); + factory.configure(options -> options + .maxInflightMessagesPerQueue(10) + .acknowledgementThreshold(10) + .acknowledgementInterval(Duration.ofMillis(200)) + .permitAcquireTimeout(Duration.ofSeconds(1)) + .pollTimeout(Duration.ofSeconds(3))); + factory.setSqsAsyncClientSupplier(BaseSqsIntegrationTest::createAsyncClient); + factory.setContainerComponentFactories(Collections.singletonList(new FifoSqsComponentFactory() { + @Override + public AcknowledgementHandler createAcknowledgementHandler(ContainerOptions options) { + return new OnSuccessAcknowledgementHandler() { + + final AtomicBoolean hasThrown = new AtomicBoolean(false); + + @Override + public CompletableFuture onSuccess(Message message, + AcknowledgementCallback callback) { + if (message.getPayload().equals("3") && hasThrown.compareAndSet(false, true)) { + messagesContainer.stopsProcessingOnAckErrorBeforeThrown.add(message.getPayload()); + latchContainer.stopsProcessingOnAckErrorLatch1.countDown(); + return CompletableFutures.failedFuture(new RuntimeException("Expected acking error")); + } + return super.onSuccess(message, callback).whenComplete((v, t) -> handleResult(message)); + } + + private void handleResult(Message message) { + if (!hasThrown.get()) { + messagesContainer.stopsProcessingOnAckErrorBeforeThrown.add(message.getPayload()); + latchContainer.stopsProcessingOnAckErrorLatch1.countDown(); + } + else { + messagesContainer.stopsProcessingOnAckErrorAfterThrown.add(message.getPayload()); + latchContainer.stopsProcessingOnAckErrorLatch2.countDown(); + } + } + }; + } + })); + return factory; + } + + @Bean + public MessageListenerContainer manuallyCreatedContainer() { + SqsMessageListenerContainer container = new SqsMessageListenerContainer<>(createAsyncClient()); + container.configure(options -> options + .permitAcquireTimeout(Duration.ofSeconds(1)) + .pollTimeout(Duration.ofSeconds(1))); + container.setQueueNames(FIFO_MANUALLY_CREATE_CONTAINER_QUEUE_NAME); + container.setId("fifo-manually-created-container"); + container.setMessageListener(msg -> { + messagesContainer.manuallyCreatedContainerMessages.add(msg.getPayload()); + latchContainer.manuallyCreatedContainerLatch.countDown(); + }); + return container; + } + + @Bean + public MessageListenerContainer manuallyCreatedBatchContainer() { + SqsMessageListenerContainer container = new SqsMessageListenerContainer<>(createAsyncClient()); + container.configure(options -> options + .permitAcquireTimeout(Duration.ofSeconds(1)) + .pollTimeout(Duration.ofSeconds(1)) + .listenerMode(ListenerMode.BATCH)); + container.setQueueNames(FIFO_MANUALLY_CREATE_BATCH_CONTAINER_QUEUE_NAME); + container.setMessageListener(new MessageListener() { + @Override + public void onMessage(Message message) { + throw new UnsupportedOperationException(); + } + + @Override + public void onMessage(Collection> messages) { + messagesContainer.manuallyCreatedBatchContainerMessages + .addAll(messages.stream().map(Message::getPayload).collect(toList())); + messages.forEach(msg -> latchContainer.manuallyCreatedBatchContainerLatch.countDown()); + } + }); + return container; + } + + @Bean + public SqsMessageListenerContainer manuallyCreatedFactory() { + SqsMessageListenerContainerFactory factory = new SqsMessageListenerContainerFactory<>(); + factory.configure(options -> + options.maxInflightMessagesPerQueue(10) + .pollTimeout(Duration.ofSeconds(1)) + .maxMessagesPerPoll(10) + .permitAcquireTimeout(Duration.ofSeconds(1))); + factory.setSqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()); + factory.setMessageListener(msg -> { + logger.debug("Processed message {}", msg.getPayload()); + messagesContainer.manuallyCreatedFactoryMessages.add(msg.getPayload()); + latchContainer.manuallyCreatedFactoryLatch.countDown(); + }); + return factory.createContainer(FIFO_MANUALLY_CREATE_FACTORY_QUEUE_NAME); + } + + @Bean + public MessageListenerContainer manuallyCreatedBatchFactory() { + SqsMessageListenerContainerFactory factory = new SqsMessageListenerContainerFactory<>(); + factory.configure(options -> options + .maxInflightMessagesPerQueue(10) + .pollTimeout(Duration.ofSeconds(1)) + .maxMessagesPerPoll(10) + .permitAcquireTimeout(Duration.ofSeconds(1)) + .listenerMode(ListenerMode.BATCH)); + factory.setSqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()); + factory.setMessageListener(new MessageListener() { + @Override + public void onMessage(Message message) { + throw new UnsupportedOperationException(); + } + + @Override + public void onMessage(Collection> messages) { + messagesContainer.manuallyCreatedBatchFactoryMessages + .addAll(messages.stream().map(Message::getPayload).collect(toList())); + messages.forEach(msg -> latchContainer.manuallyCreatedBatchFactoryLatch.countDown()); + } + }); + return factory.createContainer(FIFO_MANUALLY_CREATE_BATCH_FACTORY_QUEUE_NAME); + } + // @formatter:on + + @Bean + ReceivesMessageInOrderListener receivesMessageInOrderListener() { + return new ReceivesMessageInOrderListener(); + } + + @Bean + ReceivesMessageInOrderManyGroupsListener receivesMessageInOrderManyGroupsListener() { + if (settings.receiveMessages) { + return new ReceivesMessageInOrderManyGroupsListener(); + } + return null; + } + + @Bean + StopsOnErrorListener stopsOnErrorListener() { + return new StopsOnErrorListener(); + } + + @Bean + StopsOnAckErrorListener stopsOnAckErrorListener() { + return new StopsOnAckErrorListener(); + } + + @Bean + ReceivesBatchesFromManyGroupsListener receiveBatchesFromManyGroupsListener() { + return new ReceivesBatchesFromManyGroupsListener(); + } + + LatchContainer latchContainer = new LatchContainer(); + + @Bean + LatchContainer latchContainer() { + return this.latchContainer; + } + + @Bean + LoadSimulator loadSimulator() { + return new LoadSimulator(); + } + + Settings settings = new Settings(); + + @Bean + Settings settings() { + return settings; + } + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + @Bean(name = TEST_SQS_ASYNC_CLIENT_BEAN_NAME) + SqsAsyncClient sqsAsyncClientProducer() { + return BaseSqsIntegrationTest.createHighThroughputAsyncClient(); + } + + } + +} 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 new file mode 100644 index 000000000..2ae70f1d5 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsIntegrationTests.java @@ -0,0 +1,717 @@ +/* + * 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 com.fasterxml.jackson.databind.ObjectMapper; +import io.awspring.cloud.sqs.CompletableFutures; +import io.awspring.cloud.sqs.MessageHeaderUtils; +import io.awspring.cloud.sqs.annotation.SqsListener; +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.ContainerComponentFactory; +import io.awspring.cloud.sqs.listener.ContainerOptions; +import io.awspring.cloud.sqs.listener.MessageListenerContainer; +import io.awspring.cloud.sqs.listener.QueueAttributes; +import io.awspring.cloud.sqs.listener.SqsHeaders; +import io.awspring.cloud.sqs.listener.SqsMessageListenerContainer; +import io.awspring.cloud.sqs.listener.StandardSqsComponentFactory; +import io.awspring.cloud.sqs.listener.Visibility; +import io.awspring.cloud.sqs.listener.acknowledgement.Acknowledgement; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementExecutor; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.BatchAcknowledgement; +import io.awspring.cloud.sqs.listener.acknowledgement.SqsAcknowledgementExecutor; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementMode; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import io.awspring.cloud.sqs.listener.sink.MessageSink; +import io.awspring.cloud.sqs.listener.source.MessageSource; +import io.awspring.cloud.sqs.listener.source.SqsMessageSource; +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.ArrayList; +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.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +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.MessageHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.support.DefaultMessageHandlerMethodFactory; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; +import org.springframework.test.context.TestPropertySource; +import org.springframework.util.Assert; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.model.DeleteMessageBatchRequestEntry; +import software.amazon.awssdk.services.sqs.model.QueueAttributeName; +import software.amazon.awssdk.services.sqs.model.SendMessageBatchRequest; +import software.amazon.awssdk.services.sqs.model.SendMessageBatchRequestEntry; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SpringBootTest +@TestPropertySource(properties = { "property.one=1", "property.five.seconds=5s", + "receives.message.queue.name=" + SqsIntegrationTests.RECEIVES_MESSAGE_QUEUE_NAME, + "low.resource.factory.name=" + SqsIntegrationTests.LOW_RESOURCE_FACTORY }) +class SqsIntegrationTests extends BaseSqsIntegrationTest { + + private static final Logger logger = LoggerFactory.getLogger(SqsIntegrationTests.class); + + private static final String TEST_SQS_ASYNC_CLIENT_BEAN_NAME = "testSqsAsyncClient"; + + 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_FACTORY_QUEUE_NAME = "manually_create_factory_test_queue"; + + static final String LOW_RESOURCE_FACTORY = "lowResourceFactory"; + + static final String MANUAL_ACK_FACTORY = "manualAcknowledgementFactory"; + + 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_FACTORY_QUEUE_NAME)).join(); + } + + @Autowired + LatchContainer latchContainer; + + @Autowired + @Qualifier(TEST_SQS_ASYNC_CLIENT_BEAN_NAME) + SqsAsyncClient sqsAsyncClient; + + @Autowired + ObjectMapper objectMapper; + + @Test + void receivesMessage() throws Exception { + sendMessageTo(RECEIVES_MESSAGE_QUEUE_NAME, "receivesMessage-payload"); + assertThat(latchContainer.receivesMessageLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(latchContainer.invocableHandlerMethodLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(latchContainer.acknowledgementCallbackSuccessLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void receivesMessageBatch() throws Exception { + sendMessageTo(RECEIVES_MESSAGE_BATCH_QUEUE_NAME, "receivesMessageBatch-payload"); + assertThat(latchContainer.receivesMessageBatchLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(latchContainer.acknowledgementCallbackBatchLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void receivesMessageAsync() throws Exception { + sendMessageTo(RECEIVES_MESSAGE_ASYNC_QUEUE_NAME, "receivesMessageAsync-payload"); + assertThat(latchContainer.receivesMessageAsyncLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void doesNotAckOnError() throws Exception { + sendMessageTo(DOES_NOT_ACK_ON_ERROR_QUEUE_NAME, "doesNotAckOnError-payload"); + assertThat(latchContainer.doesNotAckLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(latchContainer.acknowledgementCallbackErrorLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void doesNotAckOnErrorAsync() throws Exception { + sendMessageTo(DOES_NOT_ACK_ON_ERROR_ASYNC_QUEUE_NAME, "doesNotAckOnErrorAsync-payload"); + assertThat(latchContainer.doesNotAckAsyncLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void doesNotAckOnErrorBatch() throws Exception { + List values = IntStream.range(0, 10).mapToObj(index -> "doesNotAckOnErrorBatch-payload-" + index) + .collect(Collectors.toList()); + sendMessageBatch(DOES_NOT_ACK_ON_ERROR_BATCH_QUEUE_NAME, values); + assertThat(latchContainer.doesNotAckBatchLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void doesNotAckOnErrorBatchAsync() throws Exception { + List values = IntStream.range(0, 10).mapToObj(index -> "doesNotAckOnErrorBatchAsync-payload-" + index) + .collect(Collectors.toList()); + sendMessageBatch(DOES_NOT_ACK_ON_ERROR_BATCH_ASYNC_QUEUE_NAME, values); + assertThat(latchContainer.doesNotAckBatchAsyncLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void resolvesManyParameterTypes() throws Exception { + sendMessageTo(RESOLVES_PARAMETER_TYPES_QUEUE_NAME, "many-parameter-types-payload"); + assertThat(latchContainer.manyParameterTypesLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(latchContainer.manyParameterTypesSecondLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void manuallyCreatesContainer() throws Exception { + sendMessageTo(MANUALLY_CREATE_CONTAINER_QUEUE_NAME, "Testing manually creates container"); + assertThat(latchContainer.manuallyCreatedContainerLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + // @formatter:off + @Test + void manuallyStartsContainerAndChangesComponent() throws Exception { + SqsMessageListenerContainer container = SqsMessageListenerContainer + .builder() + .sqsAsyncClient(createAsyncClient()) + .queueNames(MANUALLY_START_CONTAINER) + .messageListener(msg -> latchContainer.manuallyStartedContainerLatch.countDown()) + .configure(options -> options + .permitAcquireTimeout(Duration.ofSeconds(1)) + .pollTimeout(Duration.ofSeconds(3))) + .build(); + container.start(); + sendMessageTo(MANUALLY_START_CONTAINER, "MyTest"); + assertThat(latchContainer.manuallyStartedContainerLatch.await(10, TimeUnit.SECONDS)).isTrue(); + container.stop(); + container.setMessageListener(msg -> latchContainer.manuallyStartedContainerLatch2.countDown()); + ContainerOptions.Builder builder = container.getContainerOptions().toBuilder(); + builder.acknowledgementMode(AcknowledgementMode.ALWAYS); + container.configure(options -> options.fromBuilder(builder)); + container.start(); + sendMessageTo(MANUALLY_START_CONTAINER, "MyTest2"); + assertThat(latchContainer.manuallyStartedContainerLatch2.await(10, TimeUnit.SECONDS)).isTrue(); + container.stop(); + } + // @formatter:on + + @Test + void manuallyCreatesFactory() throws Exception { + sendMessageTo(MANUALLY_CREATE_FACTORY_QUEUE_NAME, "Testing manually creates factory"); + assertThat(latchContainer.manuallyCreatedFactoryLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(latchContainer.manuallyCreatedFactorySourceFactoryLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(latchContainer.manuallyCreatedFactorySinkLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + private void sendMessageTo(String queueName, String messageBody) { + String queueUrl = fetchQueueUrl(queueName); + sqsAsyncClient.sendMessage(req -> req.messageBody(messageBody).queueUrl(queueUrl).build()).join(); + logger.debug("Sent message to queue {} with messageBody {}", queueName, messageBody); + } + + private void sendMessageBatch(String queueName, Collection messageBodies) { + String queueUrl = fetchQueueUrl(queueName); + sqsAsyncClient.sendMessageBatch(SendMessageBatchRequest.builder().queueUrl(queueUrl) + .entries(messageBodies.stream() + .map(payload -> SendMessageBatchRequestEntry.builder().messageBody(payload) + .id(UUID.randomUUID().toString()).build()) + .collect(Collectors.toList())) + .build()).join(); + logger.debug("Sent messages to queue {} with messageBodies {}", queueName, messageBodies); + } + + private String fetchQueueUrl(String receivesMessageQueueName) { + return sqsAsyncClient.getQueueUrl(req -> req.queueName(receivesMessageQueueName)).join().queueUrl(); + } + + static class ReceivesMessageListener { + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = "${receives.message.queue.name}", pollTimeoutSeconds = "${property.one}", maxMessagesPerPoll = "${property.one}", maxInflightMessagesPerQueue = "${missing.property:5}", id = "receivesMessageContainer") + void listen(String message) { + logger.debug("Received message in Listener Method: " + message); + latchContainer.receivesMessageLatch.countDown(); + } + } + + static class ReceivesMessageBatchListener { + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = RECEIVES_MESSAGE_BATCH_QUEUE_NAME, factory = MANUAL_ACK_FACTORY, id = "receivesMessageBatchListener") + CompletableFuture listen(List messages, BatchAcknowledgement acknowledgement) { + logger.debug("Received messages in listener: " + messages); + latchContainer.receivesMessageBatchLatch.countDown(); + return acknowledgement.acknowledgeAsync(); + } + } + + static class ReceivesMessageAsyncListener { + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = RECEIVES_MESSAGE_ASYNC_QUEUE_NAME, factory = "${low.resource.factory.name}", id = "receivesMessageAsyncContainer") + CompletableFuture listen(String message) { + logger.debug("Received message in Listener Method: " + message); + latchContainer.receivesMessageAsyncLatch.countDown(); + return CompletableFuture.completedFuture(null); + } + } + + static class DoesNotAckOnErrorListener { + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = DOES_NOT_ACK_ON_ERROR_QUEUE_NAME, factory = ACK_AFTER_SECOND_ERROR_FACTORY, id = "does-not-ack") + void listen(String message, @Header(SqsHeaders.SQS_QUEUE_NAME_HEADER) String queueName) { + logger.debug("Received message {} from queue {}", message, queueName); + latchContainer.doesNotAckLatch.countDown(); + throw new RuntimeException("Expected exception from does-not-ack"); + } + } + + static class DoesNotAckOnErrorAsyncListener { + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = DOES_NOT_ACK_ON_ERROR_ASYNC_QUEUE_NAME, messageVisibilitySeconds = "#{1}", factory = ACK_AFTER_SECOND_ERROR_FACTORY, id = "does-not-ack-async") + CompletableFuture listen(String message, @Header(SqsHeaders.SQS_QUEUE_NAME_HEADER) String queueName) { + logger.debug("Received message {} from queue {}", message, queueName); + latchContainer.doesNotAckAsyncLatch.countDown(); + return CompletableFutures.failedFuture(new RuntimeException("Expected exception from does-not-ack-async")); + } + } + + static class DoesNotAckOnErrorBatchListener { + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = DOES_NOT_ACK_ON_ERROR_BATCH_QUEUE_NAME, messageVisibilitySeconds = "2", factory = ACK_AFTER_SECOND_ERROR_FACTORY, id = "does-not-ack-batch") + void listen(List> messages) { + logger.debug("Received messages {} from queue {}", MessageHeaderUtils.getId(messages), + messages.get(0).getHeaders().get(SqsHeaders.SQS_QUEUE_NAME_HEADER)); + messages.forEach(msg -> latchContainer.doesNotAckBatchLatch.countDown()); + throw new RuntimeException("Expected exception from does-not-ack-batch"); + } + } + + static class DoesNotAckOnErrorAsyncBatchListener { + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = DOES_NOT_ACK_ON_ERROR_BATCH_ASYNC_QUEUE_NAME, factory = ACK_AFTER_SECOND_ERROR_FACTORY, id = "does-not-ack-batch-async") + CompletableFuture listen(List> messages) { + logger.debug("Received messages {} from queue {}", MessageHeaderUtils.getId(messages), + messages.get(0).getHeaders().get(SqsHeaders.SQS_QUEUE_NAME_HEADER)); + messages.forEach(msg -> latchContainer.doesNotAckBatchAsyncLatch.countDown()); + return CompletableFutures + .failedFuture(new RuntimeException("Expected exception from does-not-ack-batch-async")); + } + } + + static class ResolvesParameterTypesListener { + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = RESOLVES_PARAMETER_TYPES_QUEUE_NAME, factory = MANUAL_ACK_FACTORY, id = "resolves-parameter") + void listen(Message message, MessageHeaders headers, Acknowledgement ack, Visibility visibility, + QueueAttributes queueAttributes, software.amazon.awssdk.services.sqs.model.Message originalMessage) + throws Exception { + Assert.notNull(headers, "Received null MessageHeaders"); + Assert.notNull(ack, "Received null Acknowledgement"); + Assert.notNull(visibility, "Received null Visibility"); + Assert.notNull(queueAttributes, "Received null QueueAttributes"); + Assert.notNull(originalMessage, "Received null software.amazon.awssdk.services.sqs.model.Message"); + Assert.notNull(message, "Received null message"); + logger.debug("Received message in Listener Method: " + message); + Assert.notNull(queueAttributes.getQueueAttribute(QueueAttributeName.QUEUE_ARN), + "QueueArn attribute not found"); + + visibility.changeTo(1); + + // Verify VisibilityTimeout extension + latchContainer.manyParameterTypesLatch.countDown(); + if (latchContainer.manyParameterTypesSecondLatch.getCount() == 1) { + ack.acknowledge(); + } + latchContainer.manyParameterTypesSecondLatch.countDown(); + Thread.sleep(1000); + } + } + + static class LatchContainer { + + final CountDownLatch receivesMessageLatch = new CountDownLatch(1); + final CountDownLatch receivesMessageBatchLatch = new CountDownLatch(1); + final CountDownLatch receivesMessageAsyncLatch = new CountDownLatch(1); + final CountDownLatch doesNotAckLatch = new CountDownLatch(2); + final CountDownLatch doesNotAckAsyncLatch = new CountDownLatch(2); + final CountDownLatch doesNotAckBatchLatch = new CountDownLatch(20); + final CountDownLatch doesNotAckBatchAsyncLatch = new CountDownLatch(20); + final CountDownLatch interceptorLatch = new CountDownLatch(2); + final CountDownLatch manyParameterTypesLatch = new CountDownLatch(1); + final CountDownLatch manyParameterTypesSecondLatch = new CountDownLatch(2); + final CountDownLatch manuallyCreatedContainerLatch = new CountDownLatch(1); + final CountDownLatch manuallyStartedContainerLatch = new CountDownLatch(1); + final CountDownLatch manuallyStartedContainerLatch2 = new CountDownLatch(1); + final CountDownLatch manuallyCreatedFactorySourceFactoryLatch = new CountDownLatch(1); + final CountDownLatch manuallyCreatedFactorySinkLatch = new CountDownLatch(1); + final CountDownLatch manuallyCreatedFactoryLatch = new CountDownLatch(1); + final CountDownLatch invocableHandlerMethodLatch = new CountDownLatch(1); + final CountDownLatch acknowledgementCallbackSuccessLatch = new CountDownLatch(1); + final CountDownLatch acknowledgementCallbackBatchLatch = new CountDownLatch(1); + final CountDownLatch acknowledgementCallbackErrorLatch = new CountDownLatch(1); + + } + + @Import(SqsBootstrapConfiguration.class) + @Configuration + static class SQSConfiguration { + + // @formatter:off + @Bean + public SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory() { + return SqsMessageListenerContainerFactory + .builder() + .sqsAsyncClientSupplier(BaseSqsIntegrationTest::createAsyncClient) + .acknowledgementResultCallback(getAcknowledgementResultCallback()) + .configure(options -> options + .permitAcquireTimeout(Duration.ofSeconds(5)) + .queueAttributeNames(Collections.singletonList(QueueAttributeName.QUEUE_ARN)) + .pollTimeout(Duration.ofSeconds(5))) + .build(); + } + + @Bean(name = LOW_RESOURCE_FACTORY) + public SqsMessageListenerContainerFactory lowResourceFactory() { + return SqsMessageListenerContainerFactory + .builder() + .configure(options -> options + .maxInflightMessagesPerQueue(1) + .pollTimeout(Duration.ofSeconds(5)) + .maxMessagesPerPoll(1) + .permitAcquireTimeout(Duration.ofSeconds(5))) + .messageInterceptor(testInterceptor()) + .messageInterceptor(testInterceptor()) + .errorHandler(testErrorHandler()) + .sqsAsyncClientSupplier(BaseSqsIntegrationTest::createAsyncClient) + .build(); + } + + @Bean(name = ACK_AFTER_SECOND_ERROR_FACTORY) + public SqsMessageListenerContainerFactory ackAfterSecondErrorFactory() { + return SqsMessageListenerContainerFactory + .builder() + .configure(options -> options + .maxInflightMessagesPerQueue(10) + .pollTimeout(Duration.ofSeconds(10)) + .maxMessagesPerPoll(10) + .permitAcquireTimeout(Duration.ofSeconds(1))) + .messageInterceptor(testInterceptor()) + .messageInterceptor(testInterceptor()) + .containerComponentFactories(getExceptionThrowingAckExecutor()) + .acknowledgementResultCallback(getAcknowledgementResultCallback()) + .errorHandler(testErrorHandler()) + .sqsAsyncClientSupplier(BaseSqsIntegrationTest::createAsyncClient) + .build(); + } + + private List> getExceptionThrowingAckExecutor() { + return Collections.singletonList(new StandardSqsComponentFactory() { + @Override + public MessageSource createMessageSource(ContainerOptions options) { + return new SqsMessageSource() { + @Override + protected AcknowledgementExecutor createAcknowledgementExecutorInstance() { + return new SqsAcknowledgementExecutor() { + + final AtomicBoolean hasThrown = new AtomicBoolean(); + + @Override + public CompletableFuture execute(Collection> messagesToAck) { + if (MessageHeaderUtils + .getHeaderAsString(messagesToAck.iterator().next(), SqsHeaders.SQS_QUEUE_NAME_HEADER).equals(DOES_NOT_ACK_ON_ERROR_QUEUE_NAME) + && hasThrown.compareAndSet(false, true)) { + return CompletableFutures.failedFuture(new RuntimeException("Expected acknowledgement exception for " + DOES_NOT_ACK_ON_ERROR_QUEUE_NAME)); + } + return super.execute(messagesToAck); + } + }; + } + }; + } + }); + } + + @Bean(name = MANUAL_ACK_FACTORY) + public SqsMessageListenerContainerFactory manualAcknowledgementFactory() { + return SqsMessageListenerContainerFactory + .builder() + .configure(options -> options + .acknowledgementMode(AcknowledgementMode.MANUAL) + .maxInflightMessagesPerQueue(1) + .pollTimeout(Duration.ofSeconds(3)) + .maxMessagesPerPoll(1) + .queueAttributeNames(Collections.singletonList(QueueAttributeName.QUEUE_ARN)) + .permitAcquireTimeout(Duration.ofSeconds(1))) + .sqsAsyncClientSupplier(BaseSqsIntegrationTest::createAsyncClient) + .acknowledgementResultCallback(new AcknowledgementResultCallback() { + @Override + public void onSuccess(Collection> messages) { + if (RECEIVES_MESSAGE_BATCH_QUEUE_NAME.equals(MessageHeaderUtils.getHeaderAsString(messages.iterator().next(), + SqsHeaders.SQS_QUEUE_NAME_HEADER))) { + latchContainer.acknowledgementCallbackBatchLatch.countDown(); + } + } + }) + .build(); + } + + @Bean + public MessageListenerContainer manuallyCreatedContainer(SqsAsyncClient client) throws Exception { + String queueUrl = client.getQueueUrl(req -> req.queueName(MANUALLY_CREATE_CONTAINER_QUEUE_NAME)).get() + .queueUrl(); + return SqsMessageListenerContainer + .builder() + .queueNames(queueUrl) + .sqsAsyncClient(client) + .configure(options -> options + .permitAcquireTimeout(Duration.ofSeconds(1)) + .pollTimeout(Duration.ofSeconds(3))) + .messageListener(msg -> latchContainer.manuallyCreatedContainerLatch.countDown()) + .build(); + } + + @Bean + public SqsMessageListenerContainer manuallyCreatedFactory() { + SqsMessageListenerContainerFactory factory = new SqsMessageListenerContainerFactory<>(); + factory.configure(options -> options + .maxInflightMessagesPerQueue(1) + .pollTimeout(Duration.ofSeconds(3)) + .maxMessagesPerPoll(1) + .permitAcquireTimeout(Duration.ofSeconds(1))); + factory.setContainerComponentFactories(Collections.singletonList(new StandardSqsComponentFactory() { + @Override + public MessageSource createMessageSource(ContainerOptions options) { + latchContainer.manuallyCreatedFactorySourceFactoryLatch.countDown(); + return super.createMessageSource(options); + } + + @Override + public MessageSink createMessageSink(ContainerOptions options) { + latchContainer.manuallyCreatedFactorySinkLatch.countDown(); + return super.createMessageSink(options); + } + })); + factory.setSqsAsyncClient(BaseSqsIntegrationTest.createAsyncClient()); + factory.setMessageListener(msg -> latchContainer.manuallyCreatedFactoryLatch.countDown()); + return factory.createContainer(MANUALLY_CREATE_FACTORY_QUEUE_NAME); + } + // @formatter:on + + LatchContainer latchContainer = new LatchContainer(); + + @Bean + ReceivesMessageListener receivesMessageListener() { + return new ReceivesMessageListener(); + } + + @Bean + ReceivesMessageBatchListener receivesBatchMessageListener() { + return new ReceivesMessageBatchListener(); + } + + @Bean + ReceivesMessageAsyncListener receivesMessageAsyncListener() { + return new ReceivesMessageAsyncListener(); + } + + @Bean + DoesNotAckOnErrorListener doesNotAckOnErrorListener() { + return new DoesNotAckOnErrorListener(); + } + + @Bean + DoesNotAckOnErrorAsyncListener doesNotAckOnErrorAsyncListener() { + return new DoesNotAckOnErrorAsyncListener(); + } + + @Bean + DoesNotAckOnErrorBatchListener doesNotAckOnErrorBatchListener() { + return new DoesNotAckOnErrorBatchListener(); + } + + @Bean + DoesNotAckOnErrorAsyncBatchListener doesNotAckOnErrorAsyncBatchListener() { + return new DoesNotAckOnErrorAsyncBatchListener(); + } + + @Bean + ResolvesParameterTypesListener resolvesParameterTypesListener() { + return new ResolvesParameterTypesListener(); + } + + @Bean + SqsListenerConfigurer customizer() { + return registrar -> { + registrar.setMessageHandlerMethodFactory(new DefaultMessageHandlerMethodFactory() { + @Override + public InvocableHandlerMethod createInvocableHandlerMethod(Object bean, Method method) { + latchContainer.invocableHandlerMethodLatch.countDown(); + return super.createInvocableHandlerMethod(bean, method); + } + }); + }; + } + + @Bean + LatchContainer latchContainer() { + return this.latchContainer; + } + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + @Bean(name = TEST_SQS_ASYNC_CLIENT_BEAN_NAME) + SqsAsyncClient sqsAsyncClientProducer() { + return BaseSqsIntegrationTest.createHighThroughputAsyncClient(); + } + + private AsyncMessageInterceptor testInterceptor() { + return new AsyncMessageInterceptor() { + @Override + public CompletableFuture> intercept(Message message) { + latchContainer.interceptorLatch.countDown(); + return CompletableFuture.completedFuture(message); + } + }; + } + + private AsyncErrorHandler testErrorHandler() { + return new AsyncErrorHandler() { + + final List previousMessages = Collections.synchronizedList(new ArrayList<>()); + + @Override + public CompletableFuture handle(Message message, Throwable t) { + // Eventually ack to not interfere with other tests. + if (previousMessages.contains(message.getPayload())) { + return CompletableFuture.completedFuture(null); + } + previousMessages.add(message.getPayload()); + return CompletableFutures.failedFuture(t); + } + + @Override + public CompletableFuture handle(Collection> messages, Throwable t) { + // Eventually ack to not interfere with other tests. + if (previousMessages.containsAll(toPayloadList(messages))) { + return CompletableFuture.completedFuture(null); + } + previousMessages.addAll(toPayloadList(messages)); + return CompletableFutures.failedFuture(t); + } + + private List toPayloadList(Collection> messages) { + return messages.stream().map(Message::getPayload).collect(Collectors.toList()); + } + + private Collection getBatchEntries( + Collection> messages) { + return messages.stream().map(this::getBatchEntry).collect(Collectors.toList()); + } + + private DeleteMessageBatchRequestEntry getBatchEntry(Message message) { + return DeleteMessageBatchRequestEntry.builder().id(UUID.randomUUID().toString()) + .receiptHandle( + MessageHeaderUtils.getHeaderAsString(message, SqsHeaders.SQS_RECEIPT_HANDLE_HEADER)) + .build(); + } + }; + } + + private AcknowledgementResultCallback getAcknowledgementResultCallback() { + return new AcknowledgementResultCallback() { + @Override + public void onSuccess(Collection> messages) { + logger.debug("Invoking on success acknowledgement result callback for {}", + MessageHeaderUtils.getId(messages)); + if (RECEIVES_MESSAGE_QUEUE_NAME.equals(MessageHeaderUtils + .getHeaderAsString(messages.iterator().next(), SqsHeaders.SQS_QUEUE_NAME_HEADER))) { + latchContainer.acknowledgementCallbackSuccessLatch.countDown(); + } + } + + @Override + public void onFailure(Collection> messages, Throwable t) { + logger.debug("Invoking on failure acknowledgement result callback for {}", + MessageHeaderUtils.getId(messages)); + if (DOES_NOT_ACK_ON_ERROR_QUEUE_NAME.equals(MessageHeaderUtils + .getHeaderAsString(messages.iterator().next(), SqsHeaders.SQS_QUEUE_NAME_HEADER))) { + latchContainer.acknowledgementCallbackErrorLatch.countDown(); + } + } + }; + } + + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsInterceptorIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsInterceptorIntegrationTests.java new file mode 100644 index 000000000..9661d1f80 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsInterceptorIntegrationTests.java @@ -0,0 +1,270 @@ +/* + * 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 org.assertj.core.api.Assertions.assertThat; + +import io.awspring.cloud.sqs.CompletableFutures; +import io.awspring.cloud.sqs.MessageHeaderUtils; +import io.awspring.cloud.sqs.annotation.SqsListener; +import io.awspring.cloud.sqs.config.SqsBootstrapConfiguration; +import io.awspring.cloud.sqs.config.SqsMessageListenerContainerFactory; +import io.awspring.cloud.sqs.listener.SqsHeaders; +import io.awspring.cloud.sqs.listener.StandardSqsComponentFactory; +import io.awspring.cloud.sqs.listener.acknowledgement.BatchingAcknowledgementProcessor; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementMode; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import io.awspring.cloud.sqs.support.converter.MessagingMessageHeaders; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +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.handler.annotation.Header; +import org.springframework.messaging.support.MessageBuilder; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.model.QueueAttributeName; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SpringBootTest +class SqsInterceptorIntegrationTests extends BaseSqsIntegrationTest { + + private static final Logger logger = LoggerFactory.getLogger(SqsInterceptorIntegrationTests.class); + + private static final String TEST_SQS_ASYNC_CLIENT_BEAN_NAME = "testSqsAsyncClient"; + + static final String RECEIVES_CHANGED_MESSAGE_ON_COMPONENTS_QUEUE_NAME = "receives_changed_message_on_components_test_queue"; + + static final String RECEIVES_CHANGED_MESSAGE_ON_ERROR_QUEUE_NAME = "receives_changed_message_on_error_test_queue"; + + static final String SHOULD_CHANGE_PAYLOAD = "should-change-payload"; + + private static final String CHANGED_PAYLOAD = "Changed payload"; + + private static final UUID CHANGED_ID = UUID.fromString("0009f2c8-1dc4-e211-cc99-9f9c62b5e66b"); + + @BeforeAll + static void beforeTests() { + SqsAsyncClient client = createAsyncClient(); + CompletableFuture.allOf(createQueue(client, RECEIVES_CHANGED_MESSAGE_ON_COMPONENTS_QUEUE_NAME), + createQueue(client, RECEIVES_CHANGED_MESSAGE_ON_ERROR_QUEUE_NAME)).join(); + } + + @Autowired + LatchContainer latchContainer; + + @Autowired + @Qualifier(TEST_SQS_ASYNC_CLIENT_BEAN_NAME) + SqsAsyncClient sqsAsyncClient; + + @Autowired(required = false) + ReceivesChangedPayloadListener receivesChangedPayloadListener; + + @Test + void shouldReceiveChangedMessageOnComponents() throws Exception { + sendMessageTo(RECEIVES_CHANGED_MESSAGE_ON_COMPONENTS_QUEUE_NAME, SHOULD_CHANGE_PAYLOAD); + assertThat(latchContainer.receivesChangedMessageLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(receivesChangedPayloadListener.receivedMessages).containsExactly(CHANGED_PAYLOAD); + } + + @Test + void shouldReceiveChangedMessageOnComponentsWhenError() throws Exception { + sendMessageTo(RECEIVES_CHANGED_MESSAGE_ON_ERROR_QUEUE_NAME, SHOULD_CHANGE_PAYLOAD); + assertThat(latchContainer.receivesChangedMessageOnErrorLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + private void sendMessageTo(String queueName, String messageBody) throws InterruptedException, ExecutionException { + String queueUrl = fetchQueueUrl(queueName); + sqsAsyncClient.sendMessage(req -> req.messageBody(messageBody).queueUrl(queueUrl).build()).get(); + logger.debug("Sent message to queue {} with messageBody {}", queueName, messageBody); + } + + private String fetchQueueUrl(String receivesMessageQueueName) throws InterruptedException, ExecutionException { + return sqsAsyncClient.getQueueUrl(req -> req.queueName(receivesMessageQueueName)).get().queueUrl(); + } + + static class ReceivesChangedPayloadListener { + + @Autowired + LatchContainer latchContainer; + + Collection receivedMessages = Collections.synchronizedList(new ArrayList<>()); + + @SqsListener(queueNames = RECEIVES_CHANGED_MESSAGE_ON_COMPONENTS_QUEUE_NAME, id = "receives-changed-payload-on-success") + void listen(Message message, @Header(SqsHeaders.SQS_QUEUE_NAME_HEADER) String queueName) { + logger.debug("Received message {} with id {} from queue {}", message.getPayload(), + MessageHeaderUtils.getId(message), queueName); + if (isChangedPayload(message)) { + receivedMessages.add(message.getPayload()); + latchContainer.receivesChangedMessageLatch.countDown(); + } + } + } + + static class ReceivesChangedPayloadOnErrorListener { + + @Autowired + LatchContainer latchContainer; + + Collection receivedMessages = Collections.synchronizedList(new ArrayList<>()); + + @SqsListener(queueNames = RECEIVES_CHANGED_MESSAGE_ON_ERROR_QUEUE_NAME, id = "receives-changed-payload-on-error") + void listen(Message message, @Header(SqsHeaders.SQS_QUEUE_NAME_HEADER) String queueName) { + logger.debug("Received message {} with id {} from queue {}", message.getPayload(), + MessageHeaderUtils.getId(message), queueName); + if (isChangedPayload(message)) { + receivedMessages.add(message.getPayload()); + latchContainer.receivesChangedMessageOnErrorLatch.countDown(); + } + throw new RuntimeException("Expected exception from receives-changed-payload-on-error"); + } + } + + static class LatchContainer { + + final CountDownLatch receivesChangedMessageLatch = new CountDownLatch(3); + + final CountDownLatch receivesChangedMessageOnErrorLatch = new CountDownLatch(3); + + } + + @Import(SqsBootstrapConfiguration.class) + @Configuration + static class SQSConfiguration { + + // @formatter:off + @Bean + public SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory() { + SqsMessageListenerContainerFactory factory = new SqsMessageListenerContainerFactory<>(); + factory.configure(options -> options + .permitAcquireTimeout(Duration.ofSeconds(1)) + .queueAttributeNames(Collections.singletonList(QueueAttributeName.QUEUE_ARN)) + .acknowledgementMode(AcknowledgementMode.ALWAYS) + .pollTimeout(Duration.ofSeconds(3))); + factory.setSqsAsyncClientSupplier(BaseSqsIntegrationTest::createAsyncClient); + factory.addMessageInterceptor(getMessageInterceptor()); + factory.setErrorHandler(getErrorHandler()); + factory.setContainerComponentFactories(Collections.singletonList(getContainerComponentFactory())); + return factory; + } + // @formatter:on + + @Bean + ReceivesChangedPayloadListener receivesChangedPayloadListener() { + return new ReceivesChangedPayloadListener(); + } + + @Bean + ReceivesChangedPayloadOnErrorListener receivesChangedPayloadOnErrorListener() { + return new ReceivesChangedPayloadOnErrorListener(); + } + + LatchContainer latchContainer = new LatchContainer(); + + @Bean + LatchContainer latchContainer() { + return this.latchContainer; + } + + @Bean(name = TEST_SQS_ASYNC_CLIENT_BEAN_NAME) + SqsAsyncClient sqsAsyncClientProducer() { + return BaseSqsIntegrationTest.createAsyncClient(); + } + + private AsyncMessageInterceptor getMessageInterceptor() { + return new AsyncMessageInterceptor() { + @Override + public CompletableFuture> intercept(Message message) { + logger.debug("Received message in interceptor: {}", MessageHeaderUtils.getId(message)); + if (message.getPayload().equals(SHOULD_CHANGE_PAYLOAD)) { + MessagingMessageHeaders headers = new MessagingMessageHeaders(message.getHeaders(), CHANGED_ID); + return CompletableFuture + .completedFuture(MessageBuilder.createMessage(CHANGED_PAYLOAD, headers)); + } + return CompletableFuture.completedFuture(message); + } + + @Override + public CompletableFuture afterProcessing(Message message, Throwable t) { + logger.debug("Received message in afterProcessing: {}", MessageHeaderUtils.getId(message)); + if (isChangedPayload(message)) { + latchContainer.receivesChangedMessageLatch.countDown(); + latchContainer.receivesChangedMessageOnErrorLatch.countDown(); + } + return CompletableFuture.completedFuture(null); + } + }; + } + + private AsyncErrorHandler getErrorHandler() { + return new AsyncErrorHandler() { + @Override + public CompletableFuture handle(Message message, Throwable t) { + logger.debug("Received message in error handler: {}", MessageHeaderUtils.getId(message)); + if (isChangedPayload(message)) { + latchContainer.receivesChangedMessageOnErrorLatch.countDown(); + } + return CompletableFutures.failedFuture(t); + } + }; + } + + private StandardSqsComponentFactory getContainerComponentFactory() { + return new StandardSqsComponentFactory() { + @Override + protected BatchingAcknowledgementProcessor createBatchingProcessorInstance() { + return new BatchingAcknowledgementProcessor() { + @Override + protected CompletableFuture sendToExecutor(Collection> messagesToAck) { + return super.sendToExecutor(messagesToAck).whenComplete((v, t) -> { + if (messagesToAck.stream().allMatch(SqsInterceptorIntegrationTests::isChangedPayload)) { + latchContainer.receivesChangedMessageLatch.countDown(); + latchContainer.receivesChangedMessageOnErrorLatch.countDown(); + } + }); + } + }; + } + }; + } + + } + + private static boolean isChangedPayload(Message message) { + return message != null && message.getPayload().equals(CHANGED_PAYLOAD) + && MessageHeaderUtils.getId(message).equals(CHANGED_ID.toString()); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsLoadIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsLoadIntegrationTests.java new file mode 100644 index 000000000..79cadca27 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsLoadIntegrationTests.java @@ -0,0 +1,488 @@ +/* + * 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 com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.awspring.cloud.sqs.MessageHeaderUtils; +import io.awspring.cloud.sqs.annotation.SqsListener; +import io.awspring.cloud.sqs.config.SqsBootstrapConfiguration; +import io.awspring.cloud.sqs.config.SqsMessageListenerContainerFactory; +import io.awspring.cloud.sqs.listener.BackPressureMode; +import io.awspring.cloud.sqs.listener.ContainerOptions; +import io.awspring.cloud.sqs.listener.MessageListenerContainerRegistry; +import io.awspring.cloud.sqs.listener.SqsHeaders; +import io.awspring.cloud.sqs.listener.StandardSqsComponentFactory; +import io.awspring.cloud.sqs.listener.acknowledgement.SqsAcknowledgementExecutor; +import io.awspring.cloud.sqs.listener.source.MessageSource; +import io.awspring.cloud.sqs.listener.source.SqsMessageSource; +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +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.util.Assert; +import org.springframework.util.StopWatch; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.model.QueueAttributeName; +import software.amazon.awssdk.services.sqs.model.SendMessageBatchRequestEntry; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SpringBootTest +class SqsLoadIntegrationTests extends BaseSqsIntegrationTest { + + private static final Logger logger = LoggerFactory.getLogger(SqsLoadIntegrationTests.class); + + private static final String RECEIVE_FROM_MANY_1_QUEUE_NAME = "receive_many_test_queue_1"; + + private static final String RECEIVE_FROM_MANY_2_QUEUE_NAME = "receive_many_test_queue_2"; + + private static final String RECEIVE_BATCH_1_QUEUE_NAME = "receive_batch_test_queue_1"; + + private static final String RECEIVE_BATCH_2_QUEUE_NAME = "receive_batch_test_queue_2"; + + private static final String TEST_SQS_ASYNC_CLIENT_BEAN_NAME = "testSqsAsyncClient"; + + private static final String HIGH_THROUGHPUT_FACTORY_NAME = "highThroughputFactory"; + + @Autowired + LatchContainer latchContainer; + + @Autowired + @Qualifier(TEST_SQS_ASYNC_CLIENT_BEAN_NAME) + SqsAsyncClient sqsAsyncClient; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + Settings settings; + + @Autowired + LoadSimulator loadSimulator; + + @Autowired + MessageContainer messageContainer; + + static class Settings implements SmartInitializingSingleton { + + @Autowired + LoadSimulator loadSimulator; + + final int totalMessages = 20; + final boolean sendMessages = true; + final boolean receiveMessages = true; + final boolean receivesManyTestEnabled = true; + final boolean receivesBatchesTestEnabled = true; + + final int maxInflight = 10; + final int messagesPerPoll = 10; + + final int latchAwaitSeconds = 100; + + @Override + public void afterSingletonsInstantiated() { + loadSimulator.setLoadEnabled(false); + loadSimulator.setBound(1000); + loadSimulator.setRandom(false); + } + } + + @BeforeAll + static void beforeTests() { + SqsAsyncClient client = createAsyncClient(); + CompletableFuture.allOf(createQueue(client, RECEIVE_FROM_MANY_1_QUEUE_NAME), + createQueue(client, RECEIVE_FROM_MANY_2_QUEUE_NAME), + createQueue(client, RECEIVE_BATCH_1_QUEUE_NAME, + singletonMap(QueueAttributeName.VISIBILITY_TIMEOUT, "10")), + createQueue(client, RECEIVE_BATCH_2_QUEUE_NAME, + singletonMap(QueueAttributeName.VISIBILITY_TIMEOUT, "10"))) + .join(); + } + + @Test + void receivesManyFromTwoQueuesWithLoad() throws Exception { + latchContainer.singleMessageListenerLatch = new CountDownLatch(this.settings.totalMessages); + latchContainer.acknowledgementLatch = new CountDownLatch(this.settings.totalMessages); + testWithLoad(RECEIVE_FROM_MANY_1_QUEUE_NAME, RECEIVE_FROM_MANY_2_QUEUE_NAME, + messageContainer.receivedByListener, latchContainer.singleMessageListenerLatch, + latchContainer.acknowledgementLatch); + } + + @Test + void receivesBatchesFromTwoQueuesWithLoad() throws Exception { + latchContainer.batchListenerLatch = new CountDownLatch(this.settings.totalMessages); + latchContainer.batchAcknowledgementLatch = new CountDownLatch(this.settings.totalMessages); + testWithLoad(RECEIVE_BATCH_1_QUEUE_NAME, RECEIVE_BATCH_2_QUEUE_NAME, messageContainer.receivedByBatchListener, + latchContainer.batchListenerLatch, latchContainer.batchAcknowledgementLatch); + } + + @Autowired + MessageListenerContainerRegistry registry; + + private void testWithLoad(String queue1, String queue2, Collection receivedCollection, + CountDownLatch listenerLatch, CountDownLatch acknowledgementLatch) + throws InterruptedException, ExecutionException { + Assert.isTrue(settings.totalMessages >= 20, "Minimum of 20 messages"); + String queueUrl1 = fetchQueueUrl(queue1); + String queueUrl2 = fetchQueueUrl(queue2); + LoadSimulator sendLoadSimulator = new LoadSimulator(); + sendLoadSimulator.setLoadEnabled(settings.totalMessages > 1000); + logger.debug("Starting watch"); + StopWatch watch = new StopWatch(); + watch.start(); + IntStream.range(0, Math.max(settings.totalMessages / 20, 1)).forEach(index -> { + sendMessageBatchAsync(queueUrl1); + sendMessageBatchAsync(queueUrl2); + if (index % 20 == 0) { + sendLoadSimulator.runLoad(50); + } + }); + assertThat(listenerLatch.await(settings.latchAwaitSeconds, TimeUnit.SECONDS)).isTrue(); + logger.debug("Received all {} messages", settings.totalMessages); + logger.debug("Waiting for {} acks.", settings.totalMessages); + assertThat(acknowledgementLatch.await(settings.latchAwaitSeconds, TimeUnit.SECONDS)).isTrue(); + logger.debug("Acked all {} messages", settings.totalMessages); + logger.debug("Messages received by listener: {}", receivedCollection.size()); + logger.debug("messageContainer.successfullyAcked: {}", messageContainer.successfullyAcked.size()); + logger.debug("messageContainer.errorAcking: {}", messageContainer.errorAcking.size()); + HashSet acked = new HashSet<>(messageContainer.successfullyAcked); + acked.addAll(messageContainer.errorAcking); + assertThat(acked.containsAll(receivedCollection)).isTrue(); + if (!messageContainer.errorAcking.isEmpty()) { + logger.warn("Some messages got an error acking: {}", messageContainer.errorAcking); + } + watch.stop(); + double totalTimeSeconds = watch.getTotalTimeSeconds(); + logger.info("{} seconds for {}consuming {} messages with {}. Messages / second: {}", totalTimeSeconds, + this.settings.sendMessages ? "sending and " : "", settings.totalMessages, loadSimulator, + settings.totalMessages / totalTimeSeconds); + } + + AtomicInteger sentMessages = new AtomicInteger(); + + AtomicInteger bodyInteger = new AtomicInteger(); + + private void sendMessageBatchAsync(String queueUrl) { + if (!settings.sendMessages) { + return; + } + Collection batchEntries = getBatchEntries(); + doSendMessageBatch(queueUrl, batchEntries); + } + + private void doSendMessageBatch(String queueUrl, Collection batchEntries) { + sqsAsyncClient.sendMessageBatch(req -> req.entries(batchEntries).queueUrl(queueUrl).build()) + .thenRun(this::logSend).exceptionally(t -> { + logger.error("Error sending messages - retrying", t); + doSendMessageBatch(queueUrl, batchEntries); + return null; + }); + } + + private void logSend() { + int sent = sentMessages.addAndGet(10); + if (sent % 1000 == 0) { + logger.debug("Sent {} messages", sent); + } + } + + private Collection getBatchEntries() { + return IntStream.range(0, Math.min(settings.totalMessages / 2, 10)).mapToObj(index -> { + String id = UUID.randomUUID().toString(); + logger.trace("Sending message with id {}", id); + return SendMessageBatchRequestEntry.builder().id(id).messageBody(getBody()).build(); + }).collect(Collectors.toList()); + } + + private String getBody() { + try { + return this.objectMapper.writeValueAsString( + new MyPojo("MyPojo - " + bodyInteger.incrementAndGet(), "MyPojo - secondValue")); + } + catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private String fetchQueueUrl(String receivesMessageQueueName) throws InterruptedException, ExecutionException { + return sqsAsyncClient.getQueueUrl(req -> req.queueName(receivesMessageQueueName)).get().queueUrl(); + } + + static class MessageContainer { + + Collection receivedByListener = Collections.synchronizedSet(new HashSet<>()); + + Collection receivedByBatchListener = Collections.synchronizedSet(new HashSet<>()); + + Collection successfullyAcked = Collections.synchronizedSet(new HashSet<>()); + + Collection errorAcking = Collections.synchronizedSet(new HashSet<>()); + + } + + static class ReceiveManyFromTwoQueuesListener { + + @Autowired + MessageContainer messageContainer; + + @Autowired + LatchContainer latchContainer; + + AtomicInteger messagesReceived = new AtomicInteger(); + + @Autowired + LoadSimulator loadSimulator; + + @SqsListener(queueNames = { RECEIVE_FROM_MANY_1_QUEUE_NAME, + RECEIVE_FROM_MANY_2_QUEUE_NAME }, factory = HIGH_THROUGHPUT_FACTORY_NAME, id = "many-from-two-queues") + void listen(Message message) throws Exception { + logger.trace("Started processing {}", MessageHeaderUtils.getId(message)); + if (this.messageContainer.receivedByListener.contains(MessageHeaderUtils.getId(message))) { + logger.warn("Received duplicated message: {}", message); + } + loadSimulator.runLoad(); + this.messageContainer.receivedByListener.add(MessageHeaderUtils.getId(message)); + int count; + if ((count = messagesReceived.incrementAndGet()) % 1000 == 0) { + logger.debug("Listener processed {} messages", count); + } + logger.trace("Finished processing {}", MessageHeaderUtils.getId(message)); + latchContainer.singleMessageListenerLatch.countDown(); + } + } + + static class ReceiveBatchesFromTwoQueuesListener { + + @Autowired + LatchContainer latchContainer; + + AtomicInteger messagesReceived = new AtomicInteger(); + + AtomicInteger batchesReceived = new AtomicInteger(); + + @Autowired + LoadSimulator loadSimulator; + + @Autowired + MessageContainer messageContainer; + + @SqsListener(queueNames = { RECEIVE_BATCH_1_QUEUE_NAME, + RECEIVE_BATCH_2_QUEUE_NAME }, maxMessagesPerPoll = "20", maxInflightMessagesPerQueue = "20", factory = HIGH_THROUGHPUT_FACTORY_NAME, id = "batch-from-two-queues") + void listen(List> messages) { + logger.trace("Started processing {} messages {}", messages.size(), MessageHeaderUtils.getId(messages)); + String firstField = messages.get(0).getPayload().firstField;// Make sure we got the right type + loadSimulator.runLoad(); + int messagesCount = this.messagesReceived.addAndGet(messages.size()); + this.messageContainer.receivedByBatchListener + .addAll(messages.stream().map(MessageHeaderUtils::getId).collect(Collectors.toList())); + int batches; + if ((batches = batchesReceived.incrementAndGet()) % 5 == 0) { + logger.debug("Listener processed {} batches and {} messages", batches, messagesCount); + } + logger.trace("Finished processing {} messages", messages.size()); + messages.forEach(msg -> latchContainer.batchListenerLatch.countDown()); + } + } + + static class LatchContainer { + + public CountDownLatch singleMessageListenerLatch; + + public CountDownLatch acknowledgementLatch; + + public CountDownLatch batchListenerLatch; + + public CountDownLatch batchAcknowledgementLatch; + + } + + @Import(SqsBootstrapConfiguration.class) + @Configuration + static class SQSConfiguration { + + // @formatter:off + @Bean + public SqsMessageListenerContainerFactory highThroughputFactory() { + // For load tests, set maxInflightMessagesPerQueue to a higher value - e.g. 600 + SqsMessageListenerContainerFactory factory = new SqsMessageListenerContainerFactory<>(); + factory.configure(options -> + options.maxInflightMessagesPerQueue(settings.maxInflight) + .pollTimeout(Duration.ofSeconds(3)) + .maxMessagesPerPoll(settings.messagesPerPoll) + .permitAcquireTimeout(Duration.ofSeconds(1)) + .acknowledgementInterval(Duration.ofMillis(500)) + .backPressureMode(BackPressureMode.FIXED_HIGH_THROUGHPUT) + .shutdownTimeout(Duration.ofSeconds(40))); + factory.setSqsAsyncClientSupplier(BaseSqsIntegrationTest::createHighThroughputAsyncClient); + factory.setContainerComponentFactories(Collections.singletonList(getTestAckHandlerComponentFactory())); + return factory; + } + // @formatter:on + + LatchContainer latchContainer = new LatchContainer(); + + Settings settings = new Settings(); + + @Bean + Settings settings() { + return this.settings; + } + + @Bean + ReceiveManyFromTwoQueuesListener receiveManyFromTwoQueuesListener() { + return settings.receiveMessages && settings.receivesManyTestEnabled ? new ReceiveManyFromTwoQueuesListener() + : null; + } + + @Bean + ReceiveBatchesFromTwoQueuesListener receiveBatchesFromTwoQueuesListener() { + return settings.receiveMessages && settings.receivesBatchesTestEnabled + ? new ReceiveBatchesFromTwoQueuesListener() + : null; + } + + MessageContainer messageContainer = new MessageContainer(); + + @Bean + MessageContainer messageContainer() { + return messageContainer; + } + + @Bean + LatchContainer latchContainer() { + return this.latchContainer; + } + + @Bean + LoadSimulator sleeper() { + return new LoadSimulator(); + } + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + @Bean(name = TEST_SQS_ASYNC_CLIENT_BEAN_NAME) + SqsAsyncClient sqsAsyncClientProducer() { + return BaseSqsIntegrationTest.createHighThroughputAsyncClient(); + } + + private final AtomicInteger acks = new AtomicInteger(); + + private StandardSqsComponentFactory getTestAckHandlerComponentFactory() { + return new StandardSqsComponentFactory() { + @Override + public MessageSource createMessageSource(ContainerOptions options) { + return new SqsMessageSource() { + @Override + protected SqsAcknowledgementExecutor createAcknowledgementExecutorInstance() { + return new SqsAcknowledgementExecutor() { + @Override + public CompletableFuture execute(Collection> messagesToAck) { + return super.execute(messagesToAck).whenComplete((v, t) -> { + if (t != null) { + logger.error("Error acknowledging messages", t); + messageContainer.errorAcking.addAll(messagesToAck.stream() + .map(MessageHeaderUtils::getId).collect(Collectors.toList())); + return; + } + messageContainer.successfullyAcked.addAll(messagesToAck.stream() + .map(MessageHeaderUtils::getId).collect(Collectors.toList())); + messagesToAck.forEach(msg -> { + int acked = acks.incrementAndGet(); + if (acked % 1000 == 0) { + logger.debug("Acked {} messages", acked); + } + String queueName = MessageHeaderUtils.getHeaderAsString( + messagesToAck.iterator().next(), SqsHeaders.SQS_QUEUE_NAME_HEADER); + if (RECEIVE_FROM_MANY_1_QUEUE_NAME.equals(queueName) + || RECEIVE_FROM_MANY_2_QUEUE_NAME.equals(queueName)) { + latchContainer.acknowledgementLatch.countDown(); + } + else { + latchContainer.batchAcknowledgementLatch.countDown(); + } + }); + }); + } + }; + } + }; + } + }; + } + } + + static class MyPojo { + + String firstField; + String secondField; + + MyPojo(String firstField, String secondField) { + this.firstField = firstField; + this.secondField = secondField; + } + + MyPojo() { + } + + public String getFirstField() { + return firstField; + } + + public void setFirstField(String firstField) { + this.firstField = firstField; + } + + public String getSecondField() { + return secondField; + } + + public void setSecondField(String secondField) { + this.secondField = secondField; + } + + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsMessageConversionIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsMessageConversionIntegrationTests.java new file mode 100644 index 000000000..b37f87143 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/SqsMessageConversionIntegrationTests.java @@ -0,0 +1,397 @@ +/* + * 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 org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.awspring.cloud.sqs.annotation.SqsListener; +import io.awspring.cloud.sqs.config.SqsBootstrapConfiguration; +import io.awspring.cloud.sqs.config.SqsMessageListenerContainerFactory; +import io.awspring.cloud.sqs.listener.SqsHeaders; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +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.handler.annotation.Header; +import org.springframework.util.Assert; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.model.MessageAttributeValue; +import software.amazon.awssdk.services.sqs.model.QueueAttributeName; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SpringBootTest +class SqsMessageConversionIntegrationTests extends BaseSqsIntegrationTest { + + private static final Logger logger = LoggerFactory.getLogger(SqsMessageConversionIntegrationTests.class); + + static final String RESOLVES_POJO_TYPES_QUEUE_NAME = "resolves_pojo_test_queue"; + static final String RESOLVES_POJO_MESSAGE_QUEUE_NAME = "resolves_pojo_message_test_queue"; + static final String RESOLVES_POJO_LIST_QUEUE_NAME = "resolves_pojo_list_test_queue"; + static final String RESOLVES_POJO_MESSAGE_LIST_QUEUE_NAME = "resolves_pojo_message_list_test_queue"; + static final String RESOLVES_POJO_FROM_HEADER_QUEUE_NAME = "resolves_pojo_from_mapping_test_queue"; + static final String RESOLVES_MY_OTHER_POJO_FROM_HEADER_QUEUE_NAME = "resolves_my_other_pojo_from_mapping_test_queue"; + + @Autowired + LatchContainer latchContainer; + + @Autowired + SqsAsyncClient sqsAsyncClient; + + @Autowired + ObjectMapper objectMapper; + + @BeforeAll + static void beforeTests() { + SqsAsyncClient client = createAsyncClient(); + CompletableFuture.allOf(createQueue(client, RESOLVES_POJO_TYPES_QUEUE_NAME), + createQueue(client, RESOLVES_POJO_MESSAGE_QUEUE_NAME), + createQueue(client, RESOLVES_POJO_LIST_QUEUE_NAME), + createQueue(client, RESOLVES_POJO_MESSAGE_LIST_QUEUE_NAME), + createQueue(client, RESOLVES_POJO_FROM_HEADER_QUEUE_NAME), + createQueue(client, RESOLVES_MY_OTHER_POJO_FROM_HEADER_QUEUE_NAME)).join(); + } + + @Test + void resolvesPojoParameterTypes() throws Exception { + sendMessageTo(RESOLVES_POJO_TYPES_QUEUE_NAME, new MyPojo("pojoParameterType", "secondValue")); + assertThat(latchContainer.resolvesPojoLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void resolvesPojoMessage() throws Exception { + sendMessageTo(RESOLVES_POJO_MESSAGE_QUEUE_NAME, new MyPojo("resolvesPojoMessage", "secondValue")); + assertThat(latchContainer.resolvesPojoMessageLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void resolvesPojoList() throws Exception { + sendMessageTo(RESOLVES_POJO_LIST_QUEUE_NAME, new MyPojo("resolvesPojoList", "secondValue")); + assertThat(latchContainer.resolvesPojoListLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void resolvesPojoMessageList() throws Exception { + sendMessageTo(RESOLVES_POJO_MESSAGE_LIST_QUEUE_NAME, new MyPojo("resolvesPojoMessageList", "secondValue")); + assertThat(latchContainer.resolvesPojoMessageListLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void resolvesPojoFromHeader() throws Exception { + sendMessageTo(RESOLVES_POJO_FROM_HEADER_QUEUE_NAME, new MyPojo("pojoParameterType", "secondValue"), + getHeaderMapping(MyPojo.class)); + assertThat(latchContainer.resolvesPojoFromMappingLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void resolvesMyOtherPojoFromHeader() throws Exception { + sendMessageTo(RESOLVES_MY_OTHER_POJO_FROM_HEADER_QUEUE_NAME, + new MyOtherPojo("pojoParameterType", "secondValue"), getHeaderMapping(MyOtherPojo.class)); + assertThat(latchContainer.resolvesMyOtherPojoFromMappingLatch.await(10, TimeUnit.SECONDS)).isTrue(); + } + + private Map getHeaderMapping(Class clazz) { + return Collections.singletonMap(SqsHeaders.SQS_DEFAULT_TYPE_HEADER, + MessageAttributeValue.builder().stringValue(clazz.getName()).dataType("String").build()); + } + + static class ResolvesPojoListener { + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = RESOLVES_POJO_TYPES_QUEUE_NAME, id = "resolves-pojo") + void listen(MyPojo pojo, @Header(SqsHeaders.SQS_QUEUE_NAME_HEADER) String queueName) { + Assert.notNull(pojo.firstField, "Received null message"); + logger.debug("Received message {} from queue {}", pojo, queueName); + latchContainer.resolvesPojoLatch.countDown(); + } + } + + static class ResolvesPojoMessageListener { + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = RESOLVES_POJO_MESSAGE_QUEUE_NAME, id = "resolves-pojo-message") + void listen(Message pojo, @Header(SqsHeaders.SQS_QUEUE_NAME_HEADER) String queueName) { + Assert.notNull(pojo.getPayload().firstField, "Received null message"); + logger.debug("Received message {} from queue {}", pojo, queueName); + latchContainer.resolvesPojoMessageLatch.countDown(); + } + } + + static class ResolvesPojoListListener { + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = RESOLVES_POJO_LIST_QUEUE_NAME, id = "resolves-pojo-list") + void listen(List pojos) { + Assert.notNull(pojos.get(0).firstField, "Received null message"); + logger.debug("Received messages {} from queue {}", pojos, RESOLVES_POJO_MESSAGE_QUEUE_NAME); + latchContainer.resolvesPojoListLatch.countDown(); + } + } + + static class ResolvesPojoMessageListListener { + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = RESOLVES_POJO_MESSAGE_LIST_QUEUE_NAME, id = "resolves-pojo-message-list") + void listen(List> pojos) { + Assert.notNull(pojos.get(0).getPayload().firstField, "Received null message"); + logger.debug("Received messages {} from queue {}", pojos, RESOLVES_POJO_MESSAGE_QUEUE_NAME); + latchContainer.resolvesPojoMessageListLatch.countDown(); + } + } + + static class ResolvesMyPojoWithMappingListener { + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = RESOLVES_POJO_FROM_HEADER_QUEUE_NAME, id = "resolves-pojo-with-mapping", factory = "myPojoListenerContainerFactory") + void listen(Message pojo, @Header(SqsHeaders.SQS_QUEUE_NAME_HEADER) String queueName) { + Assert.isInstanceOf(MyPojo.class, pojo.getPayload()); + Assert.notNull(((MyPojo) pojo.getPayload()).firstField, "Received null message"); + logger.debug("Received message {} from queue {}", pojo, queueName); + latchContainer.resolvesPojoLatch.countDown(); + } + } + + static class ResolvesMyOtherPojoWithMappingListener { + + @Autowired + LatchContainer latchContainer; + + @SqsListener(queueNames = RESOLVES_MY_OTHER_POJO_FROM_HEADER_QUEUE_NAME, id = "resolves-my-other-pojo-with-mapping", factory = "myPojoListenerContainerFactory") + void listen(MyInterface pojo, @Header(SqsHeaders.SQS_QUEUE_NAME_HEADER) String queueName) { + Assert.isInstanceOf(MyOtherPojo.class, pojo); + Assert.notNull(((MyOtherPojo) pojo).otherFirstField, "Received null message"); + logger.debug("Received message {} from queue {}", pojo, queueName); + latchContainer.resolvesPojoMessageLatch.countDown(); + } + } + + static class LatchContainer { + + CountDownLatch resolvesPojoLatch = new CountDownLatch(1); + CountDownLatch resolvesPojoMessageLatch = new CountDownLatch(1); + CountDownLatch resolvesPojoListLatch = new CountDownLatch(1); + CountDownLatch resolvesPojoMessageListLatch = new CountDownLatch(1); + CountDownLatch resolvesPojoFromMappingLatch = new CountDownLatch(1); + CountDownLatch resolvesMyOtherPojoFromMappingLatch = new CountDownLatch(1); + + } + + @Import(SqsBootstrapConfiguration.class) + @Configuration + static class SQSConfiguration { + + // @formatter:off + @Bean + public SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory() { + SqsMessageListenerContainerFactory factory = new SqsMessageListenerContainerFactory<>(); + factory.configure(options -> options + .permitAcquireTimeout(Duration.ofSeconds(1)) + .pollTimeout(Duration.ofSeconds(3))); + factory.setSqsAsyncClientSupplier(BaseSqsIntegrationTest::createAsyncClient); + return factory; + } + + @Bean + public SqsMessageListenerContainerFactory myPojoListenerContainerFactory() { + SqsMessageListenerContainerFactory factory = new SqsMessageListenerContainerFactory<>(); + factory.configure(options -> options + .queueAttributeNames(Collections.singletonList(QueueAttributeName.VISIBILITY_TIMEOUT)) + .permitAcquireTimeout(Duration.ofSeconds(1)) + .pollTimeout(Duration.ofSeconds(1))); + factory.setSqsAsyncClientSupplier(BaseSqsIntegrationTest::createAsyncClient); + factory.addMessageInterceptor(new AsyncMessageInterceptor() { + @Override + public CompletableFuture> intercept(Message message) { + MyInterface payload = message.getPayload(); + Assert.notNull(payload, "null payload"); + if (payload instanceof MyPojo) { + Assert.notNull(((MyPojo) payload).firstField, "null firstField"); + latchContainer.resolvesPojoFromMappingLatch.countDown(); + } + else if (payload instanceof MyOtherPojo) { + Assert.notNull(((MyOtherPojo) payload).otherFirstField, "null otherFirstField"); + latchContainer.resolvesMyOtherPojoFromMappingLatch.countDown(); + } + return CompletableFuture.completedFuture(message); + } + }); + return factory; + } + // @formatter:on + + LatchContainer latchContainer = new LatchContainer(); + + @Bean + ResolvesPojoListener resolvesPojoListener() { + return new ResolvesPojoListener(); + } + + @Bean + ResolvesPojoListListener resolvesPojoListListener() { + return new ResolvesPojoListListener(); + } + + @Bean + ResolvesPojoMessageListener resolvesPojoMessageListener() { + return new ResolvesPojoMessageListener(); + } + + @Bean + ResolvesPojoMessageListListener resolvesPojoMessageListListener() { + return new ResolvesPojoMessageListListener(); + } + + @Bean + ResolvesMyPojoWithMappingListener resolvesMyPojoWithMappingListener() { + return new ResolvesMyPojoWithMappingListener(); + } + + @Bean + ResolvesMyOtherPojoWithMappingListener resolvesMyOtherPojoWithMappingListener() { + return new ResolvesMyOtherPojoWithMappingListener(); + } + + @Bean + LatchContainer latchContainer() { + return this.latchContainer; + } + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + @Bean + SqsAsyncClient sqsAsyncClientProducer() { + return BaseSqsIntegrationTest.createHighThroughputAsyncClient(); + } + } + + private void sendMessageTo(String queueName, Object messageBody) + throws InterruptedException, ExecutionException, JsonProcessingException { + String queueUrl = sqsAsyncClient.getQueueUrl(req -> req.queueName(queueName)).get().queueUrl(); + String payload = objectMapper.writeValueAsString(messageBody); + sqsAsyncClient.sendMessage(req -> req.messageBody(payload).queueUrl(queueUrl).build()).get(); + logger.debug("Sent message to queue {} with messageBody {}", queueName, messageBody); + } + + private void sendMessageTo(String queueName, Object messageBody, + Map messageAttributes) + throws InterruptedException, ExecutionException, JsonProcessingException { + String queueUrl = sqsAsyncClient.getQueueUrl(req -> req.queueName(queueName)).get().queueUrl(); + String payload = objectMapper.writeValueAsString(messageBody); + sqsAsyncClient + .sendMessage( + req -> req.messageBody(payload).queueUrl(queueUrl).messageAttributes(messageAttributes).build()) + .get(); + logger.debug("Sent message to queue {} with messageBody {}", queueName, messageBody); + } + + static class MyPojo implements MyInterface { + + String firstField; + String secondField; + + MyPojo(String firstField, String secondField) { + this.firstField = firstField; + this.secondField = secondField; + } + + MyPojo() { + } + + public String getFirstField() { + return firstField; + } + + public void setFirstField(String firstField) { + this.firstField = firstField; + } + + public String getSecondField() { + return secondField; + } + + public void setSecondField(String secondField) { + this.secondField = secondField; + } + + } + + static class MyOtherPojo implements MyInterface { + + String otherFirstField; + String otherSecondField; + + MyOtherPojo(String otherFirstField, String otherSecondField) { + this.otherFirstField = otherFirstField; + this.otherSecondField = otherSecondField; + } + + MyOtherPojo() { + } + + public String getOtherFirstField() { + return otherFirstField; + } + + public void setOtherFirstField(String otherFirstField) { + this.otherFirstField = otherFirstField; + } + + public String getOtherSecondField() { + return otherSecondField; + } + + public void setOtherSecondField(String otherSecondField) { + this.otherSecondField = otherSecondField; + } + + } + + interface MyInterface { + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/AbstractMessageListenerContainerTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/AbstractMessageListenerContainerTests.java new file mode 100644 index 000000000..2d842cc3c --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/AbstractMessageListenerContainerTests.java @@ -0,0 +1,102 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.AsyncAcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.errorhandler.ErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import io.awspring.cloud.sqs.listener.interceptor.MessageInterceptor; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class AbstractMessageListenerContainerTests { + + @Test + void shouldAdaptBlockingComponents() { + ContainerOptions options = ContainerOptions.builder().build(); + AbstractMessageListenerContainer container = new AbstractMessageListenerContainer(options) { + }; + + MessageListener listener = mock(MessageListener.class); + ErrorHandler errorHandler = mock(ErrorHandler.class); + MessageInterceptor interceptor = mock(MessageInterceptor.class); + AcknowledgementResultCallback callback = mock(AcknowledgementResultCallback.class); + ContainerComponentFactory componentFactory = mock(ContainerComponentFactory.class); + List> componentFactories = Collections.singletonList(componentFactory); + + container.setMessageListener(listener); + container.setErrorHandler(errorHandler); + container.setComponentFactories(componentFactories); + container.addMessageInterceptor(interceptor); + container.setAcknowledgementResultCallback(callback); + + assertThat(container.getMessageListener()) + .isInstanceOf(AsyncComponentAdapters.AbstractThreadingComponentAdapter.class) + .extracting("blockingMessageListener").isEqualTo(listener); + + assertThat(container.getErrorHandler()) + .isInstanceOf(AsyncComponentAdapters.AbstractThreadingComponentAdapter.class) + .extracting("blockingErrorHandler").isEqualTo(errorHandler); + + assertThat(container.getAcknowledgementResultCallback()) + .isInstanceOf(AsyncComponentAdapters.AbstractThreadingComponentAdapter.class) + .extracting("blockingAcknowledgementResultCallback").isEqualTo(callback); + + assertThat(container.getMessageInterceptors()).element(0) + .isInstanceOf(AsyncComponentAdapters.AbstractThreadingComponentAdapter.class) + .extracting("blockingMessageInterceptor").isEqualTo(interceptor); + + } + + @Test + void shouldSetAsyncComponents() { + ContainerOptions options = ContainerOptions.builder().build(); + AbstractMessageListenerContainer container = new AbstractMessageListenerContainer(options) { + }; + + AsyncMessageListener listener = mock(AsyncMessageListener.class); + AsyncErrorHandler errorHandler = mock(AsyncErrorHandler.class); + AsyncMessageInterceptor interceptor = mock(AsyncMessageInterceptor.class); + AsyncAcknowledgementResultCallback callback = mock(AsyncAcknowledgementResultCallback.class); + ContainerComponentFactory componentFactory = mock(ContainerComponentFactory.class); + List> componentFactories = Collections.singletonList(componentFactory); + + container.setAsyncMessageListener(listener); + container.setErrorHandler(errorHandler); + container.setComponentFactories(componentFactories); + container.addMessageInterceptor(interceptor); + container.setAcknowledgementResultCallback(callback); + + assertThat(container.getMessageListener()).isEqualTo(listener); + assertThat(container.getErrorHandler()).isEqualTo(errorHandler); + assertThat(container.getAcknowledgementResultCallback()).isEqualTo(callback); + assertThat(container.getContainerComponentFactories()).isEqualTo(componentFactories); + assertThat(container.getMessageInterceptors()).containsExactly(interceptor); + + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/AsyncComponentAdaptersTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/AsyncComponentAdaptersTests.java new file mode 100644 index 000000000..05421e7b3 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/AsyncComponentAdaptersTests.java @@ -0,0 +1,387 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +import io.awspring.cloud.sqs.MessageExecutionThreadFactory; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.AsyncAcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.errorhandler.ErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import io.awspring.cloud.sqs.listener.interceptor.MessageInterceptor; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.core.task.TaskRejectedException; +import org.springframework.messaging.Message; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class AsyncComponentAdaptersTests { + + @Test + void shouldExecuteRunnableInNewThread() { + TaskExecutor executor = mock(TaskExecutor.class); + Runnable runnable = mock(Runnable.class); + doAnswer(invocation -> { + ((Runnable) invocation.getArguments()[0]).run(); + return null; + }).when(executor).execute(any()); + AsyncComponentAdapters.AbstractThreadingComponentAdapter threadingComponentAdapter = new AsyncComponentAdapters.AbstractThreadingComponentAdapter() { + }; + threadingComponentAdapter.setTaskExecutor(executor); + threadingComponentAdapter.execute(runnable).join(); + then(executor).should().execute(any()); + } + + @Test + void shouldExecuteRunnableInSameThread() throws Exception { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + MessageExecutionThreadFactory threadFactory = new MessageExecutionThreadFactory(); + executor.setThreadFactory(threadFactory); + Runnable runnable = mock(Runnable.class); + TaskExecutor adapterExecutor = mock(TaskExecutor.class); + AsyncComponentAdapters.AbstractThreadingComponentAdapter threadingComponentAdapter = new AsyncComponentAdapters.AbstractThreadingComponentAdapter() { + }; + threadingComponentAdapter.setTaskExecutor(adapterExecutor); + executor.submit(() -> threadingComponentAdapter.execute(runnable).join()).get(); + then(runnable).should().run(); + then(adapterExecutor).should(never()).execute(any()); + } + + @Test + void shouldExecuteSupplierInNewThread() { + TaskExecutor executor = mock(TaskExecutor.class); + Supplier supplier = mock(Supplier.class); + doAnswer(invocation -> { + ((Runnable) invocation.getArguments()[0]).run(); + return null; + }).when(executor).execute(any()); + AsyncComponentAdapters.AbstractThreadingComponentAdapter threadingComponentAdapter = new AsyncComponentAdapters.AbstractThreadingComponentAdapter() { + }; + threadingComponentAdapter.setTaskExecutor(executor); + threadingComponentAdapter.execute(supplier).join(); + then(supplier).should().get(); + then(executor).should().execute(any()); + } + + @Test + void shouldExecuteSupplierInSameThread() throws Exception { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + MessageExecutionThreadFactory threadFactory = new MessageExecutionThreadFactory(); + executor.setThreadFactory(threadFactory); + Supplier supplier = mock(Supplier.class); + TaskExecutor adapterExecutor = mock(TaskExecutor.class); + AsyncComponentAdapters.AbstractThreadingComponentAdapter threadingComponentAdapter = new AsyncComponentAdapters.AbstractThreadingComponentAdapter() { + }; + threadingComponentAdapter.setTaskExecutor(adapterExecutor); + executor.submit(() -> threadingComponentAdapter.execute(supplier).join()).get(); + then(supplier).should().get(); + then(adapterExecutor).should(never()).execute(any()); + } + + @Test + void shouldWrapThrowableFromRunnableInNewThread() { + TaskExecutor executor = mock(TaskExecutor.class); + Runnable runnable = mock(Runnable.class); + RuntimeException expectedException = new RuntimeException( + "Expected exception from shouldHandleThrowableFromRunnableInNewThread"); + doThrow(expectedException).when(runnable).run(); + doAnswer(invocation -> { + ((Runnable) invocation.getArguments()[0]).run(); + return null; + }).when(executor).execute(any()); + AsyncComponentAdapters.AbstractThreadingComponentAdapter threadingComponentAdapter = new AsyncComponentAdapters.AbstractThreadingComponentAdapter() { + }; + threadingComponentAdapter.setTaskExecutor(executor); + assertThatThrownBy(() -> threadingComponentAdapter.execute(runnable).join()) + .isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(AsyncAdapterBlockingExecutionFailedException.class).extracting(Throwable::getCause) + .isEqualTo(expectedException); + then(runnable).should().run(); + then(executor).should().execute(any()); + } + + @Test + void shouldWrapThrowableFromRejectedRunnableInNewThread() { + TaskExecutor executor = mock(TaskExecutor.class); + Runnable runnable = mock(Runnable.class); + TaskRejectedException expectedException = new TaskRejectedException( + "Expected exception from shouldHandleThrowableFromRunnableInNewThread"); + doThrow(expectedException).when(executor).execute(any()); + AsyncComponentAdapters.AbstractThreadingComponentAdapter threadingComponentAdapter = new AsyncComponentAdapters.AbstractThreadingComponentAdapter() { + }; + threadingComponentAdapter.setTaskExecutor(executor); + assertThatThrownBy(() -> threadingComponentAdapter.execute(runnable).join()) + .isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(AsyncAdapterBlockingExecutionFailedException.class).extracting(Throwable::getCause) + .isEqualTo(expectedException); + then(runnable).should(never()).run(); + then(executor).should().execute(any()); + } + + @Test + void shouldWrapThrowableFromRunnableInSameThread() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + MessageExecutionThreadFactory threadFactory = new MessageExecutionThreadFactory(); + executor.setThreadFactory(threadFactory); + Runnable runnable = mock(Runnable.class); + RuntimeException expectedException = new RuntimeException( + "Expected exception from shouldHandleThrowableFromRunnableInSameThread"); + doThrow(expectedException).when(runnable).run(); + TaskExecutor adapterExecutor = mock(TaskExecutor.class); + AsyncComponentAdapters.AbstractThreadingComponentAdapter threadingComponentAdapter = new AsyncComponentAdapters.AbstractThreadingComponentAdapter() { + }; + threadingComponentAdapter.setTaskExecutor(adapterExecutor); + assertThatThrownBy(() -> executor.submit(() -> threadingComponentAdapter.execute(runnable).join()).get()) + .isInstanceOf(ExecutionException.class).extracting(Throwable::getCause) + .isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(AsyncAdapterBlockingExecutionFailedException.class).extracting(Throwable::getCause) + .isEqualTo(expectedException); + + then(runnable).should().run(); + then(adapterExecutor).should(never()).execute(any()); + } + + @Test + void shouldWrapThrowableFromSupplierInNewThread() { + TaskExecutor executor = mock(TaskExecutor.class); + Supplier supplier = mock(Supplier.class); + RuntimeException expectedException = new RuntimeException( + "Expected exception from shouldHandleThrowableFromRunnableInNewThread"); + doThrow(expectedException).when(supplier).get(); + doAnswer(invocation -> { + ((Runnable) invocation.getArguments()[0]).run(); + return null; + }).when(executor).execute(any()); + AsyncComponentAdapters.AbstractThreadingComponentAdapter threadingComponentAdapter = new AsyncComponentAdapters.AbstractThreadingComponentAdapter() { + }; + threadingComponentAdapter.setTaskExecutor(executor); + assertThatThrownBy(() -> threadingComponentAdapter.execute(supplier).join()) + .isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(AsyncAdapterBlockingExecutionFailedException.class).extracting(Throwable::getCause) + .isEqualTo(expectedException); + then(supplier).should().get(); + then(executor).should().execute(any()); + } + + @Test + void shouldWrapThrowableFromRejectedSupplierInNewThread() { + TaskExecutor executor = mock(TaskExecutor.class); + Supplier supplier = mock(Supplier.class); + TaskRejectedException expectedException = new TaskRejectedException( + "Expected exception from shouldHandleThrowableFromRunnableInNewThread"); + doThrow(expectedException).when(executor).execute(any()); + AsyncComponentAdapters.AbstractThreadingComponentAdapter threadingComponentAdapter = new AsyncComponentAdapters.AbstractThreadingComponentAdapter() { + }; + threadingComponentAdapter.setTaskExecutor(executor); + assertThatThrownBy(() -> threadingComponentAdapter.execute(supplier).join()) + .isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(AsyncAdapterBlockingExecutionFailedException.class).extracting(Throwable::getCause) + .isEqualTo(expectedException); + then(supplier).should(never()).get(); + then(executor).should().execute(any()); + } + + @Test + void shouldWrapThrowableFromSupplierInSameThread() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + MessageExecutionThreadFactory threadFactory = new MessageExecutionThreadFactory(); + executor.setThreadFactory(threadFactory); + Supplier supplier = mock(Supplier.class); + RuntimeException expectedException = new RuntimeException( + "Expected exception from shouldHandleThrowableFromRunnableInSameThread"); + doThrow(expectedException).when(supplier).get(); + TaskExecutor adapterExecutor = mock(TaskExecutor.class); + AsyncComponentAdapters.AbstractThreadingComponentAdapter threadingComponentAdapter = new AsyncComponentAdapters.AbstractThreadingComponentAdapter() { + }; + threadingComponentAdapter.setTaskExecutor(adapterExecutor); + assertThatThrownBy(() -> executor.submit(() -> threadingComponentAdapter.execute(supplier).join()).get()) + .isInstanceOf(ExecutionException.class).extracting(Throwable::getCause) + .isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(AsyncAdapterBlockingExecutionFailedException.class).extracting(Throwable::getCause) + .isEqualTo(expectedException); + + then(supplier).should().get(); + then(adapterExecutor).should(never()).execute(any()); + } + + @Test + void shouldThrowIfTaskExecutorNotSetRunnable() { + MessageListener listener = mock(MessageListener.class); + Message message = mock(Message.class); + AsyncMessageListener asyncListener = AsyncComponentAdapters.adapt(listener); + assertThatThrownBy(() -> asyncListener.onMessage(message)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void shouldThrowIfTaskExecutorNotSetSupplier() { + MessageInterceptor interceptor = mock(MessageInterceptor.class); + Message message = mock(Message.class); + AsyncMessageInterceptor asyncInterceptor = AsyncComponentAdapters.adapt(interceptor); + assertThatThrownBy(() -> asyncInterceptor.intercept(message)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void shouldAdaptMessageListenerSingleMessage() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + MessageListener listener = mock(MessageListener.class); + Message message = mock(Message.class); + AsyncMessageListener asyncListener = AsyncComponentAdapters.adapt(listener); + ((TaskExecutorAware) asyncListener).setTaskExecutor(executor); + asyncListener.onMessage(message).join(); + then(listener).should().onMessage(message); + } + + @Test + void shouldAdaptMessageListenerBatch() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + MessageListener listener = mock(MessageListener.class); + Message message = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> messages = Arrays.asList(message, message2, message3); + AsyncMessageListener asyncListener = AsyncComponentAdapters.adapt(listener); + ((TaskExecutorAware) asyncListener).setTaskExecutor(executor); + asyncListener.onMessage(messages).join(); + ArgumentCaptor>> messagesCaptor = ArgumentCaptor.forClass(Collection.class); + then(listener).should().onMessage(messagesCaptor.capture()); + assertThat(messagesCaptor.getValue()).isEqualTo(messages); + } + + @Test + void shouldAdaptMessageInterceptorSingleMessage() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + MessageInterceptor interceptor = mock(MessageInterceptor.class); + Message message = mock(Message.class); + AsyncMessageInterceptor asyncInterceptor = AsyncComponentAdapters.adapt(interceptor); + ((TaskExecutorAware) asyncInterceptor).setTaskExecutor(executor); + asyncInterceptor.intercept(message).join(); + then(interceptor).should().intercept(message); + } + + @Test + void shouldAdaptMessageInterceptorBatch() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + MessageInterceptor intercept = mock(MessageInterceptor.class); + Message message = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> messages = Arrays.asList(message, message2, message3); + AsyncMessageInterceptor asyncInterceptor = AsyncComponentAdapters.adapt(intercept); + ((TaskExecutorAware) asyncInterceptor).setTaskExecutor(executor); + asyncInterceptor.intercept(messages).join(); + ArgumentCaptor>> messagesCaptor = ArgumentCaptor.forClass(Collection.class); + then(intercept).should().intercept(messagesCaptor.capture()); + assertThat(messagesCaptor.getValue()).isEqualTo(messages); + } + + @Test + void shouldAdaptErrorHandlerSingleMessage() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + ErrorHandler errorHandler = mock(ErrorHandler.class); + Message message = mock(Message.class); + AsyncErrorHandler asyncErrorHandler = AsyncComponentAdapters.adapt(errorHandler); + ((TaskExecutorAware) asyncErrorHandler).setTaskExecutor(executor); + Throwable t = mock(Throwable.class); + asyncErrorHandler.handle(message, t).join(); + then(errorHandler).should().handle(message, t); + } + + @Test + void shouldAdaptErrorHandlerBatch() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + ErrorHandler errorHandler = mock(ErrorHandler.class); + Message message = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> messages = Arrays.asList(message, message2, message3); + AsyncErrorHandler asyncErrorHandler = AsyncComponentAdapters.adapt(errorHandler); + ((TaskExecutorAware) asyncErrorHandler).setTaskExecutor(executor); + Throwable throwable = mock(Throwable.class); + asyncErrorHandler.handle(messages, throwable).join(); + ArgumentCaptor>> messagesCaptor = ArgumentCaptor.forClass(Collection.class); + then(errorHandler).should().handle(messagesCaptor.capture(), eq(throwable)); + assertThat(messagesCaptor.getValue()).isEqualTo(messages); + } + + @Test + void shouldAdaptAcknowledgementCallbackOnSuccess() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + AcknowledgementResultCallback callback = mock(AcknowledgementResultCallback.class); + Message message = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> messages = Arrays.asList(message, message2, message3); + AsyncAcknowledgementResultCallback asyncCallback = AsyncComponentAdapters.adapt(callback); + ((TaskExecutorAware) asyncCallback).setTaskExecutor(executor); + asyncCallback.onSuccess(messages).join(); + then(callback).should().onSuccess(messages); + } + + @Test + void shouldAdaptAcknowledgementCallbackOnFailure() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + AcknowledgementResultCallback callback = mock(AcknowledgementResultCallback.class); + Message message = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> messages = Arrays.asList(message, message2, message3); + AsyncAcknowledgementResultCallback asyncCallback = AsyncComponentAdapters.adapt(callback); + ((TaskExecutorAware) asyncCallback).setTaskExecutor(executor); + Throwable throwable = mock(Throwable.class); + asyncCallback.onFailure(messages, throwable).join(); + then(callback).should().onFailure(messages, throwable); + } + + @Test + void shouldAdaptAcknowledgementCallbackBatch() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + ErrorHandler errorHandler = mock(ErrorHandler.class); + Message message = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> messages = Arrays.asList(message, message2, message3); + AsyncErrorHandler asyncErrorHandler = AsyncComponentAdapters.adapt(errorHandler); + ((TaskExecutorAware) asyncErrorHandler).setTaskExecutor(executor); + Throwable throwable = mock(Throwable.class); + asyncErrorHandler.handle(messages, throwable).join(); + ArgumentCaptor>> messagesCaptor = ArgumentCaptor.forClass(Collection.class); + then(errorHandler).should().handle(messagesCaptor.capture(), eq(throwable)); + assertThat(messagesCaptor.getValue()).isEqualTo(messages); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ContainerOptionsTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ContainerOptionsTests.java new file mode 100644 index 000000000..ebe154e94 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ContainerOptionsTests.java @@ -0,0 +1,106 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import io.awspring.cloud.sqs.support.converter.MessagingMessageConverter; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.springframework.core.task.TaskExecutor; +import software.amazon.awssdk.services.sqs.model.MessageSystemAttributeName; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +class ContainerOptionsTests { + + @Test + void shouldDefaultToCreateQueues() { + ContainerOptions options = ContainerOptions.builder().build(); + assertThat(options.getQueueNotFoundStrategy()).isEqualTo(QueueNotFoundStrategy.CREATE); + } + + @Test + void shouldHaveSameValuesAfterBuilder() { + ContainerOptions options = ContainerOptions.builder().build(); + ContainerOptions builtCopy = options.toBuilder().build(); + assertThat(options).usingRecursiveComparison().isEqualTo(builtCopy); + } + + @Test + void shouldCreateCopy() { + ContainerOptions options = ContainerOptions.builder().build(); + ContainerOptions copy = options.createCopy(); + assertThat(options).usingRecursiveComparison().isEqualTo(copy); + } + + @Test + void shouldCreateCopyOfBuilder() { + ContainerOptions.Builder builder = ContainerOptions.builder(); + ContainerOptions.Builder copy = builder.createCopy(); + assertThat(copy).usingRecursiveComparison().isEqualTo(builder); + } + + @Test + void shouldHaveSameFieldsInBuilder() { + ContainerOptions options = ContainerOptions.builder().build(); + ContainerOptions.Builder builtCopy = options.toBuilder(); + assertThat(options).usingRecursiveComparison().isEqualTo(builtCopy); + } + + @Test + void shouldSetMessageAttributeNames() { + List messageAttributeNames = Arrays.asList("name-1", "name-2"); + ContainerOptions options = ContainerOptions.builder().messageAttributeNames(messageAttributeNames).build(); + assertThat(options.getMessageAttributeNames()).containsExactlyElementsOf(messageAttributeNames); + } + + @Test + void shouldSetMessageSystemAttributeNames() { + List attributeNames = Arrays.asList(MessageSystemAttributeName.MESSAGE_GROUP_ID, + MessageSystemAttributeName.MESSAGE_DEDUPLICATION_ID); + ContainerOptions options = ContainerOptions.builder().messageSystemAttributeNames(attributeNames).build(); + assertThat(options.getMessageSystemAttributeNames()).containsExactlyInAnyOrderElementsOf( + attributeNames.stream().map(MessageSystemAttributeName::toString).collect(Collectors.toList())); + } + + @Test + void shouldSetTaskExecutor() { + TaskExecutor executor = mock(TaskExecutor.class); + ContainerOptions options = ContainerOptions.builder().componentsTaskExecutor(executor).build(); + assertThat(options.getComponentsTaskExecutor()).isEqualTo(executor); + } + + @Test + void shouldSetQueueNotFoundStrategy() { + ContainerOptions options = ContainerOptions.builder().queueNotFoundStrategy(QueueNotFoundStrategy.FAIL).build(); + assertThat(options.getQueueNotFoundStrategy()).isEqualTo(QueueNotFoundStrategy.FAIL); + } + + @SuppressWarnings("unchecked") + @Test + void shouldSetMessageConverter() { + MessagingMessageConverter converter = mock(MessagingMessageConverter.class); + ContainerOptions options = ContainerOptions.builder().messageConverter(converter).build(); + assertThat(options.getMessageConverter()).isEqualTo(converter); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/DefaultListenerContainerRegistryTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/DefaultListenerContainerRegistryTests.java new file mode 100644 index 000000000..8b4f7da11 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/DefaultListenerContainerRegistryTests.java @@ -0,0 +1,109 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.Test; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class DefaultListenerContainerRegistryTests { + + @Test + void shouldRegisterListenerContainer() { + MessageListenerContainer container = mock(MessageListenerContainer.class); + String id = "test-container-id"; + given(container.getId()).willReturn(id); + DefaultListenerContainerRegistry registry = new DefaultListenerContainerRegistry(); + registry.registerListenerContainer(container); + } + + @Test + void shouldGetListenerContainer() { + MessageListenerContainer container = mock(MessageListenerContainer.class); + String id = "test-container-id"; + given(container.getId()).willReturn(id); + DefaultListenerContainerRegistry registry = new DefaultListenerContainerRegistry(); + registry.registerListenerContainer(container); + MessageListenerContainer containerFromRegistry = registry.getContainerById(id); + assertThat(containerFromRegistry).isEqualTo(container); + } + + @Test + void shouldGetAllListenerContainers() { + MessageListenerContainer container1 = mock(MessageListenerContainer.class); + MessageListenerContainer container2 = mock(MessageListenerContainer.class); + MessageListenerContainer container3 = mock(MessageListenerContainer.class); + String id1 = "test-container-id-1"; + String id2 = "test-container-id-2"; + String id3 = "test-container-id-3"; + given(container1.getId()).willReturn(id1); + given(container2.getId()).willReturn(id2); + given(container3.getId()).willReturn(id3); + DefaultListenerContainerRegistry registry = new DefaultListenerContainerRegistry(); + registry.registerListenerContainer(container1); + registry.registerListenerContainer(container2); + registry.registerListenerContainer(container3); + assertThat(registry.getListenerContainers()).containsExactlyInAnyOrder(container1, container2, container3); + } + + @Test + void shouldStartAndStopAllListenerContainers() { + MessageListenerContainer container1 = mock(MessageListenerContainer.class); + MessageListenerContainer container2 = mock(MessageListenerContainer.class); + MessageListenerContainer container3 = mock(MessageListenerContainer.class); + String id1 = "test-container-id-1"; + String id2 = "test-container-id-2"; + String id3 = "test-container-id-3"; + given(container1.getId()).willReturn(id1); + given(container2.getId()).willReturn(id2); + given(container3.getId()).willReturn(id3); + DefaultListenerContainerRegistry registry = new DefaultListenerContainerRegistry(); + registry.registerListenerContainer(container1); + registry.registerListenerContainer(container2); + registry.registerListenerContainer(container3); + registry.start(); + assertThat(registry.isRunning()).isTrue(); + registry.stop(); + assertThat(registry.isRunning()).isFalse(); + then(container1).should().start(); + then(container1).should().stop(); + then(container2).should().start(); + then(container2).should().stop(); + then(container3).should().start(); + then(container3).should().stop(); + } + + @Test + void shouldThrowIfIdAlreadyPresent() { + MessageListenerContainer container = mock(MessageListenerContainer.class); + String id = "test-container-id"; + given(container.getId()).willReturn(id); + DefaultListenerContainerRegistry registry = new DefaultListenerContainerRegistry(); + registry.registerListenerContainer(container); + assertThatThrownBy(() -> registry.registerListenerContainer(container)) + .isInstanceOf(IllegalArgumentException.class); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/FifoSqsComponentFactoryTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/FifoSqsComponentFactoryTests.java new file mode 100644 index 000000000..456dc4b6b --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/FifoSqsComponentFactoryTests.java @@ -0,0 +1,130 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.type; + +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementOrdering; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementProcessor; +import io.awspring.cloud.sqs.listener.acknowledgement.BatchingAcknowledgementProcessor; +import io.awspring.cloud.sqs.listener.acknowledgement.ImmediateAcknowledgementProcessor; +import io.awspring.cloud.sqs.listener.sink.BatchMessageSink; +import io.awspring.cloud.sqs.listener.sink.MessageSink; +import io.awspring.cloud.sqs.listener.sink.OrderedMessageSink; +import io.awspring.cloud.sqs.listener.sink.adapter.MessageGroupingSinkAdapter; +import io.awspring.cloud.sqs.listener.sink.adapter.MessageVisibilityExtendingSinkAdapter; +import java.time.Duration; +import org.assertj.core.api.AbstractObjectAssert; +import org.junit.jupiter.api.Test; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +class FifoSqsComponentFactoryTests { + + @Test + void shouldCreateGroupingSink() { + FifoSqsComponentFactory componentFactory = new FifoSqsComponentFactory<>(); + MessageSink messageSink = componentFactory.createMessageSink(ContainerOptions.builder().build()); + assertThat(messageSink).isInstanceOf(MessageGroupingSinkAdapter.class) + .asInstanceOf(type(MessageGroupingSinkAdapter.class)).extracting("delegate") + .isInstanceOf(OrderedMessageSink.class); + } + + @Test + void shouldCreateBatchSink() { + FifoSqsComponentFactory componentFactory = new FifoSqsComponentFactory<>(); + MessageSink messageSink = componentFactory + .createMessageSink(ContainerOptions.builder().listenerMode(ListenerMode.BATCH).build()); + assertThat(messageSink).isInstanceOf(MessageGroupingSinkAdapter.class) + .asInstanceOf(type(MessageGroupingSinkAdapter.class)).extracting("delegate") + .isInstanceOf(BatchMessageSink.class); + } + + @Test + void shouldCreateGroupingSinkWithVisibility() { + FifoSqsComponentFactory componentFactory = new FifoSqsComponentFactory<>(); + Duration visbilityDuration = Duration.ofSeconds(1); + MessageSink messageSink = componentFactory + .createMessageSink(ContainerOptions.builder().messageVisibility(visbilityDuration).build()); + AbstractObjectAssert visibilitySinkAssertion = assertThat(messageSink) + .isInstanceOf(MessageGroupingSinkAdapter.class).asInstanceOf(type(MessageGroupingSinkAdapter.class)) + .extracting("delegate").isInstanceOf(MessageVisibilityExtendingSinkAdapter.class); + visibilitySinkAssertion.extracting("messageVisibility").isEqualTo((int) visbilityDuration.getSeconds()); + visibilitySinkAssertion.extracting("delegate").isInstanceOf(OrderedMessageSink.class); + } + + @Test + void shouldCreateAcknowledgementProcessorWithDefaults() { + FifoSqsComponentFactory componentFactory = new FifoSqsComponentFactory<>(); + + ContainerOptions options = ContainerOptions.builder().build(); + AcknowledgementProcessor processor = componentFactory.createAcknowledgementProcessor(options); + assertThat(processor).isInstanceOf(ImmediateAcknowledgementProcessor.class) + .extracting("acknowledgementOrdering").isEqualTo(AcknowledgementOrdering.PARALLEL); + assertThat(processor).extracting("messageGroupingFunction").isNull(); + } + + @Test + void shouldCreateBatchingAcknowledgementProcessor() { + FifoSqsComponentFactory componentFactory = new FifoSqsComponentFactory<>(); + + Duration acknowledgementInterval = Duration.ofSeconds(10); + int acknowledgementThreshold = 10; + ContainerOptions options = ContainerOptions.builder().acknowledgementInterval(acknowledgementInterval) + .acknowledgementThreshold(acknowledgementThreshold).build(); + AcknowledgementProcessor processor = componentFactory.createAcknowledgementProcessor(options); + assertThat(processor).isInstanceOf(BatchingAcknowledgementProcessor.class).extracting("acknowledgementOrdering") + .isEqualTo(AcknowledgementOrdering.ORDERED); + assertThat(processor).extracting("ackThreshold").isEqualTo(acknowledgementThreshold); + assertThat(processor).extracting("ackInterval").isEqualTo(acknowledgementInterval); + assertThat(processor).extracting("messageGroupingFunction").isNull(); + + } + + @Test + void shouldCreateBatchingAcknowledgementProcessorOrderedByGroup() { + FifoSqsComponentFactory componentFactory = new FifoSqsComponentFactory<>(); + + Duration acknowledgementInterval = Duration.ofSeconds(10); + int acknowledgementThreshold = 10; + ContainerOptions options = ContainerOptions.builder().acknowledgementInterval(acknowledgementInterval) + .acknowledgementThreshold(acknowledgementThreshold) + .acknowledgementOrdering(AcknowledgementOrdering.ORDERED_BY_GROUP).build(); + AcknowledgementProcessor processor = componentFactory.createAcknowledgementProcessor(options); + assertThat(processor).isInstanceOf(BatchingAcknowledgementProcessor.class).extracting("acknowledgementOrdering") + .isEqualTo(AcknowledgementOrdering.ORDERED_BY_GROUP); + assertThat(processor).extracting("ackThreshold").isEqualTo(acknowledgementThreshold); + assertThat(processor).extracting("ackInterval").isEqualTo(acknowledgementInterval); + assertThat(processor).extracting("messageGroupingFunction").isNotNull(); + + } + + @Test + void shouldCreateImmediateAcknowledgementProcessorOrderedByGroup() { + FifoSqsComponentFactory componentFactory = new FifoSqsComponentFactory<>(); + + ContainerOptions options = ContainerOptions.builder() + .acknowledgementOrdering(AcknowledgementOrdering.ORDERED_BY_GROUP).build(); + AcknowledgementProcessor processor = componentFactory.createAcknowledgementProcessor(options); + assertThat(processor).isInstanceOf(ImmediateAcknowledgementProcessor.class) + .extracting("acknowledgementOrdering").isEqualTo(AcknowledgementOrdering.ORDERED_BY_GROUP); + assertThat(processor).extracting("messageGroupingFunction").isNotNull(); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ListenerExecutionFailedExceptionTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ListenerExecutionFailedExceptionTests.java new file mode 100644 index 000000000..dbf98803e --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/ListenerExecutionFailedExceptionTests.java @@ -0,0 +1,127 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import io.awspring.cloud.sqs.CompletableFutures; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class ListenerExecutionFailedExceptionTests { + + @Test + void shouldUnwrapMessage() { + Throwable throwable = mock(Throwable.class); + Message message = mock(Message.class); + ListenerExecutionFailedException listenerException = new ListenerExecutionFailedException("Expected error", + throwable, message); + assertThat(listenerException).extracting(ListenerExecutionFailedException::getFailedMessage).isEqualTo(message); + assertThat(ListenerExecutionFailedException.unwrapMessage(listenerException)).isEqualTo(message); + } + + @Test + void shouldUnwrapNestedMessage() { + Throwable throwable = mock(Throwable.class); + Message message = mock(Message.class); + ListenerExecutionFailedException listenerException = new ListenerExecutionFailedException("Expected error", + throwable, message); + RuntimeException oneMoreException = new RuntimeException(listenerException); + CompletableFuture failedFuture = CompletableFutures.failedFuture(oneMoreException); + assertThatThrownBy(failedFuture::join).extracting(ListenerExecutionFailedException::unwrapMessage) + .isEqualTo(message); + } + + @Test + void shouldThrowIfMoreThanOneMessage() { + Throwable throwable = mock(Throwable.class); + Message message = mock(Message.class); + List> messages = Arrays.asList(message, message, message); + ListenerExecutionFailedException listenerException = new ListenerExecutionFailedException("Expected error", + throwable, messages); + assertThatThrownBy(listenerException::getFailedMessage).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void shouldUnwrapMessages() { + Throwable throwable = mock(Throwable.class); + Message message = mock(Message.class); + List> messages = Arrays.asList(message, message, message); + ListenerExecutionFailedException listenerException = new ListenerExecutionFailedException("Expected error", + throwable, messages); + assertThat(listenerException).extracting(ListenerExecutionFailedException::getFailedMessages) + .isEqualTo(messages); + assertThat(ListenerExecutionFailedException.unwrapMessages(listenerException)).isEqualTo(messages); + } + + @Test + void shouldUnwrapNestedMessages() { + Throwable throwable = mock(Throwable.class); + Message message = mock(Message.class); + List> messages = Arrays.asList(message, message, message); + ListenerExecutionFailedException listenerException = new ListenerExecutionFailedException("Expected error", + throwable, messages); + RuntimeException oneMoreException = new RuntimeException(listenerException); + CompletableFuture failedFuture = CompletableFutures.failedFuture(oneMoreException); + assertThatThrownBy(failedFuture::join).extracting(ListenerExecutionFailedException::unwrapMessages) + .isEqualTo(messages); + } + + @Test + void shouldThrowIfListenerExceptionNotFoundForMessage() { + Throwable throwable = mock(Throwable.class); + RuntimeException oneMoreException = new RuntimeException(throwable); + assertThatThrownBy(() -> ListenerExecutionFailedException.unwrapMessage(oneMoreException)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void shouldThrowIfListenerExceptionNotFoundForMessages() { + Throwable throwable = mock(Throwable.class); + RuntimeException oneMoreException = new RuntimeException(throwable); + assertThatThrownBy(() -> ListenerExecutionFailedException.unwrapMessages(oneMoreException)) + .isInstanceOf(IllegalArgumentException.class); + } + + @SuppressWarnings("unchecked") + @Test + void shouldFindListenerException() { + Throwable throwable = mock(Throwable.class); + Message message = mock(Message.class); + ListenerExecutionFailedException listenerException = new ListenerExecutionFailedException("Expected error", + throwable, message); + RuntimeException oneMoreException = new RuntimeException(listenerException); + assertThat(ListenerExecutionFailedException.hasListenerException(oneMoreException)).isTrue(); + } + + @Test + void shouldNotFindListenerException() { + Throwable throwable = mock(Throwable.class); + RuntimeException oneMoreException = new RuntimeException(throwable); + assertThat(ListenerExecutionFailedException.hasListenerException(oneMoreException)).isFalse(); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/SqsMessageListenerContainerTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/SqsMessageListenerContainerTests.java new file mode 100644 index 000000000..6a3546fd3 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/SqsMessageListenerContainerTests.java @@ -0,0 +1,105 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.AsyncAcknowledgementResultCallback; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.errorhandler.ErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import io.awspring.cloud.sqs.listener.interceptor.MessageInterceptor; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class SqsMessageListenerContainerTests { + + @Test + void shouldCreateFromBuilderWithBlockingComponents() { + SqsAsyncClient client = mock(SqsAsyncClient.class); + MessageListener listener = mock(MessageListener.class); + ErrorHandler errorHandler = mock(ErrorHandler.class); + MessageInterceptor interceptor1 = mock(MessageInterceptor.class); + MessageInterceptor interceptor2 = mock(MessageInterceptor.class); + AcknowledgementResultCallback callback = mock(AcknowledgementResultCallback.class); + ContainerComponentFactory componentFactory = mock(ContainerComponentFactory.class); + List> componentFactories = Collections.singletonList(componentFactory); + List queueNames = Arrays.asList("test-queue-name-1", "test-queue-name-2"); + + SqsMessageListenerContainer container = SqsMessageListenerContainer.builder().messageListener(listener) + .sqsAsyncClient(client).errorHandler(errorHandler).componentFactories(componentFactories) + .acknowledgementResultCallback(callback).messageInterceptor(interceptor1) + .messageInterceptor(interceptor2).queueNames(queueNames).build(); + + assertThat(container.getMessageListener()) + .isInstanceOf(AsyncComponentAdapters.AbstractThreadingComponentAdapter.class) + .extracting("blockingMessageListener").isEqualTo(listener); + + assertThat(container.getErrorHandler()) + .isInstanceOf(AsyncComponentAdapters.AbstractThreadingComponentAdapter.class) + .extracting("blockingErrorHandler").isEqualTo(errorHandler); + + assertThat(container.getAcknowledgementResultCallback()) + .isInstanceOf(AsyncComponentAdapters.AbstractThreadingComponentAdapter.class) + .extracting("blockingAcknowledgementResultCallback").isEqualTo(callback); + + assertThat(container.getMessageInterceptors()).element(0) + .isInstanceOf(AsyncComponentAdapters.AbstractThreadingComponentAdapter.class) + .extracting("blockingMessageInterceptor").isEqualTo(interceptor1); + + assertThat(container.getMessageInterceptors()).element(1) + .isInstanceOf(AsyncComponentAdapters.AbstractThreadingComponentAdapter.class) + .extracting("blockingMessageInterceptor").isEqualTo(interceptor2); + + assertThat(container).extracting("sqsAsyncClient").isEqualTo(client); + + assertThat(container.getQueueNames()).containsExactlyElementsOf(queueNames); + } + + @Test + void shouldCreateFromBuilderWithAsyncComponents() { + String queueName = "test-queue-name"; + String id = "test-id"; + SqsAsyncClient client = mock(SqsAsyncClient.class); + AsyncMessageListener listener = mock(AsyncMessageListener.class); + AsyncErrorHandler errorHandler = mock(AsyncErrorHandler.class); + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncAcknowledgementResultCallback callback = mock(AsyncAcknowledgementResultCallback.class); + ContainerComponentFactory componentFactory = mock(ContainerComponentFactory.class); + List> componentFactories = Collections.singletonList(componentFactory); + SqsMessageListenerContainer container = SqsMessageListenerContainer.builder() + .asyncMessageListener(listener).sqsAsyncClient(client).errorHandler(errorHandler) + .componentFactories(componentFactories).acknowledgementResultCallback(callback) + .messageInterceptor(interceptor1).messageInterceptor(interceptor2).queueNames(queueName).id(id).build(); + + assertThat(container.getMessageListener()).isEqualTo(listener); + assertThat(container.getErrorHandler()).isEqualTo(errorHandler); + assertThat(container.getAcknowledgementResultCallback()).isEqualTo(callback); + assertThat(container.getMessageInterceptors()).containsExactly(interceptor1, interceptor2); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/StandardSqsComponentFactoryTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/StandardSqsComponentFactoryTests.java new file mode 100644 index 000000000..4ef6318c6 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/StandardSqsComponentFactoryTests.java @@ -0,0 +1,113 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementOrdering; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementProcessor; +import io.awspring.cloud.sqs.listener.acknowledgement.BatchingAcknowledgementProcessor; +import io.awspring.cloud.sqs.listener.acknowledgement.ImmediateAcknowledgementProcessor; +import io.awspring.cloud.sqs.listener.sink.BatchMessageSink; +import io.awspring.cloud.sqs.listener.sink.FanOutMessageSink; +import io.awspring.cloud.sqs.listener.sink.MessageSink; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +class StandardSqsComponentFactoryTests { + + @Test + void shouldCreateGroupingSink() { + ContainerComponentFactory componentFactory = new StandardSqsComponentFactory<>(); + MessageSink messageSink = componentFactory.createMessageSink(ContainerOptions.builder().build()); + assertThat(messageSink).isInstanceOf(FanOutMessageSink.class); + } + + @Test + void shouldCreateBatchSink() { + ContainerComponentFactory componentFactory = new StandardSqsComponentFactory<>(); + MessageSink messageSink = componentFactory + .createMessageSink(ContainerOptions.builder().listenerMode(ListenerMode.BATCH).build()); + assertThat(messageSink).isInstanceOf(BatchMessageSink.class); + } + + @Test + void shouldCreateAcknowledgementProcessorWithDefaults() { + ContainerComponentFactory componentFactory = new StandardSqsComponentFactory<>(); + ContainerOptions options = ContainerOptions.builder().build(); + AcknowledgementProcessor processor = componentFactory.createAcknowledgementProcessor(options); + assertThat(processor).isInstanceOf(BatchingAcknowledgementProcessor.class).extracting("acknowledgementOrdering") + .isEqualTo(AcknowledgementOrdering.PARALLEL); + assertThat(processor).extracting("messageGroupingFunction").isNull(); + assertThat(processor).extracting("ackThreshold").isEqualTo(10); + assertThat(processor).extracting("ackInterval").isEqualTo(Duration.ofSeconds(1)); + } + + @Test + void shouldCreateImmediateAcknowledgementProcessor() { + ContainerComponentFactory componentFactory = new StandardSqsComponentFactory<>(); + Duration acknowledgementInterval = Duration.ZERO; + int acknowledgementThreshold = 0; + ContainerOptions options = ContainerOptions.builder().acknowledgementInterval(acknowledgementInterval) + .acknowledgementThreshold(acknowledgementThreshold).build(); + AcknowledgementProcessor processor = componentFactory.createAcknowledgementProcessor(options); + assertThat(processor).isInstanceOf(ImmediateAcknowledgementProcessor.class) + .extracting("acknowledgementOrdering").isEqualTo(AcknowledgementOrdering.PARALLEL); + assertThat(processor).extracting("messageGroupingFunction").isNull(); + } + + @Test + void shouldThrowIfOrderedByGroup() { + ContainerComponentFactory componentFactory = new StandardSqsComponentFactory<>(); + ContainerOptions options = ContainerOptions.builder() + .acknowledgementOrdering(AcknowledgementOrdering.ORDERED_BY_GROUP).build(); + assertThatThrownBy(() -> componentFactory.createAcknowledgementProcessor(options)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void shouldCreateBatchingAcknowledgementProcessorOrdered() { + ContainerComponentFactory componentFactory = new StandardSqsComponentFactory<>(); + ContainerOptions options = ContainerOptions.builder().acknowledgementOrdering(AcknowledgementOrdering.ORDERED) + .build(); + AcknowledgementProcessor processor = componentFactory.createAcknowledgementProcessor(options); + assertThat(processor).isInstanceOf(BatchingAcknowledgementProcessor.class).extracting("acknowledgementOrdering") + .isEqualTo(AcknowledgementOrdering.ORDERED); + assertThat(processor).extracting("ackThreshold").isEqualTo(10); + assertThat(processor).extracting("ackInterval").isEqualTo(Duration.ofSeconds(1)); + assertThat(processor).extracting("messageGroupingFunction").isNull(); + } + + @Test + void shouldCreateImmediateAcknowledgementProcessorOrdered() { + ContainerComponentFactory componentFactory = new StandardSqsComponentFactory<>(); + Duration acknowledgementInterval = Duration.ZERO; + int acknowledgementThreshold = 0; + ContainerOptions options = ContainerOptions.builder().acknowledgementOrdering(AcknowledgementOrdering.ORDERED) + .acknowledgementInterval(acknowledgementInterval).acknowledgementThreshold(acknowledgementThreshold) + .build(); + AcknowledgementProcessor processor = componentFactory.createAcknowledgementProcessor(options); + assertThat(processor).isInstanceOf(ImmediateAcknowledgementProcessor.class) + .extracting("acknowledgementOrdering").isEqualTo(AcknowledgementOrdering.ORDERED); + assertThat(processor).extracting("messageGroupingFunction").isNull(); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/BatchingAcknowledgementProcessorTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/BatchingAcknowledgementProcessorTests.java new file mode 100644 index 000000000..3011b80da --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/BatchingAcknowledgementProcessorTests.java @@ -0,0 +1,344 @@ +/* + * 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.acknowledgement; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +import io.awspring.cloud.sqs.CompletableFutures; +import io.awspring.cloud.sqs.MessageHeaderUtils; +import io.awspring.cloud.sqs.listener.ContainerOptions; +import io.awspring.cloud.sqs.listener.SqsHeaders; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@ExtendWith(MockitoExtension.class) +class BatchingAcknowledgementProcessorTests { + + private static final Logger logger = LoggerFactory.getLogger(BatchingAcknowledgementProcessorTests.class); + + private static final Duration ACK_INTERVAL_HUNDRED_MILLIS = Duration.ofMillis(100); + + private static final Duration ACK_INTERVAL_ZERO = Duration.ZERO; + + private static final int MAX_ACKNOWLEDGEMENTS_PER_BATCH_TEN = 10; + + private static final Integer ACK_THRESHOLD_TEN = 10; + + private static final Integer ACK_THRESHOLD_ZERO = 0; + + private static final String ID = "batchingAcknowledgementProcessorTestsAckProcessor"; + + private final static UUID MESSAGE_ID = UUID.randomUUID(); + + @Mock + private AcknowledgementExecutor ackExecutor; + + @Mock + Message message; + + MessageHeaders messageHeaders = new MessageHeaders(null); + + @Test + void shouldAckAfterBatch() throws Exception { + given(message.getHeaders()).willReturn(messageHeaders); + TaskExecutor executor = new SimpleAsyncTaskExecutor(); + List> messages = IntStream.range(0, ACK_THRESHOLD_TEN).mapToObj(index -> message) + .collect(Collectors.toList()); + given(ackExecutor.execute(messages)).willReturn(CompletableFuture.completedFuture(null)); + CountDownLatch ackLatch = new CountDownLatch(1); + BatchingAcknowledgementProcessor processor = new BatchingAcknowledgementProcessor() { + @Override + protected CompletableFuture sendToExecutor(Collection> messagesToAck) { + return super.sendToExecutor(messagesToAck).thenRun(ackLatch::countDown); + } + }; + ContainerOptions options = ContainerOptions.builder().acknowledgementInterval(ACK_INTERVAL_ZERO) + .acknowledgementThreshold(ACK_THRESHOLD_TEN).acknowledgementOrdering(AcknowledgementOrdering.PARALLEL) + .build(); + processor.configure(options); + processor.setTaskExecutor(executor); + processor.setAcknowledgementExecutor(ackExecutor); + processor.setMaxAcknowledgementsPerBatch(MAX_ACKNOWLEDGEMENTS_PER_BATCH_TEN); + processor.setId(ID); + processor.start(); + processor.doOnAcknowledge(messages); + assertThat(ackLatch.await(10, TimeUnit.SECONDS)).isTrue(); + processor.stop(); + + then(ackExecutor).should().execute(messages); + } + + @Test + void shouldAckAfterTime() throws Exception { + given(message.getHeaders()).willReturn(messageHeaders); + TaskExecutor executor = new SimpleAsyncTaskExecutor(); + List> messages = IntStream.range(0, 5).mapToObj(index -> message).collect(Collectors.toList()); + given(ackExecutor.execute(messages)).willReturn(CompletableFuture.completedFuture(null)); + CountDownLatch ackLatch = new CountDownLatch(1); + BatchingAcknowledgementProcessor processor = new BatchingAcknowledgementProcessor() { + @Override + protected CompletableFuture sendToExecutor(Collection> messagesToAck) { + return super.sendToExecutor(messagesToAck).thenRun(ackLatch::countDown); + } + }; + ContainerOptions options = ContainerOptions.builder().acknowledgementInterval(ACK_INTERVAL_HUNDRED_MILLIS) + .acknowledgementThreshold(ACK_THRESHOLD_ZERO).acknowledgementOrdering(AcknowledgementOrdering.PARALLEL) + .build(); + processor.configure(options); + processor.setTaskExecutor(executor); + processor.setAcknowledgementExecutor(ackExecutor); + processor.setMaxAcknowledgementsPerBatch(MAX_ACKNOWLEDGEMENTS_PER_BATCH_TEN); + processor.setId(ID); + + processor.start(); + processor.doOnAcknowledge(messages); + assertThat(ackLatch.await(20, TimeUnit.SECONDS)).isTrue(); + processor.stop(); + then(ackExecutor).should().execute(messages); + } + + @Test + void shouldPartitionMessages() throws Exception { + given(message.getHeaders()).willReturn(messageHeaders); + TaskExecutor executor = new SimpleAsyncTaskExecutor(); + List> messages = IntStream.range(0, 15).mapToObj(index -> message).collect(Collectors.toList()); + given(ackExecutor.execute(any())).willReturn(CompletableFuture.completedFuture(null)); + CountDownLatch ackLatch = new CountDownLatch(1); + BatchingAcknowledgementProcessor processor = new BatchingAcknowledgementProcessor() { + @Override + protected CompletableFuture sendToExecutor(Collection> messagesToAck) { + return super.sendToExecutor(messagesToAck).thenRun(ackLatch::countDown); + } + }; + ContainerOptions options = ContainerOptions.builder().acknowledgementInterval(ACK_INTERVAL_HUNDRED_MILLIS) + .acknowledgementThreshold(ACK_THRESHOLD_ZERO).acknowledgementOrdering(AcknowledgementOrdering.PARALLEL) + .build(); + processor.configure(options); + processor.setTaskExecutor(executor); + processor.setAcknowledgementExecutor(ackExecutor); + processor.setMaxAcknowledgementsPerBatch(MAX_ACKNOWLEDGEMENTS_PER_BATCH_TEN); + processor.setId(ID); + processor.start(); + processor.doOnAcknowledge(messages); + assertThat(ackLatch.await(10, TimeUnit.SECONDS)).isTrue(); + processor.stop(); + then(ackExecutor).should().execute(messages.subList(0, 10)); + then(ackExecutor).should().execute(messages.subList(10, 15)); + } + + @Test + void shouldAcknowledgeInOrder() throws Exception { + TaskExecutor executor = new SimpleAsyncTaskExecutor(); + int numberOfMessages = 100; + int maxMessagesPerBatch = MAX_ACKNOWLEDGEMENTS_PER_BATCH_TEN; + List> messages = IntStream.range(0, numberOfMessages).mapToObj(this::createMessage) + .collect(Collectors.toList()); + CountDownLatch ackLatch = new CountDownLatch(10); + List>> acknowledgedMessages = Collections.synchronizedList(new ArrayList<>()); + AcknowledgementExecutor acknowledgementExecutor = messagesToAck -> { + ackLatch.countDown(); + acknowledgedMessages.add(messagesToAck); + return CompletableFuture.completedFuture(null); + }; + BatchingAcknowledgementProcessor processor = new BatchingAcknowledgementProcessor<>(); + ContainerOptions options = ContainerOptions.builder().acknowledgementInterval(ACK_INTERVAL_HUNDRED_MILLIS) + .acknowledgementThreshold(ACK_THRESHOLD_ZERO).acknowledgementOrdering(AcknowledgementOrdering.PARALLEL) + .build(); + processor.configure(options); + processor.setTaskExecutor(executor); + processor.setAcknowledgementExecutor(acknowledgementExecutor); + processor.setMaxAcknowledgementsPerBatch(MAX_ACKNOWLEDGEMENTS_PER_BATCH_TEN); + processor.setId(ID); + + processor.start(); + processor.doOnAcknowledge(messages); + assertThat(ackLatch.await(10, TimeUnit.SECONDS)).isTrue(); + processor.stop(); + + IntStream.range(0, Math.min(messages.size() / maxMessagesPerBatch, 1)) + .forEach(index -> assertThat(acknowledgedMessages.get(index)) + .containsExactlyElementsOf(messages.subList(index * maxMessagesPerBatch, + Math.min((index + 1) * maxMessagesPerBatch, messages.size())))); + } + + @Test + void shouldAcknowledgeOrderedByGroupFromManyMessageGroups() throws Exception { + testAcknowledgementOrdering(AcknowledgementOrdering.ORDERED_BY_GROUP, msg -> MessageHeaderUtils + .getHeaderAsString(msg, SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER)); + } + + @Test + void shouldAcknowledgeOrderedFromManyMessageGroups() throws Exception { + testAcknowledgementOrdering(AcknowledgementOrdering.ORDERED, null); + } + + private void testAcknowledgementOrdering(AcknowledgementOrdering acknowledgementOrdering, + Function, String> groupingFunction) throws InterruptedException { + TaskExecutor executor = new SimpleAsyncTaskExecutor(); + int numberOfMessages = 100; + int numberOfMessageGroups = 10; + int messagesPerGroup = numberOfMessages / numberOfMessageGroups; + List messageGroups = IntStream.range(0, numberOfMessageGroups) + .mapToObj(index -> UUID.randomUUID().toString()).collect(Collectors.toList()); + Map>> messages = messageGroups + .stream().map( + group -> createFifoMessages(messagesPerGroup, group)) + .collect(Collectors.toMap(msgs -> MessageHeaderUtils.getHeaderAsString(msgs.iterator().next(), + SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER), msgs -> msgs)); + CountDownLatch ackLatch = new CountDownLatch(numberOfMessages); + Map> acknowledgedMessages = new ConcurrentHashMap<>(); + AcknowledgementExecutor acknowledgementExecutor = messagesToAck -> { + logger.info("Acknowledging {} messages", messagesToAck.size()); + return CompletableFuture.runAsync(this::sleep, executor).whenComplete((v, t) -> { + logger.info("Done acknowledging {} messages", messagesToAck.size()); + messagesToAck.stream() + .collect( + groupingBy(msg -> MessageHeaderUtils.getHeaderAsString(msg, + SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER))) + .forEach((key, value) -> acknowledgedMessages + .computeIfAbsent(key, newGroup -> Collections.synchronizedList(new ArrayList<>())) + .addAll(value.stream().map(Message::getPayload).collect(toList()))); + messagesToAck.forEach(msg -> ackLatch.countDown()); + }); + }; + BatchingAcknowledgementProcessor processor = new BatchingAcknowledgementProcessor<>(); + ContainerOptions options = ContainerOptions.builder()// .acknowledgementInterval(ACK_INTERVAL_HUNDRED_MILLIS) + .acknowledgementInterval(Duration.ZERO).acknowledgementThreshold(10) + .acknowledgementOrdering(acknowledgementOrdering).build(); + if (groupingFunction != null) { + processor.setMessageGroupingFunction(groupingFunction); + } + processor.configure(options); + processor.setTaskExecutor(executor); + processor.setAcknowledgementExecutor(acknowledgementExecutor); + processor.setMaxAcknowledgementsPerBatch(MAX_ACKNOWLEDGEMENTS_PER_BATCH_TEN); + processor.setId(ID); + processor.start(); + messages.forEach( + (group, theMessages) -> CompletableFuture.runAsync(() -> processor.doOnAcknowledge(theMessages))); + logger.info("Sent all messages for acknowledgement"); + assertThat(ackLatch.await(1000, TimeUnit.SECONDS)).isTrue(); + processor.stop(); + messages.forEach((group, messagesFromGroup) -> assertThat(acknowledgedMessages.get(group)) + .containsExactlyElementsOf(messagesFromGroup.stream().map(Message::getPayload).collect(toList()))); + } + + private void sleep() { + try { + Thread.sleep(new Random().nextInt(100)); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @SuppressWarnings("unchecked") + @Test + void shouldRecoverFromErrorInOrder() throws Exception { + TaskExecutor executor = new SimpleAsyncTaskExecutor(); + List>> batches = IntStream.range(0, 10).mapToObj(this::createTenMessages) + .collect(Collectors.toList()); + + given(ackExecutor.execute(any())).willReturn(CompletableFuture.completedFuture(null)); + given(ackExecutor.execute(batches.get(3))).willReturn(CompletableFutures + .failedFuture(new RuntimeException("Expected exception from shouldRecoverFromErrorInOrder"))); + + CountDownLatch ackLatch = new CountDownLatch(1); + BatchingAcknowledgementProcessor processor = new BatchingAcknowledgementProcessor() { + @Override + protected CompletableFuture sendToExecutor(Collection> messagesToAck) { + return super.sendToExecutor(messagesToAck).whenComplete((v, t) -> ackLatch.countDown()); + } + }; + ContainerOptions options = ContainerOptions.builder().acknowledgementInterval(ACK_INTERVAL_HUNDRED_MILLIS) + .acknowledgementThreshold(ACK_THRESHOLD_ZERO).acknowledgementOrdering(AcknowledgementOrdering.ORDERED) + .build(); + processor.configure(options); + processor.setTaskExecutor(executor); + processor.setAcknowledgementExecutor(ackExecutor); + processor.setMaxAcknowledgementsPerBatch(MAX_ACKNOWLEDGEMENTS_PER_BATCH_TEN); + processor.setId(ID); + + processor.start(); + processor.doOnAcknowledge(batches.stream().flatMap(Collection::stream).collect(Collectors.toList())); + assertThat(ackLatch.await(10, TimeUnit.SECONDS)).isTrue(); + processor.stop(); + + ArgumentCaptor>> messagesCaptor = ArgumentCaptor.forClass(Collection.class); + + then(ackExecutor).should(times(10)).execute(messagesCaptor.capture()); + + List>> executedBatches = messagesCaptor.getAllValues(); + + IntStream.range(0, 10) + .forEach(index -> assertThat(executedBatches.get(index)).containsExactlyElementsOf(batches.get(index))); + } + + private Message createMessage(int index) { + return MessageBuilder.withPayload(String.valueOf(index)).build(); + } + + private List> createTenMessages(int index) { + return IntStream.range(0, 10).mapToObj(subIndex -> createMessage((index * 10) + subIndex)) + .collect(Collectors.toList()); + } + + private Message createFifoMessage(int index, String messageGroup) { + return MessageBuilder.withPayload(String.valueOf(index)) + .setHeader(SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER, messageGroup).build(); + } + + private List> createFifoMessages(int numberOfMessages, String messageGroup) { + return IntStream.range(0, numberOfMessages).mapToObj(subIndex -> createFifoMessage(subIndex, messageGroup)) + .collect(Collectors.toList()); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/ImmediateAcknowledgementProcessorTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/ImmediateAcknowledgementProcessorTests.java new file mode 100644 index 000000000..7f3840d4d --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/ImmediateAcknowledgementProcessorTests.java @@ -0,0 +1,197 @@ +/* + * 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.acknowledgement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import io.awspring.cloud.sqs.CompletableFutures; +import io.awspring.cloud.sqs.MessageHeaderUtils; +import io.awspring.cloud.sqs.listener.ContainerOptions; +import io.awspring.cloud.sqs.listener.SqsHeaders; +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class ImmediateAcknowledgementProcessorTests { + + @Test + void shouldAcknowledge() throws Exception { + AcknowledgementExecutor executor = mock(AcknowledgementExecutor.class); + Message message = mock(Message.class); + MessageHeaders headers = new MessageHeaders(null); + given(message.getHeaders()).willReturn(headers); + CompletableFuture result = CompletableFuture.completedFuture(null); + given(executor.execute(Collections.singletonList(message))).willReturn(result); + CountDownLatch countDownLatch = new CountDownLatch(1); + ImmediateAcknowledgementProcessor processor = new ImmediateAcknowledgementProcessor() { + @Override + protected CompletableFuture sendToExecutor(Collection> messagesToAck) { + return super.sendToExecutor(messagesToAck).thenRun(countDownLatch::countDown); + } + }; + processor.setMaxAcknowledgementsPerBatch(10); + processor.setId("id"); + processor.setAcknowledgementExecutor(executor); + processor.configure( + ContainerOptions.builder().acknowledgementOrdering(AcknowledgementOrdering.PARALLEL).build()); + processor.start(); + processor.doOnAcknowledge(message); + assertThat(countDownLatch.await(10, TimeUnit.SECONDS)).isTrue(); + processor.stop(); + } + + @Test + void shouldPropagateErrorForOrdered() { + testErrorPropagation(AcknowledgementOrdering.ORDERED); + } + + @Test + void shouldPropagateErrorForOrderedByGroup() { + testErrorPropagation(AcknowledgementOrdering.ORDERED_BY_GROUP); + } + + private void testErrorPropagation(AcknowledgementOrdering ordering) { + AcknowledgementExecutor executor = mock(AcknowledgementExecutor.class); + Message message = mock(Message.class); + MessageHeaders messageHeaders = new MessageHeaders(Collections.singletonMap( + SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER, UUID.randomUUID().toString())); + given(message.getHeaders()).willReturn(messageHeaders); + RuntimeException exception = new RuntimeException("Expected exception from shouldPropagateErrorForOrdered"); + given(executor.execute(Collections.singletonList(message))) + .willReturn(CompletableFutures.failedFuture(exception)); + ImmediateAcknowledgementProcessor processor = new ImmediateAcknowledgementProcessor<>(); + processor.setMaxAcknowledgementsPerBatch(10); + processor.setId("id"); + processor.setAcknowledgementExecutor(executor); + processor.configure(ContainerOptions.builder().acknowledgementOrdering(ordering).build()); + if (AcknowledgementOrdering.ORDERED_BY_GROUP.equals(ordering)) { + processor.setMessageGroupingFunction(msg -> MessageHeaderUtils.getHeaderAsString(msg, + SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER)); + } + processor.start(); + CompletableFuture ackResult = processor.doOnAcknowledge(message); + processor.stop(); + assertThatThrownBy(ackResult::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isEqualTo(exception); + } + + @Test + void shouldExecuteCallbackSuccessfully() throws Exception { + AcknowledgementExecutor executor = mock(AcknowledgementExecutor.class); + Message message = mock(Message.class); + MessageHeaders messageHeaders = new MessageHeaders(null); + given(message.getHeaders()).willReturn(messageHeaders); + CompletableFuture result = CompletableFuture.completedFuture(null); + given(executor.execute(Collections.singletonList(message))).willReturn(result); + CountDownLatch countDownLatch = new CountDownLatch(1); + ImmediateAcknowledgementProcessor processor = new ImmediateAcknowledgementProcessor<>(); + processor.setMaxAcknowledgementsPerBatch(10); + processor.setId("id"); + processor.setAcknowledgementExecutor(executor); + processor.setAcknowledgementResultCallback(new AsyncAcknowledgementResultCallback() { + @Override + public CompletableFuture onSuccess(Collection> messages) { + countDownLatch.countDown(); + return CompletableFuture.completedFuture(null); + } + }); + processor.configure( + ContainerOptions.builder().acknowledgementOrdering(AcknowledgementOrdering.PARALLEL).build()); + processor.start(); + processor.doOnAcknowledge(message); + assertThat(countDownLatch.await(10, TimeUnit.SECONDS)).isTrue(); + processor.stop(); + } + + @Test + void shouldExecuteCallbackOnError() throws Exception { + AcknowledgementExecutor executor = mock(AcknowledgementExecutor.class); + Message message = mock(Message.class); + MessageHeaders messageHeaders = new MessageHeaders(null); + given(message.getHeaders()).willReturn(messageHeaders); + RuntimeException acknowledgementException = new RuntimeException( + "Expected exception from shouldExecuteCallbackOnError"); + CompletableFuture result = CompletableFutures.failedFuture(acknowledgementException); + given(executor.execute(Collections.singletonList(message))).willReturn(result); + CountDownLatch countDownLatch = new CountDownLatch(1); + ImmediateAcknowledgementProcessor processor = new ImmediateAcknowledgementProcessor<>(); + processor.setMaxAcknowledgementsPerBatch(10); + processor.setId("id"); + processor.setAcknowledgementExecutor(executor); + processor.setAcknowledgementResultCallback(new AsyncAcknowledgementResultCallback() { + @Override + public CompletableFuture onFailure(Collection> messages, Throwable t) { + countDownLatch.countDown(); + return CompletableFuture.completedFuture(null); + } + }); + processor.configure( + ContainerOptions.builder().acknowledgementOrdering(AcknowledgementOrdering.PARALLEL).build()); + processor.start(); + processor.doOnAcknowledge(message); + assertThat(countDownLatch.await(10, TimeUnit.SECONDS)).isTrue(); + processor.stop(); + } + + @Test + void shouldPropagateExecuteCallbackException() throws Exception { + AcknowledgementExecutor executor = mock(AcknowledgementExecutor.class); + Message message = mock(Message.class); + MessageHeaders messageHeaders = new MessageHeaders(null); + CompletableFuture result = CompletableFuture.completedFuture(null); + given(message.getHeaders()).willReturn(messageHeaders); + given(executor.execute(Collections.singletonList(message))).willReturn(result); + RuntimeException exception = new RuntimeException( + "Expected exception from shouldPropagateExecuteCallbackException"); + CountDownLatch countDownLatch = new CountDownLatch(1); + ImmediateAcknowledgementProcessor processor = new ImmediateAcknowledgementProcessor<>(); + processor.setMaxAcknowledgementsPerBatch(10); + processor.setId("id"); + processor.setAcknowledgementExecutor(executor); + processor.setAcknowledgementResultCallback(new AsyncAcknowledgementResultCallback() { + @Override + public CompletableFuture onSuccess(Collection> messages) { + countDownLatch.countDown(); + return CompletableFutures.failedFuture(exception); + } + }); + processor.configure( + ContainerOptions.builder().acknowledgementOrdering(AcknowledgementOrdering.PARALLEL).build()); + processor.start(); + CompletableFuture resultFuture = processor.doOnAcknowledge(message); + assertThat(countDownLatch.await(10, TimeUnit.SECONDS)).isTrue(); + processor.stop(); + assertThatThrownBy(resultFuture::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(AcknowledgementResultCallbackException.class).extracting(Throwable::getCause) + .isInstanceOf(CompletionException.class).extracting(Throwable::getCause).isEqualTo(exception); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/SqsAcknowledgementExecutorTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/SqsAcknowledgementExecutorTests.java new file mode 100644 index 000000000..ebb239e82 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/SqsAcknowledgementExecutorTests.java @@ -0,0 +1,128 @@ +/* + * 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.acknowledgement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import io.awspring.cloud.sqs.CompletableFutures; +import io.awspring.cloud.sqs.listener.QueueAttributes; +import io.awspring.cloud.sqs.listener.SqsHeaders; +import java.util.Collection; +import java.util.Collections; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.model.DeleteMessageBatchRequest; +import software.amazon.awssdk.services.sqs.model.DeleteMessageBatchRequestEntry; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@ExtendWith(MockitoExtension.class) +class SqsAcknowledgementExecutorTests { + + @Mock + SqsAsyncClient sqsAsyncClient; + + @Mock + QueueAttributes queueAttributes; + + @Mock + Message message; + + String queueName = "sqsAcknowledgementExecutorTestsQueueName"; + + String queueUrl = "sqsAcknowledgementExecutorTestsQueueUrl"; + + String receiptHandle = "sqsAcknowledgementExecutorTestsQueueReceiptHandle"; + + MessageHeaders messageHeaders = new MessageHeaders( + Collections.singletonMap(SqsHeaders.SQS_RECEIPT_HANDLE_HEADER, receiptHandle)); + + @Test + void shouldDeleteMessages() throws Exception { + Collection> messages = Collections.singletonList(message); + given(message.getHeaders()).willReturn(messageHeaders); + given(queueAttributes.getQueueName()).willReturn(queueName); + given(queueAttributes.getQueueUrl()).willReturn(queueUrl); + given(sqsAsyncClient.deleteMessageBatch(any(DeleteMessageBatchRequest.class))) + .willReturn(CompletableFuture.completedFuture(null)); + + SqsAcknowledgementExecutor executor = new SqsAcknowledgementExecutor<>(); + executor.setSqsAsyncClient(sqsAsyncClient); + executor.setQueueAttributes(queueAttributes); + executor.execute(messages).get(); + + ArgumentCaptor requestCaptor = ArgumentCaptor + .forClass(DeleteMessageBatchRequest.class); + verify(sqsAsyncClient).deleteMessageBatch(requestCaptor.capture()); + DeleteMessageBatchRequest request = requestCaptor.getValue(); + assertThat(request.queueUrl()).isEqualTo(queueUrl); + DeleteMessageBatchRequestEntry entry = request.entries().get(0); + assertThat(entry.receiptHandle()).isEqualTo(receiptHandle); + } + + @Test + void shouldWrapDeletionErrors() { + IllegalArgumentException exception = new IllegalArgumentException( + "Expected exception from shouldWrapDeletionErrors"); + Collection> messages = Collections.singletonList(message); + given(message.getHeaders()).willReturn(messageHeaders); + given(queueAttributes.getQueueName()).willReturn(queueName); + given(queueAttributes.getQueueUrl()).willReturn(queueUrl); + given(sqsAsyncClient.deleteMessageBatch(any(DeleteMessageBatchRequest.class))) + .willReturn(CompletableFutures.failedFuture(exception)); + SqsAcknowledgementExecutor executor = new SqsAcknowledgementExecutor<>(); + executor.setSqsAsyncClient(sqsAsyncClient); + executor.setQueueAttributes(queueAttributes); + assertThatThrownBy(() -> executor.execute(messages).join()).isInstanceOf(CompletionException.class).getCause() + .isInstanceOf(SqsAcknowledgementException.class).asInstanceOf(type(SqsAcknowledgementException.class)) + .extracting(SqsAcknowledgementException::getFailedAcknowledgementMessages).asList() + .containsExactly(message); + } + + @Test + void shouldWrapIfErrorIsThrown() { + IllegalArgumentException exception = new IllegalArgumentException( + "Expected exception from shouldWrapIfErrorIsThrown"); + Collection> messages = Collections.singletonList(message); + given(message.getHeaders()).willReturn(messageHeaders); + given(queueAttributes.getQueueName()).willReturn(queueName); + given(queueAttributes.getQueueUrl()).willReturn(queueUrl); + given(sqsAsyncClient.deleteMessageBatch(any(DeleteMessageBatchRequest.class))).willThrow(exception); + + SqsAcknowledgementExecutor executor = new SqsAcknowledgementExecutor<>(); + executor.setSqsAsyncClient(sqsAsyncClient); + executor.setQueueAttributes(queueAttributes); + assertThatThrownBy(() -> executor.execute(messages).join()).isInstanceOf(CompletionException.class).getCause() + .isInstanceOf(SqsAcknowledgementException.class).asInstanceOf(type(SqsAcknowledgementException.class)) + .extracting(SqsAcknowledgementException::getQueueUrl).isEqualTo(queueUrl); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/AbstractAcknowledgementHandlerTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/AbstractAcknowledgementHandlerTests.java new file mode 100644 index 000000000..2ec7a2958 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/AbstractAcknowledgementHandlerTests.java @@ -0,0 +1,59 @@ +/* + * 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.acknowledgement.handler; + +import static org.mockito.BDDMockito.given; + +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback; +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@ExtendWith(MockitoExtension.class) +public class AbstractAcknowledgementHandlerTests { + + @Mock + protected Message message; + + protected Collection> messages; + + @Mock + protected AcknowledgementCallback callback; + + protected MessageHeaders headers = new MessageHeaders(null); + + protected UUID id = UUID.randomUUID(); + + @Mock + protected Throwable throwable; + + @BeforeEach + void beforeEach() { + given(message.getHeaders()).willReturn(headers); + messages = Collections.singletonList(message); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/AlwaysAcknowledgementHandlerTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/AlwaysAcknowledgementHandlerTests.java new file mode 100644 index 000000000..90264c9bf --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/AlwaysAcknowledgementHandlerTests.java @@ -0,0 +1,57 @@ +/* + * 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.acknowledgement.handler; + +import static org.mockito.Mockito.verify; + +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +class AlwaysAcknowledgementHandlerTests extends AbstractAcknowledgementHandlerTests { + + @Test + void shouldAckOnSuccess() { + AcknowledgementHandler handler = new AlwaysAcknowledgementHandler<>(); + CompletableFuture result = handler.onSuccess(message, callback); + verify(callback).onAcknowledge(message); + } + + @Test + void shouldAckOnError() { + AcknowledgementHandler handler = new AlwaysAcknowledgementHandler<>(); + CompletableFuture result = handler.onError(messages, throwable, callback); + verify(callback).onAcknowledge(messages); + } + + @Test + void shouldAckOnSuccessBatch() { + AcknowledgementHandler handler = new AlwaysAcknowledgementHandler<>(); + CompletableFuture result = handler.onSuccess(messages, callback); + verify(callback).onAcknowledge(messages); + } + + @Test + void shouldAckOnErrorBatch() { + AcknowledgementHandler handler = new AlwaysAcknowledgementHandler<>(); + CompletableFuture result = handler.onError(messages, throwable, callback); + verify(callback).onAcknowledge(messages); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/NeverAcknowledgementHandlerTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/NeverAcknowledgementHandlerTests.java new file mode 100644 index 000000000..fd072de9b --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/NeverAcknowledgementHandlerTests.java @@ -0,0 +1,58 @@ +/* + * 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.acknowledgement.handler; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +class NeverAcknowledgementHandlerTests extends AbstractAcknowledgementHandlerTests { + + @Test + void shouldNotAckOnSuccess() { + AcknowledgementHandler handler = new NeverAcknowledgementHandler<>(); + CompletableFuture result = handler.onSuccess(message, callback); + verify(callback, never()).onAcknowledge(message); + } + + @Test + void shouldNotAckOnError() { + AcknowledgementHandler handler = new NeverAcknowledgementHandler<>(); + CompletableFuture result = handler.onError(messages, throwable, callback); + verify(callback, never()).onAcknowledge(messages); + } + + @Test + void shouldNotAckOnSuccessBatch() { + AcknowledgementHandler handler = new NeverAcknowledgementHandler<>(); + CompletableFuture result = handler.onSuccess(messages, callback); + verify(callback, never()).onAcknowledge(messages); + } + + @Test + void shouldNotAckOnErrorBatch() { + AcknowledgementHandler handler = new NeverAcknowledgementHandler<>(); + CompletableFuture result = handler.onError(messages, throwable, callback); + verify(callback, never()).onAcknowledge(messages); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/OnSuccessAcknowledgementHandlerTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/OnSuccessAcknowledgementHandlerTests.java new file mode 100644 index 000000000..1543ae511 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/acknowledgement/handler/OnSuccessAcknowledgementHandlerTests.java @@ -0,0 +1,58 @@ +/* + * 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.acknowledgement.handler; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +class OnSuccessAcknowledgementHandlerTests extends AbstractAcknowledgementHandlerTests { + + @Test + void shouldAckOnSuccess() { + AcknowledgementHandler handler = new OnSuccessAcknowledgementHandler<>(); + CompletableFuture result = handler.onSuccess(message, callback); + verify(callback).onAcknowledge(message); + } + + @Test + void shouldNotAckOnError() { + AcknowledgementHandler handler = new OnSuccessAcknowledgementHandler<>(); + CompletableFuture result = handler.onError(messages, throwable, callback); + verify(callback, never()).onAcknowledge(messages); + } + + @Test + void shouldAckOnSuccessBatch() { + AcknowledgementHandler handler = new OnSuccessAcknowledgementHandler<>(); + CompletableFuture result = handler.onSuccess(messages, callback); + verify(callback).onAcknowledge(messages); + } + + @Test + void shouldNotAckOnErrorBatch() { + AcknowledgementHandler handler = new OnSuccessAcknowledgementHandler<>(); + CompletableFuture result = handler.onError(messages, throwable, callback); + verify(callback, never()).onAcknowledge(messages); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/adapter/AbstractMethodInvokingListenerAdapterTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/adapter/AbstractMethodInvokingListenerAdapterTests.java new file mode 100644 index 000000000..a8b221019 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/adapter/AbstractMethodInvokingListenerAdapterTests.java @@ -0,0 +1,116 @@ +/* + * 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.adapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.list; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.awspring.cloud.sqs.listener.ListenerExecutionFailedException; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class AbstractMethodInvokingListenerAdapterTests { + + @Test + void shouldInvokeHandler() throws Exception { + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + Message message = mock(Message.class); + Object expectedResult = new Object(); + when(handlerMethod.invoke(message)).thenReturn(expectedResult); + AbstractMethodInvokingListenerAdapter adapter = new AbstractMethodInvokingListenerAdapter( + handlerMethod) { + }; + Object actualResult = adapter.invokeHandler(message); + assertThat(actualResult).isEqualTo(expectedResult); + } + + @Test + void shouldWrapError() throws Exception { + MessageHeaders headers = new MessageHeaders(null); + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + Message message = mock(Message.class); + given(message.getHeaders()).willReturn(headers); + RuntimeException exception = new RuntimeException("Expected exception from shouldWrapError"); + when(handlerMethod.invoke(message)).thenThrow(exception); + AbstractMethodInvokingListenerAdapter adapter = new AbstractMethodInvokingListenerAdapter( + handlerMethod) { + }; + assertThatThrownBy(() -> adapter.invokeHandler(message)).isInstanceOf(ListenerExecutionFailedException.class) + .asInstanceOf(type(ListenerExecutionFailedException.class)) + .extracting(ListenerExecutionFailedException::getFailedMessage).isEqualTo(message); + } + + @Test + void shouldInvokeHandlerBatch() throws Exception { + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> messages = Arrays.asList(message1, message2, message3); + Object expectedResult = new Object(); + + when(handlerMethod.invoke(any(Message.class))).thenReturn(expectedResult); + AbstractMethodInvokingListenerAdapter adapter = new AbstractMethodInvokingListenerAdapter( + handlerMethod) { + }; + Object actualResult = adapter.invokeHandler(messages); + ArgumentCaptor> messageCaptor = ArgumentCaptor.forClass(Message.class); + verify(handlerMethod).invoke(messageCaptor.capture()); + Message messageValue = messageCaptor.getValue(); + assertThat(messageValue).extracting(Message::getPayload).asInstanceOf(list(Message.class)) + .containsExactlyElementsOf(messages); + assertThat(actualResult).isEqualTo(expectedResult); + } + + @Test + void shouldWrapErrorBatch() throws Exception { + MessageHeaders messageHeaders = new MessageHeaders(null); + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> batch = Arrays.asList(message1, message2, message3); + given(message1.getHeaders()).willReturn(messageHeaders); + given(message2.getHeaders()).willReturn(messageHeaders); + given(message3.getHeaders()).willReturn(messageHeaders); + RuntimeException exception = new RuntimeException("Expected exception from shouldWrapErrorBatch"); + when(handlerMethod.invoke(any(Message.class))).thenThrow(exception); + AbstractMethodInvokingListenerAdapter adapter = new AbstractMethodInvokingListenerAdapter( + handlerMethod) { + }; + assertThatThrownBy(() -> adapter.invokeHandler(batch)).isInstanceOf(ListenerExecutionFailedException.class) + .asInstanceOf(type(ListenerExecutionFailedException.class)) + .extracting(ListenerExecutionFailedException::getFailedMessages).isEqualTo(batch); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/adapter/AsyncMessagingMessageListenerAdapterTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/adapter/AsyncMessagingMessageListenerAdapterTests.java new file mode 100644 index 000000000..2de2b2a78 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/adapter/AsyncMessagingMessageListenerAdapterTests.java @@ -0,0 +1,194 @@ +/* + * 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.adapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.list; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import io.awspring.cloud.sqs.CompletableFutures; +import io.awspring.cloud.sqs.listener.AsyncMessageListener; +import io.awspring.cloud.sqs.listener.ListenerExecutionFailedException; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class AsyncMessagingMessageListenerAdapterTests { + + @Test + void shouldInvokeMessage() throws Exception { + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + Message message = mock(Message.class); + CompletableFuture expectedResult = CompletableFuture.completedFuture(null); + given(handlerMethod.invoke(message)).willReturn(expectedResult); + AsyncMessageListener adapter = new AsyncMessagingMessageListenerAdapter<>(handlerMethod); + CompletableFuture result = adapter.onMessage(message); + verify(handlerMethod).invoke(message); + assertThat(result).isNotCompletedExceptionally(); + } + + @Test + void shouldReturnFailedFutureOnThrownError() throws Exception { + MessageHeaders headers = new MessageHeaders(null); + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + Message message = mock(Message.class); + RuntimeException exception = new RuntimeException( + "Expected exception from shouldReturnFailedFutureOnThrownError"); + given(message.getHeaders()).willReturn(headers); + given(handlerMethod.invoke(message)).willThrow(exception); + AsyncMessageListener adapter = new AsyncMessagingMessageListenerAdapter<>(handlerMethod); + CompletableFuture result = adapter.onMessage(message); + assertThat(result).isCompletedExceptionally(); + assertThatThrownBy(result::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(ListenerExecutionFailedException.class) + .asInstanceOf(type(ListenerExecutionFailedException.class)) + .extracting(ListenerExecutionFailedException::getFailedMessage).isEqualTo(message); + } + + @Test + void shouldWrapCompletionError() throws Exception { + MessageHeaders headers = new MessageHeaders(null); + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + Message message = mock(Message.class); + RuntimeException exception = new RuntimeException("Expected exception from shouldWrapCompletionError"); + given(message.getHeaders()).willReturn(headers); + given(handlerMethod.invoke(message)).willReturn(CompletableFutures.failedFuture(exception)); + AsyncMessageListener adapter = new AsyncMessagingMessageListenerAdapter<>(handlerMethod); + CompletableFuture result = adapter.onMessage(message); + assertThat(result).isCompletedExceptionally(); + assertThatThrownBy(result::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(ListenerExecutionFailedException.class) + .asInstanceOf(type(ListenerExecutionFailedException.class)) + .extracting(ListenerExecutionFailedException::getFailedMessage).isEqualTo(message); + } + + @Test + void shouldHandleClassCastException() throws Exception { + MessageHeaders headers = new MessageHeaders(null); + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + Message message = mock(Message.class); + given(message.getHeaders()).willReturn(headers); + Object invocationResult = new Object(); + given(handlerMethod.invoke(message)).willReturn(invocationResult); + AsyncMessageListener adapter = new AsyncMessagingMessageListenerAdapter<>(handlerMethod); + CompletableFuture result = adapter.onMessage(message); + assertThat(result).isCompletedExceptionally(); + assertThatThrownBy(result::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(IllegalArgumentException.class).extracting(Throwable::getCause) + .isInstanceOf(ClassCastException.class); + } + + @Test + void shouldInvokeMessageBatch() throws Exception { + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> messages = Arrays.asList(message1, message2, message3); + CompletableFuture expectedResult = CompletableFuture.completedFuture(null); + given(handlerMethod.invoke(any(Message.class))).willReturn(expectedResult); + AsyncMessageListener adapter = new AsyncMessagingMessageListenerAdapter<>(handlerMethod); + CompletableFuture result = adapter.onMessage(messages); + ArgumentCaptor> captor = ArgumentCaptor.forClass(Message.class); + verify(handlerMethod).invoke(captor.capture()); + assertThat(captor.getValue().getPayload()).asInstanceOf(list(Message.class)) + .containsExactlyElementsOf(messages); + assertThat(result).isNotCompletedExceptionally(); + } + + @Test + void shouldReturnFailedFutureOnErrorBatch() throws Exception { + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> messages = Arrays.asList(message1, message2, message3); + MessageHeaders headers = new MessageHeaders(null); + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + RuntimeException exception = new RuntimeException( + "Expected exception from shouldReturnFailedFutureOnErrorBatch"); + given(message1.getHeaders()).willReturn(headers); + given(message2.getHeaders()).willReturn(headers); + given(message3.getHeaders()).willReturn(headers); + given(handlerMethod.invoke(any(Message.class))).willThrow(exception); + AsyncMessageListener adapter = new AsyncMessagingMessageListenerAdapter<>(handlerMethod); + CompletableFuture result = adapter.onMessage(messages); + assertThat(result).isCompletedExceptionally(); + assertThatThrownBy(result::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(ListenerExecutionFailedException.class) + .asInstanceOf(type(ListenerExecutionFailedException.class)) + .extracting(ListenerExecutionFailedException::getFailedMessages).isEqualTo(messages); + } + + @Test + void shouldWrapCompletionErrorBatch() throws Exception { + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> messages = Arrays.asList(message1, message2, message3); + MessageHeaders headers = new MessageHeaders(null); + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + RuntimeException exception = new RuntimeException("Expected exception from shouldWrapCompletionErrorBatch"); + given(message1.getHeaders()).willReturn(headers); + given(message2.getHeaders()).willReturn(headers); + given(message3.getHeaders()).willReturn(headers); + given(handlerMethod.invoke(any(Message.class))).willReturn(CompletableFutures.failedFuture(exception)); + AsyncMessageListener adapter = new AsyncMessagingMessageListenerAdapter<>(handlerMethod); + CompletableFuture result = adapter.onMessage(messages); + assertThat(result).isCompletedExceptionally(); + assertThatThrownBy(result::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(ListenerExecutionFailedException.class) + .asInstanceOf(type(ListenerExecutionFailedException.class)) + .extracting(ListenerExecutionFailedException::getFailedMessages).isEqualTo(messages); + } + + @Test + void shouldHandleClassCastExceptionBatch() throws Exception { + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> messages = Arrays.asList(message1, message2, message3); + MessageHeaders headers = new MessageHeaders(null); + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + given(message1.getHeaders()).willReturn(headers); + given(message2.getHeaders()).willReturn(headers); + given(message3.getHeaders()).willReturn(headers); + Object invocationResult = new Object(); + given(handlerMethod.invoke(any(Message.class))).willReturn(invocationResult); + AsyncMessageListener adapter = new AsyncMessagingMessageListenerAdapter<>(handlerMethod); + CompletableFuture result = adapter.onMessage(messages); + assertThat(result).isCompletedExceptionally(); + assertThatThrownBy(result::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(IllegalArgumentException.class).extracting(Throwable::getCause) + .isInstanceOf(ClassCastException.class); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/adapter/MessagingMessageListenerAdapterTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/adapter/MessagingMessageListenerAdapterTests.java new file mode 100644 index 000000000..c422081e4 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/adapter/MessagingMessageListenerAdapterTests.java @@ -0,0 +1,110 @@ +/* + * 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.adapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.list; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.awspring.cloud.sqs.listener.ListenerExecutionFailedException; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class MessagingMessageListenerAdapterTests { + + @Test + void shouldInvokeHandler() throws Exception { + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + Message message = mock(Message.class); + Object expectedResult = new Object(); + when(handlerMethod.invoke(message)).thenReturn(expectedResult); + MessagingMessageListenerAdapter adapter = new MessagingMessageListenerAdapter<>(handlerMethod); + Object actualResult = adapter.invokeHandler(message); + assertThat(actualResult).isEqualTo(expectedResult); + } + + @Test + void shouldWrapError() throws Exception { + MessageHeaders headers = new MessageHeaders(null); + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + Message message = mock(Message.class); + given(message.getHeaders()).willReturn(headers); + RuntimeException exception = new RuntimeException( + "Expected exception from MessagingMessageListenerAdapterTests#shouldWrapError"); + when(handlerMethod.invoke(message)).thenThrow(exception); + MessagingMessageListenerAdapter adapter = new MessagingMessageListenerAdapter<>(handlerMethod); + assertThatThrownBy(() -> adapter.invokeHandler(message)).isInstanceOf(ListenerExecutionFailedException.class) + .asInstanceOf(type(ListenerExecutionFailedException.class)) + .extracting(ListenerExecutionFailedException::getFailedMessage).isEqualTo(message); + } + + @Test + void shouldInvokeHandlerBatch() throws Exception { + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> messages = Arrays.asList(message1, message2, message3); + Object expectedResult = new Object(); + + when(handlerMethod.invoke(any(Message.class))).thenReturn(expectedResult); + MessagingMessageListenerAdapter adapter = new MessagingMessageListenerAdapter<>(handlerMethod); + Object actualResult = adapter.invokeHandler(messages); + ArgumentCaptor> messageCaptor = ArgumentCaptor.forClass(Message.class); + verify(handlerMethod).invoke(messageCaptor.capture()); + Message messageValue = messageCaptor.getValue(); + assertThat(messageValue).extracting(Message::getPayload).asInstanceOf(list(Message.class)) + .containsExactlyElementsOf(messages); + assertThat(actualResult).isEqualTo(expectedResult); + } + + @Test + void shouldWrapErrorBatch() throws Exception { + MessageHeaders messageHeaders = new MessageHeaders(null); + InvocableHandlerMethod handlerMethod = mock(InvocableHandlerMethod.class); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> batch = Arrays.asList(message1, message2, message3); + given(message1.getHeaders()).willReturn(messageHeaders); + given(message2.getHeaders()).willReturn(messageHeaders); + given(message3.getHeaders()).willReturn(messageHeaders); + RuntimeException exception = new RuntimeException( + "Expected exception from MessagingMessageListenerAdapterTests#shouldWrapErrorBatch"); + when(handlerMethod.invoke(any(Message.class))).thenThrow(exception); + MessagingMessageListenerAdapter adapter = new MessagingMessageListenerAdapter<>(handlerMethod); + assertThatThrownBy(() -> adapter.invokeHandler(batch)).isInstanceOf(ListenerExecutionFailedException.class) + .asInstanceOf(type(ListenerExecutionFailedException.class)) + .extracting(ListenerExecutionFailedException::getFailedMessages).isEqualTo(batch); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/errorhandler/AsyncErrorHandlerTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/errorhandler/AsyncErrorHandlerTests.java new file mode 100644 index 000000000..d30669c10 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/errorhandler/AsyncErrorHandlerTests.java @@ -0,0 +1,63 @@ +/* + * 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.errorhandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class AsyncErrorHandlerTests { + + @Test + void shouldReturnError() { + Message message = mock(Message.class); + RuntimeException exception = new RuntimeException("Expected exception from shouldReturnError"); + AsyncErrorHandler handler = new AsyncErrorHandler() { + }; + CompletableFuture future = handler.handle(message, exception); + assertThat(future).isCompletedExceptionally(); + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void shouldReturnErrorBatch() { + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> batch = Arrays.asList(message1, message2, message3); + RuntimeException exception = new RuntimeException("Expected exception from shouldReturnErrorBatch"); + AsyncErrorHandler handler = new AsyncErrorHandler() { + }; + CompletableFuture future = handler.handle(batch, exception); + assertThat(future).isCompletedExceptionally(); + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(UnsupportedOperationException.class); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/errorhandler/ErrorHandlerTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/errorhandler/ErrorHandlerTests.java new file mode 100644 index 000000000..1d7589a0d --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/errorhandler/ErrorHandlerTests.java @@ -0,0 +1,54 @@ +/* + * 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.errorhandler; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class ErrorHandlerTests { + + @Test + void shouldReturnError() { + Message message = mock(Message.class); + RuntimeException exception = new RuntimeException("Expected exception from shouldReturnError"); + ErrorHandler handler = new ErrorHandler() { + }; + assertThatThrownBy(() -> handler.handle(message, exception)).isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void shouldReturnErrorBatch() { + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> batch = Arrays.asList(message1, message2, message3); + RuntimeException exception = new RuntimeException("Expected exception from shouldReturnErrorBatch"); + ErrorHandler handler = new ErrorHandler() { + }; + assertThatThrownBy(() -> handler.handle(batch, exception)).isInstanceOf(UnsupportedOperationException.class); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/interceptor/AsyncMessageInterceptorTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/interceptor/AsyncMessageInterceptorTests.java new file mode 100644 index 000000000..488c47246 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/interceptor/AsyncMessageInterceptorTests.java @@ -0,0 +1,56 @@ +/* + * 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.interceptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class AsyncMessageInterceptorTests { + + @Test + void shouldReturnMessage() { + Message message = mock(Message.class); + AsyncMessageInterceptor interceptor = new AsyncMessageInterceptor() { + }; + CompletableFuture> future = interceptor.intercept(message); + assertThat(future).isCompletedWithValue(message); + } + + @Test + void shouldReturnMessageBatch() { + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> batch = Arrays.asList(message1, message2, message3); + AsyncMessageInterceptor interceptor = new AsyncMessageInterceptor() { + }; + CompletableFuture>> future = interceptor.intercept(batch); + assertThat(future).isCompletedWithValue(batch); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/interceptor/MessageInterceptorTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/interceptor/MessageInterceptorTests.java new file mode 100644 index 000000000..55087e4fa --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/interceptor/MessageInterceptorTests.java @@ -0,0 +1,55 @@ +/* + * 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.interceptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class MessageInterceptorTests { + + @Test + void shouldReturnMessage() { + MessageInterceptor interceptor = new MessageInterceptor() { + }; + Message message = mock(Message.class); + Message interceptResult = interceptor.intercept(message); + assertThat(interceptResult).isEqualTo(message); + } + + @Test + void shouldReturnMessageBatch() { + MessageInterceptor interceptor = new MessageInterceptor() { + }; + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> batch = Arrays.asList(message1, message2, message3); + Collection> interceptorResult = interceptor.intercept(batch); + assertThat(interceptorResult).isEqualTo(batch); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/AcknowledgementHandlerExecutionStageTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/AcknowledgementHandlerExecutionStageTests.java new file mode 100644 index 000000000..09a0c058e --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/AcknowledgementHandlerExecutionStageTests.java @@ -0,0 +1,145 @@ +/* + * 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.pipeline; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.awspring.cloud.sqs.CompletableFutures; +import io.awspring.cloud.sqs.listener.AsyncMessageListener; +import io.awspring.cloud.sqs.listener.ListenerExecutionFailedException; +import io.awspring.cloud.sqs.listener.MessageProcessingContext; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementHandler; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +class AcknowledgementHandlerExecutionStageTests { + + @SuppressWarnings("unchecked") + @Test + void shouldAckOnSuccess() { + AcknowledgementHandler handler = mock(AcknowledgementHandler.class); + MessageProcessingConfiguration configuration = createConfiguration(handler); + Message message = mock(Message.class); + CompletableFuture> future = CompletableFuture.completedFuture(message); + AcknowledgementCallback callback = mock(AcknowledgementCallback.class); + MessageProcessingContext context = mock(MessageProcessingContext.class); + when(context.getAcknowledgmentCallback()).thenReturn(callback); + when(handler.onSuccess(message, callback)).thenReturn(CompletableFuture.completedFuture(null)); + + AcknowledgementHandlerExecutionStage stage = new AcknowledgementHandlerExecutionStage<>(configuration); + CompletableFuture> resultFuture = stage.process(future, context); + + verify(handler).onSuccess(message, callback); + assertThat(resultFuture).isCompletedWithValue(message); + + } + + @SuppressWarnings("unchecked") + @Test + void shouldAckOnError() { + AcknowledgementHandler handler = mock(AcknowledgementHandler.class); + MessageProcessingConfiguration configuration = createConfiguration(handler); + Message message = mock(Message.class); + RuntimeException exception = new ListenerExecutionFailedException("Expected error", new RuntimeException(), + message); + CompletableFuture> failedFuture = CompletableFutures.failedFuture(exception); + AcknowledgementCallback callback = mock(AcknowledgementCallback.class); + MessageProcessingContext context = mock(MessageProcessingContext.class); + when(context.getAcknowledgmentCallback()).thenReturn(callback); + when(handler.onError(message, exception, callback)).thenReturn(CompletableFuture.completedFuture(null)); + + AcknowledgementHandlerExecutionStage stage = new AcknowledgementHandlerExecutionStage<>(configuration); + CompletableFuture> resultFuture = stage.process(failedFuture, context); + + verify(handler).onError(message, exception, callback); + assertThat(resultFuture).isCompletedExceptionally(); + assertThatThrownBy(resultFuture::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isEqualTo(exception).asInstanceOf(type(ListenerExecutionFailedException.class)) + .extracting(ListenerExecutionFailedException::getFailedMessage).isEqualTo(message); + + } + + @SuppressWarnings("unchecked") + @Test + void shouldAckOnSuccessBatch() { + AcknowledgementHandler handler = mock(AcknowledgementHandler.class); + MessageProcessingConfiguration configuration = createConfiguration(handler); + Message message = mock(Message.class); + List> messages = Arrays.asList(message, message, message); + CompletableFuture>> messagesFuture = CompletableFuture.completedFuture(messages); + AcknowledgementCallback callback = mock(AcknowledgementCallback.class); + MessageProcessingContext context = mock(MessageProcessingContext.class); + when(context.getAcknowledgmentCallback()).thenReturn(callback); + when(handler.onSuccess(messages, callback)).thenReturn(CompletableFuture.completedFuture(null)); + + AcknowledgementHandlerExecutionStage stage = new AcknowledgementHandlerExecutionStage<>(configuration); + CompletableFuture>> resultFuture = stage.processMany(messagesFuture, context); + + verify(handler).onSuccess(messages, callback); + assertThat(resultFuture).isCompletedWithValue(messages); + + } + + @SuppressWarnings("unchecked") + @Test + void shouldAckOnErrorBatch() { + AcknowledgementHandler handler = mock(AcknowledgementHandler.class); + MessageProcessingConfiguration configuration = createConfiguration(handler); + Message message = mock(Message.class); + List> messages = Arrays.asList(message, message, message); + RuntimeException exception = new ListenerExecutionFailedException("Expected error", new RuntimeException(), + messages); + CompletableFuture>> failedFuture = CompletableFutures.failedFuture(exception); + AcknowledgementCallback callback = mock(AcknowledgementCallback.class); + MessageProcessingContext context = mock(MessageProcessingContext.class); + when(context.getAcknowledgmentCallback()).thenReturn(callback); + when(handler.onError(messages, exception, callback)).thenReturn(CompletableFuture.completedFuture(null)); + + AcknowledgementHandlerExecutionStage stage = new AcknowledgementHandlerExecutionStage<>(configuration); + CompletableFuture>> resultFuture = stage.processMany(failedFuture, context); + + verify(handler).onError(messages, exception, callback); + assertThat(resultFuture).isCompletedExceptionally(); + assertThatThrownBy(resultFuture::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isEqualTo(exception).asInstanceOf(type(ListenerExecutionFailedException.class)) + .extracting(ListenerExecutionFailedException::getFailedMessages).isEqualTo(messages); + + } + + @SuppressWarnings("unchecked") + private MessageProcessingConfiguration createConfiguration(AcknowledgementHandler handler) { + return MessageProcessingConfiguration.builder().ackHandler(handler).errorHandler(mock(AsyncErrorHandler.class)) + .messageListener(mock(AsyncMessageListener.class)).interceptors(Collections.emptyList()).build(); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/AfterProcessingContextInterceptorExecutionStageTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/AfterProcessingContextInterceptorExecutionStageTests.java new file mode 100644 index 000000000..32de17178 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/AfterProcessingContextInterceptorExecutionStageTests.java @@ -0,0 +1,240 @@ +/* + * 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.pipeline; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.awspring.cloud.sqs.CompletableFutures; +import io.awspring.cloud.sqs.listener.AsyncMessageListener; +import io.awspring.cloud.sqs.listener.ListenerExecutionFailedException; +import io.awspring.cloud.sqs.listener.MessageProcessingContext; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementHandler; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class AfterProcessingContextInterceptorExecutionStageTests { + + @Test + void shouldExecuteInterceptorsInOrder() { + MessageHeaders headers = new MessageHeaders(null); + Message message = mock(Message.class); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + when(interceptor1.afterProcessing(message, null)).thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor2.afterProcessing(message, null)).thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor3.afterProcessing(message, null)).thenReturn(CompletableFuture.completedFuture(null)); + when(message.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create().addInterceptor(interceptor1) + .addInterceptor(interceptor2).addInterceptor(interceptor3); + + MessageProcessingPipeline stage = new AfterProcessingContextInterceptorExecutionStage<>( + createEmptyConfiguration()); + CompletableFuture> future = stage.process(CompletableFuture.completedFuture(message), context); + assertThat(future.join()).isEqualTo(message); + + InOrder inOrder = inOrder(interceptor1, interceptor2, interceptor3); + inOrder.verify(interceptor1).afterProcessing(message, null); + inOrder.verify(interceptor2).afterProcessing(message, null); + inOrder.verify(interceptor3).afterProcessing(message, null); + + } + + @Test + void shouldExecuteInterceptorsInOrderBatch() { + MessageHeaders headers = new MessageHeaders(null); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> batch = Arrays.asList(message1, message2, message3); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + when(interceptor1.afterProcessing(batch, null)).thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor2.afterProcessing(batch, null)).thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor3.afterProcessing(batch, null)).thenReturn(CompletableFuture.completedFuture(null)); + when(message1.getHeaders()).thenReturn(headers); + when(message2.getHeaders()).thenReturn(headers); + when(message3.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create().addInterceptor(interceptor1) + .addInterceptor(interceptor2).addInterceptor(interceptor3); + + MessageProcessingPipeline stage = new AfterProcessingContextInterceptorExecutionStage<>( + createEmptyConfiguration()); + CompletableFuture>> future = stage + .processMany(CompletableFuture.completedFuture(batch), context); + assertThat(future.join()).isEqualTo(batch); + + InOrder inOrder = inOrder(interceptor1, interceptor2, interceptor3); + inOrder.verify(interceptor1).afterProcessing(batch, null); + inOrder.verify(interceptor2).afterProcessing(batch, null); + inOrder.verify(interceptor3).afterProcessing(batch, null); + + } + + @Test + void shouldPassErrorToInterceptors() { + MessageHeaders headers = new MessageHeaders(null); + Message message = mock(Message.class); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + RuntimeException exception = new RuntimeException("Expected exception from shouldPassErrorToInterceptors"); + ListenerExecutionFailedException listenerException = new ListenerExecutionFailedException( + "Expected ListenerExecutionFailedException from shouldPassErrorToInterceptors", exception, message); + CompletableFuture> failedFuture = CompletableFutures.failedFuture(listenerException); + + when(interceptor1.afterProcessing(message, listenerException)) + .thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor2.afterProcessing(message, listenerException)) + .thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor3.afterProcessing(message, listenerException)) + .thenReturn(CompletableFuture.completedFuture(null)); + when(message.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create().addInterceptor(interceptor1) + .addInterceptor(interceptor2).addInterceptor(interceptor3); + + MessageProcessingPipeline stage = new AfterProcessingContextInterceptorExecutionStage<>( + createEmptyConfiguration()); + CompletableFuture> future = stage.process(failedFuture, context); + assertThat(future).isCompletedExceptionally(); + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isEqualTo(listenerException); + + verify(interceptor1).afterProcessing(message, listenerException); + verify(interceptor2).afterProcessing(message, listenerException); + verify(interceptor3).afterProcessing(message, listenerException); + + } + + @Test + void shouldPassErrorToInterceptorsBatch() { + MessageHeaders headers = new MessageHeaders(null); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> batch = Arrays.asList(message1, message2, message3); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + RuntimeException exception = new RuntimeException("Expected exception from shouldPassErrorToInterceptorsBatch"); + ListenerExecutionFailedException listenerException = new ListenerExecutionFailedException( + "Expected ListenerExecutionFailedException from shouldPassErrorToInterceptorsBatch", exception, batch); + CompletableFuture>> failedFuture = CompletableFutures + .failedFuture(listenerException); + + when(interceptor1.afterProcessing(batch, listenerException)) + .thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor2.afterProcessing(batch, listenerException)) + .thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor3.afterProcessing(batch, listenerException)) + .thenReturn(CompletableFuture.completedFuture(null)); + when(message1.getHeaders()).thenReturn(headers); + when(message2.getHeaders()).thenReturn(headers); + when(message3.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create().addInterceptor(interceptor1) + .addInterceptor(interceptor2).addInterceptor(interceptor3); + + MessageProcessingPipeline stage = new AfterProcessingContextInterceptorExecutionStage<>( + createEmptyConfiguration()); + CompletableFuture>> future = stage.processMany(failedFuture, context); + assertThat(future).isCompletedExceptionally(); + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isEqualTo(listenerException); + + verify(interceptor1).afterProcessing(batch, listenerException); + verify(interceptor2).afterProcessing(batch, listenerException); + verify(interceptor3).afterProcessing(batch, listenerException); + + } + + @Test + void shouldForwardMessageForEmptyInterceptors() { + MessageHeaders headers = new MessageHeaders(null); + Message message = mock(Message.class); + when(message.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new AfterProcessingContextInterceptorExecutionStage<>( + createEmptyConfiguration()); + CompletableFuture> future = stage.process(CompletableFuture.completedFuture(message), context); + assertThat(future.join()).isEqualTo(message); + + } + + @Test + void shouldForwardMessageForEmptyInterceptorsBatch() { + MessageHeaders headers = new MessageHeaders(null); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + when(message1.getHeaders()).thenReturn(headers); + when(message2.getHeaders()).thenReturn(headers); + when(message3.getHeaders()).thenReturn(headers); + + List> batch = Arrays.asList(message1, message2, message3); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new AfterProcessingContextInterceptorExecutionStage<>( + createEmptyConfiguration()); + CompletableFuture>> future = stage + .processMany(CompletableFuture.completedFuture(batch), context); + assertThat(future.join()).isEqualTo(batch); + + } + + @SuppressWarnings("unchecked") + private MessageProcessingConfiguration createEmptyConfiguration() { + return MessageProcessingConfiguration.builder().ackHandler(mock(AcknowledgementHandler.class)) + .errorHandler(mock(AsyncErrorHandler.class)).messageListener(mock(AsyncMessageListener.class)) + .interceptors(Collections.emptyList()).build(); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/AfterProcessingInterceptorExecutionStageTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/AfterProcessingInterceptorExecutionStageTests.java new file mode 100644 index 000000000..977f7567a --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/AfterProcessingInterceptorExecutionStageTests.java @@ -0,0 +1,247 @@ +/* + * 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.pipeline; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.awspring.cloud.sqs.CompletableFutures; +import io.awspring.cloud.sqs.listener.AsyncMessageListener; +import io.awspring.cloud.sqs.listener.ListenerExecutionFailedException; +import io.awspring.cloud.sqs.listener.MessageProcessingContext; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementHandler; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class AfterProcessingInterceptorExecutionStageTests { + + @Test + void shouldExecuteInterceptorsInOrder() { + MessageHeaders headers = new MessageHeaders(null); + Message message = mock(Message.class); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + when(interceptor1.afterProcessing(message, null)).thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor2.afterProcessing(message, null)).thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor3.afterProcessing(message, null)).thenReturn(CompletableFuture.completedFuture(null)); + when(message.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new AfterProcessingInterceptorExecutionStage<>( + createConfiguration(interceptor1, interceptor2, interceptor3)); + CompletableFuture> future = stage.process(CompletableFuture.completedFuture(message), context); + assertThat(future.join()).isEqualTo(message); + + InOrder inOrder = inOrder(interceptor1, interceptor2, interceptor3); + inOrder.verify(interceptor1).afterProcessing(message, null); + inOrder.verify(interceptor2).afterProcessing(message, null); + inOrder.verify(interceptor3).afterProcessing(message, null); + + } + + @Test + void shouldExecuteInterceptorsInOrderBatch() { + MessageHeaders headers = new MessageHeaders(null); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> batch = Arrays.asList(message1, message2, message3); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + when(interceptor1.afterProcessing(batch, null)).thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor2.afterProcessing(batch, null)).thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor3.afterProcessing(batch, null)).thenReturn(CompletableFuture.completedFuture(null)); + when(message1.getHeaders()).thenReturn(headers); + when(message2.getHeaders()).thenReturn(headers); + when(message3.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new AfterProcessingInterceptorExecutionStage<>( + createConfiguration(interceptor1, interceptor2, interceptor3)); + CompletableFuture>> future = stage + .processMany(CompletableFuture.completedFuture(batch), context); + assertThat(future.join()).isEqualTo(batch); + + InOrder inOrder = inOrder(interceptor1, interceptor2, interceptor3); + inOrder.verify(interceptor1).afterProcessing(batch, null); + inOrder.verify(interceptor2).afterProcessing(batch, null); + inOrder.verify(interceptor3).afterProcessing(batch, null); + + } + + @Test + void shouldPassErrorToInterceptors() { + MessageHeaders headers = new MessageHeaders(null); + Message message = mock(Message.class); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + RuntimeException exception = new RuntimeException( + "Expected error from AfterProcessingInterceptorExecutionStageTests#shouldPassErrorToInterceptors"); + ListenerExecutionFailedException listenerException = new ListenerExecutionFailedException( + "Expected ListenerExecutionFailedException from AfterProcessingInterceptorExecutionStageTests#shouldPassErrorToInterceptors", + exception, message); + CompletableFuture> failedFuture = CompletableFutures.failedFuture(listenerException); + + when(interceptor1.afterProcessing(message, listenerException)) + .thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor2.afterProcessing(message, listenerException)) + .thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor3.afterProcessing(message, listenerException)) + .thenReturn(CompletableFuture.completedFuture(null)); + when(message.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new AfterProcessingInterceptorExecutionStage<>( + createConfiguration(interceptor1, interceptor2, interceptor3)); + CompletableFuture> future = stage.process(failedFuture, context); + assertThat(future).isCompletedExceptionally(); + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isEqualTo(listenerException); + + verify(interceptor1).afterProcessing(message, listenerException); + verify(interceptor2).afterProcessing(message, listenerException); + verify(interceptor3).afterProcessing(message, listenerException); + + } + + @Test + void shouldPassErrorToInterceptorsBatch() { + MessageHeaders headers = new MessageHeaders(null); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> batch = Arrays.asList(message1, message2, message3); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + RuntimeException exception = new RuntimeException( + "Expected exception from AfterProcessingInterceptorExecutionStageTests#shouldPassErrorToInterceptorsBatch"); + ListenerExecutionFailedException listenerException = new ListenerExecutionFailedException( + "Expected ListenerExecutionFailedException from AfterProcessingInterceptorExecutionStageTests#shouldPassErrorToInterceptorsBatch", + exception, batch); + CompletableFuture>> failedFuture = CompletableFutures + .failedFuture(listenerException); + + when(interceptor1.afterProcessing(batch, listenerException)) + .thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor2.afterProcessing(batch, listenerException)) + .thenReturn(CompletableFuture.completedFuture(null)); + when(interceptor3.afterProcessing(batch, listenerException)) + .thenReturn(CompletableFuture.completedFuture(null)); + when(message1.getHeaders()).thenReturn(headers); + when(message2.getHeaders()).thenReturn(headers); + when(message3.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new AfterProcessingInterceptorExecutionStage<>( + createConfiguration(interceptor1, interceptor2, interceptor3)); + CompletableFuture>> future = stage.processMany(failedFuture, context); + assertThat(future).isCompletedExceptionally(); + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isEqualTo(listenerException); + + verify(interceptor1).afterProcessing(batch, listenerException); + verify(interceptor2).afterProcessing(batch, listenerException); + verify(interceptor3).afterProcessing(batch, listenerException); + + } + + @Test + void shouldForwardMessageForEmptyInterceptors() { + MessageHeaders headers = new MessageHeaders(null); + Message message = mock(Message.class); + when(message.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new AfterProcessingInterceptorExecutionStage<>( + createEmptyConfiguration()); + CompletableFuture> future = stage.process(CompletableFuture.completedFuture(message), context); + assertThat(future.join()).isEqualTo(message); + + } + + @Test + void shouldForwardMessageForEmptyInterceptorsBatch() { + MessageHeaders headers = new MessageHeaders(null); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + when(message1.getHeaders()).thenReturn(headers); + when(message2.getHeaders()).thenReturn(headers); + when(message3.getHeaders()).thenReturn(headers); + + List> batch = Arrays.asList(message1, message2, message3); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new AfterProcessingInterceptorExecutionStage<>( + createEmptyConfiguration()); + CompletableFuture>> future = stage + .processMany(CompletableFuture.completedFuture(batch), context); + assertThat(future.join()).isEqualTo(batch); + + } + + private MessageProcessingConfiguration createConfiguration(AsyncMessageInterceptor interceptor1, + AsyncMessageInterceptor interceptor2, AsyncMessageInterceptor interceptor3) { + return MessageProcessingConfiguration.builder().ackHandler(mock(AcknowledgementHandler.class)) + .errorHandler(mock(AsyncErrorHandler.class)).messageListener(mock(AsyncMessageListener.class)) + .interceptors(Arrays.asList(interceptor1, interceptor2, interceptor3)).build(); + } + + @SuppressWarnings("unchecked") + private MessageProcessingConfiguration createEmptyConfiguration() { + return MessageProcessingConfiguration.builder().ackHandler(mock(AcknowledgementHandler.class)) + .errorHandler(mock(AsyncErrorHandler.class)).messageListener(mock(AsyncMessageListener.class)) + .interceptors(Collections.emptyList()).build(); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/BeforeProcessingContextInterceptorExecutionStageTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/BeforeProcessingContextInterceptorExecutionStageTests.java new file mode 100644 index 000000000..a5c46c225 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/BeforeProcessingContextInterceptorExecutionStageTests.java @@ -0,0 +1,255 @@ +/* + * 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.pipeline; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.awspring.cloud.sqs.listener.AsyncMessageListener; +import io.awspring.cloud.sqs.listener.MessageProcessingContext; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementHandler; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class BeforeProcessingContextInterceptorExecutionStageTests { + + @Test + void shouldExecuteInterceptorsInOrder() { + MessageHeaders headers = new MessageHeaders(null); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + Message message4 = mock(Message.class); + CompletableFuture> messageFuture2 = CompletableFuture.completedFuture(message2); + CompletableFuture> messageFuture3 = CompletableFuture.completedFuture(message3); + CompletableFuture> messageFuture4 = CompletableFuture.completedFuture(message4); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + when(interceptor1.intercept(message1)).thenReturn(messageFuture2); + when(interceptor2.intercept(message2)).thenReturn(messageFuture3); + when(interceptor3.intercept(message3)).thenReturn(messageFuture4); + when(message1.getHeaders()).thenReturn(headers); + when(message2.getHeaders()).thenReturn(headers); + when(message3.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create().addInterceptor(interceptor1) + .addInterceptor(interceptor2).addInterceptor(interceptor3); + + MessageProcessingPipeline stage = new BeforeProcessingContextInterceptorExecutionStage<>( + createConfiguration()); + CompletableFuture> future = stage.process(message1, context); + assertThat(future.join()).isEqualTo(message4); + + InOrder inOrder = inOrder(interceptor1, interceptor2, interceptor3); + inOrder.verify(interceptor1).intercept(message1); + inOrder.verify(interceptor2).intercept(message2); + inOrder.verify(interceptor3).intercept(message3); + + } + + @Test + void shouldExecuteInterceptorsInOrderBatch() { + MessageHeaders headers = new MessageHeaders(null); + Message message11 = mock(Message.class); + Message message12 = mock(Message.class); + Message message13 = mock(Message.class); + Message message14 = mock(Message.class); + + Message message21 = mock(Message.class); + Message message22 = mock(Message.class); + Message message23 = mock(Message.class); + Message message24 = mock(Message.class); + + Message message31 = mock(Message.class); + Message message32 = mock(Message.class); + Message message33 = mock(Message.class); + Message message34 = mock(Message.class); + + List> firstBatch = Arrays.asList(message11, message21, message31); + List> secondBatch = Arrays.asList(message12, message22, message32); + List> thirdBatch = Arrays.asList(message13, message23, message33); + List> fourthBatch = Arrays.asList(message14, message24, message34); + + CompletableFuture>> messageFuture1 = CompletableFuture.completedFuture(secondBatch); + CompletableFuture>> messageFuture2 = CompletableFuture.completedFuture(thirdBatch); + CompletableFuture>> messageFuture3 = CompletableFuture.completedFuture(fourthBatch); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + when(interceptor1.intercept(firstBatch)).thenReturn(messageFuture1); + when(interceptor2.intercept(secondBatch)).thenReturn(messageFuture2); + when(interceptor3.intercept(thirdBatch)).thenReturn(messageFuture3); + when(message11.getHeaders()).thenReturn(headers); + when(message21.getHeaders()).thenReturn(headers); + when(message31.getHeaders()).thenReturn(headers); + when(message12.getHeaders()).thenReturn(headers); + when(message22.getHeaders()).thenReturn(headers); + when(message32.getHeaders()).thenReturn(headers); + when(message13.getHeaders()).thenReturn(headers); + when(message23.getHeaders()).thenReturn(headers); + when(message33.getHeaders()).thenReturn(headers); + when(message14.getHeaders()).thenReturn(headers); + when(message24.getHeaders()).thenReturn(headers); + when(message34.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create().addInterceptor(interceptor1) + .addInterceptor(interceptor2).addInterceptor(interceptor3); + + MessageProcessingPipeline stage = new BeforeProcessingContextInterceptorExecutionStage<>( + createConfiguration()); + CompletableFuture>> result = stage.process(firstBatch, context); + assertThat(result.join()).isEqualTo(fourthBatch); + + InOrder inOrder = inOrder(interceptor1, interceptor2, interceptor3); + inOrder.verify(interceptor1).intercept(firstBatch); + inOrder.verify(interceptor2).intercept(secondBatch); + inOrder.verify(interceptor3).intercept(thirdBatch); + + } + + @Test + void shouldPropagateError() { + MessageHeaders headers = new MessageHeaders(null); + Message message1 = mock(Message.class); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + RuntimeException exception = new RuntimeException("Expected error"); + when(interceptor1.intercept(message1)).thenThrow(exception); + when(message1.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create().addInterceptor(interceptor1) + .addInterceptor(interceptor2).addInterceptor(interceptor3); + + MessageProcessingPipeline stage = new BeforeProcessingContextInterceptorExecutionStage<>( + createConfiguration()); + CompletableFuture> future = stage.process(message1, context); + assertThat(future).isCompletedExceptionally(); + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isEqualTo(exception); + + verify(interceptor1).intercept(message1); + verify(interceptor2, never()).intercept(any(Message.class)); + verify(interceptor3, never()).intercept(any(Message.class)); + + } + + @Test + void shouldPropagateErrorBatch() { + MessageHeaders headers = new MessageHeaders(null); + Message message11 = mock(Message.class); + Message message12 = mock(Message.class); + Message message13 = mock(Message.class); + + List> firstBatch = Arrays.asList(message11, message12, message13); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + RuntimeException exception = new RuntimeException("Expected error"); + when(interceptor1.intercept(firstBatch)).thenThrow(exception); + when(message11.getHeaders()).thenReturn(headers); + when(message12.getHeaders()).thenReturn(headers); + when(message13.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create().addInterceptor(interceptor1) + .addInterceptor(interceptor2).addInterceptor(interceptor3); + + MessageProcessingPipeline stage = new BeforeProcessingContextInterceptorExecutionStage<>( + createConfiguration()); + CompletableFuture>> future = stage.process(firstBatch, context); + assertThat(future).isCompletedExceptionally(); + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isEqualTo(exception); + + verify(interceptor1).intercept(firstBatch); + verify(interceptor2, never()).intercept(any(Message.class)); + verify(interceptor3, never()).intercept(any(Message.class)); + + } + + @Test + void shouldPassMessageForEmptyInterceptors() { + MessageHeaders headers = new MessageHeaders(null); + Message message1 = mock(Message.class); + when(message1.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new BeforeProcessingContextInterceptorExecutionStage<>( + createConfiguration()); + CompletableFuture> future = stage.process(message1, context); + assertThat(future.join()).isEqualTo(message1); + + } + + @Test + void shouldPassMessageForEmptyInterceptorsBatch() { + MessageHeaders headers = new MessageHeaders(null); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + when(message1.getHeaders()).thenReturn(headers); + when(message2.getHeaders()).thenReturn(headers); + when(message3.getHeaders()).thenReturn(headers); + + List> batch = Arrays.asList(message1, message2, message3); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new BeforeProcessingContextInterceptorExecutionStage<>( + createConfiguration()); + CompletableFuture>> future = stage.process(batch, context); + assertThat(future.join()).isEqualTo(batch); + + } + + private MessageProcessingConfiguration createConfiguration() { + return MessageProcessingConfiguration.builder().ackHandler(mock(AcknowledgementHandler.class)) + .errorHandler(mock(AsyncErrorHandler.class)).messageListener(mock(AsyncMessageListener.class)) + .interceptors(Collections.emptyList()).build(); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/BeforeProcessingInterceptorExecutionStageTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/BeforeProcessingInterceptorExecutionStageTests.java new file mode 100644 index 000000000..c2956cb88 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/BeforeProcessingInterceptorExecutionStageTests.java @@ -0,0 +1,353 @@ +/* + * 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.pipeline; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.awspring.cloud.sqs.listener.AsyncMessageListener; +import io.awspring.cloud.sqs.listener.MessageProcessingContext; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementHandler; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import io.awspring.cloud.sqs.listener.interceptor.AsyncMessageInterceptor; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class BeforeProcessingInterceptorExecutionStageTests { + + @Test + void shouldExecuteInterceptorsInOrder() { + MessageHeaders headers = getMessageHeaders(); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + Message message4 = mock(Message.class); + CompletableFuture> messageFuture2 = CompletableFuture.completedFuture(message2); + CompletableFuture> messageFuture3 = CompletableFuture.completedFuture(message3); + CompletableFuture> messageFuture4 = CompletableFuture.completedFuture(message4); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + when(interceptor1.intercept(message1)).thenReturn(messageFuture2); + when(interceptor2.intercept(message2)).thenReturn(messageFuture3); + when(interceptor3.intercept(message3)).thenReturn(messageFuture4); + when(message1.getHeaders()).thenReturn(headers); + when(message2.getHeaders()).thenReturn(headers); + when(message3.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new BeforeProcessingInterceptorExecutionStage<>( + createConfiguration(Arrays.asList(interceptor1, interceptor2, interceptor3))); + CompletableFuture> future = stage.process(message1, context); + assertThat(future.join()).isEqualTo(message4); + + InOrder inOrder = inOrder(interceptor1, interceptor2, interceptor3); + inOrder.verify(interceptor1).intercept(message1); + inOrder.verify(interceptor2).intercept(message2); + inOrder.verify(interceptor3).intercept(message3); + + } + + @Test + void shouldThrowIfInterceptorReturnsNull() { + MessageHeaders headers = getMessageHeaders(); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + CompletableFuture> messageFuture2 = CompletableFuture.completedFuture(message2); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + + when(interceptor1.intercept(message1)).thenReturn(messageFuture2); + when(interceptor2.intercept(message2)).thenReturn(CompletableFuture.completedFuture(null)); + when(message1.getHeaders()).thenReturn(headers); + when(message2.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create(); + MessageProcessingPipeline stage = new BeforeProcessingInterceptorExecutionStage<>( + createConfiguration(Arrays.asList(interceptor1, interceptor2))); + CompletableFuture> future = stage.process(message1, context); + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(IllegalArgumentException.class); + + InOrder inOrder = inOrder(interceptor1, interceptor2); + inOrder.verify(interceptor1).intercept(message1); + inOrder.verify(interceptor2).intercept(message2); + + } + + @Test + void shouldExecuteInterceptorsInOrderBatch() { + MessageHeaders headers = getMessageHeaders(); + Message message11 = mock(Message.class); + Message message12 = mock(Message.class); + Message message13 = mock(Message.class); + Message message14 = mock(Message.class); + + Message message21 = mock(Message.class); + Message message22 = mock(Message.class); + Message message23 = mock(Message.class); + Message message24 = mock(Message.class); + + Message message31 = mock(Message.class); + Message message32 = mock(Message.class); + Message message33 = mock(Message.class); + Message message34 = mock(Message.class); + + List> firstBatch = Arrays.asList(message11, message21, message31); + List> secondBatch = Arrays.asList(message12, message22, message32); + List> thirdBatch = Arrays.asList(message13, message23, message33); + List> fourthBatch = Arrays.asList(message14, message24, message34); + + CompletableFuture>> messageFuture1 = CompletableFuture.completedFuture(secondBatch); + CompletableFuture>> messageFuture2 = CompletableFuture.completedFuture(thirdBatch); + CompletableFuture>> messageFuture3 = CompletableFuture.completedFuture(fourthBatch); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + when(interceptor1.intercept(firstBatch)).thenReturn(messageFuture1); + when(interceptor2.intercept(secondBatch)).thenReturn(messageFuture2); + when(interceptor3.intercept(thirdBatch)).thenReturn(messageFuture3); + when(message11.getHeaders()).thenReturn(headers); + when(message21.getHeaders()).thenReturn(headers); + when(message31.getHeaders()).thenReturn(headers); + when(message12.getHeaders()).thenReturn(headers); + when(message22.getHeaders()).thenReturn(headers); + when(message32.getHeaders()).thenReturn(headers); + when(message13.getHeaders()).thenReturn(headers); + when(message23.getHeaders()).thenReturn(headers); + when(message33.getHeaders()).thenReturn(headers); + when(message14.getHeaders()).thenReturn(headers); + when(message24.getHeaders()).thenReturn(headers); + when(message34.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new BeforeProcessingInterceptorExecutionStage<>( + createConfiguration(Arrays.asList(interceptor1, interceptor2, interceptor3))); + CompletableFuture>> result = stage.process(firstBatch, context); + assertThat(result.join()).isEqualTo(fourthBatch); + + InOrder inOrder = inOrder(interceptor1, interceptor2, interceptor3); + inOrder.verify(interceptor1).intercept(firstBatch); + inOrder.verify(interceptor2).intercept(secondBatch); + inOrder.verify(interceptor3).intercept(thirdBatch); + + } + + @NotNull + private MessageHeaders getMessageHeaders() { + return new MessageHeaders(null); + } + + @Test + void shouldThrowIfEmptyCollection() { + shouldThrowIfNullOrEmptyCollection(Collections.emptyList()); + } + + @Test + void shouldThrowIfNullCollection() { + shouldThrowIfNullOrEmptyCollection(null); + } + + private void shouldThrowIfNullOrEmptyCollection(Collection> messages) { + MessageHeaders headers = getMessageHeaders(); + Message message11 = mock(Message.class); + Message message12 = mock(Message.class); + + Message message21 = mock(Message.class); + Message message22 = mock(Message.class); + + Message message31 = mock(Message.class); + Message message32 = mock(Message.class); + + List> firstBatch = Arrays.asList(message11, message21, message31); + List> secondBatch = Arrays.asList(message12, message22, message32); + + CompletableFuture>> messageFuture1 = CompletableFuture.completedFuture(secondBatch); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + + when(interceptor1.intercept(firstBatch)).thenReturn(messageFuture1); + when(interceptor2.intercept(secondBatch)).thenReturn(CompletableFuture.completedFuture(messages)); + when(message11.getHeaders()).thenReturn(headers); + when(message21.getHeaders()).thenReturn(headers); + when(message31.getHeaders()).thenReturn(headers); + when(message12.getHeaders()).thenReturn(headers); + when(message22.getHeaders()).thenReturn(headers); + when(message32.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new BeforeProcessingInterceptorExecutionStage<>( + createConfiguration(Arrays.asList(interceptor1, interceptor2))); + CompletableFuture>> result = stage.process(firstBatch, context); + assertThatThrownBy(result::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(IllegalArgumentException.class); + + InOrder inOrder = inOrder(interceptor1, interceptor2); + inOrder.verify(interceptor1).intercept(firstBatch); + inOrder.verify(interceptor2).intercept(secondBatch); + } + + @Test + void shouldPropagateError() { + MessageHeaders headers = getMessageHeaders(); + Message message1 = mock(Message.class); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + RuntimeException exception = new RuntimeException("Expected error"); + when(interceptor1.intercept(message1)).thenThrow(exception); + when(message1.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new BeforeProcessingInterceptorExecutionStage<>( + createConfiguration(Arrays.asList(interceptor1, interceptor2, interceptor3))); + CompletableFuture> future = stage.process(message1, context); + assertThat(future).isCompletedExceptionally(); + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isEqualTo(exception); + + verify(interceptor1).intercept(message1); + verify(interceptor2, never()).intercept(any(Message.class)); + verify(interceptor3, never()).intercept(any(Message.class)); + + } + + @Test + void shouldPropagateErrorBatch() { + MessageHeaders headers = getMessageHeaders(); + Message message11 = mock(Message.class); + Message message12 = mock(Message.class); + Message message13 = mock(Message.class); + + List> firstBatch = Arrays.asList(message11, message12, message13); + + AsyncMessageInterceptor interceptor1 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor2 = mock(AsyncMessageInterceptor.class); + AsyncMessageInterceptor interceptor3 = mock(AsyncMessageInterceptor.class); + + RuntimeException exception = new RuntimeException("Expected error"); + when(interceptor1.intercept(firstBatch)).thenThrow(exception); + when(message11.getHeaders()).thenReturn(headers); + when(message12.getHeaders()).thenReturn(headers); + when(message13.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new BeforeProcessingInterceptorExecutionStage<>( + createConfiguration(Arrays.asList(interceptor1, interceptor2, interceptor3))); + CompletableFuture>> future = stage.process(firstBatch, context); + assertThat(future).isCompletedExceptionally(); + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isEqualTo(exception); + + verify(interceptor1).intercept(firstBatch); + verify(interceptor2, never()).intercept(any(Message.class)); + verify(interceptor3, never()).intercept(any(Message.class)); + + } + + @Test + void shouldPassMessageForEmptyInterceptors() { + MessageHeaders headers = getMessageHeaders(); + Message message1 = mock(Message.class); + when(message1.getHeaders()).thenReturn(headers); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new BeforeProcessingInterceptorExecutionStage<>( + createEmptyConfiguration()); + CompletableFuture> future = stage.process(message1, context); + assertThat(future.join()).isEqualTo(message1); + + } + + @Test + void shouldPassMessageForEmptyInterceptorsBatch() { + MessageHeaders headers = getMessageHeaders(); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + when(message1.getHeaders()).thenReturn(headers); + when(message2.getHeaders()).thenReturn(headers); + when(message3.getHeaders()).thenReturn(headers); + + List> batch = Arrays.asList(message1, message2, message3); + + MessageProcessingContext context = MessageProcessingContext.create(); + + MessageProcessingPipeline stage = new BeforeProcessingInterceptorExecutionStage<>( + createEmptyConfiguration()); + CompletableFuture>> future = stage.process(batch, context); + assertThat(future.join()).isEqualTo(batch); + + } + + // @formatter:off + private MessageProcessingConfiguration createConfiguration(Collection> interceptors) { + return MessageProcessingConfiguration + .builder() + .ackHandler(mock(AcknowledgementHandler.class)) + .errorHandler(mock(AsyncErrorHandler.class)) + .messageListener(mock(AsyncMessageListener.class)) + .interceptors(interceptors) + .build(); + } + + @SuppressWarnings("unchecked") + private MessageProcessingConfiguration createEmptyConfiguration() { + return MessageProcessingConfiguration + .builder() + .ackHandler(mock(AcknowledgementHandler.class)) + .errorHandler(mock(AsyncErrorHandler.class)) + .messageListener(mock(AsyncMessageListener.class)) + .interceptors(Collections.emptyList()) + .build(); + // @formatter:on + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/ErrorHandlerExecutionStageTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/ErrorHandlerExecutionStageTests.java new file mode 100644 index 000000000..97c6fb5c7 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/ErrorHandlerExecutionStageTests.java @@ -0,0 +1,229 @@ +/* + * 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.pipeline; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import io.awspring.cloud.sqs.CompletableFutures; +import io.awspring.cloud.sqs.listener.AsyncMessageListener; +import io.awspring.cloud.sqs.listener.ListenerExecutionFailedException; +import io.awspring.cloud.sqs.listener.MessageProcessingContext; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementHandler; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class ErrorHandlerExecutionStageTests { + + @Test + void shouldRecoverFromError() { + MessageHeaders headers = new MessageHeaders(null); + Message message = mock(Message.class); + given(message.getHeaders()).willReturn(headers); + + AsyncErrorHandler errorHandler = mock(AsyncErrorHandler.class); + RuntimeException cause = new RuntimeException("Expected exception from shouldRecoverFromError"); + ListenerExecutionFailedException listenerException = new ListenerExecutionFailedException( + "Expected ListenerExecutionFailedException from shouldRecoverFromError", cause, message); + CompletableFuture> failedFuture = CompletableFutures.failedFuture(listenerException); + + given(errorHandler.handle(eq(message), any(Throwable.class))) + .willReturn(CompletableFuture.completedFuture(null)); + + MessageProcessingConfiguration configuration = MessageProcessingConfiguration.builder() + .messageListener(mock(AsyncMessageListener.class)).ackHandler(mock(AcknowledgementHandler.class)) + .interceptors(Collections.emptyList()).errorHandler(errorHandler).build(); + MessageProcessingPipeline stage = new ErrorHandlerExecutionStage<>(configuration); + MessageProcessingContext context = MessageProcessingContext.create(); + + CompletableFuture> future = stage.process(failedFuture, context); + + assertThat(future).isCompletedWithValue(message); + + } + + @Test + void shouldRecoverFromErrorBatch() { + MessageHeaders headers = new MessageHeaders(null); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + given(message1.getHeaders()).willReturn(headers); + given(message2.getHeaders()).willReturn(headers); + given(message3.getHeaders()).willReturn(headers); + List> batch = Arrays.asList(message1, message2, message3); + + AsyncErrorHandler errorHandler = mock(AsyncErrorHandler.class); + RuntimeException cause = new RuntimeException("Expected exception from shouldRecoverFromErrorBatch"); + ListenerExecutionFailedException listenerException = new ListenerExecutionFailedException( + "Expected ListenerExecutionFailedException from shouldRecoverFromErrorBatch", cause, batch); + CompletableFuture>> failedFuture = CompletableFutures + .failedFuture(listenerException); + + given(errorHandler.handle(eq(batch), any(Throwable.class))).willReturn(CompletableFuture.completedFuture(null)); + + MessageProcessingConfiguration configuration = MessageProcessingConfiguration.builder() + .messageListener(mock(AsyncMessageListener.class)).ackHandler(mock(AcknowledgementHandler.class)) + .interceptors(Collections.emptyList()).errorHandler(errorHandler).build(); + MessageProcessingPipeline stage = new ErrorHandlerExecutionStage<>(configuration); + MessageProcessingContext context = MessageProcessingContext.create(); + + CompletableFuture>> future = stage.processMany(failedFuture, context); + + assertThat(future).isCompletedWithValue(batch); + + } + + @Test + void shouldWrapIfErrorNotListenerException() { + MessageHeaders headers = new MessageHeaders(null); + Message message = mock(Message.class); + given(message.getHeaders()).willReturn(headers); + + AsyncErrorHandler errorHandler = mock(AsyncErrorHandler.class); + RuntimeException cause = new RuntimeException("Expected exception from shouldWrapIfErrorNotListenerException"); + ListenerExecutionFailedException listenerException = new ListenerExecutionFailedException( + "Expected ListenerExecutionFailedException from shouldWrapIfErrorNotListenerException", cause, message); + CompletableFuture> failedFuture = CompletableFutures.failedFuture(listenerException); + + RuntimeException resultException = new RuntimeException( + "Expected result exception from shouldWrapIfErrorNotListenerException"); + given(errorHandler.handle(eq(message), any(Throwable.class))) + .willReturn(CompletableFutures.failedFuture(resultException)); + + MessageProcessingConfiguration configuration = MessageProcessingConfiguration.builder() + .messageListener(mock(AsyncMessageListener.class)).ackHandler(mock(AcknowledgementHandler.class)) + .interceptors(Collections.emptyList()).errorHandler(errorHandler).build(); + MessageProcessingPipeline stage = new ErrorHandlerExecutionStage<>(configuration); + MessageProcessingContext context = MessageProcessingContext.create(); + + CompletableFuture> future = stage.process(failedFuture, context); + + assertThat(future).isCompletedExceptionally(); + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(ListenerExecutionFailedException.class) + .asInstanceOf(type(ListenerExecutionFailedException.class)) + .extracting(ListenerExecutionFailedException::getFailedMessage).isEqualTo(message); + + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(ListenerExecutionFailedException.class).extracting(Throwable::getCause) + .isInstanceOf(CompletionException.class).extracting(Throwable::getCause).isEqualTo(resultException); + + } + + @Test + void shouldNotWrapIfErrorNotListenerException() { + MessageHeaders headers = new MessageHeaders(null); + Message message = mock(Message.class); + given(message.getHeaders()).willReturn(headers); + + AsyncErrorHandler errorHandler = mock(AsyncErrorHandler.class); + RuntimeException cause = new RuntimeException( + "Expected exception from shouldNotWrapIfErrorNotListenerException"); + ListenerExecutionFailedException listenerException = new ListenerExecutionFailedException( + "Expected ListenerExecutionFailedException from shouldNotWrapIfErrorNotListenerException", cause, + message); + CompletableFuture> failedFuture = CompletableFutures.failedFuture(listenerException); + + RuntimeException resultException = new RuntimeException( + "Expected result exception from shouldNotWrapIfErrorNotListenerException"); + given(errorHandler.handle(eq(message), any(Throwable.class))) + .willReturn(CompletableFutures.failedFuture(resultException)); + + MessageProcessingConfiguration configuration = MessageProcessingConfiguration.builder() + .messageListener(mock(AsyncMessageListener.class)).ackHandler(mock(AcknowledgementHandler.class)) + .interceptors(Collections.emptyList()).errorHandler(errorHandler).build(); + MessageProcessingPipeline stage = new ErrorHandlerExecutionStage<>(configuration); + MessageProcessingContext context = MessageProcessingContext.create(); + + CompletableFuture> future = stage.process(failedFuture, context); + + assertThat(future).isCompletedExceptionally(); + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(ListenerExecutionFailedException.class) + .asInstanceOf(type(ListenerExecutionFailedException.class)) + .extracting(ListenerExecutionFailedException::getFailedMessage).isEqualTo(message); + + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(ListenerExecutionFailedException.class).extracting(Throwable::getCause) + .isInstanceOf(CompletionException.class).extracting(Throwable::getCause).isEqualTo(resultException); + + } + + @Test + void shouldWrapIfErrorNotListenerExceptionBatch() { + MessageHeaders headers = new MessageHeaders(null); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + given(message1.getHeaders()).willReturn(headers); + given(message2.getHeaders()).willReturn(headers); + given(message3.getHeaders()).willReturn(headers); + List> batch = Arrays.asList(message1, message2, message3); + + AsyncErrorHandler errorHandler = mock(AsyncErrorHandler.class); + RuntimeException cause = new RuntimeException( + "Expected exception from shouldWrapIfErrorNotListenerExceptionBatch"); + ListenerExecutionFailedException listenerException = new ListenerExecutionFailedException( + "Expected ListenerExecutionFailedException from shouldWrapIfErrorNotListenerExceptionBatch", cause, + batch); + CompletableFuture>> failedFuture = CompletableFutures + .failedFuture(listenerException); + + RuntimeException resultException = new RuntimeException( + "Expected result exception from shouldWrapIfErrorNotListenerExceptionBatch"); + given(errorHandler.handle(eq(batch), any(Throwable.class))) + .willReturn(CompletableFutures.failedFuture(resultException)); + + MessageProcessingConfiguration configuration = MessageProcessingConfiguration.builder() + .messageListener(mock(AsyncMessageListener.class)).ackHandler(mock(AcknowledgementHandler.class)) + .interceptors(Collections.emptyList()).errorHandler(errorHandler).build(); + MessageProcessingPipeline stage = new ErrorHandlerExecutionStage<>(configuration); + MessageProcessingContext context = MessageProcessingContext.create(); + + CompletableFuture>> future = stage.processMany(failedFuture, context); + + assertThat(future).isCompletedExceptionally(); + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(ListenerExecutionFailedException.class) + .asInstanceOf(type(ListenerExecutionFailedException.class)) + .extracting(ListenerExecutionFailedException::getFailedMessages).isEqualTo(batch); + + assertThatThrownBy(future::join).isInstanceOf(CompletionException.class).extracting(Throwable::getCause) + .isInstanceOf(ListenerExecutionFailedException.class).extracting(Throwable::getCause) + .isInstanceOf(CompletionException.class).extracting(Throwable::getCause).isEqualTo(resultException); + + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/MessageListenerExecutionStageTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/MessageListenerExecutionStageTests.java new file mode 100644 index 000000000..56a47c3de --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/pipeline/MessageListenerExecutionStageTests.java @@ -0,0 +1,89 @@ +/* + * 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.pipeline; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import io.awspring.cloud.sqs.listener.AsyncMessageListener; +import io.awspring.cloud.sqs.listener.MessageProcessingContext; +import io.awspring.cloud.sqs.listener.acknowledgement.handler.AcknowledgementHandler; +import io.awspring.cloud.sqs.listener.errorhandler.AsyncErrorHandler; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class MessageListenerExecutionStageTests { + + @Test + void shouldForwardMessageOnSuccess() { + MessageHeaders headers = new MessageHeaders(null); + Message message = mock(Message.class); + AsyncMessageListener messageListener = mock(AsyncMessageListener.class); + + given(message.getHeaders()).willReturn(headers); + given(messageListener.onMessage(message)).willReturn(CompletableFuture.completedFuture(null)); + + MessageProcessingConfiguration configuration = MessageProcessingConfiguration.builder() + .interceptors(Collections.emptyList()).ackHandler(mock(AcknowledgementHandler.class)) + .errorHandler(mock(AsyncErrorHandler.class)).messageListener(messageListener).build(); + MessageProcessingContext context = MessageProcessingContext.create(); + MessageProcessingPipeline stage = new MessageListenerExecutionStage<>(configuration); + CompletableFuture> result = stage.process(message, context); + assertThat(result).isCompletedWithValue(message); + verify(messageListener).onMessage(message); + + } + + @Test + void shouldForwardMessageOnSuccessBatch() { + MessageHeaders headers = new MessageHeaders(null); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> batch = Arrays.asList(message1, message2, message3); + + AsyncMessageListener messageListener = mock(AsyncMessageListener.class); + given(message1.getHeaders()).willReturn(headers); + given(message2.getHeaders()).willReturn(headers); + given(message3.getHeaders()).willReturn(headers); + + given(messageListener.onMessage(batch)).willReturn(CompletableFuture.completedFuture(null)); + + MessageProcessingConfiguration configuration = MessageProcessingConfiguration.builder() + .interceptors(Collections.emptyList()).ackHandler(mock(AcknowledgementHandler.class)) + .errorHandler(mock(AsyncErrorHandler.class)).messageListener(messageListener).build(); + MessageProcessingContext context = MessageProcessingContext.create(); + MessageProcessingPipeline stage = new MessageListenerExecutionStage<>(configuration); + CompletableFuture>> result = stage.process(batch, context); + assertThat(result).isCompletedWithValue(batch); + verify(messageListener).onMessage(batch); + + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/sink/MessageGroupingSinkTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/sink/MessageGroupingSinkTests.java new file mode 100644 index 000000000..2b9cf28b7 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/sink/MessageGroupingSinkTests.java @@ -0,0 +1,97 @@ +/* + * 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.sink; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; + +import io.awspring.cloud.sqs.listener.MessageProcessingContext; +import io.awspring.cloud.sqs.listener.SqsHeaders; +import io.awspring.cloud.sqs.listener.pipeline.MessageProcessingPipeline; +import io.awspring.cloud.sqs.listener.sink.adapter.MessageGroupingSinkAdapter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.stream.IntStream; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +class MessageGroupingSinkTests { + + @Test + void maintainsOrderWithinEachGroup() { + String header = SqsHeaders.MessageSystemAttribute.SQS_MESSAGE_GROUP_ID_HEADER; + String firstMessageGroupId = UUID.randomUUID().toString(); + String secondMessageGroupId = UUID.randomUUID().toString(); + String thirdMessageGroupId = UUID.randomUUID().toString(); + List> firstMessageGroupMessages = IntStream.range(0, 10) + .mapToObj(index -> createMessage(index, header, firstMessageGroupId)).collect(toList()); + List> secondMessageGroupMessages = IntStream.range(0, 10) + .mapToObj(index -> createMessage(index, header, secondMessageGroupId)).collect(toList()); + List> thirdMessageGroupMessages = IntStream.range(0, 10) + .mapToObj(index -> createMessage(index, header, thirdMessageGroupId)).collect(toList()); + List> messagesToEmit = new ArrayList<>(); + messagesToEmit.addAll(firstMessageGroupMessages); + messagesToEmit.addAll(secondMessageGroupMessages); + messagesToEmit.addAll(thirdMessageGroupMessages); + + List> received = Collections.synchronizedList(new ArrayList<>()); + + MessageGroupingSinkAdapter sinkAdapter = new MessageGroupingSinkAdapter<>(new OrderedMessageSink<>(), + message -> message.getHeaders().get(header, String.class)); + sinkAdapter.setTaskExecutor(new SimpleAsyncTaskExecutor()); + sinkAdapter.setMessagePipeline(new MessageProcessingPipeline() { + @Override + public CompletableFuture> process(Message message, + MessageProcessingContext context) { + try { + Thread.sleep(new Random().nextInt(1000)); + } + catch (InterruptedException e) { + throw new RuntimeException(e); + } + received.add(message); + return CompletableFuture.completedFuture(message); + } + }); + sinkAdapter.start(); + sinkAdapter.emit(messagesToEmit, MessageProcessingContext.create()).join(); + Map>> receivedMessages = received.stream() + .collect(groupingBy(message -> (String) message.getHeaders().get(header))); + + assertThat(receivedMessages.get(firstMessageGroupId)).containsExactlyElementsOf(firstMessageGroupMessages); + assertThat(receivedMessages.get(secondMessageGroupId)).containsExactlyElementsOf(secondMessageGroupMessages); + assertThat(receivedMessages.get(thirdMessageGroupId)).containsExactlyElementsOf(thirdMessageGroupMessages); + } + + @NotNull + private Message createMessage(int index, String header, String thirdMessageGroupId) { + return MessageBuilder.withPayload(index).setHeader(header, thirdMessageGroupId).build(); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/sink/OrderedMessageListeningSinkTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/sink/OrderedMessageListeningSinkTests.java new file mode 100644 index 000000000..95f86ca9f --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/sink/OrderedMessageListeningSinkTests.java @@ -0,0 +1,63 @@ +/* + * 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.sink; + +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; + +import io.awspring.cloud.sqs.listener.MessageProcessingContext; +import io.awspring.cloud.sqs.listener.pipeline.MessageProcessingPipeline; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +class OrderedMessageListeningSinkTests { + + @Test + void shouldEmitInOrder() { + int numberOfMessagesToEmit = 1000; + List> messagesToEmit = IntStream.range(0, numberOfMessagesToEmit) + .mapToObj(index -> MessageBuilder.withPayload(index).build()).collect(toList()); + List> received = new ArrayList<>(numberOfMessagesToEmit); + AbstractMessageProcessingPipelineSink sink = new OrderedMessageSink<>(); + sink.setTaskExecutor(Runnable::run); + sink.setMessagePipeline(getMessageProcessingPipeline(received)); + sink.start(); + sink.emit(messagesToEmit, MessageProcessingContext.create()).join(); + sink.stop(); + assertThat(received).containsSequence(messagesToEmit); + } + + private MessageProcessingPipeline getMessageProcessingPipeline(List> received) { + return new MessageProcessingPipeline() { + @Override + public CompletableFuture> process(Message message, + MessageProcessingContext context) { + received.add(message); + return CompletableFuture.completedFuture(message); + } + }; + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/SqsMessageSourceTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/SqsMessageSourceTests.java new file mode 100644 index 000000000..b701d58b2 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/listener/source/SqsMessageSourceTests.java @@ -0,0 +1,98 @@ +/* + * 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.source; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +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 java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.model.Message; +import software.amazon.awssdk.services.sqs.model.ReceiveMessageRequest; +import software.amazon.awssdk.services.sqs.model.ReceiveMessageResponse; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +class SqsMessageSourceTests { + + @Test + void shouldReturnBatchOfTenMessages() { + List batch = IntStream.range(0, 10).mapToObj( + index -> Message.builder().body(String.valueOf(index)).messageId(UUID.randomUUID().toString()).build()) + .collect(Collectors.toList()); + SqsAsyncClient client = mock(SqsAsyncClient.class); + ReceiveMessageResponse response = ReceiveMessageResponse.builder().messages(batch).build(); + given(client.receiveMessage(any(ReceiveMessageRequest.class))) + .willReturn(CompletableFuture.completedFuture(response)); + SqsMessageSource source = new SqsMessageSource<>(); + source.setSqsAsyncClient(client); + CompletableFuture> messages = source.doPollForMessages(10); + assertThat(messages).isCompletedWithValue(batch); + } + + @Test + void shouldReturnBatchOfHundredMessages() { + List batch = IntStream.range(0, 10).mapToObj( + index -> Message.builder().body(String.valueOf(index)).messageId(UUID.randomUUID().toString()).build()) + .collect(Collectors.toList()); + SqsAsyncClient client = mock(SqsAsyncClient.class); + ReceiveMessageResponse response = ReceiveMessageResponse.builder().messages(batch).build(); + given(client.receiveMessage(any(ReceiveMessageRequest.class))) + .willReturn(CompletableFuture.completedFuture(response)); + SqsMessageSource source = new SqsMessageSource<>(); + source.setSqsAsyncClient(client); + CompletableFuture> messages = source.doPollForMessages(100); + assertThat(messages.join()).containsExactlyElementsOf(getHundredMessages(batch)); + } + + @Test + void shouldRequestHundredAndOneMessages() { + List batch = IntStream.range(0, 10).mapToObj( + index -> Message.builder().body(String.valueOf(index)).messageId(UUID.randomUUID().toString()).build()) + .collect(Collectors.toList()); + SqsAsyncClient client = mock(SqsAsyncClient.class); + ReceiveMessageResponse response = ReceiveMessageResponse.builder().messages(batch).build(); + ArgumentCaptor captor = ArgumentCaptor.forClass(ReceiveMessageRequest.class); + given(client.receiveMessage(any(ReceiveMessageRequest.class))) + .willReturn(CompletableFuture.completedFuture(response)); + SqsMessageSource source = new SqsMessageSource<>(); + source.setSqsAsyncClient(client); + source.doPollForMessages(101); + then(client).should(times(11)).receiveMessage(captor.capture()); + List requests = captor.getAllValues(); + IntStream.range(0, 10).forEach(index -> assertThat(requests.get(index)) + .extracting(ReceiveMessageRequest::maxNumberOfMessages).isEqualTo(10)); + assertThat(requests.get(10)).extracting(ReceiveMessageRequest::maxNumberOfMessages).isEqualTo(1); + } + + private List getHundredMessages(List batch) { + return IntStream.range(0, 10).mapToObj(index -> batch).flatMap(Collection::stream).collect(Collectors.toList()); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/support/converter/SqsHeaderMapperTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/support/converter/SqsHeaderMapperTests.java new file mode 100644 index 000000000..97e1799cd --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/support/converter/SqsHeaderMapperTests.java @@ -0,0 +1,45 @@ +/* + * 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.support.converter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.MessageHeaders; +import software.amazon.awssdk.services.sqs.model.Message; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +class SqsHeaderMapperTests { + + @Test + void shouldAddExtraHeader() { + SqsHeaderMapper headerMapper = new SqsHeaderMapper(); + String myHeader = "myHeader"; + String myValue = "myValue"; + headerMapper.setAdditionalHeadersFunction((message, accessor) -> { + accessor.setHeader(myHeader, myValue); + return accessor.toMessageHeaders(); + }); + Message message = Message.builder().body("payload").messageId(UUID.randomUUID().toString()).build(); + MessageHeaders headers = headerMapper.toHeaders(message); + assertThat(headers.get(myHeader)).isEqualTo(myValue); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/support/converter/SqsMessagingMessageConverterTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/support/converter/SqsMessagingMessageConverterTests.java new file mode 100644 index 000000000..a99e3737e --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/support/converter/SqsMessagingMessageConverterTests.java @@ -0,0 +1,126 @@ +/* + * 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.support.converter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Collections; +import java.util.Objects; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.support.HeaderMapper; +import software.amazon.awssdk.services.sqs.model.Message; +import software.amazon.awssdk.services.sqs.model.MessageAttributeValue; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +class SqsMessagingMessageConverterTests { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void shouldUseProvidedTypeMapper() throws Exception { + MyPojo myPojo = new MyPojo(); + String payload = new ObjectMapper().writeValueAsString(myPojo); + Message message = Message.builder().body(payload).messageId(UUID.randomUUID().toString()).build(); + SqsMessagingMessageConverter converter = new SqsMessagingMessageConverter(); + converter.setPayloadTypeMapper(msg -> MyPojo.class); + org.springframework.messaging.Message resultMessage = converter.toMessagingMessage(message); + assertThat(resultMessage.getPayload()).isEqualTo(myPojo); + } + + @Test + void shouldUseProvidedTypeHeader() throws Exception { + String typeHeader = "myHeader"; + MyPojo myPojo = new MyPojo(); + String payload = this.objectMapper.writeValueAsString(myPojo); + Message message = Message.builder() + .messageAttributes(Collections.singletonMap(typeHeader, + MessageAttributeValue.builder().stringValue(MyPojo.class.getName()).build())) + .body(payload).messageId(UUID.randomUUID().toString()).build(); + SqsMessagingMessageConverter converter = new SqsMessagingMessageConverter(); + converter.setPayloadTypeHeader(typeHeader); + org.springframework.messaging.Message resultMessage = converter.toMessagingMessage(message); + assertThat(resultMessage.getPayload()).isEqualTo(myPojo); + } + + @SuppressWarnings("unchecked") + @Test + void shouldUseProvidedHeaderMapper() { + Message message = Message.builder().body("test-payload").messageId(UUID.randomUUID().toString()).build(); + SqsMessagingMessageConverter converter = new SqsMessagingMessageConverter(); + HeaderMapper mapper = mock(HeaderMapper.class); + MessageHeaders messageHeaders = new MessageHeaders(Collections.singletonMap("testHeader", "testHeaderValue")); + given(mapper.toHeaders(message)).willReturn(messageHeaders); + converter.setHeaderMapper(mapper); + org.springframework.messaging.Message resultMessage = converter.toMessagingMessage(message); + assertThat(resultMessage.getHeaders()).isEqualTo(messageHeaders); + } + + @Test + void shouldUseProvidedPayloadConverter() throws Exception { + MyPojo myPojo = new MyPojo(); + String payload = new ObjectMapper().writeValueAsString(myPojo); + Message message = Message.builder().body(payload).messageId(UUID.randomUUID().toString()).build(); + MessageConverter payloadConverter = mock(MessageConverter.class); + when(payloadConverter.fromMessage(any(org.springframework.messaging.Message.class), eq(MyPojo.class))) + .thenReturn(myPojo); + SqsMessagingMessageConverter converter = new SqsMessagingMessageConverter(); + converter.setPayloadMessageConverter(payloadConverter); + converter.setPayloadTypeMapper(msg -> MyPojo.class); + org.springframework.messaging.Message resultMessage = converter.toMessagingMessage(message); + assertThat(resultMessage.getPayload()).isEqualTo(myPojo); + } + + static class MyPojo { + + private String myProperty = "myValue"; + + public String getMyProperty() { + return this.myProperty; + } + + public void setMyProperty(String myProperty) { + this.myProperty = myProperty; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + MyPojo myPojo = (MyPojo) o; + return Objects.equals(myProperty, myPojo.myProperty); + } + + @Override + public int hashCode() { + return myProperty != null ? myProperty.hashCode() : 0; + } + } + +} diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/support/resolver/BatchAcknowledgmentArgumentResolverTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/support/resolver/BatchAcknowledgmentArgumentResolverTests.java new file mode 100644 index 000000000..495ff66fa --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/support/resolver/BatchAcknowledgmentArgumentResolverTests.java @@ -0,0 +1,136 @@ +/* + * 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.support.resolver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +import io.awspring.cloud.sqs.listener.SqsHeaders; +import io.awspring.cloud.sqs.listener.acknowledgement.AcknowledgementCallback; +import io.awspring.cloud.sqs.listener.acknowledgement.BatchAcknowledgement; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; + +/** + * @author Tomaz Fernandes + * @since 3.0 + */ +@SuppressWarnings("unchecked") +class BatchAcknowledgmentArgumentResolverTests { + + @Test + void shouldConvertAndAcknowledge() throws Exception { + AcknowledgementCallback callback = mock(AcknowledgementCallback.class); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> batch = Arrays.asList(message1, message2, message3); + MessageHeaders headers = new MessageHeaders(getMessageHeaders(callback)); + Message>> rootMessage = mock(Message.class); + given(rootMessage.getPayload()).willReturn(batch); + given(message1.getHeaders()).willReturn(headers); + given(message2.getHeaders()).willReturn(headers); + given(message3.getHeaders()).willReturn(headers); + given(callback.onAcknowledge(any(Collection.class))).willReturn(CompletableFuture.completedFuture(null)); + HandlerMethodArgumentResolver resolver = new BatchAcknowledgmentArgumentResolver(); + Object result = resolver.resolveArgument(null, rootMessage); + assertThat(result).isNotNull().isInstanceOf(BatchAcknowledgement.class); + ((BatchAcknowledgement) result).acknowledge(); + then(callback).should().onAcknowledge(batch); + } + + @Test + void shouldConvertAndAcknowledgePartialBatch() throws Exception { + AcknowledgementCallback callback = mock(AcknowledgementCallback.class); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> batch = Arrays.asList(message1, message2, message3); + MessageHeaders headers = new MessageHeaders(getMessageHeaders(callback)); + Message>> rootMessage = mock(Message.class); + given(rootMessage.getPayload()).willReturn(batch); + given(message1.getHeaders()).willReturn(headers); + given(message2.getHeaders()).willReturn(headers); + given(message3.getHeaders()).willReturn(headers); + given(callback.onAcknowledge(any(Collection.class))).willReturn(CompletableFuture.completedFuture(null)); + HandlerMethodArgumentResolver resolver = new BatchAcknowledgmentArgumentResolver(); + Object result = resolver.resolveArgument(null, rootMessage); + assertThat(result).isNotNull().isInstanceOf(BatchAcknowledgement.class); + List> messagesToAcknowledge = Collections.singletonList(message2); + ((BatchAcknowledgement) result).acknowledge(messagesToAcknowledge); + then(callback).should().onAcknowledge(messagesToAcknowledge); + } + + @Test + void shouldConvertAndAcknowledgeAsync() throws Exception { + AcknowledgementCallback callback = mock(AcknowledgementCallback.class); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> batch = Arrays.asList(message1, message2, message3); + MessageHeaders headers = new MessageHeaders(getMessageHeaders(callback)); + Message>> rootMessage = mock(Message.class); + given(rootMessage.getPayload()).willReturn(batch); + given(message1.getHeaders()).willReturn(headers); + given(message2.getHeaders()).willReturn(headers); + given(message3.getHeaders()).willReturn(headers); + given(callback.onAcknowledge(any(Collection.class))).willReturn(CompletableFuture.completedFuture(null)); + HandlerMethodArgumentResolver resolver = new BatchAcknowledgmentArgumentResolver(); + Object result = resolver.resolveArgument(null, rootMessage); + assertThat(result).isNotNull().isInstanceOf(BatchAcknowledgement.class); + ((BatchAcknowledgement) result).acknowledgeAsync().join(); + then(callback).should().onAcknowledge(batch); + } + + @Test + void shouldConvertAndAcknowledgePartialBatchAsync() throws Exception { + AcknowledgementCallback callback = mock(AcknowledgementCallback.class); + Message message1 = mock(Message.class); + Message message2 = mock(Message.class); + Message message3 = mock(Message.class); + List> batch = Arrays.asList(message1, message2, message3); + MessageHeaders headers = getMessageHeaders(callback); + Message>> rootMessage = mock(Message.class); + given(rootMessage.getPayload()).willReturn(batch); + given(message1.getHeaders()).willReturn(headers); + given(message2.getHeaders()).willReturn(headers); + given(message3.getHeaders()).willReturn(headers); + given(callback.onAcknowledge(any(Collection.class))).willReturn(CompletableFuture.completedFuture(null)); + HandlerMethodArgumentResolver resolver = new BatchAcknowledgmentArgumentResolver(); + Object result = resolver.resolveArgument(null, rootMessage); + assertThat(result).isNotNull().isInstanceOf(BatchAcknowledgement.class); + List> messagesToAcknowledge = Collections.singletonList(message2); + ((BatchAcknowledgement) result).acknowledgeAsync(messagesToAcknowledge).join(); + then(callback).should().onAcknowledge(messagesToAcknowledge); + } + + @NotNull + private MessageHeaders getMessageHeaders(AcknowledgementCallback callback) { + return new MessageHeaders(Collections.singletonMap(SqsHeaders.SQS_ACKNOWLEDGMENT_CALLBACK_HEADER, callback)); + } + +} diff --git a/spring-cloud-aws-sqs/src/test/resources/junit-platform.properties b/spring-cloud-aws-sqs/src/test/resources/junit-platform.properties new file mode 100644 index 000000000..278a0ded0 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/resources/junit-platform.properties @@ -0,0 +1,3 @@ +junit.jupiter.execution.parallel.enabled = true +junit.jupiter.execution.parallel.mode.classes.default = concurrent +junit.jupiter.execution.parallel.mode.default = concurrent diff --git a/spring-cloud-aws-sqs/src/test/resources/logback.xml b/spring-cloud-aws-sqs/src/test/resources/logback.xml new file mode 100644 index 000000000..081a29bf2 --- /dev/null +++ b/spring-cloud-aws-sqs/src/test/resources/logback.xml @@ -0,0 +1,51 @@ + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spring-cloud-aws-starters/pom.xml b/spring-cloud-aws-starters/pom.xml index bbb56f1e1..15193d28d 100644 --- a/spring-cloud-aws-starters/pom.xml +++ b/spring-cloud-aws-starters/pom.xml @@ -20,6 +20,7 @@ spring-cloud-aws-starter-ses spring-cloud-aws-starter-s3 spring-cloud-aws-starter-sns + spring-cloud-aws-starter-sqs spring-cloud-aws-starter-dynamodb diff --git a/spring-cloud-aws-starters/spring-cloud-aws-starter-sqs/pom.xml b/spring-cloud-aws-starters/spring-cloud-aws-starter-sqs/pom.xml new file mode 100644 index 000000000..7952a62c7 --- /dev/null +++ b/spring-cloud-aws-starters/spring-cloud-aws-starter-sqs/pom.xml @@ -0,0 +1,28 @@ + + + + + spring-cloud-aws-starters + io.awspring.cloud + 3.0.0-SNAPSHOT + + + 4.0.0 + + spring-cloud-aws-starter-sqs + Spring Cloud AWS SQS Starter + Spring Cloud AWS Simple Queue Service Starter + + + + io.awspring.cloud + spring-cloud-aws-starter + + + io.awspring.cloud + spring-cloud-aws-sqs + + +