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 b418ab6acd42 CAMEL-25912: camel-milo - client producers no longer wait
for the manager lock or rebuild the connection (#28371)
b418ab6acd42 is described below
commit b418ab6acd424d3d92750a9b42c60fb29fe75bef
Author: Serdar Gökay <[email protected]>
AuthorDate: Sat Oct 24 16:51:34 2026 +0300
CAMEL-25912: camel-milo - client producers no longer wait for the manager
lock or rebuild the connection (#28371)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../camel/catalog/docs/milo-client-component.adoc | 6 +-
.../src/main/docs/milo-client-component.adoc | 6 +-
.../milo/client/MiloClientConnection.java | 15 +-
.../milo/client/internal/SubscriptionManager.java | 173 ++++----
.../component/milo/MiloClientExecutorTest.java | 7 +-
.../milo/ProducerOnSharedConnectionTest.java | 433 +++++++++++++++++++++
.../org/apache/camel/component/milo/TcpRelay.java | 28 +-
.../client/MiloClientProducerWriteFailureTest.java | 2 +-
...ubscriptionManagerExceptionPropagationTest.java | 4 +-
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 25 +-
10 files changed, 580 insertions(+), 119 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 e12de6d7a3b7..3acad3bb65d1 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
@@ -239,7 +239,11 @@ or detects changes must tolerate this. A value that did
not change during the lo
Routes run while communication is lost. An OPC UA request that a route makes
while the client reconnects, for example
a write, waits for the next reconnect attempt and is sent if that attempt
succeeds, so it can take effect after
-communication is restored. A request that fails rebuilds the connection.
+communication is restored. A request that fails completes with its failure;
the connection is kept, and a lost
+session is recovered by the client as described above. The client recovers it
with the endpoint and the server
+certificate it found when it connected, so a server that changes either while
it cannot be reached is reached again
+only when Camel rebuilds the connection, for example when the Camel context
restarts. A request does not wait while a
+consumer of the same connection creates or deletes its monitored items.
Camel keeps no last value. A route that needs one keeps the last good value of
each node itself. To recognise the
status, check the status code of the `DataValue` body, for example with
`${body.statusCode.bad}`.
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 e12de6d7a3b7..3acad3bb65d1 100644
--- a/components/camel-milo/src/main/docs/milo-client-component.adoc
+++ b/components/camel-milo/src/main/docs/milo-client-component.adoc
@@ -239,7 +239,11 @@ or detects changes must tolerate this. A value that did
not change during the lo
Routes run while communication is lost. An OPC UA request that a route makes
while the client reconnects, for example
a write, waits for the next reconnect attempt and is sent if that attempt
succeeds, so it can take effect after
-communication is restored. A request that fails rebuilds the connection.
+communication is restored. A request that fails completes with its failure;
the connection is kept, and a lost
+session is recovered by the client as described above. The client recovers it
with the endpoint and the server
+certificate it found when it connected, so a server that changes either while
it cannot be reached is reached again
+only when Camel rebuilds the connection, for example when the Camel context
restarts. A request does not wait while a
+consumer of the same connection creates or deletes its monitored items.
Camel keeps no last value. A route that needs one keeps the last good value of
each node itself. To recognise the
status, check the status code of the `DataValue` body, for example with
`${body.statusCode.bad}`.
diff --git
a/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientConnection.java
b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientConnection.java
index 3b276156dd81..0855eb458265 100644
---
a/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientConnection.java
+++
b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientConnection.java
@@ -138,15 +138,14 @@ public class MiloClientConnection implements
AutoCloseable, CamelContextAware {
protected void init() {
ObjectHelper.notNull(this.camelContext, "camelContext");
- // A failed request disconnects the client, which can wait for a
reconnect attempt that Milo schedules on its shared
- // scheduled executor, so this connection handles its requests and
reconnects on executors of its own.
+ // The outcome of a request continues its route, and a reconnect waits
for the server; neither may hold a thread of
+ // Milo, whose shared scheduled executor runs the reconnect attempts
of the clients. So this connection handles its
+ // requests and reconnects on executors of its own.
final ExecutorServiceManager executorServiceManager =
this.camelContext.getExecutorServiceManager();
final ThreadPoolProfile profile = new ThreadPoolProfile("MiloClient");
// as many threads as that executor, but they end when they are idle;
with virtual threads and more than one
- // processor, a virtual thread for each task. Not more platform
threads, because when the connection is lost,
- // every request in flight fails and waits for the disconnect of the
first failure. With one processor this is
- // one thread, so a route that continues on it after an awaited
request must not wait for another request of
- // this connection.
+ // processor, a virtual thread for each task. With one processor this
is one thread, so a route that continues on
+ // it after an awaited request must not wait for another request of
this connection.
final int threads = Runtime.getRuntime().availableProcessors();
profile.setPoolSize(threads);
profile.setMaxPoolSize(threads);
@@ -156,8 +155,8 @@ public class MiloClientConnection implements AutoCloseable,
CamelContextAware {
// a request that completes after the connection is closed fails
instead of being dropped
profile.setRejectedPolicy(ThreadPoolRejectedPolicy.Abort);
this.executor = executorServiceManager.newThreadPool(this,
"MiloClient", profile);
- // the reconnects of the connection run one after the other, on a
thread that starts with the first one and ends
- // when the connection is closed
+ // the reconnects of the connection and its attempts to create the
refused monitored items again run one after
+ // the other, on a thread that starts with the first one and ends when
the connection is closed
this.reconnectExecutor =
executorServiceManager.newSingleThreadScheduledExecutor(this,
"MiloClientReconnect");
this.manager = new SubscriptionManager(
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 882d185c1069..78a2c8ba4b7a 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
@@ -343,7 +343,12 @@ public class SubscriptionManager {
}
private class Connected {
- private OpcUaClient client;
+ /**
+ * The client, which is kept after the connection is disposed: a
producer reads the connection without
+ * {@code lock}, so it can still send a request on a connection that
is disposed, which the client then fails
+ * with the status of its closed session
+ */
+ private final OpcUaClient client;
private final OpcUaSubscription manager;
private final SubscriptionListenerImpl listener;
private final long generation;
@@ -517,16 +522,18 @@ public class SubscriptionManager {
/**
* Schedules the next attempt to create the refused nodes again,
unless there is none or one is scheduled. The
- * timer runs on the reconnect thread, the attempt, which blocks until
the server answers, on the executor of
- * the connection, so that a slow attempt does not hold up a reconnect.
+ * attempt blocks until the server answers, so it runs on the
reconnect thread, not on the executor of the
+ * connection, whose threads complete the requests of the producers:
with one processor that executor has a
+ * single thread, and the outcome of every request would wait for the
attempt. A rebuild of the connection does
+ * not wait for the reconnect thread longer than it waits for the
attempt anyway: it needs {@code lock}, which
+ * the attempt holds until the server answers.
*/
private void scheduleRetry() {
if (this.refused.isEmpty() || this.retryJob != null ||
SubscriptionManager.this.disposed) {
return;
}
this.retryJob =
SubscriptionManager.this.reconnectExecutor.schedule(
- () -> SubscriptionManager.this.executor.execute(() ->
retryRefused(this)),
- SubscriptionManager.this.reconnectTimeout,
TimeUnit.MILLISECONDS);
+ () -> retryRefused(this),
SubscriptionManager.this.reconnectTimeout, TimeUnit.MILLISECONDS);
}
/**
@@ -623,16 +630,15 @@ public class SubscriptionManager {
}
public void dispose() {
+ if (this.listener.closed) {
+ return;
+ }
// the session closed by the disconnect is no loss of communication
this.listener.closed = true;
- if (this.client != null) {
- try {
- this.client.disconnect();
- this.client = null;
- } catch (final UaException e) {
- LOG.warn("Failed to disconnect client", e);
- }
-
+ try {
+ this.client.disconnect();
+ } catch (final UaException e) {
+ LOG.warn("Failed to disconnect client", e);
}
}
@@ -972,7 +978,8 @@ public class SubscriptionManager {
private final Lock lock = new ReentrantLock();
/**
- * The current connection, written under {@code lock} and read by the
monitoring state without it
+ * The current connection, written under {@code lock} and read by the
producers, {@link #getOpcUaClient()} and the
+ * monitoring state without it
*/
private volatile Connected connected;
private volatile Throwable lastConnectFailure;
@@ -1003,12 +1010,9 @@ public class SubscriptionManager {
* @return the active client, or {@code null} if there is no current
connection
*/
public OpcUaClient getOpcUaClient() {
- lock.lock();
- try {
- return this.connected != null ? this.connected.client : null;
- } finally {
- lock.unlock();
- }
+ // read without lock, which a service call holds until the server
answers
+ final Connected connected = this.connected;
+ return connected != null ? connected.client : null;
}
/**
@@ -1079,8 +1083,8 @@ public class SubscriptionManager {
* 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.
+ * the client notices the loss, and holding {@code lock} until then would
hold up the consumers that start or stop
+ * as well.
*/
private void recoverSubscription(final OpcUaSubscription subscription,
final SubscriptionListenerImpl listener) {
subscription.deleteAsync().whenCompleteAsync((unit, e) -> {
@@ -1156,10 +1160,11 @@ public class SubscriptionManager {
* failure is the status of the nodes until the next attempt.
* <p>
* Like the registration of a consumer, the attempt holds {@code lock}
until the server answers, so that a consumer
- * that stops or a subscription that is recreated in the meantime cannot
interleave with it, and the producers and
- * {@link #getOpcUaClient()} wait for it. The wait is bounded: the attempt
is only sent while the session is active,
- * a call in flight when the connection drops silently fails once the
keep-alive of the client declares the session
- * lost, and a call that the server does not answer fails after the
request timeout.
+ * that stops or a subscription that is recreated in the meantime cannot
interleave with it, and a consumer that
+ * starts or stops waits for it; the producers and {@link
#getOpcUaClient()} do not. The wait is bounded: the
+ * attempt is only sent while the session is active, a call in flight when
the connection drops silently fails once
+ * the keep-alive of the client declares the session lost, and a call that
the server does not answer fails after
+ * the request timeout.
*/
private void retryRefused(final Connected connected) {
lock.lock();
@@ -1270,14 +1275,17 @@ public class SubscriptionManager {
private void handleConnectionFailure(final Throwable e) {
lock.lock();
try {
- if (this.connected != null) {
- // the nodes lose their communication with the connection,
after the values it still delivers
- reportLoss(this.connected.manager, this.connected.generation,
this.subscriptions);
- this.connected.cancelRetry();
- this.connected.dispose();
+ // set first: a producer that finds no connection while it is torn
down fails with this cause
+ this.lastConnectFailure = e;
+ final Connected failed = this.connected;
+ if (failed != null) {
+ // the producers and getOpcUaClient() no longer reach the
connection that is torn down
this.connected = null;
+ // the nodes lose their communication with the connection,
after the values it still delivers
+ reportLoss(failed.manager, failed.generation,
this.subscriptions);
+ failed.cancelRetry();
+ failed.dispose();
}
- this.lastConnectFailure = e;
// log
@@ -1498,6 +1506,8 @@ public class SubscriptionManager {
}
this.disposed = true;
connected = this.connected;
+ // the producers and getOpcUaClient() no longer reach the
connection that is disposed
+ this.connected = null;
if (connected != null) {
// the next attempt is cancelled under the lock that guards
it, the connection is disposed outside of it
connected.cancelRetry();
@@ -1702,82 +1712,57 @@ public class SubscriptionManager {
public CompletableFuture<CallMethodResult> call(
final ExpandedNodeId nodeId, final ExpandedNodeId methodId, final
Variant[] inputArguments) {
- lock.lock();
- try {
- if (this.connected == null) {
- return newNotConnectedResult();
- }
-
- return this.connected.call(nodeId, methodId,
inputArguments).whenCompleteAsync((status, e) -> {
- // handle outside the lock, running using
- // whenCompleteAsync (preserves the original exceptional
completion)
- if (e != null) {
- handleConnectionFailure(e);
- }
- }, this.executor);
- } finally {
- lock.unlock();
+ final Connected connected = this.connected;
+ if (connected == null) {
+ return newNotConnectedResult();
}
+
+ return outcomeOf(connected.call(nodeId, methodId, inputArguments));
}
public CompletableFuture<?> write(final ExpandedNodeId nodeId, final
DataValue value) {
- lock.lock();
- try {
- if (this.connected == null) {
- return newNotConnectedResult();
- }
-
- return this.connected.write(nodeId,
value).whenCompleteAsync((status, e) -> {
- // handle outside the lock, running using
- // whenCompleteAsync (preserves the original exceptional
completion)
- if (e != null) {
- handleConnectionFailure(e);
- }
- }, this.executor);
- } finally {
- lock.unlock();
+ final Connected connected = this.connected;
+ if (connected == null) {
+ return newNotConnectedResult();
}
+
+ return outcomeOf(connected.write(nodeId, value));
}
public CompletableFuture<?> readValues(final List<ExpandedNodeId> nodeIds)
{
- lock.lock();
- try {
- if (this.connected == null) {
- return newNotConnectedResult();
- }
-
- return
this.connected.readValues(nodeIds).whenCompleteAsync((nodes, e) -> {
- // handle outside the lock, running using
- // whenCompleteAsync (preserves the original exceptional
completion)
- if (e != null) {
- handleConnectionFailure(e);
- }
- }, this.executor);
- } finally {
- lock.unlock();
+ final Connected connected = this.connected;
+ if (connected == null) {
+ return newNotConnectedResult();
}
+
+ return outcomeOf(connected.readValues(nodeIds));
}
public CompletableFuture<Map<ExpandedNodeId, BrowseResult>> browse(
List<ExpandedNodeId> expandedNodeIds, BrowseDirection direction,
int nodeClasses, int maxDepth, String filter,
boolean includeSubTypes, int maxNodesPerRequest) {
- lock.lock();
- try {
- if (this.connected == null) {
- return newNotConnectedResult();
- }
-
- return this.connected.browse(expandedNodeIds, direction,
nodeClasses, 1, maxDepth,
- null != filter ? Pattern.compile(filter) : null,
includeSubTypes, maxNodesPerRequest)
- .whenCompleteAsync((browseResult, e) -> {
- // handle outside the lock, running using
- // whenCompleteAsync (preserves the original
exceptional completion)
- if (e != null) {
- handleConnectionFailure(e);
- }
- }, this.executor);
- } finally {
- lock.unlock();
+ final Connected connected = this.connected;
+ if (connected == null) {
+ return newNotConnectedResult();
}
+
+ return outcomeOf(connected.browse(expandedNodeIds, direction,
nodeClasses, 1, maxDepth,
+ null != filter ? Pattern.compile(filter) : null,
includeSubTypes, maxNodesPerRequest));
+ }
+
+ /**
+ * The outcome of a producer request, completed on the executor of the
connection, so that the route continues there
+ * and not on a thread of Milo, which completes the responses; unlike
thenApplyAsync, whenCompleteAsync hands a
+ * failure to the executor too. A request that fails completes with its
failure and does not rebuild the connection:
+ * the client recovers a lost session itself, and a session that the
server no longer has fails the transfer of the
+ * subscription, which rebuilds the connection.
+ * <p>
+ * The producers read the current connection without {@code lock}, which a
service call of the consumers holds until
+ * the server answers, so they do not wait for it. A request on a
connection that is disposed meanwhile fails with
+ * the status of its closed session.
+ */
+ private <T> CompletableFuture<T> outcomeOf(final CompletableFuture<T>
request) {
+ return request.whenCompleteAsync((result, e) -> {
+ }, this.executor);
}
}
diff --git
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/MiloClientExecutorTest.java
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/MiloClientExecutorTest.java
index 8326debb6fac..bb52813c9182 100644
---
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/MiloClientExecutorTest.java
+++
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/MiloClientExecutorTest.java
@@ -69,10 +69,9 @@ public class MiloClientExecutorTest extends
AbstractMiloServerTest {
}
/**
- * A failed request disconnects the client, and the disconnect can wait
for a reconnect attempt that the client
- * schedules on the shared scheduled executor of Milo, which has a single
thread on a machine with one processor.
- * The outcome of a request is therefore not handled on that executor: an
awaited write completes while all of its
- * threads are busy.
+ * An awaited write completes while every thread of the shared scheduled
executor of Milo is busy: neither the
+ * request nor the handling of its outcome needs a thread of that
executor, which has a single thread on a machine
+ * with one processor.
*/
@Test
void awaitedWriteCompletesWhileTheSharedScheduledExecutorOfMiloIsBusy()
throws Exception {
diff --git
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/ProducerOnSharedConnectionTest.java
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/ProducerOnSharedConnectionTest.java
new file mode 100644
index 000000000000..d71c73cb9e98
--- /dev/null
+++
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/ProducerOnSharedConnectionTest.java
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.milo;
+
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.camel.EndpointInject;
+import org.apache.camel.Exchange;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.milo.client.MiloClientConnection;
+import org.apache.camel.component.milo.client.MiloClientEndpoint;
+import org.apache.camel.component.milo.client.MonitorFilterConfiguration;
+import org.apache.camel.component.milo.client.internal.SubscriptionManager;
+import org.apache.camel.component.milo.server.MiloServerComponent;
+import org.apache.camel.component.milo.server.internal.CamelNamespace;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.util.ObjectHelper;
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
+import org.eclipse.milo.opcua.sdk.server.AddressSpace;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+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.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn;
+import org.eclipse.milo.opcua.stack.core.types.structured.ReadValueId;
+import org.eclipse.milo.opcua.stack.core.types.structured.WriteValue;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.apache.camel.component.milo.NodeIds.nodeValue;
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The producers of a connection that consumers share do not wait while a
consumer creates its monitored items, and a
+ * request that fails sets its failure on the exchange without rebuilding the
connection: the consumers keep their
+ * monitored items and their session.
+ */
+public class ProducerOnSharedConnectionTest extends AbstractMiloServerTest {
+
+ // a namespace that the server does not have until a test adds it
+ private static final String HELD_NAMESPACE_URI =
"urn:org:apache:camel:held";
+
+ private static final String HELD_NODE = "nsu=" + HELD_NAMESPACE_URI +
";s=heldItem";
+
+ // a namespace whose node the server does not have, so it refuses to
monitor it
+ private static final String REFUSED_NAMESPACE_URI =
"urn:org:apache:camel:refused";
+
+ private static final String REFUSED_NODE = "refusedItem";
+
+ // the client endpoints only differ in their nodes, so they share one
connection
+ private static final String MILO_CLIENT_BASE =
"milo-client:opc.tcp://foo:bar@localhost:@@port@@"
+ +
"?allowedSecurityPolicies=None&overrideHost=true"
+ +
"&samplingInterval=50&requestedPublishingInterval=100";
+
+ private static final String ITEM_1 = "&node=" +
nodeValue(MiloServerComponent.DEFAULT_NAMESPACE_URI, "myitem1");
+
+ private static final String ITEM_2 = "&node=" +
nodeValue(MiloServerComponent.DEFAULT_NAMESPACE_URI, "myitem2");
+
+ private static final String HELD = "&node=RAW(" + HELD_NODE + ")";
+
+ // a connection of its own, whose requests time out after a second
+ private static final String MILO_CLIENT_TIMEOUT_BASE = MILO_CLIENT_BASE +
"&clientId=timeout&requestTimeout=1000";
+
+ // a connection of its own, through the relay: the client uses the port of
the relay, not the one of the server
+ private static final String MILO_CLIENT_RELAYED_BASE = MILO_CLIENT_BASE +
"&overridePort=true";
+
+ @RegisterExtension
+ AvailablePortFinder.Port relayPortHolder = AvailablePortFinder.find();
+
+ private final TcpRelay relay = new TcpRelay();
+
+ @EndpointInject("mock:item1")
+ protected MockEndpoint item1Endpoint;
+
+ @EndpointInject("mock:item2")
+ protected MockEndpoint item2Endpoint;
+
+ @EndpointInject("mock:timeoutItem1")
+ protected MockEndpoint timeoutItem1Endpoint;
+
+ @EndpointInject("mock:relayedItem1")
+ protected MockEndpoint relayedItem1Endpoint;
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // the consumers deliver the current values while they start,
so the tests start these routes
+ from(resolve(MILO_CLIENT_BASE +
ITEM_1)).routeId("item1").autoStartup(false).to("mock:item1");
+ from(resolve(MILO_CLIENT_BASE +
ITEM_2)).routeId("item2").autoStartup(false).to("mock:item2");
+ from(resolve(MILO_CLIENT_TIMEOUT_BASE +
ITEM_1)).routeId("timeoutItem1").autoStartup(false)
+ .to("mock:timeoutItem1");
+ from(relayed(MILO_CLIENT_RELAYED_BASE +
ITEM_1)).routeId("relayedItem1").autoStartup(false)
+ .to("mock:relayedItem1");
+ from(relayed(MILO_CLIENT_RELAYED_BASE +
ITEM_2)).routeId("relayedItem2").autoStartup(false)
+ .to("mock:relayedItem2");
+
+ // a write that the route does not wait for
+
from("direct:relayedWrite").to(relayed(MILO_CLIENT_RELAYED_BASE + ITEM_1));
+ }
+ };
+ }
+
+ private String relayed(final String uri) {
+ return uri.replace("@@port@@",
Integer.toString(relayPortHolder.getPort()));
+ }
+
+ @Override
+ protected void doPostSetup() throws Exception {
+ relay.start(relayPortHolder.getPort(), getServerPort());
+ }
+
+ @Override
+ protected void doPostTearDown() throws Exception {
+ // after the context, so that its client disconnects through the relay
+ relay.stop();
+ }
+
+ /**
+ * A consumer that starts while the link to the server is silent waits for
the server until the keep-alive of the
+ * client declares the session lost. A producer and {@link
MiloClientConnection#getOpcUaClient()} of the same
+ * connection do not wait for it.
+ */
+ @Test
+ @Timeout(value = 60, unit = TimeUnit.SECONDS)
+ void producerDoesNotWaitForAConsumerThatStartsOnASilentLink() throws
Exception {
+ template.sendBody("milo-server:myitem1", "Foo");
+ relayedItem1Endpoint.expectedMessageCount(1);
+ testBody(relayedItem1Endpoint.message(0), assertGoodValue("Foo"));
+ context.getRouteController().startRoute("relayedItem1");
+ relayedItem1Endpoint.assertIsSatisfied();
+
+ final MiloClientEndpoint endpoint
+ = context.getEndpoint(relayed(MILO_CLIENT_RELAYED_BASE +
ITEM_1), MiloClientEndpoint.class);
+ final MiloClientConnection connection = endpoint.createConnection();
+ final CompletableFuture<Void> start = new CompletableFuture<>();
+ try {
+ final OpcUaClient client = connection.getOpcUaClient();
+ assertNotNull(client);
+
+ // the link drops silently, then the next consumer starts: it
waits for the answer to its
+ // CreateMonitoredItems call, which does not come
+ relay.silence();
+ final Thread starter = new Thread(() -> {
+ try {
+ context.getRouteController().startRoute("relayedItem2");
+ start.complete(null);
+ } catch (final Exception e) {
+ start.completeExceptionally(e);
+ }
+ }, "start-relayedItem2");
+ starter.start();
+ // the consumer holds the lock of the manager while it waits for
the server
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
waitsInPutSubscriptions(starter));
+
+ assertSame(client,
assertTimeoutPreemptively(Duration.ofSeconds(2), connection::getOpcUaClient));
+ assertTimeoutPreemptively(Duration.ofSeconds(2), () ->
template.sendBody("direct:relayedWrite", "Bar"));
+ // the consumer was still waiting for the server
+ assertFalse(start.isDone());
+ } finally {
+ // the connections are dropped, which fails the call of the
consumer
+ relay.dropConnections();
+ start.handle((result, e) -> null).get(30, TimeUnit.SECONDS);
+ endpoint.releaseConnection(connection);
+ }
+ }
+
+ private static boolean waitsInPutSubscriptions(final Thread thread) {
+ final Thread.State state = thread.getState();
+ return (state == Thread.State.WAITING || state ==
Thread.State.TIMED_WAITING)
+ && Arrays.stream(thread.getStackTrace())
+ .anyMatch(frame ->
frame.getClassName().startsWith(SubscriptionManager.class.getName())
+ &&
frame.getMethodName().equals("putSubscriptions"));
+ }
+
+ /**
+ * A write that times out because the server does not answer it in time
fails the exchange. The session is still
+ * active, so the connection is kept: its consumer reports no loss and
keeps receiving values in the same session.
+ */
+ @Test
+ @Timeout(value = 60, unit = TimeUnit.SECONDS)
+ void writeThatTimesOutKeepsTheConnection() throws Exception {
+ template.sendBody("milo-server:myitem1", "Foo");
+ timeoutItem1Endpoint.expectedMessageCount(1);
+ testBody(timeoutItem1Endpoint.message(0), assertGoodValue("Foo"));
+ context.getRouteController().startRoute("timeoutItem1");
+ timeoutItem1Endpoint.assertIsSatisfied();
+
+ final CountDownLatch writing = new CountDownLatch(1);
+ final CountDownLatch release = new CountDownLatch(1);
+ final CamelNamespace namespace = holdingNamespace(writing, release);
+ final MiloClientEndpoint endpoint
+ = context.getEndpoint(resolve(MILO_CLIENT_TIMEOUT_BASE +
ITEM_1), MiloClientEndpoint.class);
+ final MiloClientConnection connection = endpoint.createConnection();
+ try {
+ namespace.startup();
+ namespace.getOrAddItem("heldItem").update("Foo");
+ final OpcUaClient client = connection.getOpcUaClient();
+ final NodeId sessionId = client.getSession().getSessionId();
+
+ // the consumer receives the next value only, no loss report and
not its value again
+ timeoutItem1Endpoint.reset();
+ timeoutItem1Endpoint.expectedMessageCount(1);
+ timeoutItem1Endpoint.setAssertPeriod(2_000);
+ testBody(timeoutItem1Endpoint.message(0), assertGoodValue("Bar"));
+
+ // the server holds the write
+ final Exchange written =
template.send(resolve(MILO_CLIENT_TIMEOUT_BASE + HELD),
+ exchange -> {
+ exchange.getMessage().setBody("Baz");
+
exchange.getMessage().setHeader(MiloConstants.HEADER_AWAIT, true);
+ });
+ assertTrue(writing.await(10, TimeUnit.SECONDS));
+ assertStatus(StatusCodes.Bad_Timeout, written);
+
+ template.sendBody("milo-server:myitem1", "Bar");
+ timeoutItem1Endpoint.assertIsSatisfied();
+
+ assertSame(client, connection.getOpcUaClient());
+ assertEquals(sessionId, client.getSession().getSessionId());
+ } finally {
+ release.countDown();
+ endpoint.releaseConnection(connection);
+ namespace.shutdown();
+ }
+ }
+
+ /**
+ * A write in flight on a connection that Camel replaces fails with the
status of its closed connection. Its failure
+ * does not rebuild the connection that replaced it.
+ */
+ @Test
+ @Timeout(value = 60, unit = TimeUnit.SECONDS)
+ void writeOnAReplacedConnectionFailsAndKeepsItsReplacement() throws
Exception {
+ template.sendBody("milo-server:myitem1", "Foo");
+ template.sendBody("milo-server:myitem2", "Foo");
+ item1Endpoint.expectedMessageCount(1);
+ testBody(item1Endpoint.message(0), assertGoodValue("Foo"));
+ context.getRouteController().startRoute("item1");
+ item1Endpoint.assertIsSatisfied();
+
+ final CountDownLatch writing = new CountDownLatch(1);
+ final CountDownLatch release = new CountDownLatch(1);
+ final CamelNamespace namespace = holdingNamespace(writing, release);
+ final MiloClientEndpoint endpoint =
context.getEndpoint(resolve(MILO_CLIENT_BASE + ITEM_1),
MiloClientEndpoint.class);
+ final MiloClientConnection connection = endpoint.createConnection();
+ try {
+ namespace.startup();
+ namespace.getOrAddItem("heldItem").update("Foo");
+ final OpcUaClient client = connection.getOpcUaClient();
+ item1Endpoint.reset();
+
+ // the server holds the write
+ final CompletableFuture<Exchange> written =
template.asyncSend(resolve(MILO_CLIENT_BASE + HELD),
+ exchange -> {
+ exchange.getMessage().setBody("Baz");
+
exchange.getMessage().setHeader(MiloConstants.HEADER_AWAIT, true);
+ });
+ assertTrue(writing.await(10, TimeUnit.SECONDS));
+
+ // the server deletes the subscription, so the
CreateMonitoredItems call of the next consumer fails as a
+ // whole, which rebuilds the connection
+ final UInteger subscriptionId =
client.getSubscriptions().get(0).getSubscriptionId().orElseThrow();
+
assertTrue(client.deleteSubscriptions(List.of(subscriptionId)).getResults()[0].isGood());
+ context.getRouteController().startRoute("item2");
+
+ assertStatus(StatusCodes.Bad_ConnectionClosed, written.get(30,
TimeUnit.SECONDS));
+ final OpcUaClient replacement = await().atMost(30,
TimeUnit.SECONDS)
+ .until(connection::getOpcUaClient, current -> current !=
null && current != client);
+ final NodeId sessionId = replacement.getSession().getSessionId();
+ // the consumer receives its current value from the replacement
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+ final List<Exchange> received =
item1Endpoint.getReceivedExchanges();
+ assertFalse(received.isEmpty());
+ assertGoodValue("Foo").accept(received.get(received.size() -
1).getMessage().getBody(DataValue.class));
+ });
+
+ // the replacement is kept: the consumer receives the next value
only
+ item1Endpoint.reset();
+ item1Endpoint.expectedMessageCount(1);
+ item1Endpoint.setAssertPeriod(2_000);
+ testBody(item1Endpoint.message(0), assertGoodValue("Bar"));
+ template.sendBody("milo-server:myitem1", "Bar");
+ item1Endpoint.assertIsSatisfied();
+
+ assertSame(replacement, connection.getOpcUaClient());
+ assertNotSame(client, replacement);
+ assertEquals(sessionId, replacement.getSession().getSessionId());
+ } finally {
+ release.countDown();
+ endpoint.releaseConnection(connection);
+ namespace.shutdown();
+ }
+ }
+
+ /**
+ * A connection creates a node that the server refused again while its
producer sends a request. With one processor
+ * the executor that completes the requests has a single thread, as here:
a write completes while the server holds
+ * the attempt, which waits for its answer.
+ */
+ @Test
+ @Timeout(value = 60, unit = TimeUnit.SECONDS)
+ void writeDoesNotWaitForTheAttemptToCreateARefusedNodeAgain() throws
Exception {
+ template.sendBody("milo-server:myitem1", "Foo");
+
+ final AtomicBoolean holding = new AtomicBoolean();
+ final CountDownLatch creating = new CountDownLatch(1);
+ final CountDownLatch release = new CountDownLatch(1);
+ final CamelNamespace namespace = new
CamelNamespace(REFUSED_NAMESPACE_URI, getServer().getServer()) {
+ @Override
+ public List<DataValue> read(
+ final AddressSpace.ReadContext context, final Double
maxAge, final TimestampsToReturn timestamps,
+ final List<ReadValueId> readValueIds) {
+ // the server reads the attributes of a node before it creates
its monitored item
+ if (holding.get() && readValueIds.stream()
+ .anyMatch(id ->
REFUSED_NODE.equals(id.getNodeId().getIdentifier()))) {
+ creating.countDown();
+ try {
+ release.await(30, TimeUnit.SECONDS);
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ return super.read(context, maxAge, timestamps, readValueIds);
+ }
+ };
+ namespace.startup();
+
+ final MiloClientEndpoint endpoint
+ = context.getEndpoint(resolve(MILO_CLIENT_BASE + ITEM_1),
MiloClientEndpoint.class);
+ // a connection of its own that completes its requests on a single
thread, as with one processor, and tries to
+ // create the refused node again every 500 ms
+ final ScheduledExecutorService reconnectExecutor =
Executors.newSingleThreadScheduledExecutor();
+ final ExecutorService executor = Executors.newSingleThreadExecutor();
+ final SubscriptionManager manager = new SubscriptionManager(
+ endpoint.getConfiguration(), reconnectExecutor, executor, 500);
+ try {
+ final List<DataValue> refusals = new CopyOnWriteArrayList<>();
+ manager.registerItems(
+ Map.of(ExpandedNodeId.parse("nsu=" + REFUSED_NAMESPACE_URI
+ ";s=" + REFUSED_NODE), refusals::add),
+ 50.0, null, new MonitorFilterConfiguration());
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
!refusals.isEmpty());
+ assertEquals(new StatusCode(StatusCodes.Bad_NodeIdUnknown),
refusals.get(0).getStatusCode());
+
+ // the server holds the next attempt to create the node
+ holding.set(true);
+ assertTrue(creating.await(10, TimeUnit.SECONDS));
+
+ final ExpandedNodeId item =
ExpandedNodeId.parse(endpoint.getNode());
+ assertTimeoutPreemptively(Duration.ofSeconds(2),
+ () -> manager.write(item, new DataValue(new
Variant("Bar"))).get());
+ // the attempt was still waiting for the server
+ assertEquals(1, release.getCount());
+ } finally {
+ release.countDown();
+ manager.dispose();
+ reconnectExecutor.shutdownNow();
+ executor.shutdownNow();
+ namespace.shutdown();
+ }
+ }
+
+ /**
+ * Asserts that an awaited request failed with the given status code of
the client
+ */
+ private static void assertStatus(final long statusCode, final Exchange
exchange) {
+ final UaException failure =
ObjectHelper.getException(UaException.class, exchange.getException());
+ assertNotNull(failure, () -> "Expected a UaException, but the exchange
failed with " + exchange.getException());
+ assertEquals(new StatusCode(statusCode), failure.getStatusCode());
+ }
+
+ /**
+ * A namespace of the server that holds the writes to its nodes until it
is released
+ */
+ private CamelNamespace holdingNamespace(final CountDownLatch writing,
final CountDownLatch release) {
+ return new CamelNamespace(HELD_NAMESPACE_URI, getServer().getServer())
{
+ @Override
+ public List<StatusCode> write(final AddressSpace.WriteContext
context, final List<WriteValue> writeValues) {
+ writing.countDown();
+ try {
+ release.await(30, TimeUnit.SECONDS);
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ return super.write(context, writeValues);
+ }
+ };
+ }
+
+ private MiloServerComponent getServer() {
+ return context.getComponent("milo-server", MiloServerComponent.class);
+ }
+}
diff --git
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/TcpRelay.java
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/TcpRelay.java
index b564fc117aef..032de545559c 100644
---
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/TcpRelay.java
+++
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/TcpRelay.java
@@ -23,10 +23,12 @@ import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
/**
- * A TCP relay to the server, whose connections a test can drop while it keeps
listening, and which can be down while
- * the client tries to connect.
+ * A TCP relay to the server, whose connections a test can drop or silence
while it keeps listening, and which can be
+ * down while the client tries to connect.
*/
final class TcpRelay {
@@ -34,6 +36,9 @@ final class TcpRelay {
private final List<Socket> sockets = new ArrayList<>();
+ // the sockets whose data is dropped instead of relayed
+ private final Set<Socket> silenced = ConcurrentHashMap.newKeySet();
+
void start(final int port, final int serverPort) throws IOException {
final ServerSocket listener = new ServerSocket(port);
this.listener = listener;
@@ -61,14 +66,16 @@ final class TcpRelay {
acceptor.start();
}
- private static void pump(final Socket from, final Socket to) {
+ private void pump(final Socket from, final Socket to) {
final Thread thread = new Thread(() -> {
final byte[] buffer = new byte[8192];
try (InputStream in = from.getInputStream(); OutputStream out =
to.getOutputStream()) {
int read;
while ((read = in.read(buffer)) >= 0) {
- out.write(buffer, 0, read);
- out.flush();
+ if (!silenced.contains(from)) {
+ out.write(buffer, 0, read);
+ out.flush();
+ }
}
} catch (final IOException e) {
// the connection was dropped
@@ -81,10 +88,21 @@ final class TcpRelay {
thread.start();
}
+ /**
+ * Drops what the current connections carry in both directions, without
closing them: the link goes silent, as when
+ * the network drops it without a reset, until the connections are
dropped. New connections are relayed.
+ */
+ void silence() {
+ synchronized (sockets) {
+ silenced.addAll(sockets);
+ }
+ }
+
void dropConnections() {
synchronized (sockets) {
sockets.forEach(TcpRelay::close);
sockets.clear();
+ silenced.clear();
}
}
diff --git
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/client/MiloClientProducerWriteFailureTest.java
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/client/MiloClientProducerWriteFailureTest.java
index aec7677cad6d..314ac7612ade 100644
---
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/client/MiloClientProducerWriteFailureTest.java
+++
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/client/MiloClientProducerWriteFailureTest.java
@@ -51,7 +51,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* completion from the underlying future (e.g. a Bad_Timeout service fault)
and used {@code handleAsync()}, it converted
* the failure into a normal completion with a null value. {@code
MiloClientProducer} then reported success with a null
* body. The fix replaces {@code handleAsync()} with {@code
whenCompleteAsync()} to preserve the original exceptional
- * completion while still triggering the reconnection side-effect.
+ * completion.
*/
class MiloClientProducerWriteFailureTest {
diff --git
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/client/internal/SubscriptionManagerExceptionPropagationTest.java
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/client/internal/SubscriptionManagerExceptionPropagationTest.java
index 061e857dd2fa..4b5fc73b775a 100644
---
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/client/internal/SubscriptionManagerExceptionPropagationTest.java
+++
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/client/internal/SubscriptionManagerExceptionPropagationTest.java
@@ -91,9 +91,9 @@ class SubscriptionManagerExceptionPropagationTest {
CompletableFuture<Object> upstream = new CompletableFuture<>();
upstream.completeExceptionally(serviceError);
- // Same chain as the fixed SubscriptionManager.write(): side-effect
only, completion preserved
+ // Same chain as the fixed SubscriptionManager.write(): no
side-effect, completion preserved
CompletableFuture<?> result = upstream.whenCompleteAsync((r, e) -> {
- // side-effect (e.g. handleConnectionFailure); exception
propagated by whenCompleteAsync
+ // no side-effect; exception propagated by whenCompleteAsync
}, executor);
ExecutionException ex = null;
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 4fef939267fc..437fa838df39 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
@@ -2426,15 +2426,15 @@ the consumer delivered nothing during an outage, and
values that did not change
A consumer that starts while the server cannot be reached, or whose client
cannot connect at all, for example because
of wrong credentials or an untrusted server certificate, delivers the status
for its nodes too; the cause is in the
-log. A connection that Camel rebuilds after a failed request delivers it for
every node of the connection. Each loss and
-each recovery delivers one exchange per node.
+log. A connection that Camel rebuilds delivers it for every node of the
connection. Each loss and each recovery
+delivers one exchange per node.
A route that uses only the value of the `DataValue` body receives a null for
these exchanges, which the type converters
turn into `0` or `false`; a route that stores values checks the status code of
the body first, for example with
`${body.statusCode.bad}`. A value can now arrive again after an outage, so a
route that counts values, computes
differences or detects changes must tolerate repeats. The consumer's route
also runs during an outage: an OPC UA write
that it makes while the client reconnects waits for the next reconnect attempt
and can take effect after communication
-is restored, and a request that fails rebuilds the connection.
+is restored.
To keep the previous behaviour, filter the exchanges with a bad status out
right after the consumer, for example with
`filter(simple("${body.statusCode.bad} == false"))`. This also drops the other
bad statuses, such as the refusal of a
@@ -2452,6 +2452,25 @@ An awaited `call` that succeeds now returns the
`CallMethodResult` as the exchan
always `null` even on success. A route that reads the result of an awaited
call must be updated to read the
`CallMethodResult` from the body instead of expecting `null`.
+=== camel-milo - a failed producer request keeps the connection
+
+A `milo-client` write, method call or read, or a `milo-browse` browse, that
fails (for example with `Bad_Timeout`, or
+because the session was lost) no longer disconnects the client connection and
rebuilds the subscriptions of the
+consumers that share it. The failure is still set on the exchange of an
awaited request. A lost session is recovered by
+the client itself, and a session that the server no longer has rebuilds the
connection, as before. The client recovers
+a lost session with the endpoint and the server certificate it found when it
connected, so a server that changes either
+while it cannot be reached is reached again only when Camel rebuilds the
connection, for example when the Camel context
+restarts.
+
+Producer requests and `MiloClientConnection.getOpcUaClient()` no longer wait
for the subscription work on the same
+connection to finish, such as a consumer that starts or stops, or the periodic
attempt to create the monitored items
+that the server refused. Before, they waited until the server answered that
work, and while the network was down
+silently, until the client noticed the lost session. While Camel rebuilds the
connection, a producer request now fails
+at once with an `IllegalStateException` ("No connected", caused by the failure
that ended the connection) instead of
+waiting for the rebuild to finish. The attempts to create the refused
monitored items again now run on the
+`MiloClientReconnect` thread instead of the `MiloClient` pool, so that a slow
attempt no longer holds up the outcome of
+the requests, which that pool completes; on a machine with one processor the
pool has a single thread.
+
=== camel-milo - the reconnectTimeout of the components sets the delay between
connection attempts
`setReconnectTimeout` on the `milo-client` and `milo-browse` components set
the request timeout of every service call,