Spring Boot startup time: bean-by-bean diagnosis, and one directory per post

Adds spring-boot-startup-time/, the companion project for BLOG-618: a runnable
Spring Boot 4.1.1 application on JDK 25 that installs BufferingApplicationStartup
and FlightRecorderApplicationStartup behind a system property, and a /diag/startup
endpoint that computes step self time -- the number /actuator/startup does not give
you and the one that names the actual culprits.

Captured under docs/output/: the step tree sorted both ways, the same startup as JFR
events, a +5000-class experiment putting 0.11 ms per scanned class on the classpath
scan tax, the silent truncation a 2048-step buffer performs, and JDK 25 AOT cache
timings (6.93 s to 4.82 s). Post body and metadata live in post/.

Moves the existing Actuator project into actuator-in-production/ so the repository
holds one directory per article; the root README is now an index.
This commit is contained in:
2026-09-05 00:17:37 +05:30
parent 4b6cefa60a
commit 958b401f0f
112 changed files with 2744 additions and 154 deletions
@@ -0,0 +1,97 @@
package com.ankurm.startup;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup;
import org.springframework.boot.context.metrics.buffering.StartupTimeline;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.metrics.ApplicationStartup;
/**
* Pins the behaviour the post relies on, rather than the happy path.
* See docs/07-failure-modes.md.
*/
class StartupTimelineContractTests {
private ConfigurableApplicationContext boot(int capacity) {
return new SpringApplicationBuilder(StartupDiagnosisApplication.class)
.web(WebApplicationType.NONE)
.applicationStartup(new BufferingApplicationStartup(capacity))
.properties("spring.main.banner-mode=off")
.run();
}
@Test
void theStartupInstanceIsRegisteredAsASingletonSoNoBeanMethodIsNeeded() {
try (ConfigurableApplicationContext ctx = boot(16384)) {
ApplicationStartup fromContext = ctx.getApplicationStartup();
ApplicationStartup fromBeanFactory = ctx.getBean(ApplicationStartup.class);
assertThat(fromBeanFactory).isSameAs(fromContext);
assertThat(ctx.getBeanNamesForType(ApplicationStartup.class))
.containsExactly("applicationStartup");
}
}
@Test
void drainEmptiesTheBufferAndGetDoesNot() {
try (ConfigurableApplicationContext ctx = boot(16384)) {
BufferingApplicationStartup startup =
(BufferingApplicationStartup) ctx.getApplicationStartup();
assertThat(startup.getBufferedTimeline().getEvents()).isNotEmpty();
// A second peek still sees everything: GET /actuator/startup is safe to repeat.
assertThat(startup.getBufferedTimeline().getEvents()).isNotEmpty();
StartupTimeline drained = startup.drainBufferedTimeline();
assertThat(drained.getEvents()).isNotEmpty();
// POST /actuator/startup is not. Everything after the first call is empty.
assertThat(startup.drainBufferedTimeline().getEvents()).isEmpty();
assertThat(startup.getBufferedTimeline().getEvents()).isEmpty();
}
}
@Test
void aFullBufferTruncatesSilentlyAndLosesTheOuterSteps() {
try (ConfigurableApplicationContext ctx = boot(64)) {
BufferingApplicationStartup startup =
(BufferingApplicationStartup) ctx.getApplicationStartup();
var events = startup.getBufferedTimeline().getEvents();
assertThat(events).hasSize(64);
// The steps that bracket the whole refresh end last, so they are the ones lost.
assertThat(events)
.extracting(e -> e.getStartupStep().getName())
.doesNotContain("spring.context.refresh");
}
}
@Test
void totalTimeDoubleCountsAndSelfTimeDoesNot() {
try (ConfigurableApplicationContext ctx = boot(16384)) {
BufferingApplicationStartup startup =
(BufferingApplicationStartup) ctx.getApplicationStartup();
var events = startup.getBufferedTimeline().getEvents();
Map<Long, Long> childNanos = new java.util.HashMap<>();
for (StartupTimeline.TimelineEvent e : events) {
Long parent = e.getStartupStep().getParentId();
if (parent != null) {
childNanos.merge(parent, e.getDuration().toNanos(), Long::sum);
}
}
long refreshTotal = events.stream()
.filter(e -> e.getStartupStep().getName().equals("spring.context.refresh"))
.mapToLong(e -> e.getDuration().toNanos()).max().orElseThrow();
long refreshId = events.stream()
.filter(e -> e.getStartupStep().getName().equals("spring.context.refresh"))
.mapToLong(e -> e.getStartupStep().getId()).findFirst().orElseThrow();
// The outermost refresh step contains almost all of its own duration in children.
assertThat(childNanos.get(refreshId)).isGreaterThan(refreshTotal / 2);
}
}
}