New posts for Spring and Hibernate

This commit is contained in:
2026-11-06 01:05:27 +05:30
parent 51913ad9d4
commit dc13293046
136 changed files with 5268 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
# startup-runners
Companion project for the article **[CommandLineRunner vs ApplicationRunner vs ApplicationReadyEvent in Spring Boot 4](https://ankurm.com/commandlinerunner-vs-applicationrunner-vs-applicationreadyevent-spring-boot-4/)** on **[ankurm.com](https://ankurm.com)**.
Every console block quoted in the article came out of `output/`. Transcripts 00-14 are written by the test
suite (so a claim that stops being true turns the build red); 15-17 are read out of the Spring Boot and Spring
Framework sources and jars by `scripts/capture-facts.sh`.
There is deliberately **no `docs/` folder**: the deeper material lives in collapsible sections inside the article.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.2 (GA 2026-10-22 on Maven Central) |
| Spring Framework | 7.0.10 |
| JDK | 25 (25.0.3) |
| Maven | 3.9 |
| Sources read for contrast | Spring Boot 3.0.13 and 3.1.12 (`callRunners` only, not run) |
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
mvn test # runs the scenarios and rewrites output/00-14
./scripts/run-all.sh # everything, including 15-17 (needs network access to Maven Central once)
```
Transcript 03 (four runners without `@Order`) is the one file that is allowed to differ between runs: that is its point.
## Source layout
Each scenario is its own package with its own `@SpringBootApplication`.
| Package | What it holds |
|---|---|
| `hello/` | the smallest example: a `CommandLineRunner`, an `ApplicationRunner`, an `ApplicationReadyEvent` listener |
| `order/` | every startup hook plus `@PostConstruct`, `InitializingBean`, `SmartLifecycle`, printing the sequence and the readiness state |
| `ordering/`, `ties/` | `@Order` on classes, `@Bean` methods and `Ordered`; four runners with no order at all |
| `args/` | the same command line seen as `String[]` and as `ApplicationArguments` |
| `failure/` | a runner that throws, an `ApplicationReadyEvent` listener that throws, `ExitCodeGenerator`, a checked exception (profiles) |
| `blocking/` | a runner that blocks (web app, actuator probes) and one that hands its work to a virtual thread |
| `lifecycle/`, `lifestartfails/`, `lifeforgetful/` | `SmartLifecycle` phases, a `start()` that throws, an `isRunning()` that never says true |
| `slices/` | startup hooks declared four ways, for the `@SpringBootTest` and `@WebMvcTest` scenarios |
| `support/` | `Trace`, the in-memory log the tests print |
## Captured output
| File | What it shows |
|---|---|
| `00-hello.txt` | the three hooks of `HelloApp`, started with a real command line |
| `01-startup-hooks-in-order.txt` | every hook, in order, non-web and web, with the readiness state each one sees |
| `02-order-annotation-and-ordered.txt` | both runner kinds sorted together by `@Order` / `Ordered`; a bean that is both kinds |
| `03-ties-have-no-promised-order.txt` | four unordered runners in 8 fresh JVMs |
| `04-arguments.txt` | `String[]` versus `ApplicationArguments` |
| `05-runner-failure.txt` | a throwing runner: events, log, exit codes (1, 42), checked-exception wrapping |
| `06-ready-listener-failure.txt` | a throwing `ApplicationReadyEvent` listener, non-web and web |
| `07-blocking-runner-readiness.txt` | a blocking runner: port open, liveness UP, readiness 503 |
| `08-async-runner.txt` | a runner that returns at once and a background task that fails |
| `09-smartlifecycle-phases.txt` | start in ascending phase, stop in descending phase, async stop, a bean that is not auto-started |
| `10-lifecycle-beans-in-a-web-app.txt` | every `SmartLifecycle` bean in a web app with its phase |
| `11-smartlifecycle-start-fails.txt` | `start()` throws: no started event, no runner |
| `12-smartlifecycle-forgot-isrunning.txt` | `isRunning()` always false: `stop()` never called |
| `13-spring-boot-test-runs-the-hooks.txt` | what `@SpringBootTest` runs |
| `14-webmvctest-slice-hooks.txt` | what `@WebMvcTest` runs |
| `15-callrunners-source.txt` | `SpringApplication.run`, `callRunners`, `callRunner` and the run listener, from the 4.1.2 sources, plus 3.0.13 and 3.1.12 |
| `16-javap-callrunners.txt` | the compiled `callRunners` and the runner interfaces, read with `javap` |
| `17-lifecycleprocessor-source.txt` | when `DefaultLifecycleProcessor` calls `start()` and `stop()` |
+6
View File
@@ -0,0 +1,6 @@
# HelloApp started with: --greeting=hi --verbose notes.txt
hello: CommandLineRunner args = [--greeting=hi, --verbose, notes.txt]
hello: ApplicationRunner options = [greeting, verbose], non-option args = [notes.txt]
hello: ApplicationReadyEvent listener
process exit code: 0
@@ -0,0 +1,29 @@
# Every startup hook in the order Spring Boot 4.1.2 really calls it
--- non-web application ---
@PostConstruct
InitializingBean.afterPropertiesSet
SmartInitializingSingleton.afterSingletonsInstantiated
SmartLifecycle.start (default phase)
ContextRefreshedEvent
ApplicationStartedEvent
AvailabilityChangeEvent -> CORRECT
CommandLineRunner (readiness seen from inside: REFUSING_TRAFFIC, liveness: CORRECT)
ApplicationRunner (readiness seen from inside: REFUSING_TRAFFIC)
ApplicationReadyEvent (readiness seen from inside: REFUSING_TRAFFIC)
AvailabilityChangeEvent -> ACCEPTING_TRAFFIC
--- servlet web application (Tomcat on a random port) ---
@PostConstruct
InitializingBean.afterPropertiesSet
SmartInitializingSingleton.afterSingletonsInstantiated
WebServerInitializedEvent (port is open)
SmartLifecycle.start (default phase)
ContextRefreshedEvent
ApplicationStartedEvent
AvailabilityChangeEvent -> CORRECT
CommandLineRunner (readiness seen from inside: REFUSING_TRAFFIC, liveness: CORRECT)
ApplicationRunner (readiness seen from inside: REFUSING_TRAFFIC)
ApplicationReadyEvent (readiness seen from inside: REFUSING_TRAFFIC)
AvailabilityChangeEvent -> ACCEPTING_TRAFFIC
@@ -0,0 +1,12 @@
# CommandLineRunner and ApplicationRunner beans sorted together by @Order / Ordered
(declaration order in OrderingApp.java is NOT the order below)
CommandLineRunner @Order(HIGHEST_PRECEDENCE)
ApplicationRunner @Order(1) on the class
CommandLineRunner @Order(2) on the class
ApplicationRunner implements Ordered, getOrder() = 3
Both @Order(4) -> ApplicationRunner.run
Both @Order(4) -> CommandLineRunner.run
CommandLineRunner @Order(5) on a @Bean method
ApplicationRunner no order at all (lowest precedence)
@@ -0,0 +1,16 @@
# Four runners without @Order: the order chosen by each of 8 fresh JVMs
JVM 1: arD arB cliC cliA
JVM 2: cliC cliA arD arB
JVM 3: cliA arB arD cliC
JVM 4: arD arB cliC cliA
JVM 5: arD arB cliC cliA
JVM 6: cliC cliA arB arD
JVM 7: cliC cliA arB arD
JVM 8: arD arB cliC cliA
distinct sequences seen: 4
arD arB cliC cliA x4
cliC cliA arD arB x1
cliA arB arD cliC x1
cliC cliA arB arD x2
+14
View File
@@ -0,0 +1,14 @@
# The same command line, seen by CommandLineRunner and ApplicationRunner
args passed to SpringApplication.run: [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
CommandLineRunner String[] args = [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
ApplicationRunner getSourceArgs() = [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
ApplicationRunner getOptionNames() = [name, verbose]
ApplicationRunner getOptionValues(name) = [Ankur, Second]
ApplicationRunner getOptionValues(verbose) = []
ApplicationRunner getOptionValues(missing) = null
ApplicationRunner containsOption(verbose) = true
ApplicationRunner getNonOptionArgs() = [data.csv, -x, --after-the-dashes]
Environment property 'name' (both values, joined by a comma): Ankur,Second
@@ -0,0 +1,23 @@
# A runner throws: exit code, log, events, and the runners that never ran
--- three ordered runners, the second throws (non-web, in a separate JVM) ---
TRACE ApplicationStartedEvent
TRACE runner 1 ran
TRACE runner 2 is about to throw
TRACE ApplicationFailedEvent: java.lang.IllegalStateException: boom from runner 2
TRACE ContextClosedEvent (the context is shutting down)
ERROR o.s.b.SpringApplication : Application run failed
java.lang.IllegalStateException: boom from runner 2
process exit code: 1
--- a runner exception that implements ExitCodeGenerator ---
ERROR o.s.b.SpringApplication : Application run failed
com.ankurm.runners.failure.FailureApp$ExitWith42Exception: the data file is missing
process exit code: 42
--- a runner that throws a checked exception (declared with throws Exception) ---
ERROR o.s.b.SpringApplication : Application run failed
java.lang.IllegalStateException: Failed to execute CommandLineRunner
Caused by: java.io.IOException: cannot read data.csv
process exit code: 1
@@ -0,0 +1,20 @@
# An ApplicationReadyEvent listener throws: the app was 'ready' and is now gone
--- non-web application ---
TRACE ApplicationStartedEvent
TRACE ready listener 1 is about to throw (readiness: REFUSING_TRAFFIC)
TRACE ContextClosedEvent (the context is shutting down)
ERROR o.s.b.SpringApplication : Application run failed
java.lang.IllegalStateException: boom from the ready listener
process exit code: 1
--- web application (Tomcat is up when the listener runs) ---
TRACE ApplicationStartedEvent
TRACE GET /ping from inside the listener -> 200 pong
TRACE ready listener 1 is about to throw (readiness: REFUSING_TRAFFIC)
TRACE ContextClosedEvent (the context is shutting down)
ERROR o.s.b.SpringApplication : Application run failed
java.lang.IllegalStateException: boom from the ready listener
process exit code: 1
does the port answer after the process ended? false
@@ -0,0 +1,16 @@
# A runner that blocks: the port is open, liveness is UP, readiness is OUT_OF_SERVICE
--- while the runner is blocked ---
has SpringApplication.run() returned? false
GET /ping -> 200 pong
GET /actuator/health/liveness -> 200 {"status":"UP"}
GET /actuator/health/readiness -> 503 {"status":"OUT_OF_SERVICE"}
--- after the runner returned ---
has SpringApplication.run() returned? true
GET /actuator/health/readiness -> 200 {"status":"UP"}
--- trace ---
runner: started on platform thread app-main, now waiting (think: warming a cache for 40 s)
runner: finished, returning to SpringApplication.run
@@ -0,0 +1,14 @@
# A runner that starts a virtual thread and returns: readiness does not wait, and failures do not fail startup
--- the background work is still waiting ---
has SpringApplication.run() returned? true
GET /actuator/health/readiness -> 200 {"status":"UP"}
--- after the background work threw ---
GET /actuator/health/readiness -> 200 {"status":"UP"}
GET /ping -> 200 pong
--- trace ---
runner: started the work on a virtual thread and returned
background work on a virtual thread failed: the warm-up blew up
@@ -0,0 +1,22 @@
# SmartLifecycle: start in ascending phase, stop in descending phase, runners after all of it
--- startup ---
start low (phase -100) on main
start mid (phase 0) on main
start high (phase 100) on main
start slow (phase 200) on main
ApplicationStartedEvent
CommandLineRunner runs
ApplicationReadyEvent
manual.isAutoStartup()=false isRunning()=false (never started by the context)
--- context.close() ---
start manual (phase 50) on main
ContextClosedEvent (stop phase begins)
stop slow (phase 200) begins, will finish in 300 ms
stop slow finished, calling the callback
stop high (phase 100) on main
stop manual (phase 50) on main
stop mid (phase 0) on main
stop low (phase -100) on main
@@ -0,0 +1,15 @@
# Every SmartLifecycle bean in a servlet web application, by phase (the ones this project adds are marked)
phase -2147483647 springBootLoggingLifecycle Lifecycle
phase -100 low (this project)
phase 0 mid (this project)
phase 50 manual (this project)
phase 100 high (this project)
phase 200 slow (this project)
phase 1073741823 applicationTaskExecutor ThreadPoolTaskExecutor
phase 2147481599 webServerStartStop WebServerStartStopLifecycle
phase 2147482623 webServerGracefulShutdown WebServerGracefulShutdownLifecycle
SmartLifecycle.DEFAULT_PHASE = 2147483647
Integer.MAX_VALUE = 2147483647
timeout per shutdown phase = 30000 ms (spring.lifecycle.timeout-per-shutdown-phase, set by Boot)
Spring Boot 4.1.2, Spring Framework 7.0.10
@@ -0,0 +1,9 @@
# SmartLifecycle.start() throws: no ApplicationStartedEvent, no runner
start healthy (phase 0)
start broken (phase 10)
stop healthy
application started? false
org.springframework.context.ApplicationContextException: Failed to start bean 'broken'
java.lang.IllegalStateException: could not connect to the broker
@@ -0,0 +1,6 @@
# isRunning() always false: start() is called, stop() never is
--- startup ---
start() was called
--- after context.close() ---
(nothing)
@@ -0,0 +1,6 @@
# @SpringBootTest(classes = SlicesApp.class, args = "--from-the-test=1"): what ran before the first test method
CommandLineRunner from a top-level @Component ran, args = [--from-the-test=1]
CommandLineRunner nested in SlicesApp ran, args = [--from-the-test=1]
ApplicationRunner from a @Bean method ran, source args = [--from-the-test=1]
ApplicationReadyEvent listener (a top-level @Component) ran
@@ -0,0 +1,4 @@
# @WebMvcTest(controllers = Hello.class): what ran before the first test method
CommandLineRunner nested in SlicesApp ran, args = []
ApplicationRunner from a @Bean method ran, source args = []
@@ -0,0 +1,81 @@
# Spring Boot 4.1.2 sources: the order SpringApplication.run() calls things in, and callRunners
--- SpringApplication.run(): from refresh to ready (lines containing the calls) ---
18: refreshContext(context);
19: afterRefresh(context, applicationArguments);
24: listeners.started(context, timeTakenToStarted);
25: callRunners(context, applicationArguments);
28: throw handleRunFailure(context, ex, listeners);
32: listeners.ready(context, startup.ready());
36: throw handleRunFailure(context, ex, null);
--- SpringApplication.callRunners ---
private void callRunners(ConfigurableApplicationContext context, ApplicationArguments args) {
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
String[] beanNames = beanFactory.getBeanNamesForType(Runner.class);
Map<Runner, String> instancesToBeanNames = new IdentityHashMap<>();
for (String beanName : beanNames) {
instancesToBeanNames.put(beanFactory.getBean(beanName, Runner.class), beanName);
}
Comparator<Object> comparator = getOrderComparator(beanFactory)
.withSourceProvider(new FactoryAwareOrderSourceProvider(beanFactory, instancesToBeanNames));
instancesToBeanNames.keySet().stream().sorted(comparator).forEach((runner) -> callRunner(runner, args));
}
--- SpringApplication.callRunner (what happens to a bean that is both kinds) ---
private void callRunner(Runner runner, ApplicationArguments args) {
if (runner instanceof ApplicationRunner) {
callRunner(ApplicationRunner.class, runner, (applicationRunner) -> applicationRunner.run(args));
}
if (runner instanceof CommandLineRunner) {
callRunner(CommandLineRunner.class, runner,
(commandLineRunner) -> commandLineRunner.run(args.getSourceArgs()));
}
}
--- SpringApplication.callRunner(Class, Runner, ThrowingConsumer) (where 'Failed to execute' comes from) ---
private <R extends Runner> void callRunner(Class<R> type, Runner runner, ThrowingConsumer<R> call) {
call.throwing(
(message, ex) -> new IllegalStateException("Failed to execute " + ClassUtils.getShortName(type), ex))
.accept((R) runner);
}
--- EventPublishingRunListener.started / ready ---
public void started(ConfigurableApplicationContext context, @Nullable Duration timeTaken) {
context.publishEvent(new ApplicationStartedEvent(this.application, this.args, context, timeTaken));
AvailabilityChangeEvent.publish(context, LivenessState.CORRECT);
}
public void ready(ConfigurableApplicationContext context, @Nullable Duration timeTaken) {
context.publishEvent(new ApplicationReadyEvent(this.application, this.args, context, timeTaken));
AvailabilityChangeEvent.publish(context, ReadinessState.ACCEPTING_TRAFFIC);
}
--- the marker interface that both runners extend ---
interface Runner {
public interface CommandLineRunner extends Runner {
public interface ApplicationRunner extends Runner {
--- the same method in Spring Boot 3.0.13 ---
private void callRunners(ApplicationContext context, ApplicationArguments args) {
context.getBeanProvider(Runner.class).orderedStream().forEach((runner) -> {
if (runner instanceof ApplicationRunner applicationRunner) {
callRunner(applicationRunner, args);
}
if (runner instanceof CommandLineRunner commandLineRunner) {
callRunner(commandLineRunner, args);
}
});
}
--- the same method in Spring Boot 3.1.12 ---
private void callRunners(ConfigurableApplicationContext context, ApplicationArguments args) {
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
String[] beanNames = beanFactory.getBeanNamesForType(Runner.class);
Map<Runner, String> instancesToBeanNames = new IdentityHashMap<>();
for (String beanName : beanNames) {
instancesToBeanNames.put(beanFactory.getBean(beanName, Runner.class), beanName);
}
Comparator<Object> comparator = getOrderComparator(beanFactory)
.withSourceProvider(new FactoryAwareOrderSourceProvider(beanFactory, instancesToBeanNames));
instancesToBeanNames.keySet().stream().sorted(comparator).forEach((runner) -> callRunner(runner, args));
}
@@ -0,0 +1,31 @@
# javap -p -c on spring-boot-4.1.2.jar: the compiled SpringApplication.callRunners
--- javap -p org.springframework.boot.Runner / CommandLineRunner / ApplicationRunner ---
Compiled from "Runner.java"
interface org.springframework.boot.Runner {
}
Compiled from "CommandLineRunner.java"
public interface org.springframework.boot.CommandLineRunner extends org.springframework.boot.Runner {
public abstract void run(java.lang.String...) throws java.lang.Exception;
}
Compiled from "ApplicationRunner.java"
public interface org.springframework.boot.ApplicationRunner extends org.springframework.boot.Runner {
public abstract void run(org.springframework.boot.ApplicationArguments) throws java.lang.Exception;
}
--- invoke* instructions inside callRunners ---
invokeinterface #393, 1 // InterfaceMethod org/springframework/context/ConfigurableApplicationContext.getBeanFactory:()Lorg/springframework/beans/factory/config/ConfigurableListableBeanFactory;
invokeinterface #966, 2 // InterfaceMethod org/springframework/beans/factory/config/ConfigurableListableBeanFactory.getBeanNamesForType:(Ljava/lang/Class;)[Ljava/lang/String;
new #970 // class java/util/IdentityHashMap
invokespecial #972 // Method java/util/IdentityHashMap."<init>":()V
invokeinterface #973, 3 // InterfaceMethod org/springframework/beans/factory/config/ConfigurableListableBeanFactory.getBean:(Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object;
invokeinterface #977, 3 // InterfaceMethod java/util/Map.put:(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
invokevirtual #983 // Method getOrderComparator:(Lorg/springframework/beans/factory/config/ConfigurableListableBeanFactory;)Lorg/springframework/core/OrderComparator;
new #987 // class org/springframework/boot/SpringApplication$FactoryAwareOrderSourceProvider
invokespecial #989 // Method org/springframework/boot/SpringApplication$FactoryAwareOrderSourceProvider."<init>":(Lorg/springframework/beans/factory/config/ConfigurableBeanFactory;Ljava/util/Map;)V
invokevirtual #992 // Method org/springframework/core/OrderComparator.withSourceProvider:(Lorg/springframework/core/OrderComparator$OrderSourceProvider;)Ljava/util/Comparator;
invokeinterface #998, 1 // InterfaceMethod java/util/Map.keySet:()Ljava/util/Set;
invokeinterface #1001, 1 // InterfaceMethod java/util/Set.stream:()Ljava/util/stream/Stream;
invokeinterface #1002, 2 // InterfaceMethod java/util/stream/Stream.sorted:(Ljava/util/Comparator;)Ljava/util/stream/Stream;
invokedynamic #1006, 0 // InvokeDynamic #12:accept:(Lorg/springframework/boot/SpringApplication;Lorg/springframework/boot/ApplicationArguments;)Ljava/util/function/Consumer;
invokeinterface #1009, 2 // InterfaceMethod java/util/stream/Stream.forEach:(Ljava/util/function/Consumer;)V
@@ -0,0 +1,97 @@
# spring-context 7.0.10 sources: DefaultLifecycleProcessor, the object that starts and stops SmartLifecycle beans
--- doStart: start() is called only when isRunning() is false ---
private void doStart(Map<String, ? extends Lifecycle> lifecycleBeans, String beanName,
boolean autoStartupOnly, @Nullable List<CompletableFuture<?>> futures) {
Lifecycle bean = lifecycleBeans.remove(beanName);
if (bean != null && bean != this) {
String[] dependenciesForBean = getBeanFactory().getDependenciesForBean(beanName);
for (String dependency : dependenciesForBean) {
doStart(lifecycleBeans, dependency, autoStartupOnly, futures);
}
if (!bean.isRunning() && (!autoStartupOnly || toBeStarted(beanName, bean))) {
if (futures != null) {
futures.add(CompletableFuture.runAsync(() -> doStart(beanName, bean), getBootstrapExecutor()));
}
else {
doStart(beanName, bean);
}
}
}
}
--- doStop: stop() is called only when isRunning() is true ---
private void doStop(Map<String, ? extends Lifecycle> lifecycleBeans, final String beanName,
boolean pauseableOnly, final CountDownLatch latch, final Set<String> countDownBeanNames) {
Lifecycle bean = lifecycleBeans.remove(beanName);
if (bean != null) {
String[] dependentBeans = getBeanFactory().getDependentBeans(beanName);
for (String dependentBean : dependentBeans) {
doStop(lifecycleBeans, dependentBean, pauseableOnly, latch, countDownBeanNames);
}
try {
if (bean.isRunning()) {
Set<String> stoppedBeans = this.stoppedBeans;
if (stoppedBeans != null) {
stoppedBeans.add(beanName);
}
if (bean instanceof SmartLifecycle smartLifecycle) {
if (!pauseableOnly || smartLifecycle.isPauseable()) {
if (logger.isTraceEnabled()) {
logger.trace("Asking bean '" + beanName + "' of type [" +
bean.getClass().getName() + "] to stop");
}
countDownBeanNames.add(beanName);
smartLifecycle.stop(() -> {
latch.countDown();
countDownBeanNames.remove(beanName);
if (logger.isDebugEnabled()) {
logger.debug("Bean '" + beanName + "' completed its stop procedure");
}
});
}
else {
// Don't wait for beans that aren't pauseable...
latch.countDown();
}
}
else if (!pauseableOnly) {
if (logger.isTraceEnabled()) {
logger.trace("Stopping bean '" + beanName + "' of type [" +
bean.getClass().getName() + "]");
}
bean.stop();
if (logger.isDebugEnabled()) {
logger.debug("Successfully stopped bean '" + beanName + "'");
}
}
}
else if (bean instanceof SmartLifecycle) {
// Don't wait for beans that aren't running...
latch.countDown();
}
}
catch (Throwable ex) {
if (logger.isWarnEnabled()) {
logger.warn("Failed to stop bean '" + beanName + "'", ex);
}
if (bean instanceof SmartLifecycle) {
latch.countDown();
}
}
}
}
--- isAutoStartupCandidate / toBeStarted: what isAutoStartup() decides ---
private boolean isAutoStartupCandidate(String beanName, Lifecycle bean) {
Set<String> stoppedBeans = this.stoppedBeans;
return (stoppedBeans != null ? stoppedBeans.contains(beanName) :
(bean instanceof SmartLifecycle smartLifecycle && smartLifecycle.isAutoStartup()));
}
private boolean toBeStarted(String beanName, Lifecycle bean) {
Set<String> stoppedBeans = this.stoppedBeans;
return (stoppedBeans != null ? stoppedBeans.contains(beanName) :
(!(bean instanceof SmartLifecycle smartLifecycle) || smartLifecycle.isAutoStartup()));
}
+61
View File
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>startup-runners</artifactId>
<version>1.0.0</version>
<name>startup-runners</name>
<description>CommandLineRunner vs ApplicationRunner vs ApplicationReadyEvent vs SmartLifecycle on Spring Boot 4</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- some scenarios start a second JVM with the test classpath, so give it a real classpath, not a manifest-only jar -->
<useManifestOnlyJar>false</useManifestOnlyJar>
</configuration>
</plugin>
</plugins>
</build>
</project>
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Facts that come from the shipped jars and their sources rather than from running the scenarios:
# 15 SpringApplication.run / callRunners / EventPublishingRunListener, read from the 4.1.2 sources jar,
# and the same method in Boot 3.0.13 and 3.1.12 for contrast
# 16 the same method read out of the compiled 4.1.2 class with javap (what it really calls)
# 17 DefaultLifecycleProcessor: when start() and stop() are called
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p output target/facts
BOOT=4.1.2
SPRING=7.0.10
M2=~/.m2/repository
fetch() { # url, destination: Maven Central rate-limits shared addresses, so retry politely
for i in 1 2 3 4 5 6; do
code=$(curl -s -o "$2" -w '%{http_code}' "$1" || true)
[ "$code" = 200 ] && return 0
sleep $((i * 10))
done
echo "could not fetch $1 (last status $code)" >&2; return 1
}
src() { # groupPath artifact version
local f="target/facts/$2-$3-sources.jar"
[ -s "$f" ] || fetch "https://repo1.maven.org/maven2/$1/$2/$3/$2-$3-sources.jar" "$f"
echo "$f"
}
method() { # sources-jar file-in-jar start-regex -> prints from the first match to the first line that is a single tab + }
unzip -p "$1" "$2" | sed -n "/$3/,/^ }/p"
}
BOOT_SRC=$(src org/springframework/boot spring-boot $BOOT)
OLD0=$(src org/springframework/boot spring-boot 3.0.13)
OLD1=$(src org/springframework/boot spring-boot 3.1.12)
FW_SRC=$(src org/springframework spring-context $SPRING)
APP=org/springframework/boot/SpringApplication.java
{
echo "# Spring Boot $BOOT sources: the order SpringApplication.run() calls things in, and callRunners"
echo
echo "--- SpringApplication.run(): from refresh to ready (lines containing the calls) ---"
unzip -p "$BOOT_SRC" $APP | sed -n '/public ConfigurableApplicationContext run(String... args)/,/^ }/p' \
| grep -n -E 'refreshContext|afterRefresh|listeners\.(started|ready)|callRunners|handleRunFailure' | sed 's/^\([0-9]*\):\s*/\1: /'
echo
echo "--- SpringApplication.callRunners ---"
method "$BOOT_SRC" $APP 'private void callRunners'
echo
echo "--- SpringApplication.callRunner (what happens to a bean that is both kinds) ---"
method "$BOOT_SRC" $APP 'private void callRunner(Runner runner'
echo
echo "--- SpringApplication.callRunner(Class, Runner, ThrowingConsumer) (where 'Failed to execute' comes from) ---"
method "$BOOT_SRC" $APP 'private <R extends Runner> void callRunner'
echo
echo "--- EventPublishingRunListener.started / ready ---"
method "$BOOT_SRC" org/springframework/boot/context/event/EventPublishingRunListener.java 'public void started'
method "$BOOT_SRC" org/springframework/boot/context/event/EventPublishingRunListener.java 'public void ready'
echo
echo "--- the marker interface that both runners extend ---"
unzip -p "$BOOT_SRC" org/springframework/boot/Runner.java | grep -E '^(interface|public interface)'
unzip -p "$BOOT_SRC" org/springframework/boot/CommandLineRunner.java | grep -E '^public interface'
unzip -p "$BOOT_SRC" org/springframework/boot/ApplicationRunner.java | grep -E '^public interface'
echo
echo "--- the same method in Spring Boot 3.0.13 ---"
method "$OLD0" $APP 'private void callRunners'
echo
echo "--- the same method in Spring Boot 3.1.12 ---"
method "$OLD1" $APP 'private void callRunners'
} > output/15-callrunners-source.txt
JAR=$(find "$M2/org/springframework/boot/spring-boot/$BOOT" -name "spring-boot-$BOOT.jar" | head -1)
{
echo "# javap -p -c on spring-boot-$BOOT.jar: the compiled SpringApplication.callRunners"
echo
echo "--- javap -p org.springframework.boot.Runner / CommandLineRunner / ApplicationRunner ---"
javap -p -cp "$JAR" org.springframework.boot.Runner org.springframework.boot.CommandLineRunner org.springframework.boot.ApplicationRunner
echo
echo "--- invoke* instructions inside callRunners ---"
javap -p -c -cp "$JAR" org.springframework.boot.SpringApplication \
| sed -n '/private void callRunners(/,/^$/p' | grep -E 'invoke|new ' | sed 's/^ *[0-9]*: //'
} > output/16-javap-callrunners.txt
CTX=$(find "$M2/org/springframework/spring-context/$SPRING" -name "spring-context-$SPRING.jar" | head -1)
LP=org/springframework/context/support/DefaultLifecycleProcessor.java
{
echo "# spring-context $SPRING sources: DefaultLifecycleProcessor, the object that starts and stops SmartLifecycle beans"
echo
echo "--- doStart: start() is called only when isRunning() is false ---"
method "$FW_SRC" $LP 'private void doStart(Map'
echo
echo "--- doStop: stop() is called only when isRunning() is true ---"
method "$FW_SRC" $LP 'private void doStop(Map'
echo
echo "--- isAutoStartupCandidate / toBeStarted: what isAutoStartup() decides ---"
method "$FW_SRC" $LP 'private boolean isAutoStartupCandidate'
method "$FW_SRC" $LP 'private boolean toBeStarted'
} > output/17-lifecycleprocessor-source.txt
ls -1 output | tail -3
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Regenerates every file under output/.
#
# ./scripts/run-all.sh
#
# Needs a JDK 25 and Maven 3.9. Transcripts 01-14 are written by the test suite, so each figure in the
# article is an assertion that fails the build if it stops being true. Transcripts 15-17 are read out of the
# Spring Boot / Spring Framework sources and jars by capture-facts.sh (network access to Maven Central needed once).
# Transcript 03 (runners without @Order) is the one that is allowed to change from run to run: that is its point.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== test suite (transcripts 01-14)"
mvn -B test
echo "== facts read from sources and jars (15-17)"
./scripts/capture-facts.sh
echo
echo "output:"
ls -1 output
@@ -0,0 +1,44 @@
package com.ankurm.runners.args;
import com.ankurm.runners.support.Trace;
import java.util.Arrays;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/** Scenario 4: the same command line, as the two runner flavours see it. */
@SpringBootApplication
public class ArgsApp {
public static void main(String[] args) {
SpringApplication.run(ArgsApp.class, args);
}
@Component
@Order(1)
static class Cli implements CommandLineRunner {
@Override
public void run(String... args) {
Trace.add("CommandLineRunner String[] args = %s", Arrays.toString(args));
}
}
@Component
@Order(2)
static class App implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
Trace.add("ApplicationRunner getSourceArgs() = %s", Arrays.toString(args.getSourceArgs()));
Trace.add("ApplicationRunner getOptionNames() = %s", args.getOptionNames());
Trace.add("ApplicationRunner getOptionValues(name) = %s", args.getOptionValues("name"));
Trace.add("ApplicationRunner getOptionValues(verbose) = %s", args.getOptionValues("verbose"));
Trace.add("ApplicationRunner getOptionValues(missing) = %s", args.getOptionValues("missing"));
Trace.add("ApplicationRunner containsOption(verbose) = %s", args.containsOption("verbose"));
Trace.add("ApplicationRunner getNonOptionArgs() = %s", args.getNonOptionArgs());
}
}
}
@@ -0,0 +1,65 @@
package com.ankurm.runners.blocking;
import com.ankurm.runners.support.Trace;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Profile;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Scenarios 7 and 8: a web application whose runner takes a long time. With profile {@code blocking} the runner
* waits on a latch in the runner thread; with profile {@code async} it hands the same wait to a virtual thread and
* returns at once. The test opens and closes the latch, so nothing here depends on a sleep.
*/
@SpringBootApplication
public class BlockingApp {
public static void main(String[] args) {
SpringApplication.run(BlockingApp.class, args);
}
@RestController
static class Ping {
@GetMapping("/ping")
String ping() {
return "pong";
}
}
@Bean
@Profile("blocking")
CommandLineRunner blockingRunner() {
return args -> {
Trace.add("runner: started on %s, now waiting (think: warming a cache for 40 s)", Trace.where());
Gate.RUNNER_STARTED.countDown();
Gate.RELEASE.await(30, TimeUnit.SECONDS);
Trace.add("runner: finished, returning to SpringApplication.run");
};
}
@Bean
@Profile("async")
CommandLineRunner asyncRunner() {
return args -> {
Thread.ofVirtual()
.uncaughtExceptionHandler((thread, e) -> {
Trace.add("background work on %s failed: %s", Trace.where(), e.getMessage());
Gate.BACKGROUND_DONE.countDown();
})
.start(() -> {
Gate.RUNNER_STARTED.countDown();
try {
Gate.RELEASE.await(30, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
throw new IllegalStateException("the warm-up blew up");
});
Trace.add("runner: started the work on a virtual thread and returned");
};
}
}
@@ -0,0 +1,20 @@
package com.ankurm.runners.blocking;
import java.util.concurrent.CountDownLatch;
/** Latches the test uses to freeze and release the slow work, so the scenario needs no sleeps. */
public final class Gate {
public static volatile CountDownLatch RUNNER_STARTED = new CountDownLatch(1);
public static volatile CountDownLatch RELEASE = new CountDownLatch(1);
public static volatile CountDownLatch BACKGROUND_DONE = new CountDownLatch(1);
private Gate() {
}
public static void reset() {
RUNNER_STARTED = new CountDownLatch(1);
RELEASE = new CountDownLatch(1);
BACKGROUND_DONE = new CountDownLatch(1);
}
}
@@ -0,0 +1,164 @@
package com.ankurm.runners.failure;
import com.ankurm.runners.support.Trace;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.ExitCodeGenerator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.availability.ApplicationAvailability;
import org.springframework.boot.context.event.ApplicationFailedEvent;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Scenarios 5 and 6. Run it with {@code --spring.profiles.active=runner-fails}, {@code ready-fails}, {@code exit-code}
* or {@code checked} and look at the exit code and the console.
*/
@SpringBootApplication
public class FailureApp {
public static void main(String[] args) {
SpringApplication.run(FailureApp.class, args);
}
/** Always present: shows which of the startup events fire at all. */
@Component
static class Observer {
private final ApplicationAvailability availability;
Observer(ApplicationAvailability availability) {
this.availability = availability;
}
@EventListener
void started(ApplicationStartedEvent e) {
Trace.add("ApplicationStartedEvent");
}
@EventListener
void ready(ApplicationReadyEvent e) {
Trace.add("ApplicationReadyEvent (observer, readiness: %s)", availability.getReadinessState());
}
@EventListener
void failed(ApplicationFailedEvent e) {
Trace.add("ApplicationFailedEvent: %s", e.getException());
}
@EventListener
void closed(ContextClosedEvent e) {
Trace.add("ContextClosedEvent (the context is shutting down)");
}
}
@RestController
static class Ping {
@GetMapping("/ping")
String ping() {
return "pong";
}
}
// ---- profile runner-fails: the second of three runners throws ----------------------------------------
@Bean
@Profile("runner-fails")
@Order(1)
CommandLineRunner runnerOne() {
return args -> Trace.add("runner 1 ran");
}
@Bean
@Profile("runner-fails")
@Order(2)
ApplicationRunner runnerTwo() {
return args -> {
Trace.add("runner 2 is about to throw");
throw new IllegalStateException("boom from runner 2");
};
}
@Bean
@Profile("runner-fails")
@Order(3)
CommandLineRunner runnerThree() {
return args -> Trace.add("runner 3 ran");
}
// ---- profile ready-fails: an ApplicationReadyEvent listener throws -----------------------------------
@Component
@Profile("ready-fails")
static class ThrowingReadyListener {
private final ApplicationAvailability availability;
private final Environment env;
ThrowingReadyListener(ApplicationAvailability availability, Environment env) {
this.availability = availability;
this.env = env;
}
@EventListener
@Order(1)
void ready(ApplicationReadyEvent e) throws Exception {
String port = env.getProperty("server.port");
if (port != null) {
var response = java.net.http.HttpClient.newHttpClient().send(
java.net.http.HttpRequest.newBuilder(java.net.URI.create("http://localhost:" + port + "/ping")).build(),
java.net.http.HttpResponse.BodyHandlers.ofString());
Trace.add("GET /ping from inside the listener -> %d %s", response.statusCode(), response.body());
}
Trace.add("ready listener 1 is about to throw (readiness: %s)", availability.getReadinessState());
throw new IllegalStateException("boom from the ready listener");
}
@EventListener
@Order(2)
void secondListener(ApplicationReadyEvent e) {
Trace.add("ready listener 2 ran");
}
}
// ---- profile exit-code: a runner throws an exception that carries its own exit code ------------------
@Bean
@Profile("exit-code")
CommandLineRunner exitCodeRunner() {
return args -> {
throw new ExitWith42Exception("the data file is missing");
};
}
// ---- profile checked: a runner declares "throws Exception" and throws a checked one -------------------
@Bean
@Profile("checked")
CommandLineRunner checkedExceptionRunner() {
return args -> {
throw new java.io.IOException("cannot read data.csv");
};
}
static class ExitWith42Exception extends RuntimeException implements ExitCodeGenerator {
ExitWith42Exception(String message) {
super(message);
}
@Override
public int getExitCode() {
return 42;
}
}
}
@@ -0,0 +1,38 @@
package com.ankurm.runners.hello;
import java.util.Arrays;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Bean;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
/** The smallest working example of all three hooks: each prints one line, so you can see the sequence. */
@SpringBootApplication
public class HelloApp {
public static void main(String[] args) {
SpringApplication.run(HelloApp.class, args);
}
@Bean
@Order(1)
CommandLineRunner commandLineRunner() {
return args -> System.out.println("hello: CommandLineRunner args = " + Arrays.toString(args));
}
@Bean
@Order(2)
ApplicationRunner applicationRunner() {
return args -> System.out.println("hello: ApplicationRunner options = " + args.getOptionNames()
+ ", non-option args = " + args.getNonOptionArgs());
}
@EventListener(ApplicationReadyEvent.class)
void onReady() {
System.out.println("hello: ApplicationReadyEvent listener");
}
}
@@ -0,0 +1,72 @@
package com.ankurm.runners.lifecycle;
import com.ankurm.runners.support.Trace;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.annotation.Bean;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/**
* Scenario 9: five {@link Svc} lifecycle beans with different phases, one that must be started by hand and one that
* takes 300 ms to stop, next to a runner and the startup events.
*/
@SpringBootApplication
public class LifecycleApp {
public static void main(String[] args) {
SpringApplication.run(LifecycleApp.class, args);
}
@Bean
Svc high() {
return new Svc("high", 100, true, 0);
}
@Bean
Svc low() {
return new Svc("low", -100, true, 0);
}
@Bean
Svc mid() {
return new Svc("mid", 0, true, 0);
}
@Bean
Svc manual() {
return new Svc("manual", 50, false, 0);
}
@Bean
Svc slow() {
return new Svc("slow", 200, true, 300);
}
@Bean
CommandLineRunner runner() {
return args -> Trace.add("CommandLineRunner runs");
}
@Component
static class Events {
@EventListener
void started(ApplicationStartedEvent e) {
Trace.add("ApplicationStartedEvent");
}
@EventListener
void ready(ApplicationReadyEvent e) {
Trace.add("ApplicationReadyEvent");
}
@EventListener
void closing(ContextClosedEvent e) {
Trace.add("ContextClosedEvent (stop phase begins)");
}
}
}
@@ -0,0 +1,72 @@
package com.ankurm.runners.lifecycle;
import com.ankurm.runners.support.Trace;
import org.springframework.context.SmartLifecycle;
/** A {@link SmartLifecycle} that writes to the trace when it is started and stopped. */
public class Svc implements SmartLifecycle {
private final String name;
private final int phase;
private final boolean autoStartup;
private final long slowStopMillis;
private volatile boolean running;
public Svc(String name, int phase, boolean autoStartup, long slowStopMillis) {
this.name = name;
this.phase = phase;
this.autoStartup = autoStartup;
this.slowStopMillis = slowStopMillis;
}
@Override
public void start() {
running = true;
Trace.add("start %-6s (phase %d) on %s", name, phase, Trace.where());
}
@Override
public void stop() {
running = false;
Trace.add("stop %-6s (phase %d) on %s", name, phase, Trace.where());
}
/**
* The asynchronous stop: Spring waits for the callback (up to spring.lifecycle.timeout-per-shutdown-phase) before it moves
* on to the next phase. A bean with no slow shutdown simply keeps the default, which calls {@link #stop()} and then
* the callback.
*/
@Override
public void stop(Runnable callback) {
if (slowStopMillis == 0) {
SmartLifecycle.super.stop(callback);
return;
}
Trace.add("stop %-6s (phase %d) begins, will finish in %d ms", name, phase, slowStopMillis);
new Thread(() -> {
try {
Thread.sleep(slowStopMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
running = false;
Trace.add("stop %-6s finished, calling the callback", name);
callback.run();
}, "slow-stop").start();
}
@Override
public boolean isRunning() {
return running;
}
@Override
public boolean isAutoStartup() {
return autoStartup;
}
@Override
public int getPhase() {
return phase;
}
}
@@ -0,0 +1,37 @@
package com.ankurm.runners.lifeforgetful;
import com.ankurm.runners.support.Trace;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.annotation.Bean;
/** Scenario 10b: a {@link SmartLifecycle} whose {@code isRunning()} always says false. */
@SpringBootApplication
public class ForgetfulApp {
public static void main(String[] args) {
SpringApplication.run(ForgetfulApp.class, args);
}
@Bean
SmartLifecycle forgetful() {
return new SmartLifecycle() {
@Override
public void start() {
Trace.add("start() was called");
// ... and the author forgot to remember that. isRunning() below never turns true.
}
@Override
public void stop() {
Trace.add("stop() was called");
}
@Override
public boolean isRunning() {
return false;
}
};
}
}
@@ -0,0 +1,88 @@
package com.ankurm.runners.lifestartfails;
import com.ankurm.runners.support.Trace;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.annotation.Bean;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/** Scenario 10a: a {@link SmartLifecycle#start()} that throws, next to a healthy one and a runner. */
@SpringBootApplication
public class StartFailsApp {
public static void main(String[] args) {
SpringApplication.run(StartFailsApp.class, args);
}
@Bean
SmartLifecycle healthy() {
return new Named("healthy", 0, false);
}
@Bean
SmartLifecycle broken() {
return new Named("broken", 10, true);
}
@Bean
CommandLineRunner runner() {
return args -> Trace.add("CommandLineRunner runs");
}
@Component
static class Events {
@EventListener
void started(ApplicationStartedEvent e) {
Trace.add("ApplicationStartedEvent");
}
@EventListener
void closing(ContextClosedEvent e) {
Trace.add("ContextClosedEvent");
}
}
static class Named implements SmartLifecycle {
private final String name;
private final int phase;
private final boolean failOnStart;
private volatile boolean running;
Named(String name, int phase, boolean failOnStart) {
this.name = name;
this.phase = phase;
this.failOnStart = failOnStart;
}
@Override
public void start() {
Trace.add("start %s (phase %d)", name, phase);
if (failOnStart) {
throw new IllegalStateException("could not connect to the broker");
}
running = true;
}
@Override
public void stop() {
running = false;
Trace.add("stop %s", name);
}
@Override
public boolean isRunning() {
return running;
}
@Override
public int getPhase() {
return phase;
}
}
}
@@ -0,0 +1,47 @@
package com.ankurm.runners.order;
import com.ankurm.runners.support.Trace;
import org.springframework.boot.availability.ApplicationAvailability;
import org.springframework.boot.availability.AvailabilityChangeEvent;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.boot.web.server.context.WebServerInitializedEvent;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/** Listens to every startup event and also asks {@link ApplicationAvailability} what state the app thinks it is in. */
@Component
class EventProbe {
private final ApplicationAvailability availability;
EventProbe(ApplicationAvailability availability) {
this.availability = availability;
}
@EventListener
void webServer(WebServerInitializedEvent event) {
Trace.add("WebServerInitializedEvent (port is open)");
}
@EventListener
void refreshed(ContextRefreshedEvent event) {
Trace.add("ContextRefreshedEvent");
}
@EventListener
void started(ApplicationStartedEvent event) {
Trace.add("ApplicationStartedEvent");
}
@EventListener
void availabilityChanged(AvailabilityChangeEvent<?> event) {
Trace.add("AvailabilityChangeEvent -> %s", event.getState());
}
@EventListener
void ready(ApplicationReadyEvent event) {
Trace.add("ApplicationReadyEvent (readiness seen from inside: %s)", availability.getReadinessState());
}
}
@@ -0,0 +1,49 @@
package com.ankurm.runners.order;
import com.ankurm.runners.support.Trace;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Component;
/**
* One bean that implements every "too early" hook plus {@link SmartLifecycle}, so the trace shows them in the order
* Spring really calls them.
*/
@Component
class LifecycleProbe implements InitializingBean, SmartInitializingSingleton, SmartLifecycle {
private boolean running;
@PostConstruct
void postConstruct() {
Trace.add("@PostConstruct");
}
@Override
public void afterPropertiesSet() {
Trace.add("InitializingBean.afterPropertiesSet");
}
@Override
public void afterSingletonsInstantiated() {
Trace.add("SmartInitializingSingleton.afterSingletonsInstantiated");
}
@Override
public void start() {
running = true;
Trace.add("SmartLifecycle.start (default phase)");
}
@Override
public void stop() {
running = false;
}
@Override
public boolean isRunning() {
return running;
}
}
@@ -0,0 +1,13 @@
package com.ankurm.runners.order;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Scenario 1: where do the startup hooks run, relative to each other? Everything writes to {@code Trace}. */
@SpringBootApplication
public class OrderApp {
public static void main(String[] args) {
SpringApplication.run(OrderApp.class, args);
}
}
@@ -0,0 +1,46 @@
package com.ankurm.runners.order;
import com.ankurm.runners.support.Trace;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.availability.ApplicationAvailability;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/** The two runner flavours. Explicit {@code @Order} values, because runners without one have no promised order. */
class Runners {
@Component
@Order(1)
static class Cli implements CommandLineRunner {
private final ApplicationAvailability availability;
Cli(ApplicationAvailability availability) {
this.availability = availability;
}
@Override
public void run(String... args) {
Trace.add("CommandLineRunner (readiness seen from inside: %s, liveness: %s)",
availability.getReadinessState(), availability.getLivenessState());
}
}
@Component
@Order(2)
static class App implements ApplicationRunner {
private final ApplicationAvailability availability;
App(ApplicationAvailability availability) {
this.availability = availability;
}
@Override
public void run(ApplicationArguments args) {
Trace.add("ApplicationRunner (readiness seen from inside: %s)", availability.getReadinessState());
}
}
}
@@ -0,0 +1,92 @@
package com.ankurm.runners.ordering;
import com.ankurm.runners.support.Trace;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/**
* Scenario 2: seven runners of both kinds, with {@code @Order} on a class, on a {@code @Bean} method and through
* the {@link Ordered} interface, plus one with no order at all. The declaration order below is deliberately
* NOT the execution order.
*/
@SpringBootApplication
public class OrderingApp {
public static void main(String[] args) {
SpringApplication.run(OrderingApp.class, args);
}
/** {@code @Order} on a factory method: Spring Boot looks there too, not only on the class. */
@Bean
@Order(5)
CommandLineRunner cliOrderOnBeanMethod() {
return args -> Trace.add("CommandLineRunner @Order(5) on a @Bean method");
}
@Bean
ApplicationRunner arWithNoOrder() {
return args -> Trace.add("ApplicationRunner no order at all (lowest precedence)");
}
@Component
@Order(2)
static class CliTwo implements CommandLineRunner {
@Override
public void run(String... args) {
Trace.add("CommandLineRunner @Order(2) on the class");
}
}
@Component
@Order(1)
static class ArOne implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
Trace.add("ApplicationRunner @Order(1) on the class");
}
}
@Component
static class ArThree implements ApplicationRunner, Ordered {
@Override
public int getOrder() {
return 3;
}
@Override
public void run(ApplicationArguments args) {
Trace.add("ApplicationRunner implements Ordered, getOrder() = 3");
}
}
/** Implements both interfaces. Boot calls both methods, one after the other, at this one position. */
@Component
@Order(4)
static class Both implements CommandLineRunner, ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
Trace.add("Both @Order(4) -> ApplicationRunner.run");
}
@Override
public void run(String... args) {
Trace.add("Both @Order(4) -> CommandLineRunner.run");
}
}
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
static class First implements CommandLineRunner {
@Override
public void run(String... args) {
Trace.add("CommandLineRunner @Order(HIGHEST_PRECEDENCE)");
}
}
}
@@ -0,0 +1,16 @@
package com.ankurm.runners.slices;
import com.ankurm.runners.support.Trace;
import java.util.Arrays;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
/** A runner found by component scanning, which is what a test slice filters. */
@Component
class ComponentRunner implements CommandLineRunner {
@Override
public void run(String... args) {
Trace.add("CommandLineRunner from a top-level @Component ran, args = %s", Arrays.toString(args));
}
}
@@ -0,0 +1,14 @@
package com.ankurm.runners.slices;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/** The one controller the {@code @WebMvcTest} slice is asked to load. */
@RestController
public class Hello {
@GetMapping("/hello")
String hello() {
return "hello";
}
}
@@ -0,0 +1,16 @@
package com.ankurm.runners.slices;
import com.ankurm.runners.support.Trace;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/** An {@code ApplicationReadyEvent} listener found by component scanning. */
@Component
class ReadyListener {
@EventListener
void ready(ApplicationReadyEvent e) {
Trace.add("ApplicationReadyEvent listener (a top-level @Component) ran");
}
}
@@ -0,0 +1,39 @@
package com.ankurm.runners.slices;
import com.ankurm.runners.support.Trace;
import java.util.Arrays;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
/**
* Scenario 11: what do tests do with runners? Four startup hooks declared four ways: a runner that is a top-level
* {@code @Component} ({@link ComponentRunner}), an {@code ApplicationReadyEvent} listener that is a top-level
* {@code @Component} ({@link ReadyListener}), a runner that is a {@code @Bean} method on this class, and a runner that is
* a {@code @Component} nested inside this class.
*/
@SpringBootApplication
public class SlicesApp {
public static void main(String[] args) {
SpringApplication.run(SlicesApp.class, args);
}
@Bean
ApplicationRunner runnerFromBeanMethod() {
return args -> Trace.add("ApplicationRunner from a @Bean method ran, source args = %s",
Arrays.toString(args.getSourceArgs()));
}
/** Nested in a configuration class: registered as a member class, so no component-scan filter can exclude it. */
@Component
static class NestedRunner implements CommandLineRunner {
@Override
public void run(String... args) {
Trace.add("CommandLineRunner nested in SlicesApp ran, args = %s", Arrays.toString(args));
}
}
}
@@ -0,0 +1,43 @@
package com.ankurm.runners.support;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* A tiny in-memory log the scenarios write to and the tests print, so the order of startup steps is data and
* not a guess. When the system property {@code trace.echo} is set (the scenarios that start a second JVM do
* that), every line is also printed to stdout with a {@code TRACE } prefix so the parent test can collect it.
*/
public final class Trace {
private static final List<String> LINES = new CopyOnWriteArrayList<>();
private static final boolean ECHO = System.getProperty("trace.echo") != null;
private Trace() {
}
public static void add(String format, Object... args) {
String line = args.length == 0 ? format : String.format(format, args);
LINES.add(line);
if (ECHO) {
System.out.println("TRACE " + line);
}
}
/** Returns everything recorded so far and clears the log. */
public static List<String> drain() {
var copy = new ArrayList<>(LINES);
LINES.clear();
return copy;
}
/** Which kind of thread is running this code, in words that stay the same from run to run. */
public static String where() {
Thread t = Thread.currentThread();
if (t.isVirtual()) {
return "a virtual thread";
}
return t.getName().equals("main") ? "main" : "platform thread " + t.getName();
}
}
@@ -0,0 +1,40 @@
package com.ankurm.runners.ties;
import com.ankurm.runners.support.Trace;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.CommandLineRunner;
/**
* Scenario 3: four runners and no {@code @Order} anywhere. The test starts this in several fresh JVMs and
* records the order each one chose.
*/
@SpringBootApplication
public class TiesApp {
public static void main(String[] args) {
SpringApplication.run(TiesApp.class, args);
}
@Bean
CommandLineRunner cliA() {
return args -> Trace.add("cliA");
}
@Bean
ApplicationRunner arB() {
return args -> Trace.add("arB");
}
@Bean
CommandLineRunner cliC() {
return args -> Trace.add("cliC");
}
@Bean
ApplicationRunner arD() {
return args -> Trace.add("arD");
}
}
@@ -0,0 +1,114 @@
package com.ankurm.runners;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.runners.blocking.BlockingApp;
import com.ankurm.runners.blocking.Gate;
import com.ankurm.runners.support.Trace;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.springframework.boot.WebApplicationType;
/** Sections 7 and 8: a slow runner delays readiness; a runner that hands its work to another thread does not. */
class BlockingTests {
private static final HttpClient HTTP = HttpClient.newHttpClient();
private static int freePort() throws IOException {
try (var s = new ServerSocket(0)) {
return s.getLocalPort();
}
}
private static String get(int port, String path) throws Exception {
var r = HTTP.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)).build(),
HttpResponse.BodyHandlers.ofString());
return String.format("%-32s -> %d %s", "GET " + path, r.statusCode(), r.body());
}
private static Thread startInBackground(AtomicReference<BootRun.Result> result, int port, String profile) {
var thread = new Thread(() -> result.set(BootRun.run(BlockingApp.class, WebApplicationType.SERVLET,
"server.port=" + port,
"spring.profiles.active=" + profile,
"management.endpoint.health.probes.enabled=true",
"management.endpoints.web.exposure.include=health")), "app-main");
thread.start();
return thread;
}
@Test
void aBlockingRunnerKeepsReadinessDownAndSpringApplicationRunOpen() throws Exception {
try (var t = new Transcript("07-blocking-runner-readiness.txt",
"A runner that blocks: the port is open, liveness is UP, readiness is OUT_OF_SERVICE")) {
Trace.drain();
Gate.reset();
int port = freePort();
var result = new AtomicReference<BootRun.Result>();
var app = startInBackground(result, port, "blocking");
assertThat(Gate.RUNNER_STARTED.await(60, TimeUnit.SECONDS)).isTrue();
t.section("while the runner is blocked");
t.line("has SpringApplication.run() returned? %s", result.get() != null);
String ping = get(port, "/ping");
String live = get(port, "/actuator/health/liveness");
String ready = get(port, "/actuator/health/readiness");
t.line(ping);
t.line(live);
t.line(ready);
assertThat(ping).contains("200 pong");
assertThat(live).contains("200");
assertThat(ready).contains("503");
Gate.RELEASE.countDown();
app.join(30_000);
t.section("after the runner returned");
t.line("has SpringApplication.run() returned? %s", result.get() != null);
String readyAfter = get(port, "/actuator/health/readiness");
t.line(readyAfter);
assertThat(readyAfter).contains("200");
t.section("trace");
Trace.drain().forEach(t::line);
result.get().close();
}
}
@Test
void aRunnerThatHandsOffItsWorkLetsTheApplicationBecomeReadyAtOnce() throws Exception {
try (var t = new Transcript("08-async-runner.txt",
"A runner that starts a virtual thread and returns: readiness does not wait, and failures do not fail startup")) {
Trace.drain();
Gate.reset();
int port = freePort();
var result = new AtomicReference<BootRun.Result>();
var app = startInBackground(result, port, "async");
app.join(60_000);
assertThat(result.get()).isNotNull();
assertThat(result.get().started()).isTrue();
t.section("the background work is still waiting");
t.line("has SpringApplication.run() returned? %s", true);
String ready = get(port, "/actuator/health/readiness");
t.line(ready);
assertThat(ready).contains("200");
Gate.RELEASE.countDown();
assertThat(Gate.BACKGROUND_DONE.await(30, TimeUnit.SECONDS)).isTrue();
t.section("after the background work threw");
String readyAfter = get(port, "/actuator/health/readiness");
t.line(readyAfter);
t.line(get(port, "/ping"));
assertThat(readyAfter).contains("200");
t.section("trace");
Trace.drain().forEach(t::line);
result.get().close();
}
}
}
@@ -0,0 +1,56 @@
package com.ankurm.runners;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
/** Starts a Spring Boot application in this JVM and returns either the context or the failure. */
public final class BootRun {
private BootRun() {
}
public static Result runWithArgs(Class<?> source, WebApplicationType type, String[] args, String... properties) {
var props = new ArrayList<String>();
props.add("spring.main.banner-mode=off");
props.add("logging.level.root=OFF");
props.addAll(List.of(properties));
try {
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(source)
.web(type)
.properties(props.toArray(String[]::new))
.run(args);
return new Result(ctx, null);
} catch (RuntimeException e) {
return new Result(null, e);
}
}
public static Result run(Class<?> source, WebApplicationType type, String... properties) {
return runWithArgs(source, type, new String[0], properties);
}
/** Names the exception classes from the outside in, with the message of each one. */
public static List<String> chain(Throwable t) {
var lines = new ArrayList<String>();
for (Throwable c = t; c != null; c = (c.getCause() == c) ? null : c.getCause()) {
lines.add(c.getClass().getName() + ": " + c.getMessage());
}
return lines;
}
public record Result(ConfigurableApplicationContext context, RuntimeException failure) {
public boolean started() {
return failure == null;
}
public void close() {
if (context != null) {
context.close();
}
}
}
}
@@ -0,0 +1,86 @@
package com.ankurm.runners;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.runners.failure.FailureApp;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.List;
import org.junit.jupiter.api.Test;
/** Sections 5 and 6: what happens when a runner throws, and when an ApplicationReadyEvent listener throws. */
class FailureTests {
private static int freePort() throws IOException {
try (var s = new ServerSocket(0)) {
return s.getLocalPort();
}
}
private static boolean portAnswers(int port) {
try (var s = new Socket()) {
s.connect(new InetSocketAddress("localhost", port), 500);
return true;
} catch (IOException e) {
return false;
}
}
@Test
void aRunnerThatThrowsStopsTheApplication() {
try (var t = new Transcript("05-runner-failure.txt",
"A runner throws: exit code, log, events, and the runners that never ran")) {
t.section("three ordered runners, the second throws (non-web, in a separate JVM)");
var r = Forked.run(FailureApp.class,
List.of("--spring.profiles.active=runner-fails", "--spring.main.web-application-type=none"));
r.trace().forEach(l -> t.line("TRACE %s", l));
r.errorLog().forEach(t::line);
t.line("process exit code: %d", r.exitCode());
assertThat(r.exitCode()).isEqualTo(1);
assertThat(r.trace()).contains("runner 1 ran", "runner 2 is about to throw");
assertThat(r.trace()).doesNotContain("runner 3 ran");
assertThat(r.trace().stream().filter(l -> l.startsWith("ApplicationReadyEvent"))).isEmpty();
t.section("a runner exception that implements ExitCodeGenerator");
var e = Forked.run(FailureApp.class,
List.of("--spring.profiles.active=exit-code", "--spring.main.web-application-type=none"));
e.errorLog().forEach(t::line);
t.line("process exit code: %d", e.exitCode());
assertThat(e.exitCode()).isEqualTo(42);
t.section("a runner that throws a checked exception (declared with throws Exception)");
var c = Forked.run(FailureApp.class,
List.of("--spring.profiles.active=checked", "--spring.main.web-application-type=none"));
c.errorLog().forEach(t::line);
t.line("process exit code: %d", c.exitCode());
assertThat(c.exitCode()).isEqualTo(1);
}
}
@Test
void aReadyListenerThatThrowsAlsoStopsTheApplication() throws Exception {
try (var t = new Transcript("06-ready-listener-failure.txt",
"An ApplicationReadyEvent listener throws: the app was 'ready' and is now gone")) {
t.section("non-web application");
var r = Forked.run(FailureApp.class,
List.of("--spring.profiles.active=ready-fails", "--spring.main.web-application-type=none"));
r.trace().forEach(l -> t.line("TRACE %s", l));
r.errorLog().forEach(t::line);
t.line("process exit code: %d", r.exitCode());
assertThat(r.exitCode()).isEqualTo(1);
t.section("web application (Tomcat is up when the listener runs)");
int port = freePort();
var w = Forked.run(FailureApp.class,
List.of("--spring.profiles.active=ready-fails", "--server.port=" + port));
w.trace().forEach(l -> t.line("TRACE %s", l));
w.errorLog().forEach(t::line);
t.line("process exit code: %d", w.exitCode());
t.line("does the port answer after the process ended? %s", portAnswers(port));
assertThat(w.exitCode()).isEqualTo(1);
assertThat(portAnswers(port)).isFalse();
}
}
}
@@ -0,0 +1,78 @@
package com.ankurm.runners;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* Runs an application's {@code main} in a second JVM with the test classpath, so the test can see what a real
* {@code java -cp ... Main} does: the exit code and everything written to the console.
*/
public final class Forked {
private Forked() {
}
public record Result(int exitCode, List<String> output) {
/** The lines the scenario wrote with {@code Trace.add}, without the {@code TRACE } prefix. */
public List<String> trace() {
return output.stream().filter(l -> l.startsWith("TRACE ")).map(l -> l.substring(6)).toList();
}
/**
* The log lines worth quoting: the ERROR line and the exception headers ("java.lang.X: message" and
* "Caused by: ..."), without stack frames. The pattern is fixed in {@link #LOG_PATTERN}, so there is no
* timestamp or pid in them.
*/
public List<String> errorLog() {
return output.stream()
.filter(l -> l.startsWith("ERROR ") || l.matches("(Caused by: )?[\\w.$]+(Exception|Error)(: .*)?"))
.toList();
}
}
/** A log line without timestamp, pid or thread id, so a transcript does not change from run to run. */
public static final String LOG_PATTERN = "%-5level %logger{24} : %msg%n%wEx";
public static Result run(Class<?> main, List<String> args) {
return run(main, List.of(), args);
}
/** {@code jvmOptions} go before the main class (for example {@code -Dspring.main.web-application-type=none}). */
public static Result run(Class<?> main, List<String> jvmOptions, List<String> args) {
String java = Path.of(System.getProperty("java.home"), "bin", "java").toString();
var cmd = new ArrayList<String>();
cmd.add(java);
cmd.add("-Dtrace.echo=true");
cmd.add("-Dspring.main.banner-mode=off");
cmd.add("-Dlogging.pattern.console=" + LOG_PATTERN);
cmd.addAll(jvmOptions);
cmd.add("-cp");
cmd.add(System.getProperty("java.class.path"));
cmd.add(main.getName());
cmd.addAll(args);
try {
Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
var lines = new ArrayList<String>();
try (var r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
String l;
while ((l = r.readLine()) != null) {
lines.add(l);
}
}
if (!p.waitFor(60, TimeUnit.SECONDS)) {
p.destroyForcibly();
throw new IllegalStateException("forked JVM did not exit in 60 s: " + lines);
}
return new Result(p.exitValue(), lines);
} catch (IOException | InterruptedException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,25 @@
package com.ankurm.runners;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.runners.hello.HelloApp;
import java.util.List;
import org.junit.jupiter.api.Test;
/** Section 1: the smallest working example, run the way a user would run it: a real JVM and a real command line. */
class HelloTests {
@Test
void allThreeHooksPrintOneLineEach() {
try (var t = new Transcript("00-hello.txt", "HelloApp started with: --greeting=hi --verbose notes.txt")) {
var r = Forked.run(HelloApp.class,
List.of("-Dspring.main.web-application-type=none"), List.of("--greeting=hi", "--verbose", "notes.txt"));
var lines = r.output().stream().filter(l -> l.startsWith("hello:")).toList();
lines.forEach(t::line);
t.line("process exit code: %d", r.exitCode());
assertThat(r.exitCode()).isZero();
assertThat(lines).hasSize(3);
assertThat(lines.get(2)).isEqualTo("hello: ApplicationReadyEvent listener");
}
}
}
@@ -0,0 +1,97 @@
package com.ankurm.runners;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.runners.lifecycle.LifecycleApp;
import com.ankurm.runners.lifecycle.Svc;
import com.ankurm.runners.lifeforgetful.ForgetfulApp;
import com.ankurm.runners.lifestartfails.StartFailsApp;
import com.ankurm.runners.support.Trace;
import java.util.Comparator;
import org.junit.jupiter.api.Test;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.SpringBootVersion;
import org.springframework.context.SmartLifecycle;
import org.springframework.core.SpringVersion;
import org.springframework.test.util.ReflectionTestUtils;
/** Sections 9 and 10: SmartLifecycle phases, start, stop, and the two ways it goes wrong. */
class LifecycleTests {
@Test
void phasesDecideTheOrderAndStopRunsBackwards() {
try (var t = new Transcript("09-smartlifecycle-phases.txt",
"SmartLifecycle: start in ascending phase, stop in descending phase, runners after all of it")) {
Trace.drain();
var run = BootRun.run(LifecycleApp.class, WebApplicationType.NONE);
t.section("startup");
Trace.drain().forEach(t::line);
var manual = run.context().getBean("manual", Svc.class);
t.line("manual.isAutoStartup()=%s isRunning()=%s (never started by the context)", manual.isAutoStartup(), manual.isRunning());
manual.start();
t.section("context.close()");
run.close();
var stop = Trace.drain();
stop.forEach(t::line);
assertThat(stop.stream().filter(l -> l.startsWith("stop")).map(l -> l.split("\\s+")[1]).distinct().toList())
.containsExactly("slow", "high", "manual", "mid", "low");
assertThat(stop).contains("stop slow finished, calling the callback");
}
}
@Test
void theWebServerIsJustTwoMoreLifecycleBeans() {
try (var t = new Transcript("10-lifecycle-beans-in-a-web-app.txt",
"Every SmartLifecycle bean in a servlet web application, by phase (the ones this project adds are marked)")) {
var run = BootRun.run(LifecycleApp.class, WebApplicationType.SERVLET, "server.port=0");
var ctx = run.context();
ctx.getBeansOfType(SmartLifecycle.class).entrySet().stream()
.sorted(Comparator.comparingInt(e -> e.getValue().getPhase()))
.forEach(e -> t.line("phase %11d %-30s %s", e.getValue().getPhase(), e.getKey(),
e.getValue() instanceof Svc ? "(this project)" : e.getValue().getClass().getSimpleName()));
t.line("SmartLifecycle.DEFAULT_PHASE = %d", SmartLifecycle.DEFAULT_PHASE);
t.line("Integer.MAX_VALUE = %d", Integer.MAX_VALUE);
t.line("timeout per shutdown phase = %s ms (spring.lifecycle.timeout-per-shutdown-phase, set by Boot)",
ReflectionTestUtils.getField(ctx.getBean("lifecycleProcessor"), "timeoutPerShutdownPhase"));
t.line("Spring Boot %s, Spring Framework %s", SpringBootVersion.getVersion(), SpringVersion.getVersion());
assertThat(ctx.getBeansOfType(SmartLifecycle.class)).containsKeys("webServerStartStop", "webServerGracefulShutdown");
run.close();
Trace.drain();
}
}
@Test
void aSmartLifecycleThatThrowsOnStartFailsTheApplicationBeforeAnyRunner() {
try (var t = new Transcript("11-smartlifecycle-start-fails.txt",
"SmartLifecycle.start() throws: no ApplicationStartedEvent, no runner")) {
Trace.drain();
var run = BootRun.run(StartFailsApp.class, WebApplicationType.NONE);
var lines = Trace.drain();
lines.forEach(t::line);
t.blank();
t.line("application started? %s", run.started());
BootRun.chain(run.failure()).forEach(l -> t.line(" %s", l));
assertThat(run.started()).isFalse();
assertThat(lines).doesNotContain("CommandLineRunner runs", "ApplicationStartedEvent");
}
}
@Test
void aSmartLifecycleThatNeverReportsRunningIsNeverStopped() {
try (var t = new Transcript("12-smartlifecycle-forgot-isrunning.txt",
"isRunning() always false: start() is called, stop() never is")) {
Trace.drain();
var run = BootRun.run(ForgetfulApp.class, WebApplicationType.NONE);
t.line("--- startup ---");
Trace.drain().forEach(t::line);
run.close();
t.line("--- after context.close() ---");
var afterClose = Trace.drain();
afterClose.forEach(t::line);
if (afterClose.isEmpty()) {
t.line("(nothing)");
}
assertThat(afterClose).isEmpty();
}
}
}
@@ -0,0 +1,46 @@
package com.ankurm.runners;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.runners.order.OrderApp;
import com.ankurm.runners.support.Trace;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.boot.WebApplicationType;
/** Section 2: where each startup hook runs, printed from a real run. */
class OrderTests {
@Test
void theHooksRunInOneFixedSequence() {
try (var t = new Transcript("01-startup-hooks-in-order.txt",
"Every startup hook in the order Spring Boot 4.1.2 really calls it")) {
t.section("non-web application");
Trace.drain();
var plain = BootRun.run(OrderApp.class, WebApplicationType.NONE);
List<String> noWeb = Trace.drain();
noWeb.forEach(t::line);
plain.close();
t.section("servlet web application (Tomcat on a random port)");
var web = BootRun.run(OrderApp.class, WebApplicationType.SERVLET, "server.port=0");
List<String> withWeb = Trace.drain();
withWeb.forEach(t::line);
web.close();
assertThat(noWeb).containsExactly(
"@PostConstruct",
"InitializingBean.afterPropertiesSet",
"SmartInitializingSingleton.afterSingletonsInstantiated",
"SmartLifecycle.start (default phase)",
"ContextRefreshedEvent",
"ApplicationStartedEvent",
"AvailabilityChangeEvent -> CORRECT",
"CommandLineRunner (readiness seen from inside: REFUSING_TRAFFIC, liveness: CORRECT)",
"ApplicationRunner (readiness seen from inside: REFUSING_TRAFFIC)",
"ApplicationReadyEvent (readiness seen from inside: REFUSING_TRAFFIC)",
"AvailabilityChangeEvent -> ACCEPTING_TRAFFIC");
assertThat(withWeb).contains("WebServerInitializedEvent (port is open)");
}
}
}
@@ -0,0 +1,79 @@
package com.ankurm.runners;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.runners.args.ArgsApp;
import com.ankurm.runners.ordering.OrderingApp;
import com.ankurm.runners.support.Trace;
import com.ankurm.runners.ties.TiesApp;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.WebApplicationType;
/** Sections 3 and 4: ordering between runners, and the arguments each flavour receives. */
class RunnerTests {
@Test
void orderAnnotationsSortBothFlavoursIntoOneList() {
try (var t = new Transcript("02-order-annotation-and-ordered.txt",
"CommandLineRunner and ApplicationRunner beans sorted together by @Order / Ordered")) {
Trace.drain();
var run = BootRun.run(OrderingApp.class, WebApplicationType.NONE);
var lines = Trace.drain();
run.close();
t.line("(declaration order in OrderingApp.java is NOT the order below)");
t.blank();
lines.forEach(t::line);
assertThat(lines).containsExactly(
"CommandLineRunner @Order(HIGHEST_PRECEDENCE)",
"ApplicationRunner @Order(1) on the class",
"CommandLineRunner @Order(2) on the class",
"ApplicationRunner implements Ordered, getOrder() = 3",
"Both @Order(4) -> ApplicationRunner.run",
"Both @Order(4) -> CommandLineRunner.run",
"CommandLineRunner @Order(5) on a @Bean method",
"ApplicationRunner no order at all (lowest precedence)");
}
}
@Test
void runnersWithTheSameOrderHaveNoPromisedSequence() {
// Every run is a fresh JVM, because the sequence comes from identity hash codes, which change per JVM.
try (var t = new Transcript("03-ties-have-no-promised-order.txt",
"Four runners without @Order: the order chosen by each of 8 fresh JVMs")) {
Map<String, Integer> seen = new LinkedHashMap<>();
for (int i = 1; i <= 8; i++) {
var result = Forked.run(TiesApp.class, List.of("--spring.main.web-application-type=none"));
assertThat(result.exitCode()).isZero();
String order = String.join(" ", result.trace());
t.line("JVM %d: %s", i, order);
seen.merge(order, 1, Integer::sum);
}
t.blank();
t.line("distinct sequences seen: %d", seen.size());
seen.forEach((order, n) -> t.line(" %-22s x%d", order, n));
// The contract is only "all four ran". How many distinct sequences appear is exactly what is not promised.
seen.keySet().forEach(order -> assertThat(order.split(" ")).containsExactlyInAnyOrder("cliA", "arB", "cliC", "arD"));
}
}
@Test
void theTwoFlavoursSeeTheSameCommandLineDifferently() {
try (var t = new Transcript("04-arguments.txt", "The same command line, seen by CommandLineRunner and ApplicationRunner")) {
String[] args = {"--name=Ankur", "--verbose", "--name=Second", "data.csv", "-x", "--", "--after-the-dashes"};
t.line("args passed to SpringApplication.run: %s", java.util.Arrays.toString(args));
t.blank();
Trace.drain();
var run = BootRun.runWithArgs(ArgsApp.class, WebApplicationType.NONE, args);
var lines = Trace.drain();
var name = run.context().getEnvironment().getProperty("name");
run.close();
lines.forEach(t::line);
t.blank();
t.line("Environment property 'name' (both values, joined by a comma): %s", name);
assertThat(lines).hasSize(8);
}
}
}
@@ -0,0 +1,30 @@
package com.ankurm.runners;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.runners.slices.SlicesApp;
import com.ankurm.runners.support.Trace;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
/** Section 11: {@code @SpringBootTest} starts the application through SpringApplication, so every startup hook runs. */
@SpringBootTest(classes = SlicesApp.class, args = "--from-the-test=1")
class SpringBootTestHooksTests {
@BeforeAll
static void clear() {
Trace.drain();
}
@Test
void springBootTestRunsRunnersAndTheReadyEvent() {
List<String> lines = Trace.drain();
try (var t = new Transcript("13-spring-boot-test-runs-the-hooks.txt",
"@SpringBootTest(classes = SlicesApp.class, args = \"--from-the-test=1\"): what ran before the first test method")) {
lines.forEach(t::line);
}
assertThat(lines).hasSize(4);
}
}
@@ -0,0 +1,54 @@
package com.ankurm.runners;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code output/} and echoes it to the console.
* Every console block quoted in the article comes out of one of these files verbatim.
*/
public final class Transcript implements AutoCloseable {
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = Path.of("output", fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
out.println();
return this;
}
public Transcript section(String heading) {
out.println();
out.println("--- " + heading + " ---");
return this;
}
@Override
public void close() {
out.flush();
// Absolute paths of whoever ran the build are environment noise, not a finding.
String text = buffer.toString().replace(System.getProperty("user.dir"), "<startup-runners>");
try {
Files.createDirectories(path.getParent());
Files.writeString(path, text);
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(text);
}
}
@@ -0,0 +1,36 @@
package com.ankurm.runners;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.runners.slices.Hello;
import com.ankurm.runners.slices.SlicesApp;
import com.ankurm.runners.support.Trace;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.ContextConfiguration;
/** Section 11: {@code @WebMvcTest} keeps the {@code @Bean} method (it is on the configuration class) and drops the rest. */
@WebMvcTest(controllers = Hello.class)
@ContextConfiguration(classes = SlicesApp.class)
class WebMvcSliceTests {
@BeforeAll
static void clear() {
Trace.drain();
}
@Test
void aWebMvcSliceDropsScannedComponentsButKeepsBeanMethodsAndNestedClasses() {
List<String> lines = Trace.drain();
try (var t = new Transcript("14-webmvctest-slice-hooks.txt",
"@WebMvcTest(controllers = Hello.class): what ran before the first test method")) {
lines.forEach(t::line);
if (lines.isEmpty()) {
t.line("(nothing)");
}
}
assertThat(lines).hasSize(2);
}
}