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 adf38d969ea8 CAMEL-25846: camel-tahu - an Edge Node producer connects 
again after its route is restarted (#28319)
adf38d969ea8 is described below

commit adf38d969ea830efe333b9971634f67b84b6023c
Author: allthingssecurity <[email protected]>
AuthorDate: Fri Oct 23 22:03:44 2026 +0530

    CAMEL-25846: camel-tahu - an Edge Node producer connects again after its 
route is restarted (#28319)
    
    * CAMEL-25846: camel-tahu - an Edge Node producer connects again after its 
route is restarted
    
    TahuEdgeProducer keeps the TahuEdgeClient of each Edge Node in a static
    map. When the Edge Node producer stops, it shuts its client down, but the
    client stays in the map. A Tahu EdgeClient cannot run again after a
    shutdown (EdgeClient.run() returns at once once stayRunning is false, and
    only the constructor sets it). The producer created when the route starts
    again took the shut down client from the map and "started" it: it never
    connected, and every message was dropped with an ERROR log
    ("Failed to publish message") while the exchange completed. Device
    producers kept the old client as well.
    
    The Edge Node producer now removes its client from the map when it stops
    (and shuts down the client's executor), and creates a new client when it
    starts. A new client gets the metric maps of the Edge Node and of its
    Devices again, and Device producers take the current client of their
    Edge Node when they publish. A producer keeps its metric map for that
    from its start to its stop; producers to the same endpoint share its
    map, which stays while one of them runs.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25846: camel-tahu - a stopped Edge Node producer shuts down the 
client of the future it removed
    
    Review of #28319: TahuEdgeNodeProducer.doStop shut down the producer's own 
client reference,
    which is stale when another producer of the same Edge Node was restarted 
since this producer
    took its client. The future it removed belongs to the new client, so the 
new client kept
    running, stayed in descriptorClients and was never shut down. doStop now 
takes the client with
    currentClient() (the client of the Edge Node in the map).
    
    Test: 
clientIsShutDownWhenBothProducersOfTheEdgeNodeStopAfterOneWasRestarted (two 
routes to
    one Edge Node endpoint, one restarted, then both stopped): the second 
client publishes its
    NDEATH and is removed from the map. Without the change it times out waiting 
for the second
    NDEATH.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25846: camel-tahu - a Device producer never creates the client of 
its Edge Node
    
    Review of #28319: with the client removed from descriptorClients when the 
Edge Node producer
    stops, a Device producer created while the Edge Node producer is stopped 
reached
    computeIfAbsent with the Device endpoint, whose getDeviceIdList() throws a 
NullPointerException
    when the Device URI has no deviceIds (an Edge Node option), so the Device 
route failed to start;
    with deviceIds set, the Edge Node took a client built from the Device 
endpoint's options.
    
    Only an Edge Node producer creates the client now. A Device producer takes 
the client of its
    Edge Node from the map when it publishes (and adds its metric map when it 
starts, as before);
    when no Edge Node producer is started the exchange fails with an 
IllegalStateException that
    names the Device and the Edge Node, instead of publishing on a client that 
was shut down.
    
    Test: 
deviceProducerCreatedWhileTheEdgeNodeIsStoppedUsesTheClientOfTheEdgeNode 
(Device route
    without deviceIds added while the Edge Node route is stopped: a send fails 
with the
    IllegalStateException, and after the Edge Node route starts the Device is 
born and its data
    published). Without the change the Device route fails to start with the 
NullPointerException.
    
    Docs: a sentence in tahu-edge-component.adoc (and its catalog copy) and an 
upgrade guide entry.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25846: camel-tahu - an Edge Node producer creates its client only 
when it starts
    
    Review of #28319: the constructor of an Edge Node producer still called 
createClient,
    which clientForStart() in doStart does again (it takes the client from the 
map, or creates
    it when there is none), so the call in the constructor is redundant. 
Removing it also means
    that an Edge Node producer that is created and never started no longer 
leaves a client and
    its executor in the static map.
    
    The camel-tahu unit tests pass unchanged (12, among them the 5 of
    TahuEdgeProducerRestartTest).
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../camel/catalog/docs/tahu-edge-component.adoc    |   4 +
 .../src/main/docs/tahu-edge-component.adoc         |   4 +
 .../camel/component/tahu/TahuEdgeProducer.java     | 145 ++++++++++--
 .../component/tahu/handlers/TahuEdgeClient.java    |   7 +
 .../camel/component/tahu/FakeMqttBroker.java       | 168 ++++++++++++++
 .../tahu/TahuEdgeProducerRestartTest.java          | 249 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   8 +
 7 files changed, 569 insertions(+), 16 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/tahu-edge-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/tahu-edge-component.adoc
index 08dd2a6a34f1..e4af59a909e0 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/tahu-edge-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/tahu-edge-component.adoc
@@ -89,6 +89,10 @@ Device Endpoints inherit all MQTT Server connection 
information from their assoc
 Edge Node Endpoint. Setting Component- or Endpoint-level configuration values 
on Device
 Components or Endpoints is unnecessary and should be avoided.
 
+A Device Producer publishes through the client of its Edge Node Producer and 
never
+creates one, so sending to a Device Endpoint while no producer of its Edge Node
+Endpoint is started fails the exchange with an `IllegalStateException`.
+
 === Edge Node and Device Endpoint Interaction
 
 Sparkplug Edge Nodes are not required to have a Device hierarchy and physical
diff --git a/components/camel-tahu/src/main/docs/tahu-edge-component.adoc 
b/components/camel-tahu/src/main/docs/tahu-edge-component.adoc
index 08dd2a6a34f1..e4af59a909e0 100644
--- a/components/camel-tahu/src/main/docs/tahu-edge-component.adoc
+++ b/components/camel-tahu/src/main/docs/tahu-edge-component.adoc
@@ -89,6 +89,10 @@ Device Endpoints inherit all MQTT Server connection 
information from their assoc
 Edge Node Endpoint. Setting Component- or Endpoint-level configuration values 
on Device
 Components or Endpoints is unnecessary and should be avoided.
 
+A Device Producer publishes through the client of its Edge Node Producer and 
never
+creates one, so sending to a Device Endpoint while no producer of its Edge Node
+Endpoint is started fails the exchange with an `IllegalStateException`.
+
 === Edge Node and Device Endpoint Interaction
 
 Sparkplug Edge Nodes are not required to have a Device hierarchy and physical
diff --git 
a/components/camel-tahu/src/main/java/org/apache/camel/component/tahu/TahuEdgeProducer.java
 
b/components/camel-tahu/src/main/java/org/apache/camel/component/tahu/TahuEdgeProducer.java
index 4412833f2f77..e5554ad60d1a 100644
--- 
a/components/camel-tahu/src/main/java/org/apache/camel/component/tahu/TahuEdgeProducer.java
+++ 
b/components/camel-tahu/src/main/java/org/apache/camel/component/tahu/TahuEdgeProducer.java
@@ -35,18 +35,31 @@ import org.eclipse.tahu.message.BdSeqManager;
 import org.eclipse.tahu.message.model.DeviceDescriptor;
 import org.eclipse.tahu.message.model.EdgeNodeDescriptor;
 import org.eclipse.tahu.message.model.SparkplugBPayload;
+import org.eclipse.tahu.message.model.SparkplugBPayloadMap;
 import org.eclipse.tahu.model.MqttServerDefinition;
 
 public abstract class TahuEdgeProducer extends DefaultProducer {
 
     protected static final ConcurrentMap<EdgeNodeDescriptor, TahuEdgeClient> 
descriptorClients = new ConcurrentHashMap<>();
     protected static final ConcurrentMap<EdgeNodeDescriptor, Future<?>> 
descriptorFutures = new ConcurrentHashMap<>();
+    // the metric maps of the started Edge Node and Device producers, added 
again to a client that replaces a stopped
+    // one (a producer adds its map when it starts and removes it when it 
stops)
+    private static final ConcurrentMap<EdgeNodeDescriptor, 
SparkplugBPayloadMap> descriptorMetricMaps
+            = new ConcurrentHashMap<>();
+    // the started producers that use the metric map of a descriptor (several 
producers to one endpoint share its map),
+    // changed together with descriptorMetricMaps while holding its lock
+    private static final ConcurrentMap<EdgeNodeDescriptor, Integer> 
descriptorMetricMapUsers = new ConcurrentHashMap<>();
 
     private final CamelContext camelContext;
 
-    protected final TahuEdgeClient tahuEdgeClient;
+    // the client of the Edge Node: a stopped Edge Node producer shuts its 
client down, and the next start creates a
+    // new one, as a Tahu EdgeClient cannot be started again
+    protected volatile TahuEdgeClient tahuEdgeClient;
     protected ExecutorService clientExecutorService;
     protected final EdgeNodeDescriptor edgeNodeDescriptor;
+    // the Edge Node of the producer, whose client it uses
+    protected final EdgeNodeDescriptor clientDescriptor;
+    private final TahuEdgeEndpoint endpoint;
 
     private TahuEdgeProducer(TahuEdgeEndpoint endpoint, EdgeNodeDescriptor 
edgeNodeDescriptor) {
         super(endpoint);
@@ -54,8 +67,41 @@ public abstract class TahuEdgeProducer extends 
DefaultProducer {
         camelContext = endpoint.getCamelContext();
 
         this.edgeNodeDescriptor = edgeNodeDescriptor;
+        this.endpoint = endpoint;
 
-        tahuEdgeClient = createClient(endpoint, edgeNodeDescriptor);
+        // A DeviceDescriptor looks up the client of its EdgeNodeDescriptor, 
i.e. minus its deviceId.
+        clientDescriptor = edgeNodeDescriptor.isDeviceDescriptor()
+                ? ((DeviceDescriptor) 
edgeNodeDescriptor).getEdgeNodeDescriptor() : edgeNodeDescriptor;
+
+        // Only an Edge Node producer creates the client of its Edge Node, 
when it starts (clientForStart): the options
+        // that describe the Edge Node (deviceIds, primaryHostId, servers) are 
on the Edge Node endpoint, not on a Device
+        // endpoint. A Device producer takes the client of its Edge Node when 
it publishes, and adds its metric map to it
+        // when it starts.
+    }
+
+    /**
+     * The client of the Edge Node. When the Edge Node producer was stopped 
and started since this producer took its
+     * client, that client is shut down and replaced by a new one (which has 
the metric maps of this producer): take the
+     * new one.
+     */
+    protected TahuEdgeClient currentClient() {
+        TahuEdgeClient client = tahuEdgeClient;
+        TahuEdgeClient current = descriptorClients.get(clientDescriptor);
+        if (current != null && current != client) {
+            client = current;
+            tahuEdgeClient = client;
+        }
+        return client;
+    }
+
+    /**
+     * The client to start: the client of the Edge Node in the map, or a new 
one when there is none (a stop shut it down
+     * and removed it, or no Edge Node producer started yet).
+     */
+    protected TahuEdgeClient clientForStart() {
+        TahuEdgeClient client = createClient(endpoint, edgeNodeDescriptor);
+        tahuEdgeClient = client;
+        return client;
     }
 
     @Override
@@ -83,7 +129,7 @@ public abstract class TahuEdgeProducer extends 
DefaultProducer {
                 .ifPresent(seq -> 
message.setHeader(TahuConstants.MESSAGE_SEQUENCE_NUMBER, seq));
 
         try {
-            tahuEdgeClient.publishData(edgeNodeDescriptor, payload);
+            currentClient().publishData(edgeNodeDescriptor, payload);
         } catch (Throwable t) {
             exchange.setException(t);
         }
@@ -95,15 +141,7 @@ public abstract class TahuEdgeProducer extends 
DefaultProducer {
 
     private TahuEdgeClient createClient(TahuEdgeEndpoint endpoint, 
EdgeNodeDescriptor edgeNodeDescriptor) {
 
-        // Only create clients for Edge Nodes, not Devices. If passed a
-        // DeviceDescriptor, create/lookup based on its EdgeNodeDescriptor, 
i.e. minus
-        // its deviceId.
-        EdgeNodeDescriptor clientCreationDescriptor = edgeNodeDescriptor;
-        if (edgeNodeDescriptor.isDeviceDescriptor()) {
-            clientCreationDescriptor = ((DeviceDescriptor) 
edgeNodeDescriptor).getEdgeNodeDescriptor();
-        }
-
-        TahuEdgeClient tahuEdgeClient = 
descriptorClients.computeIfAbsent(clientCreationDescriptor, end -> {
+        TahuEdgeClient tahuEdgeClient = 
descriptorClients.computeIfAbsent(clientDescriptor, end -> {
 
             TahuConfiguration configuration = endpoint.getConfiguration();
 
@@ -131,6 +169,15 @@ public abstract class TahuEdgeProducer extends 
DefaultProducer {
                     .clientExecutorService(clientExecutorService)
                     .build();
 
+            // a client that replaces a stopped one gets the metric maps of 
the Edge Node and of its Devices again
+            descriptorMetricMaps.forEach((descriptor, metricMap) -> {
+                EdgeNodeDescriptor nodeOf = descriptor.isDeviceDescriptor()
+                        ? ((DeviceDescriptor) 
descriptor).getEdgeNodeDescriptor() : descriptor;
+                if (end.equals(nodeOf)) {
+                    client.addDeviceMetricDataPayloadMap(descriptor, 
metricMap);
+                }
+            });
+
             return client;
         });
 
@@ -142,6 +189,45 @@ public abstract class TahuEdgeProducer extends 
DefaultProducer {
         return tahuEdgeClient;
     }
 
+    @Override
+    protected void doStart() throws Exception {
+        super.doStart();
+
+        SparkplugBPayloadMap metricMap = 
endpoint.getMetricDataTypePayloadMap();
+        if (metricMap != null) {
+            synchronized (descriptorMetricMaps) {
+                if (descriptorMetricMaps.get(edgeNodeDescriptor) == metricMap) 
{
+                    descriptorMetricMapUsers.merge(edgeNodeDescriptor, 1, 
Integer::sum);
+                } else {
+                    descriptorMetricMaps.put(edgeNodeDescriptor, metricMap);
+                    descriptorMetricMapUsers.put(edgeNodeDescriptor, 1);
+                }
+            }
+            // the client of the Edge Node may have been created again while 
this producer was stopped
+            TahuEdgeClient client = descriptorClients.get(clientDescriptor);
+            if (client != null) {
+                client.addDeviceMetricDataPayloadMap(edgeNodeDescriptor, 
metricMap);
+            }
+        }
+    }
+
+    @Override
+    protected void doStop() throws Exception {
+        super.doStop();
+
+        SparkplugBPayloadMap metricMap = 
endpoint.getMetricDataTypePayloadMap();
+        if (metricMap != null) {
+            synchronized (descriptorMetricMaps) {
+                // only when no other started producer (of the same endpoint) 
uses the map
+                if (descriptorMetricMaps.get(edgeNodeDescriptor) == metricMap
+                        && descriptorMetricMapUsers.merge(edgeNodeDescriptor, 
-1, Integer::sum) <= 0) {
+                    descriptorMetricMaps.remove(edgeNodeDescriptor, metricMap);
+                    descriptorMetricMapUsers.remove(edgeNodeDescriptor);
+                }
+            }
+        }
+    }
+
     static final class TahuEdgeNodeProducer extends TahuEdgeProducer {
         private TahuEdgeNodeProducer(TahuEdgeEndpoint endpoint, 
EdgeNodeDescriptor edgeNodeDescriptor) {
             super(endpoint, edgeNodeDescriptor);
@@ -151,21 +237,22 @@ public abstract class TahuEdgeProducer extends 
DefaultProducer {
         protected void doStart() throws Exception {
             super.doStart();
 
-            descriptorFutures.computeIfAbsent(edgeNodeDescriptor, end -> 
tahuEdgeClient.startup());
+            TahuEdgeClient client = clientForStart();
+            descriptorFutures.computeIfAbsent(edgeNodeDescriptor, end -> 
client.startup());
         }
 
         @Override
         protected void doSuspend() throws Exception {
             super.doSuspend();
 
-            tahuEdgeClient.suspend();
+            currentClient().suspend();
         }
 
         @Override
         protected void doResume() throws Exception {
             super.doResume();
 
-            tahuEdgeClient.resume();
+            currentClient().resume();
         }
 
         @Override
@@ -174,7 +261,14 @@ public abstract class TahuEdgeProducer extends 
DefaultProducer {
 
             Future<?> clientFuture = 
descriptorFutures.remove(edgeNodeDescriptor);
             if (clientFuture != null) {
-                tahuEdgeClient.shutdown();
+                // the client of the future just removed: another producer of 
this Edge Node may have been restarted
+                // since this producer took its client, so its own reference 
can be a client that is already shut down
+                TahuEdgeClient client = currentClient();
+                client.shutdown();
+                // a shut down client cannot connect again: the next start of 
an Edge Node producer creates a new
+                // client, else it would take this one from the map and never 
connect
+                descriptorClients.remove(edgeNodeDescriptor, client);
+                
getEndpoint().getCamelContext().getExecutorServiceManager().shutdown(client.getClientExecutorService());
             }
         }
     }
@@ -183,6 +277,25 @@ public abstract class TahuEdgeProducer extends 
DefaultProducer {
         private TahuEdgeDeviceProducer(TahuEdgeEndpoint endpoint, 
DeviceDescriptor deviceDescriptor) {
             super(endpoint, deviceDescriptor);
         }
+
+        /**
+         * The client that the started Edge Node producer of this Device 
created. A Device producer never creates one,
+         * and it does not publish on a client that a stopped Edge Node 
producer shut down.
+         *
+         * @throws IllegalStateException when no Edge Node producer of the 
Edge Node of this Device is started
+         */
+        @Override
+        protected TahuEdgeClient currentClient() {
+            TahuEdgeClient client = descriptorClients.get(clientDescriptor);
+            if (client == null) {
+                throw new IllegalStateException(
+                        "Cannot publish the data of Device " + 
edgeNodeDescriptor.getDescriptorString()
+                                                + ": no producer of its Edge 
Node " + clientDescriptor.getDescriptorString()
+                                                + " is started");
+            }
+            tahuEdgeClient = client;
+            return client;
+        }
     }
 
     static final class Builder {
diff --git 
a/components/camel-tahu/src/main/java/org/apache/camel/component/tahu/handlers/TahuEdgeClient.java
 
b/components/camel-tahu/src/main/java/org/apache/camel/component/tahu/handlers/TahuEdgeClient.java
index 377526845eea..368ded4e919b 100644
--- 
a/components/camel-tahu/src/main/java/org/apache/camel/component/tahu/handlers/TahuEdgeClient.java
+++ 
b/components/camel-tahu/src/main/java/org/apache/camel/component/tahu/handlers/TahuEdgeClient.java
@@ -116,6 +116,13 @@ public final class TahuEdgeClient extends EdgeClient {
         }
     }
 
+    /**
+     * The executor that runs this client.
+     */
+    public ExecutorService getClientExecutorService() {
+        return clientExecutorService;
+    }
+
     public SparkplugBPayloadMap addDeviceMetricDataPayloadMap(
             SparkplugDescriptor metricDescriptor, SparkplugBPayloadMap 
metricDataTypePayloadMap) {
         return 
tahuEdgeNodeMetricHandler.addDeviceMetricDataPayloadMap(metricDescriptor, 
metricDataTypePayloadMap);
diff --git 
a/components/camel-tahu/src/test/java/org/apache/camel/component/tahu/FakeMqttBroker.java
 
b/components/camel-tahu/src/test/java/org/apache/camel/component/tahu/FakeMqttBroker.java
new file mode 100644
index 000000000000..f95999082801
--- /dev/null
+++ 
b/components/camel-tahu/src/test/java/org/apache/camel/component/tahu/FakeMqttBroker.java
@@ -0,0 +1,168 @@
+/*
+ * 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.tahu;
+
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * A minimal in-process MQTT 3.1.1 broker for tests: it accepts every 
connection and subscription, acknowledges every
+ * publish and records the topics published to it. It does not route messages 
to subscribers.
+ */
+final class FakeMqttBroker implements AutoCloseable {
+
+    private final ServerSocket serverSocket;
+    private final List<Socket> sockets = new CopyOnWriteArrayList<>();
+    private final List<String> publishedTopics = new CopyOnWriteArrayList<>();
+    private final AtomicInteger connects = new AtomicInteger();
+    private volatile boolean running = true;
+
+    FakeMqttBroker() throws IOException {
+        serverSocket = new ServerSocket(0, 50, 
InetAddress.getLoopbackAddress());
+        Thread acceptor = new Thread(this::accept, "fake-mqtt-acceptor");
+        acceptor.setDaemon(true);
+        acceptor.start();
+    }
+
+    int getPort() {
+        return serverSocket.getLocalPort();
+    }
+
+    List<String> getPublishedTopics() {
+        return publishedTopics;
+    }
+
+    int getConnects() {
+        return connects.get();
+    }
+
+    private void accept() {
+        while (running) {
+            try {
+                Socket socket = serverSocket.accept();
+                sockets.add(socket);
+                Thread handler = new Thread(() -> handle(socket), 
"fake-mqtt-connection");
+                handler.setDaemon(true);
+                handler.start();
+            } catch (IOException e) {
+                return;
+            }
+        }
+    }
+
+    private void handle(Socket socket) {
+        try (socket) {
+            DataInputStream in = new DataInputStream(socket.getInputStream());
+            OutputStream out = socket.getOutputStream();
+            while (running) {
+                int header = in.read();
+                if (header < 0) {
+                    return;
+                }
+                byte[] body = new byte[readRemainingLength(in)];
+                in.readFully(body);
+                int type = header >> 4;
+                switch (type) {
+                    case 1 -> {
+                        connects.incrementAndGet();
+                        write(out, 0x20, 0, 0);
+                    }
+                    case 3 -> {
+                        int qos = (header >> 1) & 3;
+                        int topicLength = ((body[0] & 0xff) << 8) | (body[1] & 
0xff);
+                        publishedTopics.add(new String(body, 2, topicLength, 
StandardCharsets.UTF_8));
+                        if (qos == 1) {
+                            write(out, 0x40, body[2 + topicLength], body[3 + 
topicLength]);
+                        } else if (qos == 2) {
+                            write(out, 0x50, body[2 + topicLength], body[3 + 
topicLength]);
+                        }
+                    }
+                    case 6 -> write(out, 0x70, body[0], body[1]);
+                    case 8 -> {
+                        // SUBACK: the packet id and the requested QoS of 
every topic filter
+                        ByteArrayOutputStream granted = new 
ByteArrayOutputStream();
+                        int i = 2;
+                        while (i < body.length) {
+                            int length = ((body[i] & 0xff) << 8) | (body[i + 
1] & 0xff);
+                            i += 2 + length;
+                            granted.write(body[i++]);
+                        }
+                        byte[] qos = granted.toByteArray();
+                        byte[] suback = new byte[2 + qos.length];
+                        suback[0] = body[0];
+                        suback[1] = body[1];
+                        System.arraycopy(qos, 0, suback, 2, qos.length);
+                        writePacket(out, 0x90, suback);
+                    }
+                    case 10 -> write(out, 0xB0, body[0], body[1]);
+                    case 12 -> writePacket(out, 0xD0, new byte[0]);
+                    case 14 -> {
+                        return;
+                    }
+                    default -> {
+                        // nothing to answer
+                    }
+                }
+            }
+        } catch (IOException e) {
+            // the client went away
+        } finally {
+            sockets.remove(socket);
+        }
+    }
+
+    private static int readRemainingLength(DataInputStream in) throws 
IOException {
+        int value = 0;
+        int multiplier = 1;
+        int digit;
+        do {
+            digit = in.readUnsignedByte();
+            value += (digit & 127) * multiplier;
+            multiplier *= 128;
+        } while ((digit & 128) != 0);
+        return value;
+    }
+
+    private static void write(OutputStream out, int header, int b1, int b2) 
throws IOException {
+        writePacket(out, header, new byte[] { (byte) b1, (byte) b2 });
+    }
+
+    private static synchronized void writePacket(OutputStream out, int header, 
byte[] body) throws IOException {
+        out.write(header);
+        out.write(body.length);
+        out.write(body);
+        out.flush();
+    }
+
+    @Override
+    public void close() throws IOException {
+        running = false;
+        serverSocket.close();
+        for (Socket socket : sockets) {
+            socket.close();
+        }
+    }
+}
diff --git 
a/components/camel-tahu/src/test/java/org/apache/camel/component/tahu/TahuEdgeProducerRestartTest.java
 
b/components/camel-tahu/src/test/java/org/apache/camel/component/tahu/TahuEdgeProducerRestartTest.java
new file mode 100644
index 000000000000..251a67a7f184
--- /dev/null
+++ 
b/components/camel-tahu/src/test/java/org/apache/camel/component/tahu/TahuEdgeProducerRestartTest.java
@@ -0,0 +1,249 @@
+/*
+ * 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.tahu;
+
+import java.util.Date;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BooleanSupplier;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.ProducerTemplate;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.eclipse.tahu.message.model.EdgeNodeDescriptor;
+import org.eclipse.tahu.message.model.Metric.MetricBuilder;
+import org.eclipse.tahu.message.model.MetricDataType;
+import org.eclipse.tahu.message.model.SparkplugBPayload;
+import org.eclipse.tahu.message.model.SparkplugBPayloadMap;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * An Edge Node producer must connect and publish again after its route was 
stopped and started.
+ */
+class TahuEdgeProducerRestartTest {
+
+    @Test
+    void edgeNodePublishesAgainAfterTheRouteIsRestarted() throws Exception {
+        try (FakeMqttBroker broker = new FakeMqttBroker(); CamelContext 
context = new DefaultCamelContext()) {
+            String uri = 
"tahu-edge:RestartGroup/RestartNode?deviceIds=D1&servers=Mqtt:tcp://localhost:" 
+ broker.getPort();
+            TahuEdgeEndpoint endpoint = context.getEndpoint(uri, 
TahuEdgeEndpoint.class);
+            endpoint.setMetricDataTypePayloadMap(new 
SparkplugBPayloadMap.SparkplugBPayloadMapBuilder()
+                    .addMetric(new MetricBuilder("level", 
MetricDataType.Int32, 0).createMetric()).createPayload());
+            context.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    from("direct:node").routeId("node").to(endpoint);
+                }
+            });
+            context.start();
+
+            String ndata = "spBv1.0/RestartGroup/NDATA/RestartNode";
+            waitFor(broker, () -> 
broker.getPublishedTopics().contains("spBv1.0/RestartGroup/NBIRTH/RestartNode"));
+            ProducerTemplate template = context.createProducerTemplate();
+            template.sendBody("direct:node", payload(1));
+            waitFor(broker, () -> count(broker, ndata) == 1);
+
+            context.getRouteController().stopRoute("node");
+            context.getRouteController().startRoute("node");
+
+            // without the fix the producer of the restarted route took the 
client that the stop had shut down: it never
+            // connected again, and the data was dropped (logged) while the 
exchange completed
+            waitFor(broker, () -> broker.getPublishedTopics().stream()
+                    .filter(t -> 
t.equals("spBv1.0/RestartGroup/NBIRTH/RestartNode")).count() == 2);
+            template.sendBody("direct:node", payload(2));
+            waitFor(broker, () -> count(broker, ndata) == 2);
+            assertEquals(2, broker.getConnects());
+        }
+    }
+
+    @Test
+    void deviceOfAnotherRoutePublishesAgainAfterTheEdgeNodeRouteIsRestarted() 
throws Exception {
+        try (FakeMqttBroker broker = new FakeMqttBroker(); CamelContext 
context = new DefaultCamelContext()) {
+            TahuEdgeEndpoint node = context.getEndpoint(
+                    
"tahu-edge:DeviceGroup/DeviceNode?deviceIds=D1&servers=Mqtt:tcp://localhost:" + 
broker.getPort(),
+                    TahuEdgeEndpoint.class);
+            node.setMetricDataTypePayloadMap(new 
SparkplugBPayloadMap.SparkplugBPayloadMapBuilder()
+                    .addMetric(new MetricBuilder("level", 
MetricDataType.Int32, 0).createMetric()).createPayload());
+            TahuEdgeEndpoint device = 
context.getEndpoint("tahu-edge:DeviceGroup/DeviceNode/D1", 
TahuEdgeEndpoint.class);
+            device.setMetricDataTypePayloadMap(new 
SparkplugBPayloadMap.SparkplugBPayloadMapBuilder()
+                    .addMetric(new MetricBuilder("level", 
MetricDataType.Int32, 0).createMetric()).createPayload());
+            context.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    from("direct:node").routeId("node").to(node);
+                    from("direct:device").routeId("device").to(device);
+                }
+            });
+            context.start();
+
+            String ddata = "spBv1.0/DeviceGroup/DDATA/DeviceNode/D1";
+            waitFor(broker, () -> 
broker.getPublishedTopics().contains("spBv1.0/DeviceGroup/DBIRTH/DeviceNode/D1"));
+            ProducerTemplate template = context.createProducerTemplate();
+            template.sendBody("direct:device", payload(1));
+            waitFor(broker, () -> count(broker, ddata) == 1);
+
+            // only the route of the Edge Node is restarted: the producer of 
the device keeps running
+            context.getRouteController().stopRoute("node");
+            context.getRouteController().startRoute("node");
+
+            waitFor(broker, () -> count(broker, 
"spBv1.0/DeviceGroup/DBIRTH/DeviceNode/D1") == 2);
+            template.sendBody("direct:device", payload(2));
+            waitFor(broker, () -> count(broker, ddata) == 2);
+        }
+    }
+
+    @Test
+    void 
deviceStillBornAfterTheEdgeNodeRestartWhenOneOfTwoProducersOfTheDeviceStopped() 
throws Exception {
+        try (FakeMqttBroker broker = new FakeMqttBroker(); CamelContext 
context = new DefaultCamelContext()) {
+            TahuEdgeEndpoint node = context.getEndpoint(
+                    
"tahu-edge:SharedGroup/SharedNode?deviceIds=D1&servers=Mqtt:tcp://localhost:" + 
broker.getPort(),
+                    TahuEdgeEndpoint.class);
+            node.setMetricDataTypePayloadMap(new 
SparkplugBPayloadMap.SparkplugBPayloadMapBuilder()
+                    .addMetric(new MetricBuilder("level", 
MetricDataType.Int32, 0).createMetric()).createPayload());
+            TahuEdgeEndpoint device = 
context.getEndpoint("tahu-edge:SharedGroup/SharedNode/D1", 
TahuEdgeEndpoint.class);
+            device.setMetricDataTypePayloadMap(new 
SparkplugBPayloadMap.SparkplugBPayloadMapBuilder()
+                    .addMetric(new MetricBuilder("level", 
MetricDataType.Int32, 0).createMetric()).createPayload());
+            context.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    from("direct:node").routeId("node").to(node);
+                    // two producers to the same Device endpoint share its 
metric map
+                    from("direct:device1").routeId("device1").to(device);
+                    from("direct:device2").routeId("device2").to(device);
+                }
+            });
+            context.start();
+
+            String dbirth = "spBv1.0/SharedGroup/DBIRTH/SharedNode/D1";
+            waitFor(broker, () -> count(broker, dbirth) == 1);
+
+            // one of the two Device producers stops, the other keeps running
+            context.getRouteController().stopRoute("device2");
+            context.getRouteController().stopRoute("node");
+            context.getRouteController().startRoute("node");
+
+            // the new client of the Edge Node still has the metric map of the 
Device, so it is born again
+            waitFor(broker, () -> count(broker, dbirth) == 2);
+            ProducerTemplate template = context.createProducerTemplate();
+            template.sendBody("direct:device1", payload(1));
+            waitFor(broker, () -> count(broker, 
"spBv1.0/SharedGroup/DDATA/SharedNode/D1") == 1);
+        }
+    }
+
+    @Test
+    void 
clientIsShutDownWhenBothProducersOfTheEdgeNodeStopAfterOneWasRestarted() throws 
Exception {
+        try (FakeMqttBroker broker = new FakeMqttBroker(); CamelContext 
context = new DefaultCamelContext()) {
+            TahuEdgeEndpoint node = context.getEndpoint(
+                    
"tahu-edge:TwoGroup/TwoNode?deviceIds=D1&servers=Mqtt:tcp://localhost:" + 
broker.getPort(),
+                    TahuEdgeEndpoint.class);
+            node.setMetricDataTypePayloadMap(new 
SparkplugBPayloadMap.SparkplugBPayloadMapBuilder()
+                    .addMetric(new MetricBuilder("level", 
MetricDataType.Int32, 0).createMetric()).createPayload());
+            context.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    // two producers of the same Edge Node
+                    from("direct:node1").routeId("node1").to(node);
+                    from("direct:node2").routeId("node2").to(node);
+                }
+            });
+            context.start();
+
+            String nbirth = "spBv1.0/TwoGroup/NBIRTH/TwoNode";
+            String ndeath = "spBv1.0/TwoGroup/NDEATH/TwoNode";
+            waitFor(broker, () -> count(broker, nbirth) == 1);
+
+            // the restarted producer shuts down the first client and starts a 
second one; the other producer still
+            // refers to the first client
+            context.getRouteController().stopRoute("node1");
+            context.getRouteController().startRoute("node1");
+            waitFor(broker, () -> count(broker, nbirth) == 2);
+
+            context.getRouteController().stopRoute("node2");
+            context.getRouteController().stopRoute("node1");
+
+            // the second client is shut down (its NDEATH is published) and 
removed, so that no client of this Edge
+            // Node is left running without a started producer
+            waitFor(broker, () -> count(broker, ndeath) == 2);
+            assertNull(TahuEdgeProducer.descriptorClients.get(new 
EdgeNodeDescriptor("TwoGroup", "TwoNode")));
+        }
+    }
+
+    @Test
+    void 
deviceProducerCreatedWhileTheEdgeNodeIsStoppedUsesTheClientOfTheEdgeNode() 
throws Exception {
+        try (FakeMqttBroker broker = new FakeMqttBroker(); CamelContext 
context = new DefaultCamelContext()) {
+            TahuEdgeEndpoint node = context.getEndpoint(
+                    
"tahu-edge:LateGroup/LateNode?deviceIds=D1&servers=Mqtt:tcp://localhost:" + 
broker.getPort(),
+                    TahuEdgeEndpoint.class);
+            node.setMetricDataTypePayloadMap(new 
SparkplugBPayloadMap.SparkplugBPayloadMapBuilder()
+                    .addMetric(new MetricBuilder("level", 
MetricDataType.Int32, 0).createMetric()).createPayload());
+            context.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    from("direct:node").routeId("node").to(node);
+                }
+            });
+            context.start();
+            waitFor(broker, () -> count(broker, 
"spBv1.0/LateGroup/NBIRTH/LateNode") == 1);
+            context.getRouteController().stopRoute("node");
+
+            // the Device URI has none of the Edge Node options (deviceIds, 
servers): without the fix its producer
+            // created the client of the Edge Node from them and failed to 
start with a NullPointerException
+            TahuEdgeEndpoint device = 
context.getEndpoint("tahu-edge:LateGroup/LateNode/D1", TahuEdgeEndpoint.class);
+            device.setMetricDataTypePayloadMap(new 
SparkplugBPayloadMap.SparkplugBPayloadMapBuilder()
+                    .addMetric(new MetricBuilder("level", 
MetricDataType.Int32, 0).createMetric()).createPayload());
+            context.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    from("direct:device").routeId("device").to(device);
+                }
+            });
+
+            // the Edge Node has no started producer, so the Device data 
cannot be published
+            ProducerTemplate template = context.createProducerTemplate();
+            Exchange failed = template.send("direct:device", e -> 
e.getMessage().setBody(payload(1)));
+            IllegalStateException e = 
assertInstanceOf(IllegalStateException.class, failed.getException());
+            assertTrue(e.getMessage().contains("LateGroup/LateNode"), 
e.getMessage());
+
+            // the Edge Node producer creates the client from its own 
endpoint, with the metric map of the Device
+            context.getRouteController().startRoute("node");
+            waitFor(broker, () -> count(broker, 
"spBv1.0/LateGroup/DBIRTH/LateNode/D1") == 1);
+            template.sendBody("direct:device", payload(2));
+            waitFor(broker, () -> count(broker, 
"spBv1.0/LateGroup/DDATA/LateNode/D1") == 1);
+        }
+    }
+
+    private static void waitFor(FakeMqttBroker broker, BooleanSupplier 
condition) {
+        await().atMost(20, TimeUnit.SECONDS).untilAsserted(() -> 
assertTrue(condition.getAsBoolean(),
+                () -> "not published; connects: " + broker.getConnects() + ", 
published: " + broker.getPublishedTopics()));
+    }
+
+    private static long count(FakeMqttBroker broker, String topic) {
+        return 
broker.getPublishedTopics().stream().filter(topic::equals).count();
+    }
+
+    private static SparkplugBPayload payload(int value) throws Exception {
+        return new 
SparkplugBPayload.SparkplugBPayloadBuilder().setTimestamp(new Date())
+                .addMetric(new MetricBuilder("level", MetricDataType.Int32, 
value).createMetric()).createPayload();
+    }
+}
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 982ce7102bdc..c513494b38f6 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
@@ -2612,3 +2612,11 @@ With `crossOriginFilterOn=true`, the CORS responses now 
include `Access-Control-
 has an entry with a wildcard such as `https://*` or an entry `null`, the 
origin is still allowed, but without credentials. Previously credentials
 were allowed for every origin. A cross-origin client that sends cookies or 
other credentials must have its origin
 listed in `allowedOrigins`.
+
+=== camel-tahu - a Device producer uses the client of its started Edge Node 
producer
+
+A Device producer (`tahu-edge:group/edgeNode/device`) no longer creates the 
client of its Edge Node: only the Edge
+Node producer creates it, from the options of the Edge Node endpoint 
(`deviceIds`, `primaryHostId`, `servers`). A
+Device route that starts before the Edge Node route, or while it is stopped, 
therefore no longer fails with a
+`NullPointerException` when the Device URI has no `deviceIds`. Sending to a 
Device endpoint while no producer of its
+Edge Node endpoint is started now fails the exchange with an 
`IllegalStateException`; before, the data was dropped.

Reply via email to