diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java index 4f30ab25de3..759d336bc23 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 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. @@ -37,6 +37,11 @@ public class TaskExecutionProperties { private final Shutdown shutdown = new Shutdown(); + /** + * Determine when the task executor is to be created. + */ + private Mode mode = Mode.AUTO; + /** * Prefix to use for the names of newly created threads. */ @@ -54,6 +59,14 @@ public class TaskExecutionProperties { return this.shutdown; } + public Mode getMode() { + return this.mode; + } + + public void setMode(Mode mode) { + this.mode = mode; + } + public String getThreadNamePrefix() { return this.threadNamePrefix; } @@ -209,4 +222,23 @@ public class TaskExecutionProperties { } + /** + * Determine when the task executor is to be created. + * + * @since 3.5.0 + */ + public enum Mode { + + /** + * Create the task executor if no user-defined executor is present. + */ + AUTO, + + /** + * Create the task executor even if a user-defined executor is present. + */ + FORCE + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutorConfigurations.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutorConfigurations.java index ee2bbf14e84..c09e4df2f07 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutorConfigurations.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutorConfigurations.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 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. @@ -18,8 +18,11 @@ package org.springframework.boot.autoconfigure.task; import java.util.concurrent.Executor; +import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading; import org.springframework.boot.autoconfigure.thread.Threading; import org.springframework.boot.task.SimpleAsyncTaskExecutorBuilder; @@ -27,12 +30,15 @@ import org.springframework.boot.task.SimpleAsyncTaskExecutorCustomizer; import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder; import org.springframework.boot.task.ThreadPoolTaskExecutorCustomizer; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Lazy; import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.core.task.TaskDecorator; import org.springframework.core.task.TaskExecutor; import org.springframework.scheduling.annotation.AsyncAnnotationBeanPostProcessor; +import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; /** @@ -46,19 +52,18 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; class TaskExecutorConfigurations { @Configuration(proxyBeanMethods = false) - @ConditionalOnMissingBean(Executor.class) + @Conditional(OnExecutorCondition.class) + @Import(AsyncConfigurerConfiguration.class) static class TaskExecutorConfiguration { - @Bean(name = { TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME, - AsyncAnnotationBeanPostProcessor.DEFAULT_TASK_EXECUTOR_BEAN_NAME }) + @Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME) @ConditionalOnThreading(Threading.VIRTUAL) SimpleAsyncTaskExecutor applicationTaskExecutorVirtualThreads(SimpleAsyncTaskExecutorBuilder builder) { return builder.build(); } + @Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME) @Lazy - @Bean(name = { TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME, - AsyncAnnotationBeanPostProcessor.DEFAULT_TASK_EXECUTOR_BEAN_NAME }) @ConditionalOnThreading(Threading.PLATFORM) ThreadPoolTaskExecutor applicationTaskExecutor(ThreadPoolTaskExecutorBuilder threadPoolTaskExecutorBuilder) { return threadPoolTaskExecutorBuilder.build(); @@ -140,4 +145,41 @@ class TaskExecutorConfigurations { } + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(name = AsyncAnnotationBeanPostProcessor.DEFAULT_TASK_EXECUTOR_BEAN_NAME, + value = AsyncConfigurer.class) + static class AsyncConfigurerConfiguration { + + @Bean + @ConditionalOnMissingBean + AsyncConfigurer applicationTaskExecutorAsyncConfigurer(BeanFactory beanFactory) { + return new AsyncConfigurer() { + @Override + public Executor getAsyncExecutor() { + return beanFactory.getBean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME, + Executor.class); + } + }; + } + + } + + static class OnExecutorCondition extends AnyNestedCondition { + + OnExecutorCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnMissingBean(Executor.class) + private static final class ExecutorBeanCondition { + + } + + @ConditionalOnProperty(value = "spring.task.execution.mode", havingValue = "force") + private static final class ModelCondition { + + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java index f8c6f2f337f..004c350b097 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 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. @@ -30,6 +30,7 @@ import org.junit.jupiter.api.condition.JRE; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionOverrideException; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.task.SimpleAsyncTaskExecutorBuilder; import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder; @@ -44,6 +45,7 @@ import org.springframework.core.task.SyncTaskExecutor; import org.springframework.core.task.TaskDecorator; import org.springframework.core.task.TaskExecutor; import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @@ -204,16 +206,43 @@ class TaskExecutionAutoConfigurationTests { @Test void taskExecutorWhenHasCustomTaskExecutorShouldBackOff() { - this.contextRunner.withUserConfiguration(CustomTaskExecutorConfig.class).run((context) -> { + this.contextRunner.withBean("customTaskExecutor", Executor.class, SyncTaskExecutor::new).run((context) -> { assertThat(context).hasSingleBean(Executor.class); assertThat(context.getBean(Executor.class)).isSameAs(context.getBean("customTaskExecutor")); }); } + @Test + void taskExecutorWhenModeIsAutoAndHasCustomTaskExecutorShouldBackOff() { + this.contextRunner.withBean("customTaskExecutor", Executor.class, SyncTaskExecutor::new) + .withPropertyValues("spring.task.execution.mode=auto") + .run((context) -> { + assertThat(context).hasSingleBean(Executor.class); + assertThat(context.getBean(Executor.class)).isSameAs(context.getBean("customTaskExecutor")); + }); + } + + @Test + void taskExecutorWhenModeIsForceAndHasCustomTaskExecutorShouldCreateApplicationTaskExecutor() { + this.contextRunner.withBean("customTaskExecutor", Executor.class, SyncTaskExecutor::new) + .withPropertyValues("spring.task.execution.mode=force") + .run((context) -> assertThat(context.getBeansOfType(Executor.class)).hasSize(2) + .containsKeys("customTaskExecutor", "applicationTaskExecutor")); + } + + @Test + void taskExecutorWhenModeIsForceAndHasCustomTaskExecutorWithReservedNameShouldThrowException() { + this.contextRunner.withBean("applicationTaskExecutor", Executor.class, SyncTaskExecutor::new) + .withPropertyValues("spring.task.execution.mode=force") + .run((context) -> assertThat(context).hasFailed() + .getFailure() + .isInstanceOf(BeanDefinitionOverrideException.class)); + } + @Test @EnabledForJreRange(min = JRE.JAVA_21) void whenVirtualThreadsAreEnabledAndCustomTaskExecutorIsDefinedThenSimpleAsyncTaskExecutorThatUsesVirtualThreadsBacksOff() { - this.contextRunner.withUserConfiguration(CustomTaskExecutorConfig.class) + this.contextRunner.withBean("customTaskExecutor", Executor.class, SyncTaskExecutor::new) .withPropertyValues("spring.threads.virtual.enabled=true") .run((context) -> { assertThat(context).hasSingleBean(Executor.class); @@ -223,25 +252,101 @@ class TaskExecutionAutoConfigurationTests { @Test void enableAsyncUsesAutoConfiguredOneByDefault() { - this.contextRunner.withPropertyValues("spring.task.execution.thread-name-prefix=task-test-") + this.contextRunner.withPropertyValues("spring.task.execution.thread-name-prefix=auto-task-") .withUserConfiguration(AsyncConfiguration.class, TestBean.class) .run((context) -> { + assertThat(context).hasSingleBean(AsyncConfigurer.class); assertThat(context).hasSingleBean(TaskExecutor.class); TestBean bean = context.getBean(TestBean.class); String text = bean.echo("something").get(); - assertThat(text).contains("task-test-").contains("something"); + assertThat(text).contains("auto-task-").contains("something"); }); } + @Test + void enableAsyncUsesCustomExecutorIfPresent() { + this.contextRunner.withPropertyValues("spring.task.execution.thread-name-prefix=auto-task-") + .withBean("customTaskExecutor", Executor.class, () -> createCustomAsyncExecutor("custom-task-")) + .withUserConfiguration(AsyncConfiguration.class, TestBean.class) + .run((context) -> { + assertThat(context).doesNotHaveBean(AsyncConfigurer.class); + assertThat(context).hasSingleBean(Executor.class); + TestBean bean = context.getBean(TestBean.class); + String text = bean.echo("something").get(); + assertThat(text).contains("custom-task-").contains("something"); + }); + } + + @Test + void enableAsyncUsesAutoConfiguredExecutorWhenModeIsForceAndHasCustomTaskExecutor() { + this.contextRunner + .withPropertyValues("spring.task.execution.thread-name-prefix=auto-task-", + "spring.task.execution.mode=force") + .withBean("customTaskExecutor", Executor.class, () -> createCustomAsyncExecutor("custom-task-")) + .withUserConfiguration(AsyncConfiguration.class, TestBean.class) + .run((context) -> { + assertThat(context).hasSingleBean(AsyncConfigurer.class); + assertThat(context.getBeansOfType(Executor.class)).hasSize(2); + TestBean bean = context.getBean(TestBean.class); + String text = bean.echo("something").get(); + assertThat(text).contains("auto-task-").contains("something"); + }); + } + + @Test + void enableAsyncUsesCustomExecutorWhenModeIsForceAndHasCustomTaskExecutorWithReservedName() { + this.contextRunner + .withPropertyValues("spring.task.execution.thread-name-prefix=auto-task-", + "spring.task.execution.mode=force") + .withBean("taskExecutor", Executor.class, () -> createCustomAsyncExecutor("custom-task-")) + .withUserConfiguration(AsyncConfiguration.class, TestBean.class) + .run((context) -> { + assertThat(context).doesNotHaveBean(AsyncConfigurer.class); + assertThat(context.getBeansOfType(Executor.class)).hasSize(2); + TestBean bean = context.getBean(TestBean.class); + String text = bean.echo("something").get(); + assertThat(text).contains("custom-task-").contains("something"); + }); + } + + @Test + void enableAsyncUsesAsyncConfigurerWhenModeIsForce() { + this.contextRunner + .withPropertyValues("spring.task.execution.thread-name-prefix=auto-task-", + "spring.task.execution.mode=force") + .withBean("taskExecutor", Executor.class, () -> createCustomAsyncExecutor("custom-task-")) + .withBean("customAsyncConfigurer", AsyncConfigurer.class, () -> new AsyncConfigurer() { + @Override + public Executor getAsyncExecutor() { + return createCustomAsyncExecutor("async-task-"); + } + }) + .withUserConfiguration(AsyncConfiguration.class, TestBean.class) + .run((context) -> { + assertThat(context).hasSingleBean(AsyncConfigurer.class); + assertThat(context.getBeansOfType(Executor.class)).hasSize(2) + .containsOnlyKeys("taskExecutor", "applicationTaskExecutor"); + TestBean bean = context.getBean(TestBean.class); + String text = bean.echo("something").get(); + assertThat(text).contains("async-task-").contains("something"); + }); + } + + private Executor createCustomAsyncExecutor(String threadNamePrefix) { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + executor.setThreadNamePrefix(threadNamePrefix); + return executor; + } + @Test void enableAsyncUsesAutoConfiguredOneByDefaultEvenThoughSchedulingIsConfigured() { - this.contextRunner.withPropertyValues("spring.task.execution.thread-name-prefix=task-test-") + this.contextRunner.withPropertyValues("spring.task.execution.thread-name-prefix=auto-task-") .withConfiguration(AutoConfigurations.of(TaskSchedulingAutoConfiguration.class)) .withUserConfiguration(AsyncConfiguration.class, SchedulingConfiguration.class, TestBean.class) .run((context) -> { TestBean bean = context.getBean(TestBean.class); String text = bean.echo("something").get(); - assertThat(text).contains("task-test-").contains("something"); + assertThat(text).contains("auto-task-").contains("something"); }); } @@ -299,16 +404,6 @@ class TaskExecutionAutoConfigurationTests { } - @Configuration(proxyBeanMethods = false) - static class CustomTaskExecutorConfig { - - @Bean - Executor customTaskExecutor() { - return new SyncTaskExecutor(); - } - - } - @Configuration(proxyBeanMethods = false) @EnableAsync static class AsyncConfiguration { diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/task-execution-and-scheduling.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/task-execution-and-scheduling.adoc index ddc39edd63c..c54af72bbb9 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/task-execution-and-scheduling.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/task-execution-and-scheduling.adoc @@ -4,26 +4,39 @@ In the absence of an javadoc:java.util.concurrent.Executor[] bean in the context, Spring Boot auto-configures an javadoc:org.springframework.core.task.AsyncTaskExecutor[]. When virtual threads are enabled (using Java 21+ and configprop:spring.threads.virtual.enabled[] set to `true`) this will be a javadoc:org.springframework.core.task.SimpleAsyncTaskExecutor[] that uses virtual threads. Otherwise, it will be a javadoc:org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor[] with sensible defaults. -In either case, the auto-configured executor will be automatically used for: -- asynchronous task execution (`@EnableAsync`) -- Spring for GraphQL's asynchronous handling of javadoc:java.util.concurrent.Callable[] return values from controller methods -- Spring MVC's asynchronous request processing -- Spring WebFlux's blocking execution support +If a custom `Executor` bean is present, you can request Spring Boot to auto-configure an `AsyncTaskExecutor` anyway, as follows: + +[configprops,yaml] +---- +spring: + task: + execution: + mode: force +---- + +The auto-configured executor will be automatically used for: + +- Asynchronous task execution (`@EnableAsync`), unless an javadoc:org.springframework.scheduling.annotation.AsyncConfigurer[] bean is present. +- Spring for GraphQL's asynchronous handling of javadoc:java.util.concurrent.Callable[] return values from controller methods. +- Spring MVC's asynchronous request processing. +- Spring WebFlux's blocking execution support. [TIP] ==== If you have defined a custom javadoc:java.util.concurrent.Executor[] in the context, both regular task execution (that is javadoc:org.springframework.scheduling.annotation.EnableAsync[format=annotation]) and Spring for GraphQL will use it. -However, the Spring MVC and Spring WebFlux support will only use it if it is an javadoc:org.springframework.core.task.AsyncTaskExecutor[] implementation (named `applicationTaskExecutor`). -Depending on your target arrangement, you could change your javadoc:java.util.concurrent.Executor[] into an javadoc:org.springframework.core.task.AsyncTaskExecutor[] or define both an javadoc:org.springframework.core.task.AsyncTaskExecutor[] and an javadoc:org.springframework.scheduling.annotation.AsyncConfigurer[] wrapping your custom javadoc:java.util.concurrent.Executor[]. +However, the Spring MVC and Spring WebFlux support will only use it if it is an javadoc:org.springframework.core.task.AsyncTaskExecutor[] implementation named `applicationTaskExecutor`. -The auto-configured javadoc:org.springframework.boot.task.ThreadPoolTaskExecutorBuilder[] allows you to easily create instances that reproduce what the auto-configuration does by default. +Depending on your target arrangement, you could set configprop:spring.task.execution.mode[] to `force` to auto-configure an `applicationTaskExecutor`, change your javadoc:java.util.concurrent.Executor[] into an javadoc:org.springframework.core.task.AsyncTaskExecutor[] or define both an javadoc:org.springframework.core.task.AsyncTaskExecutor[] and an javadoc:org.springframework.scheduling.annotation.AsyncConfigurer[] wrapping your custom javadoc:java.util.concurrent.Executor[]. + +Another option is to define those beans explicitly. +The auto-configured javadoc:org.springframework.boot.task.ThreadPoolTaskExecutorBuilder[] or javadoc:org.springframework.boot.task.SimpleAsyncTaskExecutorBuilder[] allow you to easily create instances that reproduce what the auto-configuration does by default. ==== [NOTE] ==== -If multiple javadoc:java.util.concurrent.Executor[] beans are defined, regular task execution fallbacks to a bean named `taskExecutor`. -GraphQL, Spring MVC and Spring WebFlux support fallback to a bean named `applicationTaskExecutor`. +If multiple javadoc:java.util.concurrent.Executor[] beans are defined with configprop:spring.task.execution.mode[] to `force`, all the supported integrations look for a bean named `applicationTaskExecutor`. +If the auto-configured `AsyncTaskExecutor` is not defined, only regular task execution fallbacks to a bean named `taskExecutor` to match Spring Framework's behavior. ====