Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -41,11 +32,9 @@
})
@Order()
@EnableConfigurationProperties(RequestReplyProperties.class)
public class RequestReplyAutoConfiguration implements ApplicationContextInitializer<GenericApplicationContext> {
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
Expand All @@ -61,36 +50,4 @@ public SolaceHeaderParser solaceHeaderParser() {
public RequestReplyServiceImpl requestReplyService() {
return new RequestReplyServiceImpl();
}

@Override
public void initialize(final GenericApplicationContext context) {
final BindResult<RequestReplyProperties> 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<Consumer<Message<?>>>(
((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");
}
}
}
Original file line number Diff line number Diff line change
@@ -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)}.
*
* <p>This logic used to live in an {@link org.springframework.context.ApplicationContextInitializer}.
* Spring Boot applies such initializers to <em>every</em> 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
* (<a href="https://github.com/solacecommunity/spring-cloud-stream-request-reply/issues/8">issue&nbsp;#8</a>).</p>
*
* <p>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.</p>
*/
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<Message<Object>>}. spring-cloud-function
* uses it to wire the registration to the {@code <bindingName>-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<RequestReplyProperties> 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<FunctionRegistration<Consumer<Message<?>>>> replyConsumerSupplier() {
return () -> {
final RequestReplyServiceImpl service = beanFactory.getBean(RequestReplyServiceImpl.class);
return new FunctionRegistration<Consumer<Message<?>>>(service::onReplyReceived)
.type(REPLY_CONSUMER_TYPE.getType());
};
}
}
1 change: 0 additions & 1 deletion src/main/resources/META-INF/spring.factories
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
helios57 marked this conversation as resolved.

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
* <a href="https://github.com/solacecommunity/spring-cloud-stream-request-reply/issues/8">issue&nbsp;#8</a>.
*
* <p>A {@code @JsonTest} (or any other sliced test) only loads a small, fixed set of
* auto-configurations and therefore does <em>not</em> 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
* <em>every</em> 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.</p>
*
* <p>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.</p>
*/
@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");
}
}
Loading
Loading