davsclaus commented on code in PR #28324:
URL: https://github.com/apache/camel/pull/28324#discussion_r4357369455
##########
components/camel-paho/src/main/java/org/apache/camel/component/paho/PahoConsumer.java:
##########
@@ -221,6 +194,40 @@ protected void doStop() throws Exception {
}
}
+ /**
+ * Subscribes again after the client reconnected.
+ */
+ void resubscribe() {
+ MqttClient current = client;
+ if (current == null || !isRunAllowed()) {
+ // stopping: do not subscribe the topic again, the stop
unsubscribes it
+ return;
+ }
+ try {
+ current.subscribe(getEndpoint().getTopic(),
getEndpoint().getConfiguration().getQos());
+ } catch (MqttException e) {
+ if (stopClient) {
+ LOG.warn("MQTT resubscribe failed on reconnect, restarting
route for recovery: {}",
+ e.getMessage(), e);
+ restartRouteAsync();
+ } else {
+ LOG.error(
+ "MQTT resubscribe failed on reconnect with externally
provided client,"
+ + " route will not be auto-restarted: {}",
+ e.getMessage(), e);
+ }
+ }
+ }
+
+ void onMessageArrived(String topic, MqttMessage message) {
+ LOG.debug("Message arrived on topic: {} -> {}", topic, message);
Review Comment:
Nit: `PahoClientCallback.messageArrived` already logs this line, so each
message is logged twice at DEBUG. One of the two can go:
```suggestion
```
##########
components/camel-paho/src/main/java/org/apache/camel/component/paho/PahoClientCallback.java:
##########
@@ -0,0 +1,198 @@
+/*
+ * 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.paho;
+
+import java.util.IdentityHashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
+import org.eclipse.paho.client.mqttv3.MqttCallbackExtended;
+import org.eclipse.paho.client.mqttv3.MqttClient;
+import org.eclipse.paho.client.mqttv3.MqttException;
+import org.eclipse.paho.client.mqttv3.MqttMessage;
+import org.eclipse.paho.client.mqttv3.MqttTopic;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The callback of a {@link MqttClient} used by consumers. A client has a
single callback, and the same client can be
+ * used by the consumers of several endpoints (the client option), so the
callback is shared by all the consumers of the
+ * client: a message goes to the consumers whose topic matches it, and every
consumer subscribes again after a
+ * reconnect. Only a client that the consumer created for itself gives every
message to that consumer. A message that no
+ * consumer of a shared client matches is dropped, and acknowledged when its
consumers use manualAcksEnabled.
+ */
+final class PahoClientCallback implements MqttCallbackExtended {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(PahoClientCallback.class);
+
+ // the callbacks of the clients that have consumers, by client instance
+ private static final Map<MqttClient, PahoClientCallback> CALLBACKS = new
IdentityHashMap<>();
+
+ // the number of topics whose unmatched messages are logged at WARN (the
next ones at DEBUG)
+ private static final int MAX_WARNED_TOPICS = 100;
+
+ private final List<PahoConsumer> consumers = new CopyOnWriteArrayList<>();
+ private final MqttClient client;
+ // whether the client is the own client of its consumer, so that no other
consumer ever used it
+ private final boolean ownedClient;
+ // whether a consumer of the client acknowledges its messages itself
(manualAcksEnabled); kept after it stops, as
+ // the messages of its subscription can still arrive
+ private volatile boolean manualAcks;
+ private final Set<String> warnedTopics = ConcurrentHashMap.newKeySet();
+
+ private PahoClientCallback(MqttClient client, boolean ownedClient) {
+ this.client = client;
+ this.ownedClient = ownedClient;
+ }
+
+ /**
+ * Adds the consumer to the callback of the client, and sets that callback
on the client.
+ *
+ * @param ownedClient whether the consumer created the client for itself
(no other consumer uses it)
+ */
+ static void register(MqttClient client, PahoConsumer consumer, boolean
ownedClient) {
+ synchronized (CALLBACKS) {
+ PahoClientCallback callback
+ = CALLBACKS.computeIfAbsent(client, c -> new
PahoClientCallback(c, ownedClient));
+ if
(consumer.getEndpoint().getConfiguration().isManualAcksEnabled()) {
+ callback.manualAcks = true;
+ }
+ if (!callback.consumers.contains(consumer)) {
+ callback.consumers.add(consumer);
+ }
+ client.setCallback(callback);
+ }
+ }
+
+ /**
+ * Removes the consumer from the callback of its client, if any.
+ *
+ * @return whether another consumer of the client still uses the topic
filter of the consumer, so that the topic
+ * must stay subscribed
+ */
+ static boolean unregister(PahoConsumer consumer) {
+ String topic = consumer.getEndpoint().getTopic();
+ boolean topicStillUsed = false;
+ synchronized (CALLBACKS) {
+ Iterator<PahoClientCallback> it = CALLBACKS.values().iterator();
+ while (it.hasNext()) {
+ PahoClientCallback callback = it.next();
+ if (callback.consumers.remove(consumer)) {
+ for (PahoConsumer other : callback.consumers) {
+ if (topic.equals(other.getEndpoint().getTopic())) {
+ topicStillUsed = true;
+ }
+ }
+ }
+ if (callback.consumers.isEmpty()) {
+ it.remove();
+ }
+ }
+ }
+ return topicStillUsed;
+ }
+
+ @Override
+ public void connectComplete(boolean reconnect, String serverURI) {
+ if (reconnect) {
+ for (PahoConsumer consumer : consumers) {
+ consumer.resubscribe();
+ }
+ }
+ }
+
+ @Override
+ public void messageArrived(String topic, MqttMessage message) throws
Exception {
+ LOG.debug("Message arrived on topic: {} -> {}", topic, message);
+ // a snapshot: a consumer can unregister on another thread
+ PahoConsumer[] current = consumers.toArray(new PahoConsumer[0]);
+ if (ownedClient && current.length == 1) {
+ // the own client of a consumer: it is the only one that
subscribed, so it gets every message, as before
+ current[0].onMessageArrived(topic, message);
+ return;
+ }
+ // a shared client: a stopped consumer can leave its subscription on
the client (cleanStart / cleanSession
+ // false), so a message goes only to the consumers whose topic filter
matches it, even when one consumer is left
+ boolean delivered = false;
+ for (PahoConsumer consumer : current) {
+ if (matches(consumer.getEndpoint().getTopic(), topic)) {
+ consumer.onMessageArrived(topic, message);
+ delivered = true;
+ }
+ }
+ if (!delivered) {
+ // with manual acks the client sends no PUBACK (QoS 1) or PUBCOMP
(QoS 2) until the message is acknowledged,
+ // and the broker sends a limited number of unacknowledged
messages, so a dropped message that stays
+ // unacknowledged would stop every consumer of the client
+ // (the own client of a consumer is not acknowledged: it is being
stopped, and a session that the broker
+ // keeps sends the message again on the next connect)
+ boolean acknowledge = !ownedClient && manualAcks &&
message.getQos() > 0;
+ String dropped = acknowledge ? "dropped and acknowledged" :
"dropped";
+ if (warnedTopics.size() < MAX_WARNED_TOPICS &&
warnedTopics.add(topic)) {
+ LOG.warn("Message arrived on topic: {} that no running
consumer of the client subscribed to; it is {}"
+ + " (further messages of this topic are logged at
DEBUG)",
+ topic, dropped);
+ } else {
+ LOG.debug("Message arrived on topic: {} that no running
consumer of the client subscribed to; it is {}",
+ topic, dropped);
+ }
+ if (acknowledge) {
+ acknowledge(topic, message);
+ }
+ }
+ }
+
+ private void acknowledge(String topic, MqttMessage message) {
+ try {
+ client.messageArrivedComplete(message.getId(), message.getQos());
+ } catch (MqttException e) {
+ LOG.warn("Failed to acknowledge the dropped MQTT message with ID:
{} of topic: {}: {}",
+ message.getId(), topic, e.toString(), e);
+ }
+ }
+
+ static boolean matches(String topicFilter, String topic) {
+ String filter = topicFilter;
+ // a shared subscription ($share/<group>/<filter>) delivers the
messages of <filter>
+ if (filter.startsWith("$share/")) {
+ int index = filter.indexOf('/', "$share/".length());
+ if (index > 0) {
+ filter = filter.substring(index + 1);
+ }
+ }
+ try {
+ return MqttTopic.isMatched(filter, topic);
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
Review Comment:
Same as in camel-paho-mqtt5: `MqttTopic.isMatched` (v3, 1.2.5) gives the
same wrong results for empty levels and `$` topics. Suggested replacement (the
`MqttTopic` import is then unused):
```suggestion
// MQTT 4.7.2: a filter that starts with a wildcard does not match a
topic that starts with $
if (topic.startsWith("$") && (filter.startsWith("+") ||
filter.startsWith("#"))) {
return false;
}
// split with -1 keeps the empty levels, which + matches (MQTT
4.7.1.3)
String[] filterLevels = filter.split("/", -1);
String[] topicLevels = topic.split("/", -1);
for (int i = 0; i < filterLevels.length; i++) {
String level = filterLevels[i];
if ("#".equals(level)) {
// # matches the parent level and any number of child levels
return i == filterLevels.length - 1;
}
if (i >= topicLevels.length || !("+".equals(level) ||
level.equals(topicLevels[i]))) {
return false;
}
}
return filterLevels.length == topicLevels.length;
```
##########
components/camel-paho-mqtt5/src/main/java/org/apache/camel/component/paho/mqtt5/PahoMqtt5ClientCallback.java:
##########
@@ -0,0 +1,208 @@
+/*
+ * 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.paho.mqtt5;
+
+import java.util.IdentityHashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.eclipse.paho.mqttv5.client.IMqttToken;
+import org.eclipse.paho.mqttv5.client.MqttCallback;
+import org.eclipse.paho.mqttv5.client.MqttClient;
+import org.eclipse.paho.mqttv5.client.MqttDisconnectResponse;
+import org.eclipse.paho.mqttv5.common.MqttException;
+import org.eclipse.paho.mqttv5.common.MqttMessage;
+import org.eclipse.paho.mqttv5.common.packet.MqttProperties;
+import org.eclipse.paho.mqttv5.common.util.MqttTopicValidator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The callback of a {@link MqttClient} used by consumers. A client has a
single callback, and the same client can be
+ * used by the consumers of several endpoints (the client option), so the
callback is shared by all the consumers of the
+ * client: a message goes to the consumers whose topic matches it, and every
consumer subscribes again after a
+ * reconnect. Only a client that the consumer created for itself gives every
message to that consumer. A message that no
+ * consumer of a shared client matches is dropped, and acknowledged when its
consumers use manualAcksEnabled.
+ */
+final class PahoMqtt5ClientCallback implements MqttCallback {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(PahoMqtt5ClientCallback.class);
+
+ // the callbacks of the clients that have consumers, by client instance
+ private static final Map<MqttClient, PahoMqtt5ClientCallback> CALLBACKS =
new IdentityHashMap<>();
+
+ // the number of topics whose unmatched messages are logged at WARN (the
next ones at DEBUG)
+ private static final int MAX_WARNED_TOPICS = 100;
+
+ private final List<PahoMqtt5Consumer> consumers = new
CopyOnWriteArrayList<>();
+ private final MqttClient client;
+ // whether the client is the own client of its consumer, so that no other
consumer ever used it
+ private final boolean ownedClient;
+ // whether a consumer of the client acknowledges its messages itself
(manualAcksEnabled); kept after it stops, as
+ // the messages of its subscription can still arrive
+ private volatile boolean manualAcks;
+ private final Set<String> warnedTopics = ConcurrentHashMap.newKeySet();
+
+ private PahoMqtt5ClientCallback(MqttClient client, boolean ownedClient) {
+ this.client = client;
+ this.ownedClient = ownedClient;
+ }
+
+ /**
+ * Adds the consumer to the callback of the client, and sets that callback
on the client.
+ *
+ * @param ownedClient whether the consumer created the client for itself
(no other consumer uses it)
+ */
+ static void register(MqttClient client, PahoMqtt5Consumer consumer,
boolean ownedClient) {
+ synchronized (CALLBACKS) {
+ PahoMqtt5ClientCallback callback
+ = CALLBACKS.computeIfAbsent(client, c -> new
PahoMqtt5ClientCallback(c, ownedClient));
+ if
(consumer.getEndpoint().getConfiguration().isManualAcksEnabled()) {
+ callback.manualAcks = true;
+ }
+ if (!callback.consumers.contains(consumer)) {
+ callback.consumers.add(consumer);
+ }
+ client.setCallback(callback);
+ }
+ }
+
+ /**
+ * Removes the consumer from the callback of its client, if any.
+ *
+ * @return whether another consumer of the client still uses the topic
filter of the consumer, so that the topic
+ * must stay subscribed
+ */
+ static boolean unregister(PahoMqtt5Consumer consumer) {
+ String topic = consumer.getEndpoint().getTopic();
+ boolean topicStillUsed = false;
+ synchronized (CALLBACKS) {
+ Iterator<PahoMqtt5ClientCallback> it =
CALLBACKS.values().iterator();
+ while (it.hasNext()) {
+ PahoMqtt5ClientCallback callback = it.next();
+ if (callback.consumers.remove(consumer)) {
+ for (PahoMqtt5Consumer other : callback.consumers) {
+ if (topic.equals(other.getEndpoint().getTopic())) {
+ topicStillUsed = true;
+ }
+ }
+ }
+ if (callback.consumers.isEmpty()) {
+ it.remove();
+ }
+ }
+ }
+ return topicStillUsed;
+ }
+
+ @Override
+ public void connectComplete(boolean reconnect, String serverURI) {
+ for (PahoMqtt5Consumer consumer : consumers) {
+ consumer.connectComplete(reconnect);
+ }
+ }
+
+ @Override
+ public void messageArrived(String topic, MqttMessage message) throws
Exception {
+ LOG.debug("Message arrived on topic: {} -> {}", topic, message);
+ // a snapshot: a consumer can unregister on another thread
+ PahoMqtt5Consumer[] current = consumers.toArray(new
PahoMqtt5Consumer[0]);
+ if (ownedClient && current.length == 1) {
+ // the own client of a consumer: it is the only one that
subscribed, so it gets every message, as before
+ current[0].onMessageArrived(topic, message);
+ return;
+ }
+ // a shared client: a stopped consumer can leave its subscription on
the client (cleanStart / cleanSession
+ // false), so a message goes only to the consumers whose topic filter
matches it, even when one consumer is left
+ boolean delivered = false;
+ for (PahoMqtt5Consumer consumer : current) {
+ if (matches(consumer.getEndpoint().getTopic(), topic)) {
+ consumer.onMessageArrived(topic, message);
+ delivered = true;
+ }
+ }
+ if (!delivered) {
+ // with manual acks the client sends no PUBACK for a QoS 1 message
until it is acknowledged, and the broker
+ // sends no more than receiveMaximum unacknowledged messages, so a
dropped message that stays unacknowledged
+ // would stop every consumer of the client; the client completes
the QoS 2 flow on its own
+ // (the own client of a consumer is not acknowledged: it is being
stopped, and a session that the broker
+ // keeps sends the message again on the next connect)
+ boolean acknowledge = !ownedClient && manualAcks &&
message.getQos() == 1;
+ String dropped = acknowledge ? "dropped and acknowledged" :
"dropped";
+ if (warnedTopics.size() < MAX_WARNED_TOPICS &&
warnedTopics.add(topic)) {
+ LOG.warn("Message arrived on topic: {} that no running
consumer of the client subscribed to; it is {}"
+ + " (further messages of this topic are logged at
DEBUG)",
+ topic, dropped);
+ } else {
+ LOG.debug("Message arrived on topic: {} that no running
consumer of the client subscribed to; it is {}",
+ topic, dropped);
+ }
+ if (acknowledge) {
+ acknowledge(topic, message);
+ }
+ }
+ }
+
+ private void acknowledge(String topic, MqttMessage message) {
+ try {
+ client.messageArrivedComplete(message.getId(), message.getQos());
+ } catch (MqttException e) {
+ LOG.warn("Failed to acknowledge the dropped MQTT message with ID:
{} of topic: {}: {}",
+ message.getId(), topic, e.toString(), e);
+ }
+ }
+
+ static boolean matches(String topicFilter, String topic) {
+ String filter = topicFilter;
+ // a shared subscription ($share/<group>/<filter>) delivers the
messages of <filter>
+ if (filter.startsWith("$share/")) {
+ int index = filter.indexOf('/', "$share/".length());
+ if (index > 0) {
+ filter = filter.substring(index + 1);
+ }
+ }
+ try {
+ return MqttTopicValidator.isMatched(filter, topic);
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
Review Comment:
`MqttTopicValidator.isMatched` does not let `+` match an empty level (`+/+`
vs `/finance`, `a/+/b` vs `a//b` return false) and lets a leading wildcard
match a `$` topic (`#` vs `$SYS/x` returns true), see finding 1. A matcher on
the split levels follows MQTT 4.7 (the `MqttTopicValidator` import is then
unused):
```suggestion
// MQTT 4.7.2: a filter that starts with a wildcard does not match a
topic that starts with $
if (topic.startsWith("$") && (filter.startsWith("+") ||
filter.startsWith("#"))) {
return false;
}
// split with -1 keeps the empty levels, which + matches (MQTT
4.7.1.3)
String[] filterLevels = filter.split("/", -1);
String[] topicLevels = topic.split("/", -1);
for (int i = 0; i < filterLevels.length; i++) {
String level = filterLevels[i];
if ("#".equals(level)) {
// # matches the parent level and any number of child levels
return i == filterLevels.length - 1;
}
if (i >= topicLevels.length || !("+".equals(level) ||
level.equals(topicLevels[i]))) {
return false;
}
}
return filterLevels.length == topicLevels.length;
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]