Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,31 @@
* <p>
* 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.
* <p>
* 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 <a href="https://docs.spring.io/spring-modulith/reference/events.html#publication-registry">Spring Modulith
* Event Publication Registry - Reference Documentation</a>
*/
@Async
@Async("#{containsObject('" + ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME + "') ? '"
+ ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME + "' : ''}") // empty qualifier -> default executor
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener
@Documented
@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE })
@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}).
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
org.springframework.aot.hint.RuntimeHintsRegistrar=\
org.springframework.modulith.events.ApplicationModuleListenerRuntimeHints
Original file line number Diff line number Diff line change
@@ -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<Thread>();

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) {
thread.complete(Thread.currentThread());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {}
Expand Down
Loading
Loading