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 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); } } }