Add cycles to core-beans: the circular-dependency failure report, what allow-circular-references repairs and hides, and four fixes on Boot 4.1

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1
This commit is contained in:
Claude
2026-09-24 06:29:14 +00:00
parent 51f3ecd5da
commit d6a2e1a5f2
61 changed files with 1545 additions and 8 deletions
@@ -0,0 +1,11 @@
package com.ankurm.corebeans.cycles.allowed;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan(basePackageClasses = AllowedConfig.class,
excludeFilters = @org.springframework.context.annotation.ComponentScan.Filter(
type = org.springframework.context.annotation.FilterType.REGEX, pattern = ".*(Async|Cached).*"))
public class AllowedConfig {
}
@@ -0,0 +1,11 @@
package com.ankurm.corebeans.cycles.allowed;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableAsync;
@Configuration
@EnableAsync
@Import({AsyncFirst.class, AsyncSecond.class})
public class AsyncConfig {
}
@@ -0,0 +1,17 @@
package com.ankurm.corebeans.cycles.allowed;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/** A field cycle, with one bean carrying @Async. */
@Service
public class AsyncFirst {
@Autowired
AsyncSecond second;
@Async
public void work() {
}
}
@@ -0,0 +1,26 @@
package com.ankurm.corebeans.cycles.allowed;
import com.ankurm.corebeans.Trace;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class AsyncSecond {
AsyncFirst first;
public AsyncSecond() {
Trace.log("AsyncSecond constructed, identity " + Integer.toHexString(System.identityHashCode(this)));
}
@Autowired
void setFirst(AsyncFirst first) {
Trace.log("AsyncSecond.setFirst on instance " + Integer.toHexString(System.identityHashCode(this)) + " received a proxy: " + AopUtils.isAopProxy(first));
this.first = first;
}
public AsyncFirst first() {
return first;
}
}
@@ -0,0 +1,19 @@
package com.ankurm.corebeans.cycles.allowed;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration
@EnableCaching
@Import({CachedFirst.class, CachedSecond.class})
public class CachedConfig {
@Bean
CacheManager cacheManager() {
return new ConcurrentMapCacheManager("answers");
}
}
@@ -0,0 +1,25 @@
package com.ankurm.corebeans.cycles.allowed;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
/** The same field cycle as AsyncFirst, but the proxying comes from @Cacheable instead of @Async. */
@Service
public class CachedFirst {
static final AtomicInteger CALLS = new AtomicInteger();
@Autowired
CachedSecond second;
public static int calls() {
return CALLS.get();
}
@Cacheable("answers")
public int answer() {
return CALLS.incrementAndGet();
}
}
@@ -0,0 +1,15 @@
package com.ankurm.corebeans.cycles.allowed;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class CachedSecond {
@Autowired
CachedFirst first;
public CachedFirst first() {
return first;
}
}
@@ -0,0 +1,29 @@
package com.ankurm.corebeans.cycles.allowed;
import com.ankurm.corebeans.Trace;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class FirstService {
@Autowired
SecondService second;
boolean ready;
public FirstService() {
Trace.log("FirstService constructed");
}
@PostConstruct
void init() {
ready = true;
Trace.log("FirstService @PostConstruct done, ready=true");
}
public boolean isReady() {
return ready;
}
}
@@ -0,0 +1,22 @@
package com.ankurm.corebeans.cycles.allowed;
import com.ankurm.corebeans.Trace;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class SecondService {
@Autowired
FirstService first;
public SecondService() {
Trace.log("SecondService constructed");
}
@PostConstruct
void init() {
Trace.log("SecondService @PostConstruct sees first.isReady() = " + first.isReady());
}
}
@@ -0,0 +1,25 @@
package com.ankurm.corebeans.cycles.beanmethods;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** The same cycle written as @Bean methods: each method asks for the other bean as a parameter. */
@Configuration
public class BeanMethodCycle {
public record Clock(Calendar calendar) {
}
public record Calendar(Clock clock) {
}
@Bean
Clock clock(Calendar calendar) {
return new Clock(calendar);
}
@Bean
Calendar calendar(Clock clock) {
return new Calendar(clock);
}
}
@@ -0,0 +1,10 @@
package com.ankurm.corebeans.cycles.broken;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
/** Scans only this package: OrderService -> PaymentService -> NotificationService -> OrderService. */
@Configuration
@ComponentScan
public class BrokenConfig {
}
@@ -0,0 +1,17 @@
package com.ankurm.corebeans.cycles.broken;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private final OrderService orders;
public NotificationService(OrderService orders) {
this.orders = orders;
}
public String confirm(String sku) {
return "e-mail says: " + orders.describe(sku);
}
}
@@ -0,0 +1,21 @@
package com.ankurm.corebeans.cycles.broken;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final PaymentService payments;
public OrderService(PaymentService payments) {
this.payments = payments;
}
public String place(String sku) {
return "order placed: " + payments.charge(sku);
}
public String describe(String sku) {
return "1 x " + sku;
}
}
@@ -0,0 +1,17 @@
package com.ankurm.corebeans.cycles.broken;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
private final NotificationService notifications;
public PaymentService(NotificationService notifications) {
this.notifications = notifications;
}
public String charge(String sku) {
return "charged, " + notifications.confirm(sku);
}
}
@@ -0,0 +1,9 @@
package com.ankurm.corebeans.cycles.events;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan
public class EventsConfig {
}
@@ -0,0 +1,24 @@
package com.ankurm.corebeans.cycles.events;
import com.ankurm.corebeans.Trace;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private final OrderService orders;
public NotificationService(OrderService orders) {
this.orders = orders;
}
@EventListener
void onPaymentCaptured(PaymentCaptured event) {
if (event.sku().equals("boom")) {
throw new IllegalStateException("mail server down");
}
Trace.log("listener on thread " + Thread.currentThread().getName()
+ ": e-mail says: " + orders.describe(event.sku()));
}
}
@@ -0,0 +1,21 @@
package com.ankurm.corebeans.cycles.events;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final PaymentService payments;
public OrderService(PaymentService payments) {
this.payments = payments;
}
public String place(String sku) {
return "order placed: " + payments.charge(sku);
}
public String describe(String sku) {
return "1 x " + sku;
}
}
@@ -0,0 +1,5 @@
package com.ankurm.corebeans.cycles.events;
/** A plain record: since Spring 4.2 an event does not have to extend ApplicationEvent. */
public record PaymentCaptured(String sku) {
}
@@ -0,0 +1,19 @@
package com.ankurm.corebeans.cycles.events;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
private final ApplicationEventPublisher events;
public PaymentService(ApplicationEventPublisher events) {
this.events = events;
}
public String charge(String sku) {
events.publishEvent(new PaymentCaptured(sku));
return "charged";
}
}
@@ -0,0 +1,9 @@
package com.ankurm.corebeans.cycles.lazy;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan
public class LazyConfig {
}
@@ -0,0 +1,23 @@
package com.ankurm.corebeans.cycles.lazy;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private final OrderService orders;
/** @Lazy makes Spring inject a proxy and look the real OrderService up on the first call. */
public NotificationService(@Lazy OrderService orders) {
this.orders = orders;
}
public String confirm(String sku) {
return "e-mail says: " + orders.describe(sku);
}
public String injectedType() {
return orders.getClass().getName();
}
}
@@ -0,0 +1,21 @@
package com.ankurm.corebeans.cycles.lazy;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final PaymentService payments;
public OrderService(PaymentService payments) {
this.payments = payments;
}
public String place(String sku) {
return "order placed: " + payments.charge(sku);
}
public String describe(String sku) {
return "1 x " + sku;
}
}
@@ -0,0 +1,17 @@
package com.ankurm.corebeans.cycles.lazy;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
private final NotificationService notifications;
public PaymentService(NotificationService notifications) {
this.notifications = notifications;
}
public String charge(String sku) {
return "charged, " + notifications.confirm(sku);
}
}
@@ -0,0 +1,19 @@
package com.ankurm.corebeans.cycles.provider;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private final ObjectProvider<OrderService> orders;
/** Nothing is resolved here: the provider is a handle, not the bean. */
public NotificationService(ObjectProvider<OrderService> orders) {
this.orders = orders;
}
public String confirm(String sku) {
return "e-mail says: " + orders.getObject().describe(sku);
}
}
@@ -0,0 +1,21 @@
package com.ankurm.corebeans.cycles.provider;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final PaymentService payments;
public OrderService(PaymentService payments) {
this.payments = payments;
}
public String place(String sku) {
return "order placed: " + payments.charge(sku);
}
public String describe(String sku) {
return "1 x " + sku;
}
}
@@ -0,0 +1,17 @@
package com.ankurm.corebeans.cycles.provider;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
private final NotificationService notifications;
public PaymentService(NotificationService notifications) {
this.notifications = notifications;
}
public String charge(String sku) {
return "charged, " + notifications.confirm(sku);
}
}
@@ -0,0 +1,9 @@
package com.ankurm.corebeans.cycles.provider;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan
public class ProviderConfig {
}
@@ -0,0 +1,17 @@
package com.ankurm.corebeans.cycles.redesign;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private final OrderCatalog catalog;
public NotificationService(OrderCatalog catalog) {
this.catalog = catalog;
}
public String confirm(String sku) {
return "e-mail says: " + catalog.describe(sku);
}
}
@@ -0,0 +1,12 @@
package com.ankurm.corebeans.cycles.redesign;
import org.springframework.stereotype.Service;
/** The one thing NotificationService actually needed from OrderService, pulled out into its own bean. */
@Service
public class OrderCatalog {
public String describe(String sku) {
return "1 x " + sku;
}
}
@@ -0,0 +1,23 @@
package com.ankurm.corebeans.cycles.redesign;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final PaymentService payments;
private final OrderCatalog catalog;
public OrderService(PaymentService payments, OrderCatalog catalog) {
this.payments = payments;
this.catalog = catalog;
}
public String place(String sku) {
return "order placed: " + payments.charge(sku);
}
public String describe(String sku) {
return catalog.describe(sku);
}
}
@@ -0,0 +1,17 @@
package com.ankurm.corebeans.cycles.redesign;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
private final NotificationService notifications;
public PaymentService(NotificationService notifications) {
this.notifications = notifications;
}
public String charge(String sku) {
return "charged, " + notifications.confirm(sku);
}
}
@@ -0,0 +1,9 @@
package com.ankurm.corebeans.cycles.redesign;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan
public class RedesignConfig {
}
@@ -0,0 +1,21 @@
package com.ankurm.corebeans.cycles.support;
import com.ankurm.corebeans.Trace;
import org.springframework.beans.factory.config.InstantiationAwareBeanPostProcessor;
/**
* Logs the moment the container starts creating each demo bean. It is how the creation
* chain (orderService, then paymentService, then notificationService) becomes visible.
*/
public class CreationTracer implements InstantiationAwareBeanPostProcessor {
@Override
public Object postProcessBeforeInstantiation(Class<?> beanClass, String beanName) {
String simple = beanClass.getSimpleName();
if (beanClass.getPackageName().startsWith("com.ankurm.corebeans.cycles")
&& !simple.endsWith("Config") && !simple.endsWith("Tracer")) {
Trace.log("start creating " + beanName);
}
return null;
}
}
@@ -0,0 +1,123 @@
package com.ankurm.corebeans;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.corebeans.cycles.allowed.AllowedConfig;
import com.ankurm.corebeans.cycles.allowed.AsyncConfig;
import com.ankurm.corebeans.cycles.allowed.AsyncFirst;
import com.ankurm.corebeans.cycles.allowed.AsyncSecond;
import com.ankurm.corebeans.cycles.allowed.CachedConfig;
import com.ankurm.corebeans.cycles.allowed.CachedFirst;
import com.ankurm.corebeans.cycles.allowed.CachedSecond;
import com.ankurm.corebeans.cycles.support.CreationTracer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.aop.support.AopUtils;
/** Post 20: what spring.main.allow-circular-references=true really buys, and what it costs. */
@ExtendWith(OutputCaptureExtension.class)
class AllowCircularTest {
private static final String[] ALLOW = {"spring.main.allow-circular-references=true"};
@BeforeEach
void reset() {
Trace.drain();
((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME))
.setLevel(ch.qos.logback.classic.Level.INFO);
}
@Test
void theFlagMakesAFieldCycleStartButOneBeanIsHalfBuilt() {
try (var t = new Transcript("24-allow-circular-half-built-bean.txt",
"A field-injected cycle with spring.main.allow-circular-references=true: what each @PostConstruct sees")) {
t.section("flag off (the Boot default)");
var off = BootRun.run(new String[0], AllowedConfig.class);
t.line("started: %s", off.started());
t.line("root cause: %s", BootRun.root(off.failure()).getMessage());
assertThat(off.started()).isFalse();
Trace.drain();
t.section("flag on");
var on = BootRun.run(ALLOW, AllowedConfig.class, CreationTracer.class);
t.line("started: %s", on.started());
assertThat(on.started()).isTrue();
Trace.drain().forEach(e -> t.line("%s", e));
on.close();
}
}
@Test
void asyncInTheCycleIsRepairedSilentlyAtStartupAndFailsOnAnyOtherPath(CapturedOutput output) {
try (var t = new Transcript("25-allow-circular-async-raw-reference.txt",
"The same field cycle, with @Async on AsyncFirst and the flag on: start-up versus a lazy first getBean")) {
t.section("eager start-up (the default)");
var eager = BootRun.run(new String[] {"spring.main.allow-circular-references=true", "logging.level.root=INFO"},
AsyncConfig.class, CreationTracer.class);
Trace.drain().forEach(e -> t.line("%s", e));
t.line("started: %s", eager.started());
describe(t, eager);
eager.close();
var infoLines = java.util.Arrays.stream(output.getAll().split("\n"))
.filter(l -> l.contains("DefaultListableBeanFactory")).map(l -> l.substring(l.indexOf(" : ") + 3)).toList();
t.line("log line from DefaultListableBeanFactory (INFO):");
infoLines.forEach(l -> t.line(" %s", l));
assertThat(eager.started()).isTrue();
assertThat(infoLines).isNotEmpty();
t.section("spring.main.lazy-initialization=true, then getBean(AsyncFirst.class)");
var lazy = BootRun.run(new String[] {"spring.main.allow-circular-references=true", "spring.main.lazy-initialization=true"},
AsyncConfig.class);
t.line("started: %s", lazy.started());
assertThat(lazy.started()).isTrue();
try {
lazy.context().getBean(AsyncFirst.class);
t.line("getBean(AsyncFirst.class): returned a bean");
} catch (RuntimeException e) {
t.line("getBean(AsyncFirst.class) failed: %s", BootRun.chain(e));
t.line("root cause: %s", BootRun.root(e).getMessage());
assertThat(BootRun.root(e).getMessage()).contains("in its raw version");
} finally {
lazy.close();
}
}
}
private static void describe(Transcript t, BootRun.Result result) {
if (!result.started()) {
return;
}
var first = result.context().getBean(AsyncFirst.class);
var second = result.context().getBean(AsyncSecond.class);
t.line("AsyncFirst bean is a proxy : %s", AopUtils.isAopProxy(first));
t.line("AsyncSecond.first is a proxy : %s", AopUtils.isAopProxy(second.first()));
t.line("AsyncSecond.first == the bean : %s", second.first() == first);
}
@Test
void cacheableInTheCycleWorksBecauseTheProxyCreatorHandsOutAnEarlyProxy() {
try (var t = new Transcript("26-allow-circular-cacheable-early-proxy.txt",
"The same field cycle, with @Cacheable on one of the two beans, and the flag on")) {
var result = BootRun.run(ALLOW, CachedConfig.class);
t.line("started: %s", result.started());
assertThat(result.started()).isTrue();
var ctx = result.context();
var first = ctx.getBean(CachedFirst.class);
var second = ctx.getBean(CachedSecond.class);
t.line("CachedFirst bean is a proxy : %s (%s)", AopUtils.isAopProxy(first), first.getClass().getSimpleName());
t.line("CachedSecond.first is a proxy : %s", AopUtils.isAopProxy(second.first()));
t.line("CachedSecond.first == the bean : %s", second.first() == first);
int a = second.first().answer();
int b = second.first().answer();
t.line("two calls through the injected reference return %d and %d, method body ran %d time(s)",
a, b, CachedFirst.calls());
assertThat(AopUtils.isAopProxy(second.first())).isTrue();
assertThat(second.first()).isSameAs(first);
assertThat(CachedFirst.calls()).isEqualTo(1);
result.close();
}
}
}
@@ -0,0 +1,60 @@
package com.ankurm.corebeans;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
/** Starts a Spring Boot context without a web server and returns either the context or the failure. */
public final class BootRun {
private BootRun() {
}
public static Result run(String[] extraProperties, Class<?>... sources) {
var props = new java.util.ArrayList<String>();
props.add("spring.main.banner-mode=off");
props.add("logging.level.root=OFF");
props.addAll(java.util.List.of(extraProperties));
try {
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(sources)
.web(WebApplicationType.NONE)
.properties(props.toArray(String[]::new))
.run();
return new Result(ctx, null);
} catch (RuntimeException e) {
return new Result(null, e);
}
}
/** Names the exception classes from the outside in: what a stack trace's "Caused by:" lines say, minus the noise. */
public static String chain(Throwable t) {
var names = new java.util.ArrayList<String>();
for (Throwable c = t; c != null && !names.contains(c.getClass().getSimpleName() + c.hashCode()); c = c.getCause()) {
names.add(c.getClass().getSimpleName());
if (c.getCause() == c) {
break;
}
}
return String.join(" -> ", names);
}
public static Throwable root(Throwable t) {
while (t.getCause() != null && t.getCause() != t) {
t = t.getCause();
}
return t;
}
public record Result(ConfigurableApplicationContext context, RuntimeException failure) {
public boolean started() {
return failure == null;
}
public void close() {
if (context != null) {
context.close();
}
}
}
}
@@ -0,0 +1,119 @@
package com.ankurm.corebeans;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.corebeans.cycles.beanmethods.BeanMethodCycle;
import com.ankurm.corebeans.cycles.broken.BrokenConfig;
import com.ankurm.corebeans.cycles.broken.OrderService;
import com.ankurm.corebeans.cycles.support.CreationTracer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
/** Post 20: the start-up failure itself, read line by line, and what the container was doing when it hit it. */
@ExtendWith(OutputCaptureExtension.class)
class CycleFailureTest {
@BeforeEach
void reset() {
Trace.drain();
((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME))
.setLevel(ch.qos.logback.classic.Level.INFO);
}
/** From the row of asterisks to the end of the "Action:" paragraph. */
static String report(String console) {
int start = console.indexOf("***************************\nAPPLICATION FAILED TO START");
assertThat(start).as("Boot printed a failure report").isGreaterThanOrEqualTo(0);
String tail = console.substring(start);
int action = tail.indexOf("Action:");
int end = tail.indexOf("\n\n", action + "Action:\n\n".length());
return (end < 0 ? tail : tail.substring(0, end)).stripTrailing();
}
@Test
void bootPrintsTheCycleAsAPicture(CapturedOutput output) {
try (var t = new Transcript("20-boot-cycle-failure-report.txt",
"Three constructor-injected services in a ring: what Spring Boot 4.1.1 prints when start-up fails")) {
var thrown = new java.util.concurrent.atomic.AtomicReference<Throwable>();
try {
new SpringApplicationBuilder(BrokenConfig.class)
.web(WebApplicationType.NONE)
.properties("logging.level.root=INFO", "spring.main.banner-mode=off")
.run();
} catch (RuntimeException e) {
thrown.set(e);
}
assertThat(thrown.get()).isNotNull();
String report = report(output.getAll());
t.line("%s", report);
assertThat(report).contains("The dependencies of some of the beans in the application context form a cycle:")
.contains("┌─────┐").contains("└─────┘")
.contains("orderService").contains("paymentService").contains("notificationService")
.contains("spring.main.allow-circular-references");
}
}
@Test
void theExceptionUnderneathAndTheCreationOrderBehindIt() {
try (var t = new Transcript("21-cycle-exception-and-creation-order.txt",
"The exception behind the report, and the order in which the container started creating beans")) {
var result = BootRun.run(new String[0], BrokenConfig.class, CreationTracer.class);
assertThat(result.started()).isFalse();
t.section("exception classes, outermost first");
t.line("%s", BootRun.chain(result.failure()));
t.section("root cause");
t.line("%s", BootRun.root(result.failure()).getClass().getName());
t.line("%s", BootRun.root(result.failure()).getMessage());
t.section("creation order (one line per bean the container began to create)");
var events = Trace.drain();
events.forEach(e -> t.line("%s", e));
assertThat(events).containsExactly(
"start creating notificationService", "start creating orderService", "start creating paymentService");
assertThat(BootRun.root(result.failure()).getMessage())
.contains("Requested bean is currently in creation").contains("notificationService");
}
}
@Test
void theSameCycleWrittenAsBeanMethods(CapturedOutput output) {
try (var t = new Transcript("22-bean-method-cycle-failure-report.txt",
"A two-bean cycle written as @Bean methods: the report names the configuration class instead of a class file")) {
try {
new SpringApplicationBuilder(BeanMethodCycle.class)
.web(WebApplicationType.NONE)
.properties("logging.level.root=INFO", "spring.main.banner-mode=off")
.run();
} catch (RuntimeException expected) {
// the report is what we came for
}
String report = report(output.getAll());
t.line("%s", report);
assertThat(report).contains("clock defined in com.ankurm.corebeans.cycles.beanmethods.BeanMethodCycle");
}
}
@Test
void lazyInitialisationMovesTheFailureToTheFirstUse() {
try (var t = new Transcript("23-lazy-initialization-hides-the-cycle.txt",
"spring.main.lazy-initialization=true on the same three-service ring")) {
var result = BootRun.run(new String[] {"spring.main.lazy-initialization=true"}, BrokenConfig.class);
t.line("context started: %s", result.started());
assertThat(result.started()).isTrue();
try {
result.context().getBean(OrderService.class);
t.line("getBean(OrderService.class): returned a bean");
} catch (RuntimeException e) {
t.line("getBean(OrderService.class) failed: %s", BootRun.chain(e));
t.line("root cause: %s", BootRun.root(e).getMessage());
assertThat(BootRun.root(e).getMessage()).contains("currently in creation");
} finally {
result.close();
}
}
}
}
@@ -0,0 +1,265 @@
package com.ankurm.corebeans;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.ankurm.corebeans.cycles.events.EventsConfig;
import com.ankurm.corebeans.cycles.lazy.LazyConfig;
import com.ankurm.corebeans.cycles.provider.ProviderConfig;
import com.ankurm.corebeans.cycles.redesign.RedesignConfig;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
/** Post 20: the four fixes, each starting the same three-service application. */
class CycleFixesTest {
@BeforeEach
void reset() {
Trace.drain();
}
@Test
void fixOneRedesign() {
try (var t = new Transcript("27-fix-redesign.txt", "Fix 1: extract OrderCatalog, the one thing NotificationService needed")) {
var result = BootRun.run(new String[0], RedesignConfig.class);
t.line("started: %s", result.started());
assertThat(result.started()).isTrue();
var orders = result.context().getBean(com.ankurm.corebeans.cycles.redesign.OrderService.class);
t.line("place(\"kettle\") -> %s", orders.place("kettle"));
assertThat(orders.place("kettle")).contains("1 x kettle");
result.close();
}
}
@Test
void fixTwoEvents() {
try (var t = new Transcript("28-fix-events.txt", "Fix 2: PaymentService publishes an event instead of calling NotificationService")) {
var result = BootRun.run(new String[0], EventsConfig.class);
t.line("started: %s", result.started());
assertThat(result.started()).isTrue();
var orders = result.context().getBean(com.ankurm.corebeans.cycles.events.OrderService.class);
t.line("place(\"kettle\") -> %s", orders.place("kettle"));
Trace.drain().forEach(e -> t.line("%s", e));
t.section("what the publisher sees when the listener throws");
try {
orders.place("boom");
t.line("place(\"boom\") returned normally");
} catch (RuntimeException e) {
t.line("place(\"boom\") threw %s: %s", e.getClass().getSimpleName(), e.getMessage());
assertThat(e).hasMessage("mail server down");
}
result.close();
}
}
@Test
void fixThreeLazy() {
try (var t = new Transcript("29-fix-lazy.txt", "Fix 3: @Lazy on NotificationService's OrderService parameter")) {
var result = BootRun.run(new String[0], LazyConfig.class);
t.line("started: %s", result.started());
assertThat(result.started()).isTrue();
var ctx = result.context();
var orders = ctx.getBean(com.ankurm.corebeans.cycles.lazy.OrderService.class);
var notifications = ctx.getBean(com.ankurm.corebeans.cycles.lazy.NotificationService.class);
t.line("NotificationService holds a %s", notifications.injectedType());
t.line("place(\"kettle\") -> %s", orders.place("kettle"));
assertThat(notifications.injectedType()).contains("SpringCGLIB");
result.close();
}
}
@Test
void fixFourObjectProvider() {
try (var t = new Transcript("30-fix-object-provider.txt", "Fix 4: ObjectProvider<OrderService> in NotificationService")) {
var result = BootRun.run(new String[0], ProviderConfig.class);
t.line("started: %s", result.started());
assertThat(result.started()).isTrue();
var orders = result.context().getBean(com.ankurm.corebeans.cycles.provider.OrderService.class);
t.line("place(\"kettle\") -> %s", orders.place("kettle"));
result.close();
t.section("the same provider, but getObject() is called inside the constructor");
var eager = Ctx.tryStart(EagerNotifier.class, EagerOrders.class);
t.line("started: %s", eager.started());
assertThat(eager.started()).isFalse();
t.line("root cause: %s", Ctx.root(eager.failure()).getMessage());
t.section("the provider needs no proxy, so a final class is fine");
var finalOutcome = Ctx.tryStart(FinalProviderConsumer.class, FinalTarget.class);
t.line("started: %s", finalOutcome.started());
assertThat(finalOutcome.started()).isTrue();
t.line("hello() -> %s", finalOutcome.context().getBean(FinalProviderConsumer.class).hello());
finalOutcome.closeQuietly();
}
}
@Component
static class FinalProviderConsumer {
private final ObjectProvider<FinalTarget> target;
FinalProviderConsumer(ObjectProvider<FinalTarget> target) {
this.target = target;
}
String hello() {
return target.getObject().hello();
}
}
// ---- the provider trap: resolving in the constructor rebuilds the cycle -------------------------------
@Component
static class EagerOrders {
EagerOrders(EagerNotifier notifier) {
}
}
@Component
static class EagerNotifier {
EagerNotifier(ObjectProvider<EagerOrders> orders) {
orders.getObject();
}
}
// ---- the two ways @Lazy fails later ---------------------------------------------------------------------
static final class FinalTarget {
String hello() {
return "hello";
}
}
@Component
static class FinalConsumer {
FinalConsumer(@Lazy FinalTarget target) {
}
}
/** A singleton like any other: the context builds it at start-up whether or not anyone injected it lazily. */
@Component
static class Flaky {
Flaky() {
throw new IllegalStateException("cannot reach the payment provider");
}
String hello() {
return "hello";
}
}
/** The same class, but the bean itself is lazy. */
@Component
@Lazy
static class LazyFlaky {
LazyFlaky() {
throw new IllegalStateException("cannot reach the payment provider");
}
String hello() {
return "hello";
}
}
@Component
static class FlakyConsumer {
private final Flaky flaky;
FlakyConsumer(@Lazy Flaky flaky) {
this.flaky = flaky;
}
String call() {
return flaky.hello();
}
}
@Component
static class LazyFlakyConsumer {
private final LazyFlaky flaky;
LazyFlakyConsumer(@Lazy LazyFlaky flaky) {
this.flaky = flaky;
}
String call() {
return flaky.hello();
}
}
@Component
static class LazyUserA {
private final LazyUserB b;
LazyUserA(@Lazy LazyUserB b) {
this.b = b;
}
@jakarta.annotation.PostConstruct
void init() {
b.hello();
}
}
@Component
static class LazyUserB {
LazyUserB(LazyUserA a) {
}
String hello() {
return "hello";
}
}
@Test
void lazyProxyResolvedDuringConstructionRebuildsTheCycle() {
try (var t = new Transcript("35-lazy-used-during-construction.txt",
"@Lazy on the parameter, but the bean calls the dependency from @PostConstruct")) {
var outcome = BootRun.run(new String[0], LazyUserA.class, LazyUserB.class);
t.line("started: %s", outcome.started());
assertThat(outcome.started()).isFalse();
t.line("exception classes: %s", BootRun.chain(outcome.failure()));
t.line("root cause: %s", BootRun.root(outcome.failure()).getMessage());
}
}
@Test
void lazyOnAFinalClass() {
try (var t = new Transcript("31-lazy-on-a-final-class.txt", "@Lazy on a constructor parameter whose type is a final class")) {
var outcome = Ctx.tryStart(FinalConsumer.class, FinalTarget.class);
t.line("started: %s", outcome.started());
if (!outcome.started()) {
t.line("exception classes: %s", BootRun.chain(outcome.failure()));
t.line("root cause: %s: %s", Ctx.root(outcome.failure()).getClass().getName(), Ctx.root(outcome.failure()).getMessage());
} else {
outcome.closeQuietly();
}
assertThat(outcome.started()).isFalse();
}
}
@Test
void lazyDelaysOnlyWhatTheBeanItselfDelays() {
try (var t = new Transcript("32-lazy-injection-point-versus-lazy-bean.txt",
"@Lazy on the injection point, with a target that cannot be built")) {
t.section("@Lazy on the parameter, target is an ordinary singleton");
var eager = Ctx.tryStart(FlakyConsumer.class, Flaky.class);
t.line("started: %s", eager.started());
assertThat(eager.started()).isFalse();
t.line("root cause: %s", Ctx.root(eager.failure()).getMessage());
t.section("@Lazy on the parameter AND on the target bean");
var lazy = Ctx.tryStart(LazyFlakyConsumer.class, LazyFlaky.class);
t.line("started: %s", lazy.started());
assertThat(lazy.started()).isTrue();
var consumer = lazy.context().getBean(LazyFlakyConsumer.class);
assertThatThrownBy(consumer::call).satisfies(e -> {
t.line("first call threw: %s", BootRun.chain(e));
t.line("root cause: %s", Ctx.root(e).getMessage());
});
lazy.closeQuietly();
}
}
}
@@ -0,0 +1,121 @@
package com.ankurm.corebeans;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.stereotype.Component;
/** Post 20: cycles that do not look like A -> B -> A when you read the code. */
class HiddenCyclesTest {
interface Handler {
String handle(String message);
}
/** Collects every Handler. Looks harmless. */
@Component
static class Dispatcher {
private final List<Handler> handlers;
Dispatcher(List<Handler> handlers) {
this.handlers = handlers;
}
String dispatch(String message) {
return handlers.stream().map(h -> h.handle(message)).toList().toString();
}
}
/** Wants to re-dispatch, so it asks for the Dispatcher. That is the whole cycle. */
@Component
static class RedispatchingHandler implements Handler {
private final Dispatcher dispatcher;
RedispatchingHandler(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
@Override
public String handle(String message) {
return "seen " + message;
}
}
/** The repair: hold a handle, not the bean. */
@Component
static class LazyLookupHandler implements Handler {
private final ObjectProvider<LookupDispatcher> dispatcher;
LazyLookupHandler(ObjectProvider<LookupDispatcher> dispatcher) {
this.dispatcher = dispatcher;
}
@Override
public String handle(String message) {
return "seen " + message + " (dispatcher available: " + (dispatcher.getIfAvailable() != null) + ")";
}
}
@Component
static class LookupDispatcher {
private final List<Handler> handlers;
LookupDispatcher(List<Handler> handlers) {
this.handlers = handlers;
}
String dispatch(String message) {
return handlers.stream().map(h -> h.handle(message)).toList().toString();
}
}
@Test
void aCycleThroughAListOfCollaborators() {
try (var t = new Transcript("33-dispatcher-handler-cycle.txt", "Dispatcher(List<Handler>) and a Handler that needs the Dispatcher")) {
t.section("Handler injects the Dispatcher");
var broken = Ctx.tryStart(Dispatcher.class, RedispatchingHandler.class);
t.line("started: %s", broken.started());
assertThat(broken.started()).isFalse();
t.line("exception classes: %s", BootRun.chain(broken.failure()));
t.line("root cause: %s", Ctx.root(broken.failure()).getMessage());
t.section("Handler injects ObjectProvider<Dispatcher>");
var fixed = Ctx.tryStart(LookupDispatcher.class, LazyLookupHandler.class);
t.line("started: %s", fixed.started());
assertThat(fixed.started()).isTrue();
t.line("dispatch(\"ping\") -> %s", fixed.context().getBean(LookupDispatcher.class).dispatch("ping"));
fixed.closeQuietly();
}
}
@Configuration
static class DependsOnCycle {
@Bean
@DependsOn("b")
Object a() {
return new Object();
}
@Bean
@DependsOn("a")
Object b() {
return new Object();
}
}
@Test
void aDependsOnCycle() {
try (var t = new Transcript("34-depends-on-cycle.txt", "@DependsOn(\"b\") on a and @DependsOn(\"a\") on b: no injection involved at all")) {
var outcome = Ctx.tryStart(DependsOnCycle.class);
t.line("started: %s", outcome.started());
assertThat(outcome.started()).isFalse();
t.line("exception classes: %s", BootRun.chain(outcome.failure()));
t.line("root cause: %s", Ctx.root(outcome.failure()).getMessage());
}
}
}
@@ -41,12 +41,14 @@ public final class Transcript implements AutoCloseable {
@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"), "<core-beans>");
try {
Files.createDirectories(path.getParent());
Files.writeString(path, buffer.toString());
Files.writeString(path, text);
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(buffer);
System.out.print(text);
}
}