From 6bf1e1a4a590e18ebaa8ea48690f6846b63fa859 Mon Sep 17 00:00:00 2001 From: Hyun Lee Date: Thu, 24 Sep 2026 00:00:26 -0500 Subject: [PATCH] GH-641 - Use dedicated task executor for @ApplicationModuleListeners. @ApplicationModuleListener now runs on the executor bean named applicationModuleListenerTaskExecutor if present, and on the default async executor otherwise. The fallback keeps setups without that bean working (plain Spring applications, applications only using spring-modulith-events-api), in which a plain bean name qualifier would fail with a NoSuchBeanDefinitionException on listener invocation. A runtime hint allows evaluating the qualifier expression in native images. EventPublicationAutoConfiguration registers the executor through Spring Boot's ThreadPoolTaskExecutorBuilder, or SimpleAsyncTaskExecutorBuilder if virtual threads are enabled, so that the spring.task.execution.* properties and task executor customizers apply to it. It backs off if a bean with that name is declared and is not a default candidate, so that by-type lookups keep resolving the application's default executor. Signed-off-by: Hyun Lee --- .../events/ApplicationModuleListener.java | 14 ++- ...ApplicationModuleListenerRuntimeHints.java | 46 ++++++++ .../resources/META-INF/spring/aot.factories | 2 + ...icationModuleListenerIntegrationTests.java | 100 +++++++++++++++++ .../ApplicationModuleListenerUnitTests.java | 19 ++++ .../EventPublicationAutoConfiguration.java | 34 +++++- ...tionAutoConfigurationIntegrationTests.java | 101 ++++++++++++++++++ .../antora/modules/ROOT/pages/events.adoc | 9 ++ 8 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/ApplicationModuleListenerRuntimeHints.java create mode 100644 spring-modulith-events/spring-modulith-events-api/src/main/resources/META-INF/spring/aot.factories create mode 100644 spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/ApplicationModuleListenerIntegrationTests.java diff --git a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/ApplicationModuleListener.java b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/ApplicationModuleListener.java index 7f27cae91..b1fa8755b 100644 --- a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/ApplicationModuleListener.java +++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/ApplicationModuleListener.java @@ -37,12 +37,17 @@ *

* It is advisable that you use these integration listeners in combination with the Spring Modulith Event Publication * Registry to make sure that the event publication does not get lost in case of an application or listener failure. + *

+ * The listeners are run on the {@link java.util.concurrent.Executor} bean named {@value #TASK_EXECUTOR_BEAN_NAME} if + * present, and on the default executor used for {@link Async} methods otherwise. Spring Modulith's Spring Boot + * auto-configuration registers such a dedicated executor by default. * * @author Oliver Drotbohm * @see Spring Modulith * Event Publication Registry - Reference Documentation */ -@Async +@Async("#{containsObject('" + ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME + "') ? '" + + ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME + "' : ''}") // empty qualifier -> default executor @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener @Documented @@ -50,6 +55,13 @@ @Retention(RetentionPolicy.RUNTIME) public @interface ApplicationModuleListener { + /** + * The name of the {@link java.util.concurrent.Executor} bean to run application module listeners on, if present. + * + * @since 2.2 + */ + String TASK_EXECUTOR_BEAN_NAME = "applicationModuleListenerTaskExecutor"; + /** * Whether the transaction to be run for the event listener is supposed to be read-only (default {@literal false}). * diff --git a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/ApplicationModuleListenerRuntimeHints.java b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/ApplicationModuleListenerRuntimeHints.java new file mode 100644 index 000000000..e8cdcbb14 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/ApplicationModuleListenerRuntimeHints.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 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.modulith.events; + +import java.util.List; + +import org.jspecify.annotations.Nullable; +import org.springframework.aot.hint.ExecutableMode; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.aot.hint.TypeReference; +import org.springframework.beans.factory.config.BeanExpressionContext; + +/** + * Registers the reflection metadata needed to evaluate the executor qualifier declared on + * {@link ApplicationModuleListener} in a native image. + * + * @author Hyun Lee + * @since 2.2 + */ +class ApplicationModuleListenerRuntimeHints implements RuntimeHintsRegistrar { + + /* + * (non-Javadoc) + * @see org.springframework.aot.hint.RuntimeHintsRegistrar#registerHints(org.springframework.aot.hint.RuntimeHints, java.lang.ClassLoader) + */ + @Override + public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { + + hints.reflection().registerType(BeanExpressionContext.class, it -> it.withMethod("containsObject", + List.of(TypeReference.of(String.class)), ExecutableMode.INVOKE)); + } +} diff --git a/spring-modulith-events/spring-modulith-events-api/src/main/resources/META-INF/spring/aot.factories b/spring-modulith-events/spring-modulith-events-api/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 000000000..c117e2409 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ + org.springframework.modulith.events.ApplicationModuleListenerRuntimeHints diff --git a/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/ApplicationModuleListenerIntegrationTests.java b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/ApplicationModuleListenerIntegrationTests.java new file mode 100644 index 000000000..84435a966 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/ApplicationModuleListenerIntegrationTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2026 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.modulith.events; + +import static org.assertj.core.api.Assertions.*; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * Integration tests for the executor selection of {@link ApplicationModuleListener} in a plain Spring application. + * + * @author Hyun Lee + */ +class ApplicationModuleListenerIntegrationTests { + + @Test // GH-641 + void runsListenerOnDedicatedTaskExecutorIfPresent() throws Exception { + assertThat(invokeListener(DedicatedExecutorConfiguration.class).getName()).startsWith("dedicated-"); + } + + @Test // GH-641 + void fallsBackToDefaultTaskExecutorIfNoDedicatedOneIsPresent() throws Exception { + assertThat(invokeListener(DefaultExecutorConfiguration.class).getName()).startsWith("default-"); + } + + private static Thread invokeListener(Class configuration) throws Exception { + + try (var context = new AnnotationConfigApplicationContext(configuration)) { + + var thread = new CompletableFuture(); + + context.getBean(SampleListener.class).on(thread); + + return thread.get(5, TimeUnit.SECONDS); + } + } + + private static ThreadPoolTaskExecutor executor(String threadNamePrefix) { + + var executor = new ThreadPoolTaskExecutor(); + executor.setThreadNamePrefix(threadNamePrefix); + + return executor; + } + + @EnableAsync + @Configuration(proxyBeanMethods = false) + static class DefaultExecutorConfiguration { + + @Bean + SampleListener listener() { + return new SampleListener(); + } + + @Bean + ThreadPoolTaskExecutor taskExecutor() { + return executor("default-"); + } + } + + @Configuration(proxyBeanMethods = false) + @Import(DefaultExecutorConfiguration.class) + static class DedicatedExecutorConfiguration { + + @Bean(ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME) + ThreadPoolTaskExecutor applicationModuleListenerTaskExecutor() { + return executor("dedicated-"); + } + } + + static class SampleListener { + + @ApplicationModuleListener + void on(CompletableFuture thread) { + thread.complete(Thread.currentThread()); + } + } +} diff --git a/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/ApplicationModuleListenerUnitTests.java b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/ApplicationModuleListenerUnitTests.java index 98745efea..aa1ce3831 100644 --- a/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/ApplicationModuleListenerUnitTests.java +++ b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/ApplicationModuleListenerUnitTests.java @@ -21,6 +21,11 @@ import java.lang.reflect.Method; import org.junit.jupiter.api.Test; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.beans.factory.aot.AotServices; +import org.springframework.beans.factory.config.BeanExpressionContext; import org.springframework.context.event.ApplicationListenerMethodAdapter; import org.springframework.context.event.EventListener; import org.springframework.test.util.ReflectionTestUtils; @@ -63,6 +68,20 @@ void declaresCustomTransactionPropagation() throws Exception { assertThat(annotation.propagation()).isEqualTo(Propagation.SUPPORTS); } + @Test // GH-641 + void registersRuntimeHintsToResolveExecutorQualifier() { + + var hints = new RuntimeHints(); + + AotServices.factories().load(RuntimeHintsRegistrar.class) + .forEach(it -> it.registerHints(hints, getClass().getClassLoader())); + + var predicate = RuntimeHintsPredicates.reflection() + .onMethodInvocation(BeanExpressionContext.class, "containsObject"); + + assertThat(predicate).accepts(hints); + } + static class Sample { @ApplicationModuleListener(condition = "#{false}") diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationAutoConfiguration.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationAutoConfiguration.java index abf9d6602..0f3286ad0 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationAutoConfiguration.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationAutoConfiguration.java @@ -31,15 +31,23 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; 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.task.TaskExecutionAutoConfiguration; import org.springframework.boot.autoconfigure.task.TaskExecutionProperties; import org.springframework.boot.autoconfigure.task.TaskExecutionProperties.Shutdown; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.task.SimpleAsyncTaskExecutorBuilder; +import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder; +import org.springframework.boot.thread.Threading; import org.springframework.context.Lifecycle; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Role; import org.springframework.core.env.Environment; +import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.modulith.events.AbandonPolicy; +import org.springframework.modulith.events.ApplicationModuleListener; import org.springframework.modulith.events.config.EventPublicationAutoConfiguration.AsyncEnablingConfiguration; import org.springframework.modulith.events.core.AbandonPolicies; import org.springframework.modulith.events.core.DefaultAbandonedEventPublications; @@ -59,8 +67,9 @@ * @author Oliver Drotbohm * @author Björn Kieling * @author Dmitry Belyaev + * @author Hyun Lee */ -@AutoConfiguration +@AutoConfiguration(after = TaskExecutionAutoConfiguration.class) @Import({ AsyncEnablingConfiguration.class, StalenessMonitorConfiguration.class }) @EnableConfigurationProperties({ StalenessProperties.class, ResubmissionProperties.class }) public class EventPublicationAutoConfiguration extends EventPublicationConfiguration { @@ -127,6 +136,29 @@ static AsyncPropertiesDefaulter asyncPropertiesDefaulter(Environment environment return new AsyncPropertiesDefaulter(environment); } + /** + * A dedicated executor for {@link ApplicationModuleListener}s, built like Spring Boot's application task executor. + * Registered as non-default candidate so that by-type lookups of an executor keep resolving the application's + * default one. + */ + @Lazy + @Bean(name = ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME, defaultCandidate = false) + @ConditionalOnMissingBean(name = ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME) + @ConditionalOnBean(ThreadPoolTaskExecutorBuilder.class) + @ConditionalOnThreading(Threading.PLATFORM) + ThreadPoolTaskExecutor applicationModuleListenerTaskExecutor(ThreadPoolTaskExecutorBuilder builder) { + return builder.build(); + } + + @Bean(name = ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME, defaultCandidate = false) + @ConditionalOnMissingBean(name = ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME) + @ConditionalOnBean(SimpleAsyncTaskExecutorBuilder.class) + @ConditionalOnThreading(Threading.VIRTUAL) + SimpleAsyncTaskExecutor applicationModuleListenerTaskExecutorVirtualThreads( + SimpleAsyncTaskExecutorBuilder builder) { + return builder.build(); + } + @EnableAsync @ConditionalOnMissingBean(AbstractAsyncConfiguration.class) static class AsyncEnablingConfiguration {} diff --git a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/config/EventPublicationAutoConfigurationIntegrationTests.java b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/config/EventPublicationAutoConfigurationIntegrationTests.java index 9d2cc6244..c0ea32f4f 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/config/EventPublicationAutoConfigurationIntegrationTests.java +++ b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/config/EventPublicationAutoConfigurationIntegrationTests.java @@ -17,15 +17,21 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +import static org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration.*; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -33,13 +39,18 @@ import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; import org.springframework.boot.autoconfigure.task.TaskExecutionProperties; import org.springframework.boot.autoconfigure.task.TaskExecutionProperties.Shutdown; +import org.springframework.boot.task.ThreadPoolTaskExecutorCustomizer; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.context.runner.ContextConsumer; import org.springframework.context.annotation.AdviceMode; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.SyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; import org.springframework.modulith.events.AbandonPolicy; import org.springframework.modulith.events.AbandonPolicy.Decision; import org.springframework.modulith.events.AbandonedEventPublications; +import org.springframework.modulith.events.ApplicationModuleListener; import org.springframework.modulith.events.CompletedEventPublications; import org.springframework.modulith.events.EventPublication; import org.springframework.modulith.events.FailedEventPublications; @@ -52,6 +63,7 @@ import org.springframework.scheduling.annotation.ProxyAsyncConfiguration; import org.springframework.scheduling.annotation.SchedulingConfigurer; import org.springframework.scheduling.aspectj.AspectJAsyncConfiguration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.test.util.ReflectionTestUtils; /** @@ -263,6 +275,87 @@ void registersStalenessMonitorIfPropertiesConfigured() { }); } + @Test // GH-641 + void registersDedicatedTaskExecutorForApplicationModuleListeners() { + + basicSetup().run(context -> { + + assertThat(context.getBean(ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME)) + .isInstanceOf(ThreadPoolTaskExecutor.class); + + // By-type lookups still resolve Boot's application task executor + assertThat(context.getBean(TaskExecutor.class)) + .isSameAs(context.getBean(APPLICATION_TASK_EXECUTOR_BEAN_NAME)); + }); + } + + @Test // GH-641 + @EnabledForJreRange(min = JRE.JAVA_21) + void registersVirtualThreadTaskExecutorForApplicationModuleListenersIfEnabled() { + + basicSetup() + .withPropertyValues("spring.threads.virtual.enabled=true") + .run(context -> { + + var executor = context.getBean(ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME, + SimpleAsyncTaskExecutor.class); + var thread = new CompletableFuture(); + + executor.execute(() -> thread.complete(Thread.currentThread())); + + assertThat(thread.get(5, TimeUnit.SECONDS)).extracting("virtual").isEqualTo(true); + }); + } + + @Test // GH-641 + void usesCustomTaskExecutorForApplicationModuleListenersIfDeclared() { + + var executor = new SyncTaskExecutor(); + + basicSetup() + .withBean(ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME, Executor.class, () -> executor) + .run(context -> { + assertThat(context.getBean(ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME)).isSameAs(executor); + }); + } + + @Test // GH-641 + void appliesTaskExecutorCustomizersToApplicationModuleListenerTaskExecutor() { + + ThreadPoolTaskExecutorCustomizer customizer = it -> it.setThreadNamePrefix("customized-"); + + basicSetup() + .withBean(ThreadPoolTaskExecutorCustomizer.class, () -> customizer) + .run(context -> { + + var executor = context.getBean(ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME, + ThreadPoolTaskExecutor.class); + + assertThat(executor.getThreadNamePrefix()).isEqualTo("customized-"); + }); + } + + @Test // GH-641 + void runsApplicationModuleListenersOnDedicatedTaskExecutor() { + + basicSetup() + .withBean(SampleListener.class) + .run(context -> { + + var thread = new CompletableFuture(); + + context.getBean(SampleListener.class).on(thread); + + assertThat(thread.get(5, TimeUnit.SECONDS)).isNotSameAs(Thread.currentThread()); + assertThat(taskCount(context, ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME)).isOne(); + assertThat(taskCount(context, APPLICATION_TASK_EXECUTOR_BEAN_NAME)).isZero(); + }); + } + + private static long taskCount(AssertableApplicationContext context, String executorName) { + return context.getBean(executorName, ThreadPoolTaskExecutor.class).getThreadPoolExecutor().getTaskCount(); + } + private static ContextConsumer expect(Function extractor, @Nullable T expected) { @@ -281,4 +374,12 @@ private ApplicationContextRunner basicSetup() { @EnableAsync(mode = AdviceMode.ASPECTJ) static class CustomAsyncConfiguration {} + + static class SampleListener { + + @ApplicationModuleListener + void on(CompletableFuture thread) { + thread.complete(Thread.currentThread()); + } + } } diff --git a/src/docs/antora/modules/ROOT/pages/events.adoc b/src/docs/antora/modules/ROOT/pages/events.adoc index feba439cd..d638841ee 100644 --- a/src/docs/antora/modules/ROOT/pages/events.adoc +++ b/src/docs/antora/modules/ROOT/pages/events.adoc @@ -195,6 +195,15 @@ class InventoryManagement { ---- ====== +[[aml.executor]] +=== Listener Executor (since 2.2) + +Application module listeners are executed on the `Executor` bean named `applicationModuleListenerTaskExecutor` if present, and on the default executor used for `@Async` methods otherwise. +In a Spring Boot application with `spring-modulith-events-core` on the classpath, Spring Modulith registers such a dedicated executor. +It is created through Spring Boot's `ThreadPoolTaskExecutorBuilder` (or `SimpleAsyncTaskExecutorBuilder` if virtual threads are enabled), so that the `spring.task.execution.*` properties and the task executor customizers registered in the application apply to it. +To replace it, declare an `Executor` bean with that name (available as `ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME`) yourself. +To customize it without affecting any other executor, use a `BeanPostProcessor` that only handles the bean of that name. + [[publication-registry]] == The Event Publication Registry