Skip to content

Lazily resolve JPA fallback bootstrap executor#50801

Open
ns3154 wants to merge 1 commit into
spring-projects:mainfrom
ns3154:gh-50797-lazy-jpa-bootstrap-executor
Open

Lazily resolve JPA fallback bootstrap executor#50801
ns3154 wants to merge 1 commit into
spring-projects:mainfrom
ns3154:gh-50797-lazy-jpa-bootstrap-executor

Conversation

@ns3154

@ns3154 ns3154 commented Jun 20, 2026

Copy link
Copy Markdown

This addresses #50797.

Broken behavior

JpaBaseConfiguration#entityManagerFactoryBuilder injects a Map<String, AsyncTaskExecutor> parameter to pick the fallback executor used for background JPA bootstrapping. A Map method parameter is resolved eagerly by the container, so creating the EntityManagerFactoryBuilder forces every AsyncTaskExecutor bean to be initialized early — even when background bootstrapping is not enabled (spring.jpa.bootstrap defaults to default).

When an application defines an AsyncTaskExecutor that depends, directly or transitively, on the EntityManagerFactory, this premature initialization produces a BeanCurrentlyInCreationException and the context fails to start. This is a regression in 4.1.0.

Fix

The fallback executor is now resolved lazily:

  • EntityManagerFactoryBuilder stores the fallback as a Supplier<? extends @Nullable AsyncTaskExecutor> and only invokes it when background bootstrapping is actually required (at build() time). A new Supplier-based constructor is added (@since 4.1.1); the existing AsyncTaskExecutor constructor is retained for backwards compatibility and delegates to it.
  • JpaBaseConfiguration#entityManagerFactoryBuilder no longer injects a Map. It now takes a ListableBeanFactory and resolves the executor via getBeansOfType(...) inside the supplier, so the lookup runs only when needed and no longer forces eager initialization.
  • DataJpaRepositoriesAutoConfiguration carried the same eager Map<String, AsyncTaskExecutor> injection plus an unused private method left over from the same change; both are removed.

The executor selection logic is unchanged: a single AsyncTaskExecutor is used if exactly one is present, otherwise the one named applicationTaskExecutor is used.

Tests

  • A regression test in HibernateJpaAutoConfigurationTests reproduces the dependency loop (an AsyncTaskExecutor depending on the EntityManagerFactory) and verifies the context now starts.
  • EntityManagerFactoryBuilderTests adds coverage for the new Supplier-based fallback, including verifying that the supplier is not invoked when background bootstrapping is not required.

The default (synchronous), spring.jpa.bootstrap=async, and spring.data.jpa.repositories.bootstrap-mode=deferred paths were all exercised.

The `entityManagerFactoryBuilder` bean method injected a
`Map<String, AsyncTaskExecutor>` to determine the fallback executor used
for background JPA bootstrapping. A `Map` parameter is resolved eagerly,
so this forced early initialization of every `AsyncTaskExecutor` bean
whenever the builder was created, even when background bootstrapping was
not in use. When an `AsyncTaskExecutor` directly or transitively depended
on the `EntityManagerFactory`, this resulted in a
`BeanCurrentlyInCreationException`.

The fallback executor is now resolved lazily. `EntityManagerFactoryBuilder`
holds a `Supplier` that is only invoked when background bootstrapping is
actually required, and the executor is then looked up from the
`BeanFactory` rather than eagerly injected. A new `Supplier`-based
constructor is added for this purpose; the existing constructor that
accepts an `AsyncTaskExecutor` is retained and delegates to it.

The same eager `Map<String, AsyncTaskExecutor>` injection, along with an
unused private method, is also removed from
`DataJpaRepositoriesAutoConfiguration`.

Closes spring-projectsgh-50797

Signed-off-by: Ns <397827222@qq.com>
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Jun 20, 2026

@prashantpiyush1111 prashantpiyush1111 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the lazy resolution approach for the JPA bootstrap executor. Left a few clarifying questions inline about backward compatibility and bean lazy-init behavior. Overall the fix looks solid for the reported regression.

@Nullable PersistenceUnitManager persistenceUnitManager, @Nullable URL persistenceUnitRootLocation,
@Nullable AsyncTaskExecutor fallbackBootstrapExecutor) {
this(jpaVendorAdapter, jpaPropertiesFactory, persistenceUnitManager, persistenceUnitRootLocation,
() -> fallbackBootstrapExecutor);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice approach wrapping the old AsyncTaskExecutor param into a Supplier for backward compatibility. Just confirming — since this old constructor still accepts a concrete AsyncTaskExecutor (not a Supplier), doesn't that mean any caller using this old constructor still eagerly resolves the executor before passing it in? The laziness benefit only applies to callers using the new Supplier-based constructor, right?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — a caller using the AsyncTaskExecutor constructor still resolves the executor itself before passing it in, so the laziness only benefits callers of the new Supplier-based constructor. That's intentional: it's the released @since 4.1.0 public API, and a caller of it already holds a concrete executor instance, so there is nothing to defer there.

The regression wasn't caused by this constructor, but by JpaBaseConfiguration injecting a Map<String, AsyncTaskExecutor> method parameter, which forced eager creation of every executor bean while the entityManagerFactoryBuilder bean was being created. The actual fix is that the auto-configuration now uses the new Supplier-based constructor and resolves the executor lazily; the old constructor is retained purely for backward compatibility.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense now—thanks for explaining it clearly.


private @Nullable AsyncTaskExecutor determineBootstrapExecutor(Map<String, AsyncTaskExecutor> taskExecutors) {
private @Nullable AsyncTaskExecutor determineBootstrapExecutor(ListableBeanFactory beanFactory) {
Map<String, AsyncTaskExecutor> taskExecutors = beanFactory.getBeansOfType(AsyncTaskExecutor.class);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix moving this behind a Supplier. One minor question: getBeansOfType(AsyncTaskExecutor.class) — does this also trigger eager initialization of any AsyncTaskExecutor beans that are themselves lazy-init? Or does getBeansOfType respect @lazy on individual beans?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. getBeansOfType(AsyncTaskExecutor.class) does use allowEagerInit=true by default, so it will instantiate the matching beans when it runs — that part is unchanged. What changed is when it runs: the lookup is now wrapped in the Supplier, which is only invoked when background bootstrapping is actually required (at build() time, and only when requireBootstrapExecutor was set — i.e. spring.jpa.bootstrap=async or spring.data.jpa.repositories.bootstrap-mode=deferred).

The original regression came from resolving the executors eagerly as a Map method parameter while the entityManagerFactoryBuilder bean itself was still being created (during JPA infrastructure setup), which is what produced the dependency cycle. Deferring the lookup to bootstrap time means a @Lazy applicationTaskExecutor is created only when it is genuinely needed, which is consistent with @Lazy's intent and avoids the early cycle.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a clear distinction — thanks! Makes sense that deferring when it's called is the actual fix, not changing what getBeansOfType does internally.

}

@Test
void fallbackExecutorSupplierIsNotInvokedWhenBootstrapExecutorNotRequired() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is exactly the right test to have — verifies the core behavior change (lazy invocation). Good coverage.

@quaff

quaff commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

I didn't repro the issue, but I think use ObjectProvider<AsyncTaskExecutor> instead of Map<String, AsyncTaskExecutor> taskExecutors may be better option:

	@Bean
	@ConditionalOnMissingBean
	public EntityManagerFactoryBuilder entityManagerFactoryBuilder(JpaVendorAdapter jpaVendorAdapter,
			ObjectProvider<PersistenceUnitManager> persistenceUnitManager,
			ObjectProvider<EntityManagerFactoryBuilderCustomizer> customizers,
			ObjectProvider<AsyncTaskExecutor> taskExecutors,
			@Qualifier(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME) Optional<AsyncTaskExecutor> applicationTaskExecutor) {
		@Nullable AsyncTaskExecutor bootstrapExecutor = determineBootstrapExecutor(taskExecutors,
				applicationTaskExecutor.orElse(null));
		EntityManagerFactoryBuilder builder = new EntityManagerFactoryBuilder(jpaVendorAdapter,
				this::buildJpaProperties, persistenceUnitManager.getIfAvailable(), null, bootstrapExecutor);
		if (this.properties.getBootstrap() == Bootstrap.ASYNC) {
			builder.requireBootstrapExecutor(
					() -> BootstrapExecutorRequiredException.ofProperty("spring.jpa.bootstrap", "async"));
		}
		customizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
		return builder;
	}

	private @Nullable AsyncTaskExecutor determineBootstrapExecutor(ObjectProvider<AsyncTaskExecutor> taskExecutors,
			@Nullable AsyncTaskExecutor applicationTaskExecutor) {
		@Nullable AsyncTaskExecutor asyncTaskExecutor = taskExecutors.getIfUnique();
		return (asyncTaskExecutor != null) ? asyncTaskExecutor : applicationTaskExecutor;
	}

@ns3154

ns3154 commented Jun 22, 2026

Copy link
Copy Markdown
Author

Thanks for taking a look, @quaff! You're right that ObjectProvider<AsyncTaskExecutor> is more idiomatic than the Map injection, and the injection point itself does become lazy.

The subtle part is that the regression isn't really about the injected type — it's about when the executors get resolved. In the snippet, determineBootstrapExecutor(...) runs directly inside the entityManagerFactoryBuilder bean method, so taskExecutors.getIfUnique() still forces every AsyncTaskExecutor to initialize while entityManagerFactoryBuilder is being created — which is exactly the step that closes the cycle when an executor (transitively) depends on the EntityManagerFactory.

I gave your version a try against the reproduction test in this PR (AsyncTaskExecutorDependingOnEntityManagerFactoryConfiguration): with the ObjectProvider + in-method getIfUnique() variant the test still fails with BeanCurrentlyInCreationException (entityManagerFactory → entityManagerFactoryBuilder → exampleTaskExecutor → … → entityManagerFactory). The Map variant fails the same way, and only the lazy Supplier version passes.

That's why the fix defers resolution into a Supplier that's invoked only when background bootstrapping is actually required — by then the builder is fully constructed and the cycle is broken.

That said, your ObjectProvider idea fits really nicely inside that supplier (resolving there instead of via the ListableBeanFactory lookup), which would keep it idiomatic while preserving the laziness — I'd be happy to take it in that direction if the maintainers prefer. One small caveat: getIfUnique() behaves a bit differently from the current size() == 1 … else by-name logic when multiple executors exist without a primary.

@quaff

quaff commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

The subtle part is that the regression isn't really about the injected type — it's about when the executors get resolved. In the snippet, determineBootstrapExecutor(...) runs directly inside the entityManagerFactoryBuilder bean method, so taskExecutors.getIfUnique() still forces every AsyncTaskExecutor to initialize while entityManagerFactoryBuilder is being created — which is exactly the step that closes the cycle when an executor (transitively) depends on the EntityManagerFactory.

@ns3154 You are right, GH-50813 improved but not fix if there is only one AsyncTaskExecutor which triggers taskExecutors.getIfUnique().
IMHO, GH-50813 should be merged as improvement, and this PR should rebase on that commit and be merged as bug fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: waiting-for-triage An issue we've not yet triaged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants