This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 69856151bcf3 CAMEL-25691: camel-milo - recreate the subscription when 
it stops publishing (#27910)
69856151bcf3 is described below

commit 69856151bcf366aba832c9a88de7bb4889f6544c
Author: Serdar Gökay <[email protected]>
AuthorDate: Sun Oct 18 10:50:51 2026 +0300

    CAMEL-25691: camel-milo - recreate the subscription when it stops 
publishing (#27910)
    
    Co-authored-by: Claude Fable 5.1 <[email protected]>
    Co-authored-by: Claude Opus 5.5 <[email protected]>
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../camel/catalog/docs/milo-client-component.adoc  |  23 +++
 .../src/main/docs/milo-client-component.adoc       |  23 +++
 .../milo/client/internal/SubscriptionManager.java  | 209 ++++++++++++++++++++-
 .../component/milo/MonitorItemLifecycleTest.java   | 205 +++++++++++++++++++-
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   5 +
 5 files changed, 459 insertions(+), 6 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/milo-client-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/milo-client-component.adoc
index 02055945a739..6b9e7e4b13e5 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/milo-client-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/milo-client-component.adoc
@@ -204,6 +204,29 @@ The default `omitNullValues=true` also omits a null value 
with a Good status tha
 does not learn that the node recovered. Set `omitNullValues=false` for a node 
whose value can be null, such as a
 `String`.
 
+=== Subscriptions that stop publishing
+
+The consumers of a connection share one subscription on the server. When the 
server stops publishing for it (no
+notification or keep-alive within about one and a half keep-alive intervals), 
Camel checks whether the server still
+has the subscription. If the server does not confirm it, or when the server 
reports that the subscription timed out
+(`Bad_Timeout`), Camel deletes it and, once the server has confirmed that it 
is gone, creates it again in the existing
+session. A node that can no longer be monitored is reported as described above 
and does not affect the other nodes.
+
+A route that is slow to process a value stops the publishing as well, because 
the client asks the server for more
+notifications only as the routes finish processing the ones it received. The 
subscription is then kept, unless the
+route takes longer than the lifetime of the subscription, which lets it time 
out on the server.
+
+A check or a delete that fails because the connection was lost is left to the 
session recovery of the client, which
+restores the session once the server can be reached again. The subscription 
then resumes publishing, or it is created
+again as above when the server no longer has it. If the server no longer has 
the session either, for example after a
+restart, the subscription cannot be transferred to a new session and the 
connection is rebuilt. A network that drops
+silently can stop the publishing before the client notices that the connection 
is lost; if the network is back before
+that, the check reaches the server and is handled as above.
+
+Any other failure of the delete, or a subscription that cannot be created 
again, rebuilds the connection to the server.
+After the subscription is recreated, every monitored item delivers its current 
value again, and value changes in
+between are not delivered.
+
 === Method ID
 
 It is possible to perform methods calls on OPC UA nodes. If the parameter 
`method` is set to the Node ID of a method call (the node ID must be set to the 
parent object in this case),
diff --git a/components/camel-milo/src/main/docs/milo-client-component.adoc 
b/components/camel-milo/src/main/docs/milo-client-component.adoc
index 02055945a739..6b9e7e4b13e5 100644
--- a/components/camel-milo/src/main/docs/milo-client-component.adoc
+++ b/components/camel-milo/src/main/docs/milo-client-component.adoc
@@ -204,6 +204,29 @@ The default `omitNullValues=true` also omits a null value 
with a Good status tha
 does not learn that the node recovered. Set `omitNullValues=false` for a node 
whose value can be null, such as a
 `String`.
 
+=== Subscriptions that stop publishing
+
+The consumers of a connection share one subscription on the server. When the 
server stops publishing for it (no
+notification or keep-alive within about one and a half keep-alive intervals), 
Camel checks whether the server still
+has the subscription. If the server does not confirm it, or when the server 
reports that the subscription timed out
+(`Bad_Timeout`), Camel deletes it and, once the server has confirmed that it 
is gone, creates it again in the existing
+session. A node that can no longer be monitored is reported as described above 
and does not affect the other nodes.
+
+A route that is slow to process a value stops the publishing as well, because 
the client asks the server for more
+notifications only as the routes finish processing the ones it received. The 
subscription is then kept, unless the
+route takes longer than the lifetime of the subscription, which lets it time 
out on the server.
+
+A check or a delete that fails because the connection was lost is left to the 
session recovery of the client, which
+restores the session once the server can be reached again. The subscription 
then resumes publishing, or it is created
+again as above when the server no longer has it. If the server no longer has 
the session either, for example after a
+restart, the subscription cannot be transferred to a new session and the 
connection is rebuilt. A network that drops
+silently can stop the publishing before the client notices that the connection 
is lost; if the network is back before
+that, the check reaches the server and is handled as above.
+
+Any other failure of the delete, or a subscription that cannot be created 
again, rebuilds the connection to the server.
+After the subscription is recreated, every monitored item delivers its current 
value again, and value changes in
+between are not delivered.
+
 === Method ID
 
 It is possible to perform methods calls on OPC UA nodes. If the parameter 
`method` is set to the Node ID of a method call (the node ID must be set to the 
parent object in this case),
diff --git 
a/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/internal/SubscriptionManager.java
 
b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/internal/SubscriptionManager.java
index ed096e646d49..e8dab0b93336 100644
--- 
a/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/internal/SubscriptionManager.java
+++ 
b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/internal/SubscriptionManager.java
@@ -31,6 +31,7 @@ import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
 import java.util.concurrent.Future;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
@@ -49,6 +50,8 @@ import 
org.apache.camel.component.milo.client.MonitorFilterConfiguration;
 import org.eclipse.milo.opcua.sdk.client.DiscoveryClient;
 import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
 import org.eclipse.milo.opcua.sdk.client.OpcUaClientConfigBuilder;
+import org.eclipse.milo.opcua.sdk.client.SessionActivityListener;
+import org.eclipse.milo.opcua.sdk.client.UaSession;
 import org.eclipse.milo.opcua.sdk.client.identity.AnonymousProvider;
 import org.eclipse.milo.opcua.sdk.client.identity.CompositeProvider;
 import org.eclipse.milo.opcua.sdk.client.identity.IdentityProvider;
@@ -97,13 +100,64 @@ public class SubscriptionManager {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(SubscriptionManager.class);
 
+    /**
+     * The status codes of a request that failed because the connection or the 
session was lost, which the session
+     * recovery of the client deals with: a closed connection, and the service 
faults on which the client leaves the
+     * active session because its session or its secure channel is no longer 
valid. The faults are those of
+     * {@code SESSION_ERROR} and {@code SECURE_CHANNEL_ERROR} in the session 
state machine of Milo 1.1
+     * ({@code SessionFsmFactory}).
+     */
+    private static final Set<Long> SESSION_LOST = Set.of(
+            StatusCodes.Bad_ConnectionClosed, StatusCodes.Bad_SessionClosed, 
StatusCodes.Bad_SessionIdInvalid,
+            StatusCodes.Bad_SessionNotActivated, 
StatusCodes.Bad_SecureChannelIdInvalid,
+            StatusCodes.Bad_SecurityChecksFailed, 
StatusCodes.Bad_TcpSecureChannelUnknown,
+            StatusCodes.Bad_RequestTypeInvalid);
+
     private final AtomicLong clientHandleCounter = new AtomicLong();
 
-    private final class SubscriptionListenerImpl implements 
OpcUaSubscription.SubscriptionListener {
+    /**
+     * Listens to the subscription of a connection and to the state of its 
session. It is registered on the client
+     * before the client connects, so that it sees every change of the session 
state.
+     */
+    private final class SubscriptionListenerImpl
+            implements OpcUaSubscription.SubscriptionListener, 
SessionActivityListener {
+
+        private volatile boolean sessionActive;
+
+        @Override
+        public void onSessionActive(final UaSession session) {
+            this.sessionActive = true;
+        }
+
+        @Override
+        public void onSessionInactive(final UaSession session) {
+            this.sessionActive = false;
+        }
+
+        /**
+         * Whether a request failed because the connection or the session was 
lost, which the session recovery of the
+         * client deals with. A request that fails while the session is not 
active counts too, whatever its status: one
+         * that was sent after the loss waits for that recovery and fails with 
the error of a failed reconnect attempt.
+         */
+        boolean isSessionLost(final StatusCode status) {
+            return !this.sessionActive || 
SESSION_LOST.contains(status.getValue());
+        }
 
         @Override
         public void onStatusChanged(final OpcUaSubscription subscription, 
final StatusCode status) {
-            LOG.info("Subscription status changed {} : {}", 
subscription.getSubscriptionId(), status);
+            if (status.getValue() == StatusCodes.Bad_Timeout) {
+                LOG.warn("Subscription timed out on the server, recreating 
it");
+                recoverSubscription(subscription, this);
+            } else {
+                LOG.info("Subscription status changed {} : {}", 
subscription.getSubscriptionId(), status);
+            }
+        }
+
+        @Override
+        public void onWatchdogTimerElapsed(final OpcUaSubscription 
subscription) {
+            LOG.debug("Subscription {} stopped publishing, checking it on the 
server",
+                    subscription.getSubscriptionId().orElse(null));
+            checkSubscription(subscription, this);
         }
 
         @Override
@@ -298,6 +352,29 @@ public class SubscriptionManager {
             return index == null ? null : toNodeId(index, nodeId);
         }
 
+        /**
+         * Creates the subscription on the server again, with the monitored 
items it had, after the server lost it.
+         *
+         * @param  subscriptions the registered subscriptions, by client handle
+         * @return               the bad status values of the nodes that could 
not be monitored again, to be delivered
+         *                       to their consumers after the lock of the 
manager has been released
+         * @throws Exception     if the subscription could not be created or 
the server rejected the
+         *                       CreateMonitoredItems service call, the 
connection has to be rebuilt
+         */
+        public List<PendingValue> recreate(final Map<UInteger, Subscription> 
subscriptions) throws Exception {
+            final Map<UInteger, Subscription> monitored = new HashMap<>();
+            this.goodSubscriptions.keySet()
+                    .forEach(clientHandle -> monitored.put(clientHandle, 
subscriptions.get(clientHandle)));
+            this.goodSubscriptions.clear();
+
+            // the monitored items were reset together with the subscription, 
so they are dropped without a service call
+            
this.manager.removeMonitoredItems(this.manager.getMonitoredItems());
+            this.manager.deleteMonitoredItems();
+
+            this.manager.create();
+            return putSubscriptions(monitored);
+        }
+
         public void deactivate(final List<UInteger> clientHandles) throws 
Exception {
             final List<OpcUaMonitoredItem> items = new 
ArrayList<>(clientHandles.size());
             for (final UInteger clientHandle : clientHandles) {
@@ -676,6 +753,129 @@ public class SubscriptionManager {
         }
     }
 
+    /**
+     * Checks the subscription of a connection on the server after it stopped 
publishing, and recovers it unless the
+     * server confirms that it still has it.
+     * <p>
+     * A subscription that the server still has stops publishing too while a 
route is slow: the client replaces each of
+     * its outstanding publish requests only once the notifications of that 
request have been processed, so the server
+     * has none left to answer. The check is a SetPublishingMode request that 
enables publishing; the subscription is
+     * always created with publishing enabled, so the request does not change 
its settings.
+     * <p>
+     * If the client has reset the subscription in the meantime, it is already 
being recovered: the client resets it
+     * when the server reports that it timed out, when it was deleted and when 
it could not be transferred, and each of
+     * these recovers it. A check that fails because the connection or the 
session was lost is left to the session
+     * recovery of the client, as a failed delete in
+     * {@link #recoverSubscription(OpcUaSubscription, 
SubscriptionListenerImpl)} is. Any other answer goes on to
+     * {@link #recoverSubscription(OpcUaSubscription, 
SubscriptionListenerImpl)}.
+     */
+    private void checkSubscription(final OpcUaSubscription subscription, final 
SubscriptionListenerImpl listener) {
+        final UInteger subscriptionId = 
subscription.getSubscriptionId().orElse(null);
+        subscription.setPublishingModeAsync(true).whenCompleteAsync((unit, e) 
-> {
+            final StatusCode status = e == null
+                    ? StatusCode.GOOD
+                    : UaException.extractStatusCode(e).orElse(new 
StatusCode(StatusCodes.Bad_UnexpectedError));
+            if (status.isGood()) {
+                LOG.info("Subscription {} stopped publishing but still exists 
on the server, keeping it",
+                        subscriptionId);
+            } else if (status.getValue() == StatusCodes.Bad_InvalidState) {
+                LOG.debug("Subscription {} was reset while it was checked, it 
is already being recovered",
+                        subscriptionId);
+            } else if (listener.isSessionLost(status)) {
+                LOG.info("Session lost while checking subscription {}, leaving 
it to the session recovery: {}",
+                        subscriptionId, status);
+            } else {
+                LOG.warn("Subscription {} stopped publishing and was not 
confirmed by the server, recreating it: {}",
+                        subscriptionId, status);
+                recoverSubscription(subscription, listener);
+            }
+        }, clientExecutor(subscription));
+    }
+
+    /**
+     * Recovers the subscription of a connection after the server did not 
confirm it or reported that it timed out.
+     * <p>
+     * The subscription is deleted first, and it is only created again, inside 
the existing session, once the server has
+     * confirmed that it no longer exists; one that the client has already 
reset because it timed out is deleted without
+     * a service call. A delete that fails because the connection or the 
session was lost, or that fails while the
+     * session is not active, is left to the session recovery of the client: 
it resumes the subscription, whose watchdog
+     * is armed again once publishing resumes, or reports that the 
subscription could not be transferred, which rebuilds
+     * the connection. Any other failure rebuilds the connection, which closes 
the session together with its
+     * subscriptions.
+     * <p>
+     * The delete neither blocks nor holds {@code lock}: a request sent before 
the connection was lost only fails once
+     * the client notices the loss, and holding {@code lock} until then would 
hold up the producers and
+     * {@link #getOpcUaClient()} as well.
+     */
+    private void recoverSubscription(final OpcUaSubscription subscription, 
final SubscriptionListenerImpl listener) {
+        subscription.deleteAsync().whenCompleteAsync((unit, e) -> {
+            final StatusCode status = e == null
+                    ? StatusCode.GOOD
+                    : UaException.extractStatusCode(e).orElse(new 
StatusCode(StatusCodes.Bad_UnexpectedError));
+            if (status.isGood() || status.getValue() == 
StatusCodes.Bad_SubscriptionIdInvalid) {
+                recreateSubscription(subscription);
+            } else if (listener.isSessionLost(status)) {
+                LOG.info("Session lost while deleting subscription {}, leaving 
it to the session recovery: {}",
+                        subscription.getSubscriptionId().orElse(null), status);
+            } else {
+                lock.lock();
+                try {
+                    if (isCurrent(subscription)) {
+                        handleConnectionFailure(e);
+                    }
+                } finally {
+                    lock.unlock();
+                }
+            }
+        }, clientExecutor(subscription));
+    }
+
+    /**
+     * The executor that the client of a subscription delivers its values on, 
which handles the outcome of a check or a
+     * delete. Creating the subscription again and rebuilding the connection 
block until the server answers, and a
+     * request sent while the connection is lost waits for the channel until a 
reconnect attempt restores it or fails.
+     * The client schedules those attempts on the shared scheduled executor of 
Milo, which {@link #executor} is as well
+     * and which has a single thread on a machine with one processor, so the 
wait must not hold one of its threads.
+     * <p>
+     * Milo asks that blocking calls are not made on the threads of this 
executor either, because they complete the
+     * responses. The executor of the transport is not set here, so this is 
the shared executor of Milo, a cached pool
+     * that starts another thread for a response while one of its threads 
waits, as it does while a route processes a
+     * value.
+     */
+    private static Executor clientExecutor(final OpcUaSubscription 
subscription) {
+        return 
subscription.getClient().getTransport().getConfig().getExecutor();
+    }
+
+    /**
+     * Creates the subscription of the current connection again, inside the 
existing session, together with the
+     * monitored items it had. Does nothing if the subscription no longer 
belongs to the current connection or has been
+     * created again already. A node that cannot be monitored again is 
reported to its consumer, as when the consumer
+     * starts; if the subscription itself cannot be created again, the 
connection is rebuilt.
+     */
+    private void recreateSubscription(final OpcUaSubscription subscription) {
+        final List<PendingValue> pendingValues = new ArrayList<>();
+        lock.lock();
+        try {
+            if (!isCurrent(subscription) || 
subscription.getSubscriptionId().isPresent()) {
+                LOG.debug("Subscription is stale or already recreated");
+                return;
+            }
+            pendingValues.addAll(this.connected.recreate(this.subscriptions));
+            LOG.info("Subscription recreated as {}", 
subscription.getSubscriptionId().orElse(null));
+        } catch (final Exception e) {
+            handleConnectionFailure(e);
+        } finally {
+            lock.unlock();
+        }
+
+        // deliver outside of the lock, the routes may use the connection
+        pendingValues.forEach(PendingValue::deliver);
+    }
+
+    private boolean isCurrent(final OpcUaSubscription subscription) {
+        return !this.disposed && this.connected != null && 
this.connected.manager == subscription;
+    }
+
     private void handleConnectionFailure(final Throwable e) {
         lock.lock();
         try {
@@ -850,6 +1050,9 @@ public class SubscriptionManager {
                     }
                 });
 
+        final SubscriptionListenerImpl listener = new 
SubscriptionListenerImpl();
+        client.addSessionActivityListener(listener);
+
         try {
 
             // connect is called inside this try block to ensure that 
disconnect is called on exceptions
@@ -858,7 +1061,7 @@ public class SubscriptionManager {
             // Create subscription synchronously - the create() method blocks 
until complete
             OpcUaSubscription subscription = new OpcUaSubscription(client);
             
subscription.setPublishingInterval(this.configuration.getRequestedPublishingInterval());
-            subscription.setSubscriptionListener(new 
SubscriptionListenerImpl());
+            subscription.setSubscriptionListener(listener);
             subscription.create();
 
             return new Connected(client, subscription);
diff --git 
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/MonitorItemLifecycleTest.java
 
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/MonitorItemLifecycleTest.java
index cec8822d6eb0..f6797ff65d19 100644
--- 
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/MonitorItemLifecycleTest.java
+++ 
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/MonitorItemLifecycleTest.java
@@ -18,6 +18,7 @@ package org.apache.camel.component.milo;
 
 import java.util.List;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.Consumer;
 
 import org.apache.camel.EndpointInject;
@@ -31,21 +32,29 @@ import 
org.apache.camel.component.milo.client.MiloClientEndpoint;
 import org.apache.camel.component.milo.server.MiloServerComponent;
 import org.apache.camel.component.mock.MockEndpoint;
 import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.client.subscriptions.OpcUaSubscription;
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
+import org.eclipse.milo.opcua.sdk.server.Session;
 import org.eclipse.milo.opcua.stack.core.StatusCodes;
 import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
 import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
 import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Timeout;
 
 import static org.awaitility.Awaitility.await;
+import static 
org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
- * Testing the monitored items of consumers sharing a connection, when a 
consumer is restarted, its node does not exist
- * or the server rejects the CreateMonitoredItems service call
+ * Testing the monitored items of consumers sharing a connection, when a 
consumer is restarted, its node does not exist,
+ * the server rejects the CreateMonitoredItems service call or the 
subscription stops publishing or times out
  */
 public class MonitorItemLifecycleTest extends AbstractMiloServerTest {
 
@@ -96,6 +105,11 @@ public class MonitorItemLifecycleTest extends 
AbstractMiloServerTest {
 
     private static final String MOCK_ITEMS_1_2 = "mock:items12";
 
+    // the route of the first item takes longer than the watchdog of a 
shortened subscription for this value, once
+    private static final String SLOW_VALUE = "Slow";
+
+    private final AtomicBoolean slowValueProcessed = new AtomicBoolean();
+
     @EndpointInject(MOCK_ITEM_1)
     protected MockEndpoint item1Endpoint;
 
@@ -128,7 +142,12 @@ public class MonitorItemLifecycleTest extends 
AbstractMiloServerTest {
                 
from(resolve(MILO_CLIENT_UNKNOWN_ITEM)).routeId("unknownItem").to(MOCK_UNKNOWN_ITEM);
 
                 // the consumers deliver the current values while they start, 
so the tests start these routes
-                
from(resolve(MILO_CLIENT_ITEM_1)).routeId("item1").autoStartup(false).to(MOCK_ITEM_1);
+                
from(resolve(MILO_CLIENT_ITEM_1)).routeId("item1").autoStartup(false)
+                        // the slow route under test: a synchronous delay 
holds the delivery thread, once
+                        .filter(exchange -> isSlowValueOnce(exchange))
+                        .delay(3000).syncDelayed().end() // the delay is a 
block of its own
+                        .end()
+                        .to(MOCK_ITEM_1);
                 
from(resolve(MILO_CLIENT_ITEM_2)).routeId("item2").autoStartup(false).to(MOCK_ITEM_2);
                 
from(resolve(MILO_CLIENT_ITEMS_1_2)).routeId("items12").autoStartup(false).to(MOCK_ITEMS_1_2);
 
@@ -142,6 +161,11 @@ public class MonitorItemLifecycleTest extends 
AbstractMiloServerTest {
         };
     }
 
+    private boolean isSlowValueOnce(Exchange exchange) {
+        final Object value = 
exchange.getMessage().getBody(DataValue.class).getValue().getValue();
+        return SLOW_VALUE.equals(value) && 
slowValueProcessed.compareAndSet(false, true);
+    }
+
     @Test
     void restartedConsumerReceivesEachValueOnce() throws Exception {
         producer1.sendBody("Foo");
@@ -264,6 +288,181 @@ public class MonitorItemLifecycleTest extends 
AbstractMiloServerTest {
                         .getBody(DataValue.class)));
     }
 
+    @Test
+    @Timeout(value = 60, unit = TimeUnit.SECONDS)
+    void subscriptionThatStopsPublishingIsRecreatedInTheSameSession() throws 
Exception {
+        producer1.sendBody("Foo");
+
+        item1Endpoint.expectedMinimumMessageCount(1);
+        testBody(item1Endpoint.message(0), assertGoodValue("Foo"));
+        context.getRouteController().startRoute("item1");
+        item1Endpoint.assertIsSatisfied();
+
+        final MiloClientEndpoint endpoint = 
context.getEndpoint(resolve(MILO_CLIENT_ITEM_1), MiloClientEndpoint.class);
+        final MiloClientConnection connection = endpoint.createConnection();
+        try {
+            final OpcUaClient client = connection.getOpcUaClient();
+            final OpcUaSubscription subscription = 
client.getSubscriptions().get(0);
+            // shortens the watchdog of the subscription to a few publishing 
intervals
+            subscription.setMaxKeepAliveCount(uint(1));
+            subscription.setLifetimeCount(uint(3));
+            subscription.modify();
+
+            // the server deletes the subscription and answers the pending 
publish requests without one
+            final UInteger subscriptionId = 
subscription.getSubscriptionId().orElseThrow();
+            
assertTrue(client.deleteSubscriptions(List.of(subscriptionId)).getResults()[0].isGood());
+
+            producer1.sendBody("Bar");
+            await().atMost(30, TimeUnit.SECONDS)
+                    .untilAsserted(() -> 
assertGoodValue("Bar").accept(lastExchange(item1Endpoint).getMessage()
+                            .getBody(DataValue.class)));
+
+            assertSame(client, connection.getOpcUaClient());
+            assertNotEquals(subscriptionId, 
subscription.getSubscriptionId().orElseThrow());
+        } finally {
+            endpoint.releaseConnection(connection);
+        }
+    }
+
+    @Test
+    @Timeout(value = 60, unit = TimeUnit.SECONDS)
+    void subscriptionThatTimesOutOnTheServerIsRecreatedInTheSameSession() 
throws Exception {
+        producer1.sendBody("Foo");
+
+        item1Endpoint.expectedMinimumMessageCount(1);
+        testBody(item1Endpoint.message(0), assertGoodValue("Foo"));
+        context.getRouteController().startRoute("item1");
+        item1Endpoint.assertIsSatisfied();
+
+        final MiloClientEndpoint endpoint = 
context.getEndpoint(resolve(MILO_CLIENT_ITEM_1), MiloClientEndpoint.class);
+        final MiloClientConnection connection = endpoint.createConnection();
+        try {
+            final OpcUaClient client = connection.getOpcUaClient();
+            final UInteger subscriptionId = 
client.getSubscriptions().get(0).getSubscriptionId().orElseThrow();
+
+            timeOutSubscription(subscriptionId);
+
+            producer1.sendBody("Bar");
+            await().atMost(30, TimeUnit.SECONDS)
+                    .untilAsserted(() -> 
assertGoodValue("Bar").accept(lastExchange(item1Endpoint).getMessage()
+                            .getBody(DataValue.class)));
+
+            assertSame(client, connection.getOpcUaClient());
+            assertNotEquals(subscriptionId, 
client.getSubscriptions().get(0).getSubscriptionId().orElseThrow());
+        } finally {
+            endpoint.releaseConnection(connection);
+        }
+    }
+
+    @Test
+    @Timeout(value = 60, unit = TimeUnit.SECONDS)
+    void subscriptionThatASlowRouteHeldUpIsKept() throws Exception {
+        producer1.sendBody("Foo");
+
+        item1Endpoint.expectedMinimumMessageCount(1);
+        testBody(item1Endpoint.message(0), assertGoodValue("Foo"));
+        context.getRouteController().startRoute("item1");
+        item1Endpoint.assertIsSatisfied();
+
+        final MiloClientEndpoint endpoint = 
context.getEndpoint(resolve(MILO_CLIENT_ITEM_1), MiloClientEndpoint.class);
+        final MiloClientConnection connection = endpoint.createConnection();
+        try {
+            final OpcUaClient client = connection.getOpcUaClient();
+            final OpcUaSubscription subscription = 
client.getSubscriptions().get(0);
+            // shortens the watchdog of the subscription to a few publishing 
intervals
+            subscription.setMaxKeepAliveCount(uint(1));
+            subscription.modify();
+            final UInteger subscriptionId = 
subscription.getSubscriptionId().orElseThrow();
+            // replaced when the server confirms the subscription
+            final OpcUaSubscription.ServerState stateBefore = 
subscription.getServerState().orElseThrow();
+
+            // the client sends no publish request while the route processes 
the value, so the watchdog elapses
+            producer1.sendBody(SLOW_VALUE);
+            await().atMost(20, TimeUnit.SECONDS).until(() -> 
countValue(item1Endpoint, SLOW_VALUE) >= 1);
+
+            // a recreated subscription would deliver the current value again
+            await().during(3, TimeUnit.SECONDS).atMost(5, TimeUnit.SECONDS)
+                    .until(() -> countValue(item1Endpoint, SLOW_VALUE) == 1);
+            assertEquals(subscriptionId, 
subscription.getSubscriptionId().orElse(null));
+            assertNotSame(stateBefore, 
subscription.getServerState().orElseThrow(), "the subscription was not 
checked");
+            producer1.sendBody("Bar");
+            await().atMost(10, TimeUnit.SECONDS)
+                    .untilAsserted(() -> 
assertGoodValue("Bar").accept(lastExchange(item1Endpoint).getMessage()
+                            .getBody(DataValue.class)));
+        } finally {
+            endpoint.releaseConnection(connection);
+        }
+    }
+
+    @Test
+    @Timeout(value = 60, unit = TimeUnit.SECONDS)
+    void 
nodeThatCannotBeMonitoredAgainIsReportedWithoutRebuildingTheConnection() throws 
Exception {
+        producer1.sendBody("Foo");
+        producer2.sendBody("Bar");
+        context.getRouteController().startRoute("item1");
+
+        items12Endpoint.expectedMessageCount(2);
+        context.getRouteController().startRoute("items12");
+        items12Endpoint.assertIsSatisfied();
+
+        final MiloClientEndpoint endpoint = 
context.getEndpoint(resolve(MILO_CLIENT_ITEM_1), MiloClientEndpoint.class);
+        final MiloClientConnection connection = endpoint.createConnection();
+        try {
+            final OpcUaClient client = connection.getOpcUaClient();
+            final OpcUaSubscription subscription = 
client.getSubscriptions().get(0);
+            final UInteger subscriptionId = 
subscription.getSubscriptionId().orElseThrow();
+
+            // the second node is removed on the server before the 
subscription times out there
+            
getServer().getAddressSpaceManager().getManagedNode(ExpandedNodeId.parse(NODE_ITEM_2)).orElseThrow().delete();
+            timeOutSubscription(subscriptionId);
+
+            // the subscription is recreated in the same session, with the 
monitored items of the first node only
+            await().atMost(30, TimeUnit.SECONDS)
+                    .until(() -> subscription.getSubscriptionId().filter(id -> 
!id.equals(subscriptionId)).isPresent());
+            assertSame(client, connection.getOpcUaClient());
+            // the items are added one by one before they are created, so the 
count alone passes 2 on the way to 3
+            await().atMost(10, TimeUnit.SECONDS).until(() -> 
subscription.getMonitoredItems().size() == 2
+                    && subscription.isMonitoredItemsSynchronized());
+
+            // the consumer of the second node is told that it is gone, the 
consumers of the first node keep receiving
+            await().atMost(10, TimeUnit.SECONDS).until(() -> 
items12Endpoint.getReceivedExchanges().stream()
+                    .anyMatch(exchange -> 
NODE_ITEM_2.equals(exchange.getMessage().getHeader(MiloConstants.HEADER_NODE_ID))
+                            && new StatusCode(StatusCodes.Bad_NodeIdUnknown)
+                                    
.equals(exchange.getMessage().getBody(DataValue.class).getStatusCode())));
+            producer1.sendBody("Baz");
+            await().atMost(10, TimeUnit.SECONDS)
+                    .untilAsserted(() -> 
assertGoodValue("Baz").accept(lastExchange(item1Endpoint).getMessage()
+                            .getBody(DataValue.class)));
+            assertSame(client, connection.getOpcUaClient());
+        } finally {
+            endpoint.releaseConnection(connection);
+        }
+    }
+
+    private OpcUaServer getServer() {
+        return context.getComponent("milo-server", 
MiloServerComponent.class).getServer();
+    }
+
+    /**
+     * The server reports the lifetime of the subscription as expired and 
removes it
+     */
+    private void timeOutSubscription(final UInteger subscriptionId) {
+        for (final Session session : 
getServer().getSessionManager().getAllSessions()) {
+            final var subscriptions = session.getSubscriptionManager();
+            final var subscription = 
subscriptions.getSubscription(subscriptionId);
+            if (subscription != null) {
+                subscriptions.sendStatusChangeNotification(subscription, new 
StatusCode(StatusCodes.Bad_Timeout));
+                subscriptions.removeSubscription(subscriptionId);
+            }
+        }
+    }
+
+    private static long countValue(final MockEndpoint endpoint, final Object 
value) {
+        return endpoint.getReceivedExchanges().stream()
+                .filter(exchange -> 
value.equals(exchange.getMessage().getBody(DataValue.class).getValue().getValue()))
+                .count();
+    }
+
     private static Exchange lastExchange(final MockEndpoint endpoint) {
         final List<Exchange> exchanges = endpoint.getReceivedExchanges();
         return exchanges.get(exchanges.size() - 1);
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 85e983b9376f..e11786cc9c92 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -1551,6 +1551,11 @@ Stopping a consumer now deletes its monitored items. 
Previously they stayed on t
 the shared connection open, so a restarted consumer received each value twice. 
While the server is unreachable, the
 stop waits until the client's next attempt to reconnect has failed.
 
+When the server removes the subscription that the consumers of a connection 
share, or the subscription times out on the
+server, it is now created again in the existing session; previously the 
consumers stopped receiving values until the
+connection was rebuilt for another reason. The monitored items deliver their 
current values again after the
+subscription is recreated.
+
 === camel-protobuf - a bytes field in a Map
 
 When a protobuf message is converted to a `Map` (for example 
`getBody(Map.class)` after unmarshalling), the value of

Reply via email to