feat(core) astubbs#190: run the user function on virtual threads - #360
feat(core) astubbs#190: run the user function on virtual threads#360astubbs wants to merge 5 commits into
Conversation
…he pressure system reading the executor A handler that blocks holds an OS thread for as long as it blocks, and a machine can only bring platform threads out of a park at a fixed rate - so reachable concurrency is min(maxConcurrency, activationRate x handlerLatency), not maxConcurrency. Measured on a 12-core laptop at maxConcurrency 5,000 with a 100ms handler: platform threads held 2,673 records in flight, virtual threads held all 5,000. Raising maxConcurrency past that ceiling does nothing; making the work not hold a thread removes the term. useVirtualThreads (default from the pc.virtualThreads system property) runs the user function on Executors#newVirtualThreadPerTaskExecutor, reached reflectively because the published artifact is still Java 8 bytecode. Opt-in, JDK 21+ at runtime, and refused loudly rather than silently falling back. WHAT THE POOL CHANGE FORCED. setupWorkerPool now returns ExecutorService rather than ThreadPoolExecutor, because a virtual-thread executor is neither. Two subsystems read the concrete type and had to stop: - requireRejectionIsVisible inspects a rejection handler that only a ThreadPoolExecutor has. It now applies to that case and passes any other pool through - a virtual-thread executor starts a thread per task, so it has no bounded queue to reject from. - The pressure system read the executor's queue depth to decide whether to step the load factor. There is no queue under virtual threads, so UserFunctionTaskAccounting derives the same figure from submissions minus completions - counted before the submit, because a virtual thread can be running the task before submit() returns, and an increment placed after would make the derived depth transiently negative. The load-factor ceiling report is silent under a virtual-thread pool: "the queue is low" is that pool's permanent resting state rather than a symptom, and the advice the line gives would be actively wrong - raising the load factor buys a deeper record buffer, not more concurrency. External engines (Vert.x, Reactor, Mutiny) opt out via supportsVirtualThreads(): their worker "pool" is one dispatch thread by design and their concurrency lives in the external runtime, so an unbounded executor there would silently make dispatch concurrent. WorkerPoolAccountingAgreementTest pins the derived queue depth against the executor's own for the platform pool, which is what stops the two accountings drifting. Adapted from b6083f5 on perf/engine-concurrency. Two things that commit predates were kept rather than reverted: master's shutdown guards in submitWorkToPool (the closing-state return, and the shut-down-pool rejection that drops the batch and stops the poll rather than escaping to the control thread - SubmitWorkToPoolShutdownRaceTest covers them), and the load-factor reporting, which is #201's and is left in master's form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MedgsxqrM8vjSt5ncuAo8g
… and refuse a green run that skipped it
Adds the execution-mode axis docs/inflight/test-opt-in-engine-paths-are-unexercised.md
asks for, with virtual threads as its first non-default entry.
WHY AN AXIS AND NOT A VIRTUAL-THREADS TEST SUITE. These paths are meant to be
behaviourally equivalent to the default engine. What needs asserting is not that
virtual threads work but that virtual threads and the default AGREE - on ordering,
commits, retries, rebalance, shutdown. That is the suite we already have, run again
with a different selector. Adding direct pull later is a matrix entry plus a row in
the guard, not a second job; the shape is written into the workflow comment and
deliberately left unpopulated.
THE SELECTOR HAD TO REACH THE FORK. Surefire runs tests in a separate JVM and a -D on
the Maven command line does not reach it, so `mvn test -Dpc.virtualThreads=true` would
have run the whole suite on the DEFAULT engine and reported green. The pom now
forwards pc.virtualThreads and pc.directPull through systemPropertyVariables.
BUILD ON 17, TEST ON 21. Virtual threads are a runtime capability and PC reaches them
reflectively, so nothing compiles against JDK 21 - and nothing can: Jabel 1.0.0, which
is what emits Java 8 bytecode from Java 17 source here, does not run on JDK 21 at all.
It rewrites javac internals with a Byte Buddy that predates 21, and having got past
that it strips Source$Feature.LAMBDA and takes the compiler down. Measured on Temurin
21.0.9; three stacked blockers, recorded in the inflight note. Surefire's fork was
already parameterised as ${jvm.location}, so the lane needs no build change and tests
the SHIPPED Java 8 bytecode on a JDK 21 runtime - what a user with the option on does.
bin/check-execution-mode.sh IS THE ANTI-SILENT-GREEN DEVICE. The upstream PR's
virtual-thread tests used JUnit Assumptions and CI runs a JDK without virtual threads,
so they skipped and the job passed - its author said so himself, and this repository
has shipped that shape before. Two halves now prevent it: the Java tests FAIL rather
than skip when the mode was selected and the runtime cannot serve it, and the guard
reads the surefire XML and refuses a green verdict when the mode-proving tests did not
execute. Two distinct reds, as check-ossindex-audit.sh has: exit 1 the lane is broken,
exit 2 the tree has a real disagreement, exit 1 wins when both.
Its self-test earned its place immediately - it caught the guard reading every surefire
attribute as zero on macOS, because BSD sed rejects `s/…/…/;t;d` on stderr while still
exiting 0. And pointing the finished guard at a real JDK 17 report caught the guard
committing its own defect: two JDK-agnostic tests in the marker class counted as proof
that the mode had run. Those moved to VirtualThreadOptionTest, so every test in the
marker class now requires the mode, and a JDK 17 report is correctly called a broken
lane.
The job is advisory and its name is new. Required checks are matched by name in the
master ruleset and a required context no run has produced blocks every PR whose base
predates it, so making it required is a separate deliberate act. Re-adding a JDK axis
also re-opens the decision that removed the last one for cost (#128), so this is
scoped to the unit suite only.
Release-Note: CI now runs the core unit suite a second time with virtual threads enabled on a JDK 21 runtime, and fails the lane if that run skipped the tests instead of running them.
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
The cut caught a real hazard worth recording: the branch commit predates master's submitWorkToPool shutdown guards, and a naive cherry-pick silently reverted all three of them. Master's versions were kept and the tests that cover them pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MedgsxqrM8vjSt5ncuAo8g
✅ Duplicate Code ReportTwo engines run in parallel for cross-validation. Each has its own thresholds tuned to its baseline - the real safety net is the per-engine "max increase vs base" check. ✅ PMD CPD
|
| PR | Base | Change | |
|---|---|---|---|
| Clones | 80 | 78 | 🫤 +2 |
| Duplicated lines | 1137 | 1110 | :face_with_raised_eyebrow: +27 |
| Duplication | 1.18% | 1.17% | 🫤 +0.01% |
| Rule | Limit | Status |
|---|---|---|
| Max duplication | 2% | ✅ Pass (1.18%) |
| Max increase vs base | +0.1% | ✅ Pass (+0.01%) |
⚠️ 2 new clones introduced
- 16 lines:
parallel-consumer-core/src/test/java/bz/stub/parallelconsumer/internal/WorkerPoolAccountingAgreementTest.java:145<->parallel-consumer-core/src/test/java/bz/stub/parallelconsumer/internal/WorkerPoolAccountingAgreementTest.java:65 - 13 lines:
parallel-consumer-core/src/test/java/bz/stub/parallelconsumer/internal/VirtualThreadExecutionModeTest.java:1<->parallel-consumer-core/src/test/java/bz/stub/parallelconsumer/internal/VirtualThreadOptionTest.java:1
Powered by astubbs/duplicate-code-cross-check
| var accounting = pc.userFunctionTaskAccounting(); | ||
|
|
||
| var release = new CountDownLatch(1); | ||
| var running = new CountDownLatch(POOL_SIZE); |
There was a problem hiding this comment.
parallel-consumer-core/src/test/java/bz/stub/parallelconsumer/internal/WorkerPoolAccountingAgreementTest.java:142
✅ SpotBugs ReportNo bugs found (new bugs only — baseline from base branch excluded). |
🧪🔒 Quarantine Lane Report
🔴 expected while the owner PR is open · 🟡🎲 flapper, pass proves nothing · 🚨 a deterministic quarantined test passing means its fix landed: delete its |
…arking decision in place Two leftovers from e67961b, the merge that reconciled #201's load-factor reporter with #360's virtual-thread guard. That merge's own message records the resolution - "keeping #201's reporter and returning early for the virtual pool" - and the reporter it kept is `reportLoadFactorAtCeiling()`, rate limited through `loadFactorAtCeilingLimiter`. What it did not do is remove the other side's field. So `loadFactorCeilingLimiter` has been declared and never read, and its javadoc pointed at `maybeReportLoadFactorCeiling()`, a method that does not exist on this branch. Nothing else in the tree names either - swept with grep across java, md, yaml and adoc; the only other hits were the build's own delombok output mirroring the source. Both go. The second leftover is a citation that no gate can catch. `ProcessingShard.takeFromEntries` cited `docs/inflight/parked-resume-shard-dispatch-scan.md` for why in-flight records must stay visible to the ordered walk, and that note lives only on `perf/engine-concurrency`. `bin/check-file-refs.sh` passes over it because it does not read paths out of `{@code ...}` in Java, so the citation reads as valid and resolves to nothing - exactly the failure mode AGENTS.md's cite-by-anchor rule exists to prevent. Repaired by stating the decision in the javadoc rather than by bringing the note across. The note is the parking record for two REJECTED branches (`perf/resume-shard-scan` and `perf/split-shard-inflight`), so it is evidence-record material belonging with #363's residue rather than open work on this branch, and `docs/inflight/AGENTS.md` scopes that directory to work that is currently open. Porting it would also have imported two further dangling citations (`perf-throughput-regression-since-0-3.md`, `perf-direct-pull-collapse-is-the-scan.md`, neither on this branch) and needed a retag - its `inflight-type: parked` predates the vocabulary `bin/lib/inflight-tags.sh` now enforces. The one fact the citation was carrying is now in the code: the split-state experiment moved in-flight records into a second map, the ordered walk stopped meeting an occupied head, and records dispatched out of order silently - it compiled clean and ten tests failed. That is precisely why `ShardOccupancy` is an index over an untouched base collection, read by the unordered path only, and the javadoc now says so where the decision lives. Behaviour unchanged: no reporting path, no log line and no test touched. Full affected-suite run alongside the other commits on this branch is green, LoadFactorCeilingReportingTest 4/4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… loosening removed A weakened assertion was left on this branch with no compensation, and this puts the strength back rather than the justification for its absence. WHAT WAS LOST, AND WHERE. `testThatInFlightWorkIsFinishedSuccessfullyAndOffsetsAreCommitted` asserted `isGreaterThan(degreeOfParallelism)` - strictly MORE than maxConcurrency records must drain after a pause, because the shipped engine keeps a load-factor multiple of maxConcurrency sitting in the executor's queue ahead of the workers, all of it already committed to running. #360 loosened that to `isAtLeast(...)` because a virtual-thread pool has no such queue, and the same is true of direct pull, where each worker claims its own record. The loosening is correct and stays. What did not arrive with it is the compensation: 47bde02 on `perf/engine-concurrency` made the identical loosening for the direct-pull engine and paid for it in the same commit, and only the loosened half travelled into this stack. Left alone, the branch asserts nothing about how much the shipped engine had buffered when the pause landed - and an engine that ignored the pause entirely would have satisfied every remaining assertion in that test. WHAT IS TIGHTENED. Nothing is loosened here; two things get stricter. An upper bound, `isLessThan(numTestRecordsPerSet)`, now sits with the existing post-drain assertions. Whatever was already dispatched drains, but the pause must stop the rest, so the total has to be nowhere near the whole set. This holds in EVERY engine - a pause that works is a pause that works - so it is asserted unconditionally rather than behind the skip below. The original test never had this bound in any engine, so this is new strength, not restored strength. `pausingDrainsThePreLoadedExecutorQueueAsWellAsTheInFlightRecords` carries the original `isGreaterThan` at full strength in its own method. Giving it a method is the whole point: skipping it for an engine with no queue then costs only itself, instead of aborting everything the parameterised test asserts either side of the loosened line. Adapted from 47bde02, which guarded only `isDirectPullEngine()` because that was the engine in front of it; this branch has both opt-in engines, so the assumption covers `pc.directPull` and `pc.virtualThreads` alike. Not parameterised by commit mode - what is under test is how much work was buffered when the pause landed, which has nothing to do with how offsets are committed. THE GUARD IS PROVEN TO FIRE, not assumed to. Default engine: 7 tests run, 0 skipped, and the new one exercises the `isGreaterThan` path for real. Under `-Dpc.directPull=true`: 7 run, exactly 1 skipped - the new one - and the other six still pass. If the assumption were mis-wired the count would not move. Verification: ParallelEoSStreamProcessorPauseResumeTest 7/7 default (up from 6), 6 pass plus 1 skip under direct pull, and the full affected set on this branch - ParallelEoSStreamProcessorTest, UnorderedShardScanResumeTest, LoadFactorCeilingReportingTest - 71 tests, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This PR ships the capability; three places in the repository still say it is deferred. A claims document that contradicts the code in the same release is worse than one that is merely out of date, because a reader has no way to tell which side is stale. Back-filled from 3456e38 on `perf/engine-concurrency` (#363), the commit that measured virtual threads against Parallel Consumer itself and corrected the four places the repository called them deferred. Only the claims travel here; the measurement notes and the feature record do not, and that is deliberate - see below. - `docs/data/roadmap.yaml` moves the `virtual-threads` horizon from `next-0x` to `0.6.0.0`. - `docs/data/module-maturity.yaml` drops `Virtual threads` from `explicit_non_defect_deferrals`, leaving micro-batching and the dead-letter queue, which are still genuinely deferred. - The README's release-posture section now says the processing function can run on virtual threads via the opt-in `useVirtualThreads` option on a JDK 21 runtime, and that the published artifacts are unchanged Java 8 bytecode so only the runtime has to move. README.adoc IS GENERATED and was not hand-edited. The source is `src/docs/README_TEMPLATE.adoc`, rendered by `io.whelk.asciidoc:asciidoc-template-maven-plugin`, bound to `process-sources` on the root pom with `inherited=false` - so `./mvnw -N process-sources` regenerates it. The template was edited and the plugin run; the resulting README.adoc diff is byte-identical to the one in 3456e38, which had edited both files by hand. Worth recording because the campaign branch's hand-edit is exactly the thing that silently drifts the two apart, and it happened to agree here. WHAT IS DELIBERATELY NOT HERE. `docs/features/virtual-threads.yaml` and the two measurement notes it references go to #362 instead, and this is the routing the gates forced rather than a preference. The feature record's `references.path` entries name `docs/inflight/perf-virtual-threads-measured.md` and `docs/inflight/perf-platform-threads-are-the-ceiling.md`, so adding it alone takes `bin/check-docs-data.sh` from clean to two failures. Adding those notes then takes `bin/check-file-refs.sh` from clean to six, because they cite `bench/run-bisect.sh`, `bench/threads/ThreadCeiling.java` and `bench/threads/AsyncCeiling.java` - all of which live on the bench-harness branch. The whole package therefore lands where the files it cites already are. `docs/data/testing-evidence.yaml`'s matching deferral line goes with it, because that line cites the feature record. That also honours what this PR's own description already said: the feature record lands with the engine-comparison documentation, not here. Gates: check-docs-data, check-file-refs, check-inflight-tags, check-issue-refs, check-copyright-headers and check-branch-self-reference all clean, matching the pre-change baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ctness went This PR loosened `isGreaterThan(degreeOfParallelism)` to `isAtLeast(...)` in `testThatInFlightWorkIsFinishedSuccessfullyAndOffsetsAreCommitted`, correctly: a virtual-thread pool has no executor queue, every submitted record is already running, so exactly `maxConcurrency` records drain after a pause and the strict form failed for a system behaving properly. What the change did not do is replace what the strict form was buying, and a weakened assertion with nothing put back is how a real property quietly stops being tested. WHAT THE STRICT FORM WAS BUYING. Under the engine PC ships, the dynamic load factor keeps a multiple of `maxConcurrency` sitting in the executor's queue ahead of the workers, all of it already committed to running when the pause lands - so strictly MORE than `maxConcurrency` records must drain. That is a real property of the shipped engine, not an implementation detail. WHAT IS TIGHTENED HERE. An upper bound, `isLessThan(numTestRecordsPerSet)`, alongside the existing post-drain assertions. Whatever was already dispatched drains, but the pause has to stop the rest, so the total must be nowhere near the whole set - and without it, an engine that ignored `pauseIfRunning()` entirely would satisfy every remaining assertion in the test. It is engine-independent, needs no per-engine skip, and holds under virtual threads as well, so it can be asserted unconditionally right here. The original test never had this bound in any engine, so it is new strength rather than restored strength. WHAT CANNOT BE ASSERTED HERE, AND WHERE IT IS. The strict `isGreaterThan` half needs to be skipped for an engine with no queue, and a skip needs a per-engine predicate. This branch has only `isUseVirtualThreads()`; the direct-pull engine's `isDirectPullEngine()` arrives with #361, and the same loosening is needed for both, so the skippable form belongs there rather than here. It is restored on `perf/shard-occupancy-scan-v2` at full strength as `pausingDrainsThePreLoadedExecutorQueueAsWellAsTheInFlightRecords`, with an assumption covering both `pc.virtualThreads` and `pc.directPull` - proven to fire rather than assumed: 7 tests run and 0 skip under the default engine, 7 run and exactly 1 skips under `-Dpc.directPull=true`. A code comment at the loosened line says all of this and names the restoring test, so the next reader does not have to reconstruct it from two branches, and does not read the loosening as a decision that the property stopped mattering. This is the note the loosening should have carried when it was made. Adapted from 47bde02 on `perf/engine-concurrency` (#363), which made the same loosening for the direct-pull engine and paid for it in the same commit; only the loosened half travelled into this stack when the children were cut out. Verification: ParallelEoSStreamProcessorPauseResumeTest 6 tests, 0 failures, 0 skipped. Gates clean and matching baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…bulary the gate enforces Port fix, not a content change: not one word of either note's body is touched. Both notes arrived in the previous commit from `perf/engine-concurrency` (#363), carrying `inflight-type: perf` and `inflight-impact: performance`. Neither value exists any more - `bin/lib/inflight-tags.sh` enforces `bug feature task register` for the type, and an impact set in which the performance axis is spelled `throughput`. The campaign branch predates that tightening, so its notes are tagged in a vocabulary master retired. - `perf-platform-threads-are-the-ceiling.md` becomes `bug` + `throughput`. It is the diagnosis of a throughput limitation - reachable concurrency is capped by platform-thread activation rate, proven by a forty-line control with no Kafka and no Parallel Consumer in it - and the impact tag answers what it costs you not to know, which is throughput. - `perf-virtual-threads-measured.md` becomes `feature` + `throughput`. It is the measurement of the capability #360 ships, against Parallel Consumer itself rather than against a control. `docs/inflight/AGENTS.md` says a feature should carry an impact whenever it addresses one, and this one addresses the ceiling above. Sequencing, recorded because the log otherwise misleads: these two files and `docs/features/virtual-threads.yaml` were meant to be their own commit, separate from the STRATEGY claims delta. `git checkout <ref> -- <path>` stages as it writes, so they were swept into the previous commit, whose message describes only the STRATEGY change - and the retags, made in the working tree afterwards, were left behind by the same mechanism. Fixed forward rather than by resetting: the repository's history guard refuses a reset-backwards without an explicit operator decision, and a rewrite is a merge-prep action, not a mid-flight one. Re-cutting the two into their intended commits is a merge-prep offer, not something to take unilaterally now. WHY THE VIRTUAL-THREAD DOCUMENTATION PACKAGE IS ON THIS PR at all, since #360 is the one that ships the option: the gates decided it, not preference. `docs/features/virtual-threads.yaml` names both of these notes in its `references.path` entries, so on #360 it took `bin/check-docs-data.sh` from clean to two failures. Adding the notes there then took `bin/check-file-refs.sh` from clean to six, because they cite `bench/run-bisect.sh`, `bench/threads/ThreadCeiling.java` and `bench/threads/AsyncCeiling.java` - files that exist only here. `docs/data/testing-evidence.yaml`'s deferral line cites the feature record, so it travels with them. #360 keeps the three claims updates that stand alone (roadmap horizon, module-maturity deferral, README) and its own description already said the feature record lands with the engine-comparison documentation. NOT brought across: `docs/plans/2026-08-22-001-feat-virtual-threads-plan.md`. Tested here rather than assumed - moving it to this branch does resolve the bench citations, cutting its dangling paths from 30 to 15, but 15 remain across nine paths, eight of them inflight notes that live only on the campaign branch. Repairing them would mean importing eight more notes with their own cascades, or writing fifteen opt-out markers into a dated plan document. Its citation web points at the campaign branch's evidence base, so it stays with it on #363. Gates: check-docs-data clean (43 files valid, up from 42 - the new feature record and its references both resolve). check-inflight-tags holds at this branch's pre-existing 8 problems and check-file-refs at its pre-existing 25 - neither ported note appears in either failure list, verified by name rather than by count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #190.
Description
Run the user function on virtual threads (opt-in, JDK 21+), and stop the pressure system reading the executor's queue.
A handler that blocks holds an OS thread for as long as it blocks, and a machine can only bring platform threads out of a park at a fixed rate. Reachable concurrency is therefore
min(maxConcurrency, activationRate × handlerLatency), notmaxConcurrency. Measured on a 12-core laptop atmaxConcurrency5,000 with a 100ms handler: platform threads held 2,673 records in flight; virtual threads held all 5,000. RaisingmaxConcurrencypast that ceiling does nothing — the term only disappears if the work stops holding a thread.useVirtualThreads(defaulting from thepc.virtualThreadssystem property) runs the user function onExecutors#newVirtualThreadPerTaskExecutor, reached reflectively because the published artifact is still Java 8 bytecode. It fails loudly on an older JDK rather than silently falling back to platform threads — a silent fallback would make the two modes indistinguishable and every comparison meaningless.What the pool change forced, and this is the reviewable core of the PR.
setupWorkerPoolnow returnsExecutorService, because a virtual-thread executor is not aThreadPoolExecutor. Two subsystems read the concrete type:requireRejectionIsVisibleinspects a rejection handler onlyThreadPoolExecutorhas. It now applies to that case and passes any other pool through — a virtual-thread executor starts a thread per task, so there is no bounded queue to reject from and nothing for the guard to protect.UserFunctionTaskAccountingderives the same figure from submissions minus completions. Counted before the submit, not after: a virtual thread can be running the task beforesubmit()returns, and an increment placed afterwards lets the task's own start land first, making the derived depth transiently negative.WorkerPoolAccountingAgreementTestpins the derived depth against the executor's own figure for the platform pool — that agreement is what stops the two accountings drifting apart silently.The load-factor ceiling report is silent under a virtual-thread pool: "the queue is low" is that pool's permanent resting state rather than a symptom, and the line's advice would be actively wrong there (raising the load factor buys a deeper record buffer, not more concurrency).
External engines opt out via
supportsVirtualThreads(): Vert.x, Reactor and Mutiny have a single dispatch thread by design with concurrency living in the external runtime, so an unbounded executor would silently make dispatch itself concurrent.CI lane.
bin/check-execution-mode.shruns the suite under a chosen mode and refuses a green run that skipped the mode's tests — the failure this exists to prevent is a matrix leg that passes by running nothing. It has its own self-test (bin/test-check-execution-mode.sh, 12 assertions) because a guard nobody tests is a guard nobody can trust.Verification. Full
parallel-consumer-coreunit suite: 403 tests, 0 failures (13 skipped, of which 5 are the JDK-21-only virtual-thread cases — the project builds on JDK 17, where delombok requires it, so those run in the CI execution-mode lane).Provenance, and two things deliberately not reverted. Adapted from
b6083f5efonperf/engine-concurrency. That commit predates two changes now on master, and this PR keeps master's versions rather than the branch's: the shutdown guards insubmitWorkToPool(the closing-state early return, and the shut-down-pool rejection that drops the batch and stops the poll instead of escaping to the control thread —SubmitWorkToPoolShutdownRaceTestcovers both, and it fails without them), and the load-factor ceiling reporting, which belongs to #201 and is left in master's form here.Checklist
docs/features/- N/A -ExecutionModeis opt-in and preview-grade; the feature record lands with the engine-comparison documentation once the mode is out of experimental statusVirtualThreadExecutionModeTest,UserFunctionTaskAccountingTest,WorkerPoolAccountingAgreementTest, plus the CI lane's own self-test