Skip to content

CAMEL-25691: camel-milo - recreate the subscription when it stops publishing - #27910

Merged
davsclaus merged 5 commits into
apache:mainfrom
etzel-mes:fix/CAMEL-25691-milo-watchdog
Oct 18, 2026
Merged

davsclaus merged 5 commits into
apache:mainfrom
etzel-mes:fix/CAMEL-25691-milo-watchdog

Conversation

@etzel-mes

@etzel-mes etzel-mes commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes CAMEL-25691.

SubscriptionManager.SubscriptionListenerImpl ignored onWatchdogTimerElapsed and only logged onStatusChanged. When the server drops the subscription while the session stays alive (subscription lifetime expired after a network or scheduling stall, server configuration reload, Bad_NoSubscription on publish), Milo stops sending publish requests, fires the watchdog and leaves the reaction to the listener. The consumer then kept its last value forever: no exchange, no reconnect, one INFO line at most. Bad_Timeout is the same case reported by the server: Milo resets the subscription before the callback, and nothing created it again.

With this change the watchdog first checks the subscription on the server with setPublishingModeAsync(true), without holding the connection lock and without waiting for it. The subscription is always created with publishing enabled, so the request does not change its settings. The watchdog also elapses while a route is slow, because Milo replaces a publish request only after its notifications have been processed, so the check decides:

  • Good: the subscription is kept.
  • Bad_InvalidState: nothing. Milo resets a subscription only after a Bad_Timeout, a delete or a failed transfer, and each of those already recovers it.
  • The connection or the session was lost, or the check failed while the session was not active: left to Milo's session recovery. A request sent after the client noticed the loss waits for the next reconnect attempt and, if that attempt fails, fails with its error, so the listener follows the session state with a SessionActivityListener registered before the client connects.
  • Anything else: the subscription is deleted with deleteAsync() and created again inside the existing session once the server has confirmed that it is gone (Good or Bad_SubscriptionIdInvalid, the answers on which Milo resets the OpcUaSubscription itself), as the Milo Javadoc of onWatchdogTimerElapsed suggests ("Consider deleting and creating a new Subscription").

A Bad_Timeout status change from the server (the subscription timed out there) goes to the delete and recreate directly, without the check. The monitored items are created again through Connected.putSubscriptions, so a node that can no longer be monitored is reported to its consumer as a bad DataValue and does not rebuild the connection. A delete that fails because the connection or the session was lost, or that fails while the session is not active, is left to Milo's session recovery as well: it resumes the subscription and arms the watchdog again when publishing resumes, or reports a failed transfer, which rebuilds the connection as before. Any other failure of the delete, or a subscription that cannot be created again, rebuilds the connection, which closes the session together with its subscriptions. The outcome of the check and of the delete is handled on the client's executor, not on Milo's shared scheduled executor: the recreate and the rebuild block until the server answers, and the client schedules its keep-alives and reconnect attempts on that executor, which has a single thread on a machine with one processor. A recreate in flight there kept the keep-alive from noticing a lost server until the request timed out, and a rebuild that waited for a reconnect attempt never got it. A failed producer request still rebuilds the connection on the scheduled executor and can reach the same wait; that path predates this PR and is left for a separate change (details in the review thread). Milo also asks that blocking calls are not made on the client's executor, because its threads complete the responses; camel-milo does not set the transport's executor, so that executor is Milo's shared cached pool, which starts another thread for a response while one waits, as it does while a route processes a value.

Not handled: a subscription that the server confirms and then removes, or stops serving, before its next publish response is not checked again, because after a confirmation the watchdog is armed again only by the next publish response, a reactivated session, or a create or modify.

Why recreate the subscription instead of rebuilding the connection

A MiloClientConnection is shared by every consumer and producer with the same connection parameters. Rebuilding it for one dead subscription closes the session, leaves writes, method calls and reads with "No connected" until the reconnect finishes, re-subscribes every consumer and opens a new session on the server. PLC-integrated OPC UA servers (Siemens S7-1500/S7-1200 and similar controllers) allow only a small number of sessions, and a session whose CloseSession did not reach the server counts against that limit until its timeout expires. Recreating the subscription in place does not touch the other users of the connection and leaves the session count unchanged. The full rebuild stays as the fallback.

Not changed: onTransferFailed still rebuilds the connection; value changes during the gap are not republished (the monitored items deliver their current value again after recreation, as after a reconnect). Both are documented.

Tests

MonitorItemLifecycleTest:

  • subscriptionThatStopsPublishingIsRecreatedInTheSameSession: shortens the watchdog through Milo's public API (setMaxKeepAliveCount(1), modify()), deletes the subscription through the connection's own session (the server then answers the pending publish requests with Bad_NoSubscription), and asserts that a new value arrives, the OpcUaClient is the same instance and the subscription id changed.
  • subscriptionThatTimesOutOnTheServerIsRecreatedInTheSameSession: the embedded server sends a Bad_Timeout status change and removes the subscription.
  • subscriptionThatASlowRouteHeldUpIsKept: shortens the watchdog the same way and has the route take 3 s for one value, so the watchdog elapses while the server still has the subscription; asserts that the value is delivered once, the subscription id is unchanged and the check was answered (ServerState replaced).
  • nodeThatCannotBeMonitoredAgainIsReportedWithoutRebuildingTheConnection: removes one monitored node on the server before the subscription times out there; its consumer receives Bad_NodeIdUnknown, the other node keeps delivering and the OpcUaClient is the same instance.

The first two fail without the fix, the slow-route test fails with the second revision of this PR and the last one with the first revision; all pass now. The class was run with -Dsurefire.rerunFailingTestsCount=0 five times in a row before the rebase onto main and on each commit after it. ./mvnw -pl components/camel-milo -Psourcecheck verify: 78 tests, 1 skipped and 1 failure, ServerCertificateValidationTest (CAMEL-25645), which fails the same way on main.

The current revision was also tested against a real server, with each client limited to one CPU: a subscription that the server loses while the session stays (upstream main stays silent, this revision recreates it), a slow route, a route slower than the subscription lifetime, a slow route while the connection is lost, silent drops shorter and longer than the subscription lifetime, a recreate in flight when the server becomes unreachable, with and without a producer on the connection, and a server restart. The executor change has no unit test, because it needs a scheduled executor with one thread and a server that cannot be reached. In these runs the previous revision hung in the rebuild, and with the recovery on the scheduled executor a recreate in flight kept the keep-alive from running for 60 s. The results are in the review thread.

Docs: a "Subscriptions that stop publishing" section in milo-client-component.adoc (catalog copy identical), including what happens with a slow route, while the connection is lost and when the subscription cannot be transferred, and a note in the 4.24 upgrade guide under the existing camel-milo section.

@davsclaus @oscerd

Target

  • I checked that the commit is targeting the correct branch (note that Camel 3 uses camel-3.x, whereas Camel 4 uses the main branch)

Tracking

  • If this is a large change, bug fix, or code improvement, I checked there's a JIRA issue (CAMEL-25691)

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 directory and I have committed all auto-generated changes.
  • I used AI to generate this pull request. The hand-written code and tests were reviewed and verified by me.

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.

Two observations on recreateSubscription and one process note.

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.

@etzel-mes
etzel-mes force-pushed the fix/CAMEL-25691-milo-watchdog branch from a07bc72 to d96ef62 Compare October 15, 2026 17:29
@etzel-mes

Copy link
Copy Markdown
Contributor Author

Thanks for the review.

Lock. Intentional, and I added the Javadoc note. The OpcUaSubscription is shared with registerItems and unregisterItems, which already run createMonitoredItems / deleteMonitoredItems under lock (through whenConnected), and handleConnectionFailure disposes the client under it. Milo's OpcUaSubscription has no synchronisation of its own, so lock is what serialises access to it. performConnect can run outside the lock because it works on a new client and a new subscription that nobody else sees yet; the recreation changes the subscription the consumers are using. Building a replacement outside the lock would still need the lock for createMonitoredItems and the swap, and a consumer started in between would register its items on the dead subscription. In the case this change is for, a server that dropped the subscription and answers immediately, the three calls take a few milliseconds in the tests.

Timeout. Every service call goes through Milo's UascClientMessageHandler, which fails a request with Bad_Timeout after the request timeout hint (OpcUaClientConfig.requestTimeout, 60 s by default; the endpoint's requestTimeout option sets it). So the worst case against an unresponsive server is one request timeout per call, after which the catch block falls back to handleConnectionFailure, the path a failed reconnect takes today. The Javadoc now says so.

The review mentions a process note that I cannot see on the PR; happy to address it if you post it.

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.

Claude Code on behalf of davsclaus

Thanks @etzel-mes, this is a well-motivated fix with a clear write-up. Recreating the subscription inside the existing session instead of tearing down the shared connection is the right call for session-limited PLC servers, and the tests drive both callbacks through real server behaviour rather than mocks. I read the change against the Milo 1.1.8 sources (OpcUaSubscription, PublishingManager). The two points below are inline:

  1. reset() after a failed delete() can orphan a live subscription (inline, with a suggestion). Milo already resets the subscription itself when the server answers Bad_SubscriptionIdInvalid. The explicit reset() therefore only takes effect for the other failures (request timeout, connection closed), and in those cases the subscription may still exist on the server.
  2. A single monitored item that cannot be created again rebuilds the whole connection (inline). This is the case the PR wants to avoid. putSubscriptions already handles per-item failures without a rebuild.

Question: a silent network drop. The watchdog is only paused when the session goes inactive (WatchdogTimer.onSessionInactive). With Milo's defaults (keep-alive every 5 s, 5 s timeout, 1 failure allowed), a silent drop such as a pulled cable or a switch reboot is detected after about 10 to 15 s. At Camel's default publishing interval of 1000 ms, the watchdog fires 1000 x (10 + 1) x 1.5 = 16.5 s after the last PublishResponse. For a subscription that only gets keep-alives (about every 10 s), it can therefore fire before the session is declared inactive. Previously Milo's session recovery (ActivateSession / TransferSubscriptions / Republish) handled that case alone. Now recreateSubscription runs on the dead session, while holding the manager lock, and steps in. Did you try this, e.g. by dropping the traffic to the port instead of deleting the subscription? If recovery still ends in a usable state, it is worth a sentence in the docs. If it does not, deferring the recreate until the session is confirmed active may be needed.

On the two open threads from the earlier review: the blocking delete() / create() / createMonitoredItems() calls cannot block indefinitely. Each is bounded by the client requestTimeout (Milo default 60 s). registerItems / putSubscriptions already make createMonitoredItems calls under the same lock, so the pattern itself is not new. The difference is that three sequential round trips can hold the lock for up to three request timeouts, which blocks producers on the shared connection. That supports the first thread's request to document the trade-off, or to narrow what runs under the lock.

Docs (component page plus catalog copy) and the 4.24 upgrade-guide entry look good.

This review covers conventions and the code paths I traced statically. It does not replace specialized AI review tools or static analysis such as SonarCloud.

This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

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

I'm satisfied with the Javadoc additions in recreateSubscription() — the blocking-I/O-under-lock trade-off is now documented and the 60 s request timeout bound is explicit. Those two prior threads are addressed.

However, two findings from @davsclaus's review remain unaddressed in this revision:

1. reset() subscription leak (line 721)

} catch (final UaException e) {
    LOG.debug("Failed to delete subscription {}", id.orElse(null), e);
    subscription.reset();
}

The catch block is unconditional on the exception status code. When delete() fails for any reason other than Bad_SubscriptionIdInvalid (network blip, transient server error, etc.) the subscription may still be alive on the server. Calling reset() followed by create() leaves that server-side subscription dangling in the same session. On PLC servers with tight subscription limits this is a resource leak that accumulates across repeated recoveries. The fix is to check the status code and only call reset() on Bad_SubscriptionIdInvalid; for all other failures, propagate the exception so the outer catch (Exception e) → handleConnectionFailure path applies.

2. allMatch(isGood) over-reaction (line 724)

if (!subscription.createMonitoredItems().stream().allMatch(MonitoredItemServiceOperationResult::isGood)) {
    throw new UaException(StatusCodes.Bad_UnexpectedError, "Failed to create the monitored items again");
}

A single operation-level failure for one monitored item (e.g. Bad_NodeIdUnknown for a removed tag) throws here, which the outer catch (Exception e) turns into handleConnectionFailure — tearing down the entire connection for all producers/consumers sharing it. This directly contradicts the resilience goal of this PR. The existing putSubscriptions path already handles operation-level monitored-item failures gracefully (per @davsclaus's comment). The check here should mirror that approach: log per-item failures and continue, rather than treating any item-level failure as a fatal connection error.

Both issues are production-relevant and should be addressed before merge.

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

@github-actions

github-actions Bot commented Oct 15, 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 706 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/MonitorItemLifecycleTest.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 54s total)

Total reactor time: 5m 54s

Module Duration Status
Camel :: Launcher 51.6s SUCCESS
Camel :: JBang :: MCP 46.3s SUCCESS
Camel :: JBang :: Plugin :: Validate 29.8s SUCCESS
Camel :: Component DSL 28.9s SUCCESS
Camel :: Catalog :: Camel Catalog 25.1s SUCCESS
Camel :: YAML DSL 24.0s SUCCESS
Camel :: YAML DSL :: Validator 22.0s SUCCESS
Camel :: JBang :: Plugin :: Testing 16.5s SUCCESS
Camel :: Docs 16.4s SUCCESS
Camel :: JBang :: Plugin :: Kubernetes 15.7s SUCCESS
Camel :: Kamelet Main 13.4s SUCCESS
Camel :: YAML DSL :: Deserializers 10.5s SUCCESS
Camel :: Catalog :: Camel Route Parser 8.1s SUCCESS
Camel :: Catalog :: Camel Report Maven Plugin 8.1s SUCCESS
Camel :: Assembly 7.6s SUCCESS
Camel :: YAML DSL :: Validator Maven Plugin 6.6s SUCCESS
Camel :: All Components Sync point 5.3s SUCCESS
Camel :: YAML DSL :: Maven Plugins 3.3s SUCCESS
Camel :: Catalog :: Suggest (deprecated) 2.6s SUCCESS
Camel :: Catalog :: Maven 2.1s SUCCESS
Camel :: JBang :: Plugin :: Edit 1.4s SUCCESS
Camel :: JBang :: Plugin :: Generate 1.3s SUCCESS
Camel :: JBang :: Main 1.2s SUCCESS
Camel :: Coverage 1.2s SUCCESS
Camel :: Catalog :: Dummy Component 1.1s SUCCESS
Camel :: Catalog :: Console 1.0s SUCCESS
Camel :: Endpoint DSL :: Support 0.9s SUCCESS
Camel :: JBang :: Integration tests 0.8s SUCCESS
Camel :: Launcher :: Container 0.7s SUCCESS
Camel :: JBang :: Plugin :: MCP 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.6s)
  • Camel :: JBang :: MCP (46.3s)
  • Camel :: JBang :: Plugin :: Validate (29.8s)
  • Camel :: Component DSL (28.9s)
  • Camel :: Catalog :: Camel Catalog (25.1s)
  • Camel :: YAML DSL (24.0s)
  • Camel :: YAML DSL :: Validator (22.0s)
  • Camel :: JBang :: Plugin :: Testing (16.5s)
  • Camel :: Docs (16.4s)
  • Camel :: JBang :: Plugin :: Kubernetes (15.7s)
  • Camel :: Kamelet Main (13.4s)
  • Camel :: YAML DSL :: Deserializers (10.5s)
  • Camel :: Catalog :: Camel Route Parser (8.1s)
  • Camel :: Catalog :: Camel Report Maven Plugin (8.1s)
  • Camel :: Assembly (7.6s)
  • Camel :: YAML DSL :: Validator Maven Plugin (6.6s)
  • Camel :: All Components Sync point (5.3s)
  • Camel :: YAML DSL :: Maven Plugins (3.3s)
  • Camel :: Catalog :: Suggest (deprecated) (2.6s)
  • Camel :: Catalog :: Maven (2.1s)

⚙️ View full build and test results

@etzel-mes

Copy link
Copy Markdown
Contributor Author

Thanks @davsclaus, the silent drop was the right question. I tried it, and the first revision did not always end in a usable state, so the recovery works differently now.

Setup. A real server instead of the embedded one: opc-plc 2.15.4, an OPC UA PLC simulator on the OPC Foundation .NET stack, in Docker, with each Camel client in its own container on Milo's defaults (keep-alive every 5 s, 5 s timeout, 1 failure allowed) and the default publishing interval of 1000 ms. The drop is an iptables DROP of the traffic to and from the server port in the client's network namespace, so nothing is closed or reset and both sides keep retransmitting, as with a pulled cable. It starts 8 s after a keep-alive PublishResponse, so the watchdog fired 7 to 8 s into the drop, 2.4 to 7.5 s before the keep-alive declared the session lost: the order you describe. A separate client writes a new value every second, and the times below run from the end of the drop to the first new value the consumer delivers. Each run dropped two clients at once, main and one revision.

Drop main first revision this revision
30 s 1.1 to 2.2 s 9.6 s, connection rebuilt 2.2 s, same session
30 s, 1 CPU 1.1 to 2.1 s 26.5 s, connection rebuilt 1.1 s, same session
30 s, with a producer writing every 2 s on the same connection 11.7 to 11.8 s, rebuilt after the failed writes no value within 240 s 11.8 s, rebuilt after the failed writes
75 s: the subscription times out on the server, the session does not no value within 240 s 10.0 s, connection rebuilt 8.9 s, same session
150 s: the session times out as well 19.7 s, rebuilt after the failed transfer not run 19.7 s, rebuilt after the failed transfer

First revision. The blocking delete() went into the dead connection and only failed when the keep-alive closed the channel. reset() and create() then waited for the session on a thread of Stack.sharedScheduledExecutor() while holding lock. With a producer, the failed writes run handleConnectionFailure on the same executor, and those tasks parked on lock until they held the remaining threads (the thread dump showed four in handleConnectionFailure from write and one in OpcUaSubscription.create). Milo schedules the session reconnect on that executor too, so the session never came back and create() never returned. Without a producer, create() failed while the network was still down, and the fallback rebuilt a connection that Milo would otherwise have resumed.

This revision. Both callbacks call deleteAsync(), without lock and without waiting for it, and act on its result:

  • Good or Bad_SubscriptionIdInvalid, the answers on which Milo resets the object itself: the server has confirmed in this session that the subscription is gone. Only then is it created again under lock, with its monitored items going through putSubscriptions. For Bad_Timeout, Milo has already reset the subscription, so the delete completes without a service call.
  • Bad_ConnectionClosed, Bad_SessionClosed, Bad_SessionIdInvalid or Bad_SessionNotActivated: nothing more. Milo's session recovery resumes the subscription and arms its watchdog again when publishing resumes (PublishingManager.resumePublishing), so a subscription that turns out to be gone is caught again on the live session. A failed transfer still rebuilds through onTransferFailed.
  • Anything else, such as a request timeout: the connection is rebuilt, which closes the session and the subscription with it.

So nothing waits on a dead connection while holding lock: it is taken for create() and the monitored items only after the server has answered the delete. This follows the Milo Javadoc of onWatchdogTimerElapsed ("Consider deleting and creating a new Subscription") and leaves the connection-level recovery to Milo.

In the 30 s runs the delete failed with Bad_ConnectionClosed, and the server's diagnostics show the original subscription alive after the recovery, so a delete that failed with the connection was not applied later. Three more runs covered the other orders, each followed by a minute of values written every second, during which both clients kept delivering values with no gap longer than 2.1 s:

  • A drop starting 2 s after a keep-alive: the session is declared lost before the watchdog fires, the watchdog is paused, and Milo recovers alone (1.1 s for both).
  • A drop starting 5 s after a keep-alive: the watchdog fires first, the delete fails with the connection, and the subscription keeps its id (1.1 s for both).
  • A 10 s drop that ends before the keep-alive gives up: the delete reaches the server once the network is back, and the subscription is created again in the same session. The first value arrived after 5.4 s, against 4.4 s for main, which resumed the old subscription.

The 75 s row is the case the PR is about, caused by the network: the session survives the drop and the subscription does not. main only logs the Bad_Timeout that the server reports after the reconnect, and stays silent; this revision creates the subscription again 26 ms later, in the same session.

Without a network fault. opc-plc has a chaos mode that, 56 times in 31 minutes here, deletes a subscription without telling the client, closes one session (keeping or deleting its subscriptions) or all sessions, closes all subscriptions or injects service faults. I ran one client per variant against it, with a third client writing a value every second:

  • main was silent for 24 of the 30 minutes. Each deleted subscription left it without values until the chaos monkey happened to close its session, which failed the transfer and rebuilt the connection: five outages of 110 to 829 s, and it was silent again at the end.
  • This revision was silent for 7.7 minutes, in 25 outages of about 17.6 s. Its subscription was created again in the same session 25 times, 24 times after the 16.5 s watchdog and once after a Bad_Timeout from the server; the longest outage, 34 s, was a recreated subscription that the next chaos round deleted right away. Two deletes took the other branches: one was answered with Bad_SessionIdInvalid because the server had closed the session, so Milo's recovery opened a new session and the failed transfer rebuilt the connection; the other was answered with an injected Bad_SecureChannelClosed fault and rebuilt the connection directly, which had a new subscription 46 ms later. Sessions the chaos monkey closed rebuilt it through onTransferFailed, as on main.

Docs. The section now covers the silent drop: the delete fails with the connection, the client restores the session once the server can be reached again, and the subscription resumes publishing, or is created again when the server reports that it timed out in the meantime. It also says that a network that is back before the client has noticed the loss has the subscription created again.

A correction to my earlier reply. Milo 1.1.8 does serialize the lifecycle transitions of OpcUaSubscription (create, modify, delete) itself; only the monitored item management is not serialized. That is why the delete can run without lock now, while create() and the monitored items still take it.

@gnodet-bot, your two follow-up findings are the two inline threads; both are answered there.

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.

Following up on my earlier review — both findings from the second review have been addressed.

1. reset() subscription leak ✅ — The new design drops the synchronous delete() + conditional reset() entirely. deleteAsync() is called without holding lock, and the completion callback only proceeds to recreateSubscription() when the server confirms the subscription is gone (GOOD or Bad_SubscriptionIdInvalid). Session-loss codes defer to Milo's recovery; any other failure calls handleConnectionFailure. The leak scenario is eliminated.

2. allMatch(isGood) over-reaction ✅ — recreate() now delegates to putSubscriptions(), which already handles per-item failures gracefully: operation-level failures are logged and reported per-node as a bad DataValue, without triggering a connection rebuild. Only a rejected service call throws. The new nodeThatCannotBeMonitoredAgainIsReportedWithoutRebuildingTheConnection test covers this path directly.

The async delete design also resolves the blocking-I/O-under-lock concern from the first review: deleteAsync() runs lock-free, and manager.create() inside recreateSubscription() now holds the lock only for the create + monitored-items phase, with the 60 s Milo request timeout bounding the worst case, as documented in the Javadoc.

Three integration tests added, each exercising a distinct failure mode. 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.

Claude Code on behalf of davsclaus

Thanks @etzel-mes, the redesign addresses both points from my last review, and the silent-drop measurements answer my question. The delete now runs asynchronously without the manager lock, and the subscription is only recreated after the server confirmed it is gone (I checked this against Milo 1.1.8 applyDeleteResponse / reset()). A node that cannot be monitored again goes through putSubscriptions instead of rebuilding the connection. One small test-timing nit inline.

Heads-up for merge order: this PR and #27908 conflict in MonitorItemLifecycleTest. #27908 makes item1 autoStartup(false) and delivers the startup Bad status, so whichever merges second needs to start item1 after the first write in the three recreate tests.

CI has not run on this head yet (the workflows are waiting for approval), so this approval assumes it goes green.

This review does not replace specialized AI review tools or static analysis such as SonarCloud.

This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

@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, the silent-drop measurements are very convincing. I found one case the new watchdog handling
turns into a false positive: a route that is slow to process a value.

A slow route makes a healthy subscription look dead (SubscriptionManager.java:121 and :741)

Milo only releases a pending Publish permit after the notification has been delivered
(PublishingManager.deliverAndReleasePendingPublish: "Waiting until the client has finished receiving notifications
is the backpressure mechanism"). The consumer runs the route synchronously inside that delivery
(MiloClientConsumer.handleValueUpdate -> getProcessor().process(exchange)). So while one exchange is slow, the
remaining Publish requests are answered, their responses queue behind it, and the server has nothing left to answer
with. After the watchdog delay (16.5 s with the defaults: 1000 ms publishing interval, keep-alive count 10) the
watchdog fires. Before this PR that was harmless. Now onWatchdogTimerElapsed calls recoverSubscription.

The callback runs on the subscription's delivery queue, so it only runs after the slow exchange has returned. By then
publishing has resumed. Milo checks the timer epoch when the timer fires, not when the callback runs, so the callback
still runs. deleteAsync() is then answered Good, because the subscription is alive, and recreateSubscription
creates it again. Every consumer on the connection gets its current value again. A route that stays slower than the
watchdog (for example, retries against a downstream system that is down) repeats this cycle each time the current
value is delivered again.

Reproducer against the embedded server (same setup as MonitorItemLifecycleTest). The route sleeps once when it
sees the value Slow, then the test checks the delivery count and the subscription id:

from(resolve(MILO_CLIENT_ITEM_1)).routeId("item1")
        .process(exchange -> {
            Object v = exchange.getMessage().getBody(DataValue.class).getValue().getValue();
            if ("Slow".equals(v) && blockedOnce.compareAndSet(false, true)) {
                Thread.sleep(3000);
            }
        })
        .to("mock:item1");

// test: shorten the watchdog as in the PR's tests, send "Slow", wait for it, then 3 s more
subscription.setMaxKeepAliveCount(uint(1));
subscription.modify();
UInteger subscriptionId = subscription.getSubscriptionId().orElseThrow();
producer1.sendBody("Slow");
await().atMost(20, TimeUnit.SECONDS).until(() -> countValue("Slow") >= 1);
Thread.sleep(3000);
assertEquals(1, countValue("Slow"));
assertEquals(subscriptionId, subscription.getSubscriptionId().orElse(null));

On 9c6d4cf it fails 3 out of 3 runs (expected: <1> but was: <2>; subscription 12 becomes 13), and the log shows:

WARN  SubscriptionManager - Subscription 12 stopped publishing, recreating it
INFO  SubscriptionManager - Subscription recreated as 13

A second variant with Camel's and Milo's defaults (no modify(), default publishing interval) and a route that
sleeps 30 s once also fails: Slow is delivered twice and the subscription id changes. Both tests pass against
main's SubscriptionManager.

Suggestion: before deleting, check whether the subscription still exists, with a call that does not change it. For
example, subscription.setPublishingModeAsync(true) is answered Good for a live subscription and
Bad_SubscriptionIdInvalid for one the server no longer has. Only the second answer, or Bad_InvalidState when Milo
has already reset the object after Bad_Timeout, would then go on to deleteAsync() and the recreate. The
Bad_NoSubscription and Bad_Timeout cases this PR is for would still recover. A healthy subscription behind a slow
route would be left alone. A test like the one above would cover it.

Claude Code on behalf of allthingssecurity

@etzel-mes
etzel-mes force-pushed the fix/CAMEL-25691-milo-watchdog branch from 9c6d4cf to 70aca32 Compare October 16, 2026 16:20
@etzel-mes

Copy link
Copy Markdown
Contributor Author

Thanks @allthingssecurity, confirmed. Milo resets the watchdog when a PublishResponse is received (PublishingManager: "reset when the response is received rather than when it is eventually processed") but replaces a publish request only after its notifications have been delivered, and the callback waits on the same delivery queue. A route slower than the watchdog therefore makes a live subscription look dead. Your reproducer failed on 9c6d4cf for me as well (expected: <1> but was: <2>, the subscription was recreated).

Fixed in 70aca32 with the check you suggested. After the watchdog, setPublishingModeAsync(true) checks the subscription first; Milo creates it with publishing enabled, so the request does not change its settings.

  • Good: the subscription is kept.
  • Bad_InvalidState: nothing. Milo resets the subscription only after a Bad_Timeout, after a delete or after a failed transfer, and each of those already recovers it. A second recovery could delete the subscription that the first one has just created. This is where I departed from your suggestion.
  • Connection or session lost: left to Milo's session recovery. SESSION_LOST now also has the secure channel faults on which Milo leaves the session (SessionFsmFactory.SessionFaultListener). The listener also follows the session state with a SessionActivityListener, registered before the client connects: a request sent after the client noticed the loss, for example the check of a watchdog callback that waited behind a slow route, waits for the next reconnect attempt and, if that attempt fails, fails with its error, such as Bad_Timeout. A check or a delete that fails while the session is not active is therefore left to Milo as well. The delete of the previous revision had this gap.
  • Anything else, including Bad_SubscriptionIdInvalid: delete and recreate, as before. A server that rejected SetPublishingMode would still get the recreate, not a rebuild.

A Bad_Timeout status change from the server still recreates the subscription directly, without the check, since the server has already said that the subscription is gone. A route that takes longer than the lifetime of the subscription lets it time out and gets it recreated; the docs say so now.

Testing this against a real server also turned up a hang in 9c6d4cf. It handled the outcome of the delete on executor, which is Stack.sharedScheduledExecutor(), and the recreate (create()) and the rebuild (disconnect()) block there while holding the lock. With one processor that executor has a single thread, and a request sent while the server cannot be reached waits until a reconnect attempt restores the channel or fails; Milo schedules those attempts on that same thread. In a container limited to one CPU, 9c6d4cf sent the delete of a watchdog that had waited behind a slow route while the session was being reactivated. The delete failed with the Bad_Timeout of the failed reactivation, and the rebuild blocked that thread in OpcUaClient.disconnect() for good: the consumer stayed silent after the network came back, and getOpcUaClient() waited for the lock. The outcome of the check and of the delete is now handled on the client's executor (getTransport().getConfig().getExecutor()). Milo asks that blocking calls are not made there either, because its threads complete the responses. camel-milo does not set the transport's executor, so this is Milo's shared cached pool, which starts another thread for a response while one waits, as it already does while a route processes a value.

The producers can reach the same wait, and this PR does not change that path: the failure of a write, a method call, a read or a browse rebuilds the connection on executor as well. With a producer writing every 2 s on the connection, one CPU and a 30 s silent outage, upstream main (a16cccb) hung in 3 of 11 runs and this revision in 4 of 8, each time in SubscriptionManager.write → handleConnectionFailure → OpcUaClient.disconnect() on the scheduled thread. From the logs it is a race: after the failed keep-alive Milo closes the channel, and a CloseSession that reaches the transport before the channel state machine has handled the close fails at once, while a later one waits for the reconnect. Handling those failures on the client's executor recovered in all 9 runs of the same test, but it also moves the successful completions to that executor and needs a guard so that a stale failure does not start a second reconnect, so I will take it up separately; it touches the same handlers as CAMEL-25690. The reconnect job (connect()) and resubscribe also still run on executor, where the same wait is possible in a narrower window; CAMEL-25692 already proposes an executor owned by the component.

Tests:

  • New test MonitorItemLifecycleTest#subscriptionThatASlowRouteHeldUpIsKept, based on your reproducer: red on 9c6d4cf, green now. Besides the delivery count and the subscription id, it asserts that the subscription's ServerState was replaced, which, with the id unchanged, only a Good answer to the check does here, so the test fails if the check never ran.
  • Your second variant (Camel's and Milo's defaults, a route that holds one value for 30 s) I ran against a real server with this round's code, the first item in the list below: the watchdog fires after 16.5 s, the check is answered Good, the value is delivered once and the subscription id is unchanged. It is not in the suite because of its length.
  • The recovery still works: in subscriptionThatStopsPublishingIsRecreatedInTheSameSession the check is answered Bad_SubscriptionIdInvalid and the subscription is recreated. In the chaos run from my previous comment, the server's log shows that it had deleted the subscription 14.7 to 16.5 s before each of the 26 watchdog firings, so none of them would have been answered Good.
  • The executor change has no unit test: it needs a scheduled executor with one thread and a server that cannot be reached. The real-server runs below show what it changes.

Against a real server (opc-plc 2.15.4, Milo defaults: watchdog 16.5 s, lifetime 50 s; each client in a container limited to one CPU; traffic blocked with iptables in the client's network namespace):

  • A route that holds one value for 30 s: the check is answered Good, the subscription is kept and the value is delivered once.
  • The server loses the subscription while the session stays (the client deletes it on the server behind the back of its OpcUaSubscription): upstream main delivered nothing for 90 s. With this round the check is answered Bad_SubscriptionIdInvalid, the subscription is recreated in the same session and a value written after the loss reached the route within 13 s.
  • A route that holds one value for 90 s, longer than the lifetime: the server reports Bad_Timeout and the subscription is recreated once; the check that had waited behind the route is answered Bad_InvalidState and does nothing (4 of 4 runs). With a 60 s hold the server still had the subscription (3 of 3): it answers the last outstanding publish request with a keep-alive first, and the lifetime counts from there.
  • A slow route, with the network blocked after the watchdog has elapsed behind the route and before the route finishes, so that the queued check is sent while the session is not active: the check fails with Bad_Timeout from a reconnect attempt and is left to Milo. The session is reactivated with the same subscription, and a value written after the network was back reached the route within 2.2 to 2.3 s (three runs). 9c6d4cf went on to the delete and the rebuild and hung as described above. This round's check without the session state, with its outcome still on the scheduled executor, hung the same way in three of three runs; with the session state it recovered.
  • A 30 s silent drop starting 8 s after a keep-alive: the watchdog elapses first, the check is in flight when the client notices the loss and fails with Bad_ConnectionClosed. Same session and subscription; a value written after the drop reached the route within 2.1 s.
  • A 10 s drop: the check reaches the server once the drop ends and is answered Good, so the subscription is kept; the previous revision recreated it.
  • A 75 s silent drop, longer than the lifetime: the client notices the loss before the watchdog elapses, so Milo pauses the watchdog and no check is sent. After the session is reactivated the server reports Bad_Timeout, and the subscription is recreated in the same session; a value written after the drop reached the route within 9.9 s.
  • A server restart: the transfer fails with Bad_SubscriptionIdInvalid and the connection is rebuilt, as before.
  • The executor of the recovery, with a recreate in flight when the server becomes unreachable (the subscription deleted on the server, 3 s of added latency so that CreateMonitoredItems is still on its way when the traffic is dropped for 90 s): with the outcome on the scheduled executor, the recreate held its only thread until the request timed out after 60 s. No keep-alive ran in that time, so the loss went unnoticed and the lock was held for 68 s (2 of 2 runs). On the client's executor the keep-alive noticed the loss after 8 s and the connection was rebuilt. With a producer writing on the same connection, the same scenario with a 30 s drop recovered in 7 of 7 runs.
    One case is left out on purpose: a subscription that the server confirms and then removes, or stops serving, before its next publish response. After a Good answer Milo arms the watchdog again only on the next PublishResponse, a reactivated session, or a create or modify; neither the SetPublishingMode answer nor a Bad_NoSubscription publish fault does, so such a subscription is not checked again. For a server that keeps the keep-alive rule of the subscription model the window ends with its next keep-alive. A ModifySubscription after each confirmation would close it, at the cost of one more request per check; I left it out, but can add it if you prefer. The previous revision only caught these cases because it recreated every subscription that stopped publishing.

Before the rebase below, the class passed 5 runs in a row with reruns disabled; after it, the class passes on each of the three commits. Module -Psourcecheck verify: 78 tests, 1 skipped and 1 failure, ServerCertificateValidationTest (CAMEL-25645, merged today), which fails the same way on main (16b6e9d): mock:trustAll receives the Bad status of the server item that was not written yet before Foo, and the consumer delivers that status since #27908. The docs section (now also covering a slow route and a failed transfer) and the 4.24 note are updated, and so is the PR description.

@davsclaus, this round changes more than the revision you approved: the check, the session state and the executor of the recovery. Your inline suggestion is applied in the same commit, with one addition (details in the thread). #27908 has been merged in the meantime, so the branch is rebased onto main; 9c6d4cf, the revision you approved, is now d3c527e. Since #27908 the tests start item1 after the first write, and the recreate tests and the new one do that now as well. nodeThatCannotBeMonitoredAgainIsReportedWithoutRebuildingTheConnection starts item1 too, so the subscription has the same three monitored items as before. #27906 changes the same part of SubscriptionManager (it removes resubscribe next to recreate); whichever of the two is merged second, I will rebase it.

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.

Thanks @etzel-mes, I re-checked the new commits. d3c527e is the revision I approved, rebased onto main with the tests starting item1 after the first write (#27908); 70aca32 is new. Checking the subscription before deleting it is the right answer to @allthingssecurity's slow-route report, and the real-server runs cover the orders that the embedded server cannot reproduce. I read 70aca32 against the Milo 1.1.8 sources:

  • setPublishingModeAsync is serialized with the other lifecycle transitions and fails with Bad_InvalidState when the subscription is already reset or a reset() overtakes the request, which is the "already being recovered" branch. A Good answer replaces the ServerState, which the new test asserts.
  • create always asks for publishingEnabled=true, so the check does not change the subscription.
  • SESSION_LOST now matches SESSION_ERROR and the SECURE_CHANNEL_ERROR of SessionFsmFactory.SessionFaultListener, plus Bad_ConnectionClosed.
  • onSessionActive and onSessionInactive fire on every transition into and out of Active through a sequential executor, so the sessionActive flag sees them in order, and registering the listener before connect() catches the first activation.
  • The transport executor defaults to Stack.sharedExecutor(), an unbounded cached pool, while Stack.sharedScheduledExecutor() has availableProcessors() threads, so handling the outcome of the check and the delete on the client's executor removes the wait on a single scheduled thread.

My test-timing thread is fixed: the await now also waits for isMonitoredItemsSynchronized(), which covers the count passing 2 on its way to 3.

Nit (inline): the slow route uses Thread.sleep.

Merge state: this PR and #27906 conflict in SubscriptionManager: #27906 removes handleSubscriptionError and resubscribe right below where this PR adds recreate(). Keeping recreate() and lookupMonitoredNode and dropping the two removed methods resolves it. Neither PR conflicts with #27956.

CI: the workflows on 70aca32 are waiting for approval, so nothing has run on this head yet; this approval assumes they go green.

Checked against the project conventions in CLAUDE.md. This is a rules-and-conventions review and does not replace CodeRabbit, Sourcery or SonarCloud.

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

Copy link
Copy Markdown
Contributor

FYI @etzel-mes: the Build and test failure here (ServerCertificateValidationTest.onlyClientsThatCanValidateTheServerCertificateConnect: mock://trustAll Received message count. Expected: <1> but was: <2>) does not come from this PR. It fails on main too (reproduced locally at 55ed20f). CAMEL-25626 (#27908) and CAMEL-25645 (#27824) were merged 26 seconds apart on 2026-10-16, and neither PR's CI ran with the other change. With CAMEL-25626 the trustAll client now also receives the initial DataValue[value=null, statusCode=Bad] of milo-server:myitem1 before Foo, which the new test does not expect. The test needs to accept that first Bad value, for example by checking only the Good values, as the tests updated in CAMEL-25626 do. Once that is fixed on main, a rebase should make this build green.

Claude Code on behalf of Claus Ibsen. This comment was generated by an AI agent and may contain inaccuracies.

@davsclaus

Copy link
Copy Markdown
Contributor

@etzel-mes, #27906 (CAMEL-25671) is merged now, and it conflicts with this PR in SubscriptionManager.java: it removed badSubscriptions and the resubscribe that served the old Bad_InvalidArgument branch, while handleSubscriptionError and resubscribe() here still use them. Could you rebase onto main and resolve that against #27906? GitHub does not run the PR checks while the PR conflicts.

Two commits on your branch are from the review and should be kept:

  • 581cdf6 and 02986bb: the slow route of MonitorItemLifecycleTest holds the delivery thread with .filter(...).delay(3000).syncDelayed().end().end() instead of Thread.sleep(3000) (the delay is a block of its own, hence the two end()).
  • 017e5c7 is a merge of main for the ServerCertificateValidationTest fix (CAMEL-25645); a rebase replaces it.

Claude Code on behalf of Claus Ibsen

@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 re-review. The async subscription recovery is well-designed: deleteAsync runs lock-free, recreateSubscription acquires the lock only for the create+monitored-items phase, and the isCurrent/subscriptionId().isPresent() guard correctly prevents double-recovery races.

Key design points verified:

  • SESSION_LOST set is correct — it mirrors Milo 1.1.8 SessionFsmFactory exactly: SESSION_ERROR ∪ SECURE_CHANNEL_ERROR + Bad_ConnectionClosed. Bad_RequestTypeInvalid belongs because it is explicitly in SECURE_CHANNEL_ERROR in Milo's predicate.
  • No double-recreate race — recreateSubscription acquires ReentrantLock then checks subscriptionId().isPresent(); the second concurrent call sees the ID already present and returns immediately.
  • No deadlock risk — the lock is ReentrantLock; handleConnectionFailure called from within recreateSubscription's catch block re-enters successfully.
  • No state loss on recreate failure — recreate() clears goodSubscriptions locally; on failure handleConnectionFailure disposes the entire Connected and rebuilds from the outer this.subscriptions map.

The SessionActivityListener addition for the sessionActive volatile flag is a sound approach to catch requests that fail after connection loss with non-SESSION_LOST status codes. The four integration tests cover the critical paths. Documentation in both the component page and upgrade guide is thorough.

Merge-ready pending CI.

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

etzel-mes and others added 5 commits October 17, 2026 19:46
…lishing

When the server stops publishing for the subscription (watchdog) or
reports Bad_Timeout, the consumer deletes and creates the subscription
again inside the existing session and creates its monitored items again,
on the manager's executor under the connection lock. If that fails, the
existing connection rebuild takes over. Previously the watchdog callback
was ignored and Bad_Timeout was only logged, so the consumer stopped
receiving values until the connection was rebuilt for another reason.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
… delete

The watchdog and Bad_Timeout now delete the subscription asynchronously,
without holding the connection lock, and create it again in the session
only once the server has confirmed that it is gone. If the delete fails
because the connection or the session was lost, Milo's session recovery
takes over and arms the watchdog again when publishing resumes; any
other failure rebuilds the connection, which closes the session together
with the subscription. The monitored items are created again through
putSubscriptions, so a node that can no longer be monitored is reported
to its consumer instead of rebuilding the connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as after its watchdog elapses

The watchdog also elapses while a route is slow to process a value: the
client replaces each outstanding publish request only after its
notifications have been processed, so the server has none left to
answer. The subscription was then recreated although the server still
had it, and every monitored item delivered its current value again.

After the watchdog, a SetPublishingMode request that enables publishing
now checks the subscription first; it is always created with publishing
enabled, so the request does not change its settings. The outcome:

- confirmed by the server: the subscription is kept;
- already reset by the client: nothing, since every reset is followed
  by its own recovery;
- failed because the connection or the session was lost: left to the
  session recovery of the client;
- anything else: delete and recreate, as before.

A Bad_Timeout status change from the server still recreates the
subscription directly, without the check.

A request that is sent after the client noticed a lost connection, for
example the delete of a watchdog callback that waited behind a slow
route, waits for the next reconnect attempt and, if that attempt fails,
fails with its error, which was not taken for a lost session: the
delete then either rebuilt the connection or removed the subscription
that the reconnect had resumed and created it again. The listener of
the subscription now follows the session state through a
SessionActivityListener, registered before the client connects, and a
check or a delete that fails while the session is not active is left to
the session recovery. The status codes of a lost session also include
the secure channel faults on which the client leaves the session.

The outcome of the check and of the delete is now handled on the
executor of the client instead of the shared scheduled executor. The
recreate and the rebuild block until the server answers or a reconnect
attempt fails, and the client schedules those attempts on that shared
executor; with one processor it has a single thread, and the rebuild
after a delete that failed while the server was unreachable blocked it
for good. The executor of the client is Milo's shared cached pool, so a
blocked thread does not keep a response from completing.

New test: a route that holds one value longer than the watchdog keeps
the subscription and receives the value once. The test of a node that
cannot be monitored again now waits until the remaining monitored items
are created on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrG4zh2t5bq7jniA5Nwvg
…us delay instead of Thread.sleep

Review follow-up: the route of the first item holds the delivery thread for the
slow value with the delay EIP (syncDelayed) inside a filter, instead of calling
Thread.sleep in a processor, which new test code should not add.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Claus Ibsen <claus.ibsen@gmail.com>
…e test route

The delay EIP is a block of its own, so the single end() closed the delay and
left the mock endpoint inside the filter: only the slow value reached it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Claus Ibsen <claus.ibsen@gmail.com>
@etzel-mes
etzel-mes force-pushed the fix/CAMEL-25691-milo-watchdog branch from 02986bb to 6f24e05 Compare October 17, 2026 16:56
@etzel-mes

Copy link
Copy Markdown
Contributor Author

@davsclaus, rebased onto main (94ca501) and resolved against #27906. In SubscriptionManager, lookupMonitoredNode from #27906 stays and recreate(...) follows it; handleSubscriptionError, resubscribe() and badSubscriptions are gone. recreate(...) only recreates the items in goodSubscriptions, so a node with an unknown namespace URI is still reported once and not retried. The 4.24 upgrade guide conflict is resolved as in your merge.

Your two commits are kept on top, now 202f1e2 and 6f24e05; the rebase replaced 017e5c7. camel-milo with -Psourcecheck clean verify: 79 tests, 0 failures, 1 skipped; MonitorItemLifecycleTest and MonitorNodesTest also passed three runs each with -Dsurefire.rerunFailingTestsCount=0.

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.

Thanks @etzel-mes for the rebase. I compared it with the revision I approved (git range-diff of 581cdf6 against 6f24e05):

  • SubscriptionManager: the three commits are unchanged apart from context. recreate() now sits after lookupMonitoredNode from #27906, and handleSubscriptionError, resubscribe() and badSubscriptions are gone.
  • recreate() still goes through putSubscriptions, which after #27906 also reports a node with an unknown namespace URI once and does not retry it. Recreating only the goodSubscriptions matches that: a node that failed earlier is created again when its consumer restarts or the connection is rebuilt, as on main.
  • The upgrade-guide entry stays in the camel-milo monitored-items section, before camel-protobuf.
  • 6f24e05 adds the missing end() for the delay block, so mock:item1 is outside the filter again.

CI is green on 6f24e05 (JDK 17 and 25).

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 18, 2026
@davsclaus davsclaus added the enhancement New feature or request label Oct 18, 2026
@davsclaus
davsclaus merged commit 6985615 into apache:main Oct 18, 2026
7 checks passed
@etzel-mes
etzel-mes deleted the fix/CAMEL-25691-milo-watchdog branch October 18, 2026 08:31
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