Browse Source

MQTT client: limit retransmission attempts to prevent unlimited memory usage and network overload

pull/13054/head
Dmytro Skarzhynets 1 year ago
parent
commit
8d749f593b
No known key found for this signature in database GPG Key ID: 2B51652F224037DF
  1. 7
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  2. 9
      application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
  3. 37
      application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientRetransmissionSettingsComponent.java
  4. 47
      application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientSettingsComponent.java
  5. 24
      application/src/main/resources/thingsboard.yml
  6. 12
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java
  7. 15
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java
  8. 14
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java
  9. 20
      netty-mqtt/pom.xml
  10. 24
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MaxRetransmissionsReachedException.java
  11. 30
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttChannelHandler.java
  12. 2
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClient.java
  13. 20
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java
  14. 82
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java
  15. 3
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java
  16. 134
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingPublish.java
  17. 119
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingSubscription.java
  18. 85
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingUnsubscription.java
  19. 17
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPingHandler.java
  20. 4
      netty-mqtt/src/main/java/org/thingsboard/mqtt/PendingOperation.java
  21. 101
      netty-mqtt/src/main/java/org/thingsboard/mqtt/RetransmissionHandler.java
  22. 210
      netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttClientTest.java
  23. 63
      netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttPingHandlerTest.java
  24. 202
      netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttTestProxy.java
  25. 151
      netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java
  26. 84
      netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttServer.java
  27. 141
      netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttTransportHandler.java
  28. 3
      netty-mqtt/src/test/resources/junit-platform.properties
  29. 6
      pom.xml
  30. 26
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MqttClientSettings.java
  31. 5
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java
  32. 8
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java
  33. 18
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java

7
application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java

@ -30,9 +30,10 @@ import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.SmsService;
import org.thingsboard.rule.engine.api.notification.SlackService;
import org.thingsboard.rule.engine.api.sms.SmsSenderFactory;
@ -639,6 +640,10 @@ public class ActorSystemContext {
@Getter
private long cfCalculationResultTimeout;
@Autowired
@Getter
private MqttClientSettings mqttClientSettings;
@Getter
@Setter
private TbActorSystem actorSystem;

9
application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java

@ -23,14 +23,15 @@ import org.bouncycastle.util.Arrays;
import org.thingsboard.common.util.DebugModeUtil;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ListeningExecutor;
import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.RuleEngineAlarmService;
import org.thingsboard.rule.engine.api.RuleEngineApiUsageStateService;
import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache;
import org.thingsboard.rule.engine.api.RuleEngineCalculatedFieldQueueService;
import org.thingsboard.rule.engine.api.RuleEngineDeviceProfileCache;
import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.RuleEngineRpcService;
import org.thingsboard.rule.engine.api.RuleEngineTelemetryService;
import org.thingsboard.rule.engine.api.ScriptEngine;
@ -1010,13 +1011,17 @@ public class DefaultTbContext implements TbContext {
return mainCtx.getAuditLogService();
}
@Override
public MqttClientSettings getMqttClientSettings() {
return mainCtx.getMqttClientSettings();
}
private TbMsgMetaData getActionMetaData(RuleNodeId ruleNodeId) {
TbMsgMetaData metaData = new TbMsgMetaData();
metaData.putValue("ruleNodeId", ruleNodeId.toString());
return metaData;
}
@Override
public void schedule(Runnable runnable, long delay, TimeUnit timeUnit) {
mainCtx.getScheduler().schedule(runnable, delay, timeUnit);

37
application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientRetransmissionSettingsComponent.java

@ -0,0 +1,37 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed 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.thingsboard.server.config.mqtt;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.annotation.Validated;
@Data
@Validated
@Configuration
@ConfigurationProperties(prefix = "mqtt.client.retransmission")
public class MqttClientRetransmissionSettingsComponent {
@PositiveOrZero
private int maxAttempts;
@PositiveOrZero
private long initialDelayMillis;
@PositiveOrZero
private double jitterFactor;
}

47
application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientSettingsComponent.java

@ -0,0 +1,47 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed 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.thingsboard.server.config.mqtt;
import lombok.EqualsAndHashCode;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.springframework.context.annotation.Configuration;
import org.thingsboard.rule.engine.api.MqttClientSettings;
@ToString
@EqualsAndHashCode
@Configuration
@RequiredArgsConstructor
public class MqttClientSettingsComponent implements MqttClientSettings {
private final MqttClientRetransmissionSettingsComponent retransmissionSettingsComponent;
@Override
public int getRetransmissionMaxAttempts() {
return retransmissionSettingsComponent.getMaxAttempts();
}
@Override
public long getRetransmissionInitialDelayMillis() {
return retransmissionSettingsComponent.getInitialDelayMillis();
}
@Override
public double getRetransmissionJitterFactor() {
return retransmissionSettingsComponent.getJitterFactor();
}
}

24
application/src/main/resources/thingsboard.yml

@ -1920,3 +1920,27 @@ mobileApp:
googlePlayLink: "${TB_MOBILE_APP_GOOGLE_PLAY_LINK:https://play.google.com/store/apps/details?id=org.thingsboard.demo.app}"
# Link to App Store for Thingsboard Live mobile application
appStoreLink: "${TB_MOBILE_APP_APP_STORE_LINK:https://apps.apple.com/us/app/thingsboard-live/id1594355695}"
mqtt:
# MQTT client configuration parameters
client:
# Parameters that control the retransmission mechanism.
# This mechanism only applies to the handling of MQTT Publish, Subscribe, Unsubscribe and Pubrel messages.
# With the updated default settings:
# - After sending the message, wait approximately 5000 ms (± jitter) for the 1st attempt.
# - The 2nd attempt will occur after roughly 5000 * 2 = 10,000 ms (± jitter).
# - The 3rd attempt will occur after roughly 5000 * 4 = 20,000 ms (± jitter).
# - The 4th "attempt" will not actually perform a retransmission.
# Instead, the system will detect that the maximum number of attempts has been reached and drop the pending message.
retransmission:
# Maximum number of retransmission attempts allowed.
# If the attempt count exceeds this value, retransmissions will stop and the pending message will be dropped.
max_attempts: "${TB_MQTT_CLIENT_RETRANSMISSION_MAX_ATTEMPTS:3}"
# Base delay (in milliseconds) before the first retransmission attempt, measured from the moment the message is sent.
# Subsequent delays are calculated using exponential backoff.
# This base delay is also used as the reference value for applying jitter.
initial_delay_millis: "${TB_MQTT_CLIENT_RETRANSMISSION_INITIAL_DELAY_MILLIS:5000}"
# Jitter factor applied to the calculated retransmission delay.
# The actual delay is randomized within a range defined by multiplying the base delay by a factor between (1 - jitter_factor) and (1 + jitter_factor).
# For example, a jitter_factor of 0.15 means the actual delay may vary by up to ±15% of the base delay.
jitter_factor: "${TB_MQTT_CLIENT_RETRANSMISSION_JITTER_FACTOR:0.15}"

12
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java

@ -55,8 +55,8 @@ public class ContainerTestSuite {
private static final String TB_JS_EXECUTOR_LOG_REGEXP = ".*template started.*";
private static final Duration CONTAINER_STARTUP_TIMEOUT = Duration.ofSeconds(400);
private DockerComposeContainer<?> testContainer;
private ThingsBoardDbInstaller installTb;
private DockerComposeContainer<?> testContainer;
private ThingsBoardDbInstaller installTb;
private boolean isActive;
private static ContainerTestSuite containerTestSuite;
@ -194,7 +194,7 @@ public class ContainerTestSuite {
setActive(true);
} catch (Exception e) {
log.error("Failed to create test container", e);
fail("Failed to create test container");
fail("Failed to create test container", e);
}
}
@ -263,7 +263,7 @@ public class ContainerTestSuite {
log.info("Trying to delete temp dir {}", targetDir);
FileUtils.deleteDirectory(new File(targetDir));
} catch (IOException e) {
log.error("Can't delete temp directory " + targetDir, e);
log.error("Can't delete temp directory {}", targetDir, e);
}
}
@ -286,8 +286,8 @@ public class ContainerTestSuite {
FileUtils.writeStringToFile(file, outputContent, StandardCharsets.UTF_8);
assertThat(FileUtils.readFileToString(file, StandardCharsets.UTF_8), is(outputContent));
} catch (IOException e) {
log.error("failed to update file " + sourceFilename, e);
fail("failed to update file");
log.error("failed to update file {}", sourceFilename, e);
fail("failed to update file", e);
}
}

15
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java

@ -42,7 +42,6 @@ import org.thingsboard.mqtt.MqttClient;
import org.thingsboard.mqtt.MqttClientCallback;
import org.thingsboard.mqtt.MqttClientConfig;
import org.thingsboard.mqtt.MqttHandler;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.DeviceProfileProvisionType;
@ -82,7 +81,6 @@ import java.util.concurrent.TimeoutException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.fail;
import static org.thingsboard.server.common.data.DataConstants.DEVICE;
import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE;
import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype;
@ -301,7 +299,7 @@ public class MqttClientTest extends AbstractContainerTest {
assertThat(Objects.requireNonNull(requestFromServer).getMessage()).isEqualTo("{\"method\":\"getValue\",\"params\":true}");
Integer requestId = Integer.valueOf(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length()));
int requestId = Integer.parseInt(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length()));
JsonObject clientResponse = new JsonObject();
clientResponse.addProperty("response", "someResponse");
// Send a response to the server's RPC request
@ -340,7 +338,7 @@ public class MqttClientTest extends AbstractContainerTest {
assertThat(Objects.requireNonNull(requestFromServer).getMessage()).isEqualTo("{\"method\":\"getValue\",\"params\":true}");
Integer requestId = Integer.valueOf(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length()));
int requestId = Integer.parseInt(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length()));
JsonObject clientResponse = new JsonObject();
clientResponse.addProperty("response", "someResponse");
// Send a response to the server's RPC request
@ -520,13 +518,13 @@ public class MqttClientTest extends AbstractContainerTest {
mqttClient.on("/provision/response", listener, MqttQoS.AT_LEAST_ONCE).get(3 * timeoutMultiplier, TimeUnit.SECONDS);
TimeUnit.SECONDS.sleep(2 * timeoutMultiplier);
assertThat(subAckResult[0]).isNotNull();
assertThat(MqttReasonCodes.SubAck.GRANTED_QOS_1.equals(subAckResult[0]));
assertThat(MqttReasonCodes.SubAck.GRANTED_QOS_1).isEqualTo(subAckResult[0]);
subAckResult[0] = null;
mqttClient.on("v1/devices/me/attributes", listener, MqttQoS.AT_LEAST_ONCE).get(3 * timeoutMultiplier, TimeUnit.SECONDS);
TimeUnit.SECONDS.sleep(2 * timeoutMultiplier);
assertThat(subAckResult[0]).isNotNull();
assertThat(MqttReasonCodes.SubAck.TOPIC_FILTER_INVALID.equals(subAckResult[0]));
assertThat(MqttReasonCodes.SubAck.TOPIC_FILTER_INVALID).isEqualTo(subAckResult[0]);
testRestClient.deleteDeviceIfExists(device.getId());
updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.DISABLED);
@ -596,7 +594,7 @@ public class MqttClientTest extends AbstractContainerTest {
.await()
.alias("Check device disconnect.")
.atMost(TIMEOUT*timeoutMultiplier, TimeUnit.SECONDS)
.until(() -> returnCodeByteValue.size() > 0);
.until(() -> !returnCodeByteValue.isEmpty());
assertThat(returnCodeByteValueSecondClient).isEmpty();
assertThat(returnCodeByteValue).isNotEmpty();
@ -663,7 +661,7 @@ public class MqttClientTest extends AbstractContainerTest {
.stream()
.filter(RuleChain::isRoot)
.findFirst();
if (!defaultRuleChain.isPresent()) {
if (defaultRuleChain.isEmpty()) {
fail("Root rule chain wasn't found");
}
return defaultRuleChain.get().getId();
@ -717,6 +715,7 @@ public class MqttClientTest extends AbstractContainerTest {
clientConfig.setClientId("MQTT client from test");
clientConfig.setUsername(username);
clientConfig.setProtocolVersion(mqttVersion);
clientConfig.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig(5, 5000L, 0.1d)); // same as defaults in thingsboard.yml as of time of this writing
MqttClient mqttClient = MqttClient.create(clientConfig, listener, handlerExecutor);
if (connect) {
mqttClient.connect(TRANSPORT_HOST, TRANSPORT_PORT).get();

14
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java

@ -39,7 +39,6 @@ import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.mqtt.MqttClient;
import org.thingsboard.mqtt.MqttClientConfig;
import org.thingsboard.mqtt.MqttHandler;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.DeviceId;
@ -65,7 +64,6 @@ import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.thingsboard.server.common.data.DataConstants.DEVICE;
import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE;
import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultGatewayPrototype;
@ -76,7 +74,6 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
private MqttClient mqttClient;
private Device createdDevice;
private MqttMessageListener listener;
private JsonParser jsonParser = new JsonParser();
AbstractListeningExecutor handlerExecutor;
@ -100,7 +97,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
}
@AfterMethod
public void removeGateway() {
public void removeGateway() {
testRestClient.deleteDeviceIfExists(this.gatewayDevice.getId());
testRestClient.deleteDeviceIfExists(this.createdDevice.getId());
this.listener = null;
@ -197,7 +194,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get();
event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS);
JsonObject responseData = jsonParser.parse(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
JsonObject responseData = JsonParser.parseString(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
assertThat(responseData.has("value")).isTrue();
assertThat(responseData.get("value").getAsString()).isEqualTo(sharedAttributes.get("attr1").getAsString());
@ -213,7 +210,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
mqttClient.on("v1/gateway/attributes/response", listener, MqttQoS.AT_LEAST_ONCE).get();
mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get();
event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS);
responseData = jsonParser.parse(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
responseData = JsonParser.parseString(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
assertThat(responseData.has("values")).isTrue();
assertThat(responseData.get("values").getAsJsonObject().get("attr1").getAsString()).isEqualTo(sharedAttributes.get("attr1").getAsString());
@ -231,7 +228,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
mqttClient.on("v1/gateway/attributes/response", listener, MqttQoS.AT_LEAST_ONCE).get();
mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get();
event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS);
responseData = jsonParser.parse(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
responseData = JsonParser.parseString(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
assertThat(responseData.has("values")).isTrue();
assertThat(responseData.get("values").getAsJsonObject().get("attr1").getAsString()).isEqualTo(sharedAttributes.get("attr1").getAsString());
@ -390,7 +387,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(gatewayAttributesRequest.toString().getBytes())).get();
MqttEvent clientAttributeEvent = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS);
assertThat(clientAttributeEvent).isNotNull();
JsonObject responseMessage = new JsonParser().parse(Objects.requireNonNull(clientAttributeEvent).getMessage()).getAsJsonObject();
JsonObject responseMessage = JsonParser.parseString(Objects.requireNonNull(clientAttributeEvent).getMessage()).getAsJsonObject();
assertThat(responseMessage.get("id").getAsInt()).isEqualTo(messageId);
assertThat(responseMessage.get("device").getAsString()).isEqualTo(createdDevice.getName());
@ -427,6 +424,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
clientConfig.setOwnerId(getOwnerId());
clientConfig.setClientId("MQTT client from test");
clientConfig.setUsername(deviceCredentials.getCredentialsId());
clientConfig.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig(3, 5000L, 0.1d)); // same as defaults in thingsboard.yml as of time of this writing
MqttClient mqttClient = MqttClient.create(clientConfig, listener, handlerExecutor);
mqttClient.connect("localhost", 1883).get();
return mqttClient;

20
netty-mqtt/pom.xml

@ -87,6 +87,26 @@
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>software.xdev</groupId>
<artifactId>testcontainers-junit4-mock</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>hivemq</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

24
netty-mqtt/src/main/java/org/thingsboard/mqtt/MaxRetransmissionsReachedException.java

@ -0,0 +1,24 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed 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.thingsboard.mqtt;
public class MaxRetransmissionsReachedException extends RuntimeException {
public MaxRetransmissionsReachedException(String message) {
super(message);
}
}

30
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttChannelHandler.java

@ -57,7 +57,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) throws Exception {
protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) {
if (msg.decoderResult().isSuccess()) {
switch (msg.fixedHeader().messageType()) {
case CONNACK:
@ -120,6 +120,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
this.client.getClientConfig().getUsername(),
this.client.getClientConfig().getPassword() != null ? this.client.getClientConfig().getPassword().getBytes(CharsetUtil.UTF_8) : null
);
log.debug("{} Sending CONNECT", client.getClientConfig().getOwnerId());
ctx.channel().writeAndFlush(new MqttConnectMessage(fixedHeader, variableHeader, payload));
}
@ -173,6 +174,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
}
private void handleConack(Channel channel, MqttConnAckMessage message) {
log.debug("{} Handling CONNACK", client.getClientConfig().getOwnerId());
switch (message.variableHeader().connectReturnCode()) {
case CONNECTION_ACCEPTED:
this.connectFuture.setSuccess(new MqttConnectResult(true, MqttConnectReturnCode.CONNECTION_ACCEPTED, channel.closeFuture()));
@ -219,9 +221,9 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
}
pendingSubscription.onSubackReceived();
for (MqttPendingSubscription.MqttPendingHandler handler : pendingSubscription.getHandlers()) {
MqttSubscription subscription = new MqttSubscription(pendingSubscription.getTopic(), handler.getHandler(), handler.isOnce());
MqttSubscription subscription = new MqttSubscription(pendingSubscription.getTopic(), handler.handler(), handler.once());
this.client.getSubscriptions().put(pendingSubscription.getTopic(), subscription);
this.client.getHandlerToSubscription().put(handler.getHandler(), subscription);
this.client.getHandlerToSubscription().put(handler.handler(), subscription);
}
this.client.getPendingSubscribeTopics().remove(pendingSubscription.getTopic());
@ -282,17 +284,16 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
}
private void handlePuback(MqttPubAckMessage message) {
MqttPendingPublish pendingPublish = this.client.getPendingPublishes().get(message.variableHeader().messageId());
if (pendingPublish == null) {
return;
}
pendingPublish.getFuture().setSuccess(null);
pendingPublish.onPubackReceived();
this.client.getPendingPublishes().remove(message.variableHeader().messageId());
pendingPublish.getPayload().release();
if (this.client.getCallback() != null) {
this.client.getCallback().onPubAck(message);
}
log.trace("{} Handling PUBACK", client.getClientConfig().getOwnerId());
client.getPendingPublishes().computeIfPresent(message.variableHeader().messageId(), (__, pendingPublish) -> {
pendingPublish.getFuture().setSuccess(null);
pendingPublish.onPubackReceived();
pendingPublish.getPayload().release();
if (client.getCallback() != null) {
client.getCallback().onPubAck(message);
}
return null;
});
}
private void handlePubrec(Channel channel, MqttMessage message) {
@ -335,6 +336,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
}
private void handleDisconnect(MqttMessage message) {
log.debug("{} Handling DISCONNECT", client.getClientConfig().getOwnerId());
if (this.client.getCallback() != null) {
this.client.getCallback().onDisconnect(message);
}

2
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClient.java

@ -184,7 +184,7 @@ public interface MqttClient {
* @param config The config object to use while looking for settings
* @param defaultHandler The handler for incoming messages that do not match any topic subscriptions
*/
static MqttClient create(MqttClientConfig config, MqttHandler defaultHandler, ListeningExecutor handlerExecutor){
static MqttClient create(MqttClientConfig config, MqttHandler defaultHandler, ListeningExecutor handlerExecutor) {
return new MqttClientImpl(config, defaultHandler, handlerExecutor);
}

20
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java

@ -47,6 +47,26 @@ public final class MqttClientConfig {
private long reconnectDelay = 1L;
private int maxBytesInMessage = 8092;
@Getter
@Setter
private RetransmissionConfig retransmissionConfig;
public record RetransmissionConfig(int maxAttempts, long initialDelayMillis, double jitterFactor) {
public RetransmissionConfig {
if (maxAttempts < 0) {
throw new IllegalArgumentException("Max retransmission attempts (maxAttempts) must be zero or greater, but was " + maxAttempts);
}
if (initialDelayMillis < 0) {
throw new IllegalArgumentException("Initial retransmission delay (initialDelayMillis) must be zero or greater, but was " + initialDelayMillis);
}
if (jitterFactor < 0) {
throw new IllegalArgumentException("Jitter factor (jitterFactor) must be zero or greater, but was " + jitterFactor);
}
}
}
public MqttClientConfig() {
this(null);
}

82
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java

@ -17,6 +17,7 @@ package org.thingsboard.mqtt;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.Channel;
@ -384,8 +385,33 @@ final class MqttClientImpl implements MqttClient {
MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.PUBLISH, false, qos, retain, 0);
MqttPublishVariableHeader variableHeader = new MqttPublishVariableHeader(topic, getNewMessageId().messageId());
MqttPublishMessage message = new MqttPublishMessage(fixedHeader, variableHeader, payload);
MqttPendingPublish pendingPublish = new MqttPendingPublish(variableHeader.packetId(), future,
payload.retain(), message, qos, () -> !pendingPublishes.containsKey(variableHeader.packetId()));
final var pendingPublish = MqttPendingPublish.builder()
.messageId(variableHeader.packetId())
.future(future)
.payload(payload.retain())
.message(message)
.qos(qos)
.ownerId(clientConfig.getOwnerId())
.retransmissionConfig(clientConfig.getRetransmissionConfig())
.pendingOperation(new PendingOperation() {
@Override
public boolean isCancelled() {
return !pendingPublishes.containsKey(variableHeader.packetId());
}
@Override
public void onMaxRetransmissionAttemptsReached() {
pendingPublishes.computeIfPresent(variableHeader.packetId(), (__, pendingPublish) -> {
var message = "Unable to deliver publish message due to max retransmission attempts (%s) being reached for client '%s' on topic '%s' (message ID: %d)"
.formatted(clientConfig.getRetransmissionConfig().maxAttempts(), clientConfig.getClientId(), topic, variableHeader.packetId());
pendingPublish.getFuture().tryFailure(new MaxRetransmissionsReachedException(message));
pendingPublish.getPayload().release();
return null;
});
}
}).build();
this.pendingPublishes.put(pendingPublish.getMessageId(), pendingPublish);
ChannelFuture channelFuture = this.sendAndFlushPacket(message);
@ -499,9 +525,30 @@ final class MqttClientImpl implements MqttClient {
MqttSubscribePayload payload = new MqttSubscribePayload(Collections.singletonList(subscription));
MqttSubscribeMessage message = new MqttSubscribeMessage(fixedHeader, variableHeader, payload);
final MqttPendingSubscription pendingSubscription = new MqttPendingSubscription(future, topic, message,
() -> !pendingSubscriptions.containsKey(variableHeader.messageId()));
pendingSubscription.addHandler(handler, once);
final var pendingSubscription = MqttPendingSubscription.builder()
.future(future)
.topic(topic)
.handlers(Sets.newHashSet(new MqttPendingSubscription.MqttPendingHandler(handler, once)))
.subscribeMessage(message)
.ownerId(clientConfig.getOwnerId())
.retransmissionConfig(clientConfig.getRetransmissionConfig())
.pendingOperation(new PendingOperation() {
@Override
public boolean isCancelled() {
return !pendingSubscriptions.containsKey(variableHeader.messageId());
}
@Override
public void onMaxRetransmissionAttemptsReached() {
pendingSubscriptions.computeIfPresent(variableHeader.messageId(), (__, pendingSubscription) -> {
var message = "Unable to deliver subscribe message due to max retransmission attempts (%s) being reached for client '%s' on topic '%s' (message ID: %d)"
.formatted(clientConfig.getRetransmissionConfig().maxAttempts(), clientConfig.getClientId(), topic, variableHeader.messageId());
pendingSubscription.getFuture().tryFailure(new MaxRetransmissionsReachedException(message));
return null;
});
}
}).build();
this.pendingSubscriptions.put(variableHeader.messageId(), pendingSubscription);
this.pendingSubscribeTopics.add(topic);
pendingSubscription.setSent(this.sendAndFlushPacket(message) != null); //If not sent, we will send it when the connection is opened
@ -518,8 +565,29 @@ final class MqttClientImpl implements MqttClient {
MqttUnsubscribePayload payload = new MqttUnsubscribePayload(Collections.singletonList(topic));
MqttUnsubscribeMessage message = new MqttUnsubscribeMessage(fixedHeader, variableHeader, payload);
MqttPendingUnsubscription pendingUnsubscription = new MqttPendingUnsubscription(promise, topic, message,
() -> !pendingServerUnsubscribes.containsKey(variableHeader.messageId()));
final var pendingUnsubscription = MqttPendingUnsubscription.builder()
.future(promise)
.topic(topic)
.unsubscribeMessage(message)
.ownerId(clientConfig.getOwnerId())
.retransmissionConfig(clientConfig.getRetransmissionConfig())
.pendingOperation(new PendingOperation() {
@Override
public boolean isCancelled() {
return !pendingServerUnsubscribes.containsKey(variableHeader.messageId());
}
@Override
public void onMaxRetransmissionAttemptsReached() {
pendingServerUnsubscribes.computeIfPresent(variableHeader.messageId(), (__, pendingUnsubscription) -> {
var message = "Unable to deliver unsubscribe message due to max retransmission attempts (%s) being reached for client '%s' on topic '%s' (message ID: %d)"
.formatted(clientConfig.getRetransmissionConfig().maxAttempts(), clientConfig.getClientId(), topic, variableHeader.messageId());
pendingUnsubscription.getFuture().tryFailure(new MaxRetransmissionsReachedException(message));
return null;
});
}
}).build();
this.pendingServerUnsubscribes.put(variableHeader.messageId(), pendingUnsubscription);
pendingUnsubscription.startRetransmissionTimer(this.eventLoop.next(), this::sendAndFlushPacket);

3
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java

@ -17,7 +17,9 @@ package org.thingsboard.mqtt;
import io.netty.channel.ChannelFuture;
import io.netty.handler.codec.mqtt.MqttConnectReturnCode;
import lombok.ToString;
@ToString
@SuppressWarnings({"WeakerAccess", "unused"})
public final class MqttConnectResult {
@ -42,4 +44,5 @@ public final class MqttConnectResult {
public ChannelFuture getCloseFuture() {
return closeFuture;
}
}

134
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingPublish.java

@ -21,9 +21,13 @@ import io.netty.handler.codec.mqtt.MqttMessage;
import io.netty.handler.codec.mqtt.MqttPublishMessage;
import io.netty.handler.codec.mqtt.MqttQoS;
import io.netty.util.concurrent.Promise;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import java.util.function.Consumer;
@Getter(AccessLevel.PACKAGE)
final class MqttPendingPublish {
private final int messageId;
@ -32,80 +36,126 @@ final class MqttPendingPublish {
private final MqttPublishMessage message;
private final MqttQoS qos;
@Getter(AccessLevel.NONE)
private final RetransmissionHandler<MqttPublishMessage> publishRetransmissionHandler;
@Getter(AccessLevel.NONE)
private final RetransmissionHandler<MqttMessage> pubrelRetransmissionHandler;
@Setter(AccessLevel.PACKAGE)
private boolean sent = false;
MqttPendingPublish(int messageId, Promise<Void> future, ByteBuf payload, MqttPublishMessage message, MqttQoS qos, PendingOperation operation) {
private MqttPendingPublish(
int messageId,
Promise<Void> future,
ByteBuf payload,
MqttPublishMessage message,
MqttQoS qos,
String ownerId,
MqttClientConfig.RetransmissionConfig retransmissionConfig,
PendingOperation pendingOperation
) {
this.messageId = messageId;
this.future = future;
this.payload = payload;
this.message = message;
this.qos = qos;
this.publishRetransmissionHandler = new RetransmissionHandler<>(operation);
this.publishRetransmissionHandler.setOriginalMessage(message);
this.pubrelRetransmissionHandler = new RetransmissionHandler<>(operation);
}
int getMessageId() {
return messageId;
}
Promise<Void> getFuture() {
return future;
}
ByteBuf getPayload() {
return payload;
}
boolean isSent() {
return sent;
}
void setSent(boolean sent) {
this.sent = sent;
}
MqttPublishMessage getMessage() {
return message;
}
MqttQoS getQos() {
return qos;
publishRetransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, pendingOperation, ownerId);
publishRetransmissionHandler.setOriginalMessage(message);
pubrelRetransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, pendingOperation, ownerId);
}
void startPublishRetransmissionTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
this.publishRetransmissionHandler.setHandle(((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttPublishMessage(fixedHeader, originalMessage.variableHeader(), this.payload.retain()))));
this.publishRetransmissionHandler.start(eventLoop);
publishRetransmissionHandler.setHandler(((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttPublishMessage(fixedHeader, originalMessage.variableHeader(), payload.retain()))));
publishRetransmissionHandler.start(eventLoop);
}
void onPubackReceived() {
this.publishRetransmissionHandler.stop();
publishRetransmissionHandler.stop();
}
void setPubrelMessage(MqttMessage pubrelMessage) {
this.pubrelRetransmissionHandler.setOriginalMessage(pubrelMessage);
pubrelRetransmissionHandler.setOriginalMessage(pubrelMessage);
}
void startPubrelRetransmissionTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
this.pubrelRetransmissionHandler.setHandle((fixedHeader, originalMessage) ->
pubrelRetransmissionHandler.setHandler((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttMessage(fixedHeader, originalMessage.variableHeader())));
this.pubrelRetransmissionHandler.start(eventLoop);
pubrelRetransmissionHandler.start(eventLoop);
}
void onPubcompReceived() {
this.pubrelRetransmissionHandler.stop();
pubrelRetransmissionHandler.stop();
}
void onChannelClosed() {
this.publishRetransmissionHandler.stop();
this.pubrelRetransmissionHandler.stop();
publishRetransmissionHandler.stop();
pubrelRetransmissionHandler.stop();
if (payload != null) {
payload.release();
}
}
static Builder builder() {
return new Builder();
}
static class Builder {
private int messageId;
private Promise<Void> future;
private ByteBuf payload;
private MqttPublishMessage message;
private MqttQoS qos;
private String ownerId;
private MqttClientConfig.RetransmissionConfig retransmissionConfig;
private PendingOperation pendingOperation;
Builder messageId(int messageId) {
this.messageId = messageId;
return this;
}
Builder future(Promise<Void> future) {
this.future = future;
return this;
}
Builder payload(ByteBuf payload) {
this.payload = payload;
return this;
}
Builder message(MqttPublishMessage message) {
this.message = message;
return this;
}
Builder qos(MqttQoS qos) {
this.qos = qos;
return this;
}
Builder ownerId(String ownerId) {
this.ownerId = ownerId;
return this;
}
Builder retransmissionConfig(MqttClientConfig.RetransmissionConfig retransmissionConfig) {
this.retransmissionConfig = retransmissionConfig;
return this;
}
Builder pendingOperation(PendingOperation pendingOperation) {
this.pendingOperation = pendingOperation;
return this;
}
MqttPendingPublish build() {
return new MqttPendingPublish(messageId, future, payload, message, qos, ownerId, retransmissionConfig, pendingOperation);
}
}
}

119
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingSubscription.java

@ -18,90 +18,123 @@ package org.thingsboard.mqtt;
import io.netty.channel.EventLoop;
import io.netty.handler.codec.mqtt.MqttSubscribeMessage;
import io.netty.util.concurrent.Promise;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Consumer;
import static java.util.Objects.requireNonNullElseGet;
@Getter(AccessLevel.PACKAGE)
final class MqttPendingSubscription {
private final Promise<Void> future;
private final String topic;
private final Set<MqttPendingHandler> handlers = new HashSet<>();
private final Set<MqttPendingHandler> handlers;
private final MqttSubscribeMessage subscribeMessage;
@Getter(AccessLevel.NONE)
private final RetransmissionHandler<MqttSubscribeMessage> retransmissionHandler;
@Setter(AccessLevel.PACKAGE)
private boolean sent = false;
MqttPendingSubscription(Promise<Void> future, String topic, MqttSubscribeMessage message, PendingOperation operation) {
private MqttPendingSubscription(
Promise<Void> future,
String topic,
Set<MqttPendingHandler> handlers,
MqttSubscribeMessage subscribeMessage,
String ownerId,
MqttClientConfig.RetransmissionConfig retransmissionConfig,
PendingOperation operation
) {
this.future = future;
this.topic = topic;
this.subscribeMessage = message;
this.handlers = requireNonNullElseGet(handlers, HashSet::new);
this.subscribeMessage = subscribeMessage;
this.retransmissionHandler = new RetransmissionHandler<>(operation);
this.retransmissionHandler.setOriginalMessage(message);
retransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, operation, ownerId);
retransmissionHandler.setOriginalMessage(subscribeMessage);
}
Promise<Void> getFuture() {
return future;
}
record MqttPendingHandler(MqttHandler handler, boolean once) {}
String getTopic() {
return topic;
void addHandler(MqttHandler handler, boolean once) {
handlers.add(new MqttPendingHandler(handler, once));
}
boolean isSent() {
return sent;
void startRetransmitTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
if (sent) { // If the packet is sent, we can start the retransmission timer
retransmissionHandler.setHandler((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttSubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload())));
retransmissionHandler.start(eventLoop);
}
}
void setSent(boolean sent) {
this.sent = sent;
void onSubackReceived() {
retransmissionHandler.stop();
}
MqttSubscribeMessage getSubscribeMessage() {
return subscribeMessage;
void onChannelClosed() {
retransmissionHandler.stop();
}
void addHandler(MqttHandler handler, boolean once) {
this.handlers.add(new MqttPendingHandler(handler, once));
static Builder builder() {
return new Builder();
}
Set<MqttPendingHandler> getHandlers() {
return handlers;
}
static class Builder {
void startRetransmitTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
if (this.sent) { //If the packet is sent, we can start the retransmit timer
this.retransmissionHandler.setHandle((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttSubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload())));
this.retransmissionHandler.start(eventLoop);
private Promise<Void> future;
private String topic;
private Set<MqttPendingHandler> handlers;
private MqttSubscribeMessage subscribeMessage;
private String ownerId;
private PendingOperation pendingOperation;
private MqttClientConfig.RetransmissionConfig retransmissionConfig;
Builder future(Promise<Void> future) {
this.future = future;
return this;
}
}
void onSubackReceived() {
this.retransmissionHandler.stop();
}
Builder topic(String topic) {
this.topic = topic;
return this;
}
final class MqttPendingHandler {
private final MqttHandler handler;
private final boolean once;
Builder handlers(Set<MqttPendingHandler> handlers) {
this.handlers = handlers;
return this;
}
MqttPendingHandler(MqttHandler handler, boolean once) {
this.handler = handler;
this.once = once;
Builder subscribeMessage(MqttSubscribeMessage subscribeMessage) {
this.subscribeMessage = subscribeMessage;
return this;
}
MqttHandler getHandler() {
return handler;
Builder ownerId(String ownerId) {
this.ownerId = ownerId;
return this;
}
boolean isOnce() {
return once;
Builder retransmissionConfig(MqttClientConfig.RetransmissionConfig retransmissionConfig) {
this.retransmissionConfig = retransmissionConfig;
return this;
}
Builder pendingOperation(PendingOperation pendingOperation) {
this.pendingOperation = pendingOperation;
return this;
}
MqttPendingSubscription build() {
return new MqttPendingSubscription(future, topic, handlers, subscribeMessage, ownerId, retransmissionConfig, pendingOperation);
}
}
void onChannelClosed() {
this.retransmissionHandler.stop();
}
}

85
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingUnsubscription.java

@ -18,43 +18,96 @@ package org.thingsboard.mqtt;
import io.netty.channel.EventLoop;
import io.netty.handler.codec.mqtt.MqttUnsubscribeMessage;
import io.netty.util.concurrent.Promise;
import lombok.AccessLevel;
import lombok.Getter;
import java.util.function.Consumer;
final class MqttPendingUnsubscription{
@Getter(AccessLevel.PACKAGE)
final class MqttPendingUnsubscription {
private final Promise<Void> future;
private final String topic;
@Getter(AccessLevel.NONE)
private final RetransmissionHandler<MqttUnsubscribeMessage> retransmissionHandler;
MqttPendingUnsubscription(Promise<Void> future, String topic, MqttUnsubscribeMessage unsubscribeMessage, PendingOperation operation) {
private MqttPendingUnsubscription(
Promise<Void> future,
String topic,
MqttUnsubscribeMessage unsubscribeMessage,
String ownerId,
MqttClientConfig.RetransmissionConfig retransmissionConfig,
PendingOperation operation
) {
this.future = future;
this.topic = topic;
this.retransmissionHandler = new RetransmissionHandler<>(operation);
this.retransmissionHandler.setOriginalMessage(unsubscribeMessage);
retransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, operation, ownerId);
retransmissionHandler.setOriginalMessage(unsubscribeMessage);
}
Promise<Void> getFuture() {
return future;
void startRetransmissionTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
retransmissionHandler.setHandler((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttUnsubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload())));
retransmissionHandler.start(eventLoop);
}
String getTopic() {
return topic;
void onUnsubackReceived() {
retransmissionHandler.stop();
}
void startRetransmissionTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
this.retransmissionHandler.setHandle((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttUnsubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload())));
this.retransmissionHandler.start(eventLoop);
void onChannelClosed() {
retransmissionHandler.stop();
}
void onUnsubackReceived(){
this.retransmissionHandler.stop();
static Builder builder() {
return new Builder();
}
void onChannelClosed(){
this.retransmissionHandler.stop();
static class Builder {
private Promise<Void> future;
private String topic;
private MqttUnsubscribeMessage unsubscribeMessage;
private String ownerId;
private PendingOperation pendingOperation;
private MqttClientConfig.RetransmissionConfig retransmissionConfig;
Builder future(Promise<Void> future) {
this.future = future;
return this;
}
Builder topic(String topic) {
this.topic = topic;
return this;
}
Builder unsubscribeMessage(MqttUnsubscribeMessage unsubscribeMessage) {
this.unsubscribeMessage = unsubscribeMessage;
return this;
}
Builder ownerId(String ownerId) {
this.ownerId = ownerId;
return this;
}
Builder retransmissionConfig(MqttClientConfig.RetransmissionConfig retransmissionConfig) {
this.retransmissionConfig = retransmissionConfig;
return this;
}
Builder pendingOperation(PendingOperation pendingOperation) {
this.pendingOperation = pendingOperation;
return this;
}
MqttPendingUnsubscription build() {
return new MqttPendingUnsubscription(future, topic, unsubscribeMessage, ownerId, retransmissionConfig, pendingOperation);
}
}
}

17
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPingHandler.java

@ -42,12 +42,11 @@ final class MqttPingHandler extends ChannelInboundHandlerAdapter {
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (!(msg instanceof MqttMessage)) {
public void channelRead(ChannelHandlerContext ctx, Object msg) {
if (!(msg instanceof MqttMessage message)) {
ctx.fireChannelRead(msg);
return;
}
MqttMessage message = (MqttMessage) msg;
if (message.fixedHeader().messageType() == MqttMessageType.PINGREQ) {
this.handlePingReq(ctx.channel());
} else if (message.fixedHeader().messageType() == MqttMessageType.PINGRESP) {
@ -61,28 +60,29 @@ final class MqttPingHandler extends ChannelInboundHandlerAdapter {
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
super.userEventTriggered(ctx, evt);
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (evt instanceof IdleStateEvent event) {
switch (event.state()) {
case READER_IDLE:
log.debug("[{}] No reads were performed for specified period for channel {}", event.state(), ctx.channel().id());
this.sendPingReq(ctx.channel());
this.sendPingReq(ctx.channel(), event);
break;
case WRITER_IDLE:
log.debug("[{}] No writes were performed for specified period for channel {}", event.state(), ctx.channel().id());
this.sendPingReq(ctx.channel());
this.sendPingReq(ctx.channel(), event);
break;
}
}
}
private void sendPingReq(Channel channel) {
private void sendPingReq(Channel channel, IdleStateEvent idleEvent) {
log.trace("[{}] Sending ping request", channel.id());
MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.PINGREQ, false, MqttQoS.AT_MOST_ONCE, false, 0);
channel.writeAndFlush(new MqttMessage(fixedHeader));
if (this.pingRespTimeout == null) {
log.trace("[{}] Scheduling disconnect due to {}", channel.id(), idleEvent);
this.pingRespTimeout = channel.eventLoop().schedule(() -> {
log.trace("[{}] Sending disconnect due to {}", channel.id(), idleEvent);
MqttFixedHeader fixedHeader2 = new MqttFixedHeader(MqttMessageType.DISCONNECT, false, MqttQoS.AT_MOST_ONCE, false, 0);
channel.writeAndFlush(new MqttMessage(fixedHeader2)).addListener(ChannelFutureListener.CLOSE);
//TODO: what do when the connection is closed ?
@ -99,6 +99,7 @@ final class MqttPingHandler extends ChannelInboundHandlerAdapter {
private void handlePingResp(Channel channel) {
log.trace("[{}] Handling ping response", channel.id());
if (this.pingRespTimeout != null && !this.pingRespTimeout.isCancelled() && !this.pingRespTimeout.isDone()) {
log.trace("[{}] Cancelling disconnect due to idle event because ping response was received", channel.id());
this.pingRespTimeout.cancel(true);
this.pingRespTimeout = null;
}

4
netty-mqtt/src/main/java/org/thingsboard/mqtt/PendingOperation.java

@ -17,6 +17,8 @@ package org.thingsboard.mqtt;
public interface PendingOperation {
boolean isCanceled();
boolean isCancelled();
void onMaxRetransmissionAttemptsReached();
}

101
netty-mqtt/src/main/java/org/thingsboard/mqtt/RetransmissionHandler.java

@ -18,66 +18,119 @@ package org.thingsboard.mqtt;
import io.netty.channel.EventLoop;
import io.netty.handler.codec.mqtt.MqttFixedHeader;
import io.netty.handler.codec.mqtt.MqttMessage;
import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader;
import io.netty.handler.codec.mqtt.MqttMessageType;
import io.netty.handler.codec.mqtt.MqttPublishVariableHeader;
import io.netty.handler.codec.mqtt.MqttQoS;
import io.netty.util.concurrent.ScheduledFuture;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
@Slf4j
@RequiredArgsConstructor
final class RetransmissionHandler<T extends MqttMessage> {
private volatile boolean stopped;
private final MqttClientConfig.RetransmissionConfig config;
private final PendingOperation pendingOperation;
private volatile boolean stopped;
private ScheduledFuture<?> timer;
private int timeout = 10;
private int attemptCount = 0;
@Setter
private BiConsumer<MqttFixedHeader, T> handler;
// the three fields below are used for logging only
private final String ownerId;
private String originalMessageId;
private long totalWaitingTimeMillis;
private T originalMessage;
void setOriginalMessage(T originalMessage) {
this.originalMessage = originalMessage;
var variableHeader = originalMessage.variableHeader();
if (variableHeader instanceof MqttMessageIdVariableHeader messageIdVariableHeader) {
originalMessageId = String.valueOf(messageIdVariableHeader.messageId());
} else if (variableHeader instanceof MqttPublishVariableHeader publishVariableHeader) {
originalMessageId = String.valueOf(publishVariableHeader.packetId());
} else {
originalMessageId = "N/A";
}
}
void start(EventLoop eventLoop) {
if (eventLoop == null) {
throw new NullPointerException("eventLoop");
}
if (this.handler == null) {
if (handler == null) {
throw new NullPointerException("handler");
}
this.timeout = 10;
this.startTimer(eventLoop);
log.debug("{}MessageID[{}] Starting retransmission handler", ownerId, originalMessageId);
startTimer(eventLoop);
}
private void startTimer(EventLoop eventLoop) {
if (stopped || pendingOperation.isCanceled()) {
if (stopped || pendingOperation.isCancelled()) {
return;
}
this.timer = eventLoop.schedule(() -> {
if (stopped || pendingOperation.isCanceled()) {
// Calculate the base delay using exponential backoff.
// For attemptCount == 0, delay = initial delay; for each subsequent attempt, the base delay doubles.
long baseDelay = config.initialDelayMillis() * (long) Math.pow(2, attemptCount);
// Apply jitter: random factor between (1 - jitterFactor) and (1 + jitterFactor).
double minFactor = 1.0 - config.jitterFactor();
double maxFactor = 1.0 + config.jitterFactor();
double randomFactor = config.jitterFactor() == 0 ? 1 : ThreadLocalRandom.current().nextDouble(minFactor, maxFactor);
long delayMillisWithJitter = (long) (baseDelay * randomFactor);
totalWaitingTimeMillis += delayMillisWithJitter;
timer = eventLoop.schedule(() -> {
if (stopped || pendingOperation.isCancelled()) {
return;
}
this.timeout += 5;
boolean isDup = this.originalMessage.fixedHeader().isDup();
if (this.originalMessage.fixedHeader().messageType() == MqttMessageType.PUBLISH && this.originalMessage.fixedHeader().qosLevel() != MqttQoS.AT_MOST_ONCE) {
isDup = true;
attemptCount++;
if (attemptCount > config.maxAttempts()) {
log.debug(
"{}MessageID[{}] Gave up after {} retransmission attempts; waited a total of {} ms without receiving acknowledgement",
ownerId, originalMessageId, config.maxAttempts(), totalWaitingTimeMillis
);
stop();
pendingOperation.onMaxRetransmissionAttemptsReached();
return;
}
MqttFixedHeader fixedHeader = new MqttFixedHeader(this.originalMessage.fixedHeader().messageType(), isDup, this.originalMessage.fixedHeader().qosLevel(), this.originalMessage.fixedHeader().isRetain(), this.originalMessage.fixedHeader().remainingLength());
handler.accept(fixedHeader, originalMessage);
log.debug("{}MessageID[{}] Retransmission attempt #{} out of {}", ownerId, originalMessageId, attemptCount, config.maxAttempts());
var originalFixedHeader = originalMessage.fixedHeader();
var newFixedHeader = new MqttFixedHeader(
originalFixedHeader.messageType(),
isDup(originalFixedHeader),
originalFixedHeader.qosLevel(),
originalFixedHeader.isRetain(),
originalFixedHeader.remainingLength()
);
handler.accept(newFixedHeader, originalMessage);
startTimer(eventLoop);
}, timeout, TimeUnit.SECONDS);
}, delayMillisWithJitter, TimeUnit.MILLISECONDS);
}
private static boolean isDup(MqttFixedHeader originalFixedHeader) {
return originalFixedHeader.isDup() || (originalFixedHeader.messageType() == MqttMessageType.PUBLISH && originalFixedHeader.qosLevel() != MqttQoS.AT_MOST_ONCE);
}
void stop() {
log.debug("{}MessageID[{}] Stopping retransmission handler", ownerId, originalMessageId);
stopped = true;
if (this.timer != null) {
this.timer.cancel(true);
if (timer != null) {
timer.cancel(true);
}
}
void setHandle(BiConsumer<MqttFixedHeader, T> runnable) {
this.handler = runnable;
}
void setOriginalMessage(T originalMessage) {
this.originalMessage = originalMessage;
}
}

210
netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttClientTest.java

@ -0,0 +1,210 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed 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.thingsboard.mqtt;
import com.google.common.util.concurrent.Futures;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.handler.codec.mqtt.MqttConnectReturnCode;
import io.netty.handler.codec.mqtt.MqttMessageType;
import io.netty.handler.codec.mqtt.MqttQoS;
import io.netty.util.ResourceLeakDetector;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.Promise;
import lombok.extern.slf4j.Slf4j;
import org.awaitility.Awaitility;
import org.awaitility.core.ConditionTimeoutException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.hivemq.HiveMQContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import org.thingsboard.common.util.AbstractListeningExecutor;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
@Testcontainers
class MqttClientTest {
final int randomPort = 0;
@Container
HiveMQContainer broker = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq-ce").withTag("2025.2"));
MqttTestProxy proxy;
MqttClient client;
AbstractListeningExecutor handlerExecutor;
@BeforeAll
static void init() {
ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.PARANOID);
}
@BeforeEach
void setup() {
handlerExecutor = new AbstractListeningExecutor() {
@Override
protected int getThreadPollSize() {
return 1;
}
};
handlerExecutor.init();
}
@AfterEach
void cleanup() {
if (client != null) {
client.disconnect();
client = null;
}
if (proxy != null) {
proxy.stop();
proxy = null;
}
handlerExecutor.destroy();
handlerExecutor = null;
}
@Test
void testConnectToBroker() {
// GIVEN
var clientConfig = new MqttClientConfig();
clientConfig.setOwnerId("Test[ConnectToBroker]");
clientConfig.setClientId("connect");
client = MqttClient.create(clientConfig, null, handlerExecutor);
// WHEN
Promise<MqttConnectResult> connectFuture = client.connect(broker.getHost(), broker.getMqttPort());
// THEN
assertThat(connectFuture).isNotNull();
Awaitility.await("waiting for client to connect")
.atMost(Duration.ofSeconds(10L))
.until(connectFuture::isDone);
assertThat(connectFuture.isSuccess()).isTrue();
MqttConnectResult actualConnectResult = connectFuture.getNow();
assertThat(actualConnectResult).isNotNull();
assertThat(actualConnectResult.isSuccess()).isTrue();
assertThat(actualConnectResult.getReturnCode()).isEqualTo(MqttConnectReturnCode.CONNECTION_ACCEPTED);
assertThat(client.isConnected()).isTrue();
}
@Test
void testDisconnectDueToKeepAliveIfNoActivity() {
// GIVEN
proxy = MqttTestProxy.builder()
.localPort(randomPort)
.brokerHost(broker.getHost())
.brokerPort(broker.getMqttPort())
.brokerToClientInterceptor(msg -> msg.fixedHeader().messageType() != MqttMessageType.PINGRESP) // drop all ping responses to simulate broker down
.build();
int idleTimeoutSeconds = 2;
var clientConfig = new MqttClientConfig();
clientConfig.setOwnerId("Test[KeepAliveDisconnect]");
clientConfig.setClientId("no-activity-disconnect");
clientConfig.setTimeoutSeconds(idleTimeoutSeconds);
clientConfig.setReconnect(false); // disable auto reconnect
client = MqttClient.create(clientConfig, null, handlerExecutor);
// WHEN-THEN
connect(broker.getHost(), proxy.getPort());
// no activity...
Awaitility.await("waiting for client to disconnect")
.pollDelay(Duration.ofSeconds(idleTimeoutSeconds * 2)) // 2 seconds to wait for the first idle event and then 2 seconds for scheduled disconnect to fire
.atMost(Duration.ofSeconds(10))
.untilAsserted(() -> assertThat(client.isConnected()).isFalse());
}
@Test
void testRetransmission() {
// GIVEN
proxy = MqttTestProxy.builder()
.localPort(randomPort)
.brokerHost(broker.getHost())
.brokerPort(broker.getMqttPort())
.brokerToClientInterceptor(msg -> msg.fixedHeader().messageType() != MqttMessageType.PUBACK) // drop all pubacks to allow retransmission to happen
.build();
// create client
var clientConfig = new MqttClientConfig();
clientConfig.setOwnerId("Test[Retransmission]");
clientConfig.setClientId("retransmission");
clientConfig.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig(1, 1000L, 0d));
client = MqttClient.create(clientConfig, null, handlerExecutor);
// connect to a broker
connect(broker.getHost(), proxy.getPort());
// subscribe to a topic
String topic = "test-topic";
List<ByteBuf> receivedMessages = Collections.synchronizedList(new ArrayList<>(2));
Future<Void> subscribeFuture = client.on(topic, (__, payload) -> {
receivedMessages.add(payload);
return Futures.immediateVoidFuture();
});
Awaitility.await("waiting for client to subscribe to a topic")
.atMost(Duration.ofSeconds(10L))
.until(subscribeFuture::isDone);
// WHEN
// publish a message
ByteBuf message = PooledByteBufAllocator.DEFAULT.buffer().writeBytes("test message".getBytes(StandardCharsets.UTF_8));
client.publish(topic, message, MqttQoS.AT_LEAST_ONCE);
// THEN
// wait enough time so that retransmission happens and stops
// if retransmission works incorrectly waiting 10 seconds allows for additional retransmissions to happen
try {
Awaitility.await("wait up to 10s, stop early if too many messages")
.atMost(Duration.ofSeconds(10L))
.pollInterval(Duration.ofMillis(100))
.until(() -> receivedMessages.size() > 2);
} catch (ConditionTimeoutException __) {
// didn't exceed 2 messages
}
assertThat(receivedMessages).size().describedAs("incorrect number of messages received, expected 2 (original plus one retransmitted)").isEqualTo(2);
}
private void connect(String host, int port) {
Promise<MqttConnectResult> connectFuture = client.connect(host, port);
Awaitility.await("waiting for client to connect")
.atMost(Duration.ofSeconds(10L))
.until(connectFuture::isSuccess);
}
}

63
netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttPingHandlerTest.java

@ -1,63 +0,0 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed 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.thingsboard.mqtt;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.DefaultEventLoop;
import io.netty.handler.timeout.IdleStateEvent;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.concurrent.TimeUnit;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.after;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MqttPingHandlerTest {
static final int KEEP_ALIVE_SECONDS = 0;
static final int PROCESS_SEND_DISCONNECT_MSG_TIME_MS = 500;
MqttPingHandler mqttPingHandler;
@BeforeEach
void setUp() {
mqttPingHandler = new MqttPingHandler(KEEP_ALIVE_SECONDS);
}
@Test
void givenChannelReaderIdleState_whenNoPingResponse_thenDisconnectClient() throws Exception {
ChannelHandlerContext ctx = mock(ChannelHandlerContext.class);
Channel channel = mock(Channel.class);
when(ctx.channel()).thenReturn(channel);
when(channel.eventLoop()).thenReturn(new DefaultEventLoop());
ChannelFuture channelFuture = mock(ChannelFuture.class);
when(channel.writeAndFlush(any())).thenReturn(channelFuture);
mqttPingHandler.userEventTriggered(ctx, IdleStateEvent.FIRST_READER_IDLE_STATE_EVENT);
verify(
channelFuture,
after(TimeUnit.SECONDS.toMillis(KEEP_ALIVE_SECONDS) + PROCESS_SEND_DISCONNECT_MSG_TIME_MS)
).addListener(eq(ChannelFutureListener.CLOSE));
}
}

202
netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttTestProxy.java

@ -0,0 +1,202 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed 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.thingsboard.mqtt;
import io.netty.bootstrap.Bootstrap;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.mqtt.MqttDecoder;
import io.netty.handler.codec.mqtt.MqttEncoder;
import io.netty.handler.codec.mqtt.MqttMessage;
import io.netty.util.ReferenceCountUtil;
import lombok.extern.slf4j.Slf4j;
import java.net.InetSocketAddress;
import java.util.function.Predicate;
@Slf4j
public class MqttTestProxy {
private final EventLoopGroup bossGroup;
private final EventLoopGroup workerGroup;
private Channel clientToProxyChannel;
private Channel proxyToBrokerChannel;
private final int assignedPort;
private boolean stopped;
private final Predicate<MqttMessage> brokerToClientInterceptor;
private MqttTestProxy(Builder builder) {
log.info("Starting MQTT proxy...");
brokerToClientInterceptor = builder.brokerToClientInterceptor != null ? builder.brokerToClientInterceptor : msg -> true;
bossGroup = new NioEventLoopGroup(1);
workerGroup = new NioEventLoopGroup(1);
ServerBootstrap proxyBootstrap = new ServerBootstrap();
proxyBootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel channel) {
clientToProxyChannel = channel;
clientToProxyChannel.config().setAutoRead(false); // do not accept data before we connected to a broker
connectToBroker(builder.brokerHost, builder.brokerPort).addListener(future -> {
if (future.isSuccess()) {
clientToProxyChannel.pipeline().addLast("mqttDecoder", new MqttDecoder());
clientToProxyChannel.pipeline().addLast("mqttToBroker", new MqttRelayHandler(proxyToBrokerChannel, null));
clientToProxyChannel.pipeline().addLast("mqttEncoder", MqttEncoder.INSTANCE);
clientToProxyChannel.config().setAutoRead(true); // start accepting data for a client
} else {
log.error("Failed to connect to broker", future.cause());
clientToProxyChannel.close();
}
});
}
});
try {
Channel proxyChannel = proxyBootstrap.bind(builder.localPort).sync().channel();
assignedPort = ((InetSocketAddress) proxyChannel.localAddress()).getPort();
} catch (Exception e) {
log.error("Failed to start MQTT proxy", e);
throw new RuntimeException("Failed to start MQTT proxy", e);
}
log.info("MQTT proxy started on port {}", assignedPort);
}
private ChannelFuture connectToBroker(String brokerHost, int brokerPort) {
Bootstrap proxyToBrokerBootstrap = new Bootstrap();
proxyToBrokerBootstrap.group(workerGroup)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel channel) {
proxyToBrokerChannel = channel;
proxyToBrokerChannel.pipeline().addLast(new MqttDecoder());
proxyToBrokerChannel.pipeline().addLast("mqttToClient", new MqttRelayHandler(clientToProxyChannel, brokerToClientInterceptor));
proxyToBrokerChannel.pipeline().addLast(MqttEncoder.INSTANCE);
}
});
return proxyToBrokerBootstrap.connect(brokerHost, brokerPort);
}
private static class MqttRelayHandler extends SimpleChannelInboundHandler<MqttMessage> {
private final Channel targetChannel;
private final Predicate<MqttMessage> interceptor;
private MqttRelayHandler(Channel targetChannel, Predicate<MqttMessage> interceptor) {
this.targetChannel = targetChannel;
this.interceptor = interceptor;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) {
log.debug("Received message: {}", msg.fixedHeader().messageType());
if (interceptor == null || interceptor.test(msg)) {
if (targetChannel.isActive()) {
targetChannel.writeAndFlush(ReferenceCountUtil.retain(msg));
}
} else {
log.info("Dropping message: {}", msg.fixedHeader().messageType());
}
}
}
public void stop() {
if (stopped) {
log.info("MQTT proxy was already stopped");
return;
}
stopped = true;
log.info("Stopping MQTT proxy...");
if (clientToProxyChannel != null) {
clientToProxyChannel.close();
}
if (proxyToBrokerChannel != null) {
proxyToBrokerChannel.close();
}
if (bossGroup != null) {
bossGroup.shutdownGracefully();
}
if (workerGroup != null) {
workerGroup.shutdownGracefully();
}
log.info("MQTT proxy stopped");
}
public int getPort() {
return assignedPort;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private int localPort;
private String brokerHost;
private int brokerPort;
private Predicate<MqttMessage> brokerToClientInterceptor;
public Builder localPort(int localPort) {
this.localPort = localPort;
return this;
}
public Builder brokerHost(String brokerHost) {
this.brokerHost = brokerHost;
return this;
}
public Builder brokerPort(int brokerPort) {
this.brokerPort = brokerPort;
return this;
}
public Builder brokerToClientInterceptor(Predicate<MqttMessage> interceptor) {
this.brokerToClientInterceptor = interceptor;
return this;
}
public MqttTestProxy build() {
return new MqttTestProxy(this);
}
}
}

151
netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java

@ -1,151 +0,0 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed 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.thingsboard.mqtt.integration;
import io.netty.buffer.Unpooled;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.handler.codec.mqtt.MqttMessageType;
import io.netty.handler.codec.mqtt.MqttQoS;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.Promise;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.thingsboard.common.util.AbstractListeningExecutor;
import org.thingsboard.mqtt.MqttClient;
import org.thingsboard.mqtt.MqttClientConfig;
import org.thingsboard.mqtt.MqttConnectResult;
import org.thingsboard.mqtt.integration.server.MqttServer;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@ResourceLock("port8885") // test MQTT server port
@Slf4j
public class MqttIntegrationTest {
static final String MQTT_HOST = "localhost";
static final int KEEPALIVE_TIMEOUT_SECONDS = 2;
static final long RECONNECT_DELAY_SECONDS = 10L;
EventLoopGroup eventLoopGroup;
MqttServer mqttServer;
MqttClient mqttClient;
AbstractListeningExecutor handlerExecutor;
@BeforeEach
public void init() throws Exception {
this.handlerExecutor = new AbstractListeningExecutor() {
@Override
protected int getThreadPollSize() {
return 4;
}
};
handlerExecutor.init();
this.eventLoopGroup = new NioEventLoopGroup();
this.mqttServer = new MqttServer();
this.mqttServer.init();
}
@AfterEach
public void destroy() throws InterruptedException {
if (this.mqttClient != null) {
this.mqttClient.disconnect();
}
if (this.mqttServer != null) {
this.mqttServer.shutdown();
}
if (this.eventLoopGroup != null) {
this.eventLoopGroup.shutdownGracefully(0, 0, TimeUnit.MILLISECONDS);
}
if (this.handlerExecutor != null) {
this.handlerExecutor.destroy();
}
}
@Test
public void givenActiveMqttClient_whenNoActivityForKeepAliveTimeout_thenDisconnectClient() throws Throwable {
//given
this.mqttClient = initClient();
log.warn("Sending publish messages...");
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
Thread.sleep(30);
Future<Void> pubFuture = publishMsg();
pubFuture.addListener(future -> latch.countDown());
}
log.warn("Waiting for messages acknowledgments...");
boolean awaitResult = latch.await(10, TimeUnit.SECONDS);
Assertions.assertTrue(awaitResult);
log.warn("Messages are delivered successfully...");
//when
log.warn("Starting idle period...");
Thread.sleep(5000);
//then
List<MqttMessageType> allReceivedEvents = this.mqttServer.getEventsFromClient();
long disconnectCount = allReceivedEvents.stream().filter(type -> type == MqttMessageType.DISCONNECT).count();
Assertions.assertEquals(1, disconnectCount);
}
private Future<Void> publishMsg() {
return this.mqttClient.publish(
"test/topic",
Unpooled.wrappedBuffer("payload".getBytes(StandardCharsets.UTF_8)),
MqttQoS.AT_MOST_ONCE);
}
private MqttClient initClient() throws Exception {
MqttClientConfig config = new MqttClientConfig();
config.setOwnerId("MqttIntegrationTest");
config.setTimeoutSeconds(KEEPALIVE_TIMEOUT_SECONDS);
config.setReconnectDelay(RECONNECT_DELAY_SECONDS);
MqttClient client = MqttClient.create(config, null, handlerExecutor);
client.setEventLoop(this.eventLoopGroup);
Promise<MqttConnectResult> connectFuture = client.connect(MQTT_HOST, this.mqttServer.getMqttPort());
String hostPort = MQTT_HOST + ":" + this.mqttServer.getMqttPort();
MqttConnectResult result;
try {
result = connectFuture.get(10, TimeUnit.SECONDS);
} catch (TimeoutException ex) {
connectFuture.cancel(true);
client.disconnect();
throw new RuntimeException(String.format("Failed to connect to MQTT server at %s.", hostPort));
}
if (!result.isSuccess()) {
connectFuture.cancel(true);
client.disconnect();
throw new RuntimeException(String.format("Failed to connect to MQTT server at %s. Result code is: %s", hostPort, result.getReturnCode()));
}
return client;
}
}

84
netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttServer.java

@ -1,84 +0,0 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed 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.thingsboard.mqtt.integration.server;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.mqtt.MqttDecoder;
import io.netty.handler.codec.mqtt.MqttEncoder;
import io.netty.handler.codec.mqtt.MqttMessageType;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
@Slf4j
public class MqttServer {
@Getter
private final List<MqttMessageType> eventsFromClient = new CopyOnWriteArrayList<>();
@Getter
private final int mqttPort = 8885;
private Channel serverChannel;
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
public void init() throws Exception {
log.info("Starting MQTT server on port {}...", mqttPort);
bossGroup = new NioEventLoopGroup();
workerGroup = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast("decoder", new MqttDecoder(65536));
pipeline.addLast("encoder", MqttEncoder.INSTANCE);
MqttTransportHandler handler = new MqttTransportHandler(eventsFromClient);
pipeline.addLast(handler);
ch.closeFuture().addListener(handler);
}
})
.childOption(ChannelOption.SO_KEEPALIVE, true);
serverChannel = b.bind(mqttPort).sync().channel();
log.info("Mqtt transport started!");
}
public void shutdown() throws InterruptedException {
log.info("Stopping MQTT transport!");
try {
serverChannel.close().sync();
} finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
log.info("MQTT transport stopped!");
}
}

141
netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttTransportHandler.java

@ -1,141 +0,0 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed 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.thingsboard.mqtt.integration.server;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.mqtt.MqttConnAckMessage;
import io.netty.handler.codec.mqtt.MqttConnAckVariableHeader;
import io.netty.handler.codec.mqtt.MqttConnectMessage;
import io.netty.handler.codec.mqtt.MqttConnectReturnCode;
import io.netty.handler.codec.mqtt.MqttFixedHeader;
import io.netty.handler.codec.mqtt.MqttMessage;
import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader;
import io.netty.handler.codec.mqtt.MqttMessageType;
import io.netty.handler.codec.mqtt.MqttPubAckMessage;
import io.netty.handler.codec.mqtt.MqttPublishMessage;
import io.netty.util.ReferenceCountUtil;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.GenericFutureListener;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.UUID;
import static io.netty.handler.codec.mqtt.MqttMessageType.CONNACK;
import static io.netty.handler.codec.mqtt.MqttMessageType.CONNECT;
import static io.netty.handler.codec.mqtt.MqttMessageType.DISCONNECT;
import static io.netty.handler.codec.mqtt.MqttMessageType.PINGREQ;
import static io.netty.handler.codec.mqtt.MqttMessageType.PUBACK;
import static io.netty.handler.codec.mqtt.MqttMessageType.PUBLISH;
import static io.netty.handler.codec.mqtt.MqttQoS.AT_MOST_ONCE;
@Slf4j
public class MqttTransportHandler extends ChannelInboundHandlerAdapter implements GenericFutureListener<Future<? super Void>> {
private final List<MqttMessageType> eventsFromClient;
private final UUID sessionId;
MqttTransportHandler(List<MqttMessageType> eventsFromClient) {
this.sessionId = UUID.randomUUID();
this.eventsFromClient = eventsFromClient;
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
log.trace("[{}] Processing msg: {}", sessionId, msg);
try {
if (msg instanceof MqttMessage) {
MqttMessage message = (MqttMessage) msg;
if (message.decoderResult().isSuccess()) {
processMqttMsg(ctx, message);
} else {
log.error("[{}] Message decoding failed: {}", sessionId, message.decoderResult().cause().getMessage());
ctx.close();
}
} else {
log.debug("[{}] Received non mqtt message: {}", sessionId, msg.getClass().getSimpleName());
ctx.close();
}
} finally {
ReferenceCountUtil.safeRelease(msg);
}
}
void processMqttMsg(ChannelHandlerContext ctx, MqttMessage msg) {
if (msg.fixedHeader() == null) {
ctx.close();
return;
}
switch (msg.fixedHeader().messageType()) {
case CONNECT:
eventsFromClient.add(CONNECT);
processConnect(ctx, (MqttConnectMessage) msg);
break;
case DISCONNECT:
eventsFromClient.add(DISCONNECT);
ctx.close();
break;
case PUBLISH:
// QoS 0 and 1 supported only here
eventsFromClient.add(PUBLISH);
MqttPublishMessage mqttPubMsg = (MqttPublishMessage) msg;
ack(ctx, mqttPubMsg.variableHeader().packetId());
break;
case PINGREQ:
// We will not handle PINGREQ and will not send any PINGRESP to simulate the MQTT server is down
eventsFromClient.add(PINGREQ);
break;
default:
break;
}
}
void processConnect(ChannelHandlerContext ctx, MqttConnectMessage msg) {
String userName = msg.payload().userName();
String clientId = msg.payload().clientIdentifier();
log.warn("[{}][{}] Processing connect msg for client: {}!", sessionId, userName, clientId);
ctx.writeAndFlush(createMqttConnAckMsg(msg));
}
private MqttConnAckMessage createMqttConnAckMsg(MqttConnectMessage msg) {
MqttFixedHeader mqttFixedHeader =
new MqttFixedHeader(CONNACK, false, AT_MOST_ONCE, false, 0);
MqttConnAckVariableHeader mqttConnAckVariableHeader =
new MqttConnAckVariableHeader(MqttConnectReturnCode.CONNECTION_ACCEPTED, !msg.variableHeader().isCleanSession());
return new MqttConnAckMessage(mqttFixedHeader, mqttConnAckVariableHeader);
}
private void ack(ChannelHandlerContext ctx, int msgId) {
if (msgId > 0) {
ctx.writeAndFlush(createMqttPubAckMsg(msgId));
}
}
public static MqttPubAckMessage createMqttPubAckMsg(int requestId) {
MqttFixedHeader mqttFixedHeader =
new MqttFixedHeader(PUBACK, false, AT_MOST_ONCE, false, 0);
MqttMessageIdVariableHeader mqttMsgIdVariableHeader =
MqttMessageIdVariableHeader.from(requestId);
return new MqttPubAckMessage(mqttFixedHeader, mqttMsgIdVariableHeader);
}
@Override
public void operationComplete(Future<? super Void> future) {
log.trace("[{}] Channel closed!", sessionId);
}
}

3
netty-mqtt/src/test/resources/junit-platform.properties

@ -1,3 +0,0 @@
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.mode.classes.default = concurrent

6
pom.xml

@ -1957,6 +1957,12 @@
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>hivemq</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>

26
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MqttClientSettings.java

@ -0,0 +1,26 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed 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.thingsboard.rule.engine.api;
public interface MqttClientSettings {
int getRetransmissionMaxAttempts();
long getRetransmissionInitialDelayMillis();
double getRetransmissionJitterFactor();
}

5
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java

@ -416,4 +416,9 @@ public interface TbContext {
EventService getEventService();
AuditLogService getAuditLogService();
// Configuration parameters for the MQTT client that is used in the MQTT node and Azure IoT hub node
MqttClientSettings getMqttClientSettings();
}

8
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java

@ -26,6 +26,7 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.mqtt.MqttClient;
import org.thingsboard.mqtt.MqttClientConfig;
import org.thingsboard.mqtt.MqttConnectResult;
import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.RuleNode;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
@ -126,6 +127,13 @@ public class TbMqttNode extends TbAbstractExternalNode {
}
config.setCleanSession(this.mqttNodeConfiguration.isCleanSession());
MqttClientSettings mqttClientSettings = ctx.getMqttClientSettings();
config.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig(
mqttClientSettings.getRetransmissionMaxAttempts(),
mqttClientSettings.getRetransmissionInitialDelayMillis(),
mqttClientSettings.getRetransmissionJitterFactor()
));
prepareMqttClientConfig(config);
MqttClient client = getMqttClient(ctx, config);
client.setEventLoop(ctx.getSharedEventLoop());

18
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java

@ -40,6 +40,7 @@ import org.thingsboard.mqtt.MqttClient;
import org.thingsboard.mqtt.MqttClientConfig;
import org.thingsboard.mqtt.MqttConnectResult;
import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest;
import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNode;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
@ -80,6 +81,7 @@ import static org.mockito.BDDMockito.spy;
import static org.mockito.BDDMockito.then;
import static org.mockito.BDDMockito.willAnswer;
import static org.mockito.BDDMockito.willReturn;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class)
public class TbMqttNodeTest extends AbstractRuleNodeUpgradeTest {
@ -106,6 +108,22 @@ public class TbMqttNodeTest extends AbstractRuleNodeUpgradeTest {
protected void setUp() {
mqttNode = spy(new TbMqttNode());
mqttNodeConfig = new TbMqttNodeConfiguration().defaultConfiguration();
lenient().when(ctxMock.getMqttClientSettings()).thenReturn(new MqttClientSettings() {
@Override
public int getRetransmissionMaxAttempts() {
return 3;
}
@Override
public long getRetransmissionInitialDelayMillis() {
return 5000L;
}
@Override
public double getRetransmissionJitterFactor() {
return 0.15;
}
});
}
@Test

Loading…
Cancel
Save