From 644ac088868c4166b3bcad8cc27acfe089fc23b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Thu, 27 Nov 2025 22:48:39 +0530 Subject: [PATCH] Add support for context propagation in task execution Closes gh-48033 --- config/checkstyle/import-control.xml | 2 +- core/spring-boot-autoconfigure/build.gradle | 1 + .../task/TaskExecutionAutoConfiguration.java | 3 +- .../task/TaskExecutionProperties.java | 13 +++ .../task/TaskExecutorConfigurations.java | 15 +++ .../TaskExecutionAutoConfigurationTests.java | 94 +++++++++++++++++++ .../pages/actuator/observability.adoc | 5 +- 7 files changed, 129 insertions(+), 4 deletions(-) diff --git a/config/checkstyle/import-control.xml b/config/checkstyle/import-control.xml index 575b993240e..87f3caee62b 100644 --- a/config/checkstyle/import-control.xml +++ b/config/checkstyle/import-control.xml @@ -62,7 +62,7 @@ - + diff --git a/core/spring-boot-autoconfigure/build.gradle b/core/spring-boot-autoconfigure/build.gradle index a8686fe2be7..2070dd412b6 100644 --- a/core/spring-boot-autoconfigure/build.gradle +++ b/core/spring-boot-autoconfigure/build.gradle @@ -30,6 +30,7 @@ dependencies { optional("com.github.ben-manes.caffeine:caffeine") optional("org.aspectj:aspectjweaver") + optional("io.micrometer:context-propagation") optional("jakarta.servlet:jakarta.servlet-api") optional("javax.money:money-api") optional("org.springframework:spring-web") diff --git a/core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfiguration.java b/core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfiguration.java index ab6eb21cf47..7e17fd524d9 100644 --- a/core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfiguration.java +++ b/core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfiguration.java @@ -35,7 +35,8 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @ConditionalOnClass(ThreadPoolTaskExecutor.class) @AutoConfiguration @EnableConfigurationProperties(TaskExecutionProperties.class) -@Import({ TaskExecutorConfigurations.ThreadPoolTaskExecutorBuilderConfiguration.class, +@Import({ TaskExecutorConfigurations.TaskExecutorContextPropagationConfiguration.class, + TaskExecutorConfigurations.ThreadPoolTaskExecutorBuilderConfiguration.class, TaskExecutorConfigurations.SimpleAsyncTaskExecutorBuilderConfiguration.class, TaskExecutorConfigurations.TaskExecutorConfiguration.class, TaskExecutorConfigurations.BootstrapExecutorConfiguration.class }) diff --git a/core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java b/core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java index 144fbb60e67..e2dbfcab061 100644 --- a/core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java +++ b/core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java @@ -44,6 +44,11 @@ public class TaskExecutionProperties { */ private Mode mode = Mode.AUTO; + /** + * Whether to propagate the current context to task executions. + */ + private boolean propagateContext; + /** * Prefix to use for the names of newly created threads. */ @@ -69,6 +74,14 @@ public class TaskExecutionProperties { this.mode = mode; } + public boolean getPropagateContext() { + return this.propagateContext; + } + + public void setPropagateContext(boolean propagateContext) { + this.propagateContext = propagateContext; + } + public String getThreadNamePrefix() { return this.threadNamePrefix; } diff --git a/core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutorConfigurations.java b/core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutorConfigurations.java index a519c65374a..3909065231b 100644 --- a/core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutorConfigurations.java +++ b/core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutorConfigurations.java @@ -19,6 +19,7 @@ package org.springframework.boot.autoconfigure.task; import java.util.List; import java.util.concurrent.Executor; +import io.micrometer.context.ContextSnapshot; import org.jspecify.annotations.Nullable; import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; @@ -29,6 +30,7 @@ import org.springframework.beans.factory.config.BeanFactoryPostProcessor; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +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.autoconfigure.condition.ConditionalOnThreading; @@ -47,6 +49,7 @@ import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.core.task.TaskDecorator; import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.support.CompositeTaskDecorator; +import org.springframework.core.task.support.ContextPropagatingTaskDecorator; import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @@ -68,6 +71,18 @@ class TaskExecutorConfigurations { return (!taskDecorators.isEmpty()) ? new CompositeTaskDecorator(taskDecorators) : null; } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(ContextSnapshot.class) + static class TaskExecutorContextPropagationConfiguration { + + @Bean + @ConditionalOnProperty(name = "spring.task.execution.propagate-context", havingValue = "true") + ContextPropagatingTaskDecorator contextPropagatingTaskDecorator() { + return new ContextPropagatingTaskDecorator(); + } + + } + @Configuration(proxyBeanMethods = false) @Conditional(OnExecutorCondition.class) @Import({ AsyncConfigurerWrapperConfiguration.class, AsyncConfigurerConfiguration.class }) diff --git a/core/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java b/core/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java index 40d6fb26e7a..862452952f7 100644 --- a/core/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java +++ b/core/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java @@ -16,6 +16,10 @@ package org.springframework.boot.autoconfigure.task; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; @@ -24,6 +28,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import io.micrometer.context.ThreadLocalAccessor; import org.assertj.core.api.InstanceOfAssertFactories; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; @@ -42,6 +47,7 @@ import org.springframework.boot.test.context.assertj.AssertableApplicationContex import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.context.runner.ContextConsumer; import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.boot.testsupport.classpath.resources.WithResource; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -50,6 +56,7 @@ import org.springframework.core.task.SyncTaskExecutor; import org.springframework.core.task.TaskDecorator; import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.support.CompositeTaskDecorator; +import org.springframework.core.task.support.ContextPropagatingTaskDecorator; import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.AsyncConfigurer; @@ -253,6 +260,35 @@ class TaskExecutionAutoConfigurationTests { }); } + @Test + @WithResource(name = "META-INF/services/io.micrometer.context.ThreadLocalAccessor", + content = "org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfigurationTests$TestThreadLocalAccessor") + void asyncTaskExecutorShouldNotNotRegisterContextPropagatingTaskDecoratorByDefault() { + this.contextRunner.withUserConfiguration(AsyncConfiguration.class, TestBean.class).run((context) -> { + assertThat(context).doesNotHaveBean(ContextPropagatingTaskDecorator.class); + TestBean bean = context.getBean(TestBean.class); + TestThreadLocalHolder.setValue("from-context"); + String text = bean.echoContext().get(); + assertThat(text).contains("task-").endsWith("null"); + }); + + } + + @Test + @WithResource(name = "META-INF/services/io.micrometer.context.ThreadLocalAccessor", + content = "org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfigurationTests$TestThreadLocalAccessor") + void asyncTaskExecutorWhenContextPropagationIsEnabledShouldRegisterBean() { + this.contextRunner.withUserConfiguration(AsyncConfiguration.class, TestBean.class) + .withPropertyValues("spring.task.execution.propagate-context=true") + .run((context) -> { + assertThat(context).hasSingleBean(ContextPropagatingTaskDecorator.class); + TestBean bean = context.getBean(TestBean.class); + TestThreadLocalHolder.setValue("from-context"); + String text = bean.echoContext().get(); + assertThat(text).contains("task-").endsWith("from-context"); + }); + } + @Test void taskExecutorWhenHasCustomTaskExecutorShouldBackOff() { this.contextRunner.withBean("customTaskExecutor", Executor.class, SyncTaskExecutor::new).run((context) -> { @@ -591,6 +627,14 @@ class TaskExecutionAutoConfigurationTests { return thread.getName(); } + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @WithResource(name = "META-INF/services/io.micrometer.context.ThreadLocalAccessor", + content = "org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfigurationTests.TestThreadLocalAccessor") + @interface WithThreadLocalAccessor { + + } + @Configuration(proxyBeanMethods = false) static class CustomThreadPoolTaskExecutorBuilderConfig { @@ -622,6 +666,56 @@ class TaskExecutionAutoConfigurationTests { return CompletableFuture.completedFuture(Thread.currentThread().getName() + " " + text); } + @Async + Future echoContext() { + return CompletableFuture + .completedFuture(Thread.currentThread().getName() + " " + TestThreadLocalHolder.getValue()); + } + + } + + static class TestThreadLocalHolder { + + private static final ThreadLocal holder = new ThreadLocal<>(); + + static void setValue(String value) { + holder.set(value); + } + + static String getValue() { + return holder.get(); + } + + static void reset() { + holder.remove(); + } + + } + + public static class TestThreadLocalAccessor implements ThreadLocalAccessor { + + static final String KEY = "test.threadlocal"; + + @Override + public Object key() { + return KEY; + } + + @Override + public String getValue() { + return TestThreadLocalHolder.getValue(); + } + + @Override + public void setValue(String value) { + TestThreadLocalHolder.setValue(value); + } + + @Override + public void setValue() { + TestThreadLocalHolder.reset(); + } + } } diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/observability.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/observability.adoc index e76aeff3c9b..bff522b6396 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/observability.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/observability.adoc @@ -29,8 +29,9 @@ Observability support relies on the https://github.com/micrometer-metrics/contex By default, javadoc:java.lang.ThreadLocal[] values are not automatically reinstated in reactive operators. This behavior is controlled with the configprop:spring.reactor.context-propagation[] property, which can be set to `auto` to enable automatic propagation. -If you're working with javadoc:org.springframework.scheduling.annotation.Async[format=annotation] methods or use an javadoc:org.springframework.core.task.AsyncTaskExecutor[], you have to register the javadoc:org.springframework.core.task.support.ContextPropagatingTaskDecorator[] on the executor, otherwise the observability context is lost when switching threads. -This can be done using this configuration: +If you're working with javadoc:org.springframework.scheduling.annotation.Async[format=annotation] methods and the javadoc:org.springframework.core.task.AsyncTaskExecutor[] is auto-configured, you have to opt-in for context propagation using the configprop:spring.task.execution.propagate-context[] property. + +If you are configuring the javadoc:org.springframework.core.task.AsyncTaskExecutor[] yourself, then you need to register a javadoc:org.springframework.core.task.support.ContextPropagatingTaskDecorator[] bean, as shown in the following example: include-code::ContextPropagationConfiguration[]