From 4cb92ae6497f5589695a109542eb3df9c490369a Mon Sep 17 00:00:00 2001 From: ue56135 Date: Mon, 22 Jun 2026 15:36:57 +0000 Subject: [PATCH] Make request/reply auto-configuration excludable (#8); document context propagation (#50) Issue #8: the per-binding reply-consumer registration lived in an ApplicationContextInitializer registered via spring.factories. Spring applies such initializers to every application context it creates - including sliced tests such as @JsonTest - and they cannot be turned off via @ImportAutoConfiguration(exclude = ...) or spring.autoconfigure.exclude. Because the registered reply consumers depend on the RequestReplyServiceImpl bean (which is absent in a slice), the context failed to start. Move the registration into RequestReplyFunctionRegistrar, an ImportBeanDefinitionRegistrar imported by RequestReplyAutoConfiguration, and remove the ApplicationContextInitializer entry from spring.factories. The registration now runs only when the auto-configuration itself is active, so excluding the auto-configuration also disables the reply-consumer registration. Issue #50 (context propagation to every CompletableFuture stage) was already fixed and released in 6.0.2 but was missing from the documentation. Add the 6.0.2 CHANGELOG entry and a README section describing the behaviour. Tests (both verified red before the fix, green after): - RequestReplyAutoConfigurationExclusionTests: the reporter's @JsonTest scenario - RequestReplyExcludeAutoConfigurationTests: explicit spring.autoconfigure.exclude Full suite: 87 tests pass. --- CHANGELOG.md | 8 ++ README.md | 53 ++++++++++ .../RequestReplyAutoConfiguration.java | 49 +-------- .../RequestReplyFunctionRegistrar.java | 99 +++++++++++++++++++ src/main/resources/META-INF/spring.factories | 1 - ...tReplyAutoConfigurationExclusionTests.java | 73 ++++++++++++++ ...estReplyExcludeAutoConfigurationTests.java | 57 +++++++++++ 7 files changed, 293 insertions(+), 47 deletions(-) create mode 100644 src/main/java/community/solace/spring/cloud/requestreply/service/RequestReplyFunctionRegistrar.java create mode 100644 src/test/java/community/solace/spring/cloud/requestreply/service/RequestReplyAutoConfigurationExclusionTests.java create mode 100644 src/test/java/community/solace/spring/cloud/requestreply/service/RequestReplyExcludeAutoConfigurationTests.java diff --git a/CHANGELOG.md b/CHANGELOG.md index ae2180b..3d37760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Fixed +- The request/reply auto-configuration can now be excluded from sliced or focused tests (for example `@JsonTest`, or via `@ImportAutoConfiguration(exclude = RequestReplyAutoConfiguration.class)` / `spring.autoconfigure.exclude`). The per-binding reply consumers are no longer contributed by an `ApplicationContextInitializer` (which Spring applies to every context and which cannot be excluded), but by an `ImportBeanDefinitionRegistrar` that is only active when the auto-configuration itself is loaded. ([#8](https://github.com/solacecommunity/spring-cloud-stream-request-reply/issues/8)) + ## [6.1.0] - 2026-06-22 ### Changed - Updated spring-boot-parent from 4.0.6 to 4.1.0 - Updated spring-cloud-dependencies from 2025.1.1 to 2025.1.2 +## [6.0.2] - 2026-05-28 +### Fixed +- Context propagation (e.g. the MDC carrying `traceId`/`spanId` used for tracing) is now applied to every stage of the asynchronous request/reply pipeline by wrapping the executor instead of wrapping individual tasks. Stages that an application chains onto the returned `CompletableFuture` now observe the same context as the original request, without having to capture a `ContextSnapshot` themselves. ([#50](https://github.com/solacecommunity/spring-cloud-stream-request-reply/issues/50)) + ## [6.0.1] - 2026-04-20 ### Changed - Updated maven-source-plugin from 3.3.1 to 3.4.0 diff --git a/README.md b/README.md index 1117112..8067faa 100644 --- a/README.md +++ b/README.md @@ -594,6 +594,59 @@ logging: pattern: correlation=[${spring.application.name:},%X{traceId:-},%X{spanId:-}] ``` +#### Context propagation across asynchronous stages + +A request/reply call is processed on a dedicated executor, so the request is sent and the reply is +awaited on a different thread than the caller. To keep tracing (and any other thread-local context +such as the SLF4J `MDC`) consistent, this library propagates the +[micrometer context](https://docs.micrometer.io/context-propagation/reference/) captured on the +calling thread to that executor. + +Because the executor itself is wrapped (rather than each individual task), the context is restored +for **every** stage of the internal pipeline as well as for stages that your application chains onto +the returned `CompletableFuture`: + +```java +MDC.put("traceId", "abc"); + +requestReplyService + .requestReplyToTopic(request, topic, Response.class, Duration.ofSeconds(10)) + .thenApply(response -> { + // MDC.get("traceId") is still "abc" here, even though this runs on an executor thread + return enrich(response); + }); +``` + +You do not need to capture a `ContextSnapshot` yourself. As usual for micrometer context +propagation, the relevant `ThreadLocalAccessor` (e.g. the one for the `MDC`, normally registered by +your observability/tracing setup) must be present on the `ContextRegistry`. + +### Excluding the starter in tests + +Tests that do not need the request/reply functionality (for example a `@JsonTest` or a `@WebMvcTest` +slice) do not load this starter's auto-configuration, so it is inactive there automatically. + +If you use a broader test that does pick up auto-configuration but you still want to switch the +request/reply machinery off, exclude the auto-configuration like any other: + +```java +@SpringBootTest +@ImportAutoConfiguration(exclude = RequestReplyAutoConfiguration.class) +class MyTest { + // ... +} +``` + +or via configuration: + +```yaml +spring: + autoconfigure: + exclude: community.solace.spring.cloud.requestreply.service.RequestReplyAutoConfiguration +``` + +When excluded, neither the request/reply service nor the per-binding reply consumers are registered. + ## Known issues and Open Points ### Statefulness diff --git a/src/main/java/community/solace/spring/cloud/requestreply/service/RequestReplyAutoConfiguration.java b/src/main/java/community/solace/spring/cloud/requestreply/service/RequestReplyAutoConfiguration.java index 54b3051..4950f4f 100644 --- a/src/main/java/community/solace/spring/cloud/requestreply/service/RequestReplyAutoConfiguration.java +++ b/src/main/java/community/solace/spring/cloud/requestreply/service/RequestReplyAutoConfiguration.java @@ -2,35 +2,26 @@ import community.solace.spring.cloud.requestreply.config.RequestReplyProperties; import community.solace.spring.cloud.requestreply.service.header.parser.SolaceHeaderParser; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.context.properties.bind.BindResult; -import org.springframework.boot.context.properties.bind.Binder; -import org.springframework.cloud.function.context.FunctionRegistration; import org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration; import org.springframework.cloud.stream.config.BinderFactoryAutoConfiguration; import org.springframework.cloud.stream.function.FunctionConfiguration; -import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; -import org.springframework.context.support.GenericApplicationContext; +import org.springframework.context.annotation.Import; import org.springframework.context.support.PropertySourcesPlaceholderConfigurer; -import org.springframework.core.ResolvableType; import org.springframework.core.annotation.Order; -import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; -import java.util.function.Consumer; - @Configuration @ConditionalOnClass({MessageChannel.class}) @ComponentScan("community.solace.spring.cloud.requestreply.service") +@Import(RequestReplyFunctionRegistrar.class) @AutoConfigureAfter({ ContextFunctionCatalogAutoConfiguration.class, PropertySourcesPlaceholderConfigurer.class, @@ -41,11 +32,9 @@ }) @Order() @EnableConfigurationProperties(RequestReplyProperties.class) -public class RequestReplyAutoConfiguration implements ApplicationContextInitializer { +public class RequestReplyAutoConfiguration { private static final int SOLACE_CONFIGURERS_PRIORITY = 200; - private static final Logger LOG = LoggerFactory.getLogger(RequestReplyAutoConfiguration.class); - @Bean @Order(SOLACE_CONFIGURERS_PRIORITY) @ConditionalOnMissingBean @@ -61,36 +50,4 @@ public SolaceHeaderParser solaceHeaderParser() { public RequestReplyServiceImpl requestReplyService() { return new RequestReplyServiceImpl(); } - - @Override - public void initialize(final GenericApplicationContext context) { - final BindResult bindResult = Binder.get(context.getEnvironment()) - .bind("spring.cloud.stream.requestreply", - RequestReplyProperties.class); - - if (!bindResult.isBound()) { - return; - } - - final RequestReplyProperties requestReplyProperties = bindResult.get(); - - for (final String bindingName : requestReplyProperties.getBindingMappingNames()) { - context.registerBean( - bindingName, - FunctionRegistration.class, - () -> new FunctionRegistration>>( - ((RequestReplyServiceImpl) context.getBean("requestReplyServiceImpl"))::onReplyReceived - ) - .type(ResolvableType.forClassWithGenerics( - Consumer.class, - ResolvableType.forClassWithGenerics( - Message.class, - Object.class - ) - ).getType() - ) - ); - LOG.info("Register binding: " + bindingName + " for receiving replies"); - } - } } \ No newline at end of file diff --git a/src/main/java/community/solace/spring/cloud/requestreply/service/RequestReplyFunctionRegistrar.java b/src/main/java/community/solace/spring/cloud/requestreply/service/RequestReplyFunctionRegistrar.java new file mode 100644 index 0000000..efc6cf7 --- /dev/null +++ b/src/main/java/community/solace/spring/cloud/requestreply/service/RequestReplyFunctionRegistrar.java @@ -0,0 +1,99 @@ +package community.solace.spring.cloud.requestreply.service; + +import community.solace.spring.cloud.requestreply.config.RequestReplyProperties; +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.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.boot.context.properties.bind.BindResult; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.cloud.function.context.FunctionRegistration; +import org.springframework.context.EnvironmentAware; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.ResolvableType; +import org.springframework.core.env.Environment; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.messaging.Message; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Registers one {@link FunctionRegistration} per configured + * {@code spring.cloud.stream.requestreply.bindingMapping} so that spring-cloud-stream routes incoming + * replies to {@link RequestReplyServiceImpl#onReplyReceived(Message)}. + * + *

This logic used to live in an {@link org.springframework.context.ApplicationContextInitializer}. + * Spring Boot applies such initializers to every application context it creates - including + * sliced test contexts such as {@code @JsonTest} - and they cannot be turned off through + * {@code @ImportAutoConfiguration(exclude = ...)} or {@code spring.autoconfigure.exclude}. Because the + * registered reply consumers depend on the {@link RequestReplyServiceImpl} bean (which is absent in a + * slice), the context failed to start + * (issue #8).

+ * + *

By contributing the bean definitions from a registrar that is + * {@link org.springframework.context.annotation.Import imported} by {@link RequestReplyAutoConfiguration}, + * the registration now runs only when the auto-configuration itself is active. Excluding the + * auto-configuration therefore also disables the reply-consumer registration, which is the behaviour a + * test slice expects.

+ */ +class RequestReplyFunctionRegistrar implements ImportBeanDefinitionRegistrar, EnvironmentAware, BeanFactoryAware { + + private static final Logger LOG = LoggerFactory.getLogger(RequestReplyFunctionRegistrar.class); + + /** + * The generic type of the reply consumer, i.e. {@code Consumer>}. spring-cloud-function + * uses it to wire the registration to the {@code -in-0} input binding. + */ + private static final ResolvableType REPLY_CONSUMER_TYPE = ResolvableType.forClassWithGenerics( + Consumer.class, + ResolvableType.forClassWithGenerics(Message.class, Object.class)); + + private Environment environment; + private BeanFactory beanFactory; + + @Override + public void setEnvironment(final Environment environment) { + this.environment = environment; + } + + @Override + public void setBeanFactory(final BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + } + + @Override + public void registerBeanDefinitions(final AnnotationMetadata importingClassMetadata, final BeanDefinitionRegistry registry) { + final BindResult bindResult = Binder.get(environment) + .bind("spring.cloud.stream.requestreply", RequestReplyProperties.class); + + if (!bindResult.isBound()) { + return; + } + + for (final String bindingName : bindResult.get().getBindingMappingNames()) { + if (registry.containsBeanDefinition(bindingName)) { + // Never override a function the application defined itself under the same name. + LOG.debug("Skip binding: {} for receiving replies, a bean with that name already exists", bindingName); + continue; + } + + final RootBeanDefinition definition = new RootBeanDefinition(FunctionRegistration.class); + definition.setInstanceSupplier(replyConsumerSupplier()); + registry.registerBeanDefinition(bindingName, definition); + + LOG.info("Register binding: {} for receiving replies", bindingName); + } + } + + private Supplier>>> replyConsumerSupplier() { + return () -> { + final RequestReplyServiceImpl service = beanFactory.getBean(RequestReplyServiceImpl.class); + return new FunctionRegistration>>(service::onReplyReceived) + .type(REPLY_CONSUMER_TYPE.getType()); + }; + } +} diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories index 411b704..c57dd4e 100644 --- a/src/main/resources/META-INF/spring.factories +++ b/src/main/resources/META-INF/spring.factories @@ -1,2 +1 @@ org.springframework.boot.env.EnvironmentPostProcessor=community.solace.spring.cloud.requestreply.env.ReplyTopicWithWildcardPropertySourceEnvironmentPostProcessor -org.springframework.context.ApplicationContextInitializer=community.solace.spring.cloud.requestreply.service.RequestReplyAutoConfiguration diff --git a/src/test/java/community/solace/spring/cloud/requestreply/service/RequestReplyAutoConfigurationExclusionTests.java b/src/test/java/community/solace/spring/cloud/requestreply/service/RequestReplyAutoConfigurationExclusionTests.java new file mode 100644 index 0000000..292f6df --- /dev/null +++ b/src/test/java/community/solace/spring/cloud/requestreply/service/RequestReplyAutoConfigurationExclusionTests.java @@ -0,0 +1,73 @@ +package community.solace.spring.cloud.requestreply.service; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.autoconfigure.json.JsonTest; +import org.springframework.cloud.function.context.FunctionRegistration; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import tools.jackson.databind.ObjectMapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Regression test for + * issue #8. + * + *

A {@code @JsonTest} (or any other sliced test) only loads a small, fixed set of + * auto-configurations and therefore does not contain the request/reply beans. Before the + * fix, the request/reply function registration lived in an + * {@link org.springframework.context.ApplicationContextInitializer} that Spring Boot applies to + * every application context regardless of slicing or {@code @ImportAutoConfiguration(exclude = ...)}. + * That initializer registered one reply-consumer bean per configured + * {@code spring.cloud.stream.requestreply.bindingMapping}, each of which depends on the + * {@code RequestReplyServiceImpl} bean. Since that service bean is absent in a slice, the context + * failed to start.

+ * + *

The reply-consumer registration is now contributed by {@link RequestReplyFunctionRegistrar}, + * which is imported by {@link RequestReplyAutoConfiguration}. As a result it only runs when the + * auto-configuration itself is active, so a slice that does not load the auto-configuration starts + * cleanly and none of the reply-consumer beans are registered.

+ */ +@JsonTest +@ContextConfiguration(classes = RequestReplyAutoConfigurationExclusionTests.JsonOnlyApplication.class) +@TestPropertySource(properties = { + // A binding mapping is present in the environment, exactly as it would be in a real + // application. Before the fix this alone was enough to make the slice fail to start. + "spring.cloud.stream.requestreply.bindingMapping[0].binding=replyConsumerThatMustNotExist", + "spring.cloud.stream.requestreply.bindingMapping[0].replyTopic=some/reply/topic" +}) +class RequestReplyAutoConfigurationExclusionTests { + + @SpringBootConfiguration + static class JsonOnlyApplication { + } + + @Autowired + private ApplicationContext context; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void jsonSliceStartsWithoutRegisteringReplyConsumers() { + // The slice itself is functional: this is what the reporter actually wanted to test. + assertNotNull(objectMapper, "the @JsonTest slice should still provide an ObjectMapper"); + + // The reply consumer derived from the binding mapping must not have been registered, because + // the request/reply auto-configuration is not part of this slice. + assertFalse(context.containsBean("replyConsumerThatMustNotExist"), + "no reply-consumer bean must be registered when the request/reply auto-configuration is not loaded"); + assertEquals(0, context.getBeanNamesForType(FunctionRegistration.class).length, + "no request/reply FunctionRegistration beans must be registered in a slice"); + + // Without the fix the RequestReplyServiceImpl is missing yet referenced, which is what broke + // the context. Assert it is genuinely absent so the test stays meaningful. + assertEquals(0, context.getBeanNamesForType(RequestReplyServiceImpl.class).length, + "the request/reply service must not be present in a slice that excludes it"); + } +} diff --git a/src/test/java/community/solace/spring/cloud/requestreply/service/RequestReplyExcludeAutoConfigurationTests.java b/src/test/java/community/solace/spring/cloud/requestreply/service/RequestReplyExcludeAutoConfigurationTests.java new file mode 100644 index 0000000..5cec922 --- /dev/null +++ b/src/test/java/community/solace/spring/cloud/requestreply/service/RequestReplyExcludeAutoConfigurationTests.java @@ -0,0 +1,57 @@ +package community.solace.spring.cloud.requestreply.service; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cloud.function.context.FunctionRegistration; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * Verifies the behaviour the reporter of + * issue #8 + * originally expected: when {@link RequestReplyAutoConfiguration} is excluded, none of the + * request/reply infrastructure - in particular the per-binding reply consumers that previously came + * from an un-excludable {@code ApplicationContextInitializer} - must be contributed to the context. + * + *

A binding mapping is present in the environment so that, before the fix, the initializer would + * have registered a reply consumer regardless of the exclusion and broken the context.

+ */ +@SpringBootTest(classes = RequestReplyExcludeAutoConfigurationTests.MinimalApplication.class) +@TestPropertySource(properties = { + "spring.autoconfigure.exclude=community.solace.spring.cloud.requestreply.service.RequestReplyAutoConfiguration", + "spring.cloud.stream.requestreply.bindingMapping[0].binding=excludedReplyBinding", + "spring.cloud.stream.requestreply.bindingMapping[0].replyTopic=reply/excluded" +}) +class RequestReplyExcludeAutoConfigurationTests { + + /** + * Deliberately a plain {@code @Configuration} (no component scanning) so that the only way the + * request/reply beans could appear is through the auto-configuration that we exclude here. + */ + @Configuration + @EnableAutoConfiguration + @Import(TestChannelBinderConfiguration.class) + static class MinimalApplication { + } + + @Autowired + private ApplicationContext context; + + @Test + void excludingTheAutoConfigurationRemovesAllRequestReplyBeans() { + assertEquals(0, context.getBeanNamesForType(RequestReplyServiceImpl.class).length, + "the request/reply service must not be created when the auto-configuration is excluded"); + assertFalse(context.containsBean("excludedReplyBinding"), + "no reply consumer must be registered for a binding mapping when the auto-configuration is excluded"); + assertEquals(0, context.getBeanNamesForType(FunctionRegistration.class).length, + "no request/reply FunctionRegistration beans must be registered when the auto-configuration is excluded"); + } +}