Skip to content

CAMEL-25812: camel-milo - end idle client threads and run reconnects on a thread of their own - #28111

Merged
davsclaus merged 2 commits into
apache:mainfrom
etzel-mes:fix/CAMEL-25812-milo-executor-split
Oct 19, 2026
Merged

davsclaus merged 2 commits into
apache:mainfrom
etzel-mes:fix/CAMEL-25812-milo-executor-split

Conversation

@etzel-mes

Copy link
Copy Markdown
Contributor

Description

CAMEL-25812, the follow-up to #28049 (CAMEL-25713) that @davsclaus suggested in #28049 (comment).

Since #28049, a milo-client or milo-browse connection handles the outcome of its write, method call, read and browse requests, and runs its reconnects, on a scheduled thread pool of its own with one thread per processor. The threads of a scheduled pool never end while the connection is open, and with virtual threads Camel builds that pool with a single worker.

The change:

  • MiloClientConnection handles the request outcomes on a thread pool from ExecutorServiceManager.newThreadPool named MiloClient. It has up to one thread per processor, and its idle threads end after the keep-alive time of the default thread pool profile (60 s). With virtual threads and more than one processor, Camel builds it as a virtual thread per task. Its queue is not bounded (Integer.MAX_VALUE, because -1 gives a SynchronousQueue that rejects when every thread is busy), and the rejected policy stays Abort, so a request that completes after the connection is closed still fails with a RejectedExecutionException.
  • The reconnects run on newSingleThreadScheduledExecutor named MiloClientReconnect. Its thread starts with the first reconnect, and the reconnects run one after the other; before, two connects could run at the same time. They no longer wait in the queue behind the outcomes of the failed requests.
  • SubscriptionManager takes the two executors; only triggerReconnect uses the scheduled one. dispose() cancels a reconnect that is still waiting, so the MiloClientReconnect thread ends when the connection is closed instead of up to 10 s later.
  • close() shuts down the MiloClient pool even if shutting down the reconnect executor fails.
  • MiloClientExecutorTest gets a test that a connection that cannot connect has a MiloClientReconnect thread, and that the thread ends within 5 s after the route stops (it fails without the cancel in dispose()). The idle timeout and the virtual thread per task come from Camel's thread pool factory; they are covered by the measurements below, not by a unit test.
  • The 4.24 upgrade-guide section from CAMEL-25713: camel-milo - give each client connection its own executor #28049 describes the two pools.

Neither executor is Milo's shared scheduled executor, so a failed request still disconnects without waiting for a thread that only the disconnect could free; MiloClientExecutorTest from #28049 still covers that. The values that a reconnect delivers to the consumers run their routes on the reconnect thread, and an awaited request from such a route completes on the MiloClient pool. On #28049, with one processor or with virtual threads, the reconnect and that completion shared the single worker of the pool.

Because a reconnect no longer queues behind the outcomes of the failed requests, a failure that is handled after the reconnect can now dispose the new connection, which then reconnects once more. This is not new: a failure handler does not check that it belongs to the current connection. In the reconnect probe below it shows as one more connect with one processor and awaited writes (3 instead of 2), and the connection still recovers. I plan to fix that check separately, after #27956, which changes the same producer code.

I first tried a cached pool for the request outcomes, as suggested, but it does not work with platform threads. When the connection is lost, every request in flight fails, and each failure waits for the lock of the connection while the first failure disconnects. With four producers sending writes that are not awaited, and the server frozen for 30 s, the cached pool started about 4,080 threads until the JVM could not create more (pthread_create failed (EAGAIN)), and the connection was never rebuilt. So with platform threads the pool keeps the bound of #28049 and only gains the idle timeout; with virtual threads, it starts a thread per task.

Measured with host JVMs (Java 21, client limited with -XX:ActiveProcessorCount=8, against a local Milo server, two runs each):

main #28049 this PR
30 idle connections, MiloClient threads 5 s / 65 s after the load 0 / 0 240 / 240 240 / 0
platform threads of the JVM 65 s after the load 44–47 282–291 42–43
virtual threads, 1 connection × 32 callers, 10 ms of work after each awaited write 668–671/s 85/s 2,121–2,301/s
platform threads, same 641–681/s 671–682/s 670–672/s
30 connections × 4 callers, 10 ms of work, platform threads (*) 677–692/s 9,166–9,585/s 8,588–9,543/s
same, virtual threads (*) 683–685/s 2,347–2,555/s 7,686–8,612/s
30 connections × 1 caller, no work after the write, platform threads (*) – 16,644–21,040/s 16,065–23,342/s
same, virtual threads (*) – 16,416–25,225/s 24,382–28,150/s

(*) #28049 and this PR alternated run by run, three runs each, each started only when the 1-minute load of the host was below 6. Main is from the earlier matrix; its runs without work varied twofold on the busier host, so they are left out.

A reconnect probe froze the server JVM (SIGSTOP, so the sockets stay open and nothing answers) for 30 s, while one connection had a consumer and four producers, and checked when the consumer received values again after the server resumed. This PR recovered in every run, 1–6 s after the resume: with 1 and 8 processors, with awaited writes and with writes that are not awaited, and with virtual threads (8 processors). Its peak was 135 platform threads. On main, with one processor and awaited writes, the probe also reproduced CAMEL-25713: milo-shared-scheduled-executor-0 waited in OpcUaClient.disconnect() from handleConnectionFailure of a write, and the connection was not rebuilt.

Against a real server, opc-plc 2.15.4 in Docker, a client with a consumer and a producer that does an awaited write every 2 s lost its traffic silently (an iptables DROP) for 30 s, while #28049 ran beside it on a second connection, positions alternating. This PR recovered in all 16 runs (6 with 1 processor, 6 with virtual threads and 2 processors, and 4 more with 1 processor on the final commit), 1–12 s after the traffic was restored, in the same range as #28049 in the same runs. With 1 processor it more often needed a third connect, the race described above.
mvn -Psourcecheck clean verify in components/camel-milo passes (82 tests, 1 skipped; #28049 has 81), and the module build regenerates no files.

@davsclaus @oscerd could you take a look?

Target

  • I checked that the commit is targeting the correct branch (Camel 4 uses the main branch)

Tracking

  • If this is a large change, bug fix, or code improvement, I checked there is a JIRA issue filed for the change (usually before you start working on it).

Apache Camel coding standards and style

  • I checked that each commit in the pull request has a meaningful subject line and body.

  • I have run mvn clean install -DskipTests locally from root folder and I have committed all auto-generated changes.

AI-assisted contributions

  • If this PR includes AI-generated code, commits have proper co-authorship attribution (e.g., Co-authored-by trailers) and the PR description identifies the AI tool used.

Claude Code on behalf of etzel-mes

…on a thread of their own

Since CAMEL-25713, a client connection handled the outcome of its
requests and ran its reconnects on a scheduled thread pool of its own
with one thread per processor. The threads of a scheduled pool never
end while the connection is open, so 30 connections on a machine with
8 processors kept 240 idle threads. With virtual threads, the pool had
a single worker, so after awaited requests the routes of a connection
ran one at a time: one connection with 32 callers and 10 ms of work
after each awaited write fell from about 670 to 85 exchanges per
second.

A connection now handles the outcome of its write, method call, read
and browse requests on a thread pool with up to one thread per
processor, whose idle threads end after the keep-alive time of the
default thread pool profile. With virtual threads and more than one
processor, the pool starts a virtual thread for each task, and the
same measurement gives about 2,100 to 2,300 exchanges per second. The
pool stays bounded with platform threads, because when the connection
is lost every request in flight fails and waits for the disconnect of
the first failure: a pool that starts a thread for each of them ran
out of threads with writes that are not awaited, and the connection
was not rebuilt.

The reconnects run one after the other on a single-thread scheduled
executor of the connection, so a reconnect no longer waits behind
the outcomes of failed requests. Disposing the connection cancels a
reconnect that is still waiting, so the thread ends when the connection
is closed.

Neither executor is the shared scheduled executor of Milo, so a failed
request still disconnects without waiting for a thread that only the
disconnect could free. A request that completes after the connection
is closed still fails with a RejectedExecutionException.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean concurrency refactor — splitting the single ScheduledExecutorService into a plain ExecutorService for request outcomes and a dedicated SingleThreadScheduledExecutorService for reconnects is the right approach. Lock ordering is correct, dispose() properly cancels the reconnect job under the lock, and the new test validates the fix.

One low-severity suggestion below.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

…n the test

AvailablePortFinder.getNextAvailable() is deprecated. The test now
holds the port as an AvailablePortFinder.Port extension, as
AbstractMiloServerTest does for the server port, so it stays reserved
while the test runs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous finding is addressed. ✅

The deprecated AvailablePortFinder.getNextAvailable() call has been replaced by @RegisterExtension AvailablePortFinder.Port unreachablePort = AvailablePortFinder.find() — the canonical pattern that holds the port reservation for the duration of the test, matching how AbstractMiloServerTest manages the server port. LGTM.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@davsclaus davsclaus left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @etzel-mes, this is the follow-up I asked for in #28049, and the measurements explain well why a cached pool was not the answer with platform threads.

What I checked:

  • newThreadPool merges the default profile, so the MiloClient pool gets the 60 s keep-alive, and core threads time out (the factory's default). With virtual threads, maxPoolSize > 1 and a queue size above 0 give the BoundedExecutorService over a thread per task, and with one processor it stays one platform thread, as the PR and the upgrade guide say. Integer.MAX_VALUE instead of -1 is right: -1 gives a SynchronousQueue, which rejects a task while every thread is busy.
  • The reconnects now run on their own single-thread scheduled executor. That serialises them and stops them waiting behind the failed request outcomes. connect() still returns early when the manager is disposed.
  • dispose() cancels a waiting reconnect. The scheduled pools use setRemoveOnCancelPolicy(true), so the shutdown in close() lets the thread end at once. The new test covers that.
  • close() shuts down both executors, each one even if the other fails.
  • The upgrade-guide section of #28049 now describes both pools.

One non-blocking nit: triggerReconnect does not check disposed. A connect that fails, or a request that fails, while the connection is being closed can still schedule a reconnect after dispose() has cancelled the waiting one. That reconnect returns at once when it runs, because connect() checks disposed. But a scheduled pool keeps delayed tasks after shutdown(), so the MiloClientReconnect thread can stay up to 10 s after the close in that narrow window. An early if (this.disposed) return; in triggerReconnect would close that window. Fine as part of the separate fix you plan for the failure handler that does not check its connection.

I resolved the gnodet-bot thread: the test now holds the port as an AvailablePortFinder.Port extension.

CI is still running on the current head.

Claude Code on behalf of Claus Ibsen

@github-actions

Copy link
Copy Markdown
Contributor

🌟 Thank you for your contribution to the Apache Camel project! 🌟
🤖 CI automation will test this PR automatically.

🐫 Apache Camel Committers, please review the following items:

  • First-time contributors require MANUAL approval for the GitHub Actions to run
  • You can use the command /component-test (camel-)component-name1 (camel-)component-name2.. to request a test from the test bot although they are normally detected and executed by CI.
  • You can label PRs using skip-tests and test-dependents to fine-tune the checks executed by this PR.
  • Build and test logs are available in the summary page. Only Apache Camel committers have access to the summary.

⚠️ Be careful when sharing logs. Review their contents before sharing them publicly.

@github-actions

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • components/camel-milo
  • docs

🔁 1 test passed only after a retry on JDK 25 (1 retried attempt)

Recovered flaky tests on JDK 25 (1)
Module Test Failed attempts First failure
dsl/camel-jbang/camel-jbang-plugin-tui LaunchManagerOutcomeTest.startedWhenTheStatusFileSaysSo 1 expected: null but was: LaunchOutcome[ok=false, log=null]

ℹ️ These tests did not fail the build. Surefire retried them and they passed.
Retries are enabled project-wide by surefire.rerunFailingTestsCount in parent/pom.xml.


🔬 Scalpel shadow comparison — Scalpel: 8 of 707 tested, 26 compile-only — current: 8 all tested

Maveniverse Scalpel detected 8 affected modules (current approach: 8).

Skip-tests mode would test 8 modules (2 direct + 7 downstream), skip tests for 26 (generated code, meta-modules)

Modules Scalpel would test (8)
  • camel-jbang-mcp ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-mcp ← downstream of org.apache.camel:camel-jbang-core
  • camel-jbang-plugin-tui ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-validate ← downstream of org.apache.camel:camel-yaml-dsl-validator
  • camel-launcher-container ← downstream of org.apache.camel:camel-launcher
  • camel-milo ← components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientConnection.java, components/camel-milo/src/main/java/org/apache/camel/component/milo/client/internal/SubscriptionManager.java, components/camel-milo/src/test/java/org/apache/camel/component/milo/MiloClientExecutorTest.java
  • camel-yaml-dsl-validator ← downstream of org.apache.camel:camel-catalog
  • camel-yaml-dsl-validator-maven-plugin ← downstream of org.apache.camel:camel-yaml-dsl-validator
Modules with tests skipped (26)
  • apache-camel
  • camel-allcomponents
  • camel-catalog
  • camel-catalog-console
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-endpointdsl
  • camel-endpointdsl-support
  • camel-itest
  • camel-jbang-core
  • camel-jbang-it
  • camel-jbang-main
  • camel-jbang-plugin-edit
  • camel-jbang-plugin-generate
  • camel-jbang-plugin-kubernetes
  • camel-jbang-plugin-test
  • camel-kamelet-main
  • camel-launcher
  • camel-report-maven-plugin
  • camel-route-parser
  • camel-yaml-dsl
  • camel-yaml-dsl-deserializers
  • camel-yaml-dsl-maven-plugin
  • coverage
  • dummy-component

ℹ️ Shadow mode — Scalpel observes but does not affect test execution. Learn more

All tested modules (35 modules, 3m 59s total)

Total reactor time: 3m 59s

Module Duration Status
Camel :: JBang :: Plugin :: TUI 53.8s SUCCESS
Camel :: JBang :: Plugin :: Validate 22.5s SUCCESS
Camel :: Launcher 21.5s SUCCESS
Camel :: YAML DSL 18.9s SUCCESS
Camel :: JBang :: MCP 15.9s SUCCESS
Camel :: YAML DSL :: Validator 14.2s SUCCESS
Camel :: Catalog :: Camel Catalog 13.6s SUCCESS
Camel :: Component DSL 12.5s SUCCESS
Camel :: Docs 9.9s SUCCESS
Camel :: Kamelet Main 7.9s SUCCESS
Camel :: JBang :: Plugin :: Kubernetes 7.4s SUCCESS
Camel :: Catalog :: Camel Report Maven Plugin 7.3s SUCCESS
Camel :: Catalog :: Camel Route Parser 5.1s SUCCESS
Camel :: YAML DSL :: Deserializers 4.7s SUCCESS
Camel :: JBang :: Plugin :: Testing 4.3s SUCCESS
Camel :: All Components Sync point 3.5s SUCCESS
Camel :: YAML DSL :: Validator Maven Plugin 2.4s SUCCESS
Camel :: Catalog :: Maven 2.2s SUCCESS
Camel :: YAML DSL :: Maven Plugins 2.1s SUCCESS
Camel :: Catalog :: Suggest (deprecated) 1.9s SUCCESS
Camel :: Assembly 1.1s SUCCESS
Camel :: JBang :: Plugin :: Edit 0.9s SUCCESS
Camel :: Catalog :: Dummy Component 0.9s SUCCESS
Camel :: JBang :: Plugin :: Generate 0.8s SUCCESS
Camel :: Endpoint DSL :: Support 0.7s SUCCESS
Camel :: Catalog :: Console 0.6s SUCCESS
Camel :: Coverage 0.6s SUCCESS
Camel :: JBang :: Integration tests 0.6s SUCCESS
Camel :: JBang :: Main 0.5s SUCCESS
Camel :: Launcher :: Container 0.5s SUCCESS
Camel :: JBang :: Plugin :: MCP 0.4s SUCCESS
Camel :: Endpoint DSL n/a
Camel :: Integration Tests n/a
Camel :: JBang :: Core n/a
Camel :: Milo n/a

Top 20 slowest modules:

  • Camel :: JBang :: Plugin :: TUI (53.8s)
  • Camel :: JBang :: Plugin :: Validate (22.5s)
  • Camel :: Launcher (21.5s)
  • Camel :: YAML DSL (18.9s)
  • Camel :: JBang :: MCP (15.9s)
  • Camel :: YAML DSL :: Validator (14.2s)
  • Camel :: Catalog :: Camel Catalog (13.6s)
  • Camel :: Component DSL (12.5s)
  • Camel :: Docs (9.9s)
  • Camel :: Kamelet Main (7.9s)
  • Camel :: JBang :: Plugin :: Kubernetes (7.4s)
  • Camel :: Catalog :: Camel Report Maven Plugin (7.3s)
  • Camel :: Catalog :: Camel Route Parser (5.1s)
  • Camel :: YAML DSL :: Deserializers (4.7s)
  • Camel :: JBang :: Plugin :: Testing (4.3s)
  • Camel :: All Components Sync point (3.5s)
  • Camel :: YAML DSL :: Validator Maven Plugin (2.4s)
  • Camel :: Catalog :: Maven (2.2s)
  • Camel :: YAML DSL :: Maven Plugins (2.1s)
  • Camel :: Catalog :: Suggest (deprecated) (1.9s)

⚙️ View full build and test results

@davsclaus davsclaus added this to the 4.24.0 milestone Oct 19, 2026
@davsclaus davsclaus added the enhancement New feature or request label Oct 19, 2026
@davsclaus
davsclaus merged commit 3d6b266 into apache:main Oct 19, 2026
7 checks passed
@etzel-mes
etzel-mes deleted the fix/CAMEL-25812-milo-executor-split branch October 23, 2026 06:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

components docs enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants