Skip to content

Commit 48009c8

Browse files
committed
Introduce support for concurrent startup phases with timeouts
Closes gh-34634
1 parent 203ca30 commit 48009c8

File tree

2 files changed

+200
-62
lines changed

2 files changed

+200
-62
lines changed

spring-context/src/main/java/org/springframework/context/support/DefaultLifecycleProcessor.java

+136-41
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -26,9 +26,12 @@
2626
import java.util.Map;
2727
import java.util.Set;
2828
import java.util.TreeMap;
29+
import java.util.concurrent.CompletableFuture;
2930
import java.util.concurrent.ConcurrentHashMap;
3031
import java.util.concurrent.CountDownLatch;
3132
import java.util.concurrent.CyclicBarrier;
33+
import java.util.concurrent.ExecutionException;
34+
import java.util.concurrent.Executor;
3235
import java.util.concurrent.TimeUnit;
3336

3437
import org.apache.commons.logging.Log;
@@ -52,6 +55,7 @@
5255
import org.springframework.lang.Nullable;
5356
import org.springframework.util.Assert;
5457
import org.springframework.util.ClassUtils;
58+
import org.springframework.util.CollectionUtils;
5559

5660
/**
5761
* Spring's default implementation of the {@link LifecycleProcessor} strategy.
@@ -61,12 +65,23 @@
6165
* interactions on a {@link org.springframework.context.ConfigurableApplicationContext}.
6266
*
6367
* <p>As of 6.1, this also includes support for JVM checkpoint/restore (Project CRaC)
64-
* when the {@code org.crac:crac} dependency on the classpath.
68+
* when the {@code org.crac:crac} dependency is on the classpath. All running beans
69+
* will get stopped and restarted according to the CRaC checkpoint/restore callbacks.
70+
*
71+
* <p>As of 6.2, this processor can be configured with custom timeouts for specific
72+
* shutdown phases, applied to {@link SmartLifecycle#stop(Runnable)} implementations.
73+
* As of 6.2.6, there is also support for the concurrent startup of specific phases
74+
* with individual timeouts, triggering the {@link SmartLifecycle#start()} callbacks
75+
* of all associated beans asynchronously and then waiting for all of them to return,
76+
* as an alternative to the default sequential startup of beans without a timeout.
6577
*
6678
* @author Mark Fisher
6779
* @author Juergen Hoeller
6880
* @author Sebastien Deleuze
6981
* @since 3.0
82+
* @see SmartLifecycle#getPhase()
83+
* @see #setConcurrentStartupForPhase
84+
* @see #setTimeoutForShutdownPhase
7085
*/
7186
public class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactoryAware {
7287

@@ -102,6 +117,8 @@ public class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactor
102117

103118
private final Log logger = LogFactory.getLog(getClass());
104119

120+
private final Map<Integer, Long> concurrentStartupForPhases = new ConcurrentHashMap<>();
121+
105122
private final Map<Integer, Long> timeoutsForShutdownPhases = new ConcurrentHashMap<>();
106123

107124
private volatile long timeoutPerShutdownPhase = 10000;
@@ -130,20 +147,59 @@ else if (checkpointOnRefresh) {
130147
}
131148

132149

150+
/**
151+
* Switch to concurrent startup for each given phase (group of {@link SmartLifecycle}
152+
* beans with the same 'phase' value) with corresponding timeouts.
153+
* <p><b>Note: By default, the startup for every phase will be sequential without
154+
* a timeout. Calling this setter with timeouts for the given phases switches to a
155+
* mode where the beans in these phases will be started concurrently, cancelling
156+
* the startup if the corresponding timeout is not met for any of these phases.</b>
157+
* <p>For an actual concurrent startup, a bootstrap {@code Executor} needs to be
158+
* set for the application context, typically through a "bootstrapExecutor" bean.
159+
* @param phasesWithTimeouts a map of phase values (matching
160+
* {@link SmartLifecycle#getPhase()}) and corresponding timeout values
161+
* (in milliseconds)
162+
* @since 6.2.6
163+
* @see SmartLifecycle#getPhase()
164+
* @see org.springframework.beans.factory.config.ConfigurableBeanFactory#getBootstrapExecutor()
165+
*/
166+
public void setConcurrentStartupForPhases(Map<Integer, Long> phasesWithTimeouts) {
167+
this.concurrentStartupForPhases.putAll(phasesWithTimeouts);
168+
}
169+
170+
/**
171+
* Switch to concurrent startup for a specific phase (group of {@link SmartLifecycle}
172+
* beans with the same 'phase' value) with a corresponding timeout.
173+
* <p><b>Note: By default, the startup for every phase will be sequential without
174+
* a timeout. Calling this setter with a timeout for the given phase switches to a
175+
* mode where the beans in this phase will be started concurrently, cancelling
176+
* the startup if the corresponding timeout is not met for this phase.</b>
177+
* <p>For an actual concurrent startup, a bootstrap {@code Executor} needs to be
178+
* set for the application context, typically through a "bootstrapExecutor" bean.
179+
* @param phase the phase value (matching {@link SmartLifecycle#getPhase()})
180+
* @param timeout the corresponding timeout value (in milliseconds)
181+
* @since 6.2.6
182+
* @see SmartLifecycle#getPhase()
183+
* @see org.springframework.beans.factory.config.ConfigurableBeanFactory#getBootstrapExecutor()
184+
*/
185+
public void setConcurrentStartupForPhase(int phase, long timeout) {
186+
this.concurrentStartupForPhases.put(phase, timeout);
187+
}
188+
133189
/**
134190
* Specify the maximum time allotted for the shutdown of each given phase
135191
* (group of {@link SmartLifecycle} beans with the same 'phase' value).
136192
* <p>In case of no specific timeout configured, the default timeout per
137193
* shutdown phase will apply: 10000 milliseconds (10 seconds) as of 6.2.
138-
* @param timeoutsForShutdownPhases a map of phase values (matching
194+
* @param phasesWithTimeouts a map of phase values (matching
139195
* {@link SmartLifecycle#getPhase()}) and corresponding timeout values
140196
* (in milliseconds)
141197
* @since 6.2
142198
* @see SmartLifecycle#getPhase()
143199
* @see #setTimeoutPerShutdownPhase
144200
*/
145-
public void setTimeoutsForShutdownPhases(Map<Integer, Long> timeoutsForShutdownPhases) {
146-
this.timeoutsForShutdownPhases.putAll(timeoutsForShutdownPhases);
201+
public void setTimeoutsForShutdownPhases(Map<Integer, Long> phasesWithTimeouts) {
202+
this.timeoutsForShutdownPhases.putAll(phasesWithTimeouts);
147203
}
148204

149205
/**
@@ -171,17 +227,15 @@ public void setTimeoutPerShutdownPhase(long timeoutPerShutdownPhase) {
171227
this.timeoutPerShutdownPhase = timeoutPerShutdownPhase;
172228
}
173229

174-
private long determineTimeout(int phase) {
175-
Long timeout = this.timeoutsForShutdownPhases.get(phase);
176-
return (timeout != null ? timeout : this.timeoutPerShutdownPhase);
177-
}
178-
179230
@Override
180231
public void setBeanFactory(BeanFactory beanFactory) {
181232
if (!(beanFactory instanceof ConfigurableListableBeanFactory clbf)) {
182233
throw new IllegalArgumentException(
183234
"DefaultLifecycleProcessor requires a ConfigurableListableBeanFactory: " + beanFactory);
184235
}
236+
if (!this.concurrentStartupForPhases.isEmpty() && clbf.getBootstrapExecutor() == null) {
237+
throw new IllegalStateException("'bootstrapExecutor' needs to be configured for concurrent startup");
238+
}
185239
this.beanFactory = clbf;
186240
}
187241

@@ -191,6 +245,22 @@ private ConfigurableListableBeanFactory getBeanFactory() {
191245
return beanFactory;
192246
}
193247

248+
private Executor getBootstrapExecutor() {
249+
Executor executor = getBeanFactory().getBootstrapExecutor();
250+
Assert.state(executor != null, "No 'bootstrapExecutor' available");
251+
return executor;
252+
}
253+
254+
@Nullable
255+
private Long determineConcurrentStartup(int phase) {
256+
return this.concurrentStartupForPhases.get(phase);
257+
}
258+
259+
private long determineShutdownTimeout(int phase) {
260+
Long timeout = this.timeoutsForShutdownPhases.get(phase);
261+
return (timeout != null ? timeout : this.timeoutPerShutdownPhase);
262+
}
263+
194264

195265
// Lifecycle implementation
196266

@@ -285,9 +355,8 @@ private void startBeans(boolean autoStartupOnly) {
285355
lifecycleBeans.forEach((beanName, bean) -> {
286356
if (!autoStartupOnly || isAutoStartupCandidate(beanName, bean)) {
287357
int startupPhase = getPhase(bean);
288-
phases.computeIfAbsent(startupPhase,
289-
phase -> new LifecycleGroup(phase, determineTimeout(phase), lifecycleBeans, autoStartupOnly)
290-
).add(beanName, bean);
358+
phases.computeIfAbsent(startupPhase, phase -> new LifecycleGroup(phase, lifecycleBeans, autoStartupOnly))
359+
.add(beanName, bean);
291360
}
292361
});
293362

@@ -308,30 +377,41 @@ private boolean isAutoStartupCandidate(String beanName, Lifecycle bean) {
308377
* @param lifecycleBeans a Map with bean name as key and Lifecycle instance as value
309378
* @param beanName the name of the bean to start
310379
*/
311-
private void doStart(Map<String, ? extends Lifecycle> lifecycleBeans, String beanName, boolean autoStartupOnly) {
380+
private void doStart(Map<String, ? extends Lifecycle> lifecycleBeans, String beanName,
381+
boolean autoStartupOnly, @Nullable List<CompletableFuture<?>> futures) {
382+
312383
Lifecycle bean = lifecycleBeans.remove(beanName);
313384
if (bean != null && bean != this) {
314385
String[] dependenciesForBean = getBeanFactory().getDependenciesForBean(beanName);
315386
for (String dependency : dependenciesForBean) {
316-
doStart(lifecycleBeans, dependency, autoStartupOnly);
387+
doStart(lifecycleBeans, dependency, autoStartupOnly, futures);
317388
}
318389
if (!bean.isRunning() && (!autoStartupOnly || toBeStarted(beanName, bean))) {
319-
if (logger.isTraceEnabled()) {
320-
logger.trace("Starting bean '" + beanName + "' of type [" + bean.getClass().getName() + "]");
390+
if (futures != null) {
391+
futures.add(CompletableFuture.runAsync(() -> doStart(beanName, bean), getBootstrapExecutor()));
321392
}
322-
try {
323-
bean.start();
324-
}
325-
catch (Throwable ex) {
326-
throw new ApplicationContextException("Failed to start bean '" + beanName + "'", ex);
327-
}
328-
if (logger.isDebugEnabled()) {
329-
logger.debug("Successfully started bean '" + beanName + "'");
393+
else {
394+
doStart(beanName, bean);
330395
}
331396
}
332397
}
333398
}
334399

400+
private void doStart(String beanName, Lifecycle bean) {
401+
if (logger.isTraceEnabled()) {
402+
logger.trace("Starting bean '" + beanName + "' of type [" + bean.getClass().getName() + "]");
403+
}
404+
try {
405+
bean.start();
406+
}
407+
catch (Throwable ex) {
408+
throw new ApplicationContextException("Failed to start bean '" + beanName + "'", ex);
409+
}
410+
if (logger.isDebugEnabled()) {
411+
logger.debug("Successfully started bean '" + beanName + "'");
412+
}
413+
}
414+
335415
private boolean toBeStarted(String beanName, Lifecycle bean) {
336416
Set<String> stoppedBeans = this.stoppedBeans;
337417
return (stoppedBeans != null ? stoppedBeans.contains(beanName) :
@@ -344,9 +424,8 @@ private void stopBeans() {
344424

345425
lifecycleBeans.forEach((beanName, bean) -> {
346426
int shutdownPhase = getPhase(bean);
347-
phases.computeIfAbsent(shutdownPhase,
348-
phase -> new LifecycleGroup(phase, determineTimeout(phase), lifecycleBeans, false)
349-
).add(beanName, bean);
427+
phases.computeIfAbsent(shutdownPhase, phase -> new LifecycleGroup(phase, lifecycleBeans, false))
428+
.add(beanName, bean);
350429
});
351430

352431
if (!phases.isEmpty()) {
@@ -417,7 +496,7 @@ else if (bean instanceof SmartLifecycle) {
417496
}
418497

419498

420-
// overridable hooks
499+
// Overridable hooks
421500

422501
/**
423502
* Retrieve all applicable Lifecycle beans: all singletons that have already been created,
@@ -473,8 +552,6 @@ private class LifecycleGroup {
473552

474553
private final int phase;
475554

476-
private final long timeout;
477-
478555
private final Map<String, ? extends Lifecycle> lifecycleBeans;
479556

480557
private final boolean autoStartupOnly;
@@ -483,11 +560,8 @@ private class LifecycleGroup {
483560

484561
private int smartMemberCount;
485562

486-
public LifecycleGroup(
487-
int phase, long timeout, Map<String, ? extends Lifecycle> lifecycleBeans, boolean autoStartupOnly) {
488-
563+
public LifecycleGroup(int phase, Map<String, ? extends Lifecycle> lifecycleBeans, boolean autoStartupOnly) {
489564
this.phase = phase;
490-
this.timeout = timeout;
491565
this.lifecycleBeans = lifecycleBeans;
492566
this.autoStartupOnly = autoStartupOnly;
493567
}
@@ -506,8 +580,26 @@ public void start() {
506580
if (logger.isDebugEnabled()) {
507581
logger.debug("Starting beans in phase " + this.phase);
508582
}
583+
Long concurrentStartup = determineConcurrentStartup(this.phase);
584+
List<CompletableFuture<?>> futures = (concurrentStartup != null ? new ArrayList<>() : null);
509585
for (LifecycleGroupMember member : this.members) {
510-
doStart(this.lifecycleBeans, member.name, this.autoStartupOnly);
586+
doStart(this.lifecycleBeans, member.name, this.autoStartupOnly, futures);
587+
}
588+
if (concurrentStartup != null && !CollectionUtils.isEmpty(futures)) {
589+
try {
590+
CompletableFuture.allOf(futures.toArray(new CompletableFuture<?>[0]))
591+
.get(concurrentStartup, TimeUnit.MILLISECONDS);
592+
}
593+
catch (Exception ex) {
594+
if (ex instanceof ExecutionException exEx) {
595+
Throwable cause = exEx.getCause();
596+
if (cause instanceof ApplicationContextException acEx) {
597+
throw acEx;
598+
}
599+
}
600+
throw new ApplicationContextException("Failed to start beans in phase " + this.phase +
601+
" within timeout of " + concurrentStartup + "ms", ex);
602+
}
511603
}
512604
}
513605

@@ -531,11 +623,14 @@ else if (member.bean instanceof SmartLifecycle) {
531623
}
532624
}
533625
try {
534-
latch.await(this.timeout, TimeUnit.MILLISECONDS);
535-
if (latch.getCount() > 0 && !countDownBeanNames.isEmpty() && logger.isInfoEnabled()) {
536-
logger.info("Shutdown phase " + this.phase + " ends with " + countDownBeanNames.size() +
537-
" bean" + (countDownBeanNames.size() > 1 ? "s" : "") +
538-
" still running after timeout of " + this.timeout + "ms: " + countDownBeanNames);
626+
long shutdownTimeout = determineShutdownTimeout(this.phase);
627+
if (!latch.await(shutdownTimeout, TimeUnit.MILLISECONDS)) {
628+
// Count is still >0 after timeout
629+
if (!countDownBeanNames.isEmpty() && logger.isInfoEnabled()) {
630+
logger.info("Shutdown phase " + this.phase + " ends with " + countDownBeanNames.size() +
631+
" bean" + (countDownBeanNames.size() > 1 ? "s" : "") +
632+
" still running after timeout of " + shutdownTimeout + "ms: " + countDownBeanNames);
633+
}
539634
}
540635
}
541636
catch (InterruptedException ex) {

0 commit comments

Comments
 (0)