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,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.