diff --git a/application/pom.xml b/application/pom.xml
index 4114f98759..f6ceba95a1 100644
--- a/application/pom.xml
+++ b/application/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
thingsboard
application
@@ -395,11 +395,7 @@
org.thingsboard.langchain4j
- langchain4j-google-ai-gemini
-
-
- org.thingsboard.langchain4j
- langchain4j-vertex-ai-gemini
+ langchain4j-google-genai
org.thingsboard.langchain4j
@@ -415,13 +411,7 @@
org.thingsboard.langchain4j
- langchain4j-github-models
-
-
- com.azure
- azure-core-test
-
-
+ langchain4j-open-ai-official
org.thingsboard.langchain4j
diff --git a/application/src/main/data/json/system/widget_types/html_container.json b/application/src/main/data/json/system/widget_types/html_container.json
index 25b9e5ac5d..a27996593b 100644
--- a/application/src/main/data/json/system/widget_types/html_container.json
+++ b/application/src/main/data/json/system/widget_types/html_container.json
@@ -11,7 +11,7 @@
"resources": [],
"templateHtml": "\n",
"templateCss": "",
- "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n",
+ "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n\nself.actionSources = function() {\n return {\n 'javaScript': {\n name: 'JavaScript',\n multiple: true\n }\n };\n}",
"settingsDirective": "tb-html-container-widget-settings",
"hasBasicMode": true,
"basicModeDirective": "tb-html-container-basic-config",
diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java
index 8a113fb424..998ca1cfa5 100644
--- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java
@@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.alarm.AlarmComment;
import org.thingsboard.server.common.data.alarm.AlarmCommentInfo;
import org.thingsboard.server.common.data.alarm.AlarmCommentType;
+import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.AlarmCommentId;
import org.thingsboard.server.common.data.id.AlarmId;
@@ -39,6 +40,7 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.entitiy.alarm.TbAlarmCommentService;
+import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.permission.Operation;
import static org.thingsboard.server.controller.ControllerConstants.ALARM_COMMENT_ID_PARAM_DESCRIPTION;
@@ -77,9 +79,13 @@ public class AlarmCommentController extends BaseController {
checkParameter(ALARM_ID, strAlarmId);
AlarmId alarmId = new AlarmId(toUUID(strAlarmId));
Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE);
+ SecurityUser currentUser = getCurrentUser();
+ if (alarmComment.getId() != null) {
+ checkUserPermission(alarmComment, alarmId, "edit", currentUser);
+ }
alarmComment.setAlarmId(alarmId);
alarmComment.setType(AlarmCommentType.OTHER);
- return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser());
+ return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, currentUser);
}
@ApiOperation(value = "Delete Alarm comment (deleteAlarmComment)",
@@ -93,7 +99,11 @@ public class AlarmCommentController extends BaseController {
AlarmCommentId alarmCommentId = new AlarmCommentId(toUUID(strCommentId));
AlarmComment alarmComment = checkAlarmCommentId(alarmCommentId, alarmId);
- tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, getCurrentUser());
+ SecurityUser currentUser = getCurrentUser();
+ if (!currentUser.isTenantAdmin()) {
+ checkUserPermission(alarmComment, alarmId, "delete", currentUser);
+ }
+ tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, currentUser);
}
@ApiOperation(value = "Get Alarm comments (getAlarmComments)",
@@ -120,4 +130,12 @@ public class AlarmCommentController extends BaseController {
return checkNotNull(alarmCommentService.findAlarmComments(alarm.getTenantId(), alarmId, pageLink));
}
+ private void checkUserPermission(AlarmComment alarmComment, AlarmId alarmId, String operation, SecurityUser currentUser) throws ThingsboardException {
+ AlarmComment existingAlarmComment = checkAlarmCommentId(alarmComment.getId(), alarmId);
+ if (existingAlarmComment.getUserId() != null && !existingAlarmComment.getUserId().equals(currentUser.getId())) {
+ throw new ThingsboardException("User is not allowed to " + operation + " other user's comment",
+ ThingsboardErrorCode.PERMISSION_DENIED);
+ }
+ }
+
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
index ee2e871d6e..3a27e4f462 100644
--- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
@@ -40,6 +40,7 @@ import org.thingsboard.server.common.data.mobile.qrCodeSettings.QrCodeSettings;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.settings.UserSettings;
import org.thingsboard.server.common.data.settings.UserSettingsType;
+import org.thingsboard.server.common.msg.edqs.EdqsService;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.dao.mobile.QrCodeSettingService;
import org.thingsboard.server.dao.trendz.TrendzSettingsService;
@@ -52,6 +53,7 @@ import org.thingsboard.server.utils.DebugModeRateLimitsConfig;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
+import java.util.Set;
import java.util.stream.Collectors;
@Hidden
@@ -76,6 +78,11 @@ public class SystemInfoController extends BaseController {
@Value("${debug.settings.default_duration:15}")
private int defaultDebugDurationMinutes;
+ @Value("${sql.entity_data_query_nulls_order_strategy:default}")
+ private String nullsOrderStrategy;
+
+ private static final Set ACCEPTED_NULLS_ORDER_STRATEGIES = Set.of("default", "nulls_first", "nulls_last");
+
@Autowired(required = false)
private BuildProperties buildProperties;
@@ -91,6 +98,9 @@ public class SystemInfoController extends BaseController {
@Autowired
private TrendzSettingsService trendzSettingsService;
+ @Autowired
+ private EdqsService edqsService;
+
@PostConstruct
public void init() {
JsonNode info = buildInfoObject();
@@ -150,6 +160,8 @@ public class SystemInfoController extends BaseController {
}
systemParams.setUserSettings(userSettingsNode);
systemParams.setMaxDatapointsLimit(maxDatapointsLimit);
+ systemParams.setNullsOrderStrategy(ACCEPTED_NULLS_ORDER_STRATEGIES.contains(nullsOrderStrategy) ? nullsOrderStrategy : "default");
+ systemParams.setEdqsEnabled(edqsService.isApiEnabled());
if (!currentUser.isSystemAdmin()) {
DefaultTenantProfileConfiguration tenantProfileConfiguration = tenantProfileCache.get(tenantId).getDefaultProfileConfiguration();
systemParams.setMaxResourceSize(tenantProfileConfiguration.getMaxResourceSize());
diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
index 15be6f3734..a2bc29104b 100644
--- a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
+++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
@@ -15,14 +15,8 @@
*/
package org.thingsboard.server.service.ai;
-import com.fasterxml.jackson.core.io.JsonStringEncoder;
import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.Futures;
-import dev.langchain4j.data.message.ChatMessage;
-import dev.langchain4j.data.message.Content;
-import dev.langchain4j.data.message.TextContent;
-import dev.langchain4j.data.message.UserMessage;
-import dev.langchain4j.model.ModelProvider;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.response.ChatResponse;
@@ -31,9 +25,6 @@ import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer;
-import java.util.List;
-import java.util.stream.Collectors;
-
@Service
@RequiredArgsConstructor
class AiChatModelServiceImpl implements AiChatModelService {
@@ -49,39 +40,7 @@ class AiChatModelServiceImpl implements AiChatModelService {
} catch (Throwable t) {
return FluentFuture.from(Futures.immediateFailedFuture(t));
}
- if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) {
- chatRequest = prepareGithubChatRequest(chatRequest);
- }
return aiRequestsExecutor.sendChatRequestAsync(langChainChatModel, chatRequest);
}
- private ChatRequest prepareGithubChatRequest(ChatRequest chatRequest) {
- List messages = chatRequest.messages().stream()
- .map(this::prepareUserMessage)
- .collect(Collectors.toList());
-
- return ChatRequest.builder()
- .messages(messages)
- .responseFormat(chatRequest.responseFormat())
- .build();
- }
-
- private ChatMessage prepareUserMessage(ChatMessage message) {
- if (message instanceof UserMessage userMessage) {
- List newContents = userMessage.contents().stream()
- .map(this::prepareContent)
- .collect(Collectors.toList());
-
- return UserMessage.from(newContents);
- }
- return message;
- }
-
- private Content prepareContent(Content content) {
- if (content instanceof TextContent txt) {
- return new TextContent(new String(JsonStringEncoder.getInstance().quoteAsString(txt.text())));
- }
- return content;
- }
-
}
diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
index 8b569f052c..3bb39c3671 100644
--- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
+++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
@@ -15,26 +15,18 @@
*/
package org.thingsboard.server.service.ai;
-import com.google.api.gax.core.FixedCredentialsProvider;
-import com.google.api.gax.retrying.RetrySettings;
+import com.google.auth.oauth2.GoogleCredentials;
import com.google.auth.oauth2.ServiceAccountCredentials;
-import com.google.cloud.vertexai.Transport;
-import com.google.cloud.vertexai.VertexAI;
-import com.google.cloud.vertexai.api.GenerationConfig;
-import com.google.cloud.vertexai.api.PredictionServiceClient;
-import com.google.cloud.vertexai.api.PredictionServiceSettings;
-import com.google.cloud.vertexai.generativeai.GenerativeModel;
import dev.langchain4j.model.anthropic.AnthropicChatModel;
import dev.langchain4j.model.azure.AzureOpenAiChatModel;
import dev.langchain4j.model.bedrock.BedrockChatModel;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequestParameters;
-import dev.langchain4j.model.github.GitHubModelsChatModel;
-import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel;
+import dev.langchain4j.model.google.genai.GoogleGenAiChatModel;
import dev.langchain4j.model.mistralai.MistralAiChatModel;
import dev.langchain4j.model.ollama.OllamaChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
-import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel;
+import dev.langchain4j.model.openaiofficial.OpenAiOfficialChatModel;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.SsrfProtectionValidator;
@@ -50,7 +42,6 @@ import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
-import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
@@ -107,7 +98,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(GoogleAiGeminiChatModelConfig chatModelConfig) {
- return GoogleAiGeminiChatModel.builder()
+ return GoogleGenAiChatModel.builder()
.apiKey(chatModelConfig.providerConfig().apiKey())
.modelName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature())
@@ -123,84 +114,28 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(GoogleVertexAiGeminiChatModelConfig chatModelConfig) {
- GoogleVertexAiGeminiProviderConfig providerConfig = chatModelConfig.providerConfig();
-
- // construct service account credentials using service account key JSON
- ServiceAccountCredentials serviceAccountCredentials;
+ GoogleCredentials credentials;
try {
- serviceAccountCredentials = ServiceAccountCredentials.fromStream(new ByteArrayInputStream(providerConfig.serviceAccountKey().getBytes()));
+ credentials = ServiceAccountCredentials
+ .fromStream(new ByteArrayInputStream(chatModelConfig.providerConfig().serviceAccountKey().getBytes(StandardCharsets.UTF_8)))
+ .createScoped("https://www.googleapis.com/auth/cloud-platform");
} catch (IOException e) {
throw new RuntimeException("Failed to parse service account key JSON", e);
}
-
- PredictionServiceSettings predictionServiceClientSettings;
- try {
- // create prediction service settings for REST transport with service account key credentials
- PredictionServiceSettings.Builder settingsBuilder = PredictionServiceSettings.newHttpJsonBuilder()
- .setCredentialsProvider(FixedCredentialsProvider.create(serviceAccountCredentials));
-
- // get the retry settings that control request timeout for generateContent RPC
- RetrySettings.Builder retrySettings = settingsBuilder
- .generateContentSettings()
- .getRetrySettings()
- .toBuilder();
-
- // set request timeout from model config
- if (chatModelConfig.timeoutSeconds() != null) {
- retrySettings.setTotalTimeoutDuration(Duration.ofSeconds(chatModelConfig.timeoutSeconds()));
- }
-
- // set updated retry settings
- settingsBuilder.generateContentSettings().setRetrySettings(retrySettings.build());
-
- // build the client settings
- predictionServiceClientSettings = settingsBuilder.build();
- } catch (IOException e) {
- throw new RuntimeException("Failed to create prediction service client settings", e);
- }
-
- // construct Vertex AI instance
- var vertexAI = new VertexAI.Builder()
- .setProjectId(providerConfig.projectId())
- .setLocation(providerConfig.location())
- .setPredictionClientSupplier(() -> createPredictionServiceClient(predictionServiceClientSettings))
- .setTransport(Transport.REST) // GRPC also possible, but likely does not work with service account keys
+ return GoogleGenAiChatModel.builder()
+ .projectId(chatModelConfig.providerConfig().projectId())
+ .location(chatModelConfig.providerConfig().location())
+ .googleCredentials(credentials)
+ .modelName(chatModelConfig.modelId())
+ .temperature(chatModelConfig.temperature())
+ .topP(chatModelConfig.topP())
+ .topK(chatModelConfig.topK())
+ .frequencyPenalty(chatModelConfig.frequencyPenalty())
+ .presencePenalty(chatModelConfig.presencePenalty())
+ .maxOutputTokens(chatModelConfig.maxOutputTokens())
+ .timeout(toDuration(chatModelConfig.timeoutSeconds()))
+ .maxRetries(chatModelConfig.maxRetries())
.build();
-
- // map model config to generation config
- var generationConfigBuilder = GenerationConfig.newBuilder();
- if (chatModelConfig.temperature() != null) {
- generationConfigBuilder.setTemperature(chatModelConfig.temperature().floatValue());
- }
- if (chatModelConfig.topP() != null) {
- generationConfigBuilder.setTopP(chatModelConfig.topP().floatValue());
- }
- if (chatModelConfig.topK() != null) {
- generationConfigBuilder.setTopK(chatModelConfig.topK());
- }
- if (chatModelConfig.frequencyPenalty() != null) {
- generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue());
- }
- if (chatModelConfig.presencePenalty() != null) {
- generationConfigBuilder.setPresencePenalty(chatModelConfig.presencePenalty().floatValue());
- }
- if (chatModelConfig.maxOutputTokens() != null) {
- generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens());
- }
- var generationConfig = generationConfigBuilder.build();
-
- // construct generative model instance
- var generativeModel = new GenerativeModel(chatModelConfig.modelId(), vertexAI).withGenerationConfig(generationConfig);
-
- return new VertexAiGeminiChatModel(generativeModel, generationConfig, chatModelConfig.maxRetries());
- }
-
- private static PredictionServiceClient createPredictionServiceClient(PredictionServiceSettings settings) {
- try {
- return PredictionServiceClient.create(settings);
- } catch (IOException e) {
- throw new RuntimeException("Failed to create prediction service client", e);
- }
}
@Override
@@ -262,14 +197,16 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig) {
- return GitHubModelsChatModel.builder()
- .gitHubToken(chatModelConfig.providerConfig().personalAccessToken())
+ return OpenAiOfficialChatModel.builder()
+ .isGitHubModels(true)
+ .strictJsonSchema(true)
+ .apiKey(chatModelConfig.providerConfig().personalAccessToken())
.modelName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature())
.topP(chatModelConfig.topP())
.frequencyPenalty(chatModelConfig.frequencyPenalty())
.presencePenalty(chatModelConfig.presencePenalty())
- .maxTokens(chatModelConfig.maxOutputTokens())
+ .maxCompletionTokens(chatModelConfig.maxOutputTokens())
.timeout(toDuration(chatModelConfig.timeoutSeconds()))
.maxRetries(chatModelConfig.maxRetries())
.build();
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java
index 7f21f8d2e7..a837b5ebde 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java
@@ -67,6 +67,8 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState {
ObjectNode valuesNode = JacksonUtil.newObjectNode();
if (result instanceof Double doubleValue) {
valuesNode.put(outputName, doubleValue);
+ } else if (result instanceof Long longValue) {
+ valuesNode.put(outputName, longValue);
} else if (result instanceof Integer integerValue) {
valuesNode.put(outputName, integerValue);
} else {
diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java
index 87c1aaa707..4e42b41dd2 100644
--- a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java
+++ b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java
@@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfi
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.queue.TbQueueAdmin;
+import org.thingsboard.server.queue.discovery.TopicService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.entitiy.AbstractTbEntityService;
@@ -45,6 +46,7 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb
private final QueueService queueService;
private final TbClusterService tbClusterService;
private final TbQueueAdmin tbQueueAdmin;
+ private final TopicService topicService;
@Override
public Queue saveQueue(Queue queue) {
@@ -173,9 +175,10 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb
private void createTopicsIfNeeded(Queue queue, Queue oldQueue) {
int newPartitions = queue.getPartitions();
int oldPartitions = oldQueue != null ? oldQueue.getPartitions() : 0;
+ String topic = topicService.buildTopicName(queue.getTopic());
for (int i = oldPartitions; i < newPartitions; i++) {
tbQueueAdmin.createTopicIfNotExists(
- new TopicPartitionInfo(queue.getTopic(), queue.getTenantId(), i, false).getFullTopicName(),
+ new TopicPartitionInfo(topic, queue.getTenantId(), i, false).getFullTopicName(),
queue.getCustomProperties(),
true); // forcing topic creation because the topic may still be cached on some nodes
}
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
index bf53a8b401..ffb6af8319 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
@@ -464,10 +464,10 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService 0 ? RpcError.values()[proto.getError()] : null;
+ void forwardToCoreRpcService(FromDeviceRPCResponseProto proto, TbCallback callback) {
+ RpcError error = RpcError.fromProtoErrorCode(proto.getError());
FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB())
- , proto.getResponse(), error);
+ , proto.hasResponse() ? proto.getResponse() : null, error);
tbCoreDeviceRpcService.processRpcResponseFromRuleEngine(response);
callback.onSuccess();
}
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java
index 74e0de9ea6..bb70a5f136 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java
@@ -179,9 +179,9 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo
callback.onSuccess();
} else if (nfMsg.hasFromDeviceRpcResponse()) {
TransportProtos.FromDeviceRPCResponseProto proto = nfMsg.getFromDeviceRpcResponse();
- RpcError error = proto.getError() > 0 ? RpcError.values()[proto.getError()] : null;
+ RpcError error = RpcError.fromProtoErrorCode(proto.getError());
FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB())
- , proto.getResponse(), error);
+ , proto.hasResponse() ? proto.getResponse() : null, error);
tbDeviceRpcService.processRpcResponseFromDevice(response);
callback.onSuccess();
} else if (nfMsg.getQueueUpdateMsgsCount() > 0) {
diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml
index 454c59d7d7..b5a8003241 100644
--- a/application/src/main/resources/thingsboard.yml
+++ b/application/src/main/resources/thingsboard.yml
@@ -1158,6 +1158,8 @@ transport:
timeout: "${CLIENT_SIDE_RPC_TIMEOUT:60000}"
# Enable/disable http/mqtt/coap/lwm2m transport protocols (has higher priority than certain protocol's 'enabled' property)
api_enabled: "${TB_TRANSPORT_API_ENABLED:true}"
+ # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently.
+ callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}"
log:
# Enable/Disable log of transport messages to telemetry. For example, logging of LwM2M registration update
enabled: "${TB_TRANSPORT_LOG_ENABLED:true}"
diff --git a/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java
index 31da96b7b4..50707e4c38 100644
--- a/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java
+++ b/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java
@@ -100,7 +100,7 @@ public class AlarmCommentApiClientTest extends AbstractApiClientTest {
.filter(alarmCommentInfo -> alarmCommentInfo.getId().getId().equals(commentToDeleteId))
.findFirst()
.get();
- assertEquals("User " + clientTenantAdmin.getEmail() + " deleted his comment", deletedComment.getComment().get("text").asText());
+ assertEquals("Comment was deleted by user " + clientTenantAdmin.getEmail(), deletedComment.getComment().get("text").asText());
}
}
diff --git a/application/src/test/java/org/thingsboard/server/client/ClientDocsExampleTest.java b/application/src/test/java/org/thingsboard/server/client/ClientDocsExampleTest.java
new file mode 100644
index 0000000000..e75f3b29c0
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/client/ClientDocsExampleTest.java
@@ -0,0 +1,318 @@
+/**
+ * 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.client;
+
+import org.junit.Test;
+import org.thingsboard.client.ApiException;
+import org.thingsboard.client.ThingsboardClient;
+import org.thingsboard.client.model.ApiKeyInfo;
+import org.thingsboard.client.model.Asset;
+import org.thingsboard.client.model.AttributeData;
+import org.thingsboard.client.model.BooleanFilterPredicate;
+import org.thingsboard.client.model.BooleanOperation;
+import org.thingsboard.client.model.Device;
+import org.thingsboard.client.model.EntityCountQuery;
+import org.thingsboard.client.model.EntityKey;
+import org.thingsboard.client.model.EntityKeyType;
+import org.thingsboard.client.model.EntityKeyValueType;
+import org.thingsboard.client.model.EntityType;
+import org.thingsboard.client.model.EntityTypeFilter;
+import org.thingsboard.client.model.FilterPredicateValueBoolean;
+import org.thingsboard.client.model.KeyFilter;
+import org.thingsboard.client.model.PageDataDevice;
+import org.thingsboard.client.model.TsData;
+import org.thingsboard.server.dao.service.DaoSqlTest;
+
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Mirrors every code snippet from the Java client documentation page
+ * ({@code /docs/reference/java-client/}, CE edition). Each snippet appears
+ * character-for-character with two allowances:
+ *
+ * - placeholder values ({@code "{BASE_URL}"}, {@code "YOUR_API_KEY_VALUE"},
+ * {@code "YOUR_DEVICE_ID"}, {@code "YOUR_ASSET_ID"},
+ * {@code "nonexistent-id"}, {@code "tenant@thingsboard.org"},
+ * {@code "tenant"}) are swapped for real test values;
+ * - {@code System.out.println} / {@code System.out.printf} calls inside the
+ * snippet are replaced with equivalent JUnit assertions, so the test
+ * actually verifies behavior instead of only compilation.
+ *
+ * Setup code that pre-creates entities required by a snippet and post-snippet
+ * verifications stay outside the snippet block.
+ */
+@DaoSqlTest
+public class ClientDocsExampleTest extends AbstractApiClientTest {
+
+ // /docs/reference/java-client/#quickstart
+ @Test
+ public void testQuickstart() throws Exception {
+ // setup: real API key for the snippet's "YOUR_API_KEY_VALUE" placeholder
+ ApiKeyInfo keyRequest = new ApiKeyInfo();
+ keyRequest.setDescription("ClientDocsExampleTest");
+ keyRequest.setUserId(clientTenantAdmin.getId());
+ keyRequest.setEnabled(true);
+ String apiKeyValue = this.client.saveApiKey(keyRequest).getValue();
+
+ // === doc snippet ===
+ ThingsboardClient client = ThingsboardClient.builder()
+ .url(getBaseUrl())
+ .apiKey(apiKeyValue)
+ .build();
+
+ Device newDevice = new Device();
+ newDevice.setName("Quickstart Device");
+ newDevice.setType("default");
+ Device savedDevice = client.saveDevice(newDevice, null, null, null, null);
+
+ String deviceId = savedDevice.getId().getId().toString();
+ client.saveEntityTelemetry("DEVICE", deviceId, "ANY", """
+ {"temperature": 22.4}
+ """);
+ assertEquals("Quickstart Device", savedDevice.getName());
+
+ client.deleteDevice(deviceId);
+
+ // post-snippet verification: the device is gone after deletion
+ assertReturns404(() -> client.getDeviceById(deviceId));
+ }
+
+ // /docs/reference/java-client/#api-key-recommended
+ @Test
+ public void testAuthenticationViaApiKey() throws Exception {
+ // setup: real API key for the snippet's "YOUR_API_KEY_VALUE" placeholder
+ ApiKeyInfo keyRequest = new ApiKeyInfo();
+ keyRequest.setDescription("ClientDocsExampleTest");
+ keyRequest.setUserId(clientTenantAdmin.getId());
+ keyRequest.setEnabled(true);
+ String apiKeyValue = this.client.saveApiKey(keyRequest).getValue();
+
+ // === doc snippet ===
+ String url = getBaseUrl();
+ String apiKey = apiKeyValue;
+ ThingsboardClient client = ThingsboardClient.builder()
+ .url(url)
+ .apiKey(apiKey)
+ .build();
+
+ assertEquals(TENANT_ADMIN_USERNAME, client.getUser().getEmail());
+ }
+
+ // /docs/reference/java-client/#username-and-password-jwt
+ @Test
+ public void testAuthenticationViaCredentials() throws Exception {
+ // === doc snippet ===
+ String url = getBaseUrl();
+ ThingsboardClient client = ThingsboardClient.builder()
+ .url(url)
+ .credentials(TENANT_ADMIN_USERNAME, TEST_PASSWORD)
+ .build();
+
+ assertEquals(TENANT_ADMIN_USERNAME, client.getUser().getEmail());
+ }
+
+ // /docs/reference/java-client/#rate-limit-handling
+ @Test
+ public void testRateLimitHandlingBuilderOptions() throws Exception {
+ // setup: real url + api key that the snippet references as locals
+ String url = getBaseUrl();
+ ApiKeyInfo keyRequest = new ApiKeyInfo();
+ keyRequest.setDescription("ClientDocsExampleTest");
+ keyRequest.setUserId(clientTenantAdmin.getId());
+ keyRequest.setEnabled(true);
+ String apiKey = this.client.saveApiKey(keyRequest).getValue();
+
+ // === doc snippet ===
+ ThingsboardClient client = ThingsboardClient.builder()
+ .url(url)
+ .apiKey(apiKey)
+ .maxRetries(3) // default 3
+ .initialRetryDelayMs(1000) // default 1 s
+ .maxRetryDelayMs(30_000) // default 30 s
+ .build();
+
+ // post-snippet verification: the tuned client is actually usable
+ assertEquals(TENANT_ADMIN_USERNAME, client.getUser().getEmail());
+ }
+
+ // /docs/reference/java-client/#working-with-entities
+ @Test
+ public void testWorkingWithEntities() throws Exception {
+ // === doc snippet ===
+ Device newDevice = new Device();
+ newDevice.setName("Test Device");
+ newDevice.setType("default");
+ Device savedDevice = client.saveDevice(newDevice, null, null, null, null);
+
+ String deviceId = savedDevice.getId().getId().toString();
+ Device fetched = client.getDeviceById(deviceId);
+ assertEquals("Test Device", fetched.getName());
+
+ client.deleteDevice(deviceId);
+
+ // post-snippet verification: the device is gone after deletion
+ assertReturns404(() -> client.getDeviceById(deviceId));
+ }
+
+ // /docs/reference/java-client/#push-telemetry
+ @Test
+ public void testPushTelemetry() throws Exception {
+ // setup: create a real device whose id replaces "YOUR_DEVICE_ID"
+ Device setup = new Device();
+ setup.setName("Telemetry Setup Device");
+ setup.setType("default");
+ String realDeviceId = client.saveDevice(setup, null, null, null, null)
+ .getId().getId().toString();
+
+ // === doc snippet ===
+ String deviceId = realDeviceId;
+ String body = """
+ {"temperature": 26.5, "humidity": 87}
+ """;
+ client.saveEntityTelemetry("DEVICE", deviceId, "ANY", body);
+
+ // post-snippet verification: telemetry was actually persisted
+ Map> latest =
+ client.getLatestTimeseries("DEVICE", deviceId, "temperature,humidity", false, null);
+ assertEquals("26.5", latest.get("temperature").get(0).getValue().toString());
+ assertEquals("87", latest.get("humidity").get(0).getValue().toString());
+ }
+
+ // /docs/reference/java-client/#read-and-write-attributes-read-modify-write
+ @Test
+ public void testReadModifyWriteAttributes() throws Exception {
+ // setup: create a real asset whose id replaces "YOUR_ASSET_ID"
+ Asset setupAsset = new Asset();
+ setupAsset.setName("Counter Setup Asset");
+ setupAsset.setType("building");
+ String realAssetId = client.saveAsset(setupAsset, null, null, null)
+ .getId().getId().toString();
+
+ // === doc snippet ===
+ String assetId = realAssetId;
+
+ List attrs = client.getAttributesByScope(
+ "ASSET", assetId, "SERVER_SCOPE", "deviceCount", null);
+
+ // getValue() returns Object — JSON numbers come back as Number subclasses
+ long current = attrs.isEmpty() ? 0L : ((Number) attrs.get(0).getValue()).longValue();
+ long updated = current + 1;
+
+ client.saveEntityAttributesV2("ASSET", assetId, "SERVER_SCOPE",
+ "{\"deviceCount\": %d}".formatted(updated));
+
+ // post-snippet verification: the increment was actually persisted
+ List after = client.getAttributesByScope(
+ "ASSET", assetId, "SERVER_SCOPE", "deviceCount", null);
+ assertEquals(1, after.size());
+ assertEquals(updated, ((Number) after.get(0).getValue()).longValue());
+ }
+
+ // /docs/reference/java-client/#paginated-tenant-list
+ @Test
+ public void testPaginatedTenantList() throws Exception {
+ // setup: populate the tenant with a few devices so the iteration has something to walk
+ int expectedDeviceCount = 5;
+ for (int i = 0; i < expectedDeviceCount; i++) {
+ Device d = new Device();
+ d.setName("Page Setup Device " + i);
+ d.setType("default");
+ client.saveDevice(d, null, null, null, null);
+ }
+
+ // === doc snippet ===
+ int page = 0; // pages are zero-indexed
+ PageDataDevice devices;
+ do {
+ devices = client.getTenantDevices(100, page, null, null, null, null);
+ devices.getData().forEach(d -> assertEquals("default", d.getType()));
+ page++;
+ } while (devices.getHasNext());
+
+ // post-snippet verification: pagination terminated and reached every device
+ assertEquals((long) expectedDeviceCount, devices.getTotalElements().longValue());
+ }
+
+ // /docs/reference/java-client/#filtered-query-with-entity-data-query-api
+ @Test
+ public void testEntityDataQueryCountFiltered() throws Exception {
+ // setup: create a mix of active and inactive devices for the count query
+ Device active1 = client.saveDevice(
+ new Device().name("Active_1").type("default"),
+ null, null, null, null);
+ Device active2 = client.saveDevice(
+ new Device().name("Active_2").type("default"),
+ null, null, null, null);
+ client.saveDevice(
+ new Device().name("Inactive_1").type("default"),
+ null, null, null, null);
+ client.saveEntityAttributesV2("DEVICE", active1.getId().getId().toString(),
+ "SERVER_SCOPE", "{\"active\": true}");
+ client.saveEntityAttributesV2("DEVICE", active2.getId().getId().toString(),
+ "SERVER_SCOPE", "{\"active\": true}");
+
+ // === doc snippet ===
+ EntityTypeFilter typeFilter = new EntityTypeFilter();
+ typeFilter.setEntityType(EntityType.DEVICE);
+
+ EntityCountQuery totalQuery = new EntityCountQuery();
+ totalQuery.setEntityFilter(typeFilter);
+ assertEquals(3L, client.countEntitiesByQuery(totalQuery).longValue());
+
+ KeyFilter activeFilter = new KeyFilter();
+ activeFilter.setKey(new EntityKey().type(EntityKeyType.ATTRIBUTE).key("active"));
+ activeFilter.setValueType(EntityKeyValueType.BOOLEAN);
+ BooleanFilterPredicate predicate = new BooleanFilterPredicate();
+ predicate.setOperation(BooleanOperation.EQUAL);
+ predicate.setValue(new FilterPredicateValueBoolean().defaultValue(true));
+ activeFilter.setPredicate(predicate);
+
+ EntityCountQuery activeQuery = new EntityCountQuery();
+ activeQuery.setEntityFilter(typeFilter);
+ activeQuery.setKeyFilters(List.of(activeFilter));
+ assertEquals(2L, client.countEntitiesByQuery(activeQuery).longValue());
+ }
+
+ // /docs/reference/java-client/#error-handling
+ @Test
+ public void testErrorHandling404() {
+ // setup: a real (random) UUID that doesn't resolve, replacing "nonexistent-id";
+ // the flag captures whether the 404 branch ran so we can assert the snippet
+ // actually entered error handling (instead of silently completing).
+ String missingDeviceId = UUID.randomUUID().toString();
+ boolean[] caught404 = {false};
+
+ // === doc snippet ===
+ try {
+ Device device = client.getDeviceById(missingDeviceId);
+ } catch (ApiException e) {
+ if (e.getCode() == 404) {
+ caught404[0] = true;
+ } else {
+ fail("API error " + e.getCode() + ": " + e.getResponseBody());
+ }
+ }
+
+ // post-snippet verification: the snippet actually exercised the 404 branch
+ assertTrue("Expected ApiException with code 404", caught404[0]);
+ }
+}
diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
index 7803b0c746..bca56deaf7 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
@@ -229,6 +229,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant";
protected static final String CUSTOMER_USER_EMAIL = "testcustomer@thingsboard.org";
+ protected static final String SECOND_CUSTOMER_USER_EMAIL = "testsecondcustomer@thingsboard.org";
private static final String CUSTOMER_USER_PASSWORD = "customer";
protected static final String DIFFERENT_CUSTOMER_USER_EMAIL = "testdifferentcustomer@thingsboard.org";
@@ -268,6 +269,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected CustomerId differentTenantCustomerId;
protected UserId customerUserId;
+ protected UserId secondCustomerUserId;
protected UserId differentCustomerUserId;
protected UserId differentTenantCustomerUserId;
@@ -393,9 +395,17 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
customerUser.setCustomerId(savedCustomer.getId());
customerUser.setEmail(CUSTOMER_USER_EMAIL);
- customerUser = createUserAndLogin(customerUser, CUSTOMER_USER_PASSWORD);
+ customerUser = createUserAndActivate(customerUser, CUSTOMER_USER_PASSWORD);
customerUserId = customerUser.getId();
+ User secondCustomerUser = new User();
+ secondCustomerUser.setAuthority(Authority.CUSTOMER_USER);
+ secondCustomerUser.setTenantId(tenantId);
+ secondCustomerUser.setCustomerId(customerId);
+ secondCustomerUser.setEmail(SECOND_CUSTOMER_USER_EMAIL);
+ secondCustomerUser = createUserAndActivate(secondCustomerUser, CUSTOMER_USER_PASSWORD);
+ secondCustomerUserId = secondCustomerUser.getId();
+
resetTokens();
log.debug("Executed web test setup");
@@ -494,6 +504,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
login(CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD);
}
+ protected void loginSecondCustomerUser() throws Exception {
+ login(SECOND_CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD);
+ }
+
protected void loginUser(String userName, String password) throws Exception {
login(userName, password);
}
@@ -608,6 +622,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return savedUser;
}
+ protected User createUserAndActivate(User user, String password) throws Exception {
+ User savedUser = doPost("/api/user", user, User.class);
+ JsonNode activateRequest = getActivateRequest(password);
+ doPost("/api/noauth/activate", activateRequest).andExpect(status().isOk());
+ return savedUser;
+ }
+
protected User createUser(User user, String password) throws Exception {
User savedUser = doPost("/api/user", user, User.class);
JsonNode activateRequest = getActivateRequest(password);
diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
index 89eb140c8b..c778916d02 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
@@ -161,6 +161,25 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.UPDATED_COMMENT, 1, updatedAlarmComment);
}
+ @Test
+ public void testEditOthersAlarmCommentIsProhibited() throws Exception {
+ loginCustomerUser();
+ AlarmComment alarmComment = createAlarmComment(alarm.getId());
+
+ JsonNode newComment = JacksonUtil.newObjectNode().set("text", new TextNode("Second customer rewrite"));
+ alarmComment.setComment(newComment);
+
+ loginSecondCustomerUser();
+ doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment)
+ .andExpect(status().isForbidden())
+ .andExpect(statusReason(containsString("User is not allowed to edit other user's comment")));
+
+ loginTenantAdmin();
+ doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment)
+ .andExpect(status().isForbidden())
+ .andExpect(statusReason(containsString("User is not allowed to edit other user's comment")));
+ }
+
@Test
public void testUpdateAlarmViaDifferentTenant() throws Exception {
loginTenantAdmin();
@@ -218,6 +237,32 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment);
}
+ @Test
+ public void testDeleteOthersAlarmCommentIsAllowedForAuthorOrTenantAdmin() throws Exception {
+ loginCustomerUser();
+ AlarmComment alarmComment = createAlarmComment(alarm.getId());
+
+ loginSecondCustomerUser();
+ Mockito.reset(tbClusterService, auditLogService);
+
+ doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId())
+ .andExpect(status().isForbidden())
+ .andExpect(statusReason(containsString("User is not allowed to delete other user's comment")));
+
+ loginTenantAdmin();
+ doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId())
+ .andExpect(status().isOk());
+ AlarmComment expectedAlarmComment = AlarmComment.builder()
+ .alarmId(alarm.getId())
+ .type(AlarmCommentType.SYSTEM)
+ .comment(JacksonUtil.newObjectNode()
+ .put("text", String.format(COMMENT_DELETED.getText(), TENANT_ADMIN_EMAIL))
+ .put("subtype", COMMENT_DELETED.name())
+ .put("userName", TENANT_ADMIN_EMAIL))
+ .build();
+ testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment);
+ }
+
@Test
public void testDeleteAlarmViaTenant() throws Exception {
loginTenantAdmin();
@@ -237,7 +282,7 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
assertThat(systemComment.getId()).isEqualTo(alarmComment.getId());
assertThat(systemComment.getType()).isEqualTo(AlarmCommentType.SYSTEM);
- assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("User %s deleted his comment",
+ assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("Comment was deleted by user %s",
TENANT_ADMIN_EMAIL));
AlarmComment expectedAlarmComment = AlarmComment.builder()
diff --git a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java
index efe553e8f2..2f37ed653d 100644
--- a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java
@@ -410,7 +410,7 @@ public class HomePageApiTest extends AbstractControllerTest {
Assert.assertEquals(1, usageInfo.getCustomers());
Assert.assertEquals(configuration.getMaxCustomers(), usageInfo.getMaxCustomers());
- Assert.assertEquals(2, usageInfo.getUsers());
+ Assert.assertEquals(3, usageInfo.getUsers());
Assert.assertEquals(configuration.getMaxUsers(), usageInfo.getMaxUsers());
Assert.assertEquals(DEFAULT_DASHBOARDS_COUNT, usageInfo.getDashboards());
@@ -476,7 +476,7 @@ public class HomePageApiTest extends AbstractControllerTest {
}
usageInfo = doGet("/api/usage", UsageInfo.class);
- Assert.assertEquals(users.size() + 2, usageInfo.getUsers());
+ Assert.assertEquals(users.size() + 3, usageInfo.getUsers());
List dashboards = new ArrayList<>();
for (int i = 0; i < 97; i++) {
diff --git a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java
index 751631aec2..998187da31 100644
--- a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java
@@ -717,6 +717,7 @@ public class UserControllerTest extends AbstractControllerTest {
String email = "testEmail1";
List expectedCustomerUserIds = new ArrayList<>();
expectedCustomerUserIds.add(customerUserId);
+ expectedCustomerUserIds.add(secondCustomerUserId);
for (int i = 0; i < 45; i++) {
User customerUser = createCustomerUser(customerId);
customerUser.setEmail(email + StringUtils.randomAlphanumeric((int) (5 + Math.random() * 10)) + "@thingsboard.org");
diff --git a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
index fb9807a2a8..c2f7c39ce5 100644
--- a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
@@ -15,20 +15,29 @@
*/
package org.thingsboard.server.service.ai;
-import com.google.cloud.vertexai.api.GenerationConfig;
+import dev.langchain4j.model.ModelProvider;
import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequestParameters;
import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock;
-import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.SsrfProtectionValidator;
+import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
@@ -53,18 +62,280 @@ class Langchain4jChatModelConfigurerImplTest {
private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl();
- @BeforeEach
- void enableSsrfProtection() {
- SsrfProtectionValidator.setEnabled(true);
- }
-
@AfterEach
- void disableSsrfProtection() {
+ void resetSsrfProtection() {
SsrfProtectionValidator.setEnabled(false);
}
+ // ============================== Configuration correctness (one per provider) ==============================
+ // For each provider we feed a fully populated config and assert that the returned ChatModel carries the same
+ // values, using only the public ChatModel surface (provider() and defaultRequestParameters()) — no reflection.
+
+ @Test
+ void shouldConfigureOpenAiModel_whenGivenOpenAiConfig() {
+ // GIVEN
+ var config = OpenAiChatModelConfig.builder()
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl("https://api.openai.com/v1")
+ .apiKey("test-key")
+ .build())
+ .modelId("gpt-4o")
+ .temperature(0.7)
+ .topP(0.9)
+ .frequencyPenalty(0.5)
+ .presencePenalty(0.25)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.OPEN_AI);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("gpt-4o");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.frequencyPenalty()).isEqualTo(0.5);
+ assertThat(params.presencePenalty()).isEqualTo(0.25);
+ assertThat(params.maxOutputTokens()).isEqualTo(500);
+ }
+
+ @Test
+ void shouldConfigureAzureOpenAiModel_whenGivenAzureOpenAiConfig() {
+ // GIVEN
+ var config = AzureOpenAiChatModelConfig.builder()
+ .providerConfig(new AzureOpenAiProviderConfig(
+ "https://my-resource.openai.azure.com/", "2024-05-01-preview", "test-key"))
+ .modelId("gpt-4o")
+ .temperature(0.7)
+ .topP(0.9)
+ .frequencyPenalty(0.5)
+ .presencePenalty(0.25)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.AZURE_OPEN_AI);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("gpt-4o"); // deployment name maps to modelName
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.frequencyPenalty()).isEqualTo(0.5);
+ assertThat(params.presencePenalty()).isEqualTo(0.25);
+ assertThat(params.maxOutputTokens()).isEqualTo(500);
+ }
+
+ @Test
+ void shouldConfigureGoogleAiGeminiModel_whenGivenGoogleAiGeminiConfig() {
+ // GIVEN
+ var config = GoogleAiGeminiChatModelConfig.builder()
+ .providerConfig(new GoogleAiGeminiProviderConfig("test-key"))
+ .modelId("gemini-2.5-flash")
+ .temperature(0.7)
+ .topP(0.9)
+ .topK(40)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.GOOGLE_GENAI);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("gemini-2.5-flash");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.topK()).isEqualTo(40);
+ assertThat(params.maxOutputTokens()).isEqualTo(500);
+ }
+
+ @Test
+ void shouldConfigureGoogleVertexAiGeminiModel_whenGivenGoogleVertexAiGeminiConfig() {
+ // GIVEN
+ var config = GoogleVertexAiGeminiChatModelConfig.builder()
+ .providerConfig(new GoogleVertexAiGeminiProviderConfig(
+ "key.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY))
+ .modelId("gemini-2.5-flash")
+ .temperature(0.7)
+ .topP(0.9)
+ .topK(40)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.GOOGLE_GENAI);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("gemini-2.5-flash");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.topK()).isEqualTo(40);
+ assertThat(params.maxOutputTokens()).isEqualTo(500);
+ }
+
+ @Test
+ void shouldConfigureMistralAiModel_whenGivenMistralAiConfig() {
+ // GIVEN
+ var config = MistralAiChatModelConfig.builder()
+ .providerConfig(new MistralAiProviderConfig("test-key"))
+ .modelId("mistral-large-latest")
+ .temperature(0.7)
+ .topP(0.9)
+ .frequencyPenalty(0.5)
+ .presencePenalty(0.25)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.MISTRAL_AI);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("mistral-large-latest");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.frequencyPenalty()).isEqualTo(0.5);
+ assertThat(params.presencePenalty()).isEqualTo(0.25);
+ assertThat(params.maxOutputTokens()).isEqualTo(500);
+ }
+
+ @Test
+ void shouldConfigureAnthropicModel_whenGivenAnthropicConfig() {
+ // GIVEN
+ var config = AnthropicChatModelConfig.builder()
+ .providerConfig(new AnthropicProviderConfig("test-key"))
+ .modelId("claude-opus-4-8")
+ .temperature(0.7)
+ .topP(0.9)
+ .topK(40)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.ANTHROPIC);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("claude-opus-4-8");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.topK()).isEqualTo(40);
+ assertThat(params.maxOutputTokens()).isEqualTo(500);
+ }
+
@Test
- void configureChatModel_openAi_withPrivateIp_shouldThrow() {
+ void shouldConfigureAmazonBedrockModel_whenGivenAmazonBedrockConfig() {
+ // GIVEN
+ var config = AmazonBedrockChatModelConfig.builder()
+ .providerConfig(new AmazonBedrockProviderConfig(
+ "us-east-1", "test-access-key-id", "test-secret-access-key"))
+ .modelId("anthropic.claude-3-5-sonnet-20240620-v1:0")
+ .temperature(0.7)
+ .topP(0.9)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.AMAZON_BEDROCK);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("anthropic.claude-3-5-sonnet-20240620-v1:0");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.maxOutputTokens()).isEqualTo(500);
+ }
+
+ @Test
+ void shouldConfigureGitHubModelsModel_whenGivenGitHubModelsConfig() {
+ // GIVEN
+ var config = GitHubModelsChatModelConfig.builder()
+ .providerConfig(new GitHubModelsProviderConfig("ghp-test-token"))
+ .modelId("gpt-4o")
+ .temperature(0.7)
+ .topP(0.9)
+ .frequencyPenalty(0.5)
+ .presencePenalty(0.25)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.GITHUB_MODELS);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("gpt-4o");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.frequencyPenalty()).isEqualTo(0.5);
+ assertThat(params.presencePenalty()).isEqualTo(0.25);
+ assertThat(params.maxOutputTokens()).isEqualTo(500); // maxCompletionTokens maps to maxOutputTokens
+ }
+
+ @Test
+ void shouldConfigureOllamaModel_whenGivenOllamaConfig() {
+ // GIVEN
+ var config = OllamaChatModelConfig.builder()
+ .providerConfig(new OllamaProviderConfig(
+ "http://localhost:11434", new OllamaProviderConfig.OllamaAuth.None()))
+ .modelId("llama3")
+ .temperature(0.7)
+ .topP(0.9)
+ .topK(40)
+ .contextLength(4096)
+ .maxOutputTokens(500)
+ .timeoutSeconds(60)
+ .maxRetries(3)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(config);
+
+ // THEN
+ assertThat(chatModel.provider()).isEqualTo(ModelProvider.OLLAMA);
+ ChatRequestParameters params = chatModel.defaultRequestParameters();
+ assertThat(params.modelName()).isEqualTo("llama3");
+ assertThat(params.temperature()).isEqualTo(0.7);
+ assertThat(params.topP()).isEqualTo(0.9);
+ assertThat(params.topK()).isEqualTo(40);
+ assertThat(params.maxOutputTokens()).isEqualTo(500); // numPredict maps to maxOutputTokens
+ }
+
+ // ============================== Base URL SSRF validation ==============================
+ // Providers that accept a user-supplied base URL must reject hosts that resolve to private/loopback addresses
+ // when SSRF protection is enabled.
+
+ @Test
+ void shouldThrow_whenOpenAiBaseUrlIsPrivateIp() {
+ // GIVEN
+ SsrfProtectionValidator.setEnabled(true);
var config = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://172.17.0.1:8080/")
@@ -73,13 +344,16 @@ class Langchain4jChatModelConfigurerImplTest {
.modelId("gpt-4o")
.build();
+ // WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
- void configureChatModel_openAi_withLocalhostUrl_shouldThrow() {
+ void shouldThrow_whenOpenAiBaseUrlIsLocalhost() {
+ // GIVEN
+ SsrfProtectionValidator.setEnabled(true);
var config = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://localhost:22/")
@@ -88,57 +362,42 @@ class Langchain4jChatModelConfigurerImplTest {
.modelId("gpt-4o")
.build();
+ // WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
- void configureChatModel_azureOpenAi_withPrivateIp_shouldThrow() {
+ void shouldThrow_whenAzureOpenAiEndpointIsPrivateIp() {
+ // GIVEN
+ SsrfProtectionValidator.setEnabled(true);
var config = AzureOpenAiChatModelConfig.builder()
.providerConfig(new AzureOpenAiProviderConfig(
"http://10.0.0.1:8080/", null, "test-key"))
.modelId("gpt-4o")
.build();
+ // WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
- void configureChatModel_ollama_withPrivateIp_shouldThrow() {
+ void shouldThrow_whenOllamaBaseUrlIsPrivateIp() {
+ // GIVEN
+ SsrfProtectionValidator.setEnabled(true);
var config = OllamaChatModelConfig.builder()
.providerConfig(new OllamaProviderConfig(
"http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None()))
.modelId("llama3")
.build();
+ // WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
- @Test
- void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() {
- // GIVEN
- var providerConfig = new GoogleVertexAiGeminiProviderConfig(
- "test.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY
- );
- var chatModelConfig = GoogleVertexAiGeminiChatModelConfig.builder()
- .providerConfig(providerConfig)
- .modelId("gemini-2.0-flash")
- .frequencyPenalty(0.3)
- .presencePenalty(0.7)
- .build();
-
- // WHEN
- ChatModel chatModel = configurer.configureChatModel(chatModelConfig);
-
- // THEN
- var generationConfig = (GenerationConfig) ReflectionTestUtils.getField(chatModel, "generationConfig");
- assertThat(generationConfig.getFrequencyPenalty()).isEqualTo(0.3f);
- assertThat(generationConfig.getPresencePenalty()).isEqualTo(0.7f);
- }
-
}
diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
index 4454d41e6e..35f637dc09 100644
--- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
@@ -154,7 +154,7 @@ public class SimpleCalculatedFieldStateTest {
Output output = getCalculatedFieldConfig().getOutput();
assertThat(result.getType()).isEqualTo(output.getType());
assertThat(result.getScope()).isEqualTo(output.getScope());
- assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49)));
+ assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49L)));
}
@Test
@@ -184,7 +184,7 @@ public class SimpleCalculatedFieldStateTest {
Output output = getCalculatedFieldConfig().getOutput();
assertThat(result.getType()).isEqualTo(output.getType());
assertThat(result.getScope()).isEqualTo(output.getScope());
- assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35)));
+ assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35L)));
}
@Test
diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java
new file mode 100644
index 0000000000..81cc325bdb
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java
@@ -0,0 +1,141 @@
+/**
+ * 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.service.entitiy.queue;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.thingsboard.server.cluster.TbClusterService;
+import org.thingsboard.server.common.data.id.QueueId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.queue.Queue;
+import org.thingsboard.server.dao.queue.QueueService;
+import org.thingsboard.server.queue.TbQueueAdmin;
+import org.thingsboard.server.queue.discovery.TopicService;
+
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class DefaultTbQueueServiceTest {
+
+ @Mock
+ private QueueService queueServiceMock;
+ @Mock
+ private TbClusterService tbClusterServiceMock;
+ @Mock
+ private TbQueueAdmin tbQueueAdminMock;
+
+ private TopicService topicService;
+ private DefaultTbQueueService tbQueueService;
+
+ private final TenantId tenantId = TenantId.SYS_TENANT_ID;
+
+ @BeforeEach
+ public void setUp() {
+ topicService = new TopicService();
+ tbQueueService = new DefaultTbQueueService(queueServiceMock, tbClusterServiceMock, tbQueueAdminMock, topicService);
+ }
+
+ private Queue newQueue(int partitions) {
+ Queue queue = new Queue();
+ queue.setTenantId(tenantId);
+ queue.setName("testQueue");
+ queue.setTopic("tb_rule_engine.testQueue");
+ queue.setPartitions(partitions);
+ return queue;
+ }
+
+ @Test
+ public void givenQueuePrefix_whenSaveQueue_thenCreatesPrefixedTopics() {
+ // queue.prefix = "thingsboard" (TB_QUEUE_PREFIX set)
+ ReflectionTestUtils.setField(topicService, "prefix", "thingsboard");
+
+ Queue queue = newQueue(2);
+ when(queueServiceMock.saveQueue(queue)).thenReturn(queue);
+
+ tbQueueService.saveQueue(queue);
+
+ ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class);
+ verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean());
+
+ // All created topics must carry the prefix - this is the fix.
+ assertThat(topicCaptor.getAllValues())
+ .containsExactlyInAnyOrder(
+ "thingsboard.tb_rule_engine.testQueue.0",
+ "thingsboard.tb_rule_engine.testQueue.1");
+ // No unprefixed (orphan-prone) topic must ever be created.
+ assertThat(topicCaptor.getAllValues())
+ .noneMatch(topic -> topic.equals("tb_rule_engine.testQueue.0")
+ || topic.equals("tb_rule_engine.testQueue.1"));
+ }
+
+ @Test
+ public void givenNoQueuePrefix_whenSaveQueue_thenCreatesUnprefixedTopics() {
+ // queue.prefix blank (TB_QUEUE_PREFIX not set) - default behavior preserved
+ ReflectionTestUtils.setField(topicService, "prefix", "");
+
+ Queue queue = newQueue(2);
+ when(queueServiceMock.saveQueue(queue)).thenReturn(queue);
+
+ tbQueueService.saveQueue(queue);
+
+ ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class);
+ verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean());
+
+ assertThat(topicCaptor.getAllValues())
+ .containsExactlyInAnyOrder(
+ "tb_rule_engine.testQueue.0",
+ "tb_rule_engine.testQueue.1");
+ }
+
+ @Test
+ public void givenQueuePrefix_whenIncreasePartitions_thenOnlyNewPartitionsCreatedPrefixed() {
+ ReflectionTestUtils.setField(topicService, "prefix", "thingsboard");
+
+ Queue oldQueue = newQueue(2);
+ oldQueue.setId(new QueueId(UUID.randomUUID()));
+ Queue updatedQueue = newQueue(4);
+ updatedQueue.setId(oldQueue.getId());
+
+ when(queueServiceMock.findQueueById(tenantId, updatedQueue.getId())).thenReturn(oldQueue);
+ when(queueServiceMock.saveQueue(updatedQueue)).thenReturn(updatedQueue);
+
+ tbQueueService.saveQueue(updatedQueue);
+
+ ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class);
+ verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean());
+
+ assertThat(topicCaptor.getAllValues())
+ .containsExactlyInAnyOrder(
+ "thingsboard.tb_rule_engine.testQueue.2",
+ "thingsboard.tb_rule_engine.testQueue.3");
+ verify(tbQueueAdminMock, never()).createTopicIfNotExists(eq("thingsboard.tb_rule_engine.testQueue.0"), any(), anyBoolean());
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java
index 86b5ae2cf8..53c855aa4d 100644
--- a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java
@@ -27,8 +27,11 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.rpc.RpcError;
import org.thingsboard.server.common.msg.queue.TbCallback;
+import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse;
import org.thingsboard.server.gen.transport.TransportProtos;
+import org.thingsboard.server.service.rpc.TbCoreDeviceRpcService;
import org.thingsboard.server.service.ruleengine.RuleEngineCallService;
import org.thingsboard.server.service.state.DeviceStateService;
@@ -51,6 +54,8 @@ public class DefaultTbCoreConsumerServiceTest {
private TbCoreConsumerStats statsMock;
@Mock
private RuleEngineCallService ruleEngineCallServiceMock;
+ @Mock
+ private TbCoreDeviceRpcService tbCoreDeviceRpcServiceMock;
@Mock
private TbCallback tbCallbackMock;
@@ -638,4 +643,31 @@ public class DefaultTbCoreConsumerServiceTest {
then(ruleEngineCallServiceMock).should().onQueueMsg(restApiCallResponseMsgProto, tbCallbackMock);
}
+ @Test
+ public void givenNotFoundErrorAndNoResponse_whenForwardToCoreRpcService_thenNotFoundAndNullResponseAreRecovered() {
+ // GIVEN
+ ReflectionTestUtils.setField(defaultTbCoreConsumerServiceMock, "tbCoreDeviceRpcService", tbCoreDeviceRpcServiceMock);
+ var requestId = UUID.randomUUID();
+ // error = NOT_FOUND.ordinal() (0) and response left unset: the previously broken combination
+ // ('error > 0' dropped NOT_FOUND, proto3 default collapsed a null response to "").
+ var proto = TransportProtos.FromDeviceRPCResponseProto.newBuilder()
+ .setRequestIdMSB(requestId.getMostSignificantBits())
+ .setRequestIdLSB(requestId.getLeastSignificantBits())
+ .setError(RpcError.NOT_FOUND.ordinal())
+ .build();
+ doCallRealMethod().when(defaultTbCoreConsumerServiceMock).forwardToCoreRpcService(proto, tbCallbackMock);
+
+ // WHEN
+ defaultTbCoreConsumerServiceMock.forwardToCoreRpcService(proto, tbCallbackMock);
+
+ // THEN
+ var responseCaptor = ArgumentCaptor.forClass(FromDeviceRpcResponse.class);
+ then(tbCoreDeviceRpcServiceMock).should().processRpcResponseFromRuleEngine(responseCaptor.capture());
+ var response = responseCaptor.getValue();
+ assertThat(response.getId()).isEqualTo(requestId);
+ assertThat(response.getError()).contains(RpcError.NOT_FOUND);
+ assertThat(response.getResponse()).isEmpty();
+ then(tbCallbackMock).should().onSuccess();
+ }
+
}
diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java
new file mode 100644
index 0000000000..1cf41eca0c
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java
@@ -0,0 +1,78 @@
+/**
+ * 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.service.queue;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.thingsboard.server.common.data.rpc.RpcError;
+import org.thingsboard.server.common.msg.queue.TbCallback;
+import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse;
+import org.thingsboard.server.gen.transport.TransportProtos;
+import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineNotificationMsg;
+import org.thingsboard.server.queue.common.TbProtoQueueMsg;
+import org.thingsboard.server.service.rpc.TbRuleEngineDeviceRpcService;
+
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.doCallRealMethod;
+
+@ExtendWith(MockitoExtension.class)
+public class DefaultTbRuleEngineConsumerServiceTest {
+
+ @Mock
+ private TbRuleEngineDeviceRpcService tbDeviceRpcServiceMock;
+ @Mock
+ private TbCallback tbCallbackMock;
+
+ @Mock
+ private DefaultTbRuleEngineConsumerService defaultTbRuleEngineConsumerServiceMock;
+
+ @Test
+ public void givenNotFoundErrorAndNoResponse_whenHandleFromDeviceRpcResponse_thenNotFoundAndNullResponseAreRecovered() {
+ // GIVEN
+ ReflectionTestUtils.setField(defaultTbRuleEngineConsumerServiceMock, "tbDeviceRpcService", tbDeviceRpcServiceMock);
+ var requestId = UUID.randomUUID();
+ // error = NOT_FOUND.ordinal() (0) and response left unset: the previously broken combination
+ // ('error > 0' dropped NOT_FOUND, proto3 default collapsed a null response to "").
+ var proto = TransportProtos.FromDeviceRPCResponseProto.newBuilder()
+ .setRequestIdMSB(requestId.getMostSignificantBits())
+ .setRequestIdLSB(requestId.getLeastSignificantBits())
+ .setError(RpcError.NOT_FOUND.ordinal())
+ .build();
+ var nfMsg = ToRuleEngineNotificationMsg.newBuilder().setFromDeviceRpcResponse(proto).build();
+ var queueMsg = new TbProtoQueueMsg<>(requestId, nfMsg);
+ doCallRealMethod().when(defaultTbRuleEngineConsumerServiceMock).handleNotification(requestId, queueMsg, tbCallbackMock);
+
+ // WHEN
+ defaultTbRuleEngineConsumerServiceMock.handleNotification(requestId, queueMsg, tbCallbackMock);
+
+ // THEN
+ var responseCaptor = ArgumentCaptor.forClass(FromDeviceRpcResponse.class);
+ then(tbDeviceRpcServiceMock).should().processRpcResponseFromDevice(responseCaptor.capture());
+ var response = responseCaptor.getValue();
+ assertThat(response.getId()).isEqualTo(requestId);
+ assertThat(response.getError()).contains(RpcError.NOT_FOUND);
+ assertThat(response.getResponse()).isEmpty();
+ then(tbCallbackMock).should().onSuccess();
+ }
+
+}
diff --git a/common/actor/pom.xml b/common/actor/pom.xml
index 6d46c10198..b3f04d31a1 100644
--- a/common/actor/pom.xml
+++ b/common/actor/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/cache/pom.xml b/common/cache/pom.xml
index aa1438d97a..e95bb301b4 100644
--- a/common/cache/pom.xml
+++ b/common/cache/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml
index e8968e2a2c..63dc714000 100644
--- a/common/cluster-api/pom.xml
+++ b/common/cluster-api/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml
index 5314510d52..d713c6d065 100644
--- a/common/coap-server/pom.xml
+++ b/common/coap-server/pom.xml
@@ -22,7 +22,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
index 3b7248ee72..8ffa489f7b 100644
--- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
+++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
@@ -85,7 +85,9 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial
dtlsSessionsExecutor.shutdownNow();
}
log.info("Stopping CoAP server!");
- server.destroy();
+ if (server != null) {
+ server.destroy();
+ }
log.info("CoAP server stopped!");
}
@@ -105,27 +107,47 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial
private CoapServer createCoapServer() throws UnknownHostException {
Configuration networkConfig = createNetworkConfiguration();
- server = new CoapServer(networkConfig);
-
- CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder();
- InetAddress addr = InetAddress.getByName(coapServerContext.getHost());
- InetSocketAddress sockAddr = new InetSocketAddress(addr, coapServerContext.getPort());
- noSecCoapEndpointBuilder.setInetSocketAddress(sockAddr);
+ try {
+ server = new CoapServer(networkConfig);
+ CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder();
+ InetAddress addr = InetAddress.getByName(coapServerContext.getHost());
+ InetSocketAddress sockAddr = new InetSocketAddress(addr, coapServerContext.getPort());
+ noSecCoapEndpointBuilder.setInetSocketAddress(sockAddr);
+
+ noSecCoapEndpointBuilder.setConfiguration(networkConfig);
+ CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build();
+ server.addEndpoint(noSecCoapEndpoint);
+ if (isDtlsEnabled()) {
+ createDtlsEndpoint(networkConfig);
+ dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName());
+ dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS);
+ }
+ Resource root = server.getRoot();
+ TbCoapServerMessageDeliverer messageDeliverer = new TbCoapServerMessageDeliverer(root);
+ server.setMessageDeliverer(messageDeliverer);
- noSecCoapEndpointBuilder.setConfiguration(networkConfig);
- CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build();
- server.addEndpoint(noSecCoapEndpoint);
- if (isDtlsEnabled()) {
- createDtlsEndpoint(networkConfig);
- dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName());
- dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS);
+ server.start();
+ return server;
+ } catch (RuntimeException | UnknownHostException e) {
+ log.error("Failed to start CoAP server, releasing resources", e);
+ try {
+ if (dtlsSessionsExecutor != null) {
+ dtlsSessionsExecutor.shutdownNow();
+ }
+ if (server != null) {
+ server.destroy();
+ }
+ } catch (Exception suppressed) {
+ e.addSuppressed(suppressed);
+ } finally {
+ server = null;
+ dtlsSessionsExecutor = null;
+ dtlsConnector = null;
+ dtlsCoapEndpoint = null;
+ tbDtlsCertificateVerifier = null;
+ }
+ throw e;
}
- Resource root = server.getRoot();
- TbCoapServerMessageDeliverer messageDeliverer = new TbCoapServerMessageDeliverer(root);
- server.setMessageDeliverer(messageDeliverer);
-
- server.start();
- return server;
}
private boolean isDtlsEnabled() {
diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java
new file mode 100644
index 0000000000..0c9e9fc29d
--- /dev/null
+++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java
@@ -0,0 +1,145 @@
+/**
+ * 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.coapserver;
+
+import org.eclipse.californium.core.CoapServer;
+import org.eclipse.californium.core.network.CoapEndpoint;
+import org.eclipse.californium.core.server.resources.Resource;
+import org.eclipse.californium.scandium.DTLSConnector;
+import org.eclipse.californium.scandium.config.DtlsConnectorConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.thingsboard.common.util.ThingsBoardExecutors;
+
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.util.concurrent.ScheduledExecutorService;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class DefaultCoapServerServiceTest {
+
+ private static final String HOST = "127.0.0.1";
+
+ @Mock
+ private CoapServerContext mockCoapServerContext;
+
+ private DefaultCoapServerService service;
+ private DatagramSocket occupiedSocket;
+ private int occupiedPort;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ occupiedSocket = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0));
+ occupiedPort = occupiedSocket.getLocalPort();
+
+ service = new DefaultCoapServerService();
+ ReflectionTestUtils.setField(service, "coapServerContext", mockCoapServerContext);
+
+ when(mockCoapServerContext.getHost()).thenReturn(HOST);
+ when(mockCoapServerContext.getPort()).thenReturn(occupiedPort);
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(null);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ if (occupiedSocket != null && !occupiedSocket.isClosed()) {
+ occupiedSocket.close();
+ }
+ }
+
+ @Test
+ public void whenPlainBindFails_thenInitThrowsAndReleasesCoapServer() {
+ assertThatThrownBy(() -> service.init())
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("None of the server endpoints could be started");
+
+ assertThat(ReflectionTestUtils.getField(service, "server")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "dtlsSessionsExecutor")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "dtlsConnector")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "dtlsCoapEndpoint")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "tbDtlsCertificateVerifier")).isNull();
+ }
+
+ @Test
+ public void whenDtlsEnabledAndStartFails_thenInitShutsDownDtlsExecutorAndReleasesCoapServer() throws Exception {
+ // DTLS enabled: the DTLS endpoint is created and dtlsSessionsExecutor is scheduled before server.start().
+ // This exercises the catch's dtlsSessionsExecutor.shutdownNow() branch, which the plain-bind test does not.
+ TbCoapDtlsSettings mockDtlsSettings = mock(TbCoapDtlsSettings.class);
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
+
+ DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class);
+ when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress(InetAddress.getByName(HOST), occupiedPort + 1));
+ TbCoapDtlsCertificateVerifier mockVerifier = mock(TbCoapDtlsCertificateVerifier.class);
+ when(mockVerifier.getDtlsSessionReportTimeout()).thenReturn(1800000L);
+ when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockVerifier);
+ when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig);
+
+ ScheduledExecutorService mockExecutor = mock(ScheduledExecutorService.class);
+ Resource mockRoot = mock(Resource.class);
+
+ try (MockedStatic executorsStatic = mockStatic(ThingsBoardExecutors.class);
+ MockedConstruction serverMock = mockConstruction(CoapServer.class, (server, ctx) -> {
+ when(server.getRoot()).thenReturn(mockRoot);
+ doThrow(new IllegalStateException("None of the server endpoints could be started")).when(server).start();
+ });
+ MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class);
+ MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class, (builder, ctx) -> {
+ when(builder.setInetSocketAddress(any())).thenReturn(builder);
+ when(builder.setConfiguration(any())).thenReturn(builder);
+ when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder);
+ when(builder.build()).thenReturn(mock(CoapEndpoint.class));
+ })) {
+
+ executorsStatic.when(() -> ThingsBoardExecutors.newSingleThreadScheduledExecutor(anyString())).thenReturn(mockExecutor);
+
+ assertThatThrownBy(() -> service.init())
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("None of the server endpoints could be started");
+
+ // DTLS branch was actually entered and the executor was created...
+ verify(mockDtlsSettings).dtlsConnectorConfig(any());
+ // ...and the cleanup branch shut it down and destroyed the server.
+ verify(mockExecutor).shutdownNow();
+ verify(serverMock.constructed().get(0)).destroy();
+ }
+
+ assertThat(ReflectionTestUtils.getField(service, "server")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "dtlsSessionsExecutor")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "dtlsConnector")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "dtlsCoapEndpoint")).isNull();
+ assertThat(ReflectionTestUtils.getField(service, "tbDtlsCertificateVerifier")).isNull();
+ }
+
+}
diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml
index 30eb360490..87b217e205 100644
--- a/common/dao-api/pom.xml
+++ b/common/dao-api/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/data/pom.xml b/common/data/pom.xml
index 8579103da7..0e9d306b85 100644
--- a/common/data/pom.xml
+++ b/common/data/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java
index 3cb6e024fc..54ce580e66 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java
@@ -26,6 +26,7 @@ import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
+import java.util.regex.Pattern;
import static org.apache.commons.lang3.StringUtils.repeat;
@@ -39,6 +40,12 @@ public class StringUtils {
public static final int INDEX_NOT_FOUND = -1;
+ public static final Pattern CONTROL_CHARS = Pattern.compile("[\\x00-\\x1F\\x7F]");
+
+ public static boolean containsControlChars(String source) {
+ return source != null && CONTROL_CHARS.matcher(source).find();
+ }
+
public static boolean isEmpty(String source) {
return source == null || source.isEmpty();
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
index 3cd2ada38f..888df32219 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
@@ -45,4 +45,6 @@ public class SystemParams {
long minAllowedAggregationIntervalInSecForCF;
long intermediateAggregationIntervalInSecForCF;
TrendzSettings trendzSettings;
+ String nullsOrderStrategy;
+ boolean edqsEnabled;
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java
index 5f7772ed8b..abd25769d6 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java
@@ -42,6 +42,8 @@ public sealed interface AiChatModelConfig> extend
C withMaxRetries(Integer maxRetries);
- boolean supportsJsonMode();
+ boolean supportsSchemalessJsonOutput();
+
+ boolean supportsJsonSchemaOutput();
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java
index 490ae67eb2..738e27a3d1 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java
@@ -52,8 +52,13 @@ public record AmazonBedrockChatModelConfig(
}
@Override
- public boolean supportsJsonMode() {
+ public boolean supportsSchemalessJsonOutput() {
return false;
}
+ @Override
+ public boolean supportsJsonSchemaOutput() {
+ return true;
+ }
+
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java
index 77352b06c3..49cebe3e25 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java
@@ -53,8 +53,13 @@ public record AnthropicChatModelConfig(
}
@Override
- public boolean supportsJsonMode() {
+ public boolean supportsSchemalessJsonOutput() {
return false;
}
+ @Override
+ public boolean supportsJsonSchemaOutput() {
+ return true;
+ }
+
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java
index d4df82d237..11fc5bcf3e 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java
@@ -54,7 +54,12 @@ public record AzureOpenAiChatModelConfig(
}
@Override
- public boolean supportsJsonMode() {
+ public boolean supportsSchemalessJsonOutput() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsJsonSchemaOutput() {
return true;
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java
index 264683f7b4..578dfa6aa3 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java
@@ -54,8 +54,13 @@ public record GitHubModelsChatModelConfig(
}
@Override
- public boolean supportsJsonMode() {
- return false;
+ public boolean supportsSchemalessJsonOutput() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsJsonSchemaOutput() {
+ return true;
}
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java
index 82ba3a0295..472e1629dd 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java
@@ -55,7 +55,12 @@ public record GoogleAiGeminiChatModelConfig(
}
@Override
- public boolean supportsJsonMode() {
+ public boolean supportsSchemalessJsonOutput() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsJsonSchemaOutput() {
return true;
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java
index 4d20a240e6..80502ee2f4 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java
@@ -55,7 +55,12 @@ public record GoogleVertexAiGeminiChatModelConfig(
}
@Override
- public boolean supportsJsonMode() {
+ public boolean supportsSchemalessJsonOutput() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsJsonSchemaOutput() {
return true;
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java
index 60c208c181..492ca6967f 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java
@@ -54,7 +54,12 @@ public record MistralAiChatModelConfig(
}
@Override
- public boolean supportsJsonMode() {
+ public boolean supportsSchemalessJsonOutput() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsJsonSchemaOutput() {
return true;
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java
index 5f7776a0a6..758487a956 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java
@@ -54,7 +54,12 @@ public record OllamaChatModelConfig(
}
@Override
- public boolean supportsJsonMode() {
+ public boolean supportsSchemalessJsonOutput() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsJsonSchemaOutput() {
return true;
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java
index 3845aaa543..ab7835c7f2 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java
@@ -54,7 +54,12 @@ public record OpenAiChatModelConfig(
}
@Override
- public boolean supportsJsonMode() {
+ public boolean supportsSchemalessJsonOutput() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsJsonSchemaOutput() {
return true;
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java
index 80d08a594d..44ee9744de 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java
@@ -24,7 +24,7 @@ public enum AlarmCommentSubType {
ASSIGNED_TO_USER("Alarm was assigned by user %s to user %s"),
UNASSIGNED_BY_USER("Alarm was unassigned by user %s"),
UNASSIGNED_FROM_DELETED_USER("Alarm was unassigned because user %s - was deleted"),
- COMMENT_DELETED("User %s deleted his comment"),
+ COMMENT_DELETED("Comment was deleted by user %s"),
SEVERITY_CHANGED("Alarm severity was updated from %s to %s");
@Getter
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java b/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java
index 03cd1d69cb..c5575b53be 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java
@@ -20,4 +20,16 @@ package org.thingsboard.server.common.data.rpc;
*/
public enum RpcError {
NOT_FOUND, FORBIDDEN, NO_ACTIVE_CONNECTION, TIMEOUT, INTERNAL;
+
+ private static final RpcError[] VALUES = values();
+
+ /**
+ * Resolves an {@link RpcError} from the proto {@code error} ordinal.
+ * Returns {@code null} both for the "no error" sentinel (negative value) and for unknown ordinals
+ * that a newer node in a mixed-version cluster might emit, so callers never hit an
+ * {@link ArrayIndexOutOfBoundsException}.
+ */
+ public static RpcError fromProtoErrorCode(int errorCode) {
+ return errorCode >= 0 && errorCode < VALUES.length ? VALUES[errorCode] : null;
+ }
}
diff --git a/common/discovery-api/pom.xml b/common/discovery-api/pom.xml
index f74163642d..275d7762d5 100644
--- a/common/discovery-api/pom.xml
+++ b/common/discovery-api/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/edge-api/pom.xml b/common/edge-api/pom.xml
index 2bff3e0b18..647accd360 100644
--- a/common/edge-api/pom.xml
+++ b/common/edge-api/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto
index 311c345b7a..11de63a977 100644
--- a/common/edge-api/src/main/proto/edge.proto
+++ b/common/edge-api/src/main/proto/edge.proto
@@ -49,10 +49,12 @@ enum EdgeVersion {
V_4_2_2 = 4220;
V_4_2_2_1 = 4221;
V_4_2_2_2 = 4222;
+ V_4_2_2_3 = 4223;
V_4_3_0_1 = 15;
V_4_3_1 = 4310;
V_4_3_1_1 = 4311;
V_4_3_1_2 = 4312;
+ V_4_3_1_3 = 4313;
V_LATEST = 99999;
}
diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml
index 854ed34c54..769606efad 100644
--- a/common/edqs/pom.xml
+++ b/common/edqs/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/message/pom.xml b/common/message/pom.xml
index 48a6471014..ee6d296262 100644
--- a/common/message/pom.xml
+++ b/common/message/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/pom.xml b/common/pom.xml
index 0759821ed4..c8f3935110 100644
--- a/common/pom.xml
+++ b/common/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
thingsboard
common
diff --git a/common/proto/pom.xml b/common/proto/pom.xml
index 433b84ceff..bee053364f 100644
--- a/common/proto/pom.xml
+++ b/common/proto/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java
index 784bd97c79..8627de539e 100644
--- a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java
+++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java
@@ -585,10 +585,11 @@ public class ProtoUtils {
}
private static ToDeviceActorNotificationMsg fromProto(TransportProtos.FromDeviceRpcResponseActorMsgProto proto) {
+ TransportProtos.FromDeviceRPCResponseProto rpcResponse = proto.getRpcResponse();
FromDeviceRpcResponse fromDeviceRpcResponse = new FromDeviceRpcResponse(
- new UUID(proto.getRpcResponse().getRequestIdMSB(), proto.getRpcResponse().getRequestIdLSB()),
- proto.getRpcResponse().getResponse(),
- proto.getRpcResponse().getError() >= 0 ? RpcError.values()[proto.getRpcResponse().getError()] : null);
+ new UUID(rpcResponse.getRequestIdMSB(), rpcResponse.getRequestIdLSB()),
+ rpcResponse.hasResponse() ? rpcResponse.getResponse() : null,
+ RpcError.fromProtoErrorCode(rpcResponse.getError()));
return new FromDeviceRpcResponseActorMsg(
proto.getRequestId(),
TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())),
diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto
index 68f7052c69..2e5a382185 100644
--- a/common/proto/src/main/proto/queue.proto
+++ b/common/proto/src/main/proto/queue.proto
@@ -1273,7 +1273,7 @@ message LocalSubscriptionServiceMsgProto {
message FromDeviceRPCResponseProto {
int64 requestIdMSB = 1;
int64 requestIdLSB = 2;
- string response = 3;
+ optional string response = 3;
int32 error = 4;
}
diff --git a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java
index 78e30aa480..cf376365b5 100644
--- a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java
+++ b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java
@@ -228,6 +228,17 @@ class ProtoUtilsTest {
assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg);
}
+ @Test
+ void protoFromDeviceRpcResponseOnewaySerialization() {
+ // Oneway RPC success: response and error are both null. Relies on the proto
+ // 'optional string response' presence bit so the receiver round-trips null
+ // rather than seeing the proto3 default "".
+ FromDeviceRpcResponseActorMsg msg = new FromDeviceRpcResponseActorMsg(23, tenantId, deviceId, new FromDeviceRpcResponse(id, null, null));
+ TransportProtos.ToDeviceActorNotificationMsgProto serializedMsg = ProtoUtils.toProto(msg);
+ Assertions.assertNotNull(serializedMsg);
+ assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg);
+ }
+
@Test
void protoRemoveRpcActorSerialization() {
RemoveRpcActorMsg msg = new RemoveRpcActorMsg(tenantId, deviceId, id);
diff --git a/common/queue/pom.xml b/common/queue/pom.xml
index 16a508ee43..b24edd37d3 100644
--- a/common/queue/pom.xml
+++ b/common/queue/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/script/pom.xml b/common/script/pom.xml
index 278799abee..db477115cb 100644
--- a/common/script/pom.xml
+++ b/common/script/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/script/remote-js-client/pom.xml b/common/script/remote-js-client/pom.xml
index 4de100d423..8add60dd90 100644
--- a/common/script/remote-js-client/pom.xml
+++ b/common/script/remote-js-client/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard.common
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
script
org.thingsboard.common.script
diff --git a/common/script/script-api/pom.xml b/common/script/script-api/pom.xml
index 2455dc3423..45adffcceb 100644
--- a/common/script/script-api/pom.xml
+++ b/common/script/script-api/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard.common
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
script
org.thingsboard.common.script
diff --git a/common/stats/pom.xml b/common/stats/pom.xml
index a89ee61a62..28930308ce 100644
--- a/common/stats/pom.xml
+++ b/common/stats/pom.xml
@@ -22,7 +22,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml
index a44fd7334f..0608a957ab 100644
--- a/common/transport/coap/pom.xml
+++ b/common/transport/coap/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard.common
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
transport
org.thingsboard.common.transport
diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml
index 9e51de98b4..77949bb51e 100644
--- a/common/transport/http/pom.xml
+++ b/common/transport/http/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard.common
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
transport
org.thingsboard.common.transport
diff --git a/common/transport/lwm2m/pom.xml b/common/transport/lwm2m/pom.xml
index 9bf13bb33d..e71e0d9a9d 100644
--- a/common/transport/lwm2m/pom.xml
+++ b/common/transport/lwm2m/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard.common
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
transport
org.thingsboard.common.transport
diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java
index 9b370d0b71..639e0bf74a 100644
--- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java
+++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java
@@ -82,13 +82,32 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto
@PostConstruct
public void init() {
log.info("Starting LwM2M transport bootstrap server...");
- this.server = getLhBootstrapServer();
- this.server.start();
- log.info("Started LwM2M transport bootstrap server.");
+ LeshanBootstrapServer bootstrapServer = null;
+ try {
+ bootstrapServer = getLhBootstrapServer();
+ this.server = bootstrapServer;
+ bootstrapServer.start();
+ log.info("Started LwM2M transport bootstrap server.");
+ } catch (RuntimeException e) {
+ log.error("Failed to start LwM2M transport bootstrap server, releasing resources", e);
+ try {
+ if (bootstrapServer != null) {
+ bootstrapServer.destroy();
+ }
+ } catch (Exception suppressed) {
+ e.addSuppressed(suppressed);
+ } finally {
+ this.server = null;
+ }
+ throw e;
+ }
}
@PreDestroy
public void shutdown() {
+ if (server == null) {
+ return;
+ }
try {
log.info("Stopping LwM2M transport bootstrap server!");
server.destroy();
diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java
new file mode 100644
index 0000000000..67e8265361
--- /dev/null
+++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java
@@ -0,0 +1,115 @@
+/**
+ * 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.lwm2m.bootstrap;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.thingsboard.server.common.transport.TransportService;
+import org.thingsboard.server.transport.lwm2m.bootstrap.secure.TbLwM2MDtlsBootstrapCertificateVerifier;
+import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MBootstrapSecurityStore;
+import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MInMemoryBootstrapConfigStore;
+import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportBootstrapConfig;
+import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig;
+
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class LwM2MTransportBootstrapServiceTest {
+
+ private static final String HOST = "127.0.0.1";
+
+ @Mock
+ private LwM2MTransportServerConfig serverConfig;
+
+ @Mock
+ private LwM2MTransportBootstrapConfig bootstrapConfig;
+
+ @Mock
+ private LwM2MBootstrapSecurityStore lwM2MBootstrapSecurityStore;
+
+ @Mock
+ private LwM2MInMemoryBootstrapConfigStore lwM2MInMemoryBootstrapConfigStore;
+
+ @Mock
+ private TransportService transportService;
+
+ @Mock
+ private TbLwM2MDtlsBootstrapCertificateVerifier certificateVerifier;
+
+ private LwM2MTransportBootstrapService service;
+ private DatagramSocket occupiedPlain;
+ private DatagramSocket occupiedSecure;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ occupiedPlain = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0));
+ occupiedSecure = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0));
+
+ when(bootstrapConfig.getHost()).thenReturn(HOST);
+ when(bootstrapConfig.getPort()).thenReturn(occupiedPlain.getLocalPort());
+ when(bootstrapConfig.getSecureHost()).thenReturn(HOST);
+ when(bootstrapConfig.getSecurePort()).thenReturn(occupiedSecure.getLocalPort());
+ when(bootstrapConfig.getSslCredentials()).thenReturn(null);
+
+ when(serverConfig.isRecommendedCiphers()).thenReturn(false);
+ when(serverConfig.isRecommendedSupportedGroups()).thenReturn(false);
+ when(serverConfig.getDtlsRetransmissionTimeout()).thenReturn(9000);
+ when(serverConfig.getDtlsCidLength()).thenReturn(null);
+
+ service = new LwM2MTransportBootstrapService(
+ serverConfig,
+ bootstrapConfig,
+ lwM2MBootstrapSecurityStore,
+ lwM2MInMemoryBootstrapConfigStore,
+ transportService,
+ certificateVerifier
+ );
+ }
+
+ @AfterEach
+ public void tearDown() {
+ if (occupiedPlain != null && !occupiedPlain.isClosed()) {
+ occupiedPlain.close();
+ }
+ if (occupiedSecure != null && !occupiedSecure.isClosed()) {
+ occupiedSecure.close();
+ }
+ }
+
+ @Test
+ public void whenEndpointsFailToStart_thenInitThrowsAndReleasesBootstrapServer() {
+ assertThatThrownBy(() -> service.init())
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("None of the server endpoints could be started");
+
+ assertThat(ReflectionTestUtils.getField(service, "server")).isNull();
+ }
+
+}
diff --git a/common/transport/mqtt/pom.xml b/common/transport/mqtt/pom.xml
index 87ca63fcba..2533ec2b0f 100644
--- a/common/transport/mqtt/pom.xml
+++ b/common/transport/mqtt/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard.common
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
transport
org.thingsboard.common.transport
diff --git a/common/transport/pom.xml b/common/transport/pom.xml
index 12f0c87e2c..1629826eae 100644
--- a/common/transport/pom.xml
+++ b/common/transport/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/transport/snmp/pom.xml b/common/transport/snmp/pom.xml
index 2f4e2dae76..bb607f4b35 100644
--- a/common/transport/snmp/pom.xml
+++ b/common/transport/snmp/pom.xml
@@ -21,7 +21,7 @@
org.thingsboard.common
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
transport
diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml
index d02278a5cc..6019b3b17c 100644
--- a/common/transport/transport-api/pom.xml
+++ b/common/transport/transport-api/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard.common
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
transport
org.thingsboard.common.transport
diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java
index e6c3cab1ba..e7ef634c72 100644
--- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java
+++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java
@@ -107,11 +107,12 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi
@Override
public void update(TenantProfileUpdateResult update) {
- log.info("Received tenant profile update: {}", update.getProfile());
- EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(update.getProfile(), TENANT_LIMITS);
- EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(update.getProfile(), DEVICE_LIMITS);
- EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_LIMITS);
- EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_DEVICE_LIMITS);
+ TenantProfile profile = update.getProfile();
+ log.info("Received tenant profile update: {}", profile);
+ EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(profile, TENANT_LIMITS);
+ EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(profile, DEVICE_LIMITS);
+ EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(profile, GATEWAY_LIMITS);
+ EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(profile, GATEWAY_DEVICE_LIMITS);
for (TenantId tenantId : update.getAffectedTenants()) {
update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype, gatewayDeviceRateLimitPrototype);
}
@@ -119,11 +120,13 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi
@Override
public void update(TenantId tenantId) {
- EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS);
- EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS);
- EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS);
- EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_DEVICE_LIMITS);
- update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype, gatewayDeviceRateLimitPrototype);
+ TenantProfile profile = tenantProfileCache.get(tenantId);
+ update(tenantId,
+ createRateLimits(profile, TENANT_LIMITS),
+ createRateLimits(profile, DEVICE_LIMITS),
+ createRateLimits(profile, GATEWAY_LIMITS),
+ createRateLimits(profile, GATEWAY_DEVICE_LIMITS)
+ );
}
private void update(TenantId tenantId, EntityTransportRateLimits tenantRateLimitPrototype, EntityTransportRateLimits deviceRateLimitPrototype,
@@ -231,25 +234,26 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi
BiConsumer putFunction) {
EntityTransportRateLimits oldRateLimits = getFunction.apply(entityId);
if (oldRateLimits == null) {
- if (EntityType.TENANT.equals(entityId.getEntityType())) {
- log.info("[{}] New rate limits: {}", entityId, newRateLimits);
- } else {
- log.debug("[{}] New rate limits: {}", entityId, newRateLimits);
- }
+ logLimits(entityId, "New", newRateLimits);
putFunction.accept(entityId, newRateLimits);
} else {
EntityTransportRateLimits updated = merge(oldRateLimits, newRateLimits);
if (updated != null) {
- if (EntityType.TENANT.equals(entityId.getEntityType())) {
- log.info("[{}] Updated rate limits: {}", entityId, updated);
- } else {
- log.debug("[{}] Updated rate limits: {}", entityId, updated);
- }
+ logLimits(entityId, "Updated", updated);
putFunction.accept(entityId, updated);
}
}
}
+ private void logLimits(EntityId entityId, String action, EntityTransportRateLimits limits) {
+ // Tenant-level changes are logged at INFO; the much noisier per-device/gateway ones at DEBUG.
+ if (EntityType.TENANT.equals(entityId.getEntityType())) {
+ log.info("[{}] {} rate limits: {}", entityId, action, limits);
+ } else {
+ log.debug("[{}] {} rate limits: {}", entityId, action, limits);
+ }
+ }
+
private EntityTransportRateLimits merge(EntityTransportRateLimits oldRateLimits, EntityTransportRateLimits newRateLimits) {
boolean regularUpdate = !oldRateLimits.getRegularMsgRateLimit().getConfiguration().equals(newRateLimits.getRegularMsgRateLimit().getConfiguration());
boolean telemetryMsgRateUpdate = !oldRateLimits.getTelemetryMsgRateLimit().getConfiguration().equals(newRateLimits.getTelemetryMsgRateLimit().getConfiguration());
@@ -269,36 +273,12 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi
DefaultTenantProfileConfiguration profile = (DefaultTenantProfileConfiguration) profileData.getConfiguration();
if (profile == null) {
return new EntityTransportRateLimits(ALLOW, ALLOW, ALLOW);
- } else {
- TransportRateLimit regularMsgRateLimit;
- TransportRateLimit telemetryMsgRateLimit;
- TransportRateLimit telemetryDpRateLimit;
- switch (limitsType) {
- case TENANT_LIMITS -> {
- regularMsgRateLimit = newLimit(profile.getTransportTenantMsgRateLimit());
- telemetryMsgRateLimit = newLimit(profile.getTransportTenantTelemetryMsgRateLimit());
- telemetryDpRateLimit = newLimit(profile.getTransportTenantTelemetryDataPointsRateLimit());
- }
- case DEVICE_LIMITS -> {
- regularMsgRateLimit = newLimit(profile.getTransportDeviceMsgRateLimit());
- telemetryMsgRateLimit = newLimit(profile.getTransportDeviceTelemetryMsgRateLimit());
- telemetryDpRateLimit = newLimit(profile.getTransportDeviceTelemetryDataPointsRateLimit());
- }
- case GATEWAY_LIMITS -> {
- regularMsgRateLimit = newLimit(profile.getTransportGatewayMsgRateLimit());
- telemetryMsgRateLimit = newLimit(profile.getTransportGatewayTelemetryMsgRateLimit());
- telemetryDpRateLimit = newLimit(profile.getTransportGatewayTelemetryDataPointsRateLimit());
- }
- case GATEWAY_DEVICE_LIMITS -> {
- regularMsgRateLimit = newLimit(profile.getTransportGatewayDeviceMsgRateLimit());
- telemetryMsgRateLimit = newLimit(profile.getTransportGatewayDeviceTelemetryMsgRateLimit());
- telemetryDpRateLimit = newLimit(profile.getTransportGatewayDeviceTelemetryDataPointsRateLimit());
- }
- default -> throw new IllegalStateException("Unknown limits type: " + limitsType);
- }
-
- return new EntityTransportRateLimits(regularMsgRateLimit, telemetryMsgRateLimit, telemetryDpRateLimit);
}
+ return new EntityTransportRateLimits(
+ newLimit(limitsType.getRegularMsgRateLimit().apply(profile)),
+ newLimit(limitsType.getTelemetryMsgRateLimit().apply(profile)),
+ newLimit(limitsType.getTelemetryDataPointsRateLimit().apply(profile))
+ );
}
private static TransportRateLimit newLimit(String config) {
@@ -306,31 +286,36 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi
}
private EntityTransportRateLimits getTenantRateLimits(TenantId tenantId) {
- return perTenantLimits.computeIfAbsent(tenantId, k -> createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS));
+ return getRateLimits(perTenantLimits, tenantId, tenantId, TENANT_LIMITS, null);
}
private EntityTransportRateLimits getDeviceRateLimits(TenantId tenantId, DeviceId deviceId) {
- return perDeviceLimits.computeIfAbsent(deviceId, k -> {
- EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS);
- getTenantDevices(tenantId).add(deviceId);
- return limits;
- });
+ return getRateLimits(perDeviceLimits, tenantId, deviceId, DEVICE_LIMITS, () -> getTenantDevices(tenantId).add(deviceId));
}
private EntityTransportRateLimits getGatewayRateLimits(TenantId tenantId, DeviceId gatewayId) {
- return perGatewayLimits.computeIfAbsent(gatewayId, k -> {
- EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS);
- getTenantGateways(tenantId).add(gatewayId);
- return limits;
- });
+ return getRateLimits(perGatewayLimits, tenantId, gatewayId, GATEWAY_LIMITS, () -> getTenantGateways(tenantId).add(gatewayId));
}
private EntityTransportRateLimits getGatewayDeviceRateLimits(TenantId tenantId, DeviceId gatewayId) {
- return perGatewayDeviceLimits.computeIfAbsent(gatewayId, k -> {
- EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_DEVICE_LIMITS);
- getTenantGatewayDevices(tenantId).add(gatewayId);
- return limits;
- });
+ return getRateLimits(perGatewayDeviceLimits, tenantId, gatewayId, GATEWAY_DEVICE_LIMITS, () -> getTenantGatewayDevices(tenantId).add(gatewayId));
+ }
+
+ private EntityTransportRateLimits getRateLimits(ConcurrentMap limitsMap, TenantId tenantId,
+ T entityId, TransportLimitsType limitsType, Runnable onMiss) {
+ EntityTransportRateLimits limits = limitsMap.get(entityId);
+ if (limits == null) {
+ // Resolve the tenant profile WITHOUT holding the ConcurrentHashMap bin lock: the fetch may
+ // block on a cross-service round-trip, so it must run before computeIfAbsent's mapping function.
+ TenantProfile tenantProfile = tenantProfileCache.get(tenantId);
+ limits = limitsMap.computeIfAbsent(entityId, k -> createRateLimits(tenantProfile, limitsType));
+ // Runs on every observed miss, including callers that lost the computeIfAbsent race and got an
+ // existing value back - NOT only on actual creation, so the callback must be idempotent.
+ if (onMiss != null) {
+ onMiss.run();
+ }
+ }
+ return limits;
}
private Set getTenantDevices(TenantId tenantId) {
diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java
index 29077877a8..3a124da2b6 100644
--- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java
+++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java
@@ -15,6 +15,39 @@
*/
package org.thingsboard.server.common.transport.limits;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
+
+import java.util.function.Function;
+
+@Getter
+@RequiredArgsConstructor
public enum TransportLimitsType {
- TENANT_LIMITS, DEVICE_LIMITS, GATEWAY_LIMITS, GATEWAY_DEVICE_LIMITS
+
+ TENANT_LIMITS(
+ DefaultTenantProfileConfiguration::getTransportTenantMsgRateLimit,
+ DefaultTenantProfileConfiguration::getTransportTenantTelemetryMsgRateLimit,
+ DefaultTenantProfileConfiguration::getTransportTenantTelemetryDataPointsRateLimit
+ ),
+ DEVICE_LIMITS(
+ DefaultTenantProfileConfiguration::getTransportDeviceMsgRateLimit,
+ DefaultTenantProfileConfiguration::getTransportDeviceTelemetryMsgRateLimit,
+ DefaultTenantProfileConfiguration::getTransportDeviceTelemetryDataPointsRateLimit
+ ),
+ GATEWAY_LIMITS(
+ DefaultTenantProfileConfiguration::getTransportGatewayMsgRateLimit,
+ DefaultTenantProfileConfiguration::getTransportGatewayTelemetryMsgRateLimit,
+ DefaultTenantProfileConfiguration::getTransportGatewayTelemetryDataPointsRateLimit
+ ),
+ GATEWAY_DEVICE_LIMITS(
+ DefaultTenantProfileConfiguration::getTransportGatewayDeviceMsgRateLimit,
+ DefaultTenantProfileConfiguration::getTransportGatewayDeviceTelemetryMsgRateLimit,
+ DefaultTenantProfileConfiguration::getTransportGatewayDeviceTelemetryDataPointsRateLimit
+ );
+
+ private final Function regularMsgRateLimit;
+ private final Function telemetryMsgRateLimit;
+ private final Function telemetryDataPointsRateLimit;
+
}
diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java
index 63f2247aba..82b7981f8f 100644
--- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java
+++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java
@@ -27,7 +27,6 @@ import org.thingsboard.server.common.transport.config.ssl.SslCredentials;
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig;
import org.thingsboard.server.queue.util.TbTransportComponent;
-import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -165,7 +164,6 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis
static class CertificateWatcher {
private final List paths;
private final Runnable reloadCallback;
- private final Map lastModifiedMap;
private final Map lastChecksumMap;
private int consecutiveFailures;
private String failedCombinedChecksum;
@@ -173,60 +171,23 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis
CertificateWatcher(List paths, Runnable reloadCallback) {
this.paths = paths;
this.reloadCallback = reloadCallback;
- this.lastModifiedMap = new HashMap<>();
this.lastChecksumMap = new HashMap<>();
for (Path path : paths) {
- lastModifiedMap.put(path, getLastModifiedTime(path));
lastChecksumMap.put(path, calculateChecksum(path));
}
this.consecutiveFailures = 0;
}
synchronized void checkAndReload(String name) {
- boolean anyModifiedChanged = false;
- for (Path path : paths) {
- long currentModified = getLastModifiedTime(path);
- Long lastModified = lastModifiedMap.getOrDefault(path, 0L);
- if (currentModified != lastModified) {
- anyModifiedChanged = true;
- break;
- }
- }
- if (!anyModifiedChanged) {
- return;
- }
-
- // Capture mtimes and checksums together before the callback runs.
- // Pairing a post-callback mtime with a pre-callback checksum would let a write-during-reload be missed on the next poll.
- Map currentModifiedTimes = new HashMap<>();
Map currentChecksums = new HashMap<>();
- StringBuilder combined = new StringBuilder();
for (Path path : paths) {
- currentModifiedTimes.put(path, getLastModifiedTime(path));
- String checksum = calculateChecksum(path);
- currentChecksums.put(path, checksum);
- if (!combined.isEmpty()) {
- combined.append("|");
- }
- combined.append(path).append("=").append(checksum);
+ currentChecksums.put(path, calculateChecksum(path));
}
- String combinedChecksum = combined.toString();
-
- // Build old combined checksum for comparison
- StringBuilder oldCombined = new StringBuilder();
- for (Path path : paths) {
- if (!oldCombined.isEmpty()) {
- oldCombined.append("|");
- }
- oldCombined.append(path).append("=").append(lastChecksumMap.getOrDefault(path, ""));
- }
- String oldCombinedChecksum = oldCombined.toString();
+ String combinedChecksum = combinedChecksum(currentChecksums);
+ String oldCombinedChecksum = combinedChecksum(lastChecksumMap);
if (combinedChecksum.equals(oldCombinedChecksum)) {
- // Content unchanged, just update modification times
- for (Path path : paths) {
- lastModifiedMap.put(path, currentModifiedTimes.get(path));
- }
+ // Content unchanged
return;
}
@@ -237,41 +198,34 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis
}
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
- // Update modification times to avoid re-checking mtime and re-computing checksums every poll cycle
- for (Path path : paths) {
- lastModifiedMap.put(path, currentModifiedTimes.get(path));
- }
return;
}
try {
log.info("Certificate change detected for: {}. Triggering reload...", name);
reloadCallback.run();
- for (Path path : paths) {
- lastModifiedMap.put(path, currentModifiedTimes.get(path));
- lastChecksumMap.put(path, currentChecksums.get(path));
- }
+ lastChecksumMap.putAll(currentChecksums);
consecutiveFailures = 0;
failedCombinedChecksum = null;
} catch (Exception e) {
consecutiveFailures++;
failedCombinedChecksum = combinedChecksum;
- // Deliberately NOT updating the lastModifiedMap here, so the next poll cycle retries
- // (mtime mismatch passes the early gate, checksum matches failedCombinedChecksum).
+ // Deliberately NOT updating lastChecksumMap here, so the next poll cycle still sees a differing
+ // checksum, re-enters this method, and retries the same content.
log.error("Failed to reload certificate for {} (attempt {}/{}): {}",
name, consecutiveFailures, MAX_CONSECUTIVE_FAILURES, e.getMessage(), e);
}
}
- private long getLastModifiedTime(Path path) {
- try {
- if (!Files.exists(path)) {
- return 0;
+ private String combinedChecksum(Map checksums) {
+ StringBuilder combined = new StringBuilder();
+ for (Path path : paths) {
+ if (!combined.isEmpty()) {
+ combined.append("|");
}
- return Files.getLastModifiedTime(path).toMillis();
- } catch (IOException e) {
- return 0;
+ combined.append(path).append("=").append(checksums.getOrDefault(path, ""));
}
+ return combined.toString();
}
private String calculateChecksum(Path path) {
diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java
index c0d862ec10..d74d73aa1d 100644
--- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java
+++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java
@@ -153,6 +153,8 @@ public class DefaultTransportService extends TransportActivityManager implements
private int notificationsPollDuration;
@Value("${transport.stats.enabled:false}")
private boolean statsEnabled;
+ @Value("${transport.callback_thread_pool_size:20}")
+ private int callbackThreadPoolSize;
@Autowired
@Lazy
@@ -198,7 +200,7 @@ public class DefaultTransportService extends TransportActivityManager implements
this.ruleEngineProducerStats = statsFactory.createMessagesStats(StatsType.RULE_ENGINE.getName() + ".producer");
this.tbCoreProducerStats = statsFactory.createMessagesStats(StatsType.CORE.getName() + ".producer");
this.transportApiStats = statsFactory.createMessagesStats(StatsType.TRANSPORT.getName() + ".producer");
- this.transportCallbackExecutor = ThingsBoardExecutors.newWorkStealingPool(20, getClass());
+ this.transportCallbackExecutor = ThingsBoardExecutors.newWorkStealingPool(callbackThreadPoolSize, getClass());
this.scheduler.scheduleAtFixedRate(this::invalidateRateLimits, new Random().nextInt((int) sessionReportTimeout), sessionReportTimeout, TimeUnit.MILLISECONDS);
transportApiRequestTemplate = queueProvider.createTransportApiRequestTemplate();
transportApiRequestTemplate.setMessagesStats(transportApiStats);
diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java
index 4923bcd9a2..ac2fd4c28a 100644
--- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java
+++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java
@@ -15,6 +15,7 @@
*/
package org.thingsboard.server.common.transport.service;
+import com.google.common.util.concurrent.Striped;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
@@ -37,14 +38,20 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.Lock;
-import java.util.concurrent.locks.ReentrantLock;
@Component
@TbTransportComponent
@Slf4j
public class DefaultTransportTenantProfileCache implements TransportTenantProfileCache {
- private final Lock tenantProfileFetchLock = new ReentrantLock();
+ // Number of stripes for the per-tenant fetch locks. Only contended during concurrent cold-cache
+ // misses (cached tenants never take the lock), and concurrent fetches are already bounded by the
+ // transport callback pool, so this comfortably over-provisions the realistic concurrency.
+ private static final int TENANT_PROFILE_FETCH_LOCK_STRIPES = 1024;
+
+ // Bounded set of per-tenant locks: de-duplicates concurrent misses for the same tenant while
+ // letting different tenants fetch concurrently (eager array - no weak-ref overhead at this size).
+ private final Striped tenantProfileFetchLocks = Striped.lock(TENANT_PROFILE_FETCH_LOCK_STRIPES);
private final ConcurrentMap profiles = new ConcurrentHashMap<>();
private final ConcurrentMap tenantIds = new ConcurrentHashMap<>();
private final ConcurrentMap> tenantProfileIds = new ConcurrentHashMap<>();
@@ -103,43 +110,52 @@ public class DefaultTransportTenantProfileCache implements TransportTenantProfil
}
private TenantProfile getTenantProfile(TenantId tenantId) {
- TenantProfile profile = null;
- TenantProfileId tenantProfileId = tenantIds.get(tenantId);
- if (tenantProfileId != null) {
- profile = profiles.get(tenantProfileId);
- }
+ TenantProfile profile = lookupCached(tenantId);
if (profile == null) {
- tenantProfileFetchLock.lock();
+ // Per-tenant lock: de-duplicates concurrent misses for the SAME tenant while allowing
+ // different tenants to resolve their profiles concurrently.
+ Lock lock = tenantProfileFetchLocks.get(tenantId);
+ lock.lock();
try {
- tenantProfileId = tenantIds.get(tenantId);
- if (tenantProfileId != null) {
- profile = profiles.get(tenantProfileId);
- }
+ profile = lookupCached(tenantId);
if (profile == null) {
- TransportProtos.GetEntityProfileRequestMsg msg = TransportProtos.GetEntityProfileRequestMsg.newBuilder()
- .setEntityType(EntityType.TENANT.name())
- .setEntityIdMSB(tenantId.getId().getMostSignificantBits())
- .setEntityIdLSB(tenantId.getId().getLeastSignificantBits())
- .build();
- TransportProtos.GetEntityProfileResponseMsg entityProfileMsg = transportService.getEntityProfile(msg);
- profile = ProtoUtils.fromProto(entityProfileMsg.getTenantProfile());
- TenantProfile existingProfile = profiles.get(profile.getId());
- if (existingProfile != null) {
- profile = existingProfile;
- } else {
- profiles.put(profile.getId(), profile);
- }
- tenantProfileIds.computeIfAbsent(profile.getId(), id -> ConcurrentHashMap.newKeySet()).add(tenantId);
- tenantIds.put(tenantId, profile.getId());
- ApiUsageState apiUsageState = ProtoUtils.fromProto(entityProfileMsg.getApiState());
- rateLimitService.update(tenantId, apiUsageState.isTransportEnabled());
+ profile = fetchAndCacheTenantProfile(tenantId);
}
} finally {
- tenantProfileFetchLock.unlock();
+ lock.unlock();
}
}
return profile;
}
+ private TenantProfile lookupCached(TenantId tenantId) {
+ TenantProfileId tenantProfileId = tenantIds.get(tenantId);
+ if (tenantProfileId != null) {
+ return profiles.get(tenantProfileId);
+ }
+ return null;
+ }
+
+ private TenantProfile fetchAndCacheTenantProfile(TenantId tenantId) {
+ TransportProtos.GetEntityProfileRequestMsg msg = TransportProtos.GetEntityProfileRequestMsg.newBuilder()
+ .setEntityType(EntityType.TENANT.name())
+ .setEntityIdMSB(tenantId.getId().getMostSignificantBits())
+ .setEntityIdLSB(tenantId.getId().getLeastSignificantBits())
+ .build();
+ TransportProtos.GetEntityProfileResponseMsg entityProfileMsg = transportService.getEntityProfile(msg);
+ TenantProfile profile = ProtoUtils.fromProto(entityProfileMsg.getTenantProfile());
+ TenantProfile existingProfile = profiles.get(profile.getId());
+ if (existingProfile != null) {
+ profile = existingProfile;
+ } else {
+ profiles.put(profile.getId(), profile);
+ }
+ tenantProfileIds.computeIfAbsent(profile.getId(), id -> ConcurrentHashMap.newKeySet()).add(tenantId);
+ tenantIds.put(tenantId, profile.getId());
+ ApiUsageState apiUsageState = ProtoUtils.fromProto(entityProfileMsg.getApiState());
+ rateLimitService.update(tenantId, apiUsageState.isTransportEnabled());
+ return profile;
+ }
+
}
diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java
new file mode 100644
index 0000000000..b7b6a81abf
--- /dev/null
+++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java
@@ -0,0 +1,202 @@
+/**
+ * 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.common.transport.limits;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.thingsboard.server.common.data.TenantProfile;
+import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.id.TenantProfileId;
+import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
+import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
+import org.thingsboard.server.common.transport.TransportTenantProfileCache;
+import org.thingsboard.server.common.transport.profile.TenantProfileUpdateResult;
+
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class DefaultTransportRateLimitServiceTest {
+
+ private TransportTenantProfileCache tenantProfileCache;
+ private ExecutorService executor;
+
+ private final TenantId tenant = TenantId.fromUUID(UUID.randomUUID());
+
+ @BeforeEach
+ void setUp() {
+ tenantProfileCache = mock(TransportTenantProfileCache.class);
+ executor = Executors.newCachedThreadPool();
+ }
+
+ @AfterEach
+ void tearDown() {
+ executor.shutdownNow();
+ }
+
+ @Test
+ void checkLimitsDoesNotHoldMapBinLockAcrossProfileFetch() throws Exception {
+ // Two concurrent rate-limit checks for the SAME tenant must both be able to reach
+ // the (blocking) tenant-profile fetch concurrently. If the blocking fetch runs inside
+ // ConcurrentHashMap.computeIfAbsent, the second caller is stuck on the bin reservation
+ // node and never reaches the fetch -> the latch never reaches zero.
+ CountDownLatch bothCallersReachedFetch = new CountDownLatch(2);
+ CountDownLatch releaseFetch = new CountDownLatch(1);
+
+ when(tenantProfileCache.get(tenant)).thenAnswer(invocation -> {
+ bothCallersReachedFetch.countDown();
+ releaseFetch.await(5, TimeUnit.SECONDS);
+ return tenantProfile();
+ });
+
+ DefaultTransportRateLimitService service = new DefaultTransportRateLimitService(tenantProfileCache);
+
+ Runnable check = () -> service.checkLimits(tenant, null, null, 1, false);
+ executor.submit(check);
+ executor.submit(check);
+
+ boolean bothReached = bothCallersReachedFetch.await(3, TimeUnit.SECONDS);
+ releaseFetch.countDown();
+
+ assertThat(bothReached)
+ .as("both checkLimits calls should reach the profile fetch concurrently (no bin lock across I/O)")
+ .isTrue();
+ }
+
+ @ParameterizedTest
+ @EnumSource(TransportLimitsType.class)
+ void eachLimitsTypeReadsItsOwnProfileFields(TransportLimitsType type) {
+ // Distinct sentinel per profile field so a transposed method reference (e.g. GATEWAY_DEVICE_LIMITS
+ // wired to the plain gateway getters) resolves to the wrong value and fails the assertion.
+ DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration();
+ config.setTransportTenantMsgRateLimit("tenant-msg");
+ config.setTransportTenantTelemetryMsgRateLimit("tenant-tele-msg");
+ config.setTransportTenantTelemetryDataPointsRateLimit("tenant-tele-dp");
+ config.setTransportDeviceMsgRateLimit("device-msg");
+ config.setTransportDeviceTelemetryMsgRateLimit("device-tele-msg");
+ config.setTransportDeviceTelemetryDataPointsRateLimit("device-tele-dp");
+ config.setTransportGatewayMsgRateLimit("gateway-msg");
+ config.setTransportGatewayTelemetryMsgRateLimit("gateway-tele-msg");
+ config.setTransportGatewayTelemetryDataPointsRateLimit("gateway-tele-dp");
+ config.setTransportGatewayDeviceMsgRateLimit("gateway-device-msg");
+ config.setTransportGatewayDeviceTelemetryMsgRateLimit("gateway-device-tele-msg");
+ config.setTransportGatewayDeviceTelemetryDataPointsRateLimit("gateway-device-tele-dp");
+
+ String prefix = switch (type) {
+ case TENANT_LIMITS -> "tenant";
+ case DEVICE_LIMITS -> "device";
+ case GATEWAY_LIMITS -> "gateway";
+ case GATEWAY_DEVICE_LIMITS -> "gateway-device";
+ };
+
+ assertThat(type.getRegularMsgRateLimit().apply(config)).isEqualTo(prefix + "-msg");
+ assertThat(type.getTelemetryMsgRateLimit().apply(config)).isEqualTo(prefix + "-tele-msg");
+ assertThat(type.getTelemetryDataPointsRateLimit().apply(config)).isEqualTo(prefix + "-tele-dp");
+ }
+
+ @ParameterizedTest
+ @EnumSource(EntityLevel.class)
+ void profileUpdateReachesEntityTrackedDuringFirstCheck(EntityLevel level) {
+ DeviceId entity = new DeviceId(UUID.randomUUID());
+ when(tenantProfileCache.get(tenant)).thenReturn(profileWithRegularMsgLimit(level, "100:600"));
+ DefaultTransportRateLimitService service = new DefaultTransportRateLimitService(tenantProfileCache);
+
+ // First check resolves the (permissive) limit and must register the entity into the per-tenant
+ // tracking set via the onMiss callback - otherwise a later update(tenantId) can't reach it.
+ assertThat(level.check(service, tenant, entity))
+ .as("permissive limit should allow the first %s check", level).isNull();
+
+ // Tighten the limit to a single message and push a profile update for this tenant.
+ service.update(new TenantProfileUpdateResult(profileWithRegularMsgLimit(level, "1:600"), Set.of(tenant)));
+
+ // The freshly merged "1:600" bucket allows exactly one message...
+ assertThat(level.check(service, tenant, entity)).isNull();
+ // ...and blocks the next one. This only happens if update(tenantId) reached the tracked entity.
+ assertThat(level.check(service, tenant, entity))
+ .as("update(tenantId) must reach the tracked %s so the tightened limit applies", level).isNotNull();
+ }
+
+ private TenantProfile tenantProfile() {
+ return profileWith(new DefaultTenantProfileConfiguration());
+ }
+
+ private TenantProfile profileWithRegularMsgLimit(EntityLevel level, String regularMsgRateLimit) {
+ DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration();
+ level.setRegularMsgRateLimit(config, regularMsgRateLimit);
+ return profileWith(config);
+ }
+
+ private TenantProfile profileWith(DefaultTenantProfileConfiguration config) {
+ TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID()));
+ profile.setName("test-profile");
+ TenantProfileData profileData = new TenantProfileData();
+ profileData.setConfiguration(config);
+ profile.setProfileData(profileData);
+ return profile;
+ }
+
+ private enum EntityLevel {
+ DEVICE {
+ @Override
+ void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) {
+ config.setTransportDeviceMsgRateLimit(value);
+ }
+
+ @Override
+ Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) {
+ return service.checkLimits(tenantId, null, entityId, 0, false);
+ }
+ },
+ GATEWAY {
+ @Override
+ void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) {
+ config.setTransportGatewayMsgRateLimit(value);
+ }
+
+ @Override
+ Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) {
+ return service.checkLimits(tenantId, entityId, null, 0, false);
+ }
+ },
+ GATEWAY_DEVICE {
+ @Override
+ void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) {
+ config.setTransportGatewayDeviceMsgRateLimit(value);
+ }
+
+ @Override
+ Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) {
+ return service.checkLimits(tenantId, null, entityId, 0, true);
+ }
+ };
+
+ abstract void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value);
+
+ abstract Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId);
+ }
+
+}
diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java
index 6f78eaefae..8894937177 100644
--- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java
+++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java
@@ -31,10 +31,7 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.awaitility.Awaitility.await;
public class CertificateReloadManagerTest {
@@ -59,11 +56,10 @@ public class CertificateReloadManagerTest {
}
}
- private void writeFileAndAwaitMtimeChange(Path path, String content, long baselineMtime) throws IOException {
+ private void writeFileAndBumpMtime(Path path, String content, long baselineMtime) throws IOException {
Files.writeString(path, content);
- await().atMost(2, SECONDS)
- .pollInterval(10, MILLISECONDS)
- .until(() -> Files.getLastModifiedTime(path).toMillis() != baselineMtime);
+ // Force a strictly newer mtime: back-to-back writes can share a millisecond, hiding the change from the watcher.
+ Files.setLastModifiedTime(path, FileTime.fromMillis(baselineMtime + 1000));
}
private long mtime(Path path) throws IOException {
@@ -77,7 +73,7 @@ public class CertificateReloadManagerTest {
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
long baseline = mtime(certFile);
- writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n", baseline);
+ writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n", baseline);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@@ -85,7 +81,7 @@ public class CertificateReloadManagerTest {
}
@Test
- public void givenCertificateFileUnchanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception {
+ public void givenCertificateFileUnchanged_whenCheckForChanges_thenShouldNotTriggerReload() {
AtomicInteger reloadCount = new AtomicInteger(0);
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
@@ -147,7 +143,7 @@ public class CertificateReloadManagerTest {
certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadCount::incrementAndGet);
long baseline = mtime(keyFile);
- writeFileAndAwaitMtimeChange(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n", baseline);
+ writeFileAndBumpMtime(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n", baseline);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@@ -168,8 +164,8 @@ public class CertificateReloadManagerTest {
long baseline1 = mtime(certFile);
long baseline2 = mtime(cert2File);
- writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1);
- writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2);
+ writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1);
+ writeFileAndBumpMtime(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@@ -191,8 +187,8 @@ public class CertificateReloadManagerTest {
long baseline1 = mtime(certFile);
long baseline2 = mtime(cert2File);
- writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1);
- writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2);
+ writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1);
+ writeFileAndBumpMtime(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@@ -224,9 +220,7 @@ public class CertificateReloadManagerTest {
for (int i = 0; i < 5; i++) {
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n");
}
- await().atMost(2, SECONDS)
- .pollInterval(10, MILLISECONDS)
- .until(() -> mtime(certFile) != baseline);
+ Files.setLastModifiedTime(certFile, FileTime.fromMillis(baseline + 1000));
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@@ -242,7 +236,7 @@ public class CertificateReloadManagerTest {
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
long baseline = mtime(certFile);
- writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n", baseline);
+ writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n", baseline);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
@@ -272,7 +266,7 @@ public class CertificateReloadManagerTest {
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
long baseline = mtime(certFile);
- writeFileAndAwaitMtimeChange(certFile, originalContent, baseline);
+ writeFileAndBumpMtime(certFile, originalContent, baseline);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@@ -289,7 +283,7 @@ public class CertificateReloadManagerTest {
});
long baseline = mtime(certFile);
- writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline);
+ writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline);
for (int i = 0; i < 15; i++) {
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@@ -311,19 +305,41 @@ public class CertificateReloadManagerTest {
});
long baseline = mtime(certFile);
- writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline);
+ writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadAttempts.get()).isEqualTo(1);
shouldFail.set(0);
long baseline2 = mtime(certFile);
- writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n", baseline2);
+ writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n", baseline2);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadAttempts.get()).isEqualTo(2);
}
+ @Test
+ public void givenContentChangedButMtimeUnchanged_whenCheckForChanges_thenShouldTriggerReload() throws Exception {
+ // Bug fingerprint: a cert-manager rotation that lands in the same wall-clock millisecond as the
+ // watcher's recorded baseline mtime. Files.getLastModifiedTime().toMillis() truncates to the ms,
+ // so the rotated content shares the baseline mtime and an mtime-only gate would never re-hash it.
+ AtomicInteger reloadCount = new AtomicInteger(0);
+
+ certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
+
+ long baseline = mtime(certFile);
+ Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nROTATED_SAME_MS\n-----END CERTIFICATE-----\n");
+ // Force the mtime back to the exact baseline millisecond — content changed, timestamp did not.
+ Files.setLastModifiedTime(certFile, FileTime.fromMillis(baseline));
+
+ // Sanity guard: the watcher observes a timestamp identical to its recorded baseline.
+ assertThat(mtime(certFile)).isEqualTo(baseline);
+
+ ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
+
+ assertThat(reloadCount.get()).isEqualTo(1);
+ }
+
@Test
public void givenCallbackHitMaxFailures_whenFileChangesToNewContent_thenShouldResetAndRetry() throws Exception {
AtomicInteger reloadAttempts = new AtomicInteger(0);
@@ -337,7 +353,7 @@ public class CertificateReloadManagerTest {
});
long baseline = mtime(certFile);
- writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline);
+ writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline);
for (int i = 0; i < 15; i++) {
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@@ -346,7 +362,7 @@ public class CertificateReloadManagerTest {
shouldFail.set(0);
long baseline2 = mtime(certFile);
- writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n", baseline2);
+ writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n", baseline2);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadAttempts.get()).isEqualTo(11);
diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java
new file mode 100644
index 0000000000..d2b4544f53
--- /dev/null
+++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java
@@ -0,0 +1,191 @@
+/**
+ * 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.common.transport.service;
+
+import com.google.common.util.concurrent.Striped;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.thingsboard.server.common.data.ApiUsageState;
+import org.thingsboard.server.common.data.ApiUsageStateValue;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.TenantProfile;
+import org.thingsboard.server.common.data.id.ApiUsageStateId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.id.TenantProfileId;
+import org.thingsboard.server.common.transport.TransportService;
+import org.thingsboard.server.common.transport.limits.TransportRateLimitService;
+import org.thingsboard.server.common.util.ProtoUtils;
+import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileRequestMsg;
+import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileResponseMsg;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class DefaultTransportTenantProfileCacheTest {
+
+ private DefaultTransportTenantProfileCache cache;
+ private TransportService transportService;
+ private TransportRateLimitService rateLimitService;
+ private ExecutorService executor;
+
+ // Must match DefaultTransportTenantProfileCache.TENANT_PROFILE_FETCH_LOCK_STRIPES.
+ private static final int STRIPE_COUNT = 1024;
+
+ private final TenantId tenantA = TenantId.fromUUID(UUID.randomUUID());
+ // Deterministically pick a tenant that maps to a DIFFERENT stripe than tenantA, so the cross-tenant
+ // test below cannot flake on the ~1/1024 chance two random UUIDs hash to the same stripe.
+ private final TenantId tenantB = differentStripeFrom(tenantA);
+
+ private static TenantId differentStripeFrom(TenantId other) {
+ Striped probe = Striped.lock(STRIPE_COUNT);
+ TenantId candidate = TenantId.fromUUID(UUID.randomUUID());
+ while (probe.get(candidate) == probe.get(other)) {
+ candidate = TenantId.fromUUID(UUID.randomUUID());
+ }
+ return candidate;
+ }
+
+ @BeforeEach
+ void setUp() {
+ cache = new DefaultTransportTenantProfileCache();
+ transportService = mock(TransportService.class);
+ rateLimitService = mock(TransportRateLimitService.class);
+ doNothing().when(rateLimitService).update(any(TenantId.class), anyBoolean());
+ cache.setTransportService(transportService);
+ cache.setRateLimitService(rateLimitService);
+ executor = Executors.newCachedThreadPool();
+ }
+
+ @AfterEach
+ void tearDown() {
+ executor.shutdownNow();
+ }
+
+ @Test
+ void fetchForOneTenantDoesNotBlockResolutionOfAnotherTenant() throws Exception {
+ CountDownLatch tenantAFetchStarted = new CountDownLatch(1);
+ CountDownLatch releaseTenantA = new CountDownLatch(1);
+
+ GetEntityProfileResponseMsg responseA = responseFor(tenantA);
+ GetEntityProfileResponseMsg responseB = responseFor(tenantB);
+
+ when(transportService.getEntityProfile(any())).thenAnswer(invocation -> {
+ GetEntityProfileRequestMsg msg = invocation.getArgument(0);
+ TenantId requested = TenantId.fromUUID(new UUID(msg.getEntityIdMSB(), msg.getEntityIdLSB()));
+ if (requested.equals(tenantA)) {
+ tenantAFetchStarted.countDown();
+ releaseTenantA.await(5, TimeUnit.SECONDS);
+ return responseA;
+ }
+ return responseB;
+ });
+
+ // T1 starts fetching tenantA's profile and blocks inside the cross-service round-trip.
+ Future tenantAResult = executor.submit(() -> cache.get(tenantA));
+ assertThat(tenantAFetchStarted.await(5, TimeUnit.SECONDS))
+ .as("tenantA fetch should have started").isTrue();
+
+ // T2 resolves a different tenant - it must NOT wait for tenantA's in-flight fetch.
+ // Fails today (single global lock); passes once locking is per-tenant.
+ TenantProfile tenantBProfile = CompletableFuture
+ .supplyAsync(() -> cache.get(tenantB), executor)
+ .get(2, TimeUnit.SECONDS);
+ assertThat(tenantBProfile).isNotNull();
+
+ releaseTenantA.countDown();
+ assertThat(tenantAResult.get(5, TimeUnit.SECONDS)).isNotNull();
+ }
+
+ @Test
+ void concurrentMissesForSameTenantDedupeToSingleFetch() throws Exception {
+ // The per-tenant lock exists precisely so that concurrent cold misses for the SAME tenant collapse
+ // into a single cross-service fetch (the rest are served from cache). Assert that contract directly.
+ int callers = 8;
+ CountDownLatch fetchStarted = new CountDownLatch(1);
+ CountDownLatch releaseFetch = new CountDownLatch(1);
+
+ when(transportService.getEntityProfile(any())).thenAnswer(invocation -> {
+ fetchStarted.countDown();
+ // Hold the (single) in-flight fetch open while the other callers pile up on the per-tenant lock.
+ releaseFetch.await(5, TimeUnit.SECONDS);
+ return responseFor(tenantA);
+ });
+
+ CountDownLatch allSubmitted = new CountDownLatch(callers);
+ List> results = new ArrayList<>();
+ for (int i = 0; i < callers; i++) {
+ results.add(executor.submit(() -> {
+ allSubmitted.countDown();
+ return cache.get(tenantA);
+ }));
+ }
+
+ assertThat(allSubmitted.await(5, TimeUnit.SECONDS)).as("all callers should start").isTrue();
+ assertThat(fetchStarted.await(5, TimeUnit.SECONDS)).as("the first fetch should start").isTrue();
+ releaseFetch.countDown();
+
+ for (Future result : results) {
+ assertThat(result.get(5, TimeUnit.SECONDS)).isNotNull();
+ }
+ // All 8 callers resolved the same tenant, but only one of them hit the backend.
+ verify(transportService, times(1)).getEntityProfile(any());
+ }
+
+ private GetEntityProfileResponseMsg responseFor(TenantId tenantId) {
+ TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID()));
+ profile.setName("profile-" + tenantId.getId());
+ return GetEntityProfileResponseMsg.newBuilder()
+ .setEntityType(EntityType.TENANT.name())
+ .setTenantProfile(ProtoUtils.toProto(profile))
+ .setApiState(ProtoUtils.toProto(enabledApiUsageState(tenantId)))
+ .build();
+ }
+
+ private ApiUsageState enabledApiUsageState(TenantId tenantId) {
+ ApiUsageState state = new ApiUsageState(new ApiUsageStateId(UUID.randomUUID()));
+ state.setTenantId(tenantId);
+ state.setEntityId(tenantId);
+ state.setTransportState(ApiUsageStateValue.ENABLED);
+ state.setDbStorageState(ApiUsageStateValue.ENABLED);
+ state.setReExecState(ApiUsageStateValue.ENABLED);
+ state.setJsExecState(ApiUsageStateValue.ENABLED);
+ state.setTbelExecState(ApiUsageStateValue.ENABLED);
+ state.setEmailExecState(ApiUsageStateValue.ENABLED);
+ state.setSmsExecState(ApiUsageStateValue.ENABLED);
+ state.setAlarmExecState(ApiUsageStateValue.ENABLED);
+ state.setVersion(1L);
+ return state;
+ }
+
+}
diff --git a/common/util/pom.xml b/common/util/pom.xml
index 1460119f02..ae6dd29420 100644
--- a/common/util/pom.xml
+++ b/common/util/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java b/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java
index 6681557c87..293120954d 100644
--- a/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java
+++ b/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java
@@ -36,12 +36,16 @@ public class NumberUtils {
return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).intValue();
}
+ public static long toLong(double value) {
+ return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).longValue();
+ }
+
public static Object roundResult(double value, Integer precision) {
if (precision == null) {
return value;
}
if (precision.equals(0)) {
- return toInt(value);
+ return toLong(value);
}
return toFixed(value, precision);
}
diff --git a/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java b/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java
index 1a1bf326e6..dabfe9cffc 100644
--- a/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java
+++ b/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java
@@ -51,11 +51,20 @@ public class NumberUtilsTest {
assertThat(NumberUtils.toInt(28.0)).isEqualTo(28);
}
+ @Test
+ public void toLong() {
+ assertThat(NumberUtils.toLong(doubleVal)).isEqualTo(1729L);
+ assertThat(NumberUtils.toLong(12.8)).isEqualTo(13L);
+ assertThat(NumberUtils.toLong(28.0)).isEqualTo(28L);
+ assertThat(NumberUtils.toLong(3_980_173_734.0)).isEqualTo(3_980_173_734L);
+ }
+
@Test
public void roundResult() {
assertThat(NumberUtils.roundResult(doubleVal, null)).isEqualTo(1729.1729);
- assertThat(NumberUtils.roundResult(doubleVal, 0)).isEqualTo(1729);
+ assertThat(NumberUtils.roundResult(doubleVal, 0)).isEqualTo(1729L);
assertThat(NumberUtils.roundResult(doubleVal, 2)).isEqualTo(1729.17);
+ assertThat(NumberUtils.roundResult(3_980_173_734.0, 0)).isEqualTo(3_980_173_734L);
}
}
diff --git a/common/version-control/pom.xml b/common/version-control/pom.xml
index 1ac78d2db2..d955a57537 100644
--- a/common/version-control/pom.xml
+++ b/common/version-control/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
common
org.thingsboard.common
diff --git a/dao/pom.xml b/dao/pom.xml
index 7b76c664d8..ae9f1a5998 100644
--- a/dao/pom.xml
+++ b/dao/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
thingsboard
dao
diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DashboardDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DashboardDataValidator.java
index 12c4c62f94..a953e07197 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DashboardDataValidator.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DashboardDataValidator.java
@@ -32,6 +32,10 @@ public class DashboardDataValidator extends DataValidator {
@Override
protected void validateCreate(TenantId tenantId, Dashboard data) {
+ validateMaxDashboardsPerTenant(tenantId);
+ }
+
+ public void validateMaxDashboardsPerTenant(TenantId tenantId) {
validateNumberOfEntitiesPerTenant(tenantId, EntityType.DASHBOARD);
}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java
index 7035fcfd9b..c053f36d5b 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java
@@ -18,10 +18,13 @@ package org.thingsboard.server.dao.service.validator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
+import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.security.DeviceCredentials;
+import org.thingsboard.server.common.data.security.DeviceCredentialsType;
import org.thingsboard.server.dao.device.DeviceCredentialsDao;
import org.thingsboard.server.dao.device.DeviceService;
import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException;
@@ -69,9 +72,24 @@ public class DeviceCredentialsDataValidator extends DataValidator validator.validateMaxDashboardsPerTenant(tenantId));
+ }
+
+ @Test
+ void validateMaxDashboardsPerTenant_throwsEntitiesLimitExceeded_whenLimitReached() {
+ long limit = 5;
+ willReturn(false).given(apiLimitService).checkEntitiesLimit(tenantId, EntityType.DASHBOARD);
+ willReturn(limit).given(apiLimitService).getLimit(eq(tenantId), any());
+
+ assertThatThrownBy(() -> validator.validateMaxDashboardsPerTenant(tenantId))
+ .isInstanceOfSatisfying(EntitiesLimitExceededException.class, ex -> {
+ assertThat(ex.getTenantId()).isEqualTo(tenantId);
+ assertThat(ex.getEntityType()).isEqualTo(EntityType.DASHBOARD);
+ assertThat(ex.getLimit()).isEqualTo(limit);
+ });
+ }
+
}
diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidatorTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidatorTest.java
new file mode 100644
index 0000000000..5eca84a466
--- /dev/null
+++ b/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidatorTest.java
@@ -0,0 +1,138 @@
+/**
+ * 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.dao.service.validator;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials;
+import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.security.DeviceCredentials;
+import org.thingsboard.server.common.data.security.DeviceCredentialsType;
+import org.thingsboard.server.dao.device.DeviceCredentialsDao;
+import org.thingsboard.server.dao.device.DeviceService;
+import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException;
+
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.BDDMockito.willReturn;
+
+@ExtendWith(MockitoExtension.class)
+class DeviceCredentialsDataValidatorTest {
+
+ @Mock
+ DeviceCredentialsDao deviceCredentialsDao;
+ @Mock
+ DeviceService deviceService;
+ @InjectMocks
+ DeviceCredentialsDataValidator validator;
+
+ final TenantId tenantId = TenantId.fromUUID(UUID.fromString("9ef79cdf-37a8-4119-b682-2e7ed4e018da"));
+ final DeviceId deviceId = new DeviceId(UUID.fromString("11111111-1111-1111-1111-111111111111"));
+
+ @Test
+ void rejectsNewlineInAccessToken() {
+ DeviceCredentials creds = accessToken("safe_token\nentrypoint: [\"/bin/sh\"]");
+
+ assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds))
+ .isInstanceOf(DeviceCredentialsValidationException.class)
+ .hasMessageContaining("credentialsId")
+ .hasMessageContaining("control characters");
+ }
+
+ @Test
+ void rejectsCarriageReturnInAccessToken() {
+ DeviceCredentials creds = accessToken("token\rprivileged: true");
+
+ assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds))
+ .isInstanceOf(DeviceCredentialsValidationException.class)
+ .hasMessageContaining("control characters");
+ }
+
+ @Test
+ void rejectsNewlineInMqttClientId() {
+ DeviceCredentials creds = mqttBasic("cid\nentrypoint: x", "user", "pwd");
+
+ assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds))
+ .isInstanceOf(DeviceCredentialsValidationException.class)
+ .hasMessageContaining("clientId");
+ }
+
+ @Test
+ void rejectsNewlineInMqttUserName() {
+ DeviceCredentials creds = mqttBasic("cid", "user\nprivileged: true", "pwd");
+
+ assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds))
+ .isInstanceOf(DeviceCredentialsValidationException.class)
+ .hasMessageContaining("userName");
+ }
+
+ @Test
+ void rejectsNewlineInMqttPassword() {
+ DeviceCredentials creds = mqttBasic("cid", "user", "pwd\nentrypoint: x");
+
+ assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds))
+ .isInstanceOf(DeviceCredentialsValidationException.class)
+ .hasMessageContaining("password");
+ }
+
+ @Test
+ void acceptsValidCredentials() {
+ willReturn(new Device()).given(deviceService).findDeviceById(tenantId, deviceId);
+ DeviceCredentials creds = accessToken("safe_token_123");
+
+ assertThatCode(() -> validator.validateDataImpl(tenantId, creds))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void acceptsValidMqttBasicCredentials() {
+ willReturn(new Device()).given(deviceService).findDeviceById(tenantId, deviceId);
+ DeviceCredentials creds = mqttBasic("client-1", "user-1", "pwd-1");
+
+ assertThatCode(() -> validator.validateDataImpl(tenantId, creds))
+ .doesNotThrowAnyException();
+ }
+
+ private DeviceCredentials accessToken(String token) {
+ DeviceCredentials c = new DeviceCredentials();
+ c.setDeviceId(deviceId);
+ c.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN);
+ c.setCredentialsId(token);
+ return c;
+ }
+
+ private DeviceCredentials mqttBasic(String clientId, String userName, String password) {
+ BasicMqttCredentials inner = new BasicMqttCredentials();
+ inner.setClientId(clientId);
+ inner.setUserName(userName);
+ inner.setPassword(password);
+ DeviceCredentials c = new DeviceCredentials();
+ c.setDeviceId(deviceId);
+ c.setCredentialsType(DeviceCredentialsType.MQTT_BASIC);
+ c.setCredentialsId("mqtt-credentials-id");
+ c.setCredentialsValue(JacksonUtil.toString(inner));
+ return c;
+ }
+
+}
diff --git a/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java b/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java
index d4c93a1d5f..2c6fee2fcf 100644
--- a/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java
+++ b/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java
@@ -16,6 +16,13 @@
package org.thingsboard.server.dao.util;
import org.junit.jupiter.api.Test;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials;
+import org.thingsboard.server.common.data.security.DeviceCredentials;
+import org.thingsboard.server.common.data.security.DeviceCredentialsType;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
@@ -29,4 +36,120 @@ class DeviceConnectivityUtilTest {
assertThat(DeviceConnectivityUtil.CA_ROOT_CERT_PEM).doesNotContainAnyWhitespaces();
}
+ @Test
+ void validAccessTokenIsRenderedAsIs() throws Exception {
+ String yaml = renderCompose(accessToken("safe_token_123"));
+
+ assertThat(yaml).contains("- TB_GW_ACCESS_TOKEN=safe_token_123\n");
+ assertNoInjectedSiblingKeys(yaml);
+ }
+
+ @Test
+ void newlineInAccessTokenIsSanitized() throws Exception {
+ String malicious = "safe_token\n entrypoint: [\"/bin/bash\",\"-c\",\"id\"]";
+
+ String yaml = renderCompose(accessToken(malicious));
+
+ assertNoInjectedSiblingKeys(yaml);
+ }
+
+ @Test
+ void carriageReturnInAccessTokenIsSanitized() throws Exception {
+ String yaml = renderCompose(accessToken("token\rprivileged: true"));
+
+ assertNoInjectedSiblingKeys(yaml);
+ }
+
+ @Test
+ void newlineInMqttClientIdIsSanitized() throws Exception {
+ String yaml = renderCompose(mqttBasic("cid\n entrypoint: [\"/bin/sh\"]", "user", "pwd"));
+
+ assertNoInjectedSiblingKeys(yaml);
+ }
+
+ @Test
+ void newlineInMqttUserNameIsSanitized() throws Exception {
+ String yaml = renderCompose(mqttBasic("cid", "user\n privileged: true", "pwd"));
+
+ assertNoInjectedSiblingKeys(yaml);
+ }
+
+ @Test
+ void newlineInMqttPasswordIsSanitized() throws Exception {
+ String yaml = renderCompose(mqttBasic("cid", "user", "pwd\n entrypoint: [\"/bin/sh\"]"));
+
+ assertNoInjectedSiblingKeys(yaml);
+ }
+
+ @Test
+ void mqttBasicQuoteInUserNameIsEscapedInPublishCommand() {
+ String command = DeviceConnectivityUtil.getMqttPublishCommand(
+ "mqtt", "localhost", "1883", "v1/devices/me/telemetry",
+ mqttBasic("cid", "u\";touch pwned;echo \"", "pwd"));
+
+ // the double quote must be backslash-escaped so it cannot terminate the -u "..." argument
+ assertThat(command).contains("-u \"u\\\";touch pwned;echo \\\"\"");
+ assertThat(command).doesNotContain("-u \"u\";");
+ }
+
+ @Test
+ void controlCharsInMqttClientIdAreStrippedInPublishCommand() {
+ String command = DeviceConnectivityUtil.getMqttPublishCommand(
+ "mqtt", "localhost", "1883", "v1/devices/me/telemetry",
+ mqttBasic("c\nid", "user", "pwd"));
+
+ assertThat(command).doesNotContain("\n");
+ assertThat(command).contains("-i \"c_id\"");
+ }
+
+ @Test
+ void controlCharsInAccessTokenAreStrippedInHttpAndCoapCommands() {
+ DeviceCredentials creds = accessToken("tok\nen");
+
+ assertThat(DeviceConnectivityUtil.getHttpPublishCommand("http", "localhost", ":8080", creds))
+ .doesNotContain("\n")
+ .contains("/api/v1/tok_en/telemetry");
+ assertThat(DeviceConnectivityUtil.getCoapPublishCommand("coap", "localhost", ":5683", creds))
+ .doesNotContain("\n")
+ .contains("/api/v1/tok_en/telemetry");
+ }
+
+ private static String renderCompose(DeviceCredentials credentials) throws Exception {
+ var resource = DeviceConnectivityUtil.getGatewayDockerComposeFile(
+ "host.docker.internal", "3.8-stable", credentials);
+ try (var in = resource.getInputStream()) {
+ return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+
+ private static DeviceCredentials accessToken(String token) {
+ DeviceCredentials c = new DeviceCredentials();
+ c.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN);
+ c.setCredentialsId(token);
+ return c;
+ }
+
+ private static DeviceCredentials mqttBasic(String clientId, String userName, String password) {
+ BasicMqttCredentials inner = new BasicMqttCredentials();
+ inner.setClientId(clientId);
+ inner.setUserName(userName);
+ inner.setPassword(password);
+ DeviceCredentials c = new DeviceCredentials();
+ c.setCredentialsType(DeviceCredentialsType.MQTT_BASIC);
+ c.setCredentialsId("mqtt-credentials-id");
+ c.setCredentialsValue(JacksonUtil.toString(inner));
+ return c;
+ }
+
+ private static void assertNoInjectedSiblingKeys(String yaml) throws IOException {
+ for (String line : yaml.split("\n")) {
+ String trimmed = line.replaceFirst("^\\s+", "");
+ assertThat(trimmed)
+ .as("unexpected sibling key — possible YAML injection: %s", line)
+ .doesNotStartWith("entrypoint:")
+ .doesNotStartWith("privileged:")
+ .doesNotStartWith("command:");
+ }
+ }
+
}
diff --git a/edqs/pom.xml b/edqs/pom.xml
index 32c6df82f2..4d13edbc22 100644
--- a/edqs/pom.xml
+++ b/edqs/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
thingsboard
edqs
diff --git a/monitoring/pom.xml b/monitoring/pom.xml
index 5bc1fb5baa..2786e1b2c8 100644
--- a/monitoring/pom.xml
+++ b/monitoring/pom.xml
@@ -21,7 +21,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
thingsboard
diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml
index b871933d9f..69a78c72bd 100644
--- a/msa/black-box-tests/pom.xml
+++ b/msa/black-box-tests/pom.xml
@@ -21,7 +21,7 @@
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
msa
org.thingsboard.msa
diff --git a/msa/edqs/pom.xml b/msa/edqs/pom.xml
index c9fcd7beb7..69cff7d0e6 100644
--- a/msa/edqs/pom.xml
+++ b/msa/edqs/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
msa
org.thingsboard.msa
diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json
index 5e0c41fe85..0843ef2dbe 100644
--- a/msa/js-executor/package.json
+++ b/msa/js-executor/package.json
@@ -1,7 +1,7 @@
{
"name": "thingsboard-js-executor",
"private": true,
- "version": "4.3.1.2",
+ "version": "4.3.1.3",
"description": "ThingsBoard JavaScript Executor Microservice",
"main": "server.ts",
"bin": "server.js",
diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml
index 4396f4fb96..f57e06f4f2 100644
--- a/msa/js-executor/pom.xml
+++ b/msa/js-executor/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
msa
org.thingsboard.msa
diff --git a/msa/monitoring/pom.xml b/msa/monitoring/pom.xml
index 2b4a0bd6da..9502552a52 100644
--- a/msa/monitoring/pom.xml
+++ b/msa/monitoring/pom.xml
@@ -22,7 +22,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
msa
diff --git a/msa/pom.xml b/msa/pom.xml
index a3494dfc3e..1498847ce3 100644
--- a/msa/pom.xml
+++ b/msa/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
thingsboard
msa
diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml
index e4ff8f4951..df53fda9c1 100644
--- a/msa/tb-node/pom.xml
+++ b/msa/tb-node/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
msa
org.thingsboard.msa
diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml
index 6b14bb92fb..327668587f 100644
--- a/msa/tb/pom.xml
+++ b/msa/tb/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
msa
org.thingsboard.msa
diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml
index 27a0210adf..c496c8c8d0 100644
--- a/msa/transport/coap/pom.xml
+++ b/msa/transport/coap/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard.msa
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
transport
org.thingsboard.msa.transport
diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml
index fb22d39da3..e15e74c8b7 100644
--- a/msa/transport/http/pom.xml
+++ b/msa/transport/http/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard.msa
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
transport
org.thingsboard.msa.transport
diff --git a/msa/transport/lwm2m/pom.xml b/msa/transport/lwm2m/pom.xml
index 916dc57712..7fe35560b3 100644
--- a/msa/transport/lwm2m/pom.xml
+++ b/msa/transport/lwm2m/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard.msa
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
transport
org.thingsboard.msa.transport
diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml
index 1dc673ce47..dcceba1e43 100644
--- a/msa/transport/mqtt/pom.xml
+++ b/msa/transport/mqtt/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard.msa
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
transport
org.thingsboard.msa.transport
diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml
index a1e867fb05..cf2411cf3e 100644
--- a/msa/transport/pom.xml
+++ b/msa/transport/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
msa
org.thingsboard.msa
diff --git a/msa/transport/snmp/pom.xml b/msa/transport/snmp/pom.xml
index 9ec7400a2c..b83b035b76 100644
--- a/msa/transport/snmp/pom.xml
+++ b/msa/transport/snmp/pom.xml
@@ -21,7 +21,7 @@
org.thingsboard.msa
transport
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
org.thingsboard.msa.transport
diff --git a/msa/vc-executor-docker/pom.xml b/msa/vc-executor-docker/pom.xml
index 5f37f101f4..fc1de84048 100644
--- a/msa/vc-executor-docker/pom.xml
+++ b/msa/vc-executor-docker/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
msa
org.thingsboard.msa
diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml
index f5beccabf6..0da1dbbbf4 100644
--- a/msa/vc-executor/pom.xml
+++ b/msa/vc-executor/pom.xml
@@ -21,7 +21,7 @@
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
msa
org.thingsboard.msa
diff --git a/msa/web-ui/package.json b/msa/web-ui/package.json
index ce18b0b063..6dc75cc9dd 100644
--- a/msa/web-ui/package.json
+++ b/msa/web-ui/package.json
@@ -1,7 +1,7 @@
{
"name": "thingsboard-web-ui",
"private": true,
- "version": "4.3.1.2",
+ "version": "4.3.1.3",
"description": "ThingsBoard Web UI Microservice",
"main": "server.ts",
"bin": "server.js",
diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml
index a6b6fb6e8e..02ac55d4a8 100644
--- a/msa/web-ui/pom.xml
+++ b/msa/web-ui/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
msa
org.thingsboard.msa
diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml
index 9e06b2d975..92766bb38d 100644
--- a/netty-mqtt/pom.xml
+++ b/netty-mqtt/pom.xml
@@ -19,11 +19,11 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
thingsboard
netty-mqtt
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
jar
Netty MQTT Client
diff --git a/pom.xml b/pom.xml
index 306879f4fa..4c36e79bff 100755
--- a/pom.xml
+++ b/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
pom
Thingsboard
@@ -70,7 +70,7 @@
3.5.13
3.18.0
42.7.11
- 4.1.133.Final
+ 4.1.134.Final
10.1.55
2.4.0-b180830.0359
0.12.5
@@ -143,14 +143,12 @@
4.0.2
1.7.5
3.8.0
- 1.8.0-TB
- 2.5.9
- 2.38.0
+ 1.16.1-TB1
+ 2.43.0
1.24
1.11.0
- 3.49.3
+ 3.42.0
0.27.0
- 1.7.0
2.7.3
1.5.6
@@ -1383,11 +1381,6 @@
postgresql
${postgresql.version}
-
- org.apache.opennlp
- opennlp-tools
- ${opennlp-tools.version}
-
commons-io
commons-io
@@ -2032,11 +2025,6 @@
perfmark-api
${perfmark-api.version}
-
- org.threeten
- threetenbp
- ${threetenbp.version}
-
diff --git a/rest-client/pom.xml b/rest-client/pom.xml
index fe159973cf..cc0e1009d5 100644
--- a/rest-client/pom.xml
+++ b/rest-client/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
thingsboard
rest-client
diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml
index 3368f77cdc..976dd842b5 100644
--- a/rule-engine/pom.xml
+++ b/rule-engine/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
thingsboard
rule-engine
diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml
index b4643ece39..85b305f1f2 100644
--- a/rule-engine/rule-engine-api/pom.xml
+++ b/rule-engine/rule-engine-api/pom.xml
@@ -22,7 +22,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
rule-engine
org.thingsboard.rule-engine
diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml
index 1f25541bd9..4a3aaa5323 100644
--- a/rule-engine/rule-engine-components/pom.xml
+++ b/rule-engine/rule-engine-components/pom.xml
@@ -22,7 +22,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
rule-engine
org.thingsboard.rule-engine
diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java
index 7495276723..d6a5f26b4e 100644
--- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java
+++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java
@@ -52,10 +52,9 @@ import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.plugin.ComponentType;
-import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.msg.TbMsg;
-import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
+import org.thingsboard.server.exception.DataValidationException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
@@ -69,7 +68,6 @@ import java.util.Set;
import java.util.UUID;
import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
-import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbResponseFormatType;
import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields;
@Slf4j
@@ -92,7 +90,7 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie
configClazz = TbAiNodeConfiguration.class,
configDirective = "tbExternalNodeAiConfig",
iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDkiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OSA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zOC42MzExIDE3LjA3OTVDNDAuMTcwNSAxNy4wNzk2IDQxLjY1MTggMTcuNjg3MiA0Mi43NDc4IDE4Ljc3NjNDNDMuODQ0OCAxOS44NjYzIDQ0LjQ2NTkgMjEuMzUwMSA0NC40NjU5IDIyLjkwMjlWMzUuNDY1MkM0NC40NjU5IDM2LjM1MDkgNDQuMzU2NyAzNy4wNzY5IDQ0LjA5NzMgMzcuNzUxN0M0My44NDE0IDM4LjQxNjcgNDMuNDY1MSAzOC45NjE0IDQzLjA0NDggMzkuNTAyOEM0Mi40NjY3IDQwLjI0NzIgNDEuNjU2MyA0MC42ODU5IDQwLjg5MTkgNDAuOTM4OEM0MC4xMjExIDQxLjE5MzcgMzkuMzE0MyA0MS4yODg1IDM4LjYzMTEgNDEuMjg4NUgzMS4wMjU5TDIzLjM4MTIgNDUuODQ2NEMyMy4wNDMxIDQ2LjA0NzggMjIuNjI0MSA0Ni4wNTA3IDIyLjI4MzkgNDUuODUyOUMyMS45NDM3IDQ1LjY1NDcgMjEuNzMzOCA0NS4yODU5IDIxLjczMzcgNDQuODg3MlY0MS4yODg1SDE5LjY2NjNDMTguMTI2OSA0MS4yODg0IDE2LjY0NTUgNDAuNjgwOSAxNS41NDk2IDM5LjU5MThDMTQuNDUyNyAzOC41MDE5IDEzLjgzMTUgMzcuMDE3OSAxMy44MzE1IDM1LjQ2NTJWMjIuOTAyOUMxMy44MzE1IDIyLjMyMDIgMTMuOTE4NSAyMS43NDY4IDE0LjA4NTggMjEuMjAwN0wxNi4yODg5IDIxLjgxMDFMMTcuMjA5OSAyNS4yNTAyQzE3Ljk0MTYgMjcuOTg0NSAyMS43NTYyIDI3Ljk4NDQgMjIuNDg4IDI1LjI1MDJMMjMuNDA3OSAyMS44MTAxTDI2Ljc5MTcgMjAuODc0OUMyOC41NzkxIDIwLjM4MDUgMjkuMTc3IDE4LjUwMjYgMjguNTg4OCAxNy4wNzk1SDM4LjYzMTFaTTIyLjU4NDIgMzEuNTM5NUMyMS45OCAzMS41Mzk3IDIxLjQ5MDEgMzIuMDM3NiAyMS40OTAxIDMyLjY1MTlDMjEuNDkwMiAzMy4yNjYgMjEuOTgwMSAzMy43NjQgMjIuNTg0MiAzMy43NjQySDM0LjYxOTFDMzUuMjIzMyAzMy43NjQyIDM1LjcxMzEgMzMuMjY2MSAzNS43MTMyIDMyLjY1MTlDMzUuNzEzMiAzMi4wMzc1IDM1LjIyMzQgMzEuNTM5NSAzNC42MTkxIDMxLjUzOTVIMjIuNTg0MlpNMjQuNzcyMyAyNC44NjU3QzI0LjE2ODIgMjQuODY1OCAyMy42NzgzIDI1LjM2MzggMjMuNjc4MyAyNS45NzhDMjMuNjc4NCAyNi41OTIyIDI0LjE2ODMgMjcuMDkwMiAyNC43NzIzIDI3LjA5MDNIMzcuOTAxNEMzOC41MDU1IDI3LjA5MDMgMzguOTk1MyAyNi41OTIyIDM4Ljk5NTQgMjUuOTc4QzM4Ljk5NTQgMjUuMzYzNyAzOC41MDU2IDI0Ljg2NTcgMzcuOTAxNCAyNC44NjU3SDI0Ljc3MjNaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0xOC43ODkxIDExLjI5NzVDMTkuMDY5MSAxMC4xODA4IDIwLjYyOTkgMTAuMTgwOCAyMC45MDk5IDExLjI5NzVMMjEuOTE0MyAxNS4zMDM2QzIyLjAxMTYgMTUuNjkxOCAyMi4zMDY1IDE1Ljk5NzggMjIuNjg2NyAxNi4xMDNMMjYuMzYxMSAxNy4xMTg3QzI3LjQzNyAxNy40MTYyIDI3LjQzNyAxOC45Njc2IDI2LjM2MTEgMTkuMjY1MUwyMi42NzYxIDIwLjI4NEMyMi4zMDE4IDIwLjM4NzQgMjIuMDA4NyAyMC42ODQ1IDIxLjkwNjggMjEuMDY1TDIwLjkwNDYgMjQuODEyNUMyMC42MTE3IDI1LjkwNTggMTkuMDg2MSAyNS45MDU5IDE4Ljc5MzMgMjQuODEyNUwxNy43OTExIDIxLjA2NUMxNy42ODkzIDIwLjY4NDcgMTcuMzk3IDIwLjM4NzUgMTcuMDIyOSAyMC4yODRMMTMuMzM2OCAxOS4yNjUxQzEyLjI2MTQgMTguOTY3MyAxMi4yNjE1IDE3LjQxNjUgMTMuMzM2OCAxNy4xMTg3TDE3LjAxMTIgMTYuMTAzQzE3LjM5MTYgMTUuOTk3OCAxNy42ODc0IDE1LjY5MTkgMTcuNzg0NyAxNS4zMDM2TDE4Ljc4OTEgMTEuMjk3NVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTEwLjAzNDMgNy4wMjQyNUMxMC4zMDY4IDUuODk0NDQgMTEuODg2OCA1Ljg5NDQ0IDEyLjE1OTQgNy4wMjQyNUwxMi42OTg5IDkuMjYyOThDMTIuNzkyNyA5LjY1MTc0IDEzLjA4NTEgOS45NTg4NyAxMy40NjQgMTAuMDY3OUwxNS41NzczIDEwLjY3NTFDMTYuNjM5MyAxMC45ODAzIDE2LjYzOTMgMTIuNTEwOSAxNS41NzczIDEyLjgxNjFMMTMuNDUzMyAxMy40MjY1QzEzLjA4MDIgMTMuNTMzOCAxMi43OTA4IDEzLjgzMzkgMTIuNjkyNSAxNC4yMTUxTDEyLjE1NTEgMTYuMzA0QzExLjg3IDE3LjQxMTYgMTAuMzIzNiAxNy40MTE2IDEwLjAzODUgMTYuMzA0TDkuNTAwMDMgMTQuMjE1MUM5LjQwMTczIDEzLjgzMzkgOS4xMTIzNSAxMy41MzM3IDguNzM5MyAxMy40MjY1TDYuNjE1MjQgMTIuODE2MUM1LjU1Mzc4IDEyLjUxMDYgNS41NTM2NCAxMC45ODA0IDYuNjE1MjQgMTAuNjc1MUw4LjcyODYyIDEwLjA2NzlDOS4xMDc2IDkuOTU4OTggOS4zOTk3OCA5LjY1MTg0IDkuNDkzNjIgOS4yNjI5OEwxMC4wMzQzIDcuMDI0MjVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0yNS45MDI4IDYuNzMzMTNDMjYuMTg3OCA1LjYyNTQxIDI3LjczNDMgNS42MjU0MSAyOC4wMTkzIDYuNzMzMTNMMjguMjAzMSA3LjQ0Njc5QzI4LjMwMyA3LjgzNDMxIDI4LjYwMDEgOC4xMzcwNSAyOC45ODA5IDguMjM5NzVMMjkuNTM0NCA4LjM4OTY1QzMwLjYxOTIgOC42ODIxMiAzMC42MTkzIDEwLjI0NjkgMjkuNTM0NCAxMC41MzkzTDI4Ljk2OTIgMTAuNjkxNEMyOC41OTQ0IDEwLjc5MjUgMjguMjk5OSAxMS4wODgzIDI4LjE5NTYgMTEuNDY4TDI4LjAxNTEgMTIuMTI4NUMyNy43MTc0IDEzLjIxMjggMjYuMjA0NyAxMy4yMTI4IDI1LjkwNyAxMi4xMjg1TDI1LjcyNTQgMTEuNDY4QzI1LjYyMTEgMTEuMDg4MiAyNS4zMjY4IDEwLjc5MjQgMjQuOTUxOCAxMC42OTE0TDI0LjM4NzcgMTAuNTM5M0MyMy4zMDI2IDEwLjI0NyAyMy4zMDI2IDguNjgxOTggMjQuMzg3NyA4LjM4OTY1TDI0Ljk0MDEgOC4yMzk3NUMyNS4zMjExIDguMTM3MDkgMjUuNjE5MSA3LjgzNDQ2IDI1LjcxOSA3LjQ0Njc5TDI1LjkwMjggNi43MzMxM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPC9zdmc+Cg==",
- docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/ai-request/"
+ docUrl = "https://thingsboard.io/docs/reference/rule-engine/nodes/external/ai-request/"
)
public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
@@ -126,13 +124,11 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] must be of type CHAT, but was " + modelType, true);
}
AiChatModelConfig> chatModelConfig = (AiChatModelConfig>) model.getConfiguration();
- if (isJsonModeConfigured(config)) {
- if (!chatModelConfig.supportsJsonMode()) {
- throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] does not support '" + config.getResponseFormat().type() + "' response format", true);
- }
- // LangChain4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT
- responseFormat = config.getResponseFormat().toLangChainResponseFormat();
+ TbResponseFormat tbResponseFormat = config.getResponseFormat();
+ if (!tbResponseFormat.isSupportedBy(chatModelConfig)) {
+ throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] does not support '" + tbResponseFormat.type() + "' response format", true);
}
+ responseFormat = tbResponseFormat.toLangChainResponseFormat();
if (config.getResourceIds() != null && !config.getResourceIds().isEmpty()) {
resourceIds = new HashSet<>(config.getResourceIds().size());
for (UUID resourceId : config.getResourceIds()) {
@@ -148,11 +144,6 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
super.forceAck = config.isForceAck() || super.forceAck; // force ack if node config says so, or if env variable (super.forceAck) says so
}
- private static boolean isJsonModeConfigured(TbAiNodeConfiguration config) {
- var responseFormatType = config.getResponseFormat().type();
- return responseFormatType == TbResponseFormatType.JSON || responseFormatType == TbResponseFormatType.JSON_SCHEMA;
- }
-
@Override
public void onMsg(TbContext ctx, TbMsg msg) {
var ackedMsg = ackIfNeeded(ctx, msg);
diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java
index a32469be42..5dd1cd74f0 100644
--- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java
+++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java
@@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import dev.langchain4j.model.chat.request.ResponseFormat;
import dev.langchain4j.model.chat.request.ResponseFormatType;
import jakarta.validation.constraints.NotNull;
+import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
import org.thingsboard.server.common.data.validation.ValidJsonSchema;
import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat;
@@ -41,6 +42,8 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes
TbResponseFormatType type();
+ boolean isSupportedBy(AiChatModelConfig> modelConfig);
+
ResponseFormat toLangChainResponseFormat();
enum TbResponseFormatType {
@@ -58,6 +61,11 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes
return TbResponseFormatType.TEXT;
}
+ @Override
+ public boolean isSupportedBy(AiChatModelConfig> modelConfig) {
+ return true;
+ }
+
@Override
public ResponseFormat toLangChainResponseFormat() {
return ResponseFormat.TEXT;
@@ -72,6 +80,11 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes
return TbResponseFormatType.JSON;
}
+ @Override
+ public boolean isSupportedBy(AiChatModelConfig> modelConfig) {
+ return modelConfig.supportsSchemalessJsonOutput();
+ }
+
@Override
public ResponseFormat toLangChainResponseFormat() {
return ResponseFormat.JSON;
@@ -86,6 +99,11 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes
return TbResponseFormatType.JSON_SCHEMA;
}
+ @Override
+ public boolean isSupportedBy(AiChatModelConfig> modelConfig) {
+ return modelConfig.supportsJsonSchemaOutput();
+ }
+
@Override
public ResponseFormat toLangChainResponseFormat() {
return ResponseFormat.builder()
diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java
index e8aad50463..3a184267a5 100644
--- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java
+++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java
@@ -38,8 +38,8 @@ import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
-import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.DirectListeningExecutor;
+import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat;
import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonSchemaResponseFormat;
import org.thingsboard.rule.engine.ai.TbResponseFormat.TbTextResponseFormat;
@@ -53,8 +53,10 @@ import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.model.AiModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
import org.thingsboard.server.common.data.id.AiModelId;
@@ -400,6 +402,124 @@ class TbAiNodeTest {
.matches(e -> ((TbNodeException) e).isUnrecoverable());
}
+ @Test
+ void givenJsonSchemaResponseFormatAndModelSupportsIt_whenInit_thenDoesNotThrow() {
+ // GIVEN
+ var jsonSchema = """
+ {
+ "title": "Joke",
+ "type": "object",
+ "properties": {
+ "joke": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "joke"
+ ]
+ }
+ """;
+
+ config = constructValidConfig();
+ config.setResponseFormat(new TbJsonSchemaResponseFormat((ObjectNode) JacksonUtil.toJsonNode(jsonSchema)));
+
+ // Anthropic does not support schemaless JSON mode, but does support JSON Schema constrained output
+ modelConfig = AnthropicChatModelConfig.builder()
+ .providerConfig(new AnthropicProviderConfig("test-api-key"))
+ .modelId("claude-sonnet-4-5")
+ .build();
+
+ model = AiModel.builder()
+ .tenantId(tenantId)
+ .name("Test model")
+ .configuration(modelConfig)
+ .build();
+
+ model.setId(modelId);
+ model.setVersion(1L);
+ model.setCreatedTime(123L);
+
+ given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model));
+
+ // WHEN-THEN
+ assertThatNoException()
+ .isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))));
+ }
+
+ @Test
+ void givenJsonSchemaResponseFormatAndBedrockModel_whenInit_thenDoesNotThrow() {
+ // GIVEN
+ var jsonSchema = """
+ {
+ "title": "Joke",
+ "type": "object",
+ "properties": {
+ "joke": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "joke"
+ ]
+ }
+ """;
+
+ config = constructValidConfig();
+ config.setResponseFormat(new TbJsonSchemaResponseFormat((ObjectNode) JacksonUtil.toJsonNode(jsonSchema)));
+
+ // Bedrock does not support schemaless JSON mode, but does support JSON Schema constrained output (Converse API)
+ modelConfig = AmazonBedrockChatModelConfig.builder()
+ .providerConfig(new AmazonBedrockProviderConfig("us-east-1", "test-access-key", "test-secret-key"))
+ .modelId("anthropic.claude-sonnet-4-5")
+ .build();
+
+ model = AiModel.builder()
+ .tenantId(tenantId)
+ .name("Test model")
+ .configuration(modelConfig)
+ .build();
+
+ model.setId(modelId);
+ model.setVersion(1L);
+ model.setCreatedTime(123L);
+
+ given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model));
+
+ // WHEN-THEN
+ assertThatNoException()
+ .isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))));
+ }
+
+ @Test
+ void givenSchemalessJsonResponseFormatAndBedrockModel_whenInit_thenThrowsUnrecoverableTbNodeException() {
+ // GIVEN
+ config = constructValidConfig();
+ config.setResponseFormat(new TbJsonResponseFormat());
+
+ modelConfig = AmazonBedrockChatModelConfig.builder()
+ .providerConfig(new AmazonBedrockProviderConfig("us-east-1", "test-access-key", "test-secret-key"))
+ .modelId("anthropic.claude-sonnet-4-5")
+ .build();
+
+ model = AiModel.builder()
+ .tenantId(tenantId)
+ .name("Test model")
+ .configuration(modelConfig)
+ .build();
+
+ model.setId(modelId);
+ model.setVersion(1L);
+ model.setCreatedTime(123L);
+
+ given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model));
+
+ // WHEN-THEN
+ assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
+ .isInstanceOf(TbNodeException.class)
+ .hasMessage("[" + tenantId + "] AI model with ID: [" + modelId + "] does not support 'JSON' response format")
+ .matches(e -> ((TbNodeException) e).isUnrecoverable());
+ }
+
@Test
void givenNotExistingResources_whenInit_thenThrowsException() {
// GIVEN
@@ -669,7 +789,7 @@ class TbAiNodeTest {
argThat(actualChatRequest -> {
assertThat(actualChatRequest.messages()).hasSize(2);
assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(systemPrompt));
- assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents())
+ assertThat(((UserMessage) actualChatRequest.messages().get(1)).contents())
.containsAll(List.of(new TextContent(userPrompt), new TextContent(textData),
new TextContent(xmlData), new ImageContent(Base64.getEncoder().encodeToString(PNG_IMAGE), "image/png")));
return true;
@@ -706,7 +826,7 @@ class TbAiNodeTest {
argThat(actualChatRequest -> {
assertThat(actualChatRequest.messages()).hasSize(2);
assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(config.getSystemPrompt()));
- assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents())
+ assertThat(((UserMessage) actualChatRequest.messages().get(1)).contents())
.containsAll(List.of(new TextContent(config.getUserPrompt())));
return true;
})
@@ -993,7 +1113,7 @@ class TbAiNodeTest {
then(aiChatModelServiceMock).should().sendChatRequestAsync(
any(),
argThat(actualChatRequest -> {
- assertThat(actualChatRequest.responseFormat()).isNull();
+ assertThat(actualChatRequest.responseFormat()).isEqualTo(ResponseFormat.builder().type(ResponseFormatType.TEXT).build());
return true;
})
);
diff --git a/tools/pom.xml b/tools/pom.xml
index 7a83b99e1f..12ea0159c1 100644
--- a/tools/pom.xml
+++ b/tools/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
thingsboard
tools
diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml
index 78e7ba7f71..74bc0becda 100644
--- a/transport/coap/pom.xml
+++ b/transport/coap/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
transport
org.thingsboard.transport
diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml
index f55f155b45..cc1500105a 100644
--- a/transport/coap/src/main/resources/tb-coap-transport.yml
+++ b/transport/coap/src/main/resources/tb-coap-transport.yml
@@ -133,6 +133,8 @@ redis:
blockWhenExhausted: "${REDIS_POOL_CONFIG_BLOCK_WHEN_EXHAUSTED:true}"
transport:
+ # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently.
+ callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}"
# Local CoAP transport parameters
coap:
# CoaP processing timeout in milliseconds
diff --git a/transport/http/pom.xml b/transport/http/pom.xml
index 0fad7a5e63..db6459b3c9 100644
--- a/transport/http/pom.xml
+++ b/transport/http/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
transport
org.thingsboard.transport
diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml
index 69e610a812..53f8f5fc5b 100644
--- a/transport/http/src/main/resources/tb-http-transport.yml
+++ b/transport/http/src/main/resources/tb-http-transport.yml
@@ -167,6 +167,8 @@ redis:
# HTTP server parameters
transport:
+ # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently.
+ callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}"
http:
# HTTP request processing timeout in milliseconds
request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}"
diff --git a/transport/lwm2m/pom.xml b/transport/lwm2m/pom.xml
index 11d4c1f2db..61ae17fe7e 100644
--- a/transport/lwm2m/pom.xml
+++ b/transport/lwm2m/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
transport
org.thingsboard.transport
diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml
index 05fe2c2956..ca41c94434 100644
--- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml
+++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml
@@ -134,6 +134,8 @@ redis:
# LWM2M server parameters
transport:
+ # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently.
+ callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}"
sessions:
# Session inactivity timeout is a global configuration parameter that defines how long the device transport session will be opened after the last message arrives from the device.
# The parameter value is in milliseconds.
diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml
index 58b04e5bba..369cd33e8b 100644
--- a/transport/mqtt/pom.xml
+++ b/transport/mqtt/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
transport
org.thingsboard.transport
diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml
index 1ff998560a..99a8a5a593 100644
--- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml
+++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml
@@ -135,6 +135,8 @@ redis:
# MQTT server parameters
transport:
+ # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently.
+ callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}"
mqtt:
# MQTT bind-address
bind_address: "${MQTT_BIND_ADDRESS:0.0.0.0}"
diff --git a/transport/pom.xml b/transport/pom.xml
index 835f98f0ad..c4b690932f 100644
--- a/transport/pom.xml
+++ b/transport/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
thingsboard
transport
diff --git a/transport/snmp/pom.xml b/transport/snmp/pom.xml
index 3158a9efaa..2f7981d9a0 100644
--- a/transport/snmp/pom.xml
+++ b/transport/snmp/pom.xml
@@ -21,7 +21,7 @@
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
transport
diff --git a/transport/snmp/src/main/resources/tb-snmp-transport.yml b/transport/snmp/src/main/resources/tb-snmp-transport.yml
index bb46478477..5516148670 100644
--- a/transport/snmp/src/main/resources/tb-snmp-transport.yml
+++ b/transport/snmp/src/main/resources/tb-snmp-transport.yml
@@ -134,6 +134,8 @@ redis:
# Snmp server parameters
transport:
+ # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently.
+ callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}"
snmp:
# Enable/disable SNMP transport protocol
enabled: "${SNMP_ENABLED:true}"
diff --git a/ui-ngx/package.json b/ui-ngx/package.json
index 082d2ad965..e93721a696 100644
--- a/ui-ngx/package.json
+++ b/ui-ngx/package.json
@@ -1,6 +1,6 @@
{
"name": "thingsboard",
- "version": "4.3.1.2",
+ "version": "4.3.1.3",
"scripts": {
"ng": "ng",
"start": "node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0 --open",
diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml
index 7c73c7bc33..61194d94bf 100644
--- a/ui-ngx/pom.xml
+++ b/ui-ngx/pom.xml
@@ -20,7 +20,7 @@
4.0.0
org.thingsboard
- 4.3.1.2-SNAPSHOT
+ 4.3.1.3-SNAPSHOT
thingsboard
org.thingsboard
diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts
index 12f5bc60d8..f7c719cc0c 100644
--- a/ui-ngx/src/app/core/api/widget-api.models.ts
+++ b/ui-ngx/src/app/core/api/widget-api.models.ts
@@ -119,6 +119,7 @@ export interface WidgetActionsApi {
elementClick: ($event: Event) => void;
cardClick: ($event: Event) => void;
click: ($event: Event) => void;
+ invokeAction: ($event: Event, actionName: string, additionalParams?: any) => void;
getActiveEntityInfo: () => SubscriptionEntityInfo;
openDashboardStateInSeparateDialog: (targetDashboardStateId: string, params?: StateParams, dialogTitle?: string,
hideDashboardToolbar?: boolean, dialogWidth?: number, dialogHeight?: number) => MatDialogRef;
diff --git a/ui-ngx/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts
index 623ceefadd..6c123e393a 100644
--- a/ui-ngx/src/app/core/auth/auth.models.ts
+++ b/ui-ngx/src/app/core/auth/auth.models.ts
@@ -17,6 +17,7 @@
import { AuthUser, User } from '@shared/models/user.model';
import { UserSettings } from '@shared/models/user-settings.models';
import { TrendzSettings } from '@shared/models/trendz-settings.models';
+import { NullsOrderStrategy } from '@shared/models/page/page-link';
export interface SysParamsState {
userTokenAccessEnabled: boolean;
@@ -40,6 +41,8 @@ export interface SysParamsState {
calculatedFieldDebugPerTenantLimitsConfiguration?: string;
intermediateAggregationIntervalInSecForCF: number;
trendzSettings: TrendzSettings;
+ nullsOrderStrategy: NullsOrderStrategy;
+ edqsEnabled: boolean;
}
export interface SysParams extends SysParamsState {
diff --git a/ui-ngx/src/app/core/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts
index 51e02b1ab8..7b02d172a7 100644
--- a/ui-ngx/src/app/core/auth/auth.reducer.ts
+++ b/ui-ngx/src/app/core/auth/auth.reducer.ts
@@ -42,7 +42,9 @@ const emptyUserAuthState: AuthPayload = {
maxDebugModeDurationMinutes: 0,
intermediateAggregationIntervalInSecForCF: 0,
userSettings: initialUserSettings,
- trendzSettings: initialTrendzSettings
+ trendzSettings: initialTrendzSettings,
+ nullsOrderStrategy: 'default',
+ edqsEnabled: false
};
export const initialState: AuthState = {
diff --git a/ui-ngx/src/app/core/auth/auth.service.ts b/ui-ngx/src/app/core/auth/auth.service.ts
index 07a8c5b7a2..ea8d1caa39 100644
--- a/ui-ngx/src/app/core/auth/auth.service.ts
+++ b/ui-ngx/src/app/core/auth/auth.service.ts
@@ -20,6 +20,7 @@ import { HttpClient } from '@angular/common/http';
import { Observable, of, ReplaySubject, throwError } from 'rxjs';
import { catchError, map, mergeMap, tap } from 'rxjs/operators';
+import { setEdqsEnabled, setNullsOrderStrategy } from '@shared/models/page/page-link';
import { LoginRequest, LoginResponse, PublicLoginRequest } from '@shared/models/login.models';
import { Router, UrlTree } from '@angular/router';
@@ -456,6 +457,8 @@ export class AuthService {
return this.http.get('/api/system/params', defaultHttpOptions()).pipe(
map((sysParams) => {
this.timeService.setMaxDatapointsLimit(sysParams.maxDatapointsLimit);
+ setNullsOrderStrategy(sysParams.nullsOrderStrategy);
+ setEdqsEnabled(sysParams.edqsEnabled);
return sysParams;
}),
catchError(() => of({} as SysParamsState))
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html
index 73605e0767..483d6be37a 100644
--- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html
+++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html
@@ -91,9 +91,15 @@
{{ 'rule-node-config.ai.response-format' | translate }}
- {{ 'rule-node-config.ai.response-text' | translate }}
- {{ 'rule-node-config.ai.response-json' | translate }}
- {{ 'rule-node-config.ai.response-json-schema' | translate }}
+ @if (allowedResponseFormats.includes(responseFormat.TEXT)) {
+ {{ 'rule-node-config.ai.response-text' | translate }}
+ }
+ @if (allowedResponseFormats.includes(responseFormat.JSON)) {
+ {{ 'rule-node-config.ai.response-json' | translate }}
+ }
+ @if (allowedResponseFormats.includes(responseFormat.JSON_SCHEMA)) {
+ {{ 'rule-node-config.ai.response-json-schema' | translate }}
+ }
-