Skip to content

CAMEL-25835: camel-milo - the consumer reports a lost connection and refreshes its values after it - #28128

Merged
davsclaus merged 2 commits into
apache:mainfrom
etzel-mes:feature/CAMEL-25835-milo-communication-loss
Oct 20, 2026
Merged

davsclaus merged 2 commits into
apache:mainfrom
etzel-mes:feature/CAMEL-25835-milo-communication-loss

Conversation

@etzel-mes

Copy link
Copy Markdown
Contributor

Description

CAMEL-25835.

The milo-client consumer hands every data change notification to its route and reports nothing when the values stop. When the session drops and Milo activates it again, the consumer only flips its sessionActive flag, and the values that did not change during the outage are not delivered again. When the subscription is transferred or the connection is rebuilt, the route sees a pause and then first values. When the server cannot be reached at startup, or the client cannot connect at all, the route sees nothing. A route cannot tell "unchanged" from "unknown": a historian keeps the last value, a publisher republishes it as live, an alarm stays quiet.

The change, following the OPC UA specifications (OPC 10000-4 §7.38 Table 179 and 10000-8 §7.3 Table 61 for Bad_NoCommunication, 10000-4 §7.11.3 for the timestamps, 10000-14 §6.2.11.1 Table 80 for a consumer without a last value, 10000-4 §6.7 step 6 and 10000-5 §9.2 for ResendData):

  • Signal. When communication with the server is lost, every monitored node delivers one exchange whose DataValue has a null value and the status Bad_NoCommunication, both timestamps set to the time the loss was recognised. The triggers are the session becoming inactive (SessionActivityListener), a rebuild of the connection (handleConnectionFailure, before it disposes the connection), and a registration while there is no connection or the session is inactive. The report of a connection is queued on its subscription's delivery queue, so it lands after the values the queue still holds and before the values of the activated session; a registration without a connection is reported on the connection's executor, not on the thread that starts the route.
  • Once per loss. Each registration keeps whether its last delivery was the report, and the generation of the connection that delivered its last value; both guarded by the registration, so a report and a value of the same node never interleave. A report is skipped when the node is reported already, when a later connection has delivered a value, and when the server refused the node's monitored item (it keeps that status, which does not depend on the communication; the mark is cleared when an item is created for it).
  • Refresh. When the same session is activated again (same session ID as the lost one), the consumer calls ResendData for its subscription, so the server sends the current value of every node, the unchanged ones included. Any answer other than Good or a lost session recreates the subscription with the recovery of CAMEL-25691 (delete, then create again in the session; the new items deliver their first values), logged at WARN. After a transfer or a rebuild the server sends the first values anyway.
  • Not done. No value cache, no option, no debounce, no marker header, no read fallback (every open-source server stack I read implements ResendData: Milo, open62541, node-opcua, the OPC Foundation .NET stack), no handling of lost notifications, no per-node ordering rules. Event items are unchanged.
  • Tests. New CommunicationLossTest with a TCP relay between the client and the embedded server: two losses in a row on the same client and session (asserted), the unchanged value resent, a change in between delivered, a refused node on the same connection keeps Bad_NodeIdUnknown; and a consumer started with the relay down reports first and receives its value once the relay is up. MonitorItemLifecycleTest, MonitorItemMultiConnectionsTest and ServerCertificateValidationTest now expect the report for a rebuilt connection and for clients that cannot connect at all. The embedded Milo server answers ResendData on the oldest queued Publish request, which belongs to the dropped channel, so the resent value reaches the client 10-20 s later through its republish of the missed notification; the relay test waits for it and says why (a real server answered in 2 ms).
  • Docs. A "Communication loss and recovery" section in the component doc (with its catalog copy) and a 4.24 upgrade-guide note: the null body becomes 0 or false through the type converters, a route checks ${body.statusCode.bad}; repeats after an outage; clients that cannot connect now report; the consumer's route runs during an outage.

Against a server that is not built on Milo (Microsoft opc-plc 2.15.4): a 20 s silent drop with and without writes, a 150 s drop, a server restart, a value held in a slow route with another queued behind it and then a drop, a consumer started while the server was stopped, and a refused node next to a good one. One Bad_NoCommunication per node per outage in every run; ResendData answered Good after the same session was activated again (the unchanged value arrived 0.1 s later), a silent drop was recognised after 10-14 s with Milo's keep-alive defaults, and the long drop and the restart ended in a transfer failure and a rebuild with first values. The report that was queued behind a value held in the route landed after that value.

CAMEL-25692 (retry of refused monitored items) changes the registration code too; I will rebase whichever lands second. A producer request that fails because the session was lost still rebuilds the shared connection, so its consumers see the report and first values instead of a reactivation; a rebuild only for other failures belongs with CAMEL-25694 (#27956), which rewrites those blocks.

Checked with -Psourcecheck verify on the module (88 tests, no reruns).

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

The concurrency design is solid. Verified five critical questions via Milo 1.1.8 bytecode analysis and code tracing — all non-issues. Two low-severity observations below.

Pre-fix test validation: Static trace of CommunicationLossTest.lostSessionReportsTheNodeAndResendsItsValue confirms it exercises the new code path: relay.dropConnections() triggers onSessionInactive → reportLoss → Subscription.report() → consumer delivery. The assertNoCommunication() assertion would fail without the fix (no Bad_NoCommunication was delivered before). ✅

Concurrency validation (all non-issues):

  • reportLoss before dispose() in handleConnectionFailure: TaskQueue.execute() enqueues onto the delivery executor which is not shut down by client.disconnect(). The loss task executes normally.
  • closed flag race with onSessionInactive: Milo dispatches SessionActivityListener callbacks asynchronously via executor.execute(). dispose() writes closed = true (volatile) before calling disconnect(). The async callback sees the write and skips the spurious report.
  • sessionActive = true initialization: safe — performConnect() blocks until success; if it fails, the listener is never associated with a Connected instance.
  • refused volatile field: volatile write in putSubscriptions is visible to the subsequent synchronized read in report(). No race.
  • generationCounter.get() (not incrementAndGet()) in registerItems: correct — generation 0 is always less than any connection's generation (≥1), so Subscription.report() drops the stale report once the first real value arrives.

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

@allthingssecurity allthingssecurity 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 a careful piece of work. I found one ordering gap in the report of a node that is
registered while there is no connection, on 7c92ff084e20.

A node registered while a reconnect is in flight can end up showing Bad_NoCommunication while the connection is
up
(SubscriptionManager.registerItems, lines 1499-1501, with Subscription.report, line 311)

When connected == null, registerItems tags the report with generationCounter.get() and runs it on
this.executor. But performConnect increments the counter when it creates the listener (line 1287), before
client.connect() and well before performAndEvalConnect installs connected. So a consumer that starts during a
reconnect attempt gets the generation of the connection that is being built. Its report and that connection's first
value then carry the same generation, and report only drops a report when this.generation > generation. The
report runs on the shared MiloClient pool and the value on the subscription's delivery queue, so nothing orders
them; if the pool is busy (it has availableProcessors() threads, and with this PR it also runs the routes of other
nodes' loss reports), the report lands after the good value. The node then stays Bad_NoCommunication until its value
next changes, which is the situation this PR sets out to avoid.

Reproducer (a test in org.apache.camel.component.milo, on the embedded server of AbstractMiloServerTest; it uses
reflection to hit the window, and an executor that holds its tasks to stand in for a busy pool):

SubscriptionManager manager = new SubscriptionManager(endpoint.getConfiguration(), reconnect, held::add, 10_000);
// connection 1 is up; it fails and is rebuilt right away
handleConnectionFailure.invoke(manager, new Exception("connection failed"));
// wait until generationCounter == 2 while connected == null, take `lock`, then:
manager.registerItems(Map.of(node, values::add), 50.0, null, null);
// connection 2 delivers the current value; then the pool runs the report
await values.size() == 1; held.forEach(Runnable::run);
assertTrue(values.get(values.size() - 1).getStatusCode().isGood());

Observed:

values delivered: [DataValue[value=Variant{value=Foo}, statusCode=Good ...],
                   DataValue[value=Variant{value=null}, statusCode=Bad_NoCommunication ...]]
AssertionFailedError: the connection is up and the value unchanged, but the last delivery is
  DataValue[value=Variant{value=null}, statusCode=StatusCode[name=Bad_NoCommunication, value=0x80310000 ...

Suggested fix: a node registered without a connection has had no value from any connection, so its report can use a
generation below every connection's, for example 0 (connections start at 1, and Subscription.generation starts
at 0):

if (this.connected == null) {
    final DateTime time = DateTime.now();
    // lower than the generation of any connection: dropped once any connection has delivered a value
    this.executor.execute(() -> deliverLoss(subscriptions, 0L, time));
}

With that change the test above passes, and CommunicationLossTest, MonitorItemLifecycleTest,
MonitorItemMultiConnectionsTest and ServerCertificateValidationTest still pass (14 tests).

Claude Code on behalf of allthingssecurity

@etzel-mes

Copy link
Copy Markdown
Contributor Author

Thanks @allthingssecurity, you are right: the counter is taken when the listener is created, before the connection is built, so a node registered during that attempt shared the generation of the connection's first value, and nothing ordered the report on the pool after it. Fixed in 66d3d31 as you suggest: the report of a node registered without a connection carries generation 0, below every connection's, so any delivered value drops it. Module verify with -Psourcecheck is green (88 tests, no reruns).

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.

✅ Re-review after latest commits (66d3d31) — all findings verified, no issues found.

The generation-counter approach correctly solves the race condition flagged by allthingssecurity. Key points confirmed:

  • Generation-0 suppression: deliverLoss() uses gen=0; generationCounter.incrementAndGet() ensures real connections carry gen≥1; the report() guard this.generation > generation cleanly drops stale gen-0 loss reports once a real connection delivers
  • HashMap→ConcurrentHashMap: correctly supports lock-free reads in deliverLoss() while writes remain under lock
  • refuse() / report() synchronization: volatile+synchronized guards ensure refused nodes cannot have their status overridden by a late report() call
  • refused=false reset on reconnect: volatile write under lock, followed by deliver() advancing the generation — structurally sound
  • Test coverage: consumerStartedWithoutTheServerReportsTheNodeThenReceivesItsValue exercises exactly the fix: gen-0 loss on startup, then assertGoodValue("Foo") on reconnect
  • Documentation: Bad_NoCommunication, null value, DateTime.now() timestamps all match deliverLoss() implementation

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 for the very thorough work and the opc-plc verification. I checked the Milo calls against 1.1.8: OpcUaSubscription.getDeliveryQueue(), callAsync, and NodeIds.Server_ResendData all exist, and SessionActivityListener callbacks go through a sequential executor, so the transitions arrive in order. Nothing that holds lock calls a synchronized method of Subscription, so I see no deadlock. There is no textual conflict with #27956.

On by default with no option is fine with me, given the upgrade-guide entry. Two small things, see inline.

Open questions, not blocking:

  • When the client cannot connect at all (wrong credentials, untrusted certificate), would a more specific status (Bad_UserAccessDenied, Bad_SecurityChecksFailed) be more useful than Bad_NoCommunication? Keeping the cause in the log only is defensible.
  • CommunicationLossTest can wait up to 2x30 s plus 60 s, because the embedded server republishes late. Acceptable, but if any of it can be shortened, the module build would thank you.

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

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • catalog/camel-catalog
  • components/camel-milo
  • docs

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

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

Skip-tests mode would test 8 modules (3 direct + 7 downstream), skip tests for 25 (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/docs/milo-client-component.adoc, 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/AbstractMiloServerTest.java, components/camel-milo/src/test/java/org/apache/camel/component/milo/CommunicationLossTest.java, components/camel-milo/src/test/java/org/apache/camel/component/milo/MonitorItemLifecycleTest.java, components/camel-milo/src/test/java/org/apache/camel/component/milo/MonitorItemMultiConnectionsTest.java, components/camel-milo/src/test/java/org/apache/camel/component/milo/ServerCertificateValidationTest.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 (25)
  • apache-camel
  • camel-allcomponents
  • 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 39s total)

Total reactor time: 5m 39s

Module Duration Status
Camel :: Launcher 53.4s SUCCESS
Camel :: JBang :: MCP 45.2s SUCCESS
Camel :: JBang :: Plugin :: Validate 29.5s SUCCESS
Camel :: YAML DSL :: Validator 25.2s SUCCESS
Camel :: YAML DSL 24.3s SUCCESS
Camel :: Catalog :: Camel Catalog 23.9s SUCCESS
Camel :: Component DSL 19.6s SUCCESS
Camel :: Kamelet Main 15.8s SUCCESS
Camel :: Docs 14.7s SUCCESS
Camel :: JBang :: Plugin :: Testing 13.5s SUCCESS
Camel :: JBang :: Plugin :: Kubernetes 13.5s SUCCESS
Camel :: Catalog :: Camel Report Maven Plugin 8.1s SUCCESS
Camel :: YAML DSL :: Deserializers 7.9s SUCCESS
Camel :: Catalog :: Camel Route Parser 7.7s SUCCESS
Camel :: Catalog :: Suggest (deprecated) 6.8s SUCCESS
Camel :: All Components Sync point 5.0s SUCCESS
Camel :: YAML DSL :: Validator Maven Plugin 3.9s SUCCESS
Camel :: Catalog :: Maven 3.4s SUCCESS
Camel :: YAML DSL :: Maven Plugins 3.3s SUCCESS
Camel :: Assembly 2.5s SUCCESS
Camel :: Catalog :: Dummy Component 1.5s SUCCESS
Camel :: Coverage 1.4s SUCCESS
Camel :: JBang :: Plugin :: Generate 1.4s SUCCESS
Camel :: JBang :: Plugin :: Edit 1.4s SUCCESS
Camel :: JBang :: Main 1.1s SUCCESS
Camel :: JBang :: Plugin :: MCP 1.1s SUCCESS
Camel :: JBang :: Integration tests 1.1s SUCCESS
Camel :: Catalog :: Console 1.0s SUCCESS
Camel :: Launcher :: Container 0.8s SUCCESS
Camel :: Endpoint DSL :: Support 0.8s 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 (53.4s)
  • Camel :: JBang :: MCP (45.2s)
  • Camel :: JBang :: Plugin :: Validate (29.5s)
  • Camel :: YAML DSL :: Validator (25.2s)
  • Camel :: YAML DSL (24.3s)
  • Camel :: Catalog :: Camel Catalog (23.9s)
  • Camel :: Component DSL (19.6s)
  • Camel :: Kamelet Main (15.8s)
  • Camel :: Docs (14.7s)
  • Camel :: JBang :: Plugin :: Testing (13.5s)
  • Camel :: JBang :: Plugin :: Kubernetes (13.5s)
  • Camel :: Catalog :: Camel Report Maven Plugin (8.1s)
  • Camel :: YAML DSL :: Deserializers (7.9s)
  • Camel :: Catalog :: Camel Route Parser (7.7s)
  • Camel :: Catalog :: Suggest (deprecated) (6.8s)
  • Camel :: All Components Sync point (5.0s)
  • Camel :: YAML DSL :: Validator Maven Plugin (3.9s)
  • Camel :: Catalog :: Maven (3.4s)
  • Camel :: YAML DSL :: Maven Plugins (3.3s)
  • Camel :: Assembly (2.5s)

⚙️ View full build and test results

@davsclaus
davsclaus force-pushed the feature/CAMEL-25835-milo-communication-loss branch from 66d3d31 to 28ae070 Compare October 20, 2026 07:21
@etzel-mes
etzel-mes force-pushed the feature/CAMEL-25835-milo-communication-loss branch from 28ae070 to 2b688a7 Compare October 20, 2026 08:15
@etzel-mes

Copy link
Copy Markdown
Contributor Author

Thanks for the review. On the status: OPC 10000-4 §7.38 lists Bad_UserAccessDenied, Bad_SecurityChecksFailed and Bad_CertificateUntrusted in Table 178, the common service result codes, i.e. the result of ActivateSession or OpenSecureChannel. Bad_NoCommunication is in Table 179, the operation level codes, defined as "communication with the data source is defined, but not established, and there is no last known value available", and OPC 10000-8 Table 61 and OPC 10000-14 Table 80 use it for exactly this situation, with a null value. A monitored item has no session failure of its own, so we would keep Bad_NoCommunication on the value and the cause in the log.

On the test: the 30 s and 60 s values are caps and are not spent when the value arrives; the class takes about 58 s. Each drop costs about 18 s that the test cannot remove: the embedded server answers ResendData on the Publish request of the dropped channel, so the value arrives with the next keep-alive (about 10 s, Milo derives the keep-alive count from a 10 s target), and the component's reconnect back-off is 10 s. The remaining fixed assert periods are small; I have left them as they are.

Claude Code on behalf of etzel-mes

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

LGTM, thanks. Both points are addressed: the upgrade guide says how to keep the previous behaviour, and the constructor now comes after the fields. The rest of the delta is the rebase onto main.

One optional wording nit is inline.

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
etzel-mes and others added 2 commits October 20, 2026 14:00
…refreshes its values after it

The consumer delivered nothing when communication with the server was
lost, and after the same session was activated again the values that
did not change were not delivered again. A route could not tell
"unchanged" from "unknown".

Every monitored node now delivers one exchange with a null value and
Bad_NoCommunication, stamped with the time the loss was recognised:
when the session stops being active, when the connection is rebuilt,
and when nodes are registered without a connection or with an inactive
session. A node is reported once per loss, and a node whose item the
server refused keeps that status. When the same session is activated
again, the consumer calls the server's ResendData method, so the
current value of every node arrives, the unchanged ones included; any
answer other than a lost session recreates the subscription, as
CAMEL-25691 does for one that stopped publishing.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…t is not left reported after its first value

The report of a node registered while there was no connection carried
the current generation counter, which the connection being built had
already taken. Its first value and the report then had the same
generation, and when the report ran after the value on the shared
pool, the node kept Bad_NoCommunication while the connection was up.
The report now carries generation 0, below every connection's, so any
delivered value drops it.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@etzel-mes
etzel-mes force-pushed the feature/CAMEL-25835-milo-communication-loss branch from 2b688a7 to 1237f27 Compare October 20, 2026 11:00

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

LGTM, thanks. After the rebase, the only change is the upgrade guide: it now says ${body.statusCode.bad} == false, which matches the old behaviour.

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

@davsclaus davsclaus added this to the 4.24.0 milestone Oct 20, 2026
@davsclaus davsclaus added the enhancement New feature or request label Oct 20, 2026
@davsclaus
davsclaus merged commit 3f11cf9 into apache:main Oct 20, 2026
7 checks passed
@etzel-mes
etzel-mes deleted the feature/CAMEL-25835-milo-communication-loss 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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants