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.
98 lines
4.4 KiB
Java
98 lines
4.4 KiB
Java
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);
|
|
}
|
|
}
|
|
}
|