Skip to content

CAMEL-25694: camel-milo - fix write/call service failures being reported as success - #27956

Merged
davsclaus merged 7 commits into
apache:mainfrom
gnodet:camel-25694-milo-write-call-failures
Oct 23, 2026
Merged

davsclaus merged 7 commits into
apache:mainfrom
gnodet:camel-25694-milo-write-call-failures

Conversation

@gnodet

@gnodet gnodet commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Fix OPC-UA write/call/readValues/browse service failures being silently swallowed and reported as success when using the awaited (CamelMiloAwait=true) mode.

Root Cause

SubscriptionManager.write(), call(), readValues(), and browse() all chained the underlying Milo client future with handleAsync():

return this.connected.write(nodeId, value).handleAsync((status, e) -> {
    if (e != null) {
        handleConnectionFailure(e);
    }
    return status;   // returns null when e != null
}, this.executor);

When the OPC-UA operation fails (e.g. Bad_Timeout service fault), handleAsync receives a non-null exception. It calls handleConnectionFailure(e) for the reconnection side-effect, then returns the value (null), converting the exceptional completion into a normal null completion.

MiloClientProducer awaits the returned future and only propagates errors when the throwable is non-null:

future.whenComplete((result, throwable) -> {
    if (throwable != null) exchange.setException(throwable);
    else msg.setBody(result);   // sets null body → reports success
});

Since handleAsync had already discarded the exception, the producer saw a null-completed future, set the body to null, and reported success. No error was raised to the Camel route.

Fix

Replace handleAsync() with whenCompleteAsync() in all four methods. whenCompleteAsync runs the side-effect (reconnection) but propagates the original completion unchanged — exceptions stay exceptional, successes stay successful:

return this.connected.write(nodeId, value).whenCompleteAsync((status, e) -> {
    // handle outside the lock, running using
    // whenCompleteAsync (preserves the original exceptional completion)
    if (e != null) {
        handleConnectionFailure(e);
    }
}, this.executor);

Tests

Two new test classes:

  • MiloClientProducerWriteFailureTest: tests that MiloClientProducer correctly propagates awaited write/call failures as exchange exceptions, and that non-awaited (fire-and-forget) writes do not set exceptions. Uses a MiloClientConnection stub returning pre-arranged futures.

  • SubscriptionManagerExceptionPropagationTest: tests the whenCompleteAsync-vs-handleAsync future completion contract directly (no server needed), and verifies that disconnected managers return exceptional futures from write(), call(), and readValues().

Fixes https://issues.apache.org/jira/browse/CAMEL-25694

Hermes Agent (Claude Sonnet 4.6) on behalf of Guillaume Nodet

@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

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • components/camel-milo
  • docs

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

Recovered flaky tests on JDK 21 (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/internal/SubscriptionManager.java, components/camel-milo/src/test/java/org/apache/camel/component/milo/call/CallClientTest.java, components/camel-milo/src/test/java/org/apache/camel/component/milo/client/MiloClientProducerWriteFailureTest.java, components/camel-milo/src/test/java/org/apache/camel/component/milo/client/internal/SubscriptionManagerExceptionPropagationTest.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 60s total)

Total reactor time: 5m 60s

Module Duration Status
Camel :: Launcher 51.9s SUCCESS
Camel :: JBang :: MCP 43.8s SUCCESS
Camel :: Component DSL 35.0s SUCCESS
Camel :: JBang :: Plugin :: Validate 29.0s SUCCESS
Camel :: Catalog :: Camel Catalog 26.3s SUCCESS
Camel :: YAML DSL 24.3s SUCCESS
Camel :: YAML DSL :: Validator 24.0s SUCCESS
Camel :: Kamelet Main 16.6s SUCCESS
Camel :: Docs 16.0s SUCCESS
Camel :: JBang :: Plugin :: Kubernetes 13.7s SUCCESS
Camel :: JBang :: Plugin :: Testing 13.0s SUCCESS
Camel :: YAML DSL :: Deserializers 11.3s SUCCESS
Camel :: Catalog :: Camel Route Parser 8.9s SUCCESS
Camel :: Catalog :: Camel Report Maven Plugin 7.9s SUCCESS
Camel :: All Components Sync point 5.7s SUCCESS
Camel :: Catalog :: Suggest (deprecated) 5.7s SUCCESS
Camel :: YAML DSL :: Validator Maven Plugin 4.7s SUCCESS
Camel :: YAML DSL :: Maven Plugins 3.8s SUCCESS
Camel :: Catalog :: Maven 2.7s SUCCESS
Camel :: Endpoint DSL :: Support 2.1s SUCCESS
Camel :: Assembly 2.0s SUCCESS
Camel :: Catalog :: Dummy Component 2.0s SUCCESS
Camel :: JBang :: Plugin :: Generate 1.5s SUCCESS
Camel :: JBang :: Plugin :: Edit 1.4s SUCCESS
Camel :: Coverage 1.3s SUCCESS
Camel :: Catalog :: Console 1.3s SUCCESS
Camel :: JBang :: Plugin :: MCP 1.0s SUCCESS
Camel :: JBang :: Main 1.0s SUCCESS
Camel :: Launcher :: Container 0.8s SUCCESS
Camel :: JBang :: Integration tests 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 (51.9s)
  • Camel :: JBang :: MCP (43.8s)
  • Camel :: Component DSL (35.0s)
  • Camel :: JBang :: Plugin :: Validate (29.0s)
  • Camel :: Catalog :: Camel Catalog (26.3s)
  • Camel :: YAML DSL (24.3s)
  • Camel :: YAML DSL :: Validator (24.0s)
  • Camel :: Kamelet Main (16.6s)
  • Camel :: Docs (16.0s)
  • Camel :: JBang :: Plugin :: Kubernetes (13.7s)
  • Camel :: JBang :: Plugin :: Testing (13.0s)
  • Camel :: YAML DSL :: Deserializers (11.3s)
  • Camel :: Catalog :: Camel Route Parser (8.9s)
  • Camel :: Catalog :: Camel Report Maven Plugin (7.9s)
  • Camel :: All Components Sync point (5.7s)
  • Camel :: Catalog :: Suggest (deprecated) (5.7s)
  • Camel :: YAML DSL :: Validator Maven Plugin (4.7s)
  • Camel :: YAML DSL :: Maven Plugins (3.8s)
  • Camel :: Catalog :: Maven (2.7s)
  • Camel :: Endpoint DSL :: Support (2.1s)

⚙️ View full build and test results

@etzel-mes

Copy link
Copy Markdown
Contributor

For reference: #28045 (CAMEL-25690) changes how the write, call, read and browse requests of SubscriptionManager.Connected resolve a node ID with an unknown nsu=, and does not touch the four guards that this PR changes. The two merge without conflicts in either order, so I kept #28045 independent of this PR. With this PR, the method call answer of #28045 (Bad_NodeIdUnknown or Bad_MethodInvalid) also reaches the exchange; I left it out of that PR's docs and test for now and can add it once this PR is merged.

Claude Code on behalf of etzel-mes

gnodet and others added 2 commits October 21, 2026 16:16
…ted as success

In SubscriptionManager.write(), call(), readValues() and browse(), the futures
returned by the underlying Milo client were chained with handleAsync(). When the
upstream future completed exceptionally (e.g. Bad_Timeout service fault), the
handleAsync() callback ran with a non-null 'e' parameter but returned the result
value (null), converting the exceptional completion into a normal completion with
a null value.

MiloClientProducer awaits the future with whenComplete() and only sets an exchange
exception when the throwable is non-null. Since handleAsync() had already discarded
the exception, the producer saw a null-completed future, reported success, and set
the exchange body to null.

The fix replaces handleAsync() with whenCompleteAsync() in all four methods.
whenCompleteAsync() runs the side-effect (handleConnectionFailure) but propagates
the original completion unchanged: exceptional stays exceptional, success stays
success.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…itial write

OPC UA consumers deliver the current server node value when they subscribe.
In the original test, all routes started automatically before the write, so
each consumer received two messages: the uninitialised value at subscription
time, and the 'Foo' write. The mock expected exactly 1 message, causing a
deterministic failure ('Expected: <1> but was: <2>').

Follow the pattern established in MonitorItemTest: mark consumer routes with
autoStartup(false), write the initial value first via the server producer,
then start all consumer routes. Each consumer then sees only one notification
(the value written before it subscribed).
@gnodet
gnodet force-pushed the camel-25694-milo-write-call-failures branch from 49a57ca to 1ef8bd8 Compare October 21, 2026 16:17
@gnodet
gnodet marked this pull request as ready for review October 22, 2026 15:40

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

The production fix is correct — replacing handleAsync with whenCompleteAsync in all four SubscriptionManager methods properly preserves exceptional completions while still triggering the reconnection side-effect. The fix also resolves a latent data-loss bug in call() where handleAsync unconditionally returned null even on success, silently discarding the CallMethodResult.

However, neither new test class actually exercises the fixed code path in SubscriptionManager.

Side note: the old call() handleAsync returned null unconditionally (line 1675 before the fix) even on success, silently discarding the CallMethodResult. Worth mentioning in the commit since it's a separate data-loss bug — the other three methods returned the actual result.


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 Guillaume, the production change is the right one: whenCompleteAsync keeps the reconnect side effect and the original completion, so an awaited write/call/read that fails now fails the exchange and the route's error handling runs.

TL;DR

Comment - the fix in SubscriptionManager is correct and merges cleanly with the recent camel-milo work on main, but no test fails without it, and the change to the awaited call() body is neither tested nor documented.

Blockers

  • None in the production code.

Other findings: 2 to address before merge, 2 non-blocking - see below.

Before merge

  1. No test fails without the fix (agreeing with the three gnodet-bot threads, which the current head does not address yet). MiloClientProducerWriteFailureTest stubs MiloClientConnection and never reaches SubscriptionManager. SubscriptionManagerExceptionPropagationTest tests the JDK CompletableFuture contract and the connected == null path. A cheap test that does go through the fixed code is in the existing CallClientTest, which runs against a real server: before this PR an awaited call always produced a null body (return null; in handleAsync, there since CAMEL-12288 in 2018), now it is the CallMethodResult:

    @Test
    public void testAwaitedCallReturnsResult() {
        CallMethodResult result = producer1.requestBodyAndHeader(DIRECT_START_1, "foo", "CamelMiloAwait", true,
                CallMethodResult.class);
    
        assertNotNull(result);
        assertTrue(result.getStatusCode().isGood());
        assertEquals("out-foo", result.getOutputArguments()[0].getValue());
    }

    For the failure side, main now has the TcpRelay test helper (used by CommunicationLossTest), and the JIRA reporter reproduced the bug by dropping request traffic through a relay; a server method that blocks longer than a small requestTimeout would give the Bad_Timeout case from the JIRA. Once there is a real test, the two JDK contract tests and the stub-based producer test could go.

  2. Upgrade guide entry. Users upgrading will notice two things, and the JIRA asks to cover the first explicitly: an awaited call now returns the CallMethodResult as the body instead of null, and an awaited write/call/read that fails at the service or transport level now fails the exchange (so onException, redelivery and dead letter run) instead of continuing with a null body. A short === camel-milo - ... entry in camel-4x-upgrade-guide-4_24.adoc would do.

Non-blocking

  1. The ServerCertificateValidationTest change is comments only: main already starts those consumer routes after the write since CAMEL-25645 (#27999), so the second commit no longer fixes anything. Dropping the file from the PR keeps the history clean (inline).

  2. The bug is on camel-4.22.x and camel-4.18.x as well (same handleAsync in all four methods), so port/camel-4.22.x / port/camel-4.18.x labels look right if this should be backported. On an LTS patch release the new call body is the part to think about; restoring the result is reasonable, but it goes in the upgrade note there too.

CI is green, but it ran on 2026-10-21, before the CAMEL-25875, CAMEL-25939 and CAMEL-25913 merges on main. GitHub reports the PR as mergeable/clean, a trial merge has no conflicts, and the constructors the new tests use (SubscriptionManager, MiloClientConnection, MiloClientEndpoint, MiloClientProducer) are unchanged on main, so the risk is low; the next push will re-run CI on top of current main anyway.

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

…dundant test changes

- Add testAwaitedCallReturnsResult() to CallClientTest to verify that an awaited
  call returns a non-null CallMethodResult with a good status and the expected
  output argument (CAMEL-25694 regression test on the real server path)
- Add upgrade guide entry for the two observable behaviour changes:
  * an awaited write/call/read that fails now fails the exchange
  * an awaited call that succeeds now returns CallMethodResult as the body
- Revert comment-only changes to ServerCertificateValidationTest: main already
  has the autoStartup(false) fix from CAMEL-25645 (apache#27999), so these rewrites
  only add noise to the commit history
@gnodet gnodet added port/camel-4.22.x Bug needs porting to camel-4.22.x port/camel-4.18.x Bug needs porting to camel-4.18.x labels Oct 23, 2026
…ite-call-failures

# Conflicts:
#	docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc

@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 main findings have been addressed.

✅ testAwaitedCallReturnsResult in CallClientTest — this is the regression test that was missing. It runs against the real embedded OPC-UA server, which routes through MiloClientProducer → MiloClientConnection.call() → SubscriptionManager.call(), exercising the exact fixed code path. Before the fix, handleAsync returned null unconditionally even on success; this test would have failed. Valid.

✅ Upgrade guide — both observable behavior changes are documented: awaited-failure-now-fails-exchange and awaited-call-success-now-returns-CallMethodResult.

✅ ServerCertificateValidationTest comment-only noise reverted.


Remaining: write() failure propagation not covered by a real server test.

testAwaitedCallReturnsResult covers the call() success path through the full stack. The original gnodet-bot threads (PRRT_kwDOAAMl7c6vRLGJ / PRRT_kwDOAAMl7c6vRLGO) also flagged that MiloClientProducerWriteFailureTest and SubscriptionManagerExceptionPropagationTest don't exercise SubscriptionManager on a connected manager with a failing upstream. That gap still exists for write(), readValues(), and browse() — there is no server-path test that verifies a Bad_Timeout on a write operation fails the exchange rather than setting a null body.

The fix itself is unambiguously correct (whenCompleteAsync preserves the exceptional completion; handleAsync did not). The production code is safe. But a reviewer reverting the fix in write() or readValues() would not be caught by the current test suite — only the call() path has a real regression test now.

This is non-blocking if the author is comfortable with that coverage gap. The CommunicationLossTest pattern (using TcpRelay to simulate network loss on an active connection) is the cleanest path to a write failure test, but it's out of scope here.

Low severity (thread PRRT_kwDOAAMl7c6vRLGS): The Javadoc on testWriteWhenDisconnectedReturnsExceptionalFuture still doesn't clarify it tests newNotConnectedResult() rather than the whenCompleteAsync fix. Suggested comment was provided in the prior review — still applies.

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

@github-actions github-actions Bot added the docs label Oct 23, 2026

@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 Guillaume, the new commit covers both points from my earlier review:

  1. Test - CallClientTest#testAwaitedCallReturnsResult runs an awaited call against the real server, so it goes through SubscriptionManager.call(). It fails without the fix: the old handleAsync returned null, so the body was null. Good.
  2. Upgrade guide - the new entry covers both changes: a failed awaited operation now fails the exchange, and an awaited call now returns the CallMethodResult. One example in it is not right, see inline: a write with the wrong type gets a per-node Bad_TypeMismatch StatusCode, so the future completes normally and the exchange does not fail. The JIRA asks to keep that per-node behaviour, and the code does.
  3. The ServerCertificateValidationTest change is reverted, and the port/camel-4.22.x / port/camel-4.18.x labels are on.

Non-blocking: MiloClientProducerWriteFailureTest and SubscriptionManagerExceptionPropagationTest are unchanged, so the first two gnodet-bot threads were resolved without a code change. The new real-server test now covers the call path, so that is fine. The failure path (Bad_Timeout / connection loss on a write) is still only covered through the stub, as gnodet-bot's follow-up says. A TcpRelay test can come later.

LGTM once CI is green (the main build jobs are still running).

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

Comment thread docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc Outdated

@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 reviews — all outstanding issues have been addressed.

✅ Upgrade guide wording — the Bad_TypeMismatch example has been corrected. The entry now accurately distinguishes transport/service failures (which fail the exchange) from per-node status codes (which are returned as the body). Wording matches davsclaus's suggestion exactly.

✅ testAwaitedCallReturnsResult in CallClientTest — already confirmed in the previous review. Exercises the full stack through the fixed code path.

✅ ServerCertificateValidationTest revert — already confirmed.

✅ All three gnodet-bot review threads are resolved.

The low-severity Javadoc nit on testWriteWhenDisconnectedReturnsExceptionalFuture (suggestion to note it tests newNotConnectedResult() not whenCompleteAsync) was resolved without a code change. That's fine — the surrounding section comment block already explains this is the disconnected-path test, and davsclaus approved without flagging it.

The write/readValues failure path coverage gap (no real server test with a failing upstream) remains, but was already marked non-blocking. The production fix is unambiguously correct — whenCompleteAsync preserves the exceptional completion; handleAsync did not.

LGTM.

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 reviews — the new commit addresses the remaining signature issue.

✅ requestBodyAndHeader signature fixed — DIRECT_START_1 is now correctly passed as the first (endpoint URI) argument. The previous call requestBodyAndHeader("foo", "CamelMiloAwait", true, ...) was invoking the 4-arg overload requestBodyAndHeader(Object body, String headerName, Object headerValue, Class<T> type) — without routing through producer1 to an actual endpoint, the test was never hitting the OPC-UA server and was almost certainly passing accidentally rather than through the fixed code path.

All previous findings are resolved. Production code is correct. CI pending on the latest push.

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

@davsclaus
davsclaus merged commit 3307540 into apache:main Oct 23, 2026
7 checks passed
@gnodet

gnodet commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

🔄 Backport Bot

Conflicts (🤖 agent dispatched to resolve):

  • ⚠️ camel-4.22.x — Cherry-pick conflict in: docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
  • ⚠️ camel-4.18.x — Cherry-pick conflict in: docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc

ℹ️ If you push additional commits after /port, use /port again to update the port PRs.

@gnodet

gnodet commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

✅ Backport to camel-4.22.x created: #28367

@gnodet

gnodet commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

✅ Backport to camel-4.18.x created: #28368

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants