Skip to content

FELIX-6859 allow a bounded VirtualThreadPool as the virtual threads executor - #560

Merged
paulrutter merged 1 commit into
masterfrom
FELIX-6859-bounded-virtual-thread-executor
Sep 4, 2026
Merged

FELIX-6859 allow a bounded VirtualThreadPool as the virtual threads executor#560
paulrutter merged 1 commit into
masterfrom
FELIX-6859-bounded-virtual-thread-executor

Conversation

@paulrutter

@paulrutter paulrutter commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes FELIX-6859.

Jetty 12 documents a preferred virtual threads setup: a QueuedThreadPool whose virtual threads executor is a bounded VirtualThreadPool. Felix HTTP could not produce it. The two virtual thread options were the extremes — an unbounded number of concurrent tasks, or a standalone VirtualThreadPool that creates only virtual threads. A deployment that wants platform threads for the acceptors and the selectors and a bound on concurrent request tasks had no way to say so.

Change

Adds org.apache.felix.http.jetty.virtualthreads.max to the jetty12 bundle. When virtual threads are enabled and this is set to a positive value, the thread pool becomes a QueuedThreadPool, sized by threadpool.max as usual, whose virtual threads executor is a VirtualThreadPool with setMaxConcurrentTasks(<value>).

Note that unlike threadpool.max, this property bounds the number of concurrent tasks, not the number of threads. That distinction is the reason the issue exists, and it is called out in both the README row and the metatype description.

virtualthreads.enable threadpool.max virtualthreads.max Thread pool
false unset - Jetty's default QueuedThreadPool, 200 platform threads
false set - QueuedThreadPool with <max> platform threads
true unset unset QueuedThreadPool + unbounded newVirtualThreadPerTaskExecutor()
true set unset standalone VirtualThreadPool, bounded by <max>
true any set QueuedThreadPool + VirtualThreadPool bounded by <value> (new)

Backwards compatibility

Fully backwards compatible.

  • The property is new in this PR and has never been released, so the name itself breaks nothing.
  • It defaults to -1, and only a positive value selects the new branch. Every existing combination reaches exactly the pool it reached before — including the fourth row, where threadpool.max keeps acting as a bound on concurrent tasks. That reuse is the very conflation this issue is about, but changing it would silently unbound existing deployments, so it stays, with a comment in the code saying so.
  • Non-positive values deliberately fall through rather than selecting the new branch, because Jetty itself treats maxConcurrentTasks <= 0 as unbounded (doStart() only creates the semaphore for a positive value). Selecting a "bounded" pool that isn't bounded would be worse than falling through.
  • org.apache.felix.http.jetty.internal is not an exported package, so renaming the constant and the getter is not an API break.
  • jetty12 only. The Jetty 11 bundle has no virtualthreads.enable at all.

Life cycle of the VirtualThreadPool

QueuedThreadPool.setVirtualThreadsExecutor(Executor) only stores the reference — it does not add the argument as a managed bean. VirtualThreadPool is a ContainerLifeCycle whose doStart() creates both the virtual executor and the bounding semaphore, and whose execute() throws RejectedExecutionException("not running") while unstarted. Hence the threadPool.addBean(virtualThreadPool) call.

Measured on Jetty 12.1.9, addBean covers teardown as well as startup:

after start() after stop()
with addBean qtp running, vtp running qtp stopped, vtp stopped
without addBean qtp running, vtp not running -

So no explicit teardown is needed: stopJetty() calls server.stop(), the QueuedThreadPool is a managed bean of the Server, and the VirtualThreadPool is a managed bean of the QueuedThreadPool. The bottom row also shows the addBean is not optional — without it the pool never starts and rejects every request.

Testing

The selection was extracted from createServer() into a package private createThreadPool(JettyConfig) so that the resulting pool can be asserted without starting a server. This was prompted by review: the integration test only asserted that a request succeeds, which holds for every combination in the table, so it could not tell the new setup apart from the pre-existing ones.

  • JettyServiceThreadPoolTest — 8 cases covering every row of the table, asserting the pool type, its bound, and that the VirtualThreadPool is a bean of the QueuedThreadPool. Virtual thread cases are skipped by assumption on a JVM without virtual threads, probed the same way JettyService probes it, so it stays correct beyond Java 21.
  • JettyConfigTest — default of -1, plus Integer and String property values.
  • JettyVirtualThreadsBoundedExecutorIT — end-to-end, and the only test that exercises the life cycle above, since an unstarted pool would reject the request.

All 19 unit tests and all 3 JettyVirtualThreads*IT pass on Corretto 21.0.6.

The new assertions were mutation-checked rather than merely observed green: raising the threshold of the new branch so it is never taken fails testVirtualThreadsBoundedExecutor and testVirtualThreadsBoundedExecutorWithoutThreadPoolMax, and removing the addBean call alone fails testVirtualThreadsBoundedExecutor.

Notes

🤖 Generated with Claude Code

Virtual threads ITs now run on every JDK 21 or later

The pre-existing guard in JettyVirtualThreadsIT matched the version string with startsWith("21"), so the body was skipped on every later JDK — on the Java 23 job of the CI matrix all three virtual threads ITs asserted nothing while still reporting as passing. It now compares Runtime.version().feature() >= 21, so they run on Java 21 and on every later JDK.

Note that below Java 21 the test still reports as green rather than as skipped: the Pax Exam runner swallows a failed JUnit assumption and reports the test as passing. Confirmed by forcing the assumption to fail locally, which still gave Tests run: 1, Skipped: 0. That matches the behaviour before this change, so nothing regresses, but it is worth knowing that the Java 17 job cannot demonstrate a skip here.

Verified on Corretto 25 by mutation: changing the expected status to 201 makes all three ITs fail with expected:<201> but was:<200>, which shows the request really is made and asserted there. Green on Corretto 21.0.6 and 25.0.2.

Metatype fix for virtualthreads.enable

Spotted while adding the new attribute next door. The virtualthreads.enable attribute definition passed -1 as its default, which selects the int constructor of AttributeDefinitionImpl. The flag was therefore declared as an INTEGER attribute defaulting to -1, even though JettyConfig reads it with getBooleanProperty and the description already said it defaults to false — a configuration UI rendered a number field for a boolean flag. Passing false selects the boolean constructor, giving BOOLEAN with a default of false.

Reading a previously stored integer value is unaffected, since getBooleanProperty parses it the same way as before.

I audited every boolean-typed property in the jetty12 metatype for the same mistake: this was the only one. The jetty11 bundle has no virtualthreads.enable and no comparable mismatch, so it is untouched.

@paulrutter
paulrutter force-pushed the FELIX-6859-bounded-virtual-thread-executor branch 3 times, most recently from 8903adb to 1495ee9 Compare September 4, 2026 00:07
…xecutor

Jetty 12 documents a QueuedThreadPool whose virtual threads executor is a
bounded VirtualThreadPool as the preferred setup, but Felix HTTP could not
be configured to build it. The two virtual thread options were the
extremes: an unbounded number of concurrent tasks, or a standalone
VirtualThreadPool that creates only virtual threads. A deployment that
wants platform threads for the acceptors and the selectors, and a bound on
concurrent request tasks, had no way to express that.

Adds org.apache.felix.http.jetty.virtualthreads.max to the jetty12 bundle.
When virtual threads are enabled and this property is set to a positive
value, the thread pool becomes a QueuedThreadPool, sized by threadpool.max
as usual, whose virtual threads executor is a VirtualThreadPool bounded by
setMaxConcurrentTasks. Note that unlike threadpool.max, this property
bounds the number of concurrent tasks rather than the number of threads.

The VirtualThreadPool is added as a bean of the QueuedThreadPool, because
setVirtualThreadsExecutor() only stores the executor and does not manage
its life cycle; an unstarted VirtualThreadPool rejects every task with a
RejectedExecutionException. Being a managed bean also covers the teardown,
so the pool is stopped when the server stops.

Non-positive values fall through to the existing behaviour, since Jetty
itself treats maxConcurrentTasks <= 0 as unbounded. The pre-existing branch
that builds a standalone VirtualThreadPool keeps using threadpool.max as
its bound, so no released behaviour changes.

The thread pool selection is extracted from createServer() into a package
private createThreadPool(JettyConfig), returning null when no thread pool
is configured so that Jetty's own default applies, so that the resulting
pool can be asserted without starting a server. JettyServiceThreadPoolTest
covers every combination: the type of pool, its bound, and that the
VirtualThreadPool is registered as a bean of the QueuedThreadPool.

Two related fixes in the same area:

* The attribute definition for virtualthreads.enable passed -1 as its
  default value, which selects the int constructor of
  AttributeDefinitionImpl, so the flag was declared as an INTEGER
  attribute defaulting to -1 even though JettyConfig reads it with
  getBooleanProperty. It is now declared as a BOOLEAN defaulting to false.
  Reading a previously stored integer value is unaffected.
* The guard in JettyVirtualThreadsIT matched the version string with
  startsWith("21"), so the test body was skipped on every later JDK while
  the test was still reported as passing. It now compares the feature
  version, so the ITs run on Java 21 and on every later JDK. Note that the
  Pax Exam runner swallows a failed assumption and reports the test as
  passing rather than as skipped, so below Java 21 the test still shows up
  as green, as it did before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@paulrutter
paulrutter force-pushed the FELIX-6859-bounded-virtual-thread-executor branch from 1495ee9 to e41af82 Compare September 4, 2026 06:55
@paulrutter
paulrutter merged commit 5f72ed2 into master Sep 4, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant