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 3b2931e3ab99 CAMEL-25913: camel-milo - add a dev console that lists
the connections and their refused monitored items (#28272)
3b2931e3ab99 is described below
commit 3b2931e3ab9935f8b61e81fc5385c6aa19c532b9
Author: Serdar Gökay <[email protected]>
AuthorDate: Thu Oct 22 23:49:37 2026 +0300
CAMEL-25913: camel-milo - add a dev console that lists the connections and
their refused monitored items (#28272)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../apache/camel/catalog/dev-consoles-openapi.json | 78 +++
.../apache/camel/catalog/dev-consoles.properties | 1 +
.../camel/catalog/dev-consoles/milo-client.json | 78 +++
.../camel/catalog/docs/milo-client-component.adoc | 5 +
.../org/apache/camel/dev-console/milo-client.json | 78 +++
.../org/apache/camel/dev-console/milo-client | 2 +
.../org/apache/camel/dev-consoles.properties | 7 +
.../src/main/docs/milo-client-component.adoc | 5 +
.../milo/client/MiloClientConnection.java | 37 +-
.../component/milo/client/MiloClientConsumer.java | 7 +
.../milo/client/MiloClientDevConsole.java | 133 +++++
.../milo/client/internal/SubscriptionManager.java | 56 +-
.../component/milo/CommunicationLossTest.java | 99 +---
.../component/milo/MiloClientDevConsoleTest.java | 588 +++++++++++++++++++++
.../org/apache/camel/component/milo/TcpRelay.java | 111 ++++
15 files changed, 1178 insertions(+), 107 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
index 6c30c57da90d..c6f2f1b1939f 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
@@ -4486,6 +4486,84 @@
}
}
},
+ "\/q\/dev\/milo-client": {
+ "get": {
+ "summary": "OPC UA Client",
+ "description": "OPC UA client connections and
their refused monitored items",
+ "operationId": "milo-client",
+ "responses": {
+ "200": {
+ "description": "OPC UA Client
output",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type":
"object",
+
"properties": {
+
"connections": {
+
"type": "array",
+
"items": {
+
"type": "object",
+
"properties": {
+
"endpointUri": {
+
"type": "string",
+
"description": "The OPC UA server endpoint,
with its password masked"
+
},
+
"clientId": {
+
"type": "string",
+
"description": "The client id of the connection
(only present when one is set)"
+
},
+
"routeIds": {
+
"type": "array",
+
"items": {
+
"type": "string"
+
},
+
"description": "The ids of the routes whose
consumers use the connection"
+
},
+
"sessionActive": {
+
"type": "boolean",
+
"description": "Whether the session with the
server is active"
+
},
+
"monitoredNodes": {
+
"type": "integer",
+
"description": "The number of nodes that have a
monitored item on the server"
+
},
+
"refusedNodes": {
+
"type": "array",
+
"items": {
+
"type": "object",
+
"properties": {
+
"nodeId": {
+
"type": "string",
+
"description": "The
node ID"
+
},
+
"status": {
+
"type": "string",
+
"description": "The
status of the latest attempt to create the monitored item of the node: the
refusal, or the failure of the attempt"
+
},
+
"reason": {
+
"type": "string",
+
"description": "Why the
client refused the node without asking the server (only present when it did)"
+
}
+
}
+
},
+
"description": "The nodes whose monitored item
was refused, which are created again every reconnectTimeout milliseconds"
+
}
+
},
+
"required": [
+
"sessionActive",
+
"monitoredNodes"
+
]
+
},
+
"description": "The connections of the consumers"
+
}
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"\/q\/dev\/opentelemetry": {
"get": {
"summary": "OpenTelemetry Spans",
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties
index d1ffc72f9789..d694988169b9 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties
@@ -43,6 +43,7 @@ main-http-server
memory
message-history
micrometer
+milo-client
opentelemetry
platform-http
processor
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/milo-client.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/milo-client.json
new file mode 100644
index 000000000000..7b4768636f30
--- /dev/null
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/milo-client.json
@@ -0,0 +1,78 @@
+{
+ "console": {
+ "kind": "console",
+ "group": "camel",
+ "name": "milo-client",
+ "title": "OPC UA Client",
+ "description": "OPC UA client connections and their refused monitored
items",
+ "deprecated": false,
+ "readOnly": true,
+ "javaType": "org.apache.camel.component.milo.client.MiloClientDevConsole",
+ "groupId": "org.apache.camel",
+ "artifactId": "camel-milo",
+ "version": "4.24.0-SNAPSHOT"
+ },
+ "response": {
+ "type": "object",
+ "properties": {
+ "connections": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "endpointUri": {
+ "type": "string",
+ "description": "The OPC UA server endpoint, with its password
masked"
+ },
+ "clientId": {
+ "type": "string",
+ "description": "The client id of the connection (only present
when one is set)"
+ },
+ "routeIds": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "The ids of the routes whose consumers use the
connection"
+ },
+ "sessionActive": {
+ "type": "boolean",
+ "description": "Whether the session with the server is active"
+ },
+ "monitoredNodes": {
+ "type": "integer",
+ "description": "The number of nodes that have a monitored item
on the server"
+ },
+ "refusedNodes": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "nodeId": {
+ "type": "string",
+ "description": "The node ID"
+ },
+ "status": {
+ "type": "string",
+ "description": "The status of the latest attempt to create
the monitored item of the node: the refusal, or the failure of the attempt"
+ },
+ "reason": {
+ "type": "string",
+ "description": "Why the client refused the node without
asking the server (only present when it did)"
+ }
+ }
+ },
+ "description": "The nodes whose monitored item was refused,
which are created again every reconnectTimeout milliseconds"
+ }
+ },
+ "required": [
+ "sessionActive",
+ "monitoredNodes"
+ ]
+ },
+ "description": "The connections of the consumers"
+ }
+ }
+ }
+}
+
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 9164dfc23803..e12de6d7a3b7 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
@@ -201,6 +201,11 @@ A node whose namespace URI (`nsu=`) is not in the
namespace table of the server
way, so a namespace that the server adds later is found.
The consumer delivers one exchange with the bad status code for such a node,
and the `CamelMiloNodeId` header names it.
+The `milo-client` developer console (`/q/dev/milo-client`, for example with
`camel run --console`) shows which nodes are
+not monitored: for each connection of the consumers, it lists the routes that
use it, whether its session is active, the
+number of monitored nodes, and the refused nodes, each with the status of the
latest attempt to create its monitored
+item. Reading it does not wait for a request to the server in progress.
+
=== Status codes and null values
A data change with a Bad or Uncertain status is always delivered, even when
its value is null; `omitNullValues` only
diff --git
a/components/camel-milo/src/generated/resources/META-INF/org/apache/camel/dev-console/milo-client.json
b/components/camel-milo/src/generated/resources/META-INF/org/apache/camel/dev-console/milo-client.json
new file mode 100644
index 000000000000..7b4768636f30
--- /dev/null
+++
b/components/camel-milo/src/generated/resources/META-INF/org/apache/camel/dev-console/milo-client.json
@@ -0,0 +1,78 @@
+{
+ "console": {
+ "kind": "console",
+ "group": "camel",
+ "name": "milo-client",
+ "title": "OPC UA Client",
+ "description": "OPC UA client connections and their refused monitored
items",
+ "deprecated": false,
+ "readOnly": true,
+ "javaType": "org.apache.camel.component.milo.client.MiloClientDevConsole",
+ "groupId": "org.apache.camel",
+ "artifactId": "camel-milo",
+ "version": "4.24.0-SNAPSHOT"
+ },
+ "response": {
+ "type": "object",
+ "properties": {
+ "connections": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "endpointUri": {
+ "type": "string",
+ "description": "The OPC UA server endpoint, with its password
masked"
+ },
+ "clientId": {
+ "type": "string",
+ "description": "The client id of the connection (only present
when one is set)"
+ },
+ "routeIds": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "The ids of the routes whose consumers use the
connection"
+ },
+ "sessionActive": {
+ "type": "boolean",
+ "description": "Whether the session with the server is active"
+ },
+ "monitoredNodes": {
+ "type": "integer",
+ "description": "The number of nodes that have a monitored item
on the server"
+ },
+ "refusedNodes": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "nodeId": {
+ "type": "string",
+ "description": "The node ID"
+ },
+ "status": {
+ "type": "string",
+ "description": "The status of the latest attempt to create
the monitored item of the node: the refusal, or the failure of the attempt"
+ },
+ "reason": {
+ "type": "string",
+ "description": "Why the client refused the node without
asking the server (only present when it did)"
+ }
+ }
+ },
+ "description": "The nodes whose monitored item was refused,
which are created again every reconnectTimeout milliseconds"
+ }
+ },
+ "required": [
+ "sessionActive",
+ "monitoredNodes"
+ ]
+ },
+ "description": "The connections of the consumers"
+ }
+ }
+ }
+}
+
diff --git
a/components/camel-milo/src/generated/resources/META-INF/services/org/apache/camel/dev-console/milo-client
b/components/camel-milo/src/generated/resources/META-INF/services/org/apache/camel/dev-console/milo-client
new file mode 100644
index 000000000000..286b5696975b
--- /dev/null
+++
b/components/camel-milo/src/generated/resources/META-INF/services/org/apache/camel/dev-console/milo-client
@@ -0,0 +1,2 @@
+# Generated by camel build tools - do NOT edit this file!
+class=org.apache.camel.component.milo.client.MiloClientDevConsole
diff --git
a/components/camel-milo/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties
b/components/camel-milo/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties
new file mode 100644
index 000000000000..1e245f56026c
--- /dev/null
+++
b/components/camel-milo/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties
@@ -0,0 +1,7 @@
+# Generated by camel build tools - do NOT edit this file!
+dev-consoles=milo-client
+groupId=org.apache.camel
+artifactId=camel-milo
+version=4.24.0-SNAPSHOT
+projectName=Camel :: Milo
+projectDescription=Camel OPC UA support
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 9164dfc23803..e12de6d7a3b7 100644
--- a/components/camel-milo/src/main/docs/milo-client-component.adoc
+++ b/components/camel-milo/src/main/docs/milo-client-component.adoc
@@ -201,6 +201,11 @@ A node whose namespace URI (`nsu=`) is not in the
namespace table of the server
way, so a namespace that the server adds later is found.
The consumer delivers one exchange with the bad status code for such a node,
and the `CamelMiloNodeId` header names it.
+The `milo-client` developer console (`/q/dev/milo-client`, for example with
`camel run --console`) shows which nodes are
+not monitored: for each connection of the consumers, it lists the routes that
use it, whether its session is active, the
+number of monitored nodes, and the refused nodes, each with the status of the
latest attempt to create its monitored
+item. Reading it does not wait for a request to the server in progress.
+
=== Status codes and null values
A data change with a Bad or Uncertain status is always delivered, even when
its value is null; `omitNullValues` only
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 64168c9197fa..3b276156dd81 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
@@ -48,12 +48,36 @@ import static java.util.Objects.requireNonNull;
public class MiloClientConnection implements AutoCloseable, CamelContextAware {
+ /**
+ * What a connection monitors on its server.
+ *
+ * @param sessionActive whether the session with the server is active
+ * @param monitoredNodes the number of nodes that have a monitored item on
the server
+ * @param refusedNodes the nodes whose monitored item was refused, which
are created again every
+ * {@code reconnectTimeout} milliseconds
+ */
+ public record MonitoringState(boolean sessionActive, int monitoredNodes,
List<RefusedNode> refusedNodes) {
+ }
+
+ /**
+ * A node whose monitored item was refused.
+ *
+ * @param nodeId the node ID
+ * @param status the status of the latest attempt to create the monitored
item of the node: the refusal, or the
+ * failure of the attempt
+ * @param reason why the client refused the node without asking the
server, or {@code null}
+ */
+ public record RefusedNode(String nodeId, StatusCode status, String reason)
{
+ }
+
private final Lock lock = new ReentrantLock();
private final MiloClientConfiguration configuration;
private volatile CamelContext camelContext;
private ExecutorService executor;
private ScheduledExecutorService reconnectExecutor;
- private SubscriptionManager manager;
+ // read by getMonitoringState without the lock, which the first connect
and the close hold while they wait for the
+ // server
+ private volatile SubscriptionManager manager;
private volatile boolean initialized;
public MiloClientConnection(final MiloClientConfiguration configuration) {
@@ -100,6 +124,17 @@ public class MiloClientConnection implements
AutoCloseable, CamelContextAware {
return this.manager.getOpcUaClient();
}
+ /**
+ * Returns what this connection monitors on its server. It does not wait
for a service call in progress and does not
+ * connect.
+ *
+ * @return the monitoring state, or {@code null} if the connection is not
used yet or is closed
+ */
+ MonitoringState getMonitoringState() {
+ final SubscriptionManager manager = this.manager;
+ return manager != null ? manager.getMonitoringState() : null;
+ }
+
protected void init() {
ObjectHelper.notNull(this.camelContext, "camelContext");
diff --git
a/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientConsumer.java
b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientConsumer.java
index 59e0c90aabc1..706ff217b909 100644
---
a/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientConsumer.java
+++
b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientConsumer.java
@@ -58,6 +58,13 @@ public class MiloClientConsumer extends DefaultConsumer {
return (MiloClientEndpoint) super.getEndpoint();
}
+ /**
+ * The connection that monitors the nodes of this consumer while it is
started
+ */
+ MiloClientConnection getConnection() {
+ return this.connection;
+ }
+
@Override
protected void doStart() throws Exception {
super.doStart();
diff --git
a/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientDevConsole.java
b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientDevConsole.java
new file mode 100644
index 000000000000..8670f893dc72
--- /dev/null
+++
b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientDevConsole.java
@@ -0,0 +1,133 @@
+/*
+ * 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.client;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.Route;
+import
org.apache.camel.component.milo.client.MiloClientConnection.MonitoringState;
+import org.apache.camel.component.milo.client.MiloClientConnection.RefusedNode;
+import org.apache.camel.spi.Metadata;
+import org.apache.camel.spi.annotations.DevConsole;
+import org.apache.camel.support.console.AbstractDevConsole;
+import org.apache.camel.util.URISupport;
+import org.apache.camel.util.json.JsonRecordSupport;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+
+/**
+ * The connections of the OPC UA client consumers, with their refused
monitored items. It reads the state of the
+ * connections without waiting for a service call in progress.
+ */
+@DevConsole(name = "milo-client", displayName = "OPC UA Client",
+ description = "OPC UA client connections and their refused
monitored items")
+public class MiloClientDevConsole extends AbstractDevConsole {
+
+ public record RefusedNodeEntry(
+ @Metadata(description = "The node ID") String nodeId,
+ @Metadata(description = "The status of the latest attempt to
create the monitored item of the node: the refusal, or the failure of the
attempt") String status,
+ @Metadata(description = "Why the client refused the node without
asking the server (only present when it did)") String reason) {
+ }
+
+ public record ConnectionEntry(
+ @Metadata(description = "The OPC UA server endpoint, with its
password masked") String endpointUri,
+ @Metadata(description = "The client id of the connection (only
present when one is set)") String clientId,
+ @Metadata(description = "The ids of the routes whose consumers use
the connection") List<String> routeIds,
+ @Metadata(description = "Whether the session with the server is
active") boolean sessionActive,
+ @Metadata(description = "The number of nodes that have a monitored
item on the server") int monitoredNodes,
+ @Metadata(description = "The nodes whose monitored item was
refused, which are created again every reconnectTimeout milliseconds")
List<RefusedNodeEntry> refusedNodes) {
+ }
+
+ public record Response(@Metadata(description = "The connections of the
consumers") List<ConnectionEntry> connections) {
+ }
+
+ public MiloClientDevConsole() {
+ super("camel", "milo-client", "OPC UA Client",
+ "OPC UA client connections and their refused monitored items");
+ }
+
+ @Override
+ protected String doCallText(Map<String, Object> options) {
+ StringBuilder sb = new StringBuilder();
+ for (ConnectionEntry connection : connections()) {
+ sb.append(String.format("%n Endpoint: %s",
connection.endpointUri()));
+ if (connection.clientId() != null) {
+ sb.append(String.format("%n Client Id: %s",
connection.clientId()));
+ }
+ sb.append(String.format("%n Routes: %s", String.join(", ",
connection.routeIds())));
+ sb.append(String.format("%n Session Active: %b",
connection.sessionActive()));
+ sb.append(String.format("%n Monitored Nodes: %d",
connection.monitoredNodes()));
+ sb.append(String.format("%n Refused Nodes: %d",
connection.refusedNodes().size()));
+ for (RefusedNodeEntry node : connection.refusedNodes()) {
+ sb.append(String.format("%n %s: %s", node.nodeId(),
node.status()));
+ if (node.reason() != null) {
+ sb.append(String.format(" (%s)", node.reason()));
+ }
+ }
+ sb.append("\n");
+ }
+ return sb.toString();
+ }
+
+ @Override
+ protected Map<String, Object> doCallJson(Map<String, Object> options) {
+ Response response = new Response(connections());
+ return JsonRecordSupport.toJsonObject(response);
+ }
+
+ /**
+ * The connections of the started consumers, each with the routes that use
it, in the order of the routes
+ */
+ private List<ConnectionEntry> connections() {
+ Map<MiloClientConnection, List<String>> routeIds = new
LinkedHashMap<>();
+ for (Route route : getCamelContext().getRoutes()) {
+ if (route.getConsumer() instanceof MiloClientConsumer consumer &&
consumer.isStarted()) {
+ routeIds.computeIfAbsent(consumer.getConnection(), connection
-> new ArrayList<>())
+ .add(route.getRouteId());
+ }
+ }
+
+ List<ConnectionEntry> connections = new ArrayList<>();
+ for (Map.Entry<MiloClientConnection, List<String>> entry :
routeIds.entrySet()) {
+ MonitoringState state = entry.getKey().getMonitoringState();
+ if (state == null) {
+ // closed while the consumers stop
+ continue;
+ }
+ List<RefusedNodeEntry> refusedNodes = new ArrayList<>();
+ for (RefusedNode node : state.refusedNodes()) {
+ refusedNodes.add(new RefusedNodeEntry(node.nodeId(),
statusName(node.status()), node.reason()));
+ }
+ MiloClientConfiguration configuration =
entry.getKey().getConfiguration();
+ connections.add(new ConnectionEntry(
+ URISupport.sanitizeUri(configuration.getEndpointUri()),
configuration.getClientId(), entry.getValue(),
+ state.sessionActive(), state.monitoredNodes(),
refusedNodes));
+ }
+ return connections;
+ }
+
+ /**
+ * The symbolic name of a status code, or its hexadecimal value if it has
none
+ */
+ private static String statusName(StatusCode status) {
+ return StatusCodes.lookup(status.getValue()).map(nameAndDescription ->
nameAndDescription[0])
+ .orElseGet(() -> String.format("0x%08X", status.getValue()));
+ }
+}
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 1ea704e01576..7ff69a4ec4bc 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
@@ -26,7 +26,6 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
-import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
@@ -34,6 +33,7 @@ import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.Executor;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
@@ -49,6 +49,8 @@ import java.util.stream.Stream;
import org.apache.camel.RuntimeCamelException;
import org.apache.camel.component.milo.client.MiloClientConfiguration;
+import
org.apache.camel.component.milo.client.MiloClientConnection.MonitoringState;
+import org.apache.camel.component.milo.client.MiloClientConnection.RefusedNode;
import org.apache.camel.component.milo.client.MonitorFilterConfiguration;
import org.eclipse.milo.opcua.sdk.client.DiscoveryClient;
import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
@@ -346,15 +348,20 @@ public class SubscriptionManager {
private final SubscriptionListenerImpl listener;
private final long generation;
- private final Map<UInteger, OpcUaMonitoredItem> goodSubscriptions =
new HashMap<>();
+ /**
+ * The monitored items by client handle, written under {@code lock}
and counted by the monitoring state without
+ * it
+ */
+ private final Map<UInteger, OpcUaMonitoredItem> goodSubscriptions =
new ConcurrentHashMap<>();
private final Map<String, UShort> namespaceCache = new
ConcurrentHashMap<>();
/**
- * The client handles of the nodes that the server refused to monitor
on this connection, which are created
- * again every {@code reconnectTimeout} milliseconds, guarded by
{@code lock}
+ * The nodes whose monitored item was refused on this connection, by
client handle, each with the status of its
+ * latest attempt, which are created again every {@code
reconnectTimeout} milliseconds. Written under
+ * {@code lock} and read by the monitoring state without it.
*/
- private final Set<UInteger> refused = new LinkedHashSet<>();
+ private final Map<UInteger, RefusedNode> refused = new
ConcurrentSkipListMap<>();
/**
* The next attempt to create the refused nodes again, if one is
scheduled, guarded by {@code lock}
@@ -454,7 +461,7 @@ public class SubscriptionManager {
if (result.isGood()) {
this.goodSubscriptions.put(clientHandle, item);
subscriptions.get(clientHandle).refused = false;
- if (this.refused.remove(clientHandle) && retry) {
+ if (this.refused.remove(clientHandle) != null &&
retry) {
LOG.info("Monitored item for node {} created on
retry",
subscriptions.get(clientHandle).getNodeId().toParseableString());
}
@@ -496,8 +503,8 @@ public class SubscriptionManager {
private void refuse(
final UInteger clientHandle, final Subscription s, final
StatusCode status, final String reason,
final boolean retry, final List<PendingValue> pendingValues) {
- this.refused.add(clientHandle);
final String node = s.getNodeId().toParseableString();
+ this.refused.put(clientHandle, new RefusedNode(node, status,
reason));
final String because = reason == null ? "" : ", " + reason;
if (retry) {
LOG.debug("Monitored item for node {} could not be created on
retry{}: {}", node, because, status);
@@ -964,7 +971,10 @@ public class SubscriptionManager {
private final ServerCertificateTrust serverCertificateTrust;
private final Lock lock = new ReentrantLock();
- private Connected connected;
+ /**
+ * The current connection, written under {@code lock} and read by the
monitoring state without it
+ */
+ private volatile Connected connected;
private volatile Throwable lastConnectFailure;
private volatile boolean disposed;
private Future<?> reconnectJob;
@@ -1001,6 +1011,23 @@ public class SubscriptionManager {
}
}
+ /**
+ * Returns what the current connection monitors. It is read without {@code
lock}, which a service call holds until
+ * the server answers, so its values are read one after the other and need
not be consistent with each other.
+ *
+ * @return the monitoring state, with no session and no nodes while there
is no connection or it is being closed
+ */
+ public MonitoringState getMonitoringState() {
+ final Connected connected = this.connected;
+ // a connection that is being closed can wait for the server, its
nodes are reported lost already
+ if (connected == null || this.disposed || connected.listener.closed) {
+ return new MonitoringState(false, 0, List.of());
+ }
+ return new MonitoringState(
+ connected.listener.sessionActive,
connected.goodSubscriptions.size(),
+ List.copyOf(connected.refused.values()));
+ }
+
/**
* 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.
@@ -1125,7 +1152,8 @@ public class SubscriptionManager {
* while the connection is the current one. Nothing is delivered to the
consumers: a node created now delivers its
* first value from the server, and its refusal, if not delivered yet, is
dropped. While the session is not active,
* the attempt is skipped. A call that fails is made again with the next
attempt and never rebuilds the connection,
- * which would interrupt the nodes that are monitored: a lost session or
subscription is recovered without it.
+ * which would interrupt the nodes that are monitored: a lost session or
subscription is recovered without it. Its
+ * 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
@@ -1146,16 +1174,24 @@ public class SubscriptionManager {
return;
}
final Map<UInteger, Subscription> refused = new LinkedHashMap<>();
- for (final UInteger clientHandle : connected.refused) {
+ for (final UInteger clientHandle : connected.refused.keySet()) {
final Subscription subscription =
this.subscriptions.get(clientHandle);
if (subscription != null) {
refused.put(clientHandle, subscription);
}
}
+ final Map<UInteger, RefusedNode> beforeAttempt = new
HashMap<>(connected.refused);
try {
connected.putSubscriptions(refused, true);
} catch (final Exception e) {
LOG.debug("Failed to create the refused monitored items again,
trying again later", e);
+ // the call was rejected or failed, or the namespace table
could not be read: a node that the attempt
+ // did not answer, whose entry is still the one from before
it, has the failure of the attempt
+ final StatusCode failure
+ = UaException.extractStatusCode(e).orElse(new
StatusCode(StatusCodes.Bad_UnexpectedError));
+ refused.keySet().forEach(clientHandle ->
connected.refused.computeIfPresent(clientHandle,
+ (handle, node) -> node == beforeAttempt.get(handle)
+ ? new RefusedNode(node.nodeId(), failure,
null) : node));
connected.scheduleRetry();
}
} finally {
diff --git
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/CommunicationLossTest.java
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/CommunicationLossTest.java
index d311681e54e5..8996efb3d0d2 100644
---
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/CommunicationLossTest.java
+++
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/CommunicationLossTest.java
@@ -16,13 +16,6 @@
*/
package org.apache.camel.component.milo;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.net.ServerSocket;
-import java.net.Socket;
-import java.util.ArrayList;
-import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@@ -99,11 +92,11 @@ public class CommunicationLossTest extends
AbstractMiloServerTest {
@RegisterExtension
AvailablePortFinder.Port reconnectTimeoutRelayPort =
AvailablePortFinder.find();
- private final Relay relay = new Relay();
+ private final TcpRelay relay = new TcpRelay();
- private final Relay lateRelay = new Relay();
+ private final TcpRelay lateRelay = new TcpRelay();
- private final Relay reconnectTimeoutRelay = new Relay();
+ private final TcpRelay reconnectTimeoutRelay = new TcpRelay();
@Override
protected RoutesBuilder createRouteBuilder() {
@@ -314,90 +307,4 @@ public class CommunicationLossTest extends
AbstractMiloServerTest {
reconnectTimeoutRelay.start(reconnectTimeoutRelayPort.getPort(),
getServerPort());
reconnectTimeoutEndpoint.assertIsSatisfied();
}
-
- /**
- * A TCP relay to the server, whose connections the test can drop while it
keeps listening, and which can be down
- * while the client tries to connect.
- */
- private static final class Relay {
-
- private volatile ServerSocket listener;
-
- private final List<Socket> sockets = new ArrayList<>();
-
- void start(final int port, final int serverPort) throws IOException {
- final ServerSocket listener = new ServerSocket(port);
- this.listener = listener;
- final Thread acceptor = new Thread(() -> {
- while (!listener.isClosed()) {
- Socket client = null;
- try {
- client = listener.accept();
- final Socket server = new Socket("localhost",
serverPort);
- synchronized (sockets) {
- sockets.add(client);
- sockets.add(server);
- }
- pump(client, server);
- pump(server, client);
- } catch (final IOException e) {
- // the listener was closed, or the server refused:
nothing to relay
- if (client != null) {
- close(client);
- }
- }
- }
- }, "relay-" + port);
- acceptor.setDaemon(true);
- acceptor.start();
- }
-
- private static 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();
- }
- } catch (final IOException e) {
- // the connection was dropped
- } finally {
- close(from);
- close(to);
- }
- }, "relay-pump");
- thread.setDaemon(true);
- thread.start();
- }
-
- void dropConnections() {
- synchronized (sockets) {
- sockets.forEach(Relay::close);
- sockets.clear();
- }
- }
-
- void stop() {
- final ServerSocket listener = this.listener;
- this.listener = null;
- if (listener != null) {
- try {
- listener.close();
- } catch (final IOException e) {
- // nothing left to close
- }
- }
- dropConnections();
- }
-
- private static void close(final Socket socket) {
- try {
- socket.close();
- } catch (final IOException e) {
- // already closed
- }
- }
- }
}
diff --git
a/components/camel-milo/src/test/java/org/apache/camel/component/milo/MiloClientDevConsoleTest.java
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/MiloClientDevConsoleTest.java
new file mode 100644
index 000000000000..632121397aba
--- /dev/null
+++
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/MiloClientDevConsoleTest.java
@@ -0,0 +1,588 @@
+/*
+ * 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.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.EndpointInject;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+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.console.DevConsole;
+import org.apache.camel.support.PluginHelper;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.eclipse.milo.opcua.sdk.server.items.DataItem;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+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.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The milo-client dev console lists the connections of the consumers, with
their refused monitored items
+ */
+public class MiloClientDevConsoleTest extends AbstractMiloServerTest {
+
+ // the client endpoints only differ in their nodes, so they share one
connection, which creates the refused nodes
+ // again every second
+ private static final String MILO_CLIENT_BASE =
"milo-client:opc.tcp://foo:bar@localhost:@@port@@"
+ +
"?allowedSecurityPolicies=None&overrideHost=true"
+ +
"&samplingInterval=50&requestedPublishingInterval=100"
+ + "&reconnectTimeout=1000";
+
+ // namespaces that the server does not have until a test adds them
+ private static final String LATE_NAMESPACE_URI =
"urn:org:apache:camel:late";
+
+ private static final String HELD_NAMESPACE_URI =
"urn:org:apache:camel:held";
+
+ private static final String MONITORED_NODE = "nsu=" +
MiloServerComponent.DEFAULT_NAMESPACE_URI + ";s=monitoredItem";
+
+ private static final String MISSING_NODE = "nsu=" +
MiloServerComponent.DEFAULT_NAMESPACE_URI + ";s=missingItem";
+
+ private static final String LATE_NAMESPACE_NODE = "nsu=" +
LATE_NAMESPACE_URI + ";s=lateNamespaceItem";
+
+ private static final String HELD_NODE = "nsu=" + HELD_NAMESPACE_URI +
";s=heldItem";
+
+ private static final String MILO_CLIENT_NODES
+ = MILO_CLIENT_BASE + "&nodes=RAW(" + MONITORED_NODE + "," +
MISSING_NODE + "," + LATE_NAMESPACE_NODE + ")";
+
+ private static final String MILO_CLIENT_OTHER
+ = MILO_CLIENT_BASE + "&node=" +
NodeIds.nodeValue(MiloServerComponent.DEFAULT_NAMESPACE_URI, "otherItem");
+
+ private static final String MILO_CLIENT_HELD = MILO_CLIENT_BASE +
"&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";
+
+ private static final String MILO_CLIENT_TIMEOUT = MILO_CLIENT_TIMEOUT_BASE
+ "&node=RAW(" + HELD_NODE + ")";
+
+ private static final String MILO_CLIENT_TIMEOUT_MONITORED
+ = MILO_CLIENT_TIMEOUT_BASE + "&node=RAW(" + MONITORED_NODE + ")";
+
+ private static final String MILO_CLIENT_TIMEOUT_LATE_NAMESPACE
+ = MILO_CLIENT_TIMEOUT_BASE + "&node=RAW(" + LATE_NAMESPACE_NODE +
")";
+
+ // 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
+ = MILO_CLIENT_BASE + "&overridePort=true&nodes=RAW(" +
MONITORED_NODE + "," + MISSING_NODE + ")";
+
+ // nothing listens on this port
+ @RegisterExtension
+ AvailablePortFinder.Port unusedPortHolder = AvailablePortFinder.find();
+
+ @RegisterExtension
+ AvailablePortFinder.Port relayPortHolder = AvailablePortFinder.find();
+
+ private final TcpRelay relay = new TcpRelay();
+
+ @EndpointInject("mock:nodes")
+ protected MockEndpoint nodesEndpoint;
+
+ @EndpointInject("mock:other")
+ protected MockEndpoint otherEndpoint;
+
+ @EndpointInject("mock:timeout")
+ protected MockEndpoint timeoutEndpoint;
+
+ @EndpointInject("mock:timeoutMonitored")
+ protected MockEndpoint timeoutMonitoredEndpoint;
+
+ @EndpointInject("mock:timeoutLateNamespace")
+ protected MockEndpoint timeoutLateNamespaceEndpoint;
+
+ @EndpointInject("mock:relayed")
+ protected MockEndpoint relayedEndpoint;
+
+ @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_NODES)).routeId("nodes").autoStartup(false).to("mock:nodes");
+
from(resolve(MILO_CLIENT_OTHER)).routeId("other").autoStartup(false).to("mock:other");
+
from(resolve(MILO_CLIENT_HELD)).routeId("held").autoStartup(false).to("mock:held");
+
from(resolve(MILO_CLIENT_TIMEOUT)).routeId("timeout").autoStartup(false).to("mock:timeout");
+
from(resolve(MILO_CLIENT_TIMEOUT_MONITORED)).routeId("timeoutMonitored").autoStartup(false)
+ .to("mock:timeoutMonitored");
+
from(resolve(MILO_CLIENT_TIMEOUT_LATE_NAMESPACE)).routeId("timeoutLateNamespace").autoStartup(false)
+ .to("mock:timeoutLateNamespace");
+ from("milo-client:opc.tcp://localhost:" +
unusedPortHolder.getPort()
+ + "?allowedSecurityPolicies=None&node=RAW(" +
MONITORED_NODE + ")")
+
.routeId("unreachable").autoStartup(false).to("mock:unreachable");
+ from(MILO_CLIENT_RELAYED.replace("@@port@@",
Integer.toString(relayPortHolder.getPort())))
+
.routeId("relayed").autoStartup(false).to("mock:relayed");
+ }
+ };
+ }
+
+ @Override
+ protected void doPostTearDown() throws Exception {
+ // after the context, so that its client disconnects through the relay
+ relay.stop();
+ }
+
+ @Test
+ void consoleWithoutConsumersListsNoConnection() {
+ final DevConsole console = resolveConsole();
+ assertEquals("camel", console.getGroup());
+ assertEquals("milo-client", console.getId());
+
+ assertEquals("", console.call(DevConsole.MediaType.TEXT));
+ final JsonObject out = (JsonObject)
console.call(DevConsole.MediaType.JSON);
+ final JsonArray connections = out.getCollection("connections");
+ assertTrue(connections.isEmpty());
+ }
+
+ @Test
+ @Timeout(value = 60, unit = TimeUnit.SECONDS)
+ void consoleListsTheRefusedNodesOfAConnection() throws Exception {
+ startNodes();
+
+ final DevConsole console = resolveConsole();
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+ final JsonObject connection = singleConnection(console);
+ assertEquals("opc.tcp://foo:xxxxxx@localhost:" + getServerPort(),
connection.getString("endpointUri"));
+ assertEquals(List.of("nodes"),
connection.getCollection("routeIds"));
+ assertTrue(connection.getBoolean("sessionActive"));
+ assertEquals(1, connection.getInteger("monitoredNodes"));
+
+ final JsonArray refusedNodes =
connection.getCollection("refusedNodes");
+ assertEquals(2, refusedNodes.size());
+ // refused by the server
+ assertRefusedNode(refusedNodes.get(0), MISSING_NODE,
"Bad_NodeIdUnknown", null);
+ // refused by the client, without asking the server
+ assertRefusedNode(refusedNodes.get(1), LATE_NAMESPACE_NODE,
"Bad_NodeIdUnknown",
+ "the namespace URI is unknown to the server");
+ });
+
+ assertEquals(String.format("%n Endpoint:
opc.tcp://foo:xxxxxx@localhost:%d", getServerPort())
+ + String.format("%n Routes: nodes")
+ + String.format("%n Session Active: true")
+ + String.format("%n Monitored Nodes: 1")
+ + String.format("%n Refused Nodes: 2")
+ + String.format("%n %s: Bad_NodeIdUnknown",
MISSING_NODE)
+ + String.format("%n %s: Bad_NodeIdUnknown (the
namespace URI is unknown to the server)",
+ LATE_NAMESPACE_NODE)
+ + "\n",
+ console.call(DevConsole.MediaType.TEXT));
+ }
+
+ @Test
+ @Timeout(value = 60, unit = TimeUnit.SECONDS)
+ void nodeCreatedOnTheServerLaterLeavesTheRefusedNodes() throws Exception {
+ startNodes();
+ final DevConsole console = resolveConsole();
+
+ // the server gets the missing node, which the next attempt creates
+ template.sendBody("milo-server:missingItem", "Foo");
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+ final JsonObject connection = singleConnection(console);
+ assertEquals(2, connection.getInteger("monitoredNodes"));
+ final JsonArray refusedNodes =
connection.getCollection("refusedNodes");
+ assertEquals(1, refusedNodes.size());
+ assertRefusedNode(refusedNodes.get(0), LATE_NAMESPACE_NODE,
"Bad_NodeIdUnknown",
+ "the namespace URI is unknown to the server");
+ });
+
+ // the server gets the namespace without the node: the next attempt
asks the server, which refuses it
+ final CamelNamespace namespace = new
CamelNamespace(LATE_NAMESPACE_URI, getServer().getServer());
+ try {
+ namespace.startup();
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+ final JsonArray refusedNodes =
singleConnection(console).getCollection("refusedNodes");
+ assertEquals(1, refusedNodes.size());
+ assertRefusedNode(refusedNodes.get(0), LATE_NAMESPACE_NODE,
"Bad_NodeIdUnknown", null);
+ });
+
+ // and then the node
+ namespace.getOrAddItem("lateNamespaceItem").update("Foo");
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+ final JsonObject connection = singleConnection(console);
+ assertEquals(3, connection.getInteger("monitoredNodes"));
+ assertTrue(connection.<JsonArray>
getCollection("refusedNodes").isEmpty());
+ });
+ } finally {
+ namespace.shutdown();
+ }
+ }
+
+ @Test
+ @Timeout(value = 60, unit = TimeUnit.SECONDS)
+ void failedAttemptIsTheStatusOfItsNodes() throws Exception {
+ // refused by the client, the server does not have the namespace yet
+ timeoutEndpoint.expectedMessageCount(1);
+ context.getRouteController().startRoute("timeout");
+ timeoutEndpoint.assertIsSatisfied();
+ // and a node of a namespace that the server does not get in this test
+ timeoutLateNamespaceEndpoint.expectedMessageCount(1);
+ context.getRouteController().startRoute("timeoutLateNamespace");
+ timeoutLateNamespaceEndpoint.assertIsSatisfied();
+
+ // the server gets the node, but holds the CreateMonitoredItems calls
of the next attempts until they time out
+ final CountDownLatch release = new CountDownLatch(1);
+ final CamelNamespace namespace = holdingNamespace(new
CountDownLatch(1), new CountDownLatch(1), release);
+ try {
+ namespace.startup();
+ namespace.getOrAddItem("heldItem").update("Foo");
+
+ final DevConsole console = resolveConsole();
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() ->
assertRefusedNode(
+ singleConnection(console).<JsonArray>
getCollection("refusedNodes").get(0), HELD_NODE, "Bad_Timeout",
+ null));
+ // the session stays active, and the other node keeps the refusal
of the same attempt, which did not send it
+ // to the server
+ final JsonObject connection = singleConnection(console);
+ assertTrue(connection.getBoolean("sessionActive"));
+ final JsonArray refusedNodes =
connection.getCollection("refusedNodes");
+ assertEquals(2, refusedNodes.size());
+ assertRefusedNode(refusedNodes.get(1), LATE_NAMESPACE_NODE,
"Bad_NodeIdUnknown",
+ "the namespace URI is unknown to the server");
+
+ // the server answers again, the next attempt creates the node
+ release.countDown();
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+ final JsonObject created = singleConnection(console);
+ assertEquals(1, created.getInteger("monitoredNodes"));
+ final JsonArray stillRefused =
created.getCollection("refusedNodes");
+ assertEquals(1, stillRefused.size());
+ assertRefusedNode(stillRefused.get(0), LATE_NAMESPACE_NODE,
"Bad_NodeIdUnknown",
+ "the namespace URI is unknown to the server");
+ });
+ } finally {
+ release.countDown();
+ namespace.shutdown();
+ }
+ }
+
+ @Test
+ @Timeout(value = 60, unit = TimeUnit.SECONDS)
+ void consumersOfAConnectionAreListedTogether() throws Exception {
+ startNodes();
+ template.sendBody("milo-server:otherItem", "Bar");
+ otherEndpoint.expectedMessageCount(1);
+ context.getRouteController().startRoute("other");
+ otherEndpoint.assertIsSatisfied();
+
+ final DevConsole console = resolveConsole();
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+ final JsonObject connection = singleConnection(console);
+ assertEquals(List.of("nodes", "other"),
connection.getCollection("routeIds"));
+ assertEquals(2, connection.getInteger("monitoredNodes"));
+ });
+
+ // a consumer that stops is not listed, and its node is not monitored
anymore
+ context.getRouteController().stopRoute("other");
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+ final JsonObject connection = singleConnection(console);
+ assertEquals(List.of("nodes"),
connection.getCollection("routeIds"));
+ assertEquals(1, connection.getInteger("monitoredNodes"));
+ });
+ }
+
+ @Test
+ @Timeout(value = 60, unit = TimeUnit.SECONDS)
+ void clientIdTellsTheConnectionsOfAServerApart() throws Exception {
+ template.sendBody("milo-server:otherItem", "Bar");
+ otherEndpoint.expectedMessageCount(1);
+ context.getRouteController().startRoute("other");
+ otherEndpoint.assertIsSatisfied();
+ template.sendBody("milo-server:monitoredItem", "Foo");
+ timeoutMonitoredEndpoint.expectedMessageCount(1);
+ context.getRouteController().startRoute("timeoutMonitored");
+ timeoutMonitoredEndpoint.assertIsSatisfied();
+
+ // both connections have the endpoint of the server, the second one
has a client id
+ final DevConsole console = resolveConsole();
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+ final JsonArray connections = connections(console);
+ assertEquals(2, connections.size());
+ final JsonObject other = (JsonObject) connections.get(0);
+ assertEquals("opc.tcp://foo:xxxxxx@localhost:" + getServerPort(),
other.getString("endpointUri"));
+ // omitted when none is set
+ assertFalse(other.containsKey("clientId"));
+ assertEquals(List.of("other"), other.getCollection("routeIds"));
+ assertEquals(1, other.getInteger("monitoredNodes"));
+ final JsonObject timeout = (JsonObject) connections.get(1);
+ assertEquals(other.getString("endpointUri"),
timeout.getString("endpointUri"));
+ assertEquals("timeout", timeout.getString("clientId"));
+ assertEquals(List.of("timeoutMonitored"),
timeout.getCollection("routeIds"));
+ assertEquals(1, timeout.getInteger("monitoredNodes"));
+ });
+
+ assertEquals(String.format("%n Endpoint:
opc.tcp://foo:xxxxxx@localhost:%d", getServerPort())
+ + String.format("%n Routes: other")
+ + String.format("%n Session Active: true")
+ + String.format("%n Monitored Nodes: 1")
+ + String.format("%n Refused Nodes: 0")
+ + "\n"
+ + String.format("%n Endpoint:
opc.tcp://foo:xxxxxx@localhost:%d", getServerPort())
+ + String.format("%n Client Id: timeout")
+ + String.format("%n Routes: timeoutMonitored")
+ + String.format("%n Session Active: true")
+ + String.format("%n Monitored Nodes: 1")
+ + String.format("%n Refused Nodes: 0")
+ + "\n",
+ console.call(DevConsole.MediaType.TEXT));
+ }
+
+ @Test
+ @Timeout(value = 90, unit = TimeUnit.SECONDS)
+ void lostSessionKeepsTheNodesOfItsConnection() throws Exception {
+ startNodes();
+ relay.start(relayPortHolder.getPort(), getServerPort());
+ relayedEndpoint.expectedMessageCount(2);
+ context.getRouteController().startRoute("relayed");
+ relayedEndpoint.assertIsSatisfied();
+
+ // each connection is listed with the routes that use it, in the order
of the routes
+ final DevConsole console = resolveConsole();
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+ final JsonArray connections = connections(console);
+ assertEquals(2, connections.size());
+ assertEquals(List.of("nodes"), ((JsonObject)
connections.get(0)).getCollection("routeIds"));
+ final JsonObject relayed = (JsonObject) connections.get(1);
+ assertEquals("opc.tcp://foo:xxxxxx@localhost:" +
relayPortHolder.getPort(), relayed.getString("endpointUri"));
+ assertEquals(List.of("relayed"),
relayed.getCollection("routeIds"));
+ assertTrue(relayed.getBoolean("sessionActive"));
+ assertEquals(1, relayed.getInteger("monitoredNodes"));
+ final JsonArray refusedNodes =
relayed.getCollection("refusedNodes");
+ assertEquals(1, refusedNodes.size());
+ assertRefusedNode(refusedNodes.get(0), MISSING_NODE,
"Bad_NodeIdUnknown", null);
+ });
+
+ // the relay goes down: the session of its connection is lost, which
keeps its nodes until it is active again
+ relay.stop();
+ await().atMost(15, TimeUnit.SECONDS).untilAsserted(() -> {
+ final JsonArray connections = connections(console);
+ assertEquals(2, connections.size());
+ assertTrue(((JsonObject)
connections.get(0)).getBoolean("sessionActive"));
+ final JsonObject relayed = (JsonObject) connections.get(1);
+ assertFalse(relayed.getBoolean("sessionActive"));
+ assertEquals(1, relayed.getInteger("monitoredNodes"));
+ final JsonArray refusedNodes =
relayed.getCollection("refusedNodes");
+ assertEquals(1, refusedNodes.size());
+ // its status is the one of the latest attempt, which the drop may
have failed
+ assertEquals(MISSING_NODE, ((JsonObject)
refusedNodes.get(0)).getString("nodeId"));
+ });
+
+ relay.start(relayPortHolder.getPort(), getServerPort());
+ await().atMost(60, TimeUnit.SECONDS).untilAsserted(() -> {
+ final JsonArray connections = connections(console);
+ assertEquals(2, connections.size());
+ assertTrue(((JsonObject)
connections.get(1)).getBoolean("sessionActive"));
+ });
+ }
+
+ @Test
+ @Timeout(value = 60, unit = TimeUnit.SECONDS)
+ void connectionWithoutSessionMonitorsNoNode() throws Exception {
+ context.getRouteController().startRoute("unreachable");
+
+ final JsonObject connection = singleConnection(resolveConsole());
+ assertEquals("opc.tcp://localhost:" + unusedPortHolder.getPort(),
connection.getString("endpointUri"));
+ assertEquals(List.of("unreachable"),
connection.getCollection("routeIds"));
+ assertFalse(connection.getBoolean("sessionActive"));
+ assertEquals(0, connection.getInteger("monitoredNodes"));
+ assertTrue(connection.<JsonArray>
getCollection("refusedNodes").isEmpty());
+ }
+
+ @Test
+ @Timeout(value = 60, unit = TimeUnit.SECONDS)
+ void connectionBeingClosedMonitorsNoNode() throws Exception {
+ template.sendBody("milo-server:monitoredItem", "Foo");
+ timeoutMonitoredEndpoint.expectedMessageCount(1);
+ context.getRouteController().startRoute("timeoutMonitored");
+ timeoutMonitoredEndpoint.assertIsSatisfied();
+
+ // the server holds the CreateMonitoredItems call of the next consumer
until it times out, which closes the
+ // connection, and then the CloseSession call, which deletes the
monitored items
+ final CountDownLatch deleting = new CountDownLatch(1);
+ final CountDownLatch release = new CountDownLatch(1);
+ final CamelNamespace namespace = holdingNamespace(new
CountDownLatch(1), deleting, release);
+ CompletableFuture<Void> start = null;
+ try {
+ namespace.startup();
+ namespace.getOrAddItem("heldItem").update("Foo");
+ start = startRouteAsync("timeout");
+ assertTrue(deleting.await(10, TimeUnit.SECONDS));
+
+ // its nodes have been reported lost while the client waits for
the server
+ final DevConsole console = resolveConsole();
+ final JsonObject connection = singleConnection(console);
+ assertFalse(connection.getBoolean("sessionActive"));
+ assertEquals(0, connection.getInteger("monitoredNodes"));
+ assertTrue(connection.<JsonArray>
getCollection("refusedNodes").isEmpty());
+
+ // the server answers again, the connection is built up again with
both nodes
+ release.countDown();
+ start.get(30, TimeUnit.SECONDS);
+ await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> {
+ final JsonObject rebuilt = singleConnection(console);
+ assertEquals(List.of("timeout", "timeoutMonitored"),
rebuilt.getCollection("routeIds"));
+ assertTrue(rebuilt.getBoolean("sessionActive"));
+ assertEquals(2, rebuilt.getInteger("monitoredNodes"));
+ });
+ } finally {
+ release.countDown();
+ if (start != null) {
+ // the start ends once the client stops waiting for the
server, its failure fails the test above
+ start.handle((result, e) -> null).get(30, TimeUnit.SECONDS);
+ }
+ namespace.shutdown();
+ }
+ }
+
+ @Test
+ @Timeout(value = 60, unit = TimeUnit.SECONDS)
+ void consoleDoesNotWaitForAServiceCallInProgress() throws Exception {
+ startNodes();
+
+ // the server holds the CreateMonitoredItems call of the next
consumer, which holds the lock of the connection
+ final CountDownLatch creating = new CountDownLatch(1);
+ final CountDownLatch release = new CountDownLatch(1);
+ final CamelNamespace namespace = holdingNamespace(creating, new
CountDownLatch(1), release);
+ CompletableFuture<Void> start = null;
+ try {
+ namespace.startup();
+ namespace.getOrAddItem("heldItem").update("Foo");
+ start = startRouteAsync("held");
+ assertTrue(creating.await(10, TimeUnit.SECONDS));
+
+ final DevConsole console = resolveConsole();
+ final JsonObject connection =
assertTimeoutPreemptively(Duration.ofSeconds(5), () ->
singleConnection(console));
+ // the call was still in progress
+ assertFalse(start.isDone());
+ assertEquals(List.of("nodes"),
connection.getCollection("routeIds"));
+ assertEquals(1, connection.getInteger("monitoredNodes"));
+ assertEquals(2, connection.<JsonArray>
getCollection("refusedNodes").size());
+
+ release.countDown();
+ start.get(30, TimeUnit.SECONDS);
+ } finally {
+ release.countDown();
+ if (start != null) {
+ // the start ends once the server answers, its failure fails
the test above
+ start.handle((result, e) -> null).get(30, TimeUnit.SECONDS);
+ }
+ namespace.shutdown();
+ }
+ }
+
+ /**
+ * Starts the consumer of a node that the server has, one that it does not
have and one of a namespace that it does
+ * not have, and waits for the value of the first and the refusals of the
others
+ */
+ private void startNodes() throws Exception {
+ template.sendBody("milo-server:monitoredItem", "Foo");
+ nodesEndpoint.expectedMessageCount(3);
+ context.getRouteController().startRoute("nodes");
+ nodesEndpoint.assertIsSatisfied();
+ }
+
+ /**
+ * Starts a route off the test thread, its consumer waits for the server
while it starts
+ */
+ private CompletableFuture<Void> startRouteAsync(final String routeId) {
+ return CompletableFuture.runAsync(() -> {
+ try {
+ context.getRouteController().startRoute(routeId);
+ } catch (final Exception e) {
+ throw new CompletionException(e);
+ }
+ });
+ }
+
+ /**
+ * A namespace of the server that holds the creation and the deletion of
its monitored items until it is released.
+ * The server answers a CreateMonitoredItems call once the items are
created, and a CloseSession call once the items
+ * of the session are deleted.
+ */
+ private CamelNamespace holdingNamespace(
+ final CountDownLatch creating, final CountDownLatch deleting,
final CountDownLatch release) {
+ return new CamelNamespace(HELD_NAMESPACE_URI, getServer().getServer())
{
+ @Override
+ public void onDataItemsCreated(final List<DataItem> dataItems) {
+ hold(creating, release);
+ super.onDataItemsCreated(dataItems);
+ }
+
+ @Override
+ public void onDataItemsDeleted(final List<DataItem> dataItems) {
+ hold(deleting, release);
+ super.onDataItemsDeleted(dataItems);
+ }
+ };
+ }
+
+ private static void hold(final CountDownLatch holding, final
CountDownLatch release) {
+ holding.countDown();
+ try {
+ release.await(30, TimeUnit.SECONDS);
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private DevConsole resolveConsole() {
+ final DevConsole console =
PluginHelper.getDevConsoleResolver(context).resolveDevConsole("milo-client");
+ assertNotNull(console);
+ return console;
+ }
+
+ private static JsonArray connections(final DevConsole console) {
+ final JsonObject out = (JsonObject)
console.call(DevConsole.MediaType.JSON);
+ return out.getCollection("connections");
+ }
+
+ private static JsonObject singleConnection(final DevConsole console) {
+ final JsonArray connections = connections(console);
+ assertEquals(1, connections.size());
+ return (JsonObject) connections.get(0);
+ }
+
+ private static void assertRefusedNode(
+ final Object refusedNode, final String nodeId, final String
status, final String reason) {
+ final JsonObject node = (JsonObject) refusedNode;
+ assertEquals(nodeId, node.getString("nodeId"));
+ assertEquals(status, node.getString("status"));
+ if (reason == null) {
+ // omitted when the server was asked
+ assertFalse(node.containsKey("reason"));
+ } else {
+ assertEquals(reason, node.getString("reason"));
+ }
+ }
+
+ 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
new file mode 100644
index 000000000000..b564fc117aef
--- /dev/null
+++
b/components/camel-milo/src/test/java/org/apache/camel/component/milo/TcpRelay.java
@@ -0,0 +1,111 @@
+/*
+ * 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.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 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.
+ */
+final class TcpRelay {
+
+ private volatile ServerSocket listener;
+
+ private final List<Socket> sockets = new ArrayList<>();
+
+ void start(final int port, final int serverPort) throws IOException {
+ final ServerSocket listener = new ServerSocket(port);
+ this.listener = listener;
+ final Thread acceptor = new Thread(() -> {
+ while (!listener.isClosed()) {
+ Socket client = null;
+ try {
+ client = listener.accept();
+ final Socket server = new Socket("localhost", serverPort);
+ synchronized (sockets) {
+ sockets.add(client);
+ sockets.add(server);
+ }
+ pump(client, server);
+ pump(server, client);
+ } catch (final IOException e) {
+ // the listener was closed, or the server refused: nothing
to relay
+ if (client != null) {
+ close(client);
+ }
+ }
+ }
+ }, "relay-" + port);
+ acceptor.setDaemon(true);
+ acceptor.start();
+ }
+
+ private static 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();
+ }
+ } catch (final IOException e) {
+ // the connection was dropped
+ } finally {
+ close(from);
+ close(to);
+ }
+ }, "relay-pump");
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ void dropConnections() {
+ synchronized (sockets) {
+ sockets.forEach(TcpRelay::close);
+ sockets.clear();
+ }
+ }
+
+ void stop() {
+ final ServerSocket listener = this.listener;
+ this.listener = null;
+ if (listener != null) {
+ try {
+ listener.close();
+ } catch (final IOException e) {
+ // nothing left to close
+ }
+ }
+ dropConnections();
+ }
+
+ private static void close(final Socket socket) {
+ try {
+ socket.close();
+ } catch (final IOException e) {
+ // already closed
+ }
+ }
+}