blob: 39c507374cbcdff0a239c3f2e0b28e8d9b8a8e09 [file] [view]
<!--
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
# Virtual thread execution proposal
Design deliverable for [#6369](https://github.com/apache/shenyu/issues/6369).
Status: proposal; the configuration and Java types below do not exist yet.
Inventory: `2.7.2-SNAPSHOT` at `297ac6f84`, reviewed on 2026-10-21.
This proposal complements the [server JDK migration plan](server-jdk-upgrade.md).
It does not change [#7602's Java 17 / Boot 3 client migration target](../shenyu-register-center/CLIENT_MIGRATION.md)
or the separate legacy registration wire-compatibility obligation.
## Decision
Keep Reactor Netty and the reactive plugin chain. Introduce an opt-in executor
for identified blocking operations, with the same capacity, deadline,
cancellation and context rules for platform and virtual execution. Do not switch
all gateway work or every `boundedElastic()` caller to virtual threads.
Java 21 finalized virtual threads in [JEP 444](https://openjdk.org/jeps/444).
They help scale work that waits on blocking I/O; they do not make CPU work
faster. Use a thread per admitted task rather than pooling virtual threads.
The [JDK 21 guide](https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html)
also describes pinning, thread-local overhead and diagnostics. The choices below
are ShenYu design decisions; performance improvements remain to be measured.
## Current execution inventory
Paths are relative to the repository. These are application-owned execution
domains, not an inventory of every internal thread in third-party libraries.
| Source / domain | Current behavior | Proposed disposition |
| --- | --- | --- |
| [ShenyuWebHandler](../shenyu-web/src/main/java/org/apache/shenyu/web/handler/ShenyuWebHandler.java) and [ShenyuConfig.Scheduler](../shenyu-common/src/main/java/org/apache/shenyu/common/config/ShenyuConfig.java) | Scheduler defaults disabled; enabled `fixed` selects `newParallel`, other values select shared `boundedElastic`; scheduling uses `subscribeOn` | Preserve current chain scheduling. A whole-chain switch does not control later async callbacks or make CPU work suitable for virtual threads |
| [ShenyuThreadPoolConfiguration](../shenyu-web/src/main/java/org/apache/shenyu/web/configuration/ShenyuThreadPoolConfiguration.java), [ShenyuThreadPoolExecutor](../shenyu-common/src/main/java/org/apache/shenyu/common/concurrent/ShenyuThreadPoolExecutor.java) and `TaskQueue` | Shared platform executor with queue/memory controls and explicit rejection; optional queue sizing uses Byte Buddy instrumentation | Preserve concrete bean/API and memory protections. Add a separate execution service; do not replace this bean with an unrelated `ExecutorService` |
| [ApacheDubboProxyService](../shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-rpc/shenyu-plugin-dubbo/shenyu-plugin-apache-dubbo/src/main/java/org/apache/shenyu/plugin/apache/dubbo/proxy/ApacheDubboProxyService.java) | Deferred reference resolution/invocation on `boundedElastic`; explicitly snapshots/restores `RpcContext` attachments; response uses a future | First request-path candidate for the synchronous reference/invocation portion. Keep the asynchronous RPC future reactive and test cancellation separately |
| [HTTP sync service](../shenyu-sync-data-center/shenyu-sync-data-http/src/main/java/org/apache/shenyu/sync/data/http/HttpSyncDataService.java), `AccessTokenManager`, WebSocket `ShenyuWebsocketClient` | One long-polling worker per configured Admin; separate token-refresh scheduler and bounded reconnect executor | Possible later blocking long-poll task candidate. Preserve per-server sequencing, reconnect limits and refresh timers; keep a separate budget from request plugins |
| [ShenyuLoaderService](../shenyu-web/src/main/java/org/apache/shenyu/web/loader/ShenyuLoaderService.java) | Scheduled plugin-directory scanning | Keep periodic trigger on a platform scheduler; consider file/jar I/O offload only after measuring it |
| [AbstractLogCollector](../shenyu-plugin/shenyu-plugin-logging/shenyu-plugin-logging-common/src/main/java/org/apache/shenyu/plugin/logging/common/collector/AbstractLogCollector.java) and log consumers | Platform executor drains buffered logs with batch/capacity semantics; client shutdown owns additional lifecycle work | Later candidate for blocking sink sends, not a task per log. Preserve buffer bounds, batch order, drop policy and draining |
| [OrderlyExecutor](../shenyu-disruptor/src/main/java/org/apache/shenyu/disruptor/thread/OrderlyExecutor.java), `SingletonExecutor`, Disruptor factories | Hash-selected serial lanes and dedicated threads | Keep ordering. The field named `virtualExecutors` represents hash-ring lanes, not JDK virtual threads |
| [HierarchicalWheelTimer](../shenyu-common/src/main/java/org/apache/shenyu/common/timer/HierarchicalWheelTimer.java), memory calculator, registry watchers and heartbeat | Dedicated timer/periodic workers | Keep timer/ordering semantics. Offload selected blocking callbacks independently; do not create a virtual thread as a repeating timer |
| [ManagedRemoteMcpCatalog](../shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/remote/ManagedRemoteMcpCatalog.java) | Dedicated `newBoundedElastic(1, 16, ...)`, version checks and serialized reload lifecycle; bounded blocking during startup/close | Preserve single-writer reload ordering and lifecycle timeouts; changing its executor requires separate evidence |
| Cache plugins and AI sensitive-word plugin | Memory-cache operations/cache-body handling and automaton work use `boundedElastic` or `publishOn` | Review operation type first. Memory access, parsing and automaton construction are not blocking-I/O candidates merely because they use this scheduler |
| WASM API/base plugins | Synchronous guest exports, instance-pool contention and host callbacks | CPU/guest-budget isolation is a separate problem. Virtual threads cannot stop an infinite loop or enforce guest memory/deadline budgets |
| Admin health checks, cluster election, HTTP/WebSocket publication, AI-key resolver and alert dispatch | Separate scheduled/worker pools, with DB/network work and task bounds | Separate Admin workstream; preserve transactions, publication ordering and existing limits before considering any migration |
Netty event loops, nonblocking HTTP/gRPC/WebSocket/AI streams, selector/rule
matching, hashing, serialization, compression, cryptography and WASM guest
computation remain on their existing or explicitly bounded CPU execution paths.
Retain protocol-client callback executors until their thread-affinity contract
has been reviewed.
## Execution abstraction
Add a main-owned `shenyu-executor` module with a Java 17-compatible, JDK-only API.
Keep Reactor adaptation in `shenyu-plugin-base` and wiring in the appropriate
starter, so plugins do not depend on `shenyu-web` and the dependency graph has
no runtime-to-plugin cycle. Proposed API shape:
```java
interface BlockingTaskExecutor {
<T> TaskHandle<T> submit(String domain, Callable<T> task, Duration timeout);
}
interface TaskHandle<T> {
CompletionStage<T> completion();
void cancel();
}
```
These names are illustrative API contracts, not implemented source. A domain
is a bounded registered value, such as `dubbo` or `http-sync`, never a
request ID, selector ID or user-controlled label. The implementation owns task
admission, queueing, timeout, context decoration and lifecycle. Plugins receive
the service by injection; they do not construct executors per request.
Provide a platform backend and a Java 21 capability adapter. While main artifacts
remain Java 17-compatible, the adapter may resolve stable public JDK APIs
reflectively only after checking runtime capability: `Thread.ofVirtual()`,
the public `Thread.Builder` interfaces for naming/factory settings, and
`Executors.newThreadPerTaskExecutor(ThreadFactory)`. Cache resolved handles;
do not reflect on a non-public builder implementation. Keep Java 21 types out
of signatures, constant-pool class references and eagerly initialized classes
loaded on Java 17. Verify this with Java 17 startup/linkage tests.
This allows opt-in Java 21 runtime testing without raising every artifact's
compiler baseline or distributing mixed-baseline providers through automatic
service loading. After the separately announced server baseline becomes 21,
a direct-API adapter can replace the reflective adapter in its own change.
Java 21-compiled server artifacts themselves cannot fall back to a Java 17 JVM.
The Reactor adapter must submit lazily on subscription and connect cancellation
to `TaskHandle.cancel()`. Never call `Future.get()`, `join()`, semaphore
`acquire()` or a blocking queue `put()` on a Netty event loop. Adapt an
asynchronous result without blocking a virtual worker for the entire response
stream. Preserve downstream demand and buffer ownership. Do not silently change
the public `ShenyuPlugin.execute/before/after` contracts.
## Proposed configuration and fallback
The following is a future configuration contract, not a runnable setting in the
current release. Initial defaults are deliberately explicit and must be tested
before implementation is enabled:
```yaml
shenyu:
execution:
blocking:
mode: platform # platform | virtual
fallback: platform # platform | fail
plugins: [] # empty means no request plugin is migrated
max-concurrency: 64
max-pending-tasks: 128
task-timeout: 3s
shutdown-grace-period: 30s
```
Capacity/deadline values apply to the new service's first request domain and
are proposed defaults, not replacements for current shared-pool settings.
Validate positive concurrency/timeouts, nonnegative pending capacity and known
plugin/domain names. `max-pending-tasks=0` means immediate rejection when all
execution slots are occupied. Add independently bounded configuration for each
later control-plane domain; one global queue must not let slow RPC calls starve
config sync or heartbeat.
| Condition | Required behavior |
| --- | --- |
| Default mode or plugin absent from the allowlist | Existing execution path remains unchanged; the new service's opt-in adapters are not invoked |
| Virtual requested, Java 21 capability available | Use named per-task virtual threads behind admission limits; log the resolved mode once at startup |
| Virtual unavailable and `fallback=platform` | Warn once with a reason and use the bounded platform backend with identical task semantics |
| Virtual unavailable and `fallback=fail` | Fail configuration/startup before accepting traffic |
| Invalid configuration | Fail binding/startup; do not conceal it as a capability fallback |
| Runtime saturation, task error or timeout | Apply the task's normal rejection/error semantics. Never retry the same call on another backend as a fallback |
Mode is resolved at startup. Rollback changes configuration and restarts/drains
the instance; live backend replacement is out of scope. Keep
`shenyu.scheduler.*` and `shenyu.shared-pool.*` unchanged. Do not enable
`spring.threads.virtual.enabled` as a substitute for controlling plugin work.
Reactor 3.6 exposes a [global virtual bounded-elastic option](https://projectreactor.io/docs/core/3.6.7/api/reactor/core/scheduler/Schedulers.html)
on Java 21. That is an alternative experiment, not this rollout mechanism:
the global switch affects multiple shared callers and provides no per-plugin
admission contract. Do not set it globally in the default distribution. Record
the resolved Reactor version and property values in every experiment.
## Admission, timeout and cancellation semantics
Bound active physical tasks to `max-concurrency` and queued task records to
`max-pending-tasks`. Include task context/retained payload in memory accounting;
do not remove existing memory protections or accept arbitrarily large captured
request bodies. Both backends use the same admission state machine:
1. On subscription, capture the allowed request context and a monotonic deadline.
Admit immediately into an execution slot or a bounded FIFO pending queue.
If neither has capacity, reject immediately. No `CallerRunsPolicy`.
2. Before starting queued work, check cancellation/deadline again. Account for
queue wait in the task timeout and discard expired/cancelled queued work.
A single platform timer schedules deadlines; its thread never runs user work.
3. Run an admitted task once. Complete its result with the original value/error,
unless its downstream result has already timed out or been cancelled.
4. For an active cancellation/timeout, cancel the underlying future with
interruption and invoke transport cancellation where supported. A terminal
downstream result does not prove that the blocking call physically stopped.
5. Release the active permit only in the running task's actual `finally`.
Pending cancellation removes its queue record once. Handle cancellation
racing with submission/start without a leaked or double-released permit.
Do not rely on `Future.isDone()` after cancellation as proof of task exit.
For a task ignoring interruption, retain its capacity charge until it exits,
record it as still running after cancellation, and reject excess work within
the configured limits. No unsafe thread stop and no replacement task beyond the
budget. This containment does not solve WASM execution deadlines or native
calls that never return.
For opted-in request plugins, map capacity rejection to the gateway's 503 error
path and deadline expiration to its 504 path, preserving tracing/error format.
If headers/body have already been committed, cancel/terminate through the
existing response error handling instead of writing a second response.
Subscriber cancellation writes no response. Preserve plugin/business errors
and existing retry policy; the executor introduces no automatic retries or
duplicate side effects.
On shutdown, stop admission, cancel queued work and allow active work up to the
configured grace period, then request interruption/cancellation and report
remaining tasks. Do not wait forever on `ExecutorService.close()` from an event
loop. Integrate with Spring lifecycle; thread daemon/liveness behavior must not
replace lifecycle ownership.
## Context, pinning and observability
Capture Reactor context and the intended tracing/security/MDC fields at the
subscription boundary. Install only those fields in a worker and restore/clear
them in `finally`; do not blindly inherit thread-local state. Preserve the
Dubbo attachment snapshot and cleanup around invocation/future lookup. Test two
overlapping requests with distinct identities and traces, plus failure,
cancellation and a reused platform worker. Spring transaction context is
thread-bound; moving Admin work across threads requires an explicit transaction
boundary and is outside the first gateway change.
For Java 21, inspect blocking while holding monitors and native/foreign calls
with JFR, rather than assuming every `synchronized` statement causes a problem.
The [JDK guide's diagnostics](https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html)
cover `jdk.VirtualThreadPinned` and thread dumps.
[JDK 24's release notes](https://www.oracle.com/java/technologies/javase/24-relnote-issues.html)
describe JEP 491's monitor-pinning improvement; do not assume this improvement
exists on Java 21 or that it removes every native-call limitation.
Name workers `shenyu-blocking-<domain>-<sequence>`. Expose requested/resolved
mode, runtime capability and fallback reason in startup logs and diagnostics.
Use bounded domain/mode/outcome labels, never request IDs or credentials.
Measure submitted, active, pending, completed, rejected, timed-out and cancelled
tasks; actual running-after-cancellation count; queue wait and execution
duration; and shutdown drain duration. Preserve trace parentage across the
handoff, with queue wait and execution observable separately. Use JFR to inspect
carrier/platform thread counts, pinning, allocation and parked tasks; OS thread
counts alone do not measure virtual-thread concurrency.
## Benchmark and verification gates
All rows below are planned experiments. No throughput, latency or memory
improvement is claimed by this document.
Compare (A) unchanged Java 17/platform, (B) unchanged Java 21/platform,
(C) Java 21/new service/platform and (D) Java 21/new service/virtual.
C versus D isolates backend effects from a JDK upgrade or a new admission policy.
Use equal active/pending limits and downstream connection pools for C/D;
report baseline differences for A/B rather than masking them.
| Scenario | Workload / assertions |
| --- | --- |
| Blocking RPC adapter | Disposable provider with fixed 10/100/1000 ms delays; concurrency 1/64/256/1024; verify response values, attachments, trace IDs and provider invocation count |
| Nonblocking HTTP control | Same payloads/rates through normal HTTP routing, including SSE and a slow consumer; verify the untouched path does not gain queueing or buffers |
| Overload and cancellation | Downstream stalls, refused connection and interruption-ignoring test task; cancel while queued/running; verify bounded active/pending memory and no duplicate invocation |
| CPU control | Rule matching/serialization or a bounded CPU fixture; confirm it remains outside the virtual executor |
| Config/order and shutdown | Apply ordered updates during traffic, restart/drain, and exercise platform fallback; verify route state, no lost/reordered callbacks and no leaked owned executors |
Use an external fixed-rate load generator, a controlled backend and fresh
processes with equal heap/CPU/container limits. Record source SHA, JDK/vendor,
OS/architecture, flags, config, payload, provider/connection limits and actual
offered/achieved load. Warm up for 2 minutes, measure for 5, repeat at least
5 times, and report all runs plus median/spread. Count rejects/timeouts/errors
in achieved-load and latency reports so fast rejection cannot look like success.
Collect p50/p95/p99 end-to-end latency, throughput, CPU, RSS/heap, allocation/GC,
active/pending tasks, platform/carrier counts and pinned duration/stacks.
Proposed promotion gates, agreed before measuring:
- Correctness: zero duplicate invocations/context leakage, bounded buffers and
exact permit cleanup across success/failure/queue cancellation/start races.
Reactive cancellation and result propagation pass deterministic tests.
- Overload: active and pending counts never exceed limits, including calls that
ignore interruption; drain completes within grace or reports retained tasks.
- Nonblocking control: no more than 5% p99 or throughput regression versus B
under matched load; investigate regressions outside run-to-run variability.
- Blocking benefit: versus C, demonstrate at least 15% throughput improvement
at the same p99 budget, or 15% lower RSS at the same successful throughput
with no more than 5% p99 regression. Publish confidence/spread and resource
limits; do not promote from a single favorable run.
## Implementation sequence and rollback
1. Add the service API, bounded platform backend, context/cancellation adapter,
metrics and race tests without migrating callers. Java 17/21 startup tests
and capability/fallback tests are required.
2. Add the Java 21 capability adapter and opt-in Dubbo synchronous-call adapter.
Keep RPC-future handling reactive and preserve attachment cleanup. Test the
existing plugin suite on both backends plus a real disposable provider.
3. Publish C/D measurements and a canary recipe; enable only an explicit
`plugins: [dubbo]` configuration after the correctness gates pass.
4. Review long-polling, log sinks and Admin candidates separately. Require
ordering, independent budgets, lifecycle and integration evidence for each.
Set `mode: platform` to retain the new service's admission/deadline contract
while changing backend; remove the plugin from the allowlist to restore its
original execution path. Restart and drain rather than replaying active calls.
The server JDK rollback rules remain those in the JDK proposal. No registration
payload, split-client dependency or client/foundation Java 17 floor changes are needed.
Runtime maintainers own admission/lifecycle and the Reactor adapter; plugin
maintainers own invocation/cancellation/context semantics; build/release
maintainers own the JDK capability matrix; observability maintainers review
metrics/tracing cardinality. Review this proposal and benchmark thresholds
before enabling a default. Structured concurrency and preview APIs are outside
this design.