Skip to content

GH-641 - Use dedicated task executor for @ApplicationModuleListeners. - #1900

Open
dlwhdgus0810 wants to merge 1 commit into
spring-projects:mainfrom
dlwhdgus0810:gh-641-listener-executor
Open

dlwhdgus0810 wants to merge 1 commit into
spring-projects:mainfrom
dlwhdgus0810:gh-641-listener-executor

Conversation

@dlwhdgus0810

Copy link
Copy Markdown
Contributor

Closes #641.

@ApplicationModuleListener methods now run on an executor bean named applicationModuleListenerTaskExecutor (also available as ApplicationModuleListener.TASK_EXECUTOR_BEAN_NAME), as outlined in the issue.

The executor

  • EventPublicationAutoConfiguration registers it the same way Boot registers applicationTaskExecutor: a @Lazy ThreadPoolTaskExecutor from ThreadPoolTaskExecutorBuilder, or a SimpleAsyncTaskExecutor from SimpleAsyncTaskExecutorBuilder when virtual threads are enabled (@ConditionalOnThreading). This means spring.task.execution.* and any ThreadPoolTaskExecutorCustomizer/SimpleAsyncTaskExecutorCustomizer beans apply to it. That includes the ContextPropagatingTaskDecorator customizers of the observability module, so the observability module needs no changes.
  • It is @ConditionalOnMissingBean(name = …). You can replace it by declaring a bean with that name, or customize it on its own with a BeanPostProcessor. The auto-configuration now runs after TaskExecutionAutoConfiguration and only registers the executor if the builder bean is present.
  • It is registered with defaultCandidate = false. Without that, a second TaskExecutor would break by-type lookups and injection with a NoUniqueBeanDefinitionException. It would also make ScenarioCustomizer.forwardExecutorService(…), which relies on getIfUnique(), stop forwarding applicationTaskExecutor. Boot's bean conditions ignore non-default candidates too, so applicationTaskExecutor is still created.

When the bean is absent

@Async("applicationModuleListenerTaskExecutor") alone fails with NoSuchBeanDefinitionException: No bean named 'applicationModuleListenerTaskExecutor' available whenever that bean doesn't exist. That covers plain Spring setups (e.g. @EnablePersistentDomainEvents), Boot apps that only use spring-modulith-events-api together with @EnableAsync, and any context without this auto-configuration. The listener then never runs, and the only sign is the after-completion error in the log. So the qualifier is an expression that resolves to the bean name if such a bean exists, and to an empty qualifier otherwise. An empty qualifier means the default async executor, which is today's behavior:

@Async("#{containsObject('applicationModuleListenerTaskExecutor') ? 'applicationModuleListenerTaskExecutor' : ''}")

BeanExpressionContext.containsObject(String), the method the expression calls, gets a reflection hint through a new RuntimeHintsRegistrar in spring-modulith-events-api, so native images can evaluate it too. If you'd rather treat those setups as unsupported, going back to the plain name is a one-line change.

One caveat of the expression: an application that disables SpEL with spring.spel.ignore=true would get the expression text as the qualifier, which isn't a bean name, so its listeners would fail to run whether or not the bean exists. With the plain name, that application would only need the bean to be present.

Behavior change

In Boot apps with spring-modulith-events-core, listeners no longer run on the default async executor, whether that is applicationTaskExecutor, an AsyncConfigurer's executor or a user-declared Executor. They run on the new executor instead. To keep using your own executor, give it the name applicationModuleListenerTaskExecutor as well.

Not included

  • The observability module still decorates every executor built with Boot's builders. Limiting that to the new executor (the TODO in ModuleObservabilityAutoConfiguration) would stop propagating context on applicationTaskExecutor, so I'd rather do it separately if you want it.
  • The new executor uses the same spring.task.execution.thread-name-prefix as applicationTaskExecutor, so both pools name their threads task-N. I can give it a prefix of its own if you prefer.

Docs

New "Listener Executor" section right after the @ApplicationModuleListener docs in events.adoc, plus a note in the annotation's Javadoc.

Tests

  • EventPublicationAutoConfigurationIntegrationTests:
    • the executor is registered under the name, and a by-type TaskExecutor lookup still returns applicationTaskExecutor
    • the virtual threads variant (JDK 21+)
    • a user bean with that name wins
    • ThreadPoolTaskExecutorCustomizers apply
    • an @ApplicationModuleListener actually runs on the dedicated executor: 1 task there, 0 on applicationTaskExecutor
  • ApplicationModuleListenerIntegrationTests (plain Spring, no Boot): the listener uses a bean with that name if present and falls back to the default executor otherwise.
  • ApplicationModuleListenerUnitTests: the hint is picked up through aot.factories.

I checked that the new tests fail without the corresponding change:

  • plain @Async: the dedicated-executor tests fail
  • a plain bean name qualifier: the fallback test fails with the NoSuchBeanDefinitionException above
  • without defaultCandidate = false: the by-type lookup fails with NoUniqueBeanDefinitionException
  • without aot.factories: the hint test fails

Commands I ran locally:

  • JDK 17, as in the PR workflow: ./mvnw -B -pl spring-modulith-events/spring-modulith-events-api,spring-modulith-events/spring-modulith-events-core,spring-modulith-events/spring-modulith-events-tests,spring-modulith-observability/spring-modulith-observability-core,spring-modulith-examples/spring-modulith-example-full -am verify → BUILD SUCCESS (504 tests, 0 failures; 7 skipped, including the JDK 21-only test).
  • JDK 21: ./mvnw -B -pl spring-modulith-events/spring-modulith-events-api,spring-modulith-events/spring-modulith-events-core verify → BUILD SUCCESS (virtual threads test included).
  • JDK 21: ./mvnw -B -Pnullaway -pl spring-modulith-events/spring-modulith-events-api,spring-modulith-events/spring-modulith-events-core -DskipTests compile → BUILD SUCCESS.

I didn't build a native image. The hint is only covered by the unit test above.

…oduleListeners.

@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 <dlwhdugs4147@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use dedicated task executor for @ApplicationModuleListeners

1 participant