Add the async module

Every @Async behaviour that surprises people, asserted by a test and captured
to docs/output/: the self-invocation trap, what CGLIB cannot override, the
IllegalArgumentException a plain return type throws, the unbounded queue that
makes max-size decoration, spring.task.execution.propagate-context (new in
Boot 4.1.0), the two Executor beans that leave @Async on an unpooled
SimpleAsyncTaskExecutor, and JEP 491 measured on JDK 21 against JDK 25.
This commit is contained in:
2026-09-01 23:27:47 +05:30
commit 9b3fe88813
53 changed files with 1891 additions and 0 deletions

64
async/README.md Normal file
View File

@@ -0,0 +1,64 @@
# async
Companion project for **@Async in Spring Boot 4: Executors, Virtual Threads and the
Self-Invocation Trap** on [ankurm.com](https://ankurm.com).
Everything here is asserted by a test and captured under [`docs/output/`](docs/output). The
measurement is always the same: the name of the thread the method body actually ran on, returned
by the method itself. Timing cannot distinguish a fast synchronous call from an asynchronous one;
a thread name can.
## Verified stack
| Component | Version | Source of the number |
|---|---|---|
| JDK | 25.0.4.1+1 (Temurin) | `java -version` |
| Spring Boot | 4.1.1 | `maven-metadata.xml` on Maven Central |
| Spring Framework | 7.0.9 | `spring-boot-dependencies-4.1.1.pom` |
| micrometer context-propagation | Boot-managed | `spring-boot-dependencies-4.1.1.pom` |
| JDK used for the pinning comparison | 21.0.12.1+1 (Temurin) | `java -version` |
## Quickstart
```bash
mvn test # 22 tests, all of the transcripts
scripts/run-all.sh # regenerates every docs/output/ file
scripts/run-all.sh /path/to/jdk21/bin/java # adds the JDK 21 vs 25 pinning comparison
```
## Documentation
| Chapter | What it settles |
|---|---|
| [01 What `@Async` actually does](docs/01-what-async-actually-does.md) | The proxy is the whole mechanism, and `@EnableAsync` is not automatic |
| [02 The self-invocation trap](docs/02-the-self-invocation-trap.md) | Why an internal call runs inline, and the three fixes |
| [03 What the proxy cannot see](docs/03-what-the-proxy-cannot-see.md) | `final` and `private` methods, and how to tell that case from a self-invocation |
| [04 Return types and exceptions](docs/04-return-types-and-exceptions.md) | A plain return type throws; it does not return null |
| [05 Pool sizing](docs/05-pool-sizing.md) | Why `max-size` does nothing until `queue-capacity` is bounded |
| [06 Context propagation](docs/06-context-propagation.md) | `spring.task.execution.propagate-context`, new in Boot 4.1.0 |
| [07 Which executor runs it](docs/07-which-executor-runs-it.md) | Two `Executor` beans and `@Async` quietly uses neither |
| [08 Virtual threads and pinning](docs/08-virtual-threads-and-pinning.md) | JEP 491 measured on JDK 21 against JDK 25 |
## Captured output
| File | What it shows |
|---|---|
| [`self-invocation.txt`](docs/output/self-invocation.txt) | The same method on `task-5`, on `main`, and on `task-6` |
| [`visibility.txt`](docs/output/visibility.txt) | `final` is inert; `protected` is not |
| [`return-types.txt`](docs/output/return-types.txt) | The `IllegalArgumentException` a plain return type throws |
| [`executor-report.txt`](docs/output/executor-report.txt) | Stock pool numbers, read off the live bean |
| [`executor-one-custom.txt`](docs/output/executor-one-custom.txt) | Boot backing off in favour of one custom `Executor` |
| [`executor-two-custom.txt`](docs/output/executor-two-custom.txt) | Two custom executors and neither is used |
| [`executor-force-mode.txt`](docs/output/executor-force-mode.txt) | `spring.task.execution.mode=force` restoring it |
| [`pool-unbounded-queue.txt`](docs/output/pool-unbounded-queue.txt) | 16 tasks, 4 threads, `max-size=12` ignored |
| [`pool-bounded-queue.txt`](docs/output/pool-bounded-queue.txt) | The same 16 tasks on 12 threads |
| [`virtual-threads.txt`](docs/output/virtual-threads.txt) | `SimpleAsyncTaskExecutor`, `virtual=true`, pool properties inert |
| [`context-propagation.txt`](docs/output/context-propagation.txt) | A `ThreadLocal` surviving the hop |
| [`pinning-probe.txt`](docs/output/pinning-probe.txt) | 4806 ms on JDK 21, 301 ms on JDK 25 |
| [`tests.txt`](docs/output/tests.txt) | The test run behind all of the above |
## The one diagnostic
`ExecutorDiagnostics` prints every `Executor` bean in the context with its real core size, max
size and queue capacity. It is the fastest way to answer "which executor is actually running
this". Delete it before shipping.