Browse Source

Merge 4c0bdd32b9 into be70822bc6

pull/15884/merge
Shvaika Dmytro 2 days ago
committed by GitHub
parent
commit
016e2deb82
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      application/src/main/resources/thingsboard.yml
  2. 59
      application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/AbstractMqttServerSideRpcIntegrationTest.java
  3. 75
      application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttGatewayServerSideRpcDeliveryTimeoutIntegrationTest.java
  4. 5
      application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcJsonIntegrationTest.java
  5. 19
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java
  6. 26
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewayDeviceSessionContext.java
  7. 32
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java
  8. 35
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/MqttRpcStatusUtil.java

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

@ -614,6 +614,13 @@ actors:
# <ul><li> QoS level 0: This feature does not apply, as no acknowledgment is expected, and therefore no timeout is triggered.</li>
# <li> QoS level 1: This feature applies, as an acknowledgment is expected.</li>
# <li> QoS level 2: Unsupported.</li></ul></li>
# <li> For MQTT Gateway (connected sub-devices): the same QoS rules as MQTT transport apply
# (only a QoS level 1 PUBACK triggers a delivery timeout). However, the gateway's own transport
# session is never closed, so the gateway connection and its other sub-devices stay unaffected.
# Only the sub-device session is deregistered and the RPC is reverted to the queued state. It is
# redelivered automatically once the sub-device session is re-registered, which happens on the next
# gateway message for that device (connect, telemetry, or attribute publish): the RPC subscription
# is reapplied and the queued RPC is resent. Until then the RPC remains queued.</li>
# <li> For CoAP transport:
# <ul><li> Confirmable requests: This feature applies, as delivery confirmation is expected.</li>
# <li> Non-confirmable requests: This feature does not apply, as no delivery acknowledgment is expected.</li></ul></li>

59
application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/AbstractMqttServerSideRpcIntegrationTest.java

@ -38,6 +38,7 @@ import org.thingsboard.server.common.data.device.profile.MqttDeviceProfileTransp
import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadConfiguration;
import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration;
import org.thingsboard.server.common.data.rpc.Rpc;
import org.thingsboard.server.common.data.rpc.RpcStatus;
import org.thingsboard.server.common.msg.session.FeatureType;
import org.thingsboard.server.gen.transport.TransportApiProtos;
import org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest;
@ -331,6 +332,62 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractM
assertEquals(MqttQoS.AT_MOST_ONCE.value(), callback.getMessageArrivedQoS());
}
protected void validateGatewayPersistentRpcDelivered(String deviceName) throws Exception {
GatewayRpcSession session = connectGatewayAndSubscribeForRpc(deviceName, false);
long expirationTime = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1);
String rpcRequest = "{\"method\":\"toggle_gpio\",\"params\":{\"pin\":1},\"persistent\":true,\"expirationTime\":" + expirationTime + "}";
String response = doPostAsync("/api/rpc/twoway/" + session.device().getId().getId().toString(), rpcRequest, String.class, status().isOk());
String rpcId = JacksonUtil.toJsonNode(response).get("rpcId").asText();
session.callback().getSubscribeLatch().await(DEFAULT_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
// Confirm the downlink arrived at QoS1 — otherwise no PUBACK is produced and the test could
// pass for the wrong reason if a regression downgraded the gateway RPC to QoS0.
assertEquals(MqttQoS.AT_LEAST_ONCE.value(), session.callback().getMessageArrivedQoS());
awaitRpcStatus(rpcId, RpcStatus.DELIVERED, 200, 100);
session.client().disconnect();
}
/**
* Connects a gateway, provisions the sub-device via CONNECT, and subscribes to the gateway RPC topic
* at QoS1. Two-step subscribe (client.subscribeAndWait + awaitForDeviceActorToReceiveSubscription, 1)
* rather than the 5-arg subscribeAndWait, which waits for subscription count+1 the gateway CONNECT
* already registered one RPC subscription, so it would hang.
*/
protected GatewayRpcSession connectGatewayAndSubscribeForRpc(String deviceName, boolean manualAcks) throws Exception {
MqttTestClient client = new MqttTestClient();
if (manualAcks) {
client.enableManualAcks();
}
client.connectAndWait(gatewayAccessToken);
String connectPayload = "{\"device\": \"" + deviceName + "\", \"type\": \"" + TransportPayloadType.JSON.name() + "\"}";
client.publish(GATEWAY_CONNECT_TOPIC, connectPayload.getBytes());
Device device = doExecuteWithRetriesAndInterval(() -> getDeviceByName(deviceName), 20, 100);
assertNotNull(device);
MqttTestCallback callback = new MqttTestSubscribeOnTopicCallback(GATEWAY_RPC_TOPIC);
client.setCallback(callback);
client.subscribeAndWait(GATEWAY_RPC_TOPIC, MqttQoS.AT_LEAST_ONCE);
awaitForDeviceActorToReceiveSubscription(device.getId(), FeatureType.RPC, 1);
return new GatewayRpcSession(client, device, callback);
}
protected Rpc awaitRpcStatus(String rpcId, RpcStatus expectedStatus, int retries, int intervalMs) throws Exception {
Rpc rpc = doExecuteWithRetriesAndInterval(() -> {
Rpc current = doGet("/api/rpc/persistent/" + rpcId, Rpc.class);
return expectedStatus.equals(current.getStatus()) ? current : null;
}, retries, intervalMs);
assertNotNull(rpc);
assertEquals(expectedStatus, rpc.getStatus());
return rpc;
}
protected record GatewayRpcSession(MqttTestClient client, Device device, MqttTestCallback callback) {}
protected void validateProtoTwoWayRpcGatewayResponse(String deviceName, MqttTestClient client, byte[] connectPayloadBytes) throws Exception {
client.publish(GATEWAY_CONNECT_TOPIC, connectPayloadBytes);
@ -353,7 +410,7 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractM
assertEquals(MqttQoS.AT_MOST_ONCE.value(), callback.getMessageArrivedQoS());
}
private Device getDeviceByName(String deviceName) throws Exception {
protected Device getDeviceByName(String deviceName) throws Exception {
return doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class);
}

75
application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttGatewayServerSideRpcDeliveryTimeoutIntegrationTest.java

@ -0,0 +1,75 @@
/**
* Copyright © 2016-2026 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.transport.mqtt.mqttv3.rpc;
import io.netty.handler.codec.mqtt.MqttQoS;
import lombok.extern.slf4j.Slf4j;
import org.junit.Before;
import org.junit.Test;
import org.springframework.test.context.TestPropertySource;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.TransportPayloadType;
import org.thingsboard.server.common.data.rpc.RpcStatus;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.transport.mqtt.MqttTestConfigProperties;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Slf4j
@DaoSqlTest
@TestPropertySource(properties = {
"actors.rpc.close_session_on_rpc_delivery_timeout=true",
"transport.mqtt.timeout=100",
})
public class MqttGatewayServerSideRpcDeliveryTimeoutIntegrationTest extends AbstractMqttServerSideRpcIntegrationTest {
@Before
public void beforeTest() throws Exception {
MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder()
.deviceName("RPC timeout test device")
.gatewayName("RPC timeout test gateway")
.transportPayloadType(TransportPayloadType.JSON)
.build();
processBeforeTest(configProperties);
}
@Test
public void testGatewayPersistentRpcTimeoutWhenNoPuback() throws Exception {
// manualAcks = true → the gateway client never PUBACKs the QoS1 downlink.
GatewayRpcSession session = connectGatewayAndSubscribeForRpc("Gateway Device Persistent RPC Timeout Json", true);
long expirationTime = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1);
String rpcRequest = "{\"method\":\"toggle_gpio\",\"params\":{\"pin\":1},\"persistent\":true,\"retries\":0,\"expirationTime\":" + expirationTime + "}";
String response = doPostAsync("/api/rpc/twoway/" + session.device().getId().getId().toString(), rpcRequest, String.class, status().isOk());
String rpcId = JacksonUtil.toJsonNode(response).get("rpcId").asText();
session.callback().getSubscribeLatch().await(DEFAULT_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
// Confirm the downlink arrived at QoS1 — a QoS0 downlink would produce no PUBACK path at all,
// so the timeout would fire for the wrong reason and mask a regression.
assertEquals(MqttQoS.AT_LEAST_ONCE.value(), session.callback().getMessageArrivedQoS());
// No PUBACK is ever sent. The gateway awaiting-ack scheduler emits TIMEOUT after
// transport.mqtt.timeout (100 ms); the RPC status reverts to QUEUED (re-queued via the
// close_session_on_rpc_delivery_timeout path).
awaitRpcStatus(rpcId, RpcStatus.QUEUED, 50, 100);
session.client().disconnect();
}
}

5
application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcJsonIntegrationTest.java

@ -81,6 +81,11 @@ public class MqttServerSideRpcJsonIntegrationTest extends AbstractMqttServerSide
processJsonTwoWayRpcTestGateway("Gateway Device TwoWay RPC Json");
}
@Test
public void testGatewayServerMqttPersistentRpcDeliveredOnPuback() throws Exception {
validateGatewayPersistentRpcDelivered("Gateway Device Persistent RPC Json");
}
protected void processJsonOneWayRpcTestGateway(String deviceName) throws Exception {
MqttTestClient client = new MqttTestClient();
client.connectAndWait(gatewayAccessToken);

19
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java

@ -88,6 +88,7 @@ import org.thingsboard.server.transport.mqtt.session.GatewaySessionHandler;
import org.thingsboard.server.transport.mqtt.session.MqttTopicMatcher;
import org.thingsboard.server.transport.mqtt.session.SparkplugDeviceSessionContext;
import org.thingsboard.server.transport.mqtt.session.SparkplugNodeSessionHandler;
import org.thingsboard.server.transport.mqtt.util.MqttRpcStatusUtil;
import org.thingsboard.server.transport.mqtt.util.ReturnCodeResolver;
import org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType;
import org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugRpcRequestHeader;
@ -385,6 +386,8 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement
TransportProtos.ToDeviceRpcRequestMsg rpcRequest = rpcAwaitingAck.remove(msgId);
if (rpcRequest != null) {
transportService.process(deviceSessionCtx.getSessionInfo(), rpcRequest, RpcStatus.DELIVERED, true, TransportServiceCallback.EMPTY);
} else if (gatewaySessionHandler != null) {
gatewaySessionHandler.onPubAck(msgId);
}
break;
default:
@ -1521,9 +1524,11 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement
}
public void sendToDeviceRpcRequest(MqttMessage payload, TransportProtos.ToDeviceRpcRequestMsg rpcRequest, TransportProtos.SessionInfoProto sessionInfo) {
int msgId = ((MqttPublishMessage) payload).variableHeader().packetId();
MqttPublishMessage publishMsg = (MqttPublishMessage) payload;
int msgId = publishMsg.variableHeader().packetId();
int requestId = rpcRequest.getRequestId();
if (isAckExpected(payload)) {
boolean requireDeliveryTracking = MqttRpcStatusUtil.requireDeliveryTracking(rpcRequest);
if (requireDeliveryTracking && isAckExpected(publishMsg)) {
rpcAwaitingAck.put(msgId, rpcRequest);
context.getScheduler().schedule(() -> {
TransportProtos.ToDeviceRpcRequestMsg msg = rpcAwaitingAck.remove(msgId);
@ -1533,7 +1538,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement
}
}, Math.max(0, Math.min(deviceSessionCtx.getContext().getTimeout(), rpcRequest.getExpirationTime() - System.currentTimeMillis())), TimeUnit.MILLISECONDS);
}
var cf = publish(payload, deviceSessionCtx);
var cf = publish(publishMsg, deviceSessionCtx);
cf.addListener(result -> {
Throwable throwable = result.cause();
if (throwable != null) {
@ -1542,9 +1547,11 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement
ThingsboardErrorCode.INVALID_ARGUMENTS, " Failed send To Device Rpc Request: " + rpcRequest.getMethodName());
return;
}
if (!isAckExpected(payload)) {
log.trace("[{}][{}][{}] Going to send to device actor RPC request DELIVERED status update ...", deviceSessionCtx.getDeviceId(), sessionId, requestId);
transportService.process(sessionInfo, rpcRequest, RpcStatus.DELIVERED, TransportServiceCallback.EMPTY);
if (!isAckExpected(publishMsg)) {
if (requireDeliveryTracking) {
log.trace("[{}][{}][{}] Going to send to device actor RPC request DELIVERED status update ...", deviceSessionCtx.getDeviceId(), sessionId, requestId);
transportService.process(sessionInfo, rpcRequest, RpcStatus.DELIVERED, TransportServiceCallback.EMPTY);
}
} else if (rpcRequest.getPersisted()) {
log.trace("[{}][{}][{}] Going to send to device actor RPC request SENT status update ...", deviceSessionCtx.getDeviceId(), sessionId, requestId);
transportService.process(sessionInfo, rpcRequest, RpcStatus.SENT, TransportServiceCallback.EMPTY);

26
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewayDeviceSessionContext.java

@ -17,6 +17,7 @@ package org.thingsboard.server.transport.mqtt.session;
import io.netty.channel.ChannelFuture;
import io.netty.handler.codec.mqtt.MqttMessage;
import io.netty.handler.codec.mqtt.MqttPublishMessage;
import lombok.Getter;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
@ -29,6 +30,7 @@ import org.thingsboard.server.common.transport.TransportServiceCallback;
import org.thingsboard.server.common.transport.auth.TransportDeviceInfo;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto;
import org.thingsboard.server.transport.mqtt.util.MqttRpcStatusUtil;
import java.util.UUID;
import java.util.concurrent.ConcurrentMap;
@ -106,19 +108,23 @@ public abstract class AbstractGatewayDeviceSessionContext<T extends AbstractGate
try {
parent.getPayloadAdaptor().convertToGatewayPublish(this, getDeviceInfo().getDeviceName(), request).ifPresent(
payload -> {
ChannelFuture channelFuture = parent.writeAndFlush(payload);
if (request.getPersisted()) {
channelFuture.addListener(result -> {
if (result.cause() == null) {
if (!isAckExpected(payload)) {
MqttPublishMessage publishMsg = (MqttPublishMessage) payload;
boolean requireDeliveryTracking = MqttRpcStatusUtil.requireDeliveryTracking(request);
if (requireDeliveryTracking && isAckExpected(publishMsg)) {
parent.registerRpcAwaitingAck(publishMsg.variableHeader().packetId(), getSessionInfo(), request);
}
ChannelFuture channelFuture = parent.writeAndFlush(publishMsg);
channelFuture.addListener(result -> {
if (result.cause() == null) {
if (!isAckExpected(publishMsg)) {
if (requireDeliveryTracking) {
transportService.process(getSessionInfo(), request, RpcStatus.DELIVERED, TransportServiceCallback.EMPTY);
} else if (request.getPersisted()) {
transportService.process(getSessionInfo(), request, RpcStatus.SENT, TransportServiceCallback.EMPTY);
}
} else if (request.getPersisted()) {
transportService.process(getSessionInfo(), request, RpcStatus.SENT, TransportServiceCallback.EMPTY);
}
});
}
}
});
}
);
} catch (Exception e) {

32
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java

@ -50,6 +50,7 @@ import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.rpc.RpcStatus;
import org.thingsboard.server.common.data.util.TbPair;
import org.thingsboard.server.common.msg.gateway.metrics.GatewayMetadata;
import org.thingsboard.server.common.msg.tools.TbRateLimitsException;
@ -80,6 +81,7 @@ import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
@ -123,6 +125,10 @@ public abstract class AbstractGatewaySessionHandler<T extends AbstractGatewayDev
protected final DeviceSessionCtx deviceSessionCtx;
protected final GatewayMetricsService gatewayMetricsService;
private final ConcurrentMap<Integer, RpcAwaitingAck> rpcAwaitingAck = new ConcurrentHashMap<>();
private record RpcAwaitingAck(SessionInfoProto deviceSessionInfo, TransportProtos.ToDeviceRpcRequestMsg rpc) {}
@Getter
@Setter
private boolean overwriteDevicesActivity = false;
@ -201,6 +207,10 @@ public abstract class AbstractGatewaySessionHandler<T extends AbstractGatewayDev
public void onDevicesDisconnect() {
log.debug("[{}] Gateway disconnect [{}]", gateway.getTenantId(), gateway.getDeviceId());
if (!rpcAwaitingAck.isEmpty()) {
log.debug("[{}] Cleanup gateway RPC awaiting ack map due to session close!", sessionId);
rpcAwaitingAck.clear();
}
try {
deviceFutures.forEach((name, future) -> {
Futures.addCallback(future, new FutureCallback<T>() {
@ -252,6 +262,28 @@ public abstract class AbstractGatewaySessionHandler<T extends AbstractGatewayDev
return deviceSessionCtx.nextMsgId();
}
void registerRpcAwaitingAck(int msgId, SessionInfoProto deviceSessionInfo, TransportProtos.ToDeviceRpcRequestMsg rpc) {
rpcAwaitingAck.put(msgId, new RpcAwaitingAck(deviceSessionInfo, rpc));
long delay = Math.max(0, Math.min(deviceSessionCtx.getContext().getTimeout(),
rpc.getExpirationTime() - System.currentTimeMillis()));
context.getScheduler().schedule(() -> {
RpcAwaitingAck pending = rpcAwaitingAck.remove(msgId);
if (pending != null) {
log.trace("[{}] Gateway RPC [{}] PUBACK timeout, sending TIMEOUT status", sessionId, rpc.getRequestId());
transportService.process(pending.deviceSessionInfo(), pending.rpc(), RpcStatus.TIMEOUT, TransportServiceCallback.EMPTY);
}
}, delay, TimeUnit.MILLISECONDS);
}
public void onPubAck(int msgId) {
RpcAwaitingAck pending = rpcAwaitingAck.remove(msgId);
if (pending == null) {
return;
}
log.trace("[{}] Gateway RPC [{}] PUBACK received, sending DELIVERED status", sessionId, pending.rpc().getRequestId());
transportService.process(pending.deviceSessionInfo(), pending.rpc(), RpcStatus.DELIVERED, true, TransportServiceCallback.EMPTY);
}
protected boolean isJsonPayloadType() {
return deviceSessionCtx.isJsonPayloadType();
}

35
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/MqttRpcStatusUtil.java

@ -0,0 +1,35 @@
/**
* Copyright © 2016-2026 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.transport.mqtt.util;
import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcRequestMsg;
public final class MqttRpcStatusUtil {
private MqttRpcStatusUtil() {
}
/**
* Whether the transport should track and emit a delivery status (DELIVERED / TIMEOUT) for the given RPC.
* Non-persistent one-way RPCs self-complete on send in the device actor and are never added to its pending
* map, so a delivery status for them lands on nothing and only produces a benign
* "RPC has already been removed from pending map" warning. Every other RPC is tracked.
*/
public static boolean requireDeliveryTracking(ToDeviceRpcRequestMsg rpc) {
return !(rpc.getOneway() && !rpc.getPersisted());
}
}
Loading…
Cancel
Save