This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch docs
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/docs by this push:
new a933cf6f98 docs(9.5.0): add Async Returns + Virtual-Thread Dispatch
topic (TODO-70); release notes for TODO-70, TODO-110, TODO-111
a933cf6f98 is described below
commit a933cf6f985d5d8eda2379ce1041226ad09993a5
Author: James Bognar <[email protected]>
AuthorDate: Tue May 26 21:10:25 2026 -0400
docs(9.5.0): add Async Returns + Virtual-Thread Dispatch topic (TODO-70);
release notes for TODO-70, TODO-110, TODO-111
- New topic page pages/topics/10.20h.RestServerAsyncDispatch.md
(CompletableFuture/CompletionStage returns, asyncTimeoutMillis, virtual-thread
dispatch, when-to-use guidance, thread-local caveats, observability
composition).
- New sidebar entry "10.20h. Async Returns + Virtual-Thread Dispatch"
slotted after 10.20g Observability.
- 9.5.0 release notes: TODO-70 feature entry under juneau-rest-server;
Security entry for Thymeleaf 3.1.3 -> 3.1.5 (TODO-110, CVE-2026-40477 / 40478 /
41901); Security entry for OpenTelemetry 1.43.0 -> 1.62.0 (TODO-111,
CVE-2026-45292).
Co-authored-by: Cursor <[email protected]>
---
pages/release-notes/9.5.0.md | 52 +++++-
pages/topics/10.20h.RestServerAsyncDispatch.md | 212 +++++++++++++++++++++++++
sidebars.ts | 5 +
3 files changed, 267 insertions(+), 2 deletions(-)
diff --git a/pages/release-notes/9.5.0.md b/pages/release-notes/9.5.0.md
index 5e32179106..7e00d28e20 100644
--- a/pages/release-notes/9.5.0.md
+++ b/pages/release-notes/9.5.0.md
@@ -2329,6 +2329,46 @@ public class MyResource extends BasicRestServlet {
See [REST Server — Observability (Micrometer +
OpenTelemetry)](/docs/topics/RestServerObservability) for the full topic.
+#### Async returns + virtual-thread dispatch (TODO-70)
+
+`@RestOp` handler methods may now return `CompletableFuture<T>` (or any
`CompletionStage<T>`); the framework unwraps the value through the standard
response-processor chain when the future resolves. A separate, opt-in
`@Rest(virtualThreads=true)` flag dispatches handler invocation onto Java 21+
virtual threads with graceful degradation on Java 17. The two features compose:
a `CompletableFuture` return on a `virtualThreads=true` resource gives you the
high-throughput "no-thread-blocked" p [...]
+
+##### `CompletableFuture<T>` / `CompletionStage<T>` return support
+
+- New `org.apache.juneau.rest.processor.AsyncResponseProcessor` slotted ahead
of `SerializedPojoProcessor` in the default `ResponseProcessorList`. Detects
`CompletionStage<T>` return values, calls `req.startAsync()` to obtain an
`AsyncContext`, and registers a `whenComplete((value, error) -> ...)` callback
that re-feeds the unwrapped value (or throwable) through the rest of the
response-processor chain when the future resolves.
+- **Failed-future propagation**: the throwable routes through the existing
exception pipeline (`ThrowableProcessor` / `ProblemDetailsProcessor`); RFC 7807
/ 9457 problem-details rendering works unchanged for failed futures when
`@Rest(problemDetails="true")` is set.
+- **Bare `Future<T>` is rejected** with HTTP 500 and a clear message: *"Bare
java.util.concurrent.Future is not supported as a return type from @RestOp
methods. Return CompletableFuture or CompletionStage instead, or block on the
result yourself before returning."* (Resolved OQA #4 — silently blocking on
`Future.get()` would be a footgun.)
+- **Synchronous-fallback path** for unit-test mock environments: when the
underlying servlet container does not support async dispatch (e.g.
`MockRestRequest`), the processor falls back to a bounded
`CompletableFuture.get(timeout)` and re-feeds the unwrapped value through the
chain on the calling thread. Production servlet containers (Jetty, Tomcat,
Undertow) take the true async path.
+- **`AsyncContext.complete()` runs exactly once** behind an `AtomicBoolean`
state machine, even when both the timeout listener and the future-resolution
callback fire concurrently.
+
+##### Async timeout
+
+- New `@Rest(asyncTimeoutMillis)` and `@RestOp(asyncTimeoutMillis)`
attributes. Default is **30 seconds** (resolved OQA #2 — matches typical proxy
timeouts). On timeout, the response is `504 Gateway Timeout` and the
`AsyncContext` is aborted.
+- `0` disables the timeout for that scope. Negative values are treated as "use
the default 30s". SVL is supported (`asyncTimeoutMillis =
"${TIMEOUT_MS:30000}"`).
+
+##### Virtual-thread per-request dispatch (Java 21+, opt-in, off by default)
+
+**Same non-negotiable off-by-default contract as Bean Validation (TODO-68) and
Observability (TODO-67).** A fresh `RestContext` built with no `virtualThreads`
attribute set never instantiates a virtual-thread executor, even on Java 21+.
+
+- New `@Rest(virtualThreads)` and `@RestOp(virtualThreads)` attributes —
`"true"` opts in for every op on the resource (or just the annotated op);
`"false"` at op level opts out of a resource-level enablement; `""` (default)
inherits.
+- When enabled (and the runtime is Java 21+), `RestOpInvoker` dispatches the
entire handler call — parameter binding, the user method body, and the standard
exception-mapping path — onto a virtual thread submitted to a per-resource
`Executors.newVirtualThreadPerTaskExecutor()`. The platform "carrier" thread
that received the request is parked on `CompletableFuture.get()` until the
virtual thread finishes.
+- **Java 17 graceful degradation**: on Java 17 / 18 / 19 / 20, the flag is
detected at first use, a one-shot `WARNING` is logged via
`Logger.getLogger("org.apache.juneau.rest.RestContext.async")`, and the
framework falls back to caller-thread dispatch. No runtime error.
+- **Reflective executor creation**:
`Executors.class.getMethod("newVirtualThreadPerTaskExecutor").invoke(null)` is
gated behind `Runtime.version().feature() >= 21`, so the framework compiles
cleanly under the project's Java 17 source level (no `<source>21</source>` bump
needed).
+
+##### Composition
+
+The two features are independent but compose naturally — a `CompletableFuture`
return on a `virtualThreads=true` resource gives both structured-concurrency
benefits AND a non-blocking response path. **Virtual-thread dispatch sits ABOVE
the observability hooks**: when `virtualThreads=true`, the entire `invoke(...)`
body — including `TracerHook.startSpan(...)` and `MetricsRecorder.record(...)`
— runs on the virtual thread, so OTel's `Context.makeCurrent()` works as
expected. **Async return [...]
+
+##### Migration / backward compatibility
+
+Synchronous handlers (no `CompletableFuture`, no `virtualThreads=true`) have
**zero behavioral change**. Async opt-in is purely by changing the return type;
virtual-thread opt-in is purely by setting the attribute.
+
+##### Thread-local caveats
+
+`RequestAttributes`, `VarResolverSession`, `Locale`, and the
`@Rest(debug=...)` `DebugConfig` are request-scoped, not `ThreadLocal`-backed —
they survive the async hop. **MDC and security contexts** that rely on
`ThreadLocal` (SLF4J MDC, classic `SecurityContextHolder`-style patterns) **do
not** survive the async hop. If you need MDC across an async boundary, copy the
relevant keys into `RequestAttributes` before returning the future.
+
+See [REST Server — Async Returns + Virtual-Thread
Dispatch](/docs/topics/RestServerAsyncDispatch) for the full reference.
+
#### Health Probe SPI + Resource (TODO-65)
`juneau-rest-server` now includes a built-in probe SPI and aggregation
resource under
@@ -3964,12 +4004,16 @@ Incoming `traceparent` / `tracestate` request headers
are extracted via the conf
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
- <version>1.43.0</version> <!-- consumer-supplied; provided scope
on juneau-rest-server-otel -->
+ <version>1.62.0</version> <!-- consumer-supplied; provided scope
on juneau-rest-server-otel -->
</dependency>
```
See [REST Server — Observability (Micrometer +
OpenTelemetry)](/docs/topics/RestServerObservability) for the full topic.
+#### Security
+
+- Bumped `io.opentelemetry:opentelemetry-api` from `1.43.0` to `1.62.0`
(TODO-111). Closes Dependabot alerts #28 and #29 (CVE-2026-45292 /
GHSA-rcgg-9c38-7xpx — "OpenTelemetry Java SDK has Unbounded Memory Allocation
in W3C Baggage Propagation"; affected range `<= 1.61.0`). The bridge's API
surface (`Tracer` / `Span` / `Context` / `Scope` / `TextMapGetter`) is stable
across the 1.x line; no consumer-visible behavior change.
+
### juneau-rest-server-view-jsp (new module)
A new opt-in REST module, `juneau-rest-server-view-jsp`, adds JSP
view-rendering to `juneau-rest-server` without bleeding the JSP-engine
dependency (Apache Jasper) into the core. The new `View` interface (see
[juneau-rest-server](#juneau-rest-server)) lives in core; this module ships the
JSP-specific implementation. Engine-agnostic POM stance: the bridge module
declares the JSP API + JSTL impl in `provided` scope only — consumers add the
engine matching their container (Jetty 12 EE11's ` [...]
@@ -4054,7 +4098,7 @@ Unlike JSP, Thymeleaf's core engine has zero
servlet-container dependencies —
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
- <version>3.1.3.RELEASE</version> <!-- Juneau microservice / Jetty -->
+ <version>3.1.5.RELEASE</version> <!-- Juneau microservice / Jetty -->
</dependency>
```
@@ -4080,6 +4124,10 @@ public class AppResource extends RestServlet {
See [Thymeleaf View Support](/docs/topics/ThymeleafViewSupport) for the full
topic — engine-selection matrix, Spring Boot integration notes, path-traversal
hardening, and known limitations.
+#### Security
+
+- Bumped `org.thymeleaf:thymeleaf` from `3.1.3.RELEASE` to `3.1.5.RELEASE`
(TODO-110). Closes Dependabot alerts #24, #25, #26, and #27 — CVE-2026-40477 /
GHSA-r4v4-5mwr-2fwr (improper restriction of accessible-object scope),
CVE-2026-40478 / GHSA-xjw8-8c5c-9r79 (unauthorized expression syntax), and
CVE-2026-41901 / GHSA-c9ph-gxww-7744 (sandboxed-expression syntax-recognition
bypass). All four are sandbox-scope vulnerabilities; the Juneau bridge does not
enable sandboxed expressions expli [...]
+
### juneau-rest-server-view-mustache (new module)
A new opt-in REST module, `juneau-rest-server-view-mustache`, adds
[Mustache](https://mustache.github.io/) view-rendering to `juneau-rest-server`
— sibling to `juneau-rest-server-view-jsp` and
`juneau-rest-server-view-thymeleaf`, but for Mustache's intentionally
logic-less template syntax that's portable across JavaScript, Go, Python, and
Ruby front ends. The same `View` interface (see
[juneau-rest-server](#juneau-rest-server)) shipped with 9.5.0 backs all three
bridges. Engine-agnostic [...]
diff --git a/pages/topics/10.20h.RestServerAsyncDispatch.md
b/pages/topics/10.20h.RestServerAsyncDispatch.md
new file mode 100644
index 0000000000..10f0e8bf61
--- /dev/null
+++ b/pages/topics/10.20h.RestServerAsyncDispatch.md
@@ -0,0 +1,212 @@
+---
+title: "Async Returns + Virtual-Thread Dispatch"
+slug: RestServerAsyncDispatch
+---
+
+`@RestOp` handler methods may return `CompletableFuture<T>` (or any
`CompletionStage<T>`); the framework unwraps the value through the standard
response-processor chain when the future resolves. A separate, opt-in
`@Rest(virtualThreads=true)` flag dispatches handler invocation onto Java 21+
virtual threads with graceful degradation on Java 17.
+
+The two features compose: a `CompletableFuture` return on a
`virtualThreads=true` resource gives you the high-throughput
"no-thread-blocked" pattern without changing the rest of the request pipeline.
+
+## Async returns (on by default — return-type opt-in)
+
+Returning a `CompletableFuture<T>` or `CompletionStage<T>` is recognized by a
new `AsyncResponseProcessor` slotted ahead of `SerializedPojoProcessor` in the
default processor chain. Synchronous handlers — methods returning `String`,
`MyDto`, `byte[]`, `Reader`, `Throwable`, etc. — keep their existing path with
zero behavioral change.
+
+```java
+@Rest(path="/orders")
+public class OrderResource extends BasicRestServlet {
+
+ @Inject OrderService orders;
+
+ @RestGet("/{id}")
+ public CompletableFuture<Order> get(@Path long id) {
+ return orders.lookupAsync(id); // resource thread is
not blocked
+ }
+
+ @RestGet("/{id}/timeline")
+ public CompletionStage<Timeline> timeline(@Path long id) {
+ return orders.timelineAsync(id); // CompletionStage works
too
+ }
+}
+```
+
+### Resolution mechanics
+
+1. The handler returns a `CompletionStage<T>` (any concrete subclass —
`CompletableFuture<T>` is the common one).
+2. `AsyncResponseProcessor` calls `req.startAsync()` to obtain an
`AsyncContext`. If the underlying servlet container does not support async
dispatch (e.g. unit-test mock environments), the processor falls back to a
bounded `CompletableFuture.get(timeout)` and re-feeds the unwrapped value
through the rest of the chain on the calling thread.
+3. A `whenComplete((value, error) -> ...)` callback on the future:
+ - **Success** — sets the unwrapped value back onto the `RestResponse` and
re-invokes the response-processor chain (skipping `AsyncResponseProcessor`
itself to avoid recursion). The standard `SerializedPojoProcessor` then handles
serialization just like a synchronous return.
+ - **Failure** — routes the throwable through the existing exception
pipeline (`ThrowableProcessor` / `ProblemDetailsProcessor`). RFC 7807 / 9457
problem-details rendering works unchanged for failed futures when
`@Rest(problemDetails="true")` is set.
+4. `AsyncContext.complete()` runs exactly once (state-machined behind an
`AtomicBoolean`).
+
+### Bare `Future<T>` is rejected
+
+Bare `java.util.concurrent.Future<T>` (i.e. NOT `CompletableFuture` or
`CompletionStage`) is explicitly rejected with HTTP 500 and a clear message:
+
+> Bare `java.util.concurrent.Future` is not supported as a return type from
`@RestOp` methods. Return `CompletableFuture` or `CompletionStage` instead, or
block on the result yourself before returning.
+
+The plan's resolved OQA #4 weighed honoring it via a blocking
`submit(future::get)` against rejecting it; rejection won because callers who
want async should already be reaching for `CompletableFuture`, and silently
blocking a request thread on a `Future.get()` is a footgun.
+
+### Timeout
+
+The default async-completion timeout is **30 seconds** — chosen to match
typical proxy timeouts (resolved OQA #2). On timeout, the response is `504
Gateway Timeout` and the `AsyncContext` is aborted.
+
+Override per-resource or per-op:
+
+```java
+@Rest(asyncTimeoutMillis = "60000") // 60s for every op on this
resource
+public class LongRunningResource extends BasicRestServlet {
+
+ @RestOp(method = "GET", path = "/quick", asyncTimeoutMillis = "5000")
+ public CompletableFuture<Quote> quick() { ... } // overrides
resource-level to 5s
+
+ @RestOp(method = "GET", path = "/poll", asyncTimeoutMillis = "0")
+ public CompletableFuture<Job> poll() { ... } // 0 disables the timeout
+}
+```
+
+The value is in milliseconds; `0` means no timeout (rely on the container's
default); negative values are treated as "use the default 30s". SVL is
supported (`asyncTimeoutMillis = "${TIMEOUT_MS:30000}"`).
+
+### Cancellation
+
+Cancellation is best-effort — when the servlet container reports a connection
drop via `AsyncListener.onError` or `onTimeout`, the underlying
`CompletableFuture` is cancelled with `mayInterruptIfRunning=true`. The
framework does not attempt to cancel the future on every client disconnect
(containers vary in how reliably they surface that signal).
+
+### Thread-local caveats
+
+`RequestAttributes`, `VarResolverSession`, `Locale`, and the
`@Rest(debug=...)` `DebugConfig` are all request-scoped (carried on
`RestSession` / `RestOpSession`), not `ThreadLocal`-backed — they survive the
async hop. **MDC and security contexts** that rely on `ThreadLocal` (SLF4J MDC,
classic `SecurityContextHolder`-style patterns, plain
`ThreadLocal<UserPrincipal>` patterns) **do not** survive the async hop. If you
need MDC across an async boundary, copy the relevant keys into `Request [...]
+
+### When to return CompletableFuture vs. block in the handler
+
+Return `CompletableFuture` / `CompletionStage` when the work you're doing
**already produces one natively** — Java 11+ `HttpClient.sendAsync(...)`,
Project Reactor's `Mono.toFuture()`, the reactive Mongo / Cassandra drivers,
R2DBC, or `CompletableFuture.supplyAsync(...)` against an explicit
application-owned `Executor`. The request thread is freed immediately and the
response is written on whatever thread completes the future.
+
+Block in the handler when the work is **synchronous-only** (JDBC, classic
Apache HttpClient blocking API, file I/O, JMS) AND `virtualThreads=true` is NOT
enabled. Wrapping a blocking call in `CompletableFuture.supplyAsync(...)`
against the common fork-join pool is an anti-pattern — you've moved the block
from one platform-thread pool to another and added scheduling overhead.
+
+The two patterns compose with virtual-thread dispatch: with
`virtualThreads=true` you can return a `CompletableFuture` AND block inside the
handler safely, because the handler is running on a virtual thread and the
future just defers the response write. The decision is **per-op**, not
service-wide — mix freely.
+
+```java
+@RestGet("/external/{id}")
+public CompletableFuture<Quote> external(@Path long id) {
+ return http.sendAsync(req(id), ofString()) // already async —
return the future
+ .thenApply(r -> Quote.parse(r.body()));
+}
+
+@RestGet("/db/{id}")
+public Order db(@Path long id) throws SQLException {
+ return jdbcRepo.lookup(id); // JDBC is blocking —
just block;
+} // pair with
virtualThreads=true if hot
+```
+
+## Virtual-thread dispatch (Java 21+, opt-in, off by default)
+
+Virtual-thread dispatch is **opt-in and disabled by default** — the same
non-negotiable contract used for [Jakarta Bean
Validation](/docs/topics/RestServerValidation) and
[Observability](/docs/topics/RestServerObservability). A fresh `RestContext`
built with no `virtualThreads` attribute set never instantiates a
virtual-thread executor, even on Java 21+. Opt in per-resource or per-op:
+
+```java
+@Rest(path="/orders", virtualThreads = "true") // every op on this
resource
+public class OrderResource extends BasicRestServlet { ... }
+
+@Rest(path="/mixed")
+public class MixedResource extends BasicRestServlet {
+ @RestOp(method = "GET", path = "/heavy", virtualThreads = "true") //
just this op
+ public Report heavy() { ... }
+}
+```
+
+When enabled (and the runtime is Java 21+), `RestOpInvoker` dispatches the
entire handler call — parameter binding, the user method body, and the standard
exception-mapping path — onto a virtual thread submitted to a per-resource
`Executors.newVirtualThreadPerTaskExecutor()`. The platform "carrier" thread
that received the request is parked on `CompletableFuture.get()` until the
virtual thread finishes, then resumes to write the response.
+
+### Java 17 graceful degradation
+
+The Juneau runtime floor is Java 17. On Java 17 / 18 / 19 / 20, the
`virtualThreads=true` flag is detected at first use, a one-shot `WARNING` is
logged via `Logger.getLogger("org.apache.juneau.rest.RestContext.async")`, and
the framework falls back to caller-thread dispatch — exactly the existing
pre-9.5.0 behavior. No runtime error.
+
+The implementation uses **runtime reflection** for the executor creation:
+
+```java
+if (Runtime.version().feature() < 21) {
+ log.warning("virtualThreads=true configured but runtime is Java <21 —
falling back to caller-thread dispatch.");
+ return null;
+}
+return (Executor)
Executors.class.getMethod("newVirtualThreadPerTaskExecutor").invoke(null);
+```
+
+This means the framework compiles cleanly on Java 17 (no `<source>21</source>`
needed) and the virtual-thread path simply does nothing on older JVMs. Tests
are guarded with `@EnabledForJreRange(min = JAVA_21)` for the
virtual-thread-active path and `@DisabledForJreRange(min = JAVA_21)` for the
warning-and-fallback path.
+
+### Composing with async returns
+
+The two features are independent but compose naturally — a `CompletableFuture`
return on a `virtualThreads=true` resource gives you both the
structured-concurrency benefits of virtual threads AND a non-blocking response
path:
+
+```java
+@Rest(path="/users", virtualThreads = "true")
+public class UserResource extends BasicRestServlet {
+
+ @Inject UserRepo repo;
+
+ @RestGet("/{id}")
+ public CompletableFuture<User> get(@Path long id) {
+ return repo.findAsync(id); // VT runs the
handler;
+ } // future
resumes the response on whatever
+ // thread
completes it.
+}
+```
+
+### Virtual-thread pinning caveat
+
+`synchronized` blocks and JNI calls **pin** a virtual thread to its underlying
carrier thread for their duration, which defeats the point of going virtual in
the first place. If you opt in to `virtualThreads=true`, prefer
`java.util.concurrent.locks.ReentrantLock` over `synchronized` in handler-side
code, especially around shared resources held during blocking I/O.
+
+### When to enable virtual-thread dispatch
+
+The headline win is **scaling I/O-bound endpoints without thread-pool
exhaustion** — anything where each request spends most of its wall-clock time
blocked on someone else's I/O.
+
+| Scenario | Why VTs help |
+|---|---|
+| **JDBC / JPA-backed CRUD** | JDBC is blocking by spec. A platform-thread
Tomcat caps at ~200 in-flight requests; with VTs the same JVM serves thousands
of concurrent DB-bound requests because blocked VTs cost ~KB of heap each, not
~1 MB of OS stack. |
+| **Fan-out aggregator endpoints** (`GET /dashboard` calling 5 downstream
services) | Write straight-line synchronous code with a blocking HTTP client
and still get massive concurrency. No `CompletableFuture.allOf(...)`
choreography required if you don't want it. |
+| **Webhook receivers / spiky workloads** | Stripe / GitHub / Slack can hammer
the service with 10k concurrent POSTs during an event. VTs absorb the spike
without queue rejection. |
+| **Long-polling / SSE / WebSocket-upgrade handshakes** | Connections that
block for many seconds. With VTs, holding 100k open is feasible on a single
JVM. |
+| **Slow third-party API integrations** (payment / identity / geocoding) |
When an upstream brownout pushes p99 to 8 seconds, platform threads exhaust the
pool and unrelated endpoints start failing too. With VTs the blocked threads
are essentially free — degradation stays localized to the slow endpoint. |
+| **Mixed workloads on one resource** | Per-op
`@RestOp(virtualThreads="true")` opts in only the I/O-heavy ops (e.g., the
JDBC-backed search) while keeping cheap ops (admin / health / metrics) on
platform threads. |
+
+Think of `virtualThreads=true` as **"this endpoint is going to spend most of
its wall-clock time blocked on someone else's I/O, and I want each request to
be as cheap as a coroutine."** That's why the flag is opt-in per-resource and
per-op rather than a global default — the framework can't know whether your
handler is JDBC-blocked or CPU-bound, but you do.
+
+### When NOT to enable virtual-thread dispatch
+
+- **CPU-bound endpoints** — image resizing, crypto, big serialization. You're
still bounded by cores; VTs just add scheduler overhead.
+- **Already non-blocking code** — if the handler returns `CompletableFuture`
and calls `HttpClient.sendAsync(...)`, the platform thread is freed at await
time anyway. VTs are redundant; the async-returns half of this feature is
enough on its own.
+- **Hot paths with heavy `synchronized`** — on Java 21, `synchronized` blocks
pin the carrier thread (see [Virtual-thread pinning
caveat](#virtual-thread-pinning-caveat)). If a hot path has contended
`synchronized` around expensive work, throughput can actually drop. Pinning is
fully fixed in JDK 24 / JEP 491.
+- **Frameworks that hard-bind state to `ThreadLocal`** with assumptions of
bounded thread count — legacy MDC / session code can balloon memory under
millions of virtual threads.
+
+When in doubt, leave it off. The default is correct for CPU-bound services and
for services that already use a non-blocking client end-to-end.
+
+## Observability composition (TODO-67)
+
+The TODO-67 `MetricsRecorder` / `TracerHook` boundary is preserved across both
async returns and virtual-thread dispatch — see
[Observability](/docs/topics/RestServerObservability):
+
+- **Virtual-thread dispatch sits ABOVE the observability hooks.** When
`virtualThreads=true`, the entire `invoke(...)` body — including
`tracerHook.startSpan(...)` and `metricsRecorder.record(...)` — runs on the
virtual thread. OpenTelemetry's `Context.makeCurrent()` works as expected
because the OTel `Scope` is opened and closed on the same (virtual) thread.
+- **Async returns defer observability completion to future-resolution time.**
When the handler returns a `CompletionStage`, the framework attaches a
`whenComplete` callback that closes the OTel `Scope` and records the Micrometer
`Timer` sample at the moment the future resolves — not the synchronous "future
returned" moment. The metric / span describes the actual end-to-end latency
including the async wait, and the resolved HTTP status / exception is captured
correctly even when the futur [...]
+
+## Configuration summary
+
+| Setting | Type | Default | Effect |
+|---|---|---|---|
+| `@Rest(virtualThreads)` | `String` (`"true"` / `"false"` / `""`) | `""`
(off) | Enable virtual-thread dispatch for every op on the resource. |
+| `@RestOp(virtualThreads)` | `String` (`"true"` / `"false"` / `""`) | `""`
(inherit from `@Rest`) | Per-op override. `"false"` opts out of a
resource-level enablement. |
+| `@Rest(asyncTimeoutMillis)` | `String` (long, ms) | `""` (use 30000ms
default) | Async-completion timeout for every async-returning op on the
resource. |
+| `@RestOp(asyncTimeoutMillis)` | `String` (long, ms) | `""` (inherit from
`@Rest`) | Per-op timeout override. `"0"` disables the timeout for this op. |
+
+All four attributes accept SVL (`${...}`) so values can flow from system
properties, env vars, or `Config`.
+
+## Acceptance verification
+
+The contract is verified end-to-end in `juneau-utest`:
+
+| Behavior | Test |
+|---|---|
+| `CompletableFuture<String>` happy path |
`AsyncResponseProcessor_Test#a01_completableFutureString_happyPath` |
+| `CompletableFuture<MyDto>` serializes through the standard processor chain |
`AsyncResponseProcessor_Test#a04_completableFuturePojo_serializesBean` |
+| Failed future propagates through `ThrowableProcessor` (404, 500, etc.) |
`AsyncResponseProcessor_Test#b0*_*_completableFuture` |
+| Never-completing future → 504 Gateway Timeout |
`AsyncResponseProcessor_Test#c01_neverCompletingFuture_504OnTimeout` |
+| Per-op `asyncTimeoutMillis` overrides resource-level |
`AsyncResponseProcessor_Test#d01_perOpTimeoutOverridesResourceLevel` |
+| Bare `Future` rejected with InternalServerError |
`AsyncResponseProcessor_Test#e01_bareFutureRejected` |
+| `virtualThreads=true` on Java 21+ → handler runs on a virtual thread |
`VirtualThreadDispatch_Test#a01_java21Plus_virtualThreadDispatch` |
+| `virtualThreads=true` + `CompletableFuture` returns combine |
`VirtualThreadDispatch_Test#a02_java21Plus_virtualThreadDispatch_combinedWithCompletableFuture`
|
+| Java 17 graceful degradation — handler still works, warning emitted |
`VirtualThreadDispatch_Test#b01_degradation_handlerStillWorks` +
`b02_java17_logsWarningOnce` |
+| Per-op `virtualThreads="false"` overrides resource-level `"true"` |
`VirtualThreadDispatch_Test#c01_perOpOptOutOverridesResourceLevel` |
+| Per-op `virtualThreads="true"` works without resource-level enablement |
`VirtualThreadDispatch_Test#c02_perOpOptInWithoutResourceLevel` |
+| Default off-by-default — no annotation, no virtual-thread dispatch even on
Java 21+ | `VirtualThreadDispatch_Test#d01_offByDefault` |
diff --git a/sidebars.ts b/sidebars.ts
index 54a80ba6de..bd7c7423e9 100644
--- a/sidebars.ts
+++ b/sidebars.ts
@@ -1561,6 +1561,11 @@ const sidebars: SidebarsConfig = {
id:
'topics/10.20g.RestServerObservability',
label: '10.20g.
Observability — Micrometer + OpenTelemetry',
},
+ {
+ type: 'doc',
+ id:
'topics/10.20h.RestServerAsyncDispatch',
+ label: '10.20h. Async
Returns + Virtual-Thread Dispatch',
+ },
{
type: 'doc',
id:
'topics/10.21.BuiltInParameters',