Fixes found during self-correction before publishing:
- /stream used Flux.interval(), which ticks on its own wall-clock schedule
independent of downstream demand and threw OverflowException under a slow
subscriber; switched to Flux.range(), which has no independent production
schedule and can never outrun demand.
- Single-trial HTTP load tests on this shared sandbox swung by more than 50%
run to run (795ms-1247ms observed on the identical /io endpoint back to
back) -- large enough to flip which threading model looked faster. Fixed
by taking the median of 5 independent trials for the I/O-bound benchmark
and the median of 3 for the event-loop-starvation benchmark, rather than
reporting a single noisy run as if it were precise.
- The event-loop-starvation test's first cut used only 8 concurrent /cpu
requests as background load, which drained through the 4 event-loop
threads well inside the /io measurement window and produced an
inconsistent, sometimes-inverted result across runs; raising to 60 fixed
the under-loading problem but still flaked once during verification
(372ms vs 374ms p99, a real tie). Final fix: 150 concurrent requests plus
the median-of-3 trials above.
Also adds StreamBackpressureTest, a StepVerifier proof that the /stream
endpoint never emits ahead of its subscriber's outstanding requests, and
updates the module's docs to report the de-noised numbers with an explicit
methodology note on how they compare to the single-trial platform/virtual-
thread numbers reused from a different post.
Companion module for the rewritten post 'Virtual Threads on Spring Boot 4.1: The Benchmarks,
Re-Run, and the Pinning Advice That Expired', retitled and re-benchmarked on Boot 4.1.1 /
JDK 25.0.4.1 (the original post was written against Boot 3.4 / JDK 21). Covers: I/O-bound and
CPU-bound throughput (platform vs virtual threads, including a JIT-warmup benchmarking bug this
build caught and fixed), JEP 491 proof that synchronized no longer pins a virtual thread's
carrier across a blocking call as of JDK 24 (obsoleting the old avoid-synchronized advice),
proof that -Djdk.tracePinnedThreads=full is inert on JDK 25, and JEP 506's finalized ScopedValue
API (JDK 25 GA, no --enable-preview, and a different shape than the old preview API). Kept as
its own module rather than a new top-level repository, alongside the existing async/ module,
which already has a stronger dual-JDK JEP 491 proof that this module's docs cross-link to
instead of duplicating.
Three replicas of one application against one PostgreSQL database, proving
duplicate @Scheduled execution and then removing it with ShedLock: 24
executions where 8 were due, then 7 for 7 ticks.
Also measured: @SchedulerLock without @EnableSchedulerLock does nothing and
warns about nothing; spring.task.scheduling.pool.size=1 does not starve a
fixedRate job but delays it and fires 35 of 40 executions in a burst; and a
node whose clock is 40 seconds fast takes a live lock unless the provider
uses usingDbTime().
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.