Skip to content

CAMEL-25713: camel-milo - give each client connection its own executor - #28049

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

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

Conversation

@etzel-mes

Copy link
Copy Markdown
Contributor

Description

CAMEL-25713.

A milo-client or milo-browse connection handled the outcome of its write, method call, read and browse requests, and ran its reconnect job, on Stack.sharedScheduledExecutor(), the scheduled executor that Milo shares between all its clients. When a request fails, SubscriptionManager.handleConnectionFailure holds the lock of the connection and calls OpcUaClient.disconnect(). A CloseSession that is sent after the client noticed the lost connection waits until a reconnect attempt restores the channel or fails, and Milo schedules those attempts on that same executor. That executor has as many threads as the JVM has processors, so with one processor its only thread waits for a task that only it could run. disconnect() waits without a timeout, and the CloseSession starts its request timeout only once it has a channel, so nothing ends the wait: the connection is not rebuilt, its consumers stay silent after the server can be reached again, and every call that needs the lock of the connection waits.

OPC UA clients often run on small gateways or in containers and pods, and a CPU limit of one or less gives the JVM one processor. For the issue, against Microsoft opc-plc 2.15.4 with the client limited to one CPU, a producer writing every 2 s and the traffic to the server dropped for 30 s, main (a16cccb) did not rebuild the connection within 60 s in 3 of 11 runs, each time with milo-shared-scheduled-executor-0 waiting in OpcUaClient.disconnect().

The change:

  • Each MiloClientConnection now has a scheduled thread pool of its own, created by the ExecutorServiceManager of the CamelContext and shut down when the connection is closed. MiloClientEndpoint and MiloBrowseEndpoint give the connection their CamelContext. Milo still schedules its reconnect attempts on its shared scheduled executor, which a disconnect no longer blocks.
  • Without virtual threads, the pool has as many threads as Milo's shared scheduled executor, one per processor: like that executor, it starts a thread for each task until it has them all. They end when the connection is closed. The pool belongs to the connection, so that an outage of one server, whose disconnect and connect attempts each hold a thread, does not delay the requests and reconnects of the other connections, nor the keep-alive and reconnect timers that Milo runs on its shared scheduled executor.
  • Its queue is not bounded, like the queue of Milo's executor, because a rejected reconnect would end the reconnects of the connection. The rejected policy is Abort: a request that completes after the connection is closed fails with a RejectedExecutionException. With the default CallerRuns, a pool that is shut down drops the task, and the request would never complete.
  • close() takes the lock that init() runs under, and shuts the pool down even if disposing the connection fails.
  • SubscriptionManager is unchanged apart from one sentence of a Javadoc that said the two executors were the same.
  • The upgrade guide describes the new threads.
  • New MiloClientExecutorTest: an awaited write completes while every thread of Milo's shared scheduled executor is busy, and the threads of the pool end when the last route that uses the connection stops. Without the change both tests fail: the write times out, and no thread of the pool exists. The first test shows that the outcome of a request no longer needs a thread of Milo's executor; it does not lose the connection.

With one processor, or with virtual threads enabled (the ExecutorServiceManager then builds a scheduled pool with one worker), the pool has a single thread. A route that runs on it, after an awaited request or for the status of a monitored item that a reconnect could not create, must then not wait for another request of the same connection, for example through a ProducerTemplate. With one processor, Milo's shared scheduled executor had the same limit.

Calling disconnectAsync() in handleConnectionFailure would avoid this one wait, but the reconnect job blocks in connect() until the server answers or the connect times out, and on Milo's shared scheduled executor that delays the timers of every Milo client in the JVM.

The issue mentions that handling the failures on another executor could need a guard against a stale failure starting a second reconnect. The pool never has more threads than Milo's shared scheduled executor, so it adds no such race: with one thread, failures that complete during the disconnect run before the queued reconnect and find it already scheduled; with more threads, the race is the one that main has. A guard needs the failing connection in the four producer blocks that #27956 changes, so it fits better after that PR. CAMEL-25692 proposes an executor owned by the component for the subscription recovery that #27910 added; this pool could serve as that executor.

Against the same server, main (e5ae19c) and this change ran side by side, each with a consumer and an awaited producer on one connection, and each with its traffic dropped for 30 s. With half a CPU per client (the JVM still sees one processor), main did not rebuild the connection within 60 s in 4 of 12 runs, each time with milo-shared-scheduled-executor-0 waiting in OpcUaClient.disconnect() from handleConnectionFailure of a write; with the change, the connection was rebuilt in all 12. With one CPU per client, both rebuilt it in all 18 runs: the hang is a race, and there the CloseSession of main always failed at once. In 6 of the 30 runs the CloseSession of the change waited about 6 s, until a reconnect attempt of the channel failed while the traffic was still dropped; on main that wait does not end.

This PR does not change the lines that #27956 (CAMEL-25694, @gnodet) and #28045 (CAMEL-25690) change. It merges with each of them without conflicts in either order, and all three merge together; on top of #27956, the new test and the tests of #27956 pass.

mvn -Psourcecheck clean verify in components/camel-milo passes (81 tests, 1 skipped as on main), and the module build regenerates no files. The module tests passed three more times with -Dsurefire.rerunFailingTestsCount=0, once with -XX:ActiveProcessorCount=1.

@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

@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.

Well-designed fix for a real deadlock on single-CPU containers. By giving each MiloClientConnection its own ScheduledExecutorService, the fix correctly decouples Milo's internal tasks from the Camel route's connect()/close() lifecycle — eliminating the deadlock without affecting shared-executor users.

One low-severity defensive coding issue flagged below.

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

@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.

Following up on my earlier review — the lock leak issue has been addressed.

The previous finding flagged lock.unlock() being called after executor.shutdown() in the finally block, which could leave the lock permanently held if shutdown threw. The new commit wraps the executor shutdown in its own inner try/finally, guaranteeing lock.unlock() runs regardless. ✅

Pre-fix test validation: awaitedWriteCompletesWhileTheSharedScheduledExecutorOfMiloIsBusy correctly exercises the deadlock scenario — blocking all threads of Milo's shared executor, then doing an awaited write that would hang indefinitely without the per-connection executor. executorIsShutDownWithTheConnection validates cleanup. Both tests would fail on the original code.

No new issues found. LGTM.

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

@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.

@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. I traced the lifecycle of the new pool. init() creates it lazily under the connection lock, from the ExecutorServiceManager of the CamelContext, so the context also shuts it down when it stops. close() now shuts it down under the same lock, even when dispose() fails. MiloClientCachingConnectionManager closes the connection when its last producer or consumer releases it, so stopping and starting a route gives a new connection with a new pool. The Abort policy behaves as described: when the pool is shut down, CompletableFuture completes the dependent stage exceptionally with the RejectedExecutionException. With CallerRuns, the pool would drop the task silently. Both endpoints set the CamelContext on every createConnection(). The thread name leaves out the cache id, which contains the endpoint URI. The new test follows the JUnit style of the module and has no sleeps.

gnodet-bot's lock-leak point is fixed in 928fac4, and that thread is already resolved. The PR merges cleanly with #28045 and #27956 (checked with git merge-tree).

I left one non-blocking comment inline about how many threads each connection now gets. Approving.

Claude Code on behalf of Claus Ibsen

@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • components/camel-milo
  • docs

🔬 Scalpel shadow comparison — Scalpel: 8 of 706 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/browse/MiloBrowseEndpoint.java, 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/MiloClientEndpoint.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, 5m 52s total)

Total reactor time: 5m 52s

Module Duration Status
Camel :: Launcher 52.8s SUCCESS
Camel :: JBang :: MCP 47.0s SUCCESS
Camel :: JBang :: Plugin :: Validate 28.7s SUCCESS
Camel :: YAML DSL 26.4s SUCCESS
Camel :: Catalog :: Camel Catalog 25.9s SUCCESS
Camel :: YAML DSL :: Validator 25.7s SUCCESS
Camel :: Component DSL 20.4s SUCCESS
Camel :: Kamelet Main 19.4s SUCCESS
Camel :: JBang :: Plugin :: Testing 17.7s SUCCESS
Camel :: Docs 16.9s SUCCESS
Camel :: JBang :: Plugin :: Kubernetes 16.0s SUCCESS
Camel :: YAML DSL :: Deserializers 9.0s SUCCESS
Camel :: Catalog :: Camel Report Maven Plugin 8.5s SUCCESS
Camel :: Catalog :: Camel Route Parser 8.2s SUCCESS
Camel :: All Components Sync point 5.3s SUCCESS
Camel :: YAML DSL :: Validator Maven Plugin 4.9s SUCCESS
Camel :: YAML DSL :: Maven Plugins 3.1s SUCCESS
Camel :: Catalog :: Maven 3.1s SUCCESS
Camel :: Catalog :: Suggest (deprecated) 2.1s SUCCESS
Camel :: JBang :: Plugin :: Edit 1.5s SUCCESS
Camel :: Assembly 1.4s SUCCESS
Camel :: Coverage 1.1s SUCCESS
Camel :: JBang :: Plugin :: Generate 1.1s SUCCESS
Camel :: JBang :: Main 1.1s SUCCESS
Camel :: Catalog :: Dummy Component 1.0s SUCCESS
Camel :: JBang :: Plugin :: MCP 0.9s SUCCESS
Camel :: Catalog :: Console 0.8s SUCCESS
Camel :: JBang :: Integration tests 0.8s SUCCESS
Camel :: Endpoint DSL :: Support 0.7s SUCCESS
Camel :: Launcher :: Container 0.7s SUCCESS
Camel :: Endpoint DSL n/a
Camel :: Integration Tests n/a
Camel :: JBang :: Core n/a
Camel :: JBang :: Plugin :: TUI n/a
Camel :: Milo n/a

Top 20 slowest modules:

  • Camel :: Launcher (52.8s)
  • Camel :: JBang :: MCP (47.0s)
  • Camel :: JBang :: Plugin :: Validate (28.7s)
  • Camel :: YAML DSL (26.4s)
  • Camel :: Catalog :: Camel Catalog (25.9s)
  • Camel :: YAML DSL :: Validator (25.7s)
  • Camel :: Component DSL (20.4s)
  • Camel :: Kamelet Main (19.4s)
  • Camel :: JBang :: Plugin :: Testing (17.7s)
  • Camel :: Docs (16.9s)
  • Camel :: JBang :: Plugin :: Kubernetes (16.0s)
  • Camel :: YAML DSL :: Deserializers (9.0s)
  • Camel :: Catalog :: Camel Report Maven Plugin (8.5s)
  • Camel :: Catalog :: Camel Route Parser (8.2s)
  • Camel :: All Components Sync point (5.3s)
  • Camel :: YAML DSL :: Validator Maven Plugin (4.9s)
  • Camel :: YAML DSL :: Maven Plugins (3.1s)
  • Camel :: Catalog :: Maven (3.1s)
  • Camel :: Catalog :: Suggest (deprecated) (2.1s)
  • Camel :: JBang :: Plugin :: Edit (1.5s)

⚙️ View full build and test results

etzel-mes and others added 2 commits October 19, 2026 08:40
A client connection handled the outcome of its write, method call,
read and browse requests, and ran its reconnect job, on the shared
scheduled executor of Milo. A failed request disconnects the client,
and the CloseSession of the disconnect can wait for a reconnect attempt
of the channel, which Milo schedules on that same executor. The
executor has as many threads as the JVM has processors, so with one
processor its only thread waited for a task that only it could run:
the connection was not rebuilt, its consumers stayed silent, and every
call that needs the lock of the connection waited.

Each connection now has a scheduled thread pool of its own from the
ExecutorServiceManager of the CamelContext, with up to as many threads
as the shared scheduled executor of Milo, and shuts it down when it is
closed. Like the queue of that executor, its queue is not bounded,
because a rejected reconnect would end the reconnects of the
connection. A request that completes after the connection is closed
fails with a RejectedExecutionException instead of being dropped.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…l fails to shut down

close() released the lock of the connection at the end of the finally
block that shuts down its thread pool, so an exception from the
shutdown left the lock held. The lock now has a finally block of its
own, and the pool is still shut down when disposing the connection
fails.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@etzel-mes
etzel-mes force-pushed the fix/CAMEL-25713-milo-executor branch from 928fac4 to b065630 Compare October 19, 2026 05:44

@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 lock-leak finding was addressed in 928fac4. The commit b065630 restructures close() again, this time with a properly nested try-finally that guarantees lock.unlock() is reached even if ExecutorServiceManager.shutdown() throws — correct.

Code review of b065630:

The fix itself is sound. Each MiloClientConnection now owns a dedicated ScheduledExecutorService obtained from Camel's ExecutorServiceManager, breaking the shared-executor deadlock on single-CPU containers. Key observations:

  1. close() resource cleanup — The nested try-finally correctly ensures: (a) manager.dispose() runs first, (b) executor.shutdown() runs even if dispose throws, (c) lock.unlock() runs even if shutdown throws. ✅

  2. Thread safety — camelContext is volatile, executor is only written under lock, and checkInit() is guarded by the same lock. The only potential concern is that close() does not reset initialized to false, but that's a pre-existing pattern (the old code also nulled manager in close() without resetting the flag), and the connection is not expected to be reused after close. Acceptable.

  3. init() pool configuration — poolSize = availableProcessors() with unbounded queue and Abort rejection policy. The pool sizing was already noted by @davsclaus as a future optimization opportunity (many connections × many processors = many idle threads). Not a blocker.

  4. CamelContextAware integration — Both MiloBrowseEndpoint and MiloClientEndpoint now inject the CamelContext into the connection after creation. The upgrade guide documents the new setCamelContext requirement for externally-created MiloClientConnection instances. ✅

  5. Tests — awaitedWriteCompletesWhileTheSharedScheduledExecutorOfMiloIsBusy directly exercises the deadlock scenario by saturating Milo's shared executor with blocking tasks, then verifying a write still completes (it uses the connection's own pool). executorIsShutDownWithTheConnection verifies thread cleanup. Both are well-targeted. ✅

No issues found on this revision.

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. Since my approval the branch was rebased on main. git range-diff shows both commits unchanged; only the context of the upgrade-guide entry moved, because other 4.24 entries landed above it. The branch still merges cleanly with main. CI for the new head was just approved and is running.

My inline note about how many threads each connection gets is still open. It does not block this PR (the upgrade guide covers it), but please reply to it or resolve it when you've read it. Approval stands.

Claude Code on behalf of Claus Ibsen. This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

@davsclaus

Copy link
Copy Markdown
Contributor

Closing and reopening to re-run CI against main: the CatalogSamplesTest.callingAServiceIsTheHttpComponentNotEnrich failure in camel-jbang-core came from main and is fixed there (bbd6f75). Nothing to change in this PR.

Claude Code on behalf of Claus Ibsen

@davsclaus davsclaus closed this Oct 19, 2026
@davsclaus davsclaus reopened this Oct 19, 2026
@davsclaus davsclaus closed this Oct 19, 2026
@davsclaus davsclaus reopened this Oct 19, 2026
@davsclaus davsclaus added this to the 4.24.0 milestone Oct 19, 2026
@davsclaus davsclaus added the bug Something isn't working label Oct 19, 2026
@davsclaus
davsclaus merged commit 39a7365 into apache:main Oct 19, 2026
19 of 25 checks passed
@etzel-mes
etzel-mes deleted the fix/CAMEL-25713-milo-executor 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

bug Something isn't working components docs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants