GH-1210: Add Kotlin suspend functions support

* GH-1210: Add Kotlin suspend functions support

Fixes https://github.com/spring-projects/spring-amqp/issues/1210

Kotlin Coroutines are essentially `Future` wrapping.
Therefore, it is natural to have `suspend` support on `@RabbitListener`
methods as we do now for `CompletableFuture` and `Mono`

* Introduce some utilities since we cannot reuse existing from Spring Messaging:
they are there about Kotlin Coroutines only for reactive handlers
* Some code clean up in the `RabbitListenerAnnotationBeanPostProcessor`
for the latest Java
* Add optional dep for `kotlinx-coroutines-reactor` and document the feature
* Fix since javadocs to reflect 2.4.x.

Co-authored-by: Artem Bilan <abilan@vmware.com>
This commit is contained in:
Gary Russell
2023-08-31 11:27:13 -07:00
committed by GitHub
parent d8e7bd3ed8
commit 1bc1ae9edf
9 changed files with 284 additions and 109 deletions

View File

@@ -52,6 +52,7 @@ ext {
jaywayJsonPathVersion = '2.4.0'
junit4Version = '4.13.2'
junitJupiterVersion = '5.8.2'
kotlinCoroutinesVersion = '1.6.4'
log4jVersion = '2.17.2'
logbackVersion = '1.2.3'
lz4Version = '1.8.0'
@@ -379,6 +380,7 @@ project('spring-rabbit') {
}
optionalApi "com.jayway.jsonpath:json-path:$jaywayJsonPathVersion"
optionalApi "org.apache.commons:commons-pool2:$commonsPoolVersion"
optionalApi "org.jetbrains.kotlinx:kotlinx-coroutines-reactor:$kotlinCoroutinesVersion"
testApi project(':spring-rabbit-junit')
testImplementation("com.willowtreeapps.assertk:assertk-jvm:$assertkVersion")

View File

@@ -18,8 +18,6 @@ package org.springframework.amqp.rabbit.annotation;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
@@ -30,7 +28,6 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -56,6 +53,7 @@ import org.springframework.amqp.rabbit.listener.MultiMethodRabbitListenerEndpoin
import org.springframework.amqp.rabbit.listener.RabbitListenerContainerFactory;
import org.springframework.amqp.rabbit.listener.RabbitListenerEndpointRegistrar;
import org.springframework.amqp.rabbit.listener.RabbitListenerEndpointRegistry;
import org.springframework.amqp.rabbit.listener.adapter.AmqpMessageHandlerMethodFactory;
import org.springframework.amqp.rabbit.listener.adapter.ReplyPostProcessor;
import org.springframework.amqp.rabbit.listener.api.RabbitListenerErrorHandler;
import org.springframework.amqp.support.converter.MessageConverter;
@@ -76,7 +74,6 @@ import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.EnvironmentAware;
import org.springframework.context.expression.StandardBeanExpressionResolver;
import org.springframework.core.MethodParameter;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.annotation.MergedAnnotations;
@@ -88,12 +85,9 @@ import org.springframework.core.env.Environment;
import org.springframework.core.task.TaskExecutor;
import org.springframework.format.support.DefaultFormattingConversionService;
import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.converter.GenericMessageConverter;
import org.springframework.messaging.handler.annotation.support.DefaultMessageHandlerMethodFactory;
import org.springframework.messaging.handler.annotation.support.MessageHandlerMethodFactory;
import org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException;
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;
@@ -101,8 +95,6 @@ import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.validation.Validator;
/**
@@ -440,14 +432,10 @@ public class RabbitListenerAnnotationBeanPostProcessor
List<Object> resolvedQueues = resolveQueues(rabbitListener, declarables);
if (!resolvedQueues.isEmpty()) {
if (resolvedQueues.get(0) instanceof String) {
endpoint.setQueueNames(resolvedQueues.stream()
.map(o -> (String) o)
.collect(Collectors.toList()).toArray(new String[0]));
endpoint.setQueueNames(resolvedQueues.stream().map(o -> (String) o).toArray(String[]::new));
}
else {
endpoint.setQueues(resolvedQueues.stream()
.map(o -> (Queue) o)
.collect(Collectors.toList()).toArray(new Queue[0]));
endpoint.setQueues(resolvedQueues.stream().map(o -> (Queue) o).toArray(Queue[]::new));
}
}
endpoint.setConcurrency(resolveExpressionAsStringOrInteger(rabbitListener.concurrency(), "concurrency"));
@@ -664,12 +652,10 @@ public class RabbitListenerAnnotationBeanPostProcessor
String[] queues = rabbitListener.queues();
QueueBinding[] bindings = rabbitListener.bindings();
org.springframework.amqp.rabbit.annotation.Queue[] queuesToDeclare = rabbitListener.queuesToDeclare();
List<String> queueNames = new ArrayList<String>();
List<Queue> queueBeans = new ArrayList<Queue>();
if (queues.length > 0) {
for (int i = 0; i < queues.length; i++) {
resolveQueues(queues[i], queueNames, queueBeans);
}
List<String> queueNames = new ArrayList<>();
List<Queue> queueBeans = new ArrayList<>();
for (String queue : queues) {
resolveQueues(queue, queueNames, queueBeans);
}
if (!queueNames.isEmpty()) {
// revert to the previous behavior of just using the name when there is mixture of String and Queue
@@ -681,8 +667,8 @@ public class RabbitListenerAnnotationBeanPostProcessor
throw new BeanInitializationException(
"@RabbitListener can have only one of 'queues', 'queuesToDeclare', or 'bindings'");
}
for (int i = 0; i < queuesToDeclare.length; i++) {
queueNames.add(declareQueue(queuesToDeclare[i], declarables));
for (org.springframework.amqp.rabbit.annotation.Queue queue : queuesToDeclare) {
queueNames.add(declareQueue(queue, declarables));
}
}
if (bindings.length > 0) {
@@ -752,7 +738,7 @@ public class RabbitListenerAnnotationBeanPostProcessor
declareExchangeAndBinding(binding, queueName, declarables);
}
}
return queues.toArray(new String[queues.size()]);
return queues.toArray(new String[0]);
}
private String declareQueue(org.springframework.amqp.rabbit.annotation.Queue bindingQueue,
@@ -859,7 +845,7 @@ public class RabbitListenerAnnotationBeanPostProcessor
}
private Map<String, Object> resolveArguments(Argument[] arguments) {
Map<String, Object> map = new HashMap<String, Object>();
Map<String, Object> map = new HashMap<>();
for (Argument arg : arguments) {
String key = resolveExpressionAsString(arg.name(), "@Argument.name");
if (StringUtils.hasText(key)) {
@@ -1025,7 +1011,7 @@ public class RabbitListenerAnnotationBeanPostProcessor
}
private MessageHandlerMethodFactory createDefaultMessageHandlerMethodFactory() {
DefaultMessageHandlerMethodFactory defaultFactory = new DefaultMessageHandlerMethodFactory();
DefaultMessageHandlerMethodFactory defaultFactory = new AmqpMessageHandlerMethodFactory();
Validator validator = RabbitListenerAnnotationBeanPostProcessor.this.registrar.getValidator();
if (validator != null) {
defaultFactory.setValidator(validator);
@@ -1038,74 +1024,14 @@ public class RabbitListenerAnnotationBeanPostProcessor
List<HandlerMethodArgumentResolver> customArgumentsResolver = new ArrayList<>(
RabbitListenerAnnotationBeanPostProcessor.this.registrar.getCustomMethodArgumentResolvers());
defaultFactory.setCustomArgumentResolvers(customArgumentsResolver);
GenericMessageConverter messageConverter = new GenericMessageConverter(
this.defaultFormattingConversionService);
defaultFactory.setMessageConverter(messageConverter);
// Has to be at the end - look at PayloadMethodArgumentResolver documentation
customArgumentsResolver.add(new OptionalEmptyAwarePayloadArgumentResolver(messageConverter, validator));
defaultFactory.setMessageConverter(new GenericMessageConverter(this.defaultFormattingConversionService));
defaultFactory.afterPropertiesSet();
return defaultFactory;
}
}
private static class OptionalEmptyAwarePayloadArgumentResolver extends PayloadMethodArgumentResolver {
OptionalEmptyAwarePayloadArgumentResolver(
org.springframework.messaging.converter.MessageConverter messageConverter,
@Nullable Validator validator) {
super(messageConverter, validator);
}
@Override
public Object resolveArgument(MethodParameter parameter, Message<?> message) throws Exception { // NOSONAR
Object resolved = null;
try {
resolved = super.resolveArgument(parameter, message);
}
catch (MethodArgumentNotValidException ex) {
Type type = parameter.getGenericParameterType();
if (isOptional(message, type)) {
BindingResult bindingResult = ex.getBindingResult();
if (bindingResult != null) {
List<ObjectError> allErrors = bindingResult.getAllErrors();
if (allErrors.size() == 1) {
String defaultMessage = allErrors.get(0).getDefaultMessage();
if ("Payload value must not be empty".equals(defaultMessage)) {
return Optional.empty();
}
}
}
}
throw ex;
}
/*
* Replace Optional.empty() list elements with null.
*/
if (resolved instanceof List) {
List<?> list = ((List<?>) resolved);
for (int i = 0; i < list.size(); i++) {
if (list.get(i).equals(Optional.empty())) {
list.set(i, null);
}
}
}
return resolved;
}
private boolean isOptional(Message<?> message, Type type) {
return (Optional.class.equals(type) || (type instanceof ParameterizedType
&& Optional.class.equals(((ParameterizedType) type).getRawType())))
&& message.getPayload().equals(Optional.empty());
}
@Override
protected boolean isEmptyPayload(Object payload) {
return payload == null || payload.equals(Optional.empty());
}
}
/**
* The metadata holder of the class with {@link RabbitListener}
* and {@link RabbitHandler} annotations.
@@ -1145,6 +1071,9 @@ public class RabbitListenerAnnotationBeanPostProcessor
/**
* A method annotated with {@link RabbitListener}, together with the annotations.
*
* @param method the method with annotations
* @param annotations on the method
*/
private static class ListenerMethod {

View File

@@ -365,7 +365,7 @@ public abstract class AbstractAdaptableMessageListener implements ChannelAwareMe
* response message back.
* @param resultArg the result object to handle (never <code>null</code>)
* @param request the original request message
* @param channel the Rabbit channel to operate on (may be <code>null</code>)
* @param channel the Rabbit channel to operate on (maybe <code>null</code>)
* @param source the source data for the method invocation - e.g.
* {@code o.s.messaging.Message<?>}; may be null
* @see #buildMessage
@@ -404,8 +404,8 @@ public abstract class AbstractAdaptableMessageListener implements ChannelAwareMe
}
else if (monoPresent && MonoHandler.isMono(resultArg.getReturnValue())) {
if (!this.isManualAck) {
this.logger.warn("Container AcknowledgeMode must be MANUAL for a Mono<?> return type; "
+ "otherwise the container will ack the message immediately");
this.logger.warn("Container AcknowledgeMode must be MANUAL for a Mono<?> return type" +
"(or Kotlin suspend function); otherwise the container will ack the message immediately");
}
MonoHandler.subscribe(resultArg.getReturnValue(),
r -> asyncSuccess(resultArg, request, channel, source, r),
@@ -461,7 +461,7 @@ public abstract class AbstractAdaptableMessageListener implements ChannelAwareMe
}
private void asyncFailure(Message request, Channel channel, Throwable t) {
this.logger.error("Future or Mono was completed with an exception for " + request, t);
this.logger.error("Future, Mono, or suspend function was completed with an exception for " + request, t);
try {
channel.basicNack(request.getMessageProperties().getDeliveryTag(), false,
ContainerUtils.shouldRequeue(this.defaultRequeueRejected, t, this.logger));

View File

@@ -0,0 +1,145 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.amqp.rabbit.listener.adapter;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.List;
import java.util.Optional;
import org.springframework.core.KotlinDetector;
import org.springframework.core.MethodParameter;
import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.converter.MessageConverter;
import org.springframework.messaging.handler.annotation.support.DefaultMessageHandlerMethodFactory;
import org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException;
import org.springframework.messaging.handler.annotation.support.PayloadMethodArgumentResolver;
import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver;
import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolverComposite;
import org.springframework.messaging.handler.invocation.InvocableHandlerMethod;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.validation.Validator;
/**
* Extension of the {@link DefaultMessageHandlerMethodFactory} for Spring AMQP requirements.
*
* @author Artem Bilan
*
* @since 2.4.16
*/
public class AmqpMessageHandlerMethodFactory extends DefaultMessageHandlerMethodFactory {
private final HandlerMethodArgumentResolverComposite argumentResolvers =
new HandlerMethodArgumentResolverComposite();
private MessageConverter messageConverter;
private Validator validator;
@Override
public void setMessageConverter(MessageConverter messageConverter) {
super.setMessageConverter(messageConverter);
this.messageConverter = messageConverter;
}
@Override
public void setValidator(Validator validator) {
super.setValidator(validator);
this.validator = validator;
}
@Override
protected List<HandlerMethodArgumentResolver> initArgumentResolvers() {
List<HandlerMethodArgumentResolver> resolvers = super.initArgumentResolvers();
if (KotlinDetector.isKotlinPresent()) {
// Insert before PayloadMethodArgumentResolver
resolvers.add(resolvers.size() - 1, new ContinuationHandlerMethodArgumentResolver());
}
// Has to be at the end, but before PayloadMethodArgumentResolver
resolvers.add(resolvers.size() - 1,
new OptionalEmptyAwarePayloadArgumentResolver(this.messageConverter, this.validator));
this.argumentResolvers.addResolvers(resolvers);
return resolvers;
}
@Override
public InvocableHandlerMethod createInvocableHandlerMethod(Object bean, Method method) {
InvocableHandlerMethod handlerMethod = new KotlinAwareInvocableHandlerMethod(bean, method);
handlerMethod.setMessageMethodArgumentResolvers(this.argumentResolvers);
return handlerMethod;
}
private static class OptionalEmptyAwarePayloadArgumentResolver extends PayloadMethodArgumentResolver {
OptionalEmptyAwarePayloadArgumentResolver(MessageConverter messageConverter, @Nullable Validator validator) {
super(messageConverter, validator);
}
@Override
public Object resolveArgument(MethodParameter parameter, Message<?> message) throws Exception { // NOSONAR
Object resolved;
try {
resolved = super.resolveArgument(parameter, message);
}
catch (MethodArgumentNotValidException ex) {
Type type = parameter.getGenericParameterType();
if (isOptional(message, type)) {
BindingResult bindingResult = ex.getBindingResult();
if (bindingResult != null) {
List<ObjectError> allErrors = bindingResult.getAllErrors();
if (allErrors.size() == 1) {
String defaultMessage = allErrors.get(0).getDefaultMessage();
if ("Payload value must not be empty".equals(defaultMessage)) {
return Optional.empty();
}
}
}
}
throw ex;
}
/*
* Replace Optional.empty() list elements with null.
*/
if (resolved instanceof List) {
List<?> list = (List<?>) resolved;
for (int i = 0; i < list.size(); i++) {
if (list.get(i).equals(Optional.empty())) {
list.set(i, null);
}
}
}
return resolved;
}
private boolean isOptional(Message<?> message, Type type) {
return (Optional.class.equals(type) ||
(type instanceof ParameterizedType
&& Optional.class.equals(((ParameterizedType) type).getRawType())))
&& message.getPayload().equals(Optional.empty());
}
@Override
protected boolean isEmptyPayload(Object payload) {
return payload == null || payload.equals(Optional.empty());
}
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.amqp.rabbit.listener.adapter;
import org.springframework.core.MethodParameter;
import org.springframework.messaging.Message;
import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver;
import reactor.core.publisher.Mono;
/**
* No-op resolver for method arguments of type {@link kotlin.coroutines.Continuation}.
* <p>
* This class is similar to
* {@link org.springframework.messaging.handler.annotation.reactive.ContinuationHandlerMethodArgumentResolver}
* but for regular {@link HandlerMethodArgumentResolver} contract.
*
* @author Artem Bilan
*
* @since 2.4.16
*
* @see org.springframework.messaging.handler.annotation.reactive.ContinuationHandlerMethodArgumentResolver
*/
public class ContinuationHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return "kotlin.coroutines.Continuation".equals(parameter.getParameterType().getName());
}
@Override
public Object resolveArgument(MethodParameter parameter, Message<?> message) {
return Mono.empty();
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.amqp.rabbit.listener.adapter;
import java.lang.reflect.Method;
import org.springframework.core.CoroutinesUtils;
import org.springframework.core.KotlinDetector;
import org.springframework.messaging.handler.invocation.InvocableHandlerMethod;
/**
* An {@link InvocableHandlerMethod} extension for supporting Kotlin {@code suspend} function.
*
* @author Artem Bilan
*
* @since 2.4.16
*/
public class KotlinAwareInvocableHandlerMethod extends InvocableHandlerMethod {
public KotlinAwareInvocableHandlerMethod(Object bean, Method method) {
super(bean, method);
}
@Override
protected Object doInvoke(Object... args) throws Exception {
Method method = getBridgedMethod();
if (KotlinDetector.isSuspendingFunction(method)) {
return CoroutinesUtils.invokeSuspendingFunction(method, getBean(), args);
}
else {
return super.doInvoke(args);
}
}
}

View File

@@ -20,6 +20,7 @@ import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isTrue
import org.junit.jupiter.api.Test
import org.springframework.amqp.core.AcknowledgeMode
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory
import org.springframework.amqp.rabbit.connection.CachingConnectionFactory
import org.springframework.amqp.rabbit.core.RabbitTemplate
@@ -56,14 +57,14 @@ class EnableRabbitKotlinTests {
private lateinit var config: Config
@Test
fun `send and wait for consume` () {
fun `send and wait for consume`() {
val template = RabbitTemplate(this.config.cf())
template.convertAndSend("kotlinQueue", "test")
assertThat(this.config.latch.await(10, TimeUnit.SECONDS)).isTrue();
}
@Test
fun `send and wait for consume with EH` () {
fun `send and wait for consume with EH`() {
val template = RabbitTemplate(this.config.cf())
template.convertAndSend("kotlinQueue1", "test")
assertThat(this.config.ehLatch.await(10, TimeUnit.SECONDS)).isTrue();
@@ -78,27 +79,22 @@ class EnableRabbitKotlinTests {
val latch = CountDownLatch(1)
@RabbitListener(queues = ["kotlinQueue"])
fun handle(@Suppress("UNUSED_PARAMETER") data: String) {
suspend fun handle(@Suppress("UNUSED_PARAMETER") data: String) {
this.latch.countDown()
}
@Bean
fun rabbitListenerContainerFactory(cf: CachingConnectionFactory): SimpleRabbitListenerContainerFactory {
val factory = SimpleRabbitListenerContainerFactory()
factory.setConnectionFactory(cf)
return factory
}
fun rabbitListenerContainerFactory(cf: CachingConnectionFactory) =
SimpleRabbitListenerContainerFactory().also {
it.setAcknowledgeMode(AcknowledgeMode.MANUAL)
it.setConnectionFactory(cf)
}
@Bean
fun cf(): CachingConnectionFactory {
return CachingConnectionFactory(
RabbitAvailableCondition.getBrokerRunning().connectionFactory)
}
fun cf() = CachingConnectionFactory(RabbitAvailableCondition.getBrokerRunning().connectionFactory)
@Bean
fun multi(): Multi {
return Multi()
}
fun multi() = Multi()
@Bean
fun proxyListenerPostProcessor(): BeanPostProcessor? {

View File

@@ -3075,7 +3075,7 @@ Each queue needed a separate property.
====== Reply Management
The existing support in `MessageListenerAdapter` already lets your method have a non-void return type.
When that is the case, the result of the invocation is encapsulated in a message sent to the the address specified in the `ReplyToAddress` header of the original message, or to the default address configured on the listener.
When that is the case, the result of the invocation is encapsulated in a message sent to the address specified in the `ReplyToAddress` header of the original message, or to the default address configured on the listener.
You can set that default address by using the `@SendTo` annotation of the messaging abstraction.
Assuming our `processOrder` method should now return an `OrderStatus`, we can write it as follows to automatically send a reply:
@@ -3635,6 +3635,10 @@ If some exception occurs within the listener method that prevents creation of th
Starting with versions 2.2.21, 2.3.13, 2.4.1, the `AcknowledgeMode` will be automatically set the `MANUAL` when async return types are detected.
In addition, incoming messages with fatal exceptions will be negatively acknowledged individually, previously any prior unacknowledged message were also negatively acknowledged.
Starting with version 2.4.16, the `@RabbitListener` (and `@RabbitHandler`) methods can be marked with Kotlin `suspend` and the whole handling process and reply producing (optional) happens on respective Kotlin coroutine.
The above mentioned rules about `AcknowledgeMode.MANUAL` still apply.
The `org.jetbrains.kotlinx:kotlinx-coroutines-reactor` dependency must be present in classpath to allow `suspend` function invocations.
[[threading]]
===== Threading and Asynchronous Consumers

View File

@@ -24,7 +24,7 @@ See <<declarable-recovery>> for more information.
==== Remoting Support
Support remoting using Spring Framework's RMI support is deprecated and will be removed in 3.0.
Support of remoting using Spring Framework's RMI support is deprecated and will be removed in 3.0.
See <<remoting>> for more information.
==== Message Converter Changes