Add virtual-threads-benchmark-webflux: the WebFlux leg of the three-way benchmark

Fixes two bugs 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.
- 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; raised to 60
  concurrent requests (matching the CPU-bound benchmark's own concurrency)
  so the load actually occupies the event loop for the full window.

Also adds StreamBackpressureTest, a StepVerifier proof that the /stream
endpoint never emits ahead of its subscriber's outstanding requests.
This commit is contained in:
Claude
2026-09-19 08:48:58 +00:00
parent f506b01389
commit b331db6638
19 changed files with 863 additions and 0 deletions
@@ -0,0 +1,8 @@
I/O-bound endpoint (/io, Mono.delay(300ms)), concurrency=600, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
==============================================================================================================
webflux (netty) : total=600 success=600 wall=852ms p50=591ms p99=738ms
Mono.delay() never parks a thread -- the event loop schedules a timer callback and
goes back to the selector loop immediately. Wall time for 600 concurrent requests
stays close to a single 300ms round, same shape as virtual threads' /io result in
../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt.
@@ -0,0 +1,17 @@
CPU-bound endpoint (/cpu vs /cpu-offloaded, 20,000x SHA-256), concurrency=60, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
==============================================================================================================================
LoopResources.DEFAULT_IO_WORKER_COUNT (Netty event-loop threads) = 4
Runtime.availableProcessors() (Schedulers.parallel() thread count) = 2
webflux naive (Mono.fromCallable, no subscribeOn) : total=60 success=60 wall=293ms p50=168ms p99=271ms
webflux offloaded (subscribeOn(Schedulers.parallel())) : total=60 success=60 wall=316ms p50=179ms p99=314ms
Counter-intuitive result, and worth stating honestly rather than forcing the expected
story: on THIS box, the two wall times are close, because Reactor Netty's default event-
loop pool (DEFAULT_IO_WORKER_COUNT = max(availableProcessors(), 4) = 4 here) is actually
LARGER than Schedulers.parallel()'s pool (sized to availableProcessors() = 2). The naive
endpoint that "incorrectly" runs on the event loop has more worker threads to run on,
at this modest concurrency, than the "correctly offloaded" one. This does not mean the
naive version is fine -- see the event-loop-starvation scenario below for what it actually
breaks -- only that per-endpoint throughput alone does not show the problem on a small,
under-loaded box like this one.
@@ -0,0 +1,16 @@
/io latency while 60 concurrent CPU-bound requests run, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
========================================================================================================
/io alone (baseline, no concurrent CPU load) : total=20 success=20 wall=336ms p50=323ms p99=329ms
/io while 60x /cpu (naive) run concurrently : total=20 success=20 wall=486ms p50=452ms p99=467ms
/io while 60x /cpu-offloaded run concurrently : total=20 success=20 wall=398ms p50=377ms p99=387ms
This is the real cost of the naive endpoint, and it does not show up by benchmarking
/cpu in isolation: /io shares the same small event-loop pool with /cpu. With enough
concurrent CPU load to occupy all 4 event-loop threads for the full measurement
window (a first cut of this test used only 8 concurrent CPU requests, which drained
through the event loop in well under the /io window and produced an inconsistent,
sometimes-inverted result across runs -- fixed by raising the CPU load's own
concurrency, not by changing the assertion), naive /io latency is consistently and
substantially worse than both the undisturbed baseline and the offloaded case. At
higher production concurrency this is the exact mechanism behind a single CPU-heavy
endpoint silently degrading every other endpoint on the same Netty server.
@@ -0,0 +1,13 @@
Flux backpressure proof: /stream, requested in batches of 3, 2, then 45
=======================================================================
StepVerifier.create(controller.streamResults(), 3)
.expectNext("event-0", "event-1", "event-2")
.expectNoEvent(Duration.ofMillis(80)) -- no 4th item arrives without a request
.thenRequest(2).expectNext("event-3", "event-4")
.thenRequest(45).expectNextCount(45)
.expectComplete()
RESULT: verified -- the flux emitted exactly as many items as were requested, in
the order requested, with no items arriving ahead of a pending request. This is
what "backpressure is part of the Flux contract" means concretely: the subscriber,
not the producer, controls the emission rate.