From d6a2e1a5f2b9debf582edbb3942037ccb55984d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 06:29:14 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1 --- core-beans/README.md | 24 +- .../output/20-boot-cycle-failure-report.txt | 22 ++ .../21-cycle-exception-and-creation-order.txt | 14 + .../22-bean-method-cycle-failure-report.txt | 20 ++ ...23-lazy-initialization-hides-the-cycle.txt | 5 + .../24-allow-circular-half-built-bean.txt | 15 + .../25-allow-circular-async-raw-reference.txt | 21 ++ ...6-allow-circular-cacheable-early-proxy.txt | 7 + core-beans/output/27-fix-redesign.txt | 4 + core-beans/output/28-fix-events.txt | 8 + core-beans/output/29-fix-lazy.txt | 5 + core-beans/output/30-fix-object-provider.txt | 12 + .../output/31-lazy-on-a-final-class.txt | 5 + ...-lazy-injection-point-versus-lazy-bean.txt | 11 + .../output/33-dispatcher-handler-cycle.txt | 11 + core-beans/output/34-depends-on-cycle.txt | 5 + .../35-lazy-used-during-construction.txt | 5 + .../output/36-main-property-defaults.txt | 4 + .../output/37-early-reference-bytecode.txt | 23 ++ core-beans/pom.xml | 2 +- core-beans/scripts/capture-cycle-facts.sh | 49 ++++ core-beans/scripts/run-all.sh | 9 +- .../cycles/allowed/AllowedConfig.java | 11 + .../corebeans/cycles/allowed/AsyncConfig.java | 11 + .../corebeans/cycles/allowed/AsyncFirst.java | 17 ++ .../corebeans/cycles/allowed/AsyncSecond.java | 26 ++ .../cycles/allowed/CachedConfig.java | 19 ++ .../corebeans/cycles/allowed/CachedFirst.java | 25 ++ .../cycles/allowed/CachedSecond.java | 15 + .../cycles/allowed/FirstService.java | 29 ++ .../cycles/allowed/SecondService.java | 22 ++ .../cycles/beanmethods/BeanMethodCycle.java | 25 ++ .../corebeans/cycles/broken/BrokenConfig.java | 10 + .../cycles/broken/NotificationService.java | 17 ++ .../corebeans/cycles/broken/OrderService.java | 21 ++ .../cycles/broken/PaymentService.java | 17 ++ .../corebeans/cycles/events/EventsConfig.java | 9 + .../cycles/events/NotificationService.java | 24 ++ .../corebeans/cycles/events/OrderService.java | 21 ++ .../cycles/events/PaymentCaptured.java | 5 + .../cycles/events/PaymentService.java | 19 ++ .../corebeans/cycles/lazy/LazyConfig.java | 9 + .../cycles/lazy/NotificationService.java | 23 ++ .../corebeans/cycles/lazy/OrderService.java | 21 ++ .../corebeans/cycles/lazy/PaymentService.java | 17 ++ .../cycles/provider/NotificationService.java | 19 ++ .../cycles/provider/OrderService.java | 21 ++ .../cycles/provider/PaymentService.java | 17 ++ .../cycles/provider/ProviderConfig.java | 9 + .../cycles/redesign/NotificationService.java | 17 ++ .../cycles/redesign/OrderCatalog.java | 12 + .../cycles/redesign/OrderService.java | 23 ++ .../cycles/redesign/PaymentService.java | 17 ++ .../cycles/redesign/RedesignConfig.java | 9 + .../cycles/support/CreationTracer.java | 21 ++ .../ankurm/corebeans/AllowCircularTest.java | 123 ++++++++ .../java/com/ankurm/corebeans/BootRun.java | 60 ++++ .../ankurm/corebeans/CycleFailureTest.java | 119 ++++++++ .../com/ankurm/corebeans/CycleFixesTest.java | 265 ++++++++++++++++++ .../ankurm/corebeans/HiddenCyclesTest.java | 121 ++++++++ .../java/com/ankurm/corebeans/Transcript.java | 6 +- 61 files changed, 1545 insertions(+), 8 deletions(-) create mode 100644 core-beans/output/20-boot-cycle-failure-report.txt create mode 100644 core-beans/output/21-cycle-exception-and-creation-order.txt create mode 100644 core-beans/output/22-bean-method-cycle-failure-report.txt create mode 100644 core-beans/output/23-lazy-initialization-hides-the-cycle.txt create mode 100644 core-beans/output/24-allow-circular-half-built-bean.txt create mode 100644 core-beans/output/25-allow-circular-async-raw-reference.txt create mode 100644 core-beans/output/26-allow-circular-cacheable-early-proxy.txt create mode 100644 core-beans/output/27-fix-redesign.txt create mode 100644 core-beans/output/28-fix-events.txt create mode 100644 core-beans/output/29-fix-lazy.txt create mode 100644 core-beans/output/30-fix-object-provider.txt create mode 100644 core-beans/output/31-lazy-on-a-final-class.txt create mode 100644 core-beans/output/32-lazy-injection-point-versus-lazy-bean.txt create mode 100644 core-beans/output/33-dispatcher-handler-cycle.txt create mode 100644 core-beans/output/34-depends-on-cycle.txt create mode 100644 core-beans/output/35-lazy-used-during-construction.txt create mode 100644 core-beans/output/36-main-property-defaults.txt create mode 100644 core-beans/output/37-early-reference-bytecode.txt create mode 100755 core-beans/scripts/capture-cycle-facts.sh create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AllowedConfig.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AsyncConfig.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AsyncFirst.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AsyncSecond.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/CachedConfig.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/CachedFirst.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/CachedSecond.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/FirstService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/SecondService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/beanmethods/BeanMethodCycle.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/BrokenConfig.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/NotificationService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/OrderService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/PaymentService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/events/EventsConfig.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/events/NotificationService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/events/OrderService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/events/PaymentCaptured.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/events/PaymentService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/LazyConfig.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/NotificationService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/OrderService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/PaymentService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/NotificationService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/OrderService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/PaymentService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/ProviderConfig.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/NotificationService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/OrderCatalog.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/OrderService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/PaymentService.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/RedesignConfig.java create mode 100644 core-beans/src/main/java/com/ankurm/corebeans/cycles/support/CreationTracer.java create mode 100644 core-beans/src/test/java/com/ankurm/corebeans/AllowCircularTest.java create mode 100644 core-beans/src/test/java/com/ankurm/corebeans/BootRun.java create mode 100644 core-beans/src/test/java/com/ankurm/corebeans/CycleFailureTest.java create mode 100644 core-beans/src/test/java/com/ankurm/corebeans/CycleFixesTest.java create mode 100644 core-beans/src/test/java/com/ankurm/corebeans/HiddenCyclesTest.java diff --git a/core-beans/README.md b/core-beans/README.md index 480f2b2..2d1cf8d 100644 --- a/core-beans/README.md +++ b/core-beans/README.md @@ -1,11 +1,12 @@ # core-beans -Companion project for two articles on **[ankurm.com](https://ankurm.com)**. +Companion project for three articles on **[ankurm.com](https://ankurm.com)**. | Article | What it demonstrates | |---|---| | [Spring Bean Scopes: Singleton, Prototype, Request, Session and the Prototype-in-Singleton Trap](https://ankurm.com/spring-bean-scopes-singleton-prototype-request-session-prototype-in-singleton-trap/) | instance counts for every scope, five ways to get a fresh prototype inside a singleton, a singleton that leaks one caller's data into another's, and request scope without a proxy failing at start-up | | [Spring Bean Lifecycle in Boot 4: @PostConstruct, InitializingBean, SmartLifecycle and Shutdown Order](https://ankurm.com/spring-bean-lifecycle-postconstruct-smartlifecycle-shutdown-order/) | every callback in order from a real run, `@PostConstruct` running before the proxy exists, the non-static `BeanPostProcessor` warning, `SmartLifecycle` phases measured, and a request in flight during graceful shutdown with virtual threads | +| [Circular Dependencies in Spring Boot 4: Why Startup Fails and 4 Ways to Fix It](https://ankurm.com/spring-boot-4-circular-dependencies-startup-fails-4-fixes/) | the start-up failure report for a three-service ring, the creation order behind it, what `allow-circular-references` does and does not repair (a half-built bean, `@Async` versus `@Cacheable`, an INFO line that swallows an exception), and four fixes run side by side: redesign, events, `@Lazy`, `ObjectProvider` | Every console block, exception message and count quoted in those articles came out of `output/`, and every file there is regenerated by one script. Most are written by the test suite, so if a @@ -39,6 +40,7 @@ mvn test # runs the scenarios and rewrites the test-writte | `web/` | `@RequestScope`, `@SessionScope`, `@ApplicationScope` beans and a diagnostic `/scopes` controller, plus the un-proxied request bean that fails at start-up | | `lifecycle/` | `KitchenSink` (every callback), the post-processor traps, `PhasedWorker` and `NeverStops` for `SmartLifecycle` | | `shutdown/` | a web application with a `/slow` endpoint and two `SmartLifecycle` beans at different phases | +| `cycles/` | one package per scenario: `broken/` (the three-service ring), `allowed/` (field cycles with the flag on), `redesign/`, `events/`, `lazy/` and `provider/` (the four fixes), `beanmethods/` (a cycle written as `@Bean` methods) | ## Endpoints @@ -51,7 +53,7 @@ Both endpoints exist only to be called by the tests. They have no authorisation. ## Captured output -Files 01-17 and 19 (tests) and 18 (`capture-metadata.sh`). Timing rows assert coarse thresholds, not exact milliseconds; treat them as indicative. +Files 01-17 and 19-35 (tests) and 18 (`capture-metadata.sh`). Timing rows assert coarse thresholds, not exact milliseconds; treat them as indicative. | File | What it shows | |---|---| @@ -74,6 +76,24 @@ Files 01-17 and 19 (tests) and 18 (`capture-metadata.sh`). Timing rows assert co | [`17-worker-phase-vs-web-server.txt`](output/17-worker-phase-vs-web-server.txt) | SmartLifecycle beans with the default phase and with phase 1000, while a request is in flight | | [`18-property-defaults.txt`](output/18-property-defaults.txt) | Property defaults read from spring-configuration-metadata.json (Boot 4.1.1 jars) | | [`19-bean-post-processors.txt`](output/19-bean-post-processors.txt) | The BeanPostProcessors registered in a plain Spring Boot context, in the order they run | +| [`20-boot-cycle-failure-report.txt`](output/20-boot-cycle-failure-report.txt) | Three constructor-injected services in a ring: what Spring Boot prints when start-up fails | +| [`21-cycle-exception-and-creation-order.txt`](output/21-cycle-exception-and-creation-order.txt) | The exception behind the report, and the order in which the container started creating beans | +| [`22-bean-method-cycle-failure-report.txt`](output/22-bean-method-cycle-failure-report.txt) | A two-bean cycle written as `@Bean` methods | +| [`23-lazy-initialization-hides-the-cycle.txt`](output/23-lazy-initialization-hides-the-cycle.txt) | `spring.main.lazy-initialization=true` on the same ring: it starts, then fails on first use | +| [`24-allow-circular-half-built-bean.txt`](output/24-allow-circular-half-built-bean.txt) | A field cycle with the flag off and on: what each `@PostConstruct` sees | +| [`25-allow-circular-async-raw-reference.txt`](output/25-allow-circular-async-raw-reference.txt) | The same cycle with `@Async`: repaired silently at start-up, a raw-version error under lazy initialisation | +| [`26-allow-circular-cacheable-early-proxy.txt`](output/26-allow-circular-cacheable-early-proxy.txt) | The same cycle with `@Cacheable`: the injected reference is the proxy | +| [`27-fix-redesign.txt`](output/27-fix-redesign.txt) | Fix 1: extract the one thing the second bean needed | +| [`28-fix-events.txt`](output/28-fix-events.txt) | Fix 2: an event instead of a call, and what the publisher sees when the listener throws | +| [`29-fix-lazy.txt`](output/29-fix-lazy.txt) | Fix 3: `@Lazy` on the parameter, and the proxy class that arrives | +| [`30-fix-object-provider.txt`](output/30-fix-object-provider.txt) | Fix 4: `ObjectProvider`, resolving in the constructor, and a final class | +| [`31-lazy-on-a-final-class.txt`](output/31-lazy-on-a-final-class.txt) | `@Lazy` on a parameter whose type is a final class | +| [`32-lazy-injection-point-versus-lazy-bean.txt`](output/32-lazy-injection-point-versus-lazy-bean.txt) | `@Lazy` on the injection point versus on the bean, with a target that cannot be built | +| [`33-dispatcher-handler-cycle.txt`](output/33-dispatcher-handler-cycle.txt) | A cycle through `List` and its `ObjectProvider` repair | +| [`34-depends-on-cycle.txt`](output/34-depends-on-cycle.txt) | `@DependsOn` in both directions: no injection involved | +| [`35-lazy-used-during-construction.txt`](output/35-lazy-used-during-construction.txt) | `@Lazy` on the parameter, but the bean calls the dependency from `@PostConstruct` | +| [`36-main-property-defaults.txt`](output/36-main-property-defaults.txt) | The defaults of `spring.main.allow-circular-references` and `spring.main.lazy-initialization`, from Boot's own metadata | +| [`37-early-reference-bytecode.txt`](output/37-early-reference-bytecode.txt) | Which post-processors hand out an early reference, and the `BeanCurrentlyInCreationException` handler in `preInstantiateSingleton` | ## Licence diff --git a/core-beans/output/20-boot-cycle-failure-report.txt b/core-beans/output/20-boot-cycle-failure-report.txt new file mode 100644 index 0000000..24d7f52 --- /dev/null +++ b/core-beans/output/20-boot-cycle-failure-report.txt @@ -0,0 +1,22 @@ +# Three constructor-injected services in a ring: what Spring Boot 4.1.1 prints when start-up fails + +*************************** +APPLICATION FAILED TO START +*************************** + +Description: + +The dependencies of some of the beans in the application context form a cycle: + +┌─────┐ +| notificationService defined in file [/target/classes/com/ankurm/corebeans/cycles/broken/NotificationService.class] +↑ ↓ +| orderService defined in file [/target/classes/com/ankurm/corebeans/cycles/broken/OrderService.class] +↑ ↓ +| paymentService defined in file [/target/classes/com/ankurm/corebeans/cycles/broken/PaymentService.class] +└─────┘ + + +Action: + +Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true. diff --git a/core-beans/output/21-cycle-exception-and-creation-order.txt b/core-beans/output/21-cycle-exception-and-creation-order.txt new file mode 100644 index 0000000..15147a9 --- /dev/null +++ b/core-beans/output/21-cycle-exception-and-creation-order.txt @@ -0,0 +1,14 @@ +# The exception behind the report, and the order in which the container started creating beans + + +--- exception classes, outermost first --- +UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException + +--- root cause --- +org.springframework.beans.factory.BeanCurrentlyInCreationException +Error creating bean with name 'notificationService': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency? + +--- creation order (one line per bean the container began to create) --- +start creating notificationService +start creating orderService +start creating paymentService diff --git a/core-beans/output/22-bean-method-cycle-failure-report.txt b/core-beans/output/22-bean-method-cycle-failure-report.txt new file mode 100644 index 0000000..6f769ba --- /dev/null +++ b/core-beans/output/22-bean-method-cycle-failure-report.txt @@ -0,0 +1,20 @@ +# A two-bean cycle written as @Bean methods: the report names the configuration class instead of a class file + +*************************** +APPLICATION FAILED TO START +*************************** + +Description: + +The dependencies of some of the beans in the application context form a cycle: + +┌─────┐ +| clock defined in com.ankurm.corebeans.cycles.beanmethods.BeanMethodCycle +↑ ↓ +| calendar defined in com.ankurm.corebeans.cycles.beanmethods.BeanMethodCycle +└─────┘ + + +Action: + +Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true. diff --git a/core-beans/output/23-lazy-initialization-hides-the-cycle.txt b/core-beans/output/23-lazy-initialization-hides-the-cycle.txt new file mode 100644 index 0000000..e961ed6 --- /dev/null +++ b/core-beans/output/23-lazy-initialization-hides-the-cycle.txt @@ -0,0 +1,5 @@ +# spring.main.lazy-initialization=true on the same three-service ring + +context started: true +getBean(OrderService.class) failed: UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException +root cause: Error creating bean with name 'orderService': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency? diff --git a/core-beans/output/24-allow-circular-half-built-bean.txt b/core-beans/output/24-allow-circular-half-built-bean.txt new file mode 100644 index 0000000..ee7dca8 --- /dev/null +++ b/core-beans/output/24-allow-circular-half-built-bean.txt @@ -0,0 +1,15 @@ +# A field-injected cycle with spring.main.allow-circular-references=true: what each @PostConstruct sees + + +--- flag off (the Boot default) --- +started: false +root cause: Error creating bean with name 'firstService': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency? + +--- flag on --- +started: true +start creating firstService +FirstService constructed +start creating secondService +SecondService constructed +SecondService @PostConstruct sees first.isReady() = false +FirstService @PostConstruct done, ready=true diff --git a/core-beans/output/25-allow-circular-async-raw-reference.txt b/core-beans/output/25-allow-circular-async-raw-reference.txt new file mode 100644 index 0000000..c80f032 --- /dev/null +++ b/core-beans/output/25-allow-circular-async-raw-reference.txt @@ -0,0 +1,21 @@ +# The same field cycle, with @Async on AsyncFirst and the flag on: start-up versus a lazy first getBean + + +--- eager start-up (the default) --- +start creating com.ankurm.corebeans.cycles.allowed.AsyncFirst +start creating com.ankurm.corebeans.cycles.allowed.AsyncSecond +AsyncSecond constructed, identity 2f0ed952 +AsyncSecond.setFirst on instance 2f0ed952 received a proxy: false +AsyncSecond constructed, identity 5a8816cc +AsyncSecond.setFirst on instance 5a8816cc received a proxy: true +started: true +AsyncFirst bean is a proxy : true +AsyncSecond.first is a proxy : true +AsyncSecond.first == the bean : true +log line from DefaultListableBeanFactory (INFO): + Bean 'com.ankurm.corebeans.cycles.allowed.AsyncFirst' marked for pre-instantiation (not lazy-init) but currently initialized by other thread - skipping it in mainline thread + +--- spring.main.lazy-initialization=true, then getBean(AsyncFirst.class) --- +started: true +getBean(AsyncFirst.class) failed: BeanCurrentlyInCreationException +root cause: Error creating bean with name 'com.ankurm.corebeans.cycles.allowed.AsyncFirst': Bean with name 'com.ankurm.corebeans.cycles.allowed.AsyncFirst' has been injected into other beans [com.ankurm.corebeans.cycles.allowed.AsyncSecond] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesForType' with the 'allowEagerInit' flag turned off, for example. diff --git a/core-beans/output/26-allow-circular-cacheable-early-proxy.txt b/core-beans/output/26-allow-circular-cacheable-early-proxy.txt new file mode 100644 index 0000000..8a8179e --- /dev/null +++ b/core-beans/output/26-allow-circular-cacheable-early-proxy.txt @@ -0,0 +1,7 @@ +# The same field cycle, with @Cacheable on one of the two beans, and the flag on + +started: true +CachedFirst bean is a proxy : true (CachedFirst$$SpringCGLIB$$0) +CachedSecond.first is a proxy : true +CachedSecond.first == the bean : true +two calls through the injected reference return 1 and 1, method body ran 1 time(s) diff --git a/core-beans/output/27-fix-redesign.txt b/core-beans/output/27-fix-redesign.txt new file mode 100644 index 0000000..4ad288f --- /dev/null +++ b/core-beans/output/27-fix-redesign.txt @@ -0,0 +1,4 @@ +# Fix 1: extract OrderCatalog, the one thing NotificationService needed + +started: true +place("kettle") -> order placed: charged, e-mail says: 1 x kettle diff --git a/core-beans/output/28-fix-events.txt b/core-beans/output/28-fix-events.txt new file mode 100644 index 0000000..1dd09bc --- /dev/null +++ b/core-beans/output/28-fix-events.txt @@ -0,0 +1,8 @@ +# Fix 2: PaymentService publishes an event instead of calling NotificationService + +started: true +place("kettle") -> order placed: charged +listener on thread main: e-mail says: 1 x kettle + +--- what the publisher sees when the listener throws --- +place("boom") threw IllegalStateException: mail server down diff --git a/core-beans/output/29-fix-lazy.txt b/core-beans/output/29-fix-lazy.txt new file mode 100644 index 0000000..6ea8255 --- /dev/null +++ b/core-beans/output/29-fix-lazy.txt @@ -0,0 +1,5 @@ +# Fix 3: @Lazy on NotificationService's OrderService parameter + +started: true +NotificationService holds a com.ankurm.corebeans.cycles.lazy.OrderService$$SpringCGLIB$$0 +place("kettle") -> order placed: charged, e-mail says: 1 x kettle diff --git a/core-beans/output/30-fix-object-provider.txt b/core-beans/output/30-fix-object-provider.txt new file mode 100644 index 0000000..b7e5f69 --- /dev/null +++ b/core-beans/output/30-fix-object-provider.txt @@ -0,0 +1,12 @@ +# Fix 4: ObjectProvider in NotificationService + +started: true +place("kettle") -> order placed: charged, e-mail says: 1 x kettle + +--- the same provider, but getObject() is called inside the constructor --- +started: false +root cause: Error creating bean with name 'cycleFixesTest.EagerNotifier': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency? + +--- the provider needs no proxy, so a final class is fine --- +started: true +hello() -> hello diff --git a/core-beans/output/31-lazy-on-a-final-class.txt b/core-beans/output/31-lazy-on-a-final-class.txt new file mode 100644 index 0000000..9a531f3 --- /dev/null +++ b/core-beans/output/31-lazy-on-a-final-class.txt @@ -0,0 +1,5 @@ +# @Lazy on a constructor parameter whose type is a final class + +started: false +exception classes: BeanCreationException -> AopConfigException -> IllegalArgumentException +root cause: java.lang.IllegalArgumentException: Cannot subclass final class com.ankurm.corebeans.CycleFixesTest$FinalTarget diff --git a/core-beans/output/32-lazy-injection-point-versus-lazy-bean.txt b/core-beans/output/32-lazy-injection-point-versus-lazy-bean.txt new file mode 100644 index 0000000..c76a516 --- /dev/null +++ b/core-beans/output/32-lazy-injection-point-versus-lazy-bean.txt @@ -0,0 +1,11 @@ +# @Lazy on the injection point, with a target that cannot be built + + +--- @Lazy on the parameter, target is an ordinary singleton --- +started: false +root cause: cannot reach the payment provider + +--- @Lazy on the parameter AND on the target bean --- +started: true +first call threw: BeanCreationException -> BeanInstantiationException -> IllegalStateException +root cause: cannot reach the payment provider diff --git a/core-beans/output/33-dispatcher-handler-cycle.txt b/core-beans/output/33-dispatcher-handler-cycle.txt new file mode 100644 index 0000000..5fd3016 --- /dev/null +++ b/core-beans/output/33-dispatcher-handler-cycle.txt @@ -0,0 +1,11 @@ +# Dispatcher(List) and a Handler that needs the Dispatcher + + +--- Handler injects the Dispatcher --- +started: false +exception classes: UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException +root cause: Error creating bean with name 'hiddenCyclesTest.Dispatcher': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency? + +--- Handler injects ObjectProvider --- +started: true +dispatch("ping") -> [seen ping (dispatcher available: true)] diff --git a/core-beans/output/34-depends-on-cycle.txt b/core-beans/output/34-depends-on-cycle.txt new file mode 100644 index 0000000..a01df64 --- /dev/null +++ b/core-beans/output/34-depends-on-cycle.txt @@ -0,0 +1,5 @@ +# @DependsOn("b") on a and @DependsOn("a") on b: no injection involved at all + +started: false +exception classes: BeanCreationException +root cause: Error creating bean with name 'b' defined in com.ankurm.corebeans.HiddenCyclesTest$DependsOnCycle: Circular depends-on relationship between 'b' and 'a' diff --git a/core-beans/output/35-lazy-used-during-construction.txt b/core-beans/output/35-lazy-used-during-construction.txt new file mode 100644 index 0000000..cc87024 --- /dev/null +++ b/core-beans/output/35-lazy-used-during-construction.txt @@ -0,0 +1,5 @@ +# @Lazy on the parameter, but the bean calls the dependency from @PostConstruct + +started: false +exception classes: BeanCreationException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException +root cause: Error creating bean with name 'cycleFixesTest.LazyUserA': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency? diff --git a/core-beans/output/36-main-property-defaults.txt b/core-beans/output/36-main-property-defaults.txt new file mode 100644 index 0000000..31bb7f6 --- /dev/null +++ b/core-beans/output/36-main-property-defaults.txt @@ -0,0 +1,4 @@ +# Defaults read from spring-configuration-metadata.json in spring-boot-4.1.1.jar + +spring.main.allow-circular-references default=False +spring.main.lazy-initialization default=False diff --git a/core-beans/output/37-early-reference-bytecode.txt b/core-beans/output/37-early-reference-bytecode.txt new file mode 100644 index 0000000..2ab1ba5 --- /dev/null +++ b/core-beans/output/37-early-reference-bytecode.txt @@ -0,0 +1,23 @@ +# Who hands out an early reference, and the handler in preInstantiateSingleton (Spring Framework 7.0.9 jars) + +--- the class that draws the report, in spring-boot-4.1.1.jar --- +org/springframework/boot/diagnostics/analyzer/BeanCurrentlyInCreationFailureAnalyzer$BeanInCycle.class +org/springframework/boot/diagnostics/analyzer/BeanCurrentlyInCreationFailureAnalyzer$DependencyCycle.class +org/springframework/boot/diagnostics/analyzer/BeanCurrentlyInCreationFailureAnalyzer.class + +--- AbstractAutoProxyCreator (spring-aop): declared methods that mention 'early' --- +private final java.util.Map earlyBeanReferences; +public java.lang.Object getEarlyBeanReference(java.lang.Object, java.lang.String); + +--- AbstractAdvisingBeanPostProcessor (spring-aop): declared members that mention 'early' --- +matches: 0 + +--- the @Async post-processor's ancestry --- +class org.springframework.scheduling.annotation.AsyncAnnotationBeanPostProcessor extends org.springframework.aop.framework.autoproxy.AbstractBeanFactoryAwareAdvisingPostProcessor +class org.springframework.aop.framework.autoproxy.AbstractBeanFactoryAwareAdvisingPostProcessor extends org.springframework.aop.framework.AbstractAdvisingBeanPostProcessor + +--- DefaultListableBeanFactory.preInstantiateSingleton(String, RootBeanDefinition): exception table --- +Exception table: +from to target type +157 162 165 Class org/springframework/beans/factory/BeanCurrentlyInCreationException + diff --git a/core-beans/pom.xml b/core-beans/pom.xml index eed87b7..b76dc24 100644 --- a/core-beans/pom.xml +++ b/core-beans/pom.xml @@ -15,7 +15,7 @@ core-beans 1.0.0 core-beans - Spring bean scopes and the bean lifecycle in Spring Boot 4 + Spring bean scopes, the bean lifecycle and circular dependencies in Spring Boot 4 25 diff --git a/core-beans/scripts/capture-cycle-facts.sh b/core-beans/scripts/capture-cycle-facts.sh new file mode 100755 index 0000000..89cd565 --- /dev/null +++ b/core-beans/scripts/capture-cycle-facts.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Two facts about the circular-reference machinery, read out of the Framework and Boot jars rather than from docs: +# 36 the defaults of spring.main.allow-circular-references and spring.main.lazy-initialization +# 37 which post-processors supply an early reference, and the exception handler in preInstantiateSingleton +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p output +M2=~/.m2/repository/org/springframework +BOOT=$M2/boot/spring-boot/4.1.1/spring-boot-4.1.1.jar +AOP=$M2/spring-aop/7.0.9/spring-aop-7.0.9.jar +CTX=$M2/spring-context/7.0.9/spring-context-7.0.9.jar +BEANS=$M2/spring-beans/7.0.9/spring-beans-7.0.9.jar +JP() { javap "$@" 2>&1 | grep -v '^Picked up'; } + +{ + echo "# Defaults read from spring-configuration-metadata.json in spring-boot-4.1.1.jar" + echo + unzip -p "$BOOT" META-INF/spring-configuration-metadata.json | python3 -c ' +import json,sys +d=json.load(sys.stdin) +for p in d["properties"]: + if p["name"] in ("spring.main.allow-circular-references","spring.main.lazy-initialization"): + print("%-40s default=%s" % (p["name"], p.get("defaultValue"))) +' +} > output/36-main-property-defaults.txt + +{ + echo "# Who hands out an early reference, and the handler in preInstantiateSingleton (Spring Framework 7.0.9 jars)" + echo + echo "--- the class that draws the report, in spring-boot-4.1.1.jar ---" + unzip -l "$BOOT" | awk '/BeanCurrentlyInCreationFailureAnalyzer/ {print $4}' + echo + echo "--- AbstractAutoProxyCreator (spring-aop): declared methods that mention 'early' ---" + JP -p -cp "$AOP" org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator | grep -i early | sed 's/^ *//' + echo + echo "--- AbstractAdvisingBeanPostProcessor (spring-aop): declared members that mention 'early' ---" + n=$(JP -p -cp "$AOP" org.springframework.aop.framework.AbstractAdvisingBeanPostProcessor | grep -ci early || true) + echo "matches: $n" + echo + echo "--- the @Async post-processor's ancestry ---" + JP -cp "$CTX:$AOP" org.springframework.scheduling.annotation.AsyncAnnotationBeanPostProcessor | grep -o 'class [^ ]* extends [^ ]*' + JP -cp "$AOP" org.springframework.aop.framework.autoproxy.AbstractBeanFactoryAwareAdvisingPostProcessor | grep -o 'class [^ ]* extends [^ ]*' + echo + echo "--- DefaultListableBeanFactory.preInstantiateSingleton(String, RootBeanDefinition): exception table ---" + JP -p -c -cp "$BEANS" org.springframework.beans.factory.support.DefaultListableBeanFactory \ + | awk '/private java.util.concurrent.CompletableFuture<\?> preInstantiateSingleton\(/ {f=1} f&&/Exception table:/ {t=1} t {print} t&&/^ private void instantiateSingletonInBackgroundThread/ {exit}' \ + | grep -v 'instantiateSingletonInBackgroundThread' | sed 's/^ *//' +} > output/37-early-reference-bytecode.txt +cat output/36-main-property-defaults.txt output/37-early-reference-bytecode.txt diff --git a/core-beans/scripts/run-all.sh b/core-beans/scripts/run-all.sh index 42cb807..add4c1e 100755 --- a/core-beans/scripts/run-all.sh +++ b/core-beans/scripts/run-all.sh @@ -3,18 +3,21 @@ # # ./scripts/run-all.sh # -# Needs a JDK 25 and Maven 3.9. Transcripts 01-17 and 19 come out of the test suite, which is the point: -# the figures in the two articles are assertions that fail the build if they stop being true. +# Needs a JDK 25 and Maven 3.9. Transcripts 01-17 and 19-35 come out of the test suite, which is the point: +# the figures in the articles are assertions that fail the build if they stop being true. # Timing-based rows (12, 16) assert coarse thresholds, not exact milliseconds. set -euo pipefail cd "$(dirname "$0")/.." -echo "== test suite (transcripts 01-17)" +echo "== test suite (transcripts 01-17 and 19-35)" mvn -B test echo "== property defaults read from Boot's metadata (18)" ./scripts/capture-metadata.sh +echo "== early-reference and property facts read from the jars (36-37)" +./scripts/capture-cycle-facts.sh + echo echo "output:" ls -1 output diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AllowedConfig.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AllowedConfig.java new file mode 100644 index 0000000..c389a23 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AllowedConfig.java @@ -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 { +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AsyncConfig.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AsyncConfig.java new file mode 100644 index 0000000..1efc7c9 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AsyncConfig.java @@ -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 { +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AsyncFirst.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AsyncFirst.java new file mode 100644 index 0000000..682fe8f --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AsyncFirst.java @@ -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() { + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AsyncSecond.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AsyncSecond.java new file mode 100644 index 0000000..d4d18a2 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/AsyncSecond.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/CachedConfig.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/CachedConfig.java new file mode 100644 index 0000000..34961df --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/CachedConfig.java @@ -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"); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/CachedFirst.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/CachedFirst.java new file mode 100644 index 0000000..f07c307 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/CachedFirst.java @@ -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(); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/CachedSecond.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/CachedSecond.java new file mode 100644 index 0000000..e038823 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/CachedSecond.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/FirstService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/FirstService.java new file mode 100644 index 0000000..3094287 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/FirstService.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/SecondService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/SecondService.java new file mode 100644 index 0000000..71ed5c1 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/allowed/SecondService.java @@ -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()); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/beanmethods/BeanMethodCycle.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/beanmethods/BeanMethodCycle.java new file mode 100644 index 0000000..47a67e1 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/beanmethods/BeanMethodCycle.java @@ -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); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/BrokenConfig.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/BrokenConfig.java new file mode 100644 index 0000000..d8b72c5 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/BrokenConfig.java @@ -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 { +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/NotificationService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/NotificationService.java new file mode 100644 index 0000000..b079d8c --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/NotificationService.java @@ -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); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/OrderService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/OrderService.java new file mode 100644 index 0000000..d18bdd9 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/OrderService.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/PaymentService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/PaymentService.java new file mode 100644 index 0000000..4045c6e --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/broken/PaymentService.java @@ -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); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/events/EventsConfig.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/events/EventsConfig.java new file mode 100644 index 0000000..db1cb5d --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/events/EventsConfig.java @@ -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 { +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/events/NotificationService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/events/NotificationService.java new file mode 100644 index 0000000..d133f9e --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/events/NotificationService.java @@ -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())); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/events/OrderService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/events/OrderService.java new file mode 100644 index 0000000..2488ed0 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/events/OrderService.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/events/PaymentCaptured.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/events/PaymentCaptured.java new file mode 100644 index 0000000..3f8675b --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/events/PaymentCaptured.java @@ -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) { +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/events/PaymentService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/events/PaymentService.java new file mode 100644 index 0000000..eb2b332 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/events/PaymentService.java @@ -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"; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/LazyConfig.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/LazyConfig.java new file mode 100644 index 0000000..1414ad1 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/LazyConfig.java @@ -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 { +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/NotificationService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/NotificationService.java new file mode 100644 index 0000000..31c8d4d --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/NotificationService.java @@ -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(); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/OrderService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/OrderService.java new file mode 100644 index 0000000..2731009 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/OrderService.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/PaymentService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/PaymentService.java new file mode 100644 index 0000000..2ee55cc --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/lazy/PaymentService.java @@ -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); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/NotificationService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/NotificationService.java new file mode 100644 index 0000000..a5e8c22 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/NotificationService.java @@ -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 orders; + + /** Nothing is resolved here: the provider is a handle, not the bean. */ + public NotificationService(ObjectProvider orders) { + this.orders = orders; + } + + public String confirm(String sku) { + return "e-mail says: " + orders.getObject().describe(sku); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/OrderService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/OrderService.java new file mode 100644 index 0000000..ce8d603 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/OrderService.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/PaymentService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/PaymentService.java new file mode 100644 index 0000000..b5455c3 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/PaymentService.java @@ -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); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/ProviderConfig.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/ProviderConfig.java new file mode 100644 index 0000000..3f152ef --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/provider/ProviderConfig.java @@ -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 { +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/NotificationService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/NotificationService.java new file mode 100644 index 0000000..8c4c048 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/NotificationService.java @@ -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); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/OrderCatalog.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/OrderCatalog.java new file mode 100644 index 0000000..270fecf --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/OrderCatalog.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/OrderService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/OrderService.java new file mode 100644 index 0000000..ae48ee7 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/OrderService.java @@ -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); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/PaymentService.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/PaymentService.java new file mode 100644 index 0000000..8260528 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/PaymentService.java @@ -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); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/RedesignConfig.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/RedesignConfig.java new file mode 100644 index 0000000..67b7e5a --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/redesign/RedesignConfig.java @@ -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 { +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/cycles/support/CreationTracer.java b/core-beans/src/main/java/com/ankurm/corebeans/cycles/support/CreationTracer.java new file mode 100644 index 0000000..7d62747 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/cycles/support/CreationTracer.java @@ -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; + } +} diff --git a/core-beans/src/test/java/com/ankurm/corebeans/AllowCircularTest.java b/core-beans/src/test/java/com/ankurm/corebeans/AllowCircularTest.java new file mode 100644 index 0000000..22dd6b2 --- /dev/null +++ b/core-beans/src/test/java/com/ankurm/corebeans/AllowCircularTest.java @@ -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(); + } + } +} diff --git a/core-beans/src/test/java/com/ankurm/corebeans/BootRun.java b/core-beans/src/test/java/com/ankurm/corebeans/BootRun.java new file mode 100644 index 0000000..9c92466 --- /dev/null +++ b/core-beans/src/test/java/com/ankurm/corebeans/BootRun.java @@ -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(); + 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(); + 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(); + } + } + } +} diff --git a/core-beans/src/test/java/com/ankurm/corebeans/CycleFailureTest.java b/core-beans/src/test/java/com/ankurm/corebeans/CycleFailureTest.java new file mode 100644 index 0000000..8128f34 --- /dev/null +++ b/core-beans/src/test/java/com/ankurm/corebeans/CycleFailureTest.java @@ -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(); + 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(); + } + } + } +} diff --git a/core-beans/src/test/java/com/ankurm/corebeans/CycleFixesTest.java b/core-beans/src/test/java/com/ankurm/corebeans/CycleFixesTest.java new file mode 100644 index 0000000..83a4ad9 --- /dev/null +++ b/core-beans/src/test/java/com/ankurm/corebeans/CycleFixesTest.java @@ -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 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 target; + + FinalProviderConsumer(ObjectProvider 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 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(); + } + } +} diff --git a/core-beans/src/test/java/com/ankurm/corebeans/HiddenCyclesTest.java b/core-beans/src/test/java/com/ankurm/corebeans/HiddenCyclesTest.java new file mode 100644 index 0000000..9f0636f --- /dev/null +++ b/core-beans/src/test/java/com/ankurm/corebeans/HiddenCyclesTest.java @@ -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 handlers; + + Dispatcher(List 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 dispatcher; + + LazyLookupHandler(ObjectProvider 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 handlers; + + LookupDispatcher(List 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) 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"); + 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()); + } + } +} diff --git a/core-beans/src/test/java/com/ankurm/corebeans/Transcript.java b/core-beans/src/test/java/com/ankurm/corebeans/Transcript.java index 3b9141d..1967f68 100644 --- a/core-beans/src/test/java/com/ankurm/corebeans/Transcript.java +++ b/core-beans/src/test/java/com/ankurm/corebeans/Transcript.java @@ -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"), ""); 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); } }