67 lines
3.7 KiB
Markdown
67 lines
3.7 KiB
Markdown
# ADR-WEB-ADV-002: Virtual threads change scheduling, not the concurrency budget
|
|
|
|
- Status: Accepted
|
|
- Date: 2026-08-25
|
|
- Scope: `adapter:inbound:web` — `advanced.virtualthread`, `advanced.blockingbridge`
|
|
|
|
## Context
|
|
|
|
Advanced Task 2 offers a virtual-thread executor for MVC; Task 3 offers a bounded blocking bridge
|
|
for WebFlux.
|
|
|
|
A platform-thread MVC deployment has an implicit concurrency limit — the thread pool — and that
|
|
limit is usually what has been protecting the database pool, the outbound HTTP bulkhead and every
|
|
downstream service from the full arrival rate. Nobody wrote it down as an admission policy; it was a
|
|
side effect of the pool size.
|
|
|
|
Switching to virtual threads deletes that limit without deleting anything that depended on it.
|
|
|
|
## Decision
|
|
|
|
**An explicit admission limit is required when virtual threads are enabled.**
|
|
`VirtualThreadProfile` refuses construction without one. Without it the deployment accepts every
|
|
arrival, queues all of them on the downstream budgets, and times out work that would have succeeded
|
|
had it been refused. The load that used to be shed at the front door is shed at the back, after the
|
|
cost of accepting it.
|
|
|
|
**The limit bounds concurrent use cases, not threads.** `VirtualThreadAdmissionGuard` is a fair
|
|
semaphore, not a pool. Bounding threads would put the waiting back and throw away what virtual
|
|
threads bought. Ten thousand virtual threads may exist while a hundred hold permits.
|
|
|
|
**The downstream budgets are carried in the profile and stated as unchanged.** The whole point is
|
|
that they did not grow. `admissionFitsDownstreamBudgets()` reports when the admission limit exceeds
|
|
them, without refusing — a deployment can legitimately admit more than its pool when the work is not
|
|
all database-bound, and that should be a choice rather than an accident.
|
|
|
|
**Blocking offloads are registered, bounded and timed out.** `boundedElastic()` is available from
|
|
anywhere and unbounded in practice, so a controller that calls it has silently opted the whole
|
|
application into an unbounded pool. `BlockingBridgeProfile` names the operations permitted to
|
|
offload; `BlockingBridgeBudget` bounds the concurrency and refuses a caller that cannot get a slot
|
|
in time, because otherwise a slow dependency's callers accumulate until the heap does and the fast
|
|
dependencies starve behind them.
|
|
|
|
**Pinning is observed, not assumed away.** `VirtualThreadProfile.requiredObservations()` lists what
|
|
has to be watched — `jdk.VirtualThreadPinned` above all. A synchronized block held across a blocking
|
|
call pins the carrier thread, the carrier pool is bounded by CPU count, and enough pinned carriers is
|
|
a deadlock a thread dump does not obviously show.
|
|
|
|
## Consequences
|
|
|
|
- Enabling virtual threads is a two-part change: the executor and the admission limit. The profile
|
|
will not let it be one.
|
|
- Refusals rise under load, and that is correct. A request refused in a millisecond is better for
|
|
the client than the same request accepted and timed out thirty seconds later behind a full pool.
|
|
An operator seeing 503s climb should read them as the limit working.
|
|
- `VirtualThreadAdmissionGuard.peakActive()` exists so a load test can assert the limit was applied.
|
|
It is invisible from throughput, which is why a load test that only measures throughput would pass
|
|
with the guard removed.
|
|
|
|
## Alternatives considered
|
|
|
|
- **Enable virtual threads and raise the downstream budgets to match.** Rejected: the budgets are
|
|
sized to what the dependencies can serve, not to what the web tier can accept.
|
|
- **Bound the virtual threads themselves with a fixed-size executor.** Rejected: that is a platform
|
|
thread pool with extra steps.
|
|
- **Let controllers call `boundedElastic()` directly.** Rejected: every such call site is invisible
|
|
until the pool is the thing consuming the heap.
|