Browse Source

Merge remote-tracking branch 'upstream/lts-4.2' into ota-data-clean-up

pull/14775/head
Andrii Landiak 8 months ago
parent
commit
ab725baae1
  1. 4
      .github/release.yml
  2. 1
      .gitignore
  3. 39
      TEST_FAST.md
  4. 3
      application/pom.xml
  5. 30
      application/src/main/data/upgrade/basic/schema_update.sql
  6. 10
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  7. 15
      application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
  8. 4
      application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java
  9. 10
      application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java
  10. 4
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java
  11. 34
      application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AdminSettingsEdgeEventFetcher.java
  12. 2
      application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/OAuth2EdgeEventFetcher.java
  13. 10
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/settings/AdminSettingsEdgeProcessor.java
  14. 3
      application/src/main/java/org/thingsboard/server/service/edge/rpc/utils/EdgeVersionUtils.java
  15. 14
      application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java
  16. 7
      application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java
  17. 10
      application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java
  18. 10
      application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java
  19. 24
      application/src/main/java/org/thingsboard/server/service/sync/DefaultGitSyncService.java
  20. 16
      application/src/main/resources/thingsboard.yml
  21. 43
      application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java
  22. 79
      application/src/test/java/org/thingsboard/server/edge/AdminSettingsEdgeTest.java
  23. 41
      application/src/test/java/org/thingsboard/server/edge/EdgeLatestVersionTest.java
  24. 34
      application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java
  25. 54
      application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java
  26. 60
      application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java
  27. 19
      application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/AbstractOtaLwM2MIntegrationTest.java
  28. 4
      application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota5LwM2MIntegrationTest.java
  29. 15
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java
  30. 15
      application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java
  31. 2
      common/actor/pom.xml
  32. 2
      common/cache/pom.xml
  33. 2
      common/cluster-api/pom.xml
  34. 2
      common/coap-server/pom.xml
  35. 2
      common/dao-api/pom.xml
  36. 32
      common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/ResultSetSizeLimitExceededException.java
  37. 24
      common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/TbResultSet.java
  38. 4
      common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java
  39. 140
      common/dao-api/src/test/java/org/thingsboard/server/dao/nosql/TbResultSetTest.java
  40. 2
      common/data/pom.xml
  41. 2
      common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java
  42. 1
      common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java
  43. 2
      common/discovery-api/pom.xml
  44. 7
      common/edge-api/pom.xml
  45. 11
      common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java
  46. 86
      common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeVersionComparator.java
  47. 4
      common/edge-api/src/main/proto/edge.proto
  48. 102
      common/edge-api/src/test/java/org/thingsboard/edge/rpc/EdgeVersionComparatorTest.java
  49. 2
      common/edqs/pom.xml
  50. 2
      common/message/pom.xml
  51. 9
      common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java
  52. 12
      common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgProcessingCtx.java
  53. 89
      common/message/src/test/java/org/thingsboard/server/common/msg/TbMsgProcessingCtxTest.java
  54. 4
      common/message/src/test/java/org/thingsboard/server/common/msg/tools/TbRateLimitsTest.java
  55. 2
      common/pom.xml
  56. 2
      common/proto/pom.xml
  57. 4
      common/proto/src/main/java/org/thingsboard/server/common/adaptor/ProtoConverter.java
  58. 2
      common/queue/pom.xml
  59. 2
      common/script/pom.xml
  60. 2
      common/script/remote-js-client/pom.xml
  61. 2
      common/script/script-api/pom.xml
  62. 8
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java
  63. 95
      common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java
  64. 2
      common/stats/pom.xml
  65. 2
      common/transport/coap/pom.xml
  66. 2
      common/transport/http/pom.xml
  67. 2
      common/transport/lwm2m/pom.xml
  68. 2
      common/transport/mqtt/pom.xml
  69. 2
      common/transport/pom.xml
  70. 2
      common/transport/snmp/pom.xml
  71. 2
      common/transport/transport-api/pom.xml
  72. 2
      common/util/pom.xml
  73. 7
      common/util/src/main/java/org/thingsboard/common/util/DirectListeningExecutor.java
  74. 247
      common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java
  75. 338
      common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java
  76. 2
      common/version-control/pom.xml
  77. 35
      common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java
  78. 2
      dao/pom.xml
  79. 3
      dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractAsyncDao.java
  80. 27
      dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java
  81. 4
      dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsDao.java
  82. 19
      dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java
  83. 5
      dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDaoListeningExecutorService.java
  84. 4
      dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDao.java
  85. 4
      dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/TimescaleTimeseriesDao.java
  86. 92
      dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java
  87. 2
      dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java
  88. 4
      dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java
  89. 23
      dao/src/test/java/org/thingsboard/server/dao/service/AdminSettingsServiceTest.java
  90. 18
      dao/src/test/java/org/thingsboard/server/dao/service/ConstraintValidatorTest.java
  91. 42
      dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java
  92. 154
      dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/LatestTimeseriesPerformanceTest.java
  93. 7
      dao/src/test/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDaoTest.java
  94. 2
      edqs/pom.xml
  95. 2
      edqs/src/main/resources/edqs.yml
  96. 2
      monitoring/pom.xml
  97. 2
      msa/black-box-tests/pom.xml
  98. 2
      msa/edqs/pom.xml
  99. 5
      msa/js-executor/package.json
  100. 2
      msa/js-executor/pom.xml

4
.github/release.yml

@ -19,6 +19,10 @@ changelog:
labels:
- Ignore for release
categories:
- title: 'Security'
labels:
- 'Security'
- title: 'Major Core & Rule Engine'
labels:
- 'Major Core'

1
.gitignore

@ -37,3 +37,4 @@ rebuild-docker.sh
*/.run/**
.run/**
.run
.claude/

39
TEST_FAST.md

@ -10,22 +10,43 @@ mvn clean install -T6 -DskipTests
mvn test -pl='!application,!dao,!ui-ngx,!msa/js-executor,!msa/web-ui' -T4
mvn test -pl dao -Dparallel=packages -DforkCount=4
mvn test -pl application -Dsurefire.excludes='**/nosql/*Test.java' -Dtest='org.thingsboard.server.controller.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dsurefire.excludes='**/nosql/*Test.java' -Dtest='org.thingsboard.server.edge.**' -DforkCount=4 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dsurefire.excludes='**/nosql/*Test.java' -Dtest='org.thingsboard.server.service.**' -DforkCount=6 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dsurefire.excludes='**/nosql/*Test.java' -Dtest='org.thingsboard.server.transport.mqtt.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dsurefire.excludes='**/nosql/*Test.java' -Dtest='org.thingsboard.server.transport.coap.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dsurefire.excludes='**/nosql/*Test.java' -Dtest='org.thingsboard.server.transport.lwm2m.**' -DforkCount=6 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dsurefire.excludes='**/nosql/*Test.java' -Dtest='**/*TestSuite.java' -DforkCount=4 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.controller.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.edge.**' -DforkCount=4 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.service.**' -DforkCount=6 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.transport.mqtt.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.transport.coap.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.transport.lwm2m.**' -DforkCount=6 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dtest='**/*TestSuite.java' -DforkCount=4 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
#the rest of application tests
mvn test -pl application -Dtest='
!**/nosql/*Test.java,
!**/nosql/**,
!org.thingsboard.server.controller.**,
!org.thingsboard.server.edge.**,
!org.thingsboard.server.service.**,
!org.thingsboard.server.transport.mqtt.**,
!org.thingsboard.server.transport.coap.**,
!org.thingsboard.server.transport.lwm2m.**
!org.thingsboard.server.transport.lwm2m.**,
!**/*TestSuite.java
' -DforkCount=6 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
```
## Testcontainers compatibility with the Docker API workaround
In case your tests failed to run testcontainers due to unsupported Docker API version
:coffee: testcontainers (Docker API 1.32) + :whale: docker 29 (min API 1.44) workaround
Add to /etc/docker/daemon.json and restart docker
```json
{
"min-api-version": "1.32"
}
```
Same works on Mac, except `daemon.json` are located in another folder and required to be edited from Docker Desktop UI.
Tip: If your testcontainer are struggling to find any Docker. You can try to remove the testcontainers property file. It will be recreated on the next testcontainers run.
```bash
rm ~/.testcontainers.properties
```

3
application/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>thingsboard</artifactId>
</parent>
<artifactId>application</artifactId>
@ -458,6 +458,7 @@
<configuration>
<systemPropertyVariables>
<spring.config.name>thingsboard</spring.config.name>
<project.version>${project.version}</project.version>
</systemPropertyVariables>
<excludes>
<exclude>**/nosql/*Test.java</exclude>

30
application/src/main/data/upgrade/basic/schema_update.sql

@ -14,3 +14,33 @@
-- limitations under the License.
--
-- UPDATE OTA PACKAGE EXTERNAL ID START
ALTER TABLE ota_package
ADD COLUMN IF NOT EXISTS external_id uuid;
DO
$$
BEGIN
IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'ota_package_external_id_unq_key') THEN
ALTER TABLE ota_package ADD CONSTRAINT ota_package_external_id_unq_key UNIQUE (tenant_id, external_id);
END IF;
END;
$$;
-- UPDATE OTA PACKAGE EXTERNAL ID END
-- DROP INDEXES THAT DUPLICATE UNIQUE CONSTRAINT START
DROP INDEX IF EXISTS idx_device_external_id;
DROP INDEX IF EXISTS idx_device_profile_external_id;
DROP INDEX IF EXISTS idx_asset_external_id;
DROP INDEX IF EXISTS idx_entity_view_external_id;
DROP INDEX IF EXISTS idx_rule_chain_external_id;
DROP INDEX IF EXISTS idx_dashboard_external_id;
DROP INDEX IF EXISTS idx_customer_external_id;
DROP INDEX IF EXISTS idx_widgets_bundle_external_id;
-- DROP INDEXES THAT DUPLICATE UNIQUE CONSTRAINT END
ALTER TABLE mobile_app ADD COLUMN IF NOT EXISTS title varchar(255);

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

@ -30,6 +30,7 @@ import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.JobManager;
import org.thingsboard.rule.engine.api.MailService;
@ -143,6 +144,7 @@ import org.thingsboard.server.utils.DebugModeRateLimitsConfig;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@ -607,9 +609,17 @@ public class ActorSystemContext {
@Getter
private boolean localCacheType;
@Value("${actors.rule.external.ssrf_protection_enabled:false}")
private boolean ssrfProtectionEnabled;
@Value("${actors.rule.external.ssrf_additional_blocked_hosts:}")
private List<String> ssrfAdditionalBlockedHosts;
@PostConstruct
public void init() {
this.localCacheType = "caffeine".equals(cacheType);
SsrfProtectionValidator.setEnabled(ssrfProtectionEnabled);
SsrfProtectionValidator.setAdditionalBlockedHosts(ssrfAdditionalBlockedHosts);
}
@Value("${actors.tenant.create_components_on_init:true}")

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

@ -183,11 +183,24 @@ public class DefaultTbContext implements TbContext {
if (!msg.isValid()) {
return;
}
RuleChainId selfRuleChainId = nodeCtx.getSelf().getRuleChainId();
RuleNodeId selfId = nodeCtx.getSelf().getId();
if (msg.isAlreadyInStack(selfRuleChainId, selfId)) {
log.warn("[{}] Detected rule chain processing loop for rule node [{}] in rule chain [{}]. " +
"The message will be failed to prevent infinite loop. " +
"Please check the rule chain configuration for circular references.",
nodeCtx.getTenantId(), selfId, selfRuleChainId);
tellFailure(msg, new RuntimeException(
"Detected rule chain processing loop for rule node [" + selfId + "] " +
"in rule chain [" + selfRuleChainId + "]. " +
"Please check the rule chain configuration for circular references."));
return;
}
TbMsg tbMsg = msg.copy()
.ruleChainId(ruleChainId)
.resetRuleNodeId()
.build();
tbMsg.pushToStack(nodeCtx.getSelf().getRuleChainId(), nodeCtx.getSelf().getId());
tbMsg.pushToStack(selfRuleChainId, selfId);
TopicPartitionInfo tpi = resolvePartition(msg);
doEnqueue(tpi, tbMsg, new SimpleTbQueueCallback(md -> ack(msg), t -> tellFailure(msg, t)));
}

4
application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java

@ -137,7 +137,7 @@ public class RuleNodeActorMessageProcessor extends ComponentMsgProcessor<RuleNod
defaultCtx.tellFailure(msg.getMsg(), e);
}
} else {
tbMsg.getCallback().onFailure(new RuleNodeException("Message is processed by more then " + maxRuleNodeExecutionsPerMessage + " rule nodes!", ruleChainName, ruleNode));
tbMsg.getCallback().onFailure(new RuleNodeException("Message is processed by more than " + maxRuleNodeExecutionsPerMessage + " rule nodes!", ruleChainName, ruleNode));
}
}
@ -160,7 +160,7 @@ public class RuleNodeActorMessageProcessor extends ComponentMsgProcessor<RuleNod
msg.getCtx().tellFailure(msg.getMsg(), e);
}
} else {
tbMsg.getCallback().onFailure(new RuleNodeException("Message is processed by more then " + maxRuleNodeExecutionsPerMessage + " rule nodes!", ruleChainName, ruleNode));
tbMsg.getCallback().onFailure(new RuleNodeException("Message is processed by more than " + maxRuleNodeExecutionsPerMessage + " rule nodes!", ruleChainName, ruleNode));
}
}
}

10
application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java

@ -17,6 +17,7 @@ package org.thingsboard.server.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.WebSocketHandler;
@ -40,11 +41,16 @@ public class WebSocketConfiguration implements WebSocketConfigurer {
private final WebSocketHandler wsHandler;
@Value("${server.ws.max_text_message_buffer_size:32768}")
private int maxTextMessageBufferSize;
@Value("${server.ws.max_binary_message_buffer_size:32768}")
private int maxBinaryMessageBufferSize;
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxTextMessageBufferSize(32768);
container.setMaxBinaryMessageBufferSize(32768);
container.setMaxTextMessageBufferSize(maxTextMessageBufferSize);
container.setMaxBinaryMessageBufferSize(maxBinaryMessageBufferSize);
return container;
}

4
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java

@ -19,6 +19,7 @@ import lombok.Getter;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.rpc.fetch.AdminSettingsEdgeEventFetcher;
import org.thingsboard.server.service.edge.rpc.fetch.AssetProfilesEdgeEventFetcher;
@ -62,7 +63,8 @@ public class EdgeSyncCursor {
fetchers.add(new TenantEdgeEventFetcher(ctx.getTenantService()));
fetchers.add(new QueuesEdgeEventFetcher(ctx.getQueueService()));
fetchers.add(new RuleChainsEdgeEventFetcher(ctx.getRuleChainService()));
fetchers.add(new AdminSettingsEdgeEventFetcher(ctx.getAdminSettingsService()));
fetchers.add(new AdminSettingsEdgeEventFetcher(ctx.getAdminSettingsService(), TenantId.SYS_TENANT_ID));
fetchers.add(new AdminSettingsEdgeEventFetcher(ctx.getAdminSettingsService(), edge.getTenantId()));
fetchers.add(new TenantAdminUsersEdgeEventFetcher(ctx.getUserService()));
}
Customer publicCustomer = ctx.getCustomerService().findPublicCustomer(edge.getTenantId());

34
application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AdminSettingsEdgeEventFetcher.java

@ -17,50 +17,32 @@ package org.thingsboard.server.service.edge.rpc.fetch;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.EdgeUtils;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.edge.EdgeEventActionType;
import org.thingsboard.server.common.data.edge.EdgeEventType;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.settings.AdminSettingsService;
import java.util.ArrayList;
import java.util.List;
@AllArgsConstructor
@Slf4j
public class AdminSettingsEdgeEventFetcher implements EdgeEventFetcher {
public class AdminSettingsEdgeEventFetcher extends BasePageableEdgeEventFetcher<AdminSettings> {
private final AdminSettingsService adminSettingsService;
private final TenantId fetcherTenantId;
@Override
public PageLink getPageLink(int pageSize) {
return null;
}
public PageData<EdgeEvent> fetchEdgeEvents(TenantId tenantId, Edge edge, PageLink pageLink) {
List<EdgeEvent> result = fetchAdminSettingsForKeys(tenantId, edge.getId(), List.of("general", "mail", "connectivity", "jwt"));
// return PageData object to be in sync with other fetchers
return new PageData<>(result, 1, result.size(), false);
PageData<AdminSettings> fetchEntities(TenantId tenantId, Edge edge, PageLink pageLink) {
return adminSettingsService.findAllByTenantId(fetcherTenantId, pageLink);
}
private List<EdgeEvent> fetchAdminSettingsForKeys(TenantId tenantId, EdgeId edgeId, List<String> keys) {
List<EdgeEvent> result = new ArrayList<>();
for (String key : keys) {
AdminSettings adminSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, key);
if (adminSettings != null) {
result.add(EdgeUtils.constructEdgeEvent(tenantId, edgeId, EdgeEventType.ADMIN_SETTINGS,
EdgeEventActionType.UPDATED, null, JacksonUtil.valueToTree(adminSettings)));
}
}
return result;
@Override
EdgeEvent constructEdgeEvent(TenantId tenantId, Edge edge, AdminSettings adminSettings) {
return EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.ADMIN_SETTINGS,
EdgeEventActionType.UPDATED, adminSettings.getId(), null);
}
}

2
application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/OAuth2EdgeEventFetcher.java

@ -41,7 +41,7 @@ public class OAuth2EdgeEventFetcher extends BasePageableEdgeEventFetcher<DomainI
@Override
EdgeEvent constructEdgeEvent(TenantId tenantId, Edge edge, DomainInfo domainInfo) {
return EdgeUtils.constructEdgeEvent(TenantId.SYS_TENANT_ID, edge.getId(), EdgeEventType.DOMAIN,
return EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.DOMAIN,
EdgeEventActionType.ADDED, domainInfo.getId(), null);
}

10
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/settings/AdminSettingsEdgeProcessor.java

@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.EdgeUtils;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.edge.EdgeEventType;
import org.thingsboard.server.common.data.id.AdminSettingsId;
import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.EdgeVersion;
@ -35,7 +36,14 @@ public class AdminSettingsEdgeProcessor extends BaseEdgeProcessor {
@Override
public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) {
AdminSettings adminSettings = JacksonUtil.convertValue(edgeEvent.getBody(), AdminSettings.class);
AdminSettings adminSettings = null;
if (edgeEvent.getEntityId() != null) {
AdminSettingsId adminSettingsId = new AdminSettingsId(edgeEvent.getEntityId());
adminSettings = edgeCtx.getAdminSettingsService().findAdminSettingsById(edgeEvent.getTenantId(), adminSettingsId);
} else if (edgeEvent.getBody() != null && !edgeEvent.getBody().isEmpty()) {
// legacy
adminSettings = JacksonUtil.convertValue(edgeEvent.getBody(), AdminSettings.class);
}
if (adminSettings == null) {
return null;
}

3
application/src/main/java/org/thingsboard/server/service/edge/rpc/utils/EdgeVersionUtils.java

@ -16,13 +16,14 @@
package org.thingsboard.server.service.edge.rpc.utils;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.edge.rpc.EdgeVersionComparator;
import org.thingsboard.server.gen.edge.v1.EdgeVersion;
@Slf4j
public final class EdgeVersionUtils {
public static boolean isEdgeVersionOlderThan(EdgeVersion currentVersion, EdgeVersion requiredVersion) {
return currentVersion.ordinal() < requiredVersion.ordinal();
return EdgeVersionComparator.INSTANCE.compare(currentVersion, requiredVersion) < 0;
}
}

14
application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java

@ -21,16 +21,18 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.thingsboard.server.service.install.update.DefaultDataUpdateService;
import java.util.List;
import java.util.Map;
@Service
@Slf4j
@RequiredArgsConstructor
public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSettingsService {
// This list should include all versions that are compatible for the upgrade in 4 digits format (like 4.2.0.0, etc.).
// The compatibility cycle usually breaks when we have some scripts written in Java that may not work after a new release.
private static final List<String> SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.1.0");
// map of versions from which the upgrade to the current version is possible
// key - supported version prefix, value - display name
private static final Map<String, String> SUPPORTED_VERSIONS_FOR_UPGRADE = Map.of(
"4.2.0", "4.2.0"
);
private final ProjectInfo projectInfo;
private final JdbcTemplate jdbcTemplate;
@ -55,9 +57,9 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti
onSchemaSettingsError("Upgrade failed: database already upgraded to current version. You can set SKIP_SCHEMA_VERSION_CHECK to 'true' if force re-upgrade needed.");
}
if (!SUPPORTED_VERSIONS_FOR_UPGRADE.contains(dbSchemaVersion)) {
if (SUPPORTED_VERSIONS_FOR_UPGRADE.keySet().stream().noneMatch(dbSchemaVersion::startsWith)) {
onSchemaSettingsError(String.format("Upgrade failed: database version '%s' is not supported for upgrade. Supported versions are: %s.",
dbSchemaVersion, SUPPORTED_VERSIONS_FOR_UPGRADE
dbSchemaVersion, SUPPORTED_VERSIONS_FOR_UPGRADE.values()
));
}
}

7
application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java

@ -19,14 +19,17 @@ import lombok.RequiredArgsConstructor;
import org.springframework.boot.info.BuildProperties;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
@RequiredArgsConstructor
public class ProjectInfo {
private final BuildProperties buildProperties;
private final Optional<BuildProperties> buildProperties;
public String getProjectVersion() {
return buildProperties.getVersion().replaceAll("[^\\d.]", "");
return buildProperties.orElseThrow(() -> new IllegalStateException("Build properties are missing. Please rebuild the project with maven"))
.getVersion().replaceAll("[^\\d.]", "");
}
public String getProductType() {

10
application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java

@ -79,9 +79,13 @@ public class AppleOAuth2ClientMapper extends AbstractOAuth2ClientMapper implemen
}
}
if (user.has(EMAIL)) {
JsonNode email = user.get(EMAIL);
if (email != null && email.isTextual()) {
updated.put(EMAIL, email.asText());
JsonNode emailNode = user.get(EMAIL);
if (emailNode != null && emailNode.isTextual()) {
Object tokenEmail = attributes.get(EMAIL);
if (tokenEmail != null && !emailNode.asText().equals(tokenEmail.toString())) {
log.warn("Apple OAuth2 callback: ignoring email [{}] from user POST parameter " +
"that differs from validated ID token email [{}]", emailNode.asText(), tokenEmail);
}
}
}
}

10
application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java

@ -29,18 +29,10 @@ public class RestAuthenticationDetails implements Serializable {
private final Client userAgent;
public RestAuthenticationDetails(HttpServletRequest request) {
this.clientAddress = getClientIP(request);
this.clientAddress = request.getRemoteAddr();
this.userAgent = getUserAgent(request);
}
private static String getClientIP(HttpServletRequest request) {
String xfHeader = request.getHeader("X-Forwarded-For");
if (xfHeader == null) {
return request.getRemoteAddr();
}
return xfHeader.split(",")[0];
}
private static Client getUserAgent(HttpServletRequest request) {
Parser uaParser = new Parser();
return uaParser.parse(request.getHeader("User-Agent"));

24
application/src/main/java/org/thingsboard/server/service/sync/DefaultGitSyncService.java

@ -18,6 +18,7 @@ package org.thingsboard.server.service.sync;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.jgit.revwalk.RevCommit;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.ThingsBoardExecutors;
@ -47,6 +48,8 @@ public class DefaultGitSyncService implements GitSyncService {
private final Map<String, GitRepository> repositories = new ConcurrentHashMap<>();
private final Map<String, Runnable> updateListeners = new ConcurrentHashMap<>();
private final Map<String, RevCommit> lastCommits = new ConcurrentHashMap<>();
@Override
public void registerSync(String key, String repoUri, String branch, long fetchFrequencyMs, Runnable onUpdate) {
RepositorySettings settings = new RepositorySettings();
@ -84,7 +87,7 @@ public class DefaultGitSyncService implements GitSyncService {
@Override
public List<RepoFile> listFiles(String key, String path, int depth, FileType type) {
GitRepository repository = getRepository(key);
return repository.listFilesAtCommit(getBranchRef(repository), path, depth).stream()
return repository.listFilesAtCommit(getLastCommit(key), path, depth).stream()
.filter(file -> type == null || file.type() == type)
.toList();
}
@ -93,7 +96,7 @@ public class DefaultGitSyncService implements GitSyncService {
@Override
public byte[] getFileContent(String key, String path) {
GitRepository repository = getRepository(key);
return repository.getFileContentAtCommit(path, getBranchRef(repository));
return repository.getFileContentAtCommit(path, getLastCommit(key));
}
@Override
@ -137,6 +140,15 @@ public class DefaultGitSyncService implements GitSyncService {
}
private void onUpdate(String key) {
GitRepository repository = getRepository(key);
String branchRef = getBranchRef(repository);
try {
lastCommits.put(key, repository.resolveCommit(branchRef));
} catch (Throwable e) {
log.error("[{}] Failed to resolve commit for ref {}", key, branchRef, e);
return;
}
Runnable listener = updateListeners.get(key);
if (listener != null) {
log.debug("[{}] Handling repository update", key);
@ -154,6 +166,14 @@ public class DefaultGitSyncService implements GitSyncService {
return Path.of(repositoriesFolder, name);
}
private RevCommit getLastCommit(String key) {
RevCommit commit = lastCommits.get(key);
if (commit == null) {
throw new IllegalStateException(key + " repository has no resolved commit");
}
return commit;
}
private String getBranchRef(GitRepository repository) {
return "refs/remotes/origin/" + repository.getSettings().getDefaultBranch();
}

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

@ -78,6 +78,10 @@ server:
max_entities_per_data_subscription: "${TB_SERVER_WS_MAX_ENTITIES_PER_DATA_SUBSCRIPTION:10000}"
# Maximum number of alarms returned for single alarm subscription. For example, no more than 10,000 alarms on the alarm widget
max_entities_per_alarm_subscription: "${TB_SERVER_WS_MAX_ENTITIES_PER_ALARM_SUBSCRIPTION:10000}"
# Maximum size (bytes) of incoming WS text message
max_text_message_buffer_size: "${TB_SERVER_WS_MAX_TEXT_MESSAGE_BUFFER_SIZE:32768}"
# Maximum size (bytes) of incoming WS binary message
max_binary_message_buffer_size: "${TB_SERVER_WS_MAX_BINARY_MESSAGE_BUFFER_SIZE:32768}"
# Maximum queue size of the websocket updates per session. This restriction prevents infinite updates of WS
max_queue_messages_per_session: "${TB_SERVER_WS_DEFAULT_QUEUE_MESSAGES_PER_SESSION:1000}"
# Maximum time between WS session opening and sending auth command
@ -208,7 +212,7 @@ ui:
# Help parameters
help:
# Base URL for UI help assets
base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.2.1}"
base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.2.2}"
# Database telemetry parameters
database:
@ -330,6 +334,8 @@ cassandra:
set_null_values_enabled: "${CASSANDRA_QUERY_SET_NULL_VALUES_ENABLED:true}"
# log one of cassandra queries with specified frequency (0 - logging is disabled)
print_queries_freq: "${CASSANDRA_QUERY_PRINT_FREQ:0}"
# Maximum total size in bytes of a Cassandra query result set across all pages. Default is 50MB. 0 means unlimited
max_result_set_size_in_bytes: "${CASSANDRA_QUERY_MAX_RESULT_SET_SIZE_IN_BYTES:52428800}"
tenant_rate_limits:
# Whether to print rate-limited tenant names when printing Cassandra query queue statistic
print_tenant_names: "${CASSANDRA_QUERY_TENANT_RATE_LIMITS_PRINT_TENANT_NAMES:false}"
@ -495,6 +501,12 @@ actors:
# Force acknowledgment of the incoming message for external rule nodes to decrease processing latency.
# Enqueue the result of external node processing as a separate message to the rule engine.
force_ack: "${ACTORS_RULE_EXTERNAL_NODE_FORCE_ACK:false}"
# Enable Server-Side Request Forgery (SSRF) protection for external HTTP rule nodes (rest api call).
# When enabled, requests to private/internal network addresses are blocked.
ssrf_protection_enabled: "${SSRF_PROTECTION_ENABLED:false}"
# Comma-separated list of additional blocked destinations (IPs, CIDR subnets, or hostnames).
# Example: "198.51.100.0/24,metadata.tencentyun.com,rancher-metadata"
ssrf_additional_blocked_hosts: "${SSRF_ADDITIONAL_BLOCKED_HOSTS:}"
rpc:
# Maximum number of persistent RPC call retries in case of failed request delivery.
max_retries: "${ACTORS_RPC_MAX_RETRIES:5}"
@ -2011,7 +2023,7 @@ management:
web:
exposure:
# Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics).
include: '${METRICS_ENDPOINTS_EXPOSE:info}'
include: "${METRICS_ENDPOINTS_EXPOSE:info}"
health:
elasticsearch:
# Enable the org.springframework.boot.actuate.elasticsearch.ElasticsearchRestClientHealthIndicator.doHealthCheck

43
application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java

@ -42,6 +42,7 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.msg.TbNodeConnectionType;
import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.common.msg.TbActorMsg;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.common.msg.TbMsgProcessingStackItem;
@ -134,6 +135,48 @@ class DefaultTbContextTest {
then(clusterService).should().pushMsgToRuleEngine(eq(tpi), eq(msg.getId()), any(), any());
}
@Test
public void givenMsgWithCurrentNodeAlreadyInStack_whenInput_thenTellFailureToPreventLoop() {
// GIVEN - simulate the second iteration: current node is already in the return stack,
// which happens when forwardMsgToDefaultRuleChain=true and the device's default rule chain
// is the same as the rule chain containing this node
var callbackMock = mock(TbMsgCallback.class);
given(callbackMock.isMsgValid()).willReturn(true);
var ruleNode = new RuleNode(RULE_NODE_ID);
ruleNode.setRuleChainId(RULE_CHAIN_ID);
ruleNode.setDebugSettings(DebugSettings.off());
given(nodeCtxMock.getSelf()).willReturn(ruleNode);
given(nodeCtxMock.getTenantId()).willReturn(TENANT_ID);
given(nodeCtxMock.getChainActor()).willReturn(chainActorMock);
var msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(TENANT_ID)
.copyMetaData(TbMsgMetaData.EMPTY)
.data(TbMsg.EMPTY_STRING)
.callback(callbackMock)
.build();
// Push current node into the stack - simulates a previous iteration that already forwarded to this rule chain
msg.pushToStack(RULE_CHAIN_ID, RULE_NODE_ID);
var targetRuleChainId = new RuleChainId(UUID.randomUUID());
// WHEN
defaultTbContext.input(msg, targetRuleChainId);
// THEN - loop detected: tellFailure is called and no message is enqueued
ArgumentCaptor<TbActorMsg> tellCaptor = ArgumentCaptor.forClass(TbActorMsg.class);
then(chainActorMock).should().tell(tellCaptor.capture());
TbActorMsg capturedTellMsg = tellCaptor.getValue();
assertThat(capturedTellMsg).isInstanceOf(RuleNodeToRuleChainTellNextMsg.class);
RuleNodeToRuleChainTellNextMsg failureMsg = (RuleNodeToRuleChainTellNextMsg) capturedTellMsg;
assertThat(failureMsg.getRelationTypes()).containsOnly(TbNodeConnectionType.FAILURE);
assertThat(failureMsg.getFailureMessage()).containsIgnoringCase("loop");
then(mainCtxMock).shouldHaveNoInteractions(); // no message was enqueued
}
@MethodSource
@ParameterizedTest
public void givenMsgWithQueueName_whenEnqueue_thenVerifyEnqueueWithCorrectTpi(String queueName) {

79
application/src/test/java/org/thingsboard/server/edge/AdminSettingsEdgeTest.java

@ -0,0 +1,79 @@
/**
* 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.edge;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.protobuf.AbstractMessage;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.settings.AdminSettingsService;
import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg;
@DaoSqlTest
public class AdminSettingsEdgeTest extends AbstractEdgeTest {
@Autowired
private AdminSettingsService adminSettingsService;
@Test
public void testAdminSettings() throws Exception {
loginSysAdmin();
// save
AdminSettings adminSettings = new AdminSettings();
adminSettings.setKey("edgeTest");
ObjectNode jsonValue = JacksonUtil.newObjectNode();
jsonValue.put("key1", "value1");
adminSettings.setJsonValue(jsonValue);
edgeImitator.expectMessageAmount(1);
AdminSettings savedAdminSettings = doPost("/api/admin/settings", adminSettings, AdminSettings.class);
Assert.assertTrue(edgeImitator.waitForMessages());
AbstractMessage latestMessage = edgeImitator.getLatestMessage();
Assert.assertTrue(latestMessage instanceof AdminSettingsUpdateMsg);
AdminSettingsUpdateMsg adminSettingsUpdateMsg = (AdminSettingsUpdateMsg) latestMessage;
AdminSettings adminSettingsMsg = JacksonUtil.fromString(adminSettingsUpdateMsg.getEntity(), AdminSettings.class, true);
Assert.assertNotNull(adminSettingsMsg);
Assert.assertEquals("edgeTest", adminSettingsMsg.getKey());
Assert.assertEquals("value1", adminSettingsMsg.getJsonValue().get("key1").asText());
// update
ObjectNode updatedJsonValue = (ObjectNode) savedAdminSettings.getJsonValue();
updatedJsonValue.put("key2", "value2");
savedAdminSettings.setJsonValue(updatedJsonValue);
edgeImitator.expectMessageAmount(1);
doPost("/api/admin/settings", savedAdminSettings, AdminSettings.class);
Assert.assertTrue(edgeImitator.waitForMessages());
latestMessage = edgeImitator.getLatestMessage();
Assert.assertTrue(latestMessage instanceof AdminSettingsUpdateMsg);
adminSettingsUpdateMsg = (AdminSettingsUpdateMsg) latestMessage;
adminSettingsMsg = JacksonUtil.fromString(adminSettingsUpdateMsg.getEntity(), AdminSettings.class, true);
Assert.assertNotNull(adminSettingsMsg);
Assert.assertEquals("edgeTest", adminSettingsMsg.getKey());
Assert.assertEquals("value1", adminSettingsMsg.getJsonValue().get("key1").asText());
Assert.assertEquals("value2", adminSettingsMsg.getJsonValue().get("key2").asText());
adminSettingsService.deleteAdminSettingsByTenantIdAndKey(savedAdminSettings.getTenantId(), "edgeTest");
}
}

41
application/src/test/java/org/thingsboard/server/edge/EdgeLatestVersionTest.java

@ -0,0 +1,41 @@
/**
* 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.edge;
import org.junit.Assert;
import org.junit.Test;
import org.thingsboard.edge.rpc.EdgeVersionComparator;
import org.thingsboard.server.gen.edge.v1.EdgeVersion;
public class EdgeLatestVersionTest {
@Test
public void edgeLatestVersionIsSynchronizedTest() {
EdgeVersion currentHighestEdgeVersion = EdgeVersionComparator.getNewestEdgeVersion();
String projectVersion = EdgeLatestVersionTest.class.getPackage().getImplementationVersion();
if (projectVersion == null || projectVersion.isBlank()) {
projectVersion = System.getProperty("project.version", "UNKNOWN");
}
String projectVersionDigits = projectVersion.replaceAll("\\D", "");
String currentHighestEdgeVersionDigits = currentHighestEdgeVersion.name().replaceAll("\\D", "");
String msg = "EdgeVersion enum in edge.proto is out of sync. Please add respective " + projectVersionDigits + " to EdgeVersion";
Assert.assertEquals(msg, projectVersionDigits, currentHighestEdgeVersionDigits);
}
}

34
application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.service.script;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.script.api.tbel.TbDate;
@ -779,7 +780,7 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
assertEquals(expected.toString(), actual.toString());
}
@Test
@Test
public void setsCreateNewSetFromCreateSetTbMethod_Test() throws ExecutionException, InterruptedException {
msgStr = """
{"list": ["B", "A", "C", "A"]}
@ -800,7 +801,7 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
assertEquals(expected.toString(), actual.toString());
}
@Test
@Test
public void setsForeachForLoop_Test() throws ExecutionException, InterruptedException {
msgStr = """
{"list": ["A", "B", "C"]}
@ -975,13 +976,13 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
ArrayList<Object> listSortDesc = new ArrayList<>(List.of("hello", "C", "B", "A", 34567, 34));
Set<Object> expectedDesc = new LinkedHashSet<>(listSortDesc);
Object actual = invokeScript(evalScript(decoderStr), msgStr);
assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>)actual).get("set1").toString());
assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>)actual).get("set1_asc").toString());
assertEquals(expectedDesc.toString(), ((LinkedHashMap<?, ?>)actual).get("set1_desc").toString());
assertEquals(expected.toString(), ((LinkedHashMap<?, ?>)actual).get("set2").toString());
assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>)actual).get("set3").toString());
assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>)actual).get("set3_asc").toString());
assertEquals(expectedDesc.toString(), ((LinkedHashMap<?, ?>)actual).get("set3_desc").toString());
assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>) actual).get("set1").toString());
assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>) actual).get("set1_asc").toString());
assertEquals(expectedDesc.toString(), ((LinkedHashMap<?, ?>) actual).get("set1_desc").toString());
assertEquals(expected.toString(), ((LinkedHashMap<?, ?>) actual).get("set2").toString());
assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>) actual).get("set3").toString());
assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>) actual).get("set3_asc").toString());
assertEquals(expectedDesc.toString(), ((LinkedHashMap<?, ?>) actual).get("set3_desc").toString());
}
@Test
@ -1002,9 +1003,9 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
List<Object> listOrigin = new ArrayList<>(List.of("C", "B", "A", 34567, "B", "C", "hello", 34));
Set<Object> expectedSet = new LinkedHashSet<>(listOrigin);
Object actual = invokeScript(evalScript(decoderStr), msgStr);
assertEquals(expectedSet.toString(), ((LinkedHashMap<?, ?>)actual).get("set1").toString());
assertEquals(true, ((LinkedHashMap<?, ?>)actual).get("result1"));
assertEquals(false, ((LinkedHashMap<?, ?>)actual).get("result2"));
assertEquals(expectedSet.toString(), ((LinkedHashMap<?, ?>) actual).get("set1").toString());
assertEquals(true, ((LinkedHashMap<?, ?>) actual).get("result1"));
assertEquals(false, ((LinkedHashMap<?, ?>) actual).get("result2"));
}
@Test
@ -1025,9 +1026,9 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
Set<Object> expectedSet = new LinkedHashSet<>(listOrigin);
List<Object> expectedToList = new ArrayList<>(expectedSet);
Object actual = invokeScript(evalScript(decoderStr), msgStr);
assertEquals(listOrigin.toString(), ((LinkedHashMap<?, ?>)actual).get("list").toString());
assertEquals(expectedSet.toString(), ((LinkedHashMap<?, ?>)actual).get("set1").toString());
assertEquals(expectedToList.toString(), ((LinkedHashMap<?, ?>)actual).get("tolist").toString());
assertEquals(listOrigin.toString(), ((LinkedHashMap<?, ?>) actual).get("list").toString());
assertEquals(expectedSet.toString(), ((LinkedHashMap<?, ?>) actual).get("set1").toString());
assertEquals(expectedToList.toString(), ((LinkedHashMap<?, ?>) actual).get("tolist").toString());
}
@Test
@ -1713,7 +1714,7 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
assertEquals(expected, actual);
}
@Test
@RepeatedTest(value = 3, name = "{displayName} {currentRepetition}/{totalRepetitions}")
public void parseBytes_Test() throws ExecutionException, InterruptedException {
byte[] bytesExecutionArrayList = new byte[]{(byte) 0xAA, (byte) 0xBB, (byte) 0xCC, (byte) 0xDD};
msgStr = "{}";
@ -2821,5 +2822,6 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
}
return list;
}
}

54
application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java

@ -140,45 +140,63 @@ public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable {
}
private void startDownloading() {
long delay = 500;
// Step 1: state = 1
scheduler.schedule(() -> {
try {
state.set(1);
fireResourceChange(3);
Thread.sleep(100);
state.set(2);
fireResourceChange(3);
} catch (Exception e) {
}
}, 100, TimeUnit.MILLISECONDS);
state.set(1); // DOWNLOADING
fireResourceChange(3);
log.info("Downloading started: state=[{}]", state.get());
}, delay, TimeUnit.MILLISECONDS); // 500 ms
delay += 1000; // next step after 100 ms
// Step 2: state = 2
scheduler.schedule(() -> {
state.set(2); // DOWNLOADED
fireResourceChange(3);
log.info("Downloading in progress: state=[{}]", state.get());
}, delay, TimeUnit.MILLISECONDS); // 1500 ms
}
private void startUpdating(LwM2mServer identity) {
scheduler.schedule(() -> {
try {
// Update state + result
state.set(3);
fireResourceChange(3);
Thread.sleep(100);
updateResult.set(1);
fireResourceChange(5);
this.pkgName = TITLE;
fireResourceChange(6);
this.pkgVersion = TARGET_FW_VERSION;
fireResourceChange(7);
if (this.leshanClient != null) {
log.info("Stop/reboot LwM2M client {}", this.leshanClient.getEndpoint(identity));
this.leshanClient.stop(false);
log.info("Start after update fw LwM2M client {}", this.leshanClient.getEndpoint(identity));
this.leshanClient.start();
this.pkgName = this.pkgNameDef;
this.pkgVersion = this.pkgVersionDef;
// Delayed reset pkgName/pkgVersion, after reboot + registration
// If a client stops, ThingsBoard can mark it as Offline.
// A new registration may take a few seconds.
scheduler.schedule(() -> {
this.pkgName = this.pkgNameDef;
fireResourceChange(6);
this.pkgVersion = this.pkgVersionDef;
fireResourceChange(7);
log.info("FW resources updating to new values: pkgName=[{}], pkgVersion=[{}]",
this.pkgName, this.pkgVersion);
}, 5, TimeUnit.SECONDS); // 5 sec — safe timing
}
} catch (Exception e) {
log.error("Error during firmware update", e);
}
}, 100, TimeUnit.MILLISECONDS);
}, 0, TimeUnit.SECONDS); // start immediately, without further delay
}
protected void setLeshanClient(LeshanClient leshanClient) {
this.leshanClient = leshanClient;
}
}

60
application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java

@ -33,6 +33,8 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.thingsboard.server.controller.AbstractWebTest.TIMEOUT;
@Slf4j
public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable {
@ -85,10 +87,7 @@ public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable {
log.info("Write on Device resource /{}/{}/{}", getModel().id, getId(), resourceId);
switch (resourceId) {
case 2:
startDownloading();
return WriteResponse.success();
case 3:
case 2, 3:
startDownloading();
return WriteResponse.success();
default:
@ -123,25 +122,34 @@ public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable {
}
private void startDownloading() {
long delay = 0;
// Step 1: start downloading
scheduler.schedule(() -> {
try {
state.set(1);
updateResult.set(1);
fireResourceChange(7);
fireResourceChange(9);
Thread.sleep(100);
state.set(2);
fireResourceChange(7);
Thread.sleep(100);
state.set(3);
fireResourceChange(7);
Thread.sleep(100);
updateResult.set(3);
fireResourceChange(9);
} catch (Exception e) {
}
}, 100, TimeUnit.MILLISECONDS);
state.set(1);
updateResult.set(1);
fireResourceChange(7);
fireResourceChange(9);
}, delay, TimeUnit.MILLISECONDS);
delay += 100;
// Step 2: downloading in progress
scheduler.schedule(() -> {
state.set(2);
fireResourceChange(7);
}, delay, TimeUnit.MILLISECONDS);
delay += 100;
// Step 3: downloading finished
scheduler.schedule(() -> {
state.set(3);
fireResourceChange(7);
updateResult.set(3);
fireResourceChange(9);
}, delay, TimeUnit.MILLISECONDS);
}
private void startUpdating() {
@ -150,7 +158,13 @@ public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable {
updateResult.set(2);
fireResourceChange(7);
fireResourceChange(9);
// Optional: delayed log about FW update
scheduler.schedule(() -> {
log.info("FW resources updating to new values: state=[{}], updateResult=[{}]",
state.get(), updateResult.get());
}, 500, TimeUnit.MILLISECONDS);
}, 100, TimeUnit.MILLISECONDS);
}
}

19
application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/AbstractOtaLwM2MIntegrationTest.java

@ -196,12 +196,23 @@ public abstract class AbstractOtaLwM2MIntegrationTest extends AbstractLwM2MInteg
}
protected boolean predicateForStatuses(List<TsKvEntry> ts) {
List<OtaPackageUpdateStatus> statuses = ts.stream()
if (ts == null || ts.isEmpty()) return false;
List<String> statuses = ts.stream()
.sorted(Comparator.comparingLong(TsKvEntry::getTs))
.map(KvEntry::getValueAsString)
.map(OtaPackageUpdateStatus::valueOf)
.collect(Collectors.toList());
log.warn("{}", statuses);
return statuses.containsAll(expectedStatuses);
// If we haven't received UPDATED yet
if (!statuses.contains(OtaPackageUpdateStatus.UPDATED.name())) {
return false;
}
log.warn("Captured statuses: {}", statuses);
// Перевірка, що всі очікувані статуси хоча б раз промайнули
return expectedStatuses.stream()
.map(Enum::name)
.allMatch(statuses::contains);
}
}

4
application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota5LwM2MIntegrationTest.java

@ -103,8 +103,10 @@ public class Ota5LwM2MIntegrationTest extends AbstractOtaLwM2MIntegrationTest {
expectedStatuses = Arrays.asList(QUEUED, INITIATED, DOWNLOADING, DOWNLOADED, UPDATING, UPDATED);
List<TsKvEntry> ts = await("await on timeseries for FW")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.atMost(60, TimeUnit.SECONDS) // Increase the timeout to 60 sec
.pollInterval(1, TimeUnit.SECONDS) // Poll once per second
.until(() -> getFwSwStateTelemetryFromAPI(device.getId().getId(), "fw_state"), this::predicateForStatuses);
log.warn("Object5: Got the ts: {}", ts);
}
}

15
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java

@ -161,6 +161,20 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes
clientCredentials.setKey(keyPsk);
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE));
DeviceProfile deviceProfile = createLwm2mDeviceProfile("profileFor" + clientEndpoint, transportConfiguration);
// Wait for the profile to become available. This synchronizes asynchronous processes and prevents TenantNotFoundException during tearDown.
org.awaitility.Awaitility.await()
.atMost(10, java.util.concurrent.TimeUnit.SECONDS)
.pollInterval(200, java.util.concurrent.TimeUnit.MILLISECONDS)
.until(() -> {
try {
String url = "/api/deviceProfile/" + deviceProfile.getId().getId().toString();
return doGet(url, DeviceProfile.class) != null;
} catch (Exception e) {
return false;
}
});
LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, null, null, PSK, false);
MvcResult result = createDeviceWithMvcResult(deviceCredentials, clientEndpoint, deviceProfile.getId());
assertEquals(HttpServletResponse.SC_BAD_REQUEST, result.getResponse().getStatus());
@ -168,7 +182,6 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes
assertTrue(result.getResponse().getContentAsString().contains(msgExpected));
}
// Bootstrap + Lwm2m
@Test
public void testWithPskConnectBsSuccess_UpdateTwoSectionsBootstrapAndLm2m_ConnectLwm2mSuccess() throws Exception {

15
application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java

@ -159,14 +159,17 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
protected List<Device> connectClientWithCorrectAccessTokenWithNDEATHCreatedDevices(int cntDevices, long ts) throws Exception {
List<Device> devices = new ArrayList<>();
clientWithCorrectNodeAccessTokenWithNDEATH();
MetricDataType metricDataType = Int32;
String key = "Node Metric int32";
String keyInt = "Node Metric int32";
int valueDeviceInt32 = 1024;
SparkplugBProto.Payload.Metric metric = createMetric(valueDeviceInt32, ts, key, metricDataType, -1L);
SparkplugBProto.Payload.Metric metricInt = createMetric(valueDeviceInt32, ts, keyInt, Int32, -1L);
String keyStringEmpty = "Node Metric String Empty";
String valueDeviceStringEmpty = "";
SparkplugBProto.Payload.Metric metricStringEmpty = createMetric(valueDeviceStringEmpty, ts, keyStringEmpty, MetricDataType.String, -1L);
SparkplugBProto.Payload.Builder payloadBirthNode = SparkplugBProto.Payload.newBuilder()
.setTimestamp(ts)
.setSeq(getBdSeqNum());
payloadBirthNode.addMetrics(metric);
payloadBirthNode.addMetrics(metricInt);
payloadBirthNode.addMetrics(metricStringEmpty);
payloadBirthNode.setTimestamp(ts);
if (client.isConnected()) {
client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode,
@ -174,14 +177,14 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
}
valueDeviceInt32 = 4024;
metric = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L);
metricInt = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L);
for (int i = 0; i < cntDevices; i++) {
SparkplugBProto.Payload.Builder payloadBirthDevice = SparkplugBProto.Payload.newBuilder()
.setTimestamp(ts)
.setSeq(getSeqNum());
String deviceName = deviceId + "_" + i;
payloadBirthDevice.addMetrics(metric);
payloadBirthDevice.addMetrics(metricInt);
if (client.isConnected()) {
client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.DBIRTH.name() + "/" + edgeNode + "/" + deviceName,
payloadBirthDevice.build().toByteArray(), 0, false);

2
common/actor/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

2
common/cache/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

2
common/cluster-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

2
common/coap-server/pom.xml

@ -22,7 +22,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

2
common/dao-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

32
common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/ResultSetSizeLimitExceededException.java

@ -0,0 +1,32 @@
/**
* 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.nosql;
import lombok.Getter;
@Getter
public class ResultSetSizeLimitExceededException extends IllegalArgumentException {
private final long limitBytes;
private final long actualBytes;
public ResultSetSizeLimitExceededException(long limitBytes, long actualBytes) {
super("Result set size exceeds the maximum allowed limit. Please narrow your query");
this.limitBytes = limitBytes;
this.actualBytes = actualBytes;
}
}

24
common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/TbResultSet.java

@ -34,6 +34,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
public class TbResultSet implements AsyncResultSet {
@ -89,9 +90,14 @@ public class TbResultSet implements AsyncResultSet {
}
public ListenableFuture<List<Row>> allRows(Executor executor) {
return allRows(executor, 0);
}
public ListenableFuture<List<Row>> allRows(Executor executor, long maxResultSetSizeBytes) {
List<Row> allRows = new ArrayList<>();
SettableFuture<List<Row>> resultFuture = SettableFuture.create();
this.processRows(originalStatement, delegate, allRows, resultFuture, executor);
AtomicLong accumulatedBytes = new AtomicLong(0);
this.processRows(originalStatement, delegate, allRows, resultFuture, executor, maxResultSetSizeBytes, accumulatedBytes);
return resultFuture;
}
@ -99,7 +105,19 @@ public class TbResultSet implements AsyncResultSet {
AsyncResultSet resultSet,
List<Row> allRows,
SettableFuture<List<Row>> resultFuture,
Executor executor) {
Executor executor,
long maxResultSetSizeBytes,
AtomicLong accumulatedBytes) {
if (maxResultSetSizeBytes > 0) {
int pageSizeInBytes = resultSet.getExecutionInfo().getResponseSizeInBytes();
if (pageSizeInBytes > 0) {
accumulatedBytes.addAndGet(pageSizeInBytes);
}
if (accumulatedBytes.get() > maxResultSetSizeBytes) {
resultFuture.setException(new ResultSetSizeLimitExceededException(maxResultSetSizeBytes, accumulatedBytes.get()));
return;
}
}
allRows.addAll(loadRows(resultSet));
if (resultSet.hasMorePages()) {
ByteBuffer nextPagingState = resultSet.getExecutionInfo().getPagingState();
@ -110,7 +128,7 @@ public class TbResultSet implements AsyncResultSet {
@Override
public void onSuccess(@Nullable TbResultSet result) {
processRows(nextStatement, result,
allRows, resultFuture, executor);
allRows, resultFuture, executor, maxResultSetSizeBytes, accumulatedBytes);
}
@Override

4
common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java

@ -18,6 +18,8 @@ package org.thingsboard.server.dao.settings;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.id.AdminSettingsId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.entity.EntityDaoService;
public interface AdminSettingsService extends EntityDaoService {
@ -28,6 +30,8 @@ public interface AdminSettingsService extends EntityDaoService {
AdminSettings findAdminSettingsByTenantIdAndKey(TenantId tenantId, String key);
PageData<AdminSettings> findAllByTenantId(TenantId tenantId, PageLink pageLink);
AdminSettings saveAdminSettings(TenantId tenantId, AdminSettings adminSettings);
boolean deleteAdminSettingsByTenantIdAndKey(TenantId tenantId, String key);

140
common/dao-api/src/test/java/org/thingsboard/server/dao/nosql/TbResultSetTest.java

@ -0,0 +1,140 @@
/**
* 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.nosql;
import com.datastax.oss.driver.api.core.cql.AsyncResultSet;
import com.datastax.oss.driver.api.core.cql.ColumnDefinitions;
import com.datastax.oss.driver.api.core.cql.ExecutionInfo;
import com.datastax.oss.driver.api.core.cql.Row;
import com.datastax.oss.driver.api.core.cql.Statement;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.SettableFuture;
import org.junit.jupiter.api.Test;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.function.Function;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class TbResultSetTest {
@Test
void allRows_withinLimit_returnsAllRows() throws Exception {
Row row = mock(Row.class);
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 1000);
Statement<?> statement = mock(Statement.class);
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null);
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 5000);
List<Row> result = future.get();
assertThat(result).hasSize(1);
assertThat(result.get(0)).isSameAs(row);
}
@Test
void allRows_exceedsLimitOnFirstPage_failsWithException() {
Row row = mock(Row.class);
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 6000);
Statement<?> statement = mock(Statement.class);
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null);
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 5000);
assertThatThrownBy(future::get)
.isInstanceOf(ExecutionException.class)
.hasCauseInstanceOf(ResultSetSizeLimitExceededException.class);
}
@Test
void allRows_exceedsLimitOnSecondPage_failsAfterSecondPage() {
Row row1 = mock(Row.class);
Row row2 = mock(Row.class);
Statement<?> statement = mock(Statement.class);
doReturn(statement).when(statement).setPagingState((ByteBuffer) null);
AsyncResultSet page2 = createMockResultSet(List.of(row2), false, 3000);
TbResultSet tbResultSetPage2 = new TbResultSet(statement, page2, s -> null);
SettableFuture<TbResultSet> page2Future = SettableFuture.create();
page2Future.set(tbResultSetPage2);
TbResultSetFuture tbPage2Future = new TbResultSetFuture(page2Future);
ExecutionInfo page1ExecInfo = mock(ExecutionInfo.class);
when(page1ExecInfo.getResponseSizeInBytes()).thenReturn(3000);
when(page1ExecInfo.getPagingState()).thenReturn(null);
AsyncResultSet page1 = createMockResultSet(List.of(row1), true, 3000);
when(page1.getExecutionInfo()).thenReturn(page1ExecInfo);
Function<Statement, TbResultSetFuture> executeAsync = s -> tbPage2Future;
TbResultSet tbResultSet = new TbResultSet(statement, page1, executeAsync);
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 5000);
assertThatThrownBy(future::get)
.isInstanceOf(ExecutionException.class)
.hasCauseInstanceOf(ResultSetSizeLimitExceededException.class);
}
@Test
void allRows_unlimitedWithZero_returnsAllRowsRegardlessOfSize() throws Exception {
Row row = mock(Row.class);
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 999999);
Statement<?> statement = mock(Statement.class);
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null);
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 0);
List<Row> result = future.get();
assertThat(result).hasSize(1);
}
@Test
void allRows_noLimitOverload_returnsAllRows() throws Exception {
Row row = mock(Row.class);
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 999999);
Statement<?> statement = mock(Statement.class);
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null);
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor());
List<Row> result = future.get();
assertThat(result).hasSize(1);
}
private AsyncResultSet createMockResultSet(List<Row> rows, boolean hasMorePages, int responseSizeInBytes) {
AsyncResultSet resultSet = mock(AsyncResultSet.class);
ExecutionInfo executionInfo = mock(ExecutionInfo.class);
ColumnDefinitions columnDefs = mock(ColumnDefinitions.class);
when(executionInfo.getResponseSizeInBytes()).thenReturn(responseSizeInBytes);
when(executionInfo.getPagingState()).thenReturn(null);
when(resultSet.getExecutionInfo()).thenReturn(executionInfo);
when(resultSet.getColumnDefinitions()).thenReturn(columnDefs);
when(resultSet.currentPage()).thenReturn(rows);
when(resultSet.hasMorePages()).thenReturn(hasMorePages);
when(resultSet.remaining()).thenReturn(rows.size());
return resultSet;
}
}

2
common/data/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

2
common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java

@ -38,7 +38,7 @@ public enum EdgeEventType {
TENANT_PROFILE(true, EntityType.TENANT_PROFILE),
WIDGETS_BUNDLE(true, EntityType.WIDGETS_BUNDLE),
WIDGET_TYPE(true, EntityType.WIDGET_TYPE),
ADMIN_SETTINGS(true, null),
ADMIN_SETTINGS(true, EntityType.ADMIN_SETTINGS),
OTA_PACKAGE(true, EntityType.OTA_PACKAGE),
QUEUE(true, EntityType.QUEUE),
NOTIFICATION_RULE(true, EntityType.NOTIFICATION_RULE),

1
common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java

@ -113,6 +113,7 @@ public class EntityIdFactory {
case OAUTH2_CLIENT -> new OAuth2ClientId(uuid);
case DOMAIN -> new DomainId(uuid);
case CALCULATED_FIELD -> new CalculatedFieldId(uuid);
case ADMIN_SETTINGS -> new AdminSettingsId(uuid);
default -> throw new IllegalArgumentException("EdgeEventType " + edgeEventType + " is not supported!");
};
}

2
common/discovery-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

7
common/edge-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>
@ -106,6 +106,11 @@
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

11
common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java

@ -143,16 +143,7 @@ public class EdgeGrpcClient implements EdgeRpcClient {
}
public static EdgeVersion getNewestEdgeVersion() {
EdgeVersion newest = null;
for (EdgeVersion v : EdgeVersion.values()) {
if (v == EdgeVersion.V_LATEST || v == EdgeVersion.UNRECOGNIZED) {
continue;
}
if (newest == null || v.getNumber() > newest.getNumber()) {
newest = v;
}
}
return newest;
return EdgeVersionComparator.getNewestEdgeVersion();
}
private StreamObserver<ResponseMsg> initOutputStream(String edgeKey,

86
common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeVersionComparator.java

@ -0,0 +1,86 @@
/**
* 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.edge.rpc;
import org.thingsboard.server.gen.edge.v1.EdgeVersion;
import java.util.Comparator;
public class EdgeVersionComparator implements Comparator<EdgeVersion> {
public static final EdgeVersionComparator INSTANCE = new EdgeVersionComparator();
@Override
public int compare(EdgeVersion v1, EdgeVersion v2) {
if (v1 == v2) {
return 0;
}
// UNRECOGNIZED is less than any other version
if (v1 == EdgeVersion.UNRECOGNIZED) {
return -1;
}
if (v2 == EdgeVersion.UNRECOGNIZED) {
return 1;
}
// V_LATEST is treated as the newest version
if (v1 == EdgeVersion.V_LATEST) {
v1 = getNewestEdgeVersion();
}
if (v2 == EdgeVersion.V_LATEST) {
v2 = getNewestEdgeVersion();
}
return compareVersionParts(parseVersionParts(v1), parseVersionParts(v2));
}
public static EdgeVersion getNewestEdgeVersion() {
EdgeVersion newest = null;
for (EdgeVersion v : EdgeVersion.values()) {
if (v == EdgeVersion.V_LATEST || v == EdgeVersion.UNRECOGNIZED) {
continue;
}
if (newest == null || INSTANCE.compare(v, newest) > 0) {
newest = v;
}
}
return newest;
}
private static int[] parseVersionParts(EdgeVersion version) {
String name = version.name();
if (name.startsWith("V_")) {
name = name.substring(2);
}
String[] parts = name.split("_");
int[] result = new int[parts.length];
for (int i = 0; i < parts.length; i++) {
result[i] = Integer.parseInt(parts[i]);
}
return result;
}
private static int compareVersionParts(int[] a, int[] b) {
int maxLen = Math.max(a.length, b.length);
for (int i = 0; i < maxLen; i++) {
int partA = i < a.length ? a[i] : 0;
int partB = i < b.length ? b[i] : 0;
if (partA != partB) {
return Integer.compare(partA, partB);
}
}
return 0;
}
}

4
common/edge-api/src/main/proto/edge.proto

@ -44,8 +44,10 @@ enum EdgeVersion {
V_4_0_0 = 10;
V_4_1_0 = 11;
V_4_2_0 = 12;
V_4_2_1_2 = 14;
V_4_2_2 = 4220;
V_LATEST = 999;
V_LATEST = 99999;
}
/**

102
common/edge-api/src/test/java/org/thingsboard/edge/rpc/EdgeVersionComparatorTest.java

@ -0,0 +1,102 @@
/**
* 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.edge.rpc;
import org.junit.jupiter.api.Test;
import org.thingsboard.server.gen.edge.v1.EdgeVersion;
import static org.assertj.core.api.Assertions.assertThat;
class EdgeVersionComparatorTest {
@Test
void compare_sameVersion_returnsZero() {
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_3_0, EdgeVersion.V_3_3_0)).isEqualTo(0);
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_0_0, EdgeVersion.V_4_0_0)).isEqualTo(0);
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_2_1_2, EdgeVersion.V_4_2_1_2)).isEqualTo(0);
}
@Test
void compare_majorVersionDifference_returnsCorrectOrder() {
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_3_0, EdgeVersion.V_4_0_0)).isLessThan(0);
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_0_0, EdgeVersion.V_3_3_0)).isGreaterThan(0);
}
@Test
void compare_minorVersionDifference_returnsCorrectOrder() {
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_3_0, EdgeVersion.V_3_6_0)).isLessThan(0);
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_6_0, EdgeVersion.V_3_3_0)).isGreaterThan(0);
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_0_0, EdgeVersion.V_4_1_0)).isLessThan(0);
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_1_0, EdgeVersion.V_4_0_0)).isGreaterThan(0);
}
@Test
void compare_patchVersionDifference_returnsCorrectOrder() {
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_6_0, EdgeVersion.V_3_6_1)).isLessThan(0);
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_6_1, EdgeVersion.V_3_6_0)).isGreaterThan(0);
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_6_1, EdgeVersion.V_3_6_2)).isLessThan(0);
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_6_2, EdgeVersion.V_3_6_4)).isLessThan(0);
}
@Test
void compare_fourPartVersion_returnsCorrectOrder() {
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_2_0, EdgeVersion.V_4_2_1_2)).isLessThan(0);
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_2_1_2, EdgeVersion.V_4_2_0)).isGreaterThan(0);
}
@Test
void compare_threePartVsFourPart_treatsImplicitZero() {
// V_4_2_0 should be less than V_4_2_1_2 (4.2.0.0 < 4.2.1.2)
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_2_0, EdgeVersion.V_4_2_1_2)).isLessThan(0);
}
@Test
void getNewestEdgeVersion_excludesLatestAndUnrecognized() {
EdgeVersion newest = EdgeVersionComparator.getNewestEdgeVersion();
assertThat(newest).isNotNull();
assertThat(newest).isNotEqualTo(EdgeVersion.V_LATEST);
assertThat(newest).isNotEqualTo(EdgeVersion.UNRECOGNIZED);
}
@Test
void compare_vLatest_treatedAsNewestVersion() {
EdgeVersion newest = EdgeVersionComparator.getNewestEdgeVersion();
// V_LATEST equals the newest version
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_LATEST, newest)).isEqualTo(0);
assertThat(EdgeVersionComparator.INSTANCE.compare(newest, EdgeVersion.V_LATEST)).isEqualTo(0);
// V_LATEST is greater than older versions
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_LATEST, EdgeVersion.V_3_3_0)).isGreaterThan(0);
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_3_0, EdgeVersion.V_LATEST)).isLessThan(0);
}
@Test
void compare_vLatest_withItself_returnsZero() {
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_LATEST, EdgeVersion.V_LATEST)).isEqualTo(0);
}
@Test
void compare_unrecognized_isLessThanAnyVersion() {
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.UNRECOGNIZED, EdgeVersion.V_3_3_0)).isLessThan(0);
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.UNRECOGNIZED, EdgeVersion.V_LATEST)).isLessThan(0);
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_3_0, EdgeVersion.UNRECOGNIZED)).isGreaterThan(0);
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_LATEST, EdgeVersion.UNRECOGNIZED)).isGreaterThan(0);
}
@Test
void compare_unrecognized_withItself_returnsZero() {
assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.UNRECOGNIZED, EdgeVersion.UNRECOGNIZED)).isEqualTo(0);
}
}

2
common/edqs/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

2
common/message/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

9
common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java

@ -110,11 +110,10 @@ public final class TbMsg implements Serializable {
.build();
}
public TbMsg copyWithNewCtx() {
public TbMsgBuilder copyWithNewCtx() {
return copy()
.ctx(ctx.copy())
.callback(TbMsgCallback.EMPTY)
.build();
.callback(TbMsgCallback.EMPTY);
}
private TbMsg(String queueName, UUID id, long ts, TbMsgType internalType, String type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, TbMsgDataType dataType, String data,
@ -266,6 +265,10 @@ public final class TbMsg implements Serializable {
ctx.push(ruleChainId, ruleNodeId);
}
public boolean isAlreadyInStack(RuleChainId ruleChainId, RuleNodeId ruleNodeId) {
return ctx.isAlreadyInStack(ruleChainId, ruleNodeId);
}
public TbMsgProcessingStackItem popFormStack() {
return ctx.pop();
}

12
common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgProcessingCtx.java

@ -63,6 +63,18 @@ public final class TbMsgProcessingCtx implements Serializable {
stack.add(new TbMsgProcessingStackItem(ruleChainId, ruleNodeId));
}
public boolean isAlreadyInStack(RuleChainId ruleChainId, RuleNodeId ruleNodeId) {
if (stack == null || stack.isEmpty()) {
return false;
}
for (TbMsgProcessingStackItem item : stack) {
if (ruleChainId.equals(item.getRuleChainId()) && ruleNodeId.equals(item.getRuleNodeId())) {
return true;
}
}
return false;
}
public TbMsgProcessingStackItem pop() {
if (stack == null || stack.isEmpty()) {
return null;

89
common/message/src/test/java/org/thingsboard/server/common/msg/TbMsgProcessingCtxTest.java

@ -0,0 +1,89 @@
/**
* 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.msg;
import org.junit.jupiter.api.Test;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class TbMsgProcessingCtxTest {
private final RuleChainId RULE_CHAIN_ID = new RuleChainId(UUID.fromString("b87c4123-f9f2-41a6-9a09-e3a5b6580b11"));
private final RuleNodeId RULE_NODE_ID = new RuleNodeId(UUID.fromString("1ca5e2ef-1309-41d9-bafa-709e9df0e2a6"));
@Test
void givenEmptyStack_whenIsAlreadyInStack_thenReturnFalse() {
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx();
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse();
}
@Test
void givenStackWithDifferentEntry_whenIsAlreadyInStack_thenReturnFalse() {
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx();
ctx.push(new RuleChainId(UUID.randomUUID()), new RuleNodeId(UUID.randomUUID()));
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse();
}
@Test
void givenStackWithMatchingEntry_whenIsAlreadyInStack_thenReturnTrue() {
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx();
ctx.push(RULE_CHAIN_ID, RULE_NODE_ID);
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isTrue();
}
@Test
void givenStackWithMatchingEntryAmongOthers_whenIsAlreadyInStack_thenReturnTrue() {
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx();
ctx.push(new RuleChainId(UUID.randomUUID()), new RuleNodeId(UUID.randomUUID()));
ctx.push(RULE_CHAIN_ID, RULE_NODE_ID);
ctx.push(new RuleChainId(UUID.randomUUID()), new RuleNodeId(UUID.randomUUID()));
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isTrue();
}
@Test
void givenStackWithSameChainButDifferentNode_whenIsAlreadyInStack_thenReturnFalse() {
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx();
ctx.push(RULE_CHAIN_ID, new RuleNodeId(UUID.randomUUID()));
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse();
}
@Test
void givenStackWithSameNodeButDifferentChain_whenIsAlreadyInStack_thenReturnFalse() {
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx();
ctx.push(new RuleChainId(UUID.randomUUID()), RULE_NODE_ID);
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse();
}
@Test
void givenStackWithEntryThenPopped_whenIsAlreadyInStack_thenReturnFalse() {
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx();
ctx.push(RULE_CHAIN_ID, RULE_NODE_ID);
ctx.pop();
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse();
}
}

4
common/message/src/test/java/org/thingsboard/server/common/msg/tools/TbRateLimitsTest.java

@ -42,7 +42,7 @@ public class TbRateLimitsTest {
assertThat(rateLimits.tryConsume()).as("new token is available").isFalse();
int expectedRefillTime = (int) (((double) period / capacity) * 1000);
int gap = 500;
int gap = 1000;
for (int i = 0; i < capacity; i++) {
await("token refill for rate limit " + rateLimitConfig)
@ -71,7 +71,7 @@ public class TbRateLimitsTest {
assertThat(rateLimits.tryConsume()).as("new token is available").isFalse();
int expectedRefillTime = period * 1000;
int gap = 500;
int gap = 1000;
await("tokens refill for rate limit " + rateLimitConfig)
.pollInterval(new FixedPollInterval(10, TimeUnit.MILLISECONDS))

2
common/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>thingsboard</artifactId>
</parent>
<artifactId>common</artifactId>

2
common/proto/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

4
common/proto/src/main/java/org/thingsboard/server/common/adaptor/ProtoConverter.java

@ -156,11 +156,7 @@ public class ProtoConverter {
case BOOLEAN_V:
case LONG_V:
case DOUBLE_V:
break;
case STRING_V:
if (StringUtils.isEmpty(keyValueProto.getStringV())) {
throw new IllegalArgumentException("Value is empty for key: " + key + "!");
}
break;
case JSON_V:
try {

2
common/queue/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

2
common/script/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

2
common/script/remote-js-client/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard.common</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>script</artifactId>
</parent>
<groupId>org.thingsboard.common.script</groupId>

2
common/script/script-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard.common</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>script</artifactId>
</parent>
<groupId>org.thingsboard.common.script</groupId>

8
common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java

@ -898,11 +898,11 @@ public class TbUtils {
public static int parseBytesToInt(byte[] data, int offset, int length, boolean bigEndian) {
validationNumberByLength(data, offset, length, BYTES_LEN_INT_MAX);
var bb = ByteBuffer.allocate(4);
var bb = ByteBuffer.allocate(BYTES_LEN_INT_MAX);
if (!bigEndian) {
bb.order(ByteOrder.LITTLE_ENDIAN);
}
bb.position(bigEndian ? 4 - length : 0);
bb.position(bigEndian ? BYTES_LEN_INT_MAX - length : 0);
bb.put(data, offset, length);
bb.position(0);
return bb.getInt();
@ -923,11 +923,11 @@ public class TbUtils {
public static long parseBytesToUnsignedInt(byte[] data, int offset, int length, boolean bigEndian) {
validationNumberByLength(data, offset, length, BYTES_LEN_INT_MAX);
ByteBuffer bb = ByteBuffer.allocate(8);
ByteBuffer bb = ByteBuffer.allocate(BYTES_LEN_LONG_MAX);
if (!bigEndian) {
bb.order(ByteOrder.LITTLE_ENDIAN);
}
bb.position(bigEndian ? 8 - length : 0);
bb.position(bigEndian ? BYTES_LEN_LONG_MAX - length : 0);
bb.put(data, offset, length);
bb.position(0);

95
common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java

@ -155,6 +155,101 @@ public class TbUtilsTest {
Assertions.assertEquals(expected, TbUtils.parseBytesToInt(data, 0, 3, false));
}
@Test
public void parseBytesToInt_doesNotChangeInputData() {
byte[] data = new byte[]{(byte) 0xAA, (byte) 0xBB, (byte) 0xCC, (byte) 0xDD};
byte[] copy = data.clone();
TbUtils.parseBytesToInt(data, 0, 4, true);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToInt(data, 0, 4, false);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToUnsignedInt(data, 0, 4, true);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToUnsignedInt(data, 0, 4, false);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToLong(data, 0, 4, true);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToLong(data, 0, 4, false);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToFloat(data, 0, 4, true);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToFloat(data, 0, 4, false);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesIntToFloat(data, 0, 4, true);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesIntToFloat(data, 0, 4, false);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToDouble(data, 0, 4, true);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToDouble(data, 0, 4, false);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesLongToDouble(data, 0, 4, true);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesLongToDouble(data, 0, 4, false);
Assertions.assertArrayEquals(copy, data);
List<Byte> listData = toList(new byte[]{(byte) 0xAA, (byte) 0xBB, (byte) 0xCC, (byte) 0xDD});
List<Byte> listCopy = new ArrayList<>(listData);
TbUtils.parseBytesToInt(listData, 0, 4, true);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToInt(listData, 0, 4, false);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToUnsignedInt(listData, 0, 4, true);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToUnsignedInt(listData, 0, 4, false);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToLong(listData, 0, 4, true);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToLong(listData, 0, 4, false);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToFloat(listData, 0, 4, true);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToFloat(listData, 0, 4, false);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesIntToFloat(listData, 0, 4, true);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesIntToFloat(listData, 0, 4, false);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToDouble(listData, 0, 4, true);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToDouble(listData, 0, 4, false);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesLongToDouble(listData, 0, 4, true);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesLongToDouble(listData, 0, 4, false);
Assertions.assertEquals(listCopy, listData);
}
@Test
public void compare_parseBytesToInt_and_parseBytesToUnsignedInt() {
byte[] data = new byte[]{(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF};
// 4 bytes: parseBytesToInt returns -1, parseBytesToUnsignedInt returns 4294967295L
Assertions.assertEquals(-1, TbUtils.parseBytesToInt(data, 0, 4, true));
Assertions.assertEquals(4294967295L, TbUtils.parseBytesToUnsignedInt(data, 0, 4, true));
// 2 bytes (0xFFFF): both return 65535 (no sign extension for parseBytesToInt when length < 4)
Assertions.assertEquals(65535, TbUtils.parseBytesToInt(data, 0, 2, true));
Assertions.assertEquals(65535L, TbUtils.parseBytesToUnsignedInt(data, 0, 2, true));
// 2 bytes with high bit set (0x8000)
byte[] data2 = new byte[]{(byte) 0x80, (byte) 0x00};
Assertions.assertEquals(32768, TbUtils.parseBytesToInt(data2, 0, 2, true));
Assertions.assertEquals(32768L, TbUtils.parseBytesToUnsignedInt(data2, 0, 2, true));
}
@Test
public void toFlatMap() {
ExecutionHashMap<String, Object> inputMap = new ExecutionHashMap<>(16, ctx);

2
common/stats/pom.xml

@ -22,7 +22,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

2
common/transport/coap/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard.common</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>transport</artifactId>
</parent>
<groupId>org.thingsboard.common.transport</groupId>

2
common/transport/http/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard.common</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>transport</artifactId>
</parent>
<groupId>org.thingsboard.common.transport</groupId>

2
common/transport/lwm2m/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard.common</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>transport</artifactId>
</parent>
<groupId>org.thingsboard.common.transport</groupId>

2
common/transport/mqtt/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard.common</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>transport</artifactId>
</parent>
<groupId>org.thingsboard.common.transport</groupId>

2
common/transport/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

2
common/transport/snmp/pom.xml

@ -21,7 +21,7 @@
<parent>
<groupId>org.thingsboard.common</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>transport</artifactId>
</parent>

2
common/transport/transport-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard.common</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>transport</artifactId>
</parent>
<groupId>org.thingsboard.common.transport</groupId>

2
common/util/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

7
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/TestDbCallbackExecutor.java → common/util/src/main/java/org/thingsboard/common/util/DirectListeningExecutor.java

@ -13,15 +13,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.rule.engine;
package org.thingsboard.common.util;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.common.util.ListeningExecutor;
import java.util.concurrent.Callable;
public class TestDbCallbackExecutor implements ListeningExecutor {
public enum DirectListeningExecutor implements ListeningExecutor {
INSTANCE;
@Override
public <T> ListenableFuture<T> executeAsync(Callable<T> task) {

247
common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java

@ -0,0 +1,247 @@
/**
* 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.common.util;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SsrfProtectionValidator {
private static volatile boolean enabled;
private static final Set<String> ALLOWED_SCHEMES = Set.of("http", "https");
private static final Set<String> BLOCKED_HOSTNAMES = Set.of("localhost");
private static final Set<String> BLOCKED_HOSTNAME_SUFFIXES = Set.of(".internal", ".local");
private static volatile AdditionalBlockedHosts additionalBlocked = AdditionalBlockedHosts.EMPTY;
// Well-known cloud metadata endpoints not covered by the JDK checks (isLoopback, isSiteLocal, isLinkLocal)
private static final List<CidrRange> CLOUD_METADATA_RANGES = List.of(
CidrRange.of("100.64.0.0", 10), // Carrier-Grade NAT (RFC 6598); Alibaba Cloud and Tencent Cloud metadata
CidrRange.of("192.0.0.0", 24), // IANA reserved (RFC 6890); Oracle Cloud alternate metadata endpoint
CidrRange.of("168.63.129.16", 32) // Azure WireServer
);
public static void validateUri(URI uri) {
validateUri(uri, enabled);
}
static void validateUri(URI uri, boolean ssrfProtectionEnabled) {
if (!ssrfProtectionEnabled) {
return;
}
String scheme = uri.getScheme();
if (scheme == null || !ALLOWED_SCHEMES.contains(scheme.toLowerCase())) {
throw new RuntimeException("URI is invalid: only HTTP and HTTPS schemes are allowed, got: " + scheme);
}
String host = uri.getHost();
if (host == null || host.isEmpty()) {
throw new RuntimeException("URI is invalid: hostname is missing");
}
String hostLower = host.toLowerCase();
if (BLOCKED_HOSTNAMES.contains(hostLower) || additionalBlocked.hostnames.contains(hostLower)) {
throwBlockedHost(host);
}
for (String suffix : BLOCKED_HOSTNAME_SUFFIXES) {
if (hostLower.endsWith(suffix)) {
throwBlockedHost(host);
}
}
// Block IPv6 loopback literal in URL (e.g. http://[::1]/)
if ("[::1]".equals(host) || "::1".equals(host)) {
throwBlockedHost(host);
}
validateResolvedAddresses(host);
}
private static void validateResolvedAddresses(String host) {
InetAddress[] addresses;
try {
addresses = InetAddress.getAllByName(host);
} catch (UnknownHostException e) {
throw new RuntimeException("URI is invalid: unable to resolve hostname '" + host + "'", e);
}
for (InetAddress address : addresses) {
if (isBlockedAddress(address)) {
log.debug("Blocked request to host '{}' resolved to '{}'", host, address.getHostAddress());
throwBlockedHost(host);
}
}
}
private static boolean isBlockedAddress(InetAddress address) {
// Covers 127.0.0.0/8 and ::1
if (address.isLoopbackAddress()) {
return true;
}
// Covers 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
if (address.isSiteLocalAddress()) {
return true;
}
// Covers 169.254.0.0/16 and fe80::/10
if (address.isLinkLocalAddress()) {
return true;
}
// Covers 0.0.0.0
if (address.isAnyLocalAddress()) {
return true;
}
// Additional check for IPv6 unique local addresses (fc00::/7)
byte[] addr = address.getAddress();
if (addr.length == 16) {
int firstByte = addr[0] & 0xFF;
// fc00::/7 means first 7 bits are 1111110, so first byte is 0xFC or 0xFD
if (firstByte == 0xFC || firstByte == 0xFD) {
return true;
}
}
for (CidrRange cidr : CLOUD_METADATA_RANGES) {
if (cidr.contains(address)) {
return true;
}
}
// Check additional configured CIDR ranges
for (CidrRange cidr : additionalBlocked.cidrRanges) {
if (cidr.contains(address)) {
return true;
}
}
return false;
}
private static void throwBlockedHost(String host) {
throw new RuntimeException("URI is invalid: host '" + host + "' is not allowed");
}
public static void setEnabled(boolean enabled) {
SsrfProtectionValidator.enabled = enabled;
}
public static void setAdditionalBlockedHosts(List<String> entries) {
if (entries == null || entries.isEmpty()) {
additionalBlocked = AdditionalBlockedHosts.EMPTY;
return;
}
List<CidrRange> cidrRanges = new ArrayList<>();
Set<String> hostnames = new HashSet<>();
for (String entry : entries) {
String trimmed = entry.trim();
if (trimmed.isEmpty()) {
continue;
}
if (trimmed.contains("/") || isIpLiteral(trimmed)) {
try {
cidrRanges.add(CidrRange.parse(trimmed));
} catch (Exception e) {
log.warn("Failed to parse CIDR/IP entry '{}': {}", trimmed, e.getMessage());
}
} else {
hostnames.add(trimmed.toLowerCase());
}
}
additionalBlocked = new AdditionalBlockedHosts(
Collections.unmodifiableList(cidrRanges),
Collections.unmodifiableSet(hostnames));
log.info("SSRF additional blocked hosts configured: {} CIDR range(s), {} hostname(s)", cidrRanges.size(), hostnames.size());
}
private static boolean isIpLiteral(String entry) {
// IPv4 starts with a digit, IPv6 contains ':'
return !entry.isEmpty() && (Character.isDigit(entry.charAt(0)) || entry.contains(":"));
}
record AdditionalBlockedHosts(List<CidrRange> cidrRanges, Set<String> hostnames) {
static final AdditionalBlockedHosts EMPTY = new AdditionalBlockedHosts(Collections.emptyList(), Collections.emptySet());
}
record CidrRange(byte[] network, int prefixLength) {
static CidrRange of(String ip, int prefixLength) {
try {
byte[] addr = InetAddress.getByName(ip).getAddress();
if (prefixLength < 0 || prefixLength > addr.length * 8) {
throw new IllegalArgumentException("Invalid prefix length: " + prefixLength + " for " + ip);
}
return new CidrRange(addr, prefixLength);
} catch (UnknownHostException e) {
throw new IllegalArgumentException("Invalid IP: " + ip, e);
}
}
static CidrRange parse(String entry) throws UnknownHostException {
int slashIndex = entry.indexOf('/');
if (slashIndex >= 0) {
String ip = entry.substring(0, slashIndex);
int prefix = Integer.parseInt(entry.substring(slashIndex + 1));
byte[] addr = InetAddress.getByName(ip).getAddress();
if (prefix < 0 || prefix > addr.length * 8) {
throw new IllegalArgumentException("Invalid prefix length: " + prefix + " for " + entry);
}
return new CidrRange(addr, prefix);
} else {
byte[] addr = InetAddress.getByName(entry).getAddress();
return new CidrRange(addr, addr.length * 8);
}
}
boolean contains(InetAddress address) {
byte[] addr = address.getAddress();
if (addr.length != network.length) {
return false;
}
int fullBytes = prefixLength / 8;
int remainingBits = prefixLength % 8;
for (int i = 0; i < fullBytes; i++) {
if (addr[i] != network[i]) {
return false;
}
}
if (remainingBits > 0 && fullBytes < addr.length) {
int mask = 0xFF << (8 - remainingBits);
if ((addr[fullBytes] & mask) != (network[fullBytes] & mask)) {
return false;
}
}
return true;
}
@Override
public String toString() {
try {
return InetAddress.getByAddress(network).getHostAddress() + "/" + prefixLength;
} catch (UnknownHostException e) {
return "invalid/" + prefixLength;
}
}
}
}

338
common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java

@ -0,0 +1,338 @@
/**
* 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.common.util;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.net.URI;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ResourceLock("SsrfProtectionValidatorTest") // some tests mutate static additional-blocked-hosts
public class SsrfProtectionValidatorTest {
@ParameterizedTest
@ValueSource(strings = {
"http://example.com",
"https://example.com:8443/path",
"https://8.8.8.8/dns-query"
})
void testAllowedUrls(String url) {
URI uri = URI.create(url);
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true));
}
@ParameterizedTest
@ValueSource(strings = {
"http://127.0.0.1",
"http://127.0.0.1:8080/path",
"http://127.1.2.3"
})
void testBlockedLoopbackIpv4(String url) {
URI uri = URI.create(url);
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void testBlockedLocalhost() {
URI uri = URI.create("http://localhost/path");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void testBlockedIpv6Loopback() {
URI uri = URI.create("http://[::1]/path");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@ParameterizedTest
@ValueSource(strings = {
"http://169.254.169.254/latest/meta-data/",
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"http://169.254.1.1"
})
void testBlockedLinkLocalImds(String url) {
URI uri = URI.create(url);
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@ParameterizedTest
@ValueSource(strings = {
"http://10.0.0.1",
"http://10.255.255.255",
"http://172.16.0.1",
"http://172.31.255.255",
"http://192.168.1.1",
"http://192.168.0.100:8080/api"
})
void testBlockedPrivateRfc1918(String url) {
URI uri = URI.create(url);
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@ParameterizedTest
@ValueSource(strings = {
// 100.64.0.0/10 — Carrier-Grade NAT (RFC 6598): Alibaba Cloud metadata (100.100.100.200), Tencent Cloud (100.88.222.5)
"http://100.100.100.200",
"http://100.88.222.5",
"http://100.64.0.1",
"http://100.127.255.255",
// 192.0.0.0/24 — IANA reserved (RFC 6890): Oracle Cloud alternate metadata (192.0.0.192)
"http://192.0.0.192",
"http://192.0.0.1",
// 168.63.129.16 — Azure WireServer
"http://168.63.129.16"
})
void testBlockedCloudMetadataEndpoints(String url) {
URI uri = URI.create(url);
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@ParameterizedTest
@ValueSource(strings = {
// Just outside 100.64.0.0/10
"http://100.128.0.1",
// Just outside 192.0.0.0/24
"http://192.0.1.1",
// Adjacent to Azure WireServer
"http://168.63.129.17"
})
void testAllowedNearCloudMetadataBoundaries(String url) {
URI uri = URI.create(url);
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true));
}
@ParameterizedTest
@ValueSource(strings = {
"file:///etc/passwd",
"ftp://internal.host/file"
})
void testBlockedSchemes(String url) {
URI uri = URI.create(url);
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("only HTTP and HTTPS schemes are allowed");
}
@Test
void testBlockedZeroAddress() {
URI uri = URI.create("http://0.0.0.0");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@ParameterizedTest
@ValueSource(strings = {
"http://server.internal",
"http://app.local"
})
void testBlockedHostnameSuffixes(String url) {
URI uri = URI.create(url);
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void testBlockedNullScheme() {
URI uri = URI.create("//example.com/path");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("only HTTP and HTTPS schemes are allowed");
}
@Test
void testBlockedEmptyHost() {
URI uri = URI.create("http:///path");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("hostname is missing");
}
@Test
void testBlockedUnresolvableHostname() {
URI uri = URI.create("http://host.invalid.tld.that.does.not.exist/path");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("unable to resolve hostname");
}
@Test
void testBlockedLocalhostCaseInsensitive() {
URI uri = URI.create("http://LOCALHOST/path");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void testDisabledAllowsPrivateAddresses() {
URI uri = URI.create("http://127.0.0.1");
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, false));
}
@Test
void testAdditionalBlockedSingleIp() {
try {
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("8.8.8.8"));
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.8/dns-query"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Adjacent IP is not blocked
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.9"), true));
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
}
}
@Test
void testAdditionalBlockedCidrSlash10() {
try {
// Use 44.0.0.0/10 (not blocked by default) to verify CIDR /10 matching
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("44.0.0.0/10"));
// Inside the range
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://44.0.1.1"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Last address in the range
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://44.63.255.255"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Outside the range
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://44.64.0.1"), true));
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
}
}
@Test
void testAdditionalBlockedCidrSlash24() {
try {
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("198.51.100.0/24"));
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://198.51.100.0"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://198.51.100.255"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Outside the range
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://198.51.101.0"), true));
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
}
}
@Test
void testAdditionalBlockedHostnameViaValidateUri() {
try {
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("evil.corp"));
URI uri = URI.create("http://evil.corp/api");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
}
}
@Test
void testAdditionalBlockedHostnameCaseInsensitive() {
try {
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("My-Service.Corp"));
URI uri = URI.create("http://my-service.corp/api");
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
}
}
@Test
void testSetAdditionalBlockedHostsEmptyAndNull() {
// Should not throw
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
SsrfProtectionValidator.setAdditionalBlockedHosts(null);
}
@Test
void testCidrRangeInvalidPrefixLength() {
assertThatThrownBy(() -> SsrfProtectionValidator.CidrRange.parse("10.0.0.0/999"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid prefix length");
assertThatThrownBy(() -> SsrfProtectionValidator.CidrRange.parse("10.0.0.0/-1"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid prefix length");
}
@Test
void testAdditionalBlockedCidrViaValidateUri() {
// 203.0.113.0/24 (TEST-NET-3) is not blocked by default
URI uri = URI.create("http://203.0.113.1");
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true));
try {
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("203.0.113.0/24"));
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
}
}
@Test
void testAdditionalBlockedMixedConfig() {
try {
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("203.0.113.0/24", "evil.corp", "8.8.8.8"));
// CIDR range
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://203.0.113.50"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Hostname
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://evil.corp/api"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Single IP
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.8"), true))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
// Not in any additional block list
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://1.1.1.1"), true));
} finally {
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList());
}
}
}

2
common/version-control/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>

35
common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java

@ -277,13 +277,17 @@ public class GitRepository {
return listFilesAtCommit(commitId, path, -1).stream().map(RepoFile::path).toList();
}
@SneakyThrows
public List<RepoFile> listFilesAtCommit(String commitId, String path, int depth) {
log.debug("Executing listFilesAtCommit [{}][{}][{}]", settings.getRepositoryUri(), commitId, path);
RevCommit commit = resolveCommit(commitId);
return listFilesAtCommit(commit, path, depth);
}
@SneakyThrows
public List<RepoFile> listFilesAtCommit(RevCommit commit, String path, int depth) {
log.debug("Executing listFilesAtCommit [{}][{}][{}]", settings.getRepositoryUri(), commit, path);
List<RepoFile> files = new ArrayList<>();
RevCommit revCommit = resolveCommit(commitId);
try (TreeWalk treeWalk = new TreeWalk(git.getRepository())) {
treeWalk.reset(revCommit.getTree().getId());
treeWalk.reset(commit.getTree().getId());
if (StringUtils.isNotEmpty(path)) {
treeWalk.setFilter(PathFilter.create(path));
}
@ -301,11 +305,15 @@ public class GitRepository {
return files;
}
@SneakyThrows
public byte[] getFileContentAtCommit(String file, String commitId) {
log.debug("Executing getFileContentAtCommit [{}][{}][{}]", settings.getRepositoryUri(), commitId, file);
RevCommit revCommit = resolveCommit(commitId);
try (TreeWalk treeWalk = TreeWalk.forPath(git.getRepository(), file, revCommit.getTree())) {
RevCommit commit = resolveCommit(commitId);
return getFileContentAtCommit(file, commit);
}
@SneakyThrows
public byte[] getFileContentAtCommit(String file, RevCommit commit) {
log.debug("Executing getFileContentAtCommit [{}][{}][{}]", settings.getRepositoryUri(), commit, file);
try (TreeWalk treeWalk = TreeWalk.forPath(git.getRepository(), file, commit.getTree())) {
if (treeWalk == null) {
throw new IllegalArgumentException("File not found");
}
@ -373,9 +381,9 @@ public class GitRepository {
for (RemoteRefUpdate update : pushResult.getRemoteUpdates()) {
RemoteRefUpdate.Status status = update.getStatus();
if (status == REJECTED_NONFASTFORWARD || status == REJECTED_NODELETE ||
status == REJECTED_REMOTE_CHANGED || status == REJECTED_OTHER_REASON) {
status == REJECTED_REMOTE_CHANGED || status == REJECTED_OTHER_REASON) {
throw new RuntimeException("Remote repository answered with error: " +
Optional.ofNullable(update.getMessage()).orElseGet(status::name));
Optional.ofNullable(update.getMessage()).orElseGet(status::name));
}
}
});
@ -450,7 +458,8 @@ public class GitRepository {
revCommit.getFullMessage(), revCommit.getAuthorIdent().getName(), revCommit.getAuthorIdent().getEmailAddress());
}
private RevCommit resolveCommit(String id) throws IOException {
@SneakyThrows
public RevCommit resolveCommit(String id) {
return git.getRepository().parseCommit(resolve(id));
}
@ -481,8 +490,8 @@ public class GitRepository {
private static final Function<PageLink, Comparator<RevCommit>> revCommitComparatorFunction = pageLink -> {
SortOrder sortOrder = pageLink.getSortOrder();
if (sortOrder != null
&& sortOrder.getProperty().equals("timestamp")
&& SortOrder.Direction.ASC.equals(sortOrder.getDirection())) {
&& sortOrder.getProperty().equals("timestamp")
&& SortOrder.Direction.ASC.equals(sortOrder.getDirection())) {
return Comparator.comparingInt(RevCommit::getCommitTime);
}
return null;

2
dao/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>thingsboard</artifactId>
</parent>
<artifactId>dao</artifactId>

3
dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractAsyncDao.java

@ -37,6 +37,9 @@ public abstract class CassandraAbstractAsyncDao extends CassandraAbstractDao {
@Value("${cassandra.query.result_processing_threads:50}")
private int threadPoolSize;
@Value("${cassandra.query.max_result_set_size_in_bytes:52428800}")
protected long maxResultSetSizeBytes;
@PostConstruct
public void startExecutor() {
readResultsProcessingExecutor = ThingsBoardExecutors.newWorkStealingPool(threadPoolSize, "cassandra-callback");

27
dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java

@ -35,12 +35,17 @@ public class Oauth2ClientDataValidator extends DataValidator<OAuth2Client> {
@Override
protected void validateDataImpl(TenantId tenantId, OAuth2Client oAuth2Client) {
OAuth2MapperConfig mapperConfig = oAuth2Client.getMapperConfig();
if (mapperConfig.getType() == MapperType.BASIC) {
MapperType type = mapperConfig.getType();
if (type == MapperType.BASIC || type == MapperType.GITHUB || type == MapperType.APPLE) {
OAuth2BasicMapperConfig basicConfig = mapperConfig.getBasic();
if (basicConfig == null) {
throw new DataValidationException("Basic config should be specified!");
}
if (StringUtils.isEmpty(basicConfig.getEmailAttributeKey())) {
if (type == MapperType.GITHUB) {
if (!StringUtils.isEmpty(basicConfig.getEmailAttributeKey())) {
throw new DataValidationException("Email attribute key cannot be configured for GITHUB mapper type!");
}
} else if (StringUtils.isEmpty(basicConfig.getEmailAttributeKey())) {
throw new DataValidationException("Email attribute key should be specified!");
}
if (basicConfig.getTenantNameStrategy() == null) {
@ -51,23 +56,7 @@ public class Oauth2ClientDataValidator extends DataValidator<OAuth2Client> {
throw new DataValidationException("Tenant name pattern should be specified!");
}
}
if (mapperConfig.getType() == MapperType.GITHUB) {
OAuth2BasicMapperConfig basicConfig = mapperConfig.getBasic();
if (basicConfig == null) {
throw new DataValidationException("Basic config should be specified!");
}
if (!StringUtils.isEmpty(basicConfig.getEmailAttributeKey())) {
throw new DataValidationException("Email attribute key cannot be configured for GITHUB mapper type!");
}
if (basicConfig.getTenantNameStrategy() == null) {
throw new DataValidationException("Tenant name strategy should be specified!");
}
if (basicConfig.getTenantNameStrategy() == TenantNameStrategyType.CUSTOM
&& StringUtils.isEmpty(basicConfig.getTenantNamePattern())) {
throw new DataValidationException("Tenant name pattern should be specified!");
}
}
if (mapperConfig.getType() == MapperType.CUSTOM) {
if (type == MapperType.CUSTOM) {
OAuth2CustomMapperConfig customConfig = mapperConfig.getCustom();
if (customConfig == null) {
throw new DataValidationException("Custom config should be specified!");

4
dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsDao.java

@ -17,6 +17,8 @@ package org.thingsboard.server.dao.settings;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.Dao;
import java.util.UUID;
@ -27,6 +29,8 @@ public interface AdminSettingsDao extends Dao<AdminSettings> {
AdminSettings findByTenantIdAndKey(UUID tenantId, String key);
PageData<AdminSettings> findAllByTenantId(TenantId tenantId, PageLink pageLink);
boolean removeByTenantIdAndKey(UUID tenantId, String key);
void removeByTenantId(UUID tenantId);

19
dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java

@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.FluentFuture;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.EntityType;
@ -27,6 +28,9 @@ import org.thingsboard.server.common.data.id.AdminSettingsId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.service.Validator;
@ -44,6 +48,9 @@ public class AdminSettingsServiceImpl implements AdminSettingsService {
@Autowired
private DataValidator<AdminSettings> adminSettingsValidator;
@Autowired
protected ApplicationEventPublisher eventPublisher;
@Override
public AdminSettings findAdminSettingsById(TenantId tenantId, AdminSettingsId adminSettingsId) {
log.trace("Executing findAdminSettingsById [{}]", adminSettingsId);
@ -63,10 +70,15 @@ public class AdminSettingsServiceImpl implements AdminSettingsService {
return adminSettingsDao.findByTenantIdAndKey(tenantId.getId(), key);
}
@Override
public PageData<AdminSettings> findAllByTenantId(TenantId tenantId, PageLink pageLink) {
return adminSettingsDao.findAllByTenantId(tenantId, pageLink);
}
@Override
public AdminSettings saveAdminSettings(TenantId tenantId, AdminSettings adminSettings) {
log.trace("Executing saveAdminSettings [{}]", adminSettings);
adminSettingsValidator.validate(adminSettings, data -> tenantId);
AdminSettings oldAdminSettings = adminSettingsValidator.validate(adminSettings, data -> tenantId);
if (adminSettings.getKey().equals("mail")) {
AdminSettings mailSettings = findAdminSettingsByKey(tenantId, "mail");
if (mailSettings != null) {
@ -84,7 +96,10 @@ public class AdminSettingsServiceImpl implements AdminSettingsService {
if (adminSettings.getTenantId() == null) {
adminSettings.setTenantId(TenantId.SYS_TENANT_ID);
}
return adminSettingsDao.save(tenantId, adminSettings);
AdminSettings savedAdminSettings = adminSettingsDao.save(tenantId, adminSettings);
eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(savedAdminSettings.getTenantId()).entityId(savedAdminSettings.getId())
.entity(savedAdminSettings).oldEntity(oldAdminSettings).created(adminSettings.getId() == null).build());
return savedAdminSettings;
}
@Override

5
dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDaoListeningExecutorService.java

@ -17,8 +17,10 @@ package org.thingsboard.server.dao.sql;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.support.TransactionTemplate;
import org.thingsboard.common.util.ListeningExecutor;
import javax.sql.DataSource;
import java.sql.SQLException;
@ -29,7 +31,8 @@ import java.sql.Statement;
public abstract class JpaAbstractDaoListeningExecutorService {
@Autowired
protected JpaExecutorService service;
@Qualifier("jpaExecutorService")
protected ListeningExecutor service;
@Autowired
protected DataSource dataSource;

4
dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDao.java

@ -120,7 +120,7 @@ public abstract class AbstractChunkedAggregationTimeseriesDao extends AbstractSq
public ListenableFuture<ReadTsKvQueryResult> findAllAsync(TenantId tenantId, EntityId entityId, ReadTsKvQuery query) {
var aggParams = query.getAggParameters();
if (Aggregation.NONE.equals(aggParams.getAggregation()) || aggParams.getInterval() < 1) {
return Futures.immediateFuture(findAllAsyncWithLimit(entityId, query));
return service.submit(() -> findAllWithLimit(entityId, query));
} else {
List<ListenableFuture<Optional<TsKvEntity>>> futures = new ArrayList<>();
var intervalType = aggParams.getIntervalType();
@ -144,7 +144,7 @@ public abstract class AbstractChunkedAggregationTimeseriesDao extends AbstractSq
}
}
ReadTsKvQueryResult findAllAsyncWithLimit(EntityId entityId, ReadTsKvQuery query) {
ReadTsKvQueryResult findAllWithLimit(EntityId entityId, ReadTsKvQuery query) {
Integer keyId = keyDictionaryDao.getOrSaveKeyId(query.getKey());
List<TsKvEntity> tsKvEntities = tsKvRepository.findAllWithLimit(
entityId.getId(),

4
dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/TimescaleTimeseriesDao.java

@ -152,7 +152,7 @@ public class TimescaleTimeseriesDao extends AbstractSqlTimeseriesDao implements
var aggParams = query.getAggParameters();
var intervalType = aggParams.getIntervalType();
if (query.getAggregation() == Aggregation.NONE) {
return Futures.immediateFuture(findAllAsyncWithLimit(entityId, query));
return service.submit(() -> findAllWithLimit(entityId, query));
} else if (IntervalType.MILLISECONDS.equals(intervalType)) {
long startTs = query.getStartTs();
long endTs = Math.max(query.getStartTs() + 1, query.getEndTs());
@ -179,7 +179,7 @@ public class TimescaleTimeseriesDao extends AbstractSqlTimeseriesDao implements
super.cleanup(systemTtl);
}
private ReadTsKvQueryResult findAllAsyncWithLimit(EntityId entityId, ReadTsKvQuery query) {
private ReadTsKvQueryResult findAllWithLimit(EntityId entityId, ReadTsKvQuery query) {
String strKey = query.getKey();
Integer keyId = keyDictionaryDao.getOrSaveKeyId(strKey);
List<TimescaleTsKvEntity> timescaleTsKvEntities = tsKvRepository.findAllWithLimit(

92
dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java

@ -52,6 +52,7 @@ import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.kv.TsKvEntryAggWrapper;
import org.thingsboard.server.common.data.kv.TsKvQuery;
import org.thingsboard.server.dao.model.ModelConstants;
import org.thingsboard.server.dao.nosql.ResultSetSizeLimitExceededException;
import org.thingsboard.server.dao.nosql.TbResultSet;
import org.thingsboard.server.dao.nosql.TbResultSetFuture;
import org.thingsboard.server.dao.sqlts.AggregationTimeseriesDao;
@ -256,7 +257,8 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to fetch partitions for interval {}-{}", entityId.getEntityType().name(), entityId.getId(), minPartition, maxPartition, t);
log.error("[{}][{}][{}] Failed to fetch partitions for interval {}-{}", tenantId, entityId.getEntityType(), entityId.getId(), minPartition, maxPartition, t);
resultFuture.setException(t);
}
}, readResultsProcessingExecutor);
return resultFuture;
@ -330,7 +332,8 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to fetch partitions for interval {}-{}", entityId.getEntityType().name(), entityId.getId(), toPartitionTs(query.getStartTs()), toPartitionTs(query.getEndTs()), t);
log.error("[{}][{}][{}] Failed to fetch partitions for interval {}-{}", tenantId, entityId.getEntityType(), entityId.getId(), toPartitionTs(query.getStartTs()), toPartitionTs(query.getEndTs()), t);
resultFuture.setException(t);
}
}, readResultsProcessingExecutor);
@ -372,8 +375,7 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
cursor.addData(convertResultToTsKvEntryList(Collections.emptyList()));
findAllAsyncSequentiallyWithLimit(tenantId, cursor, resultFuture);
} else {
Futures.addCallback(result.allRows(readResultsProcessingExecutor), new FutureCallback<List<Row>>() {
Futures.addCallback(result.allRows(readResultsProcessingExecutor, maxResultSetSizeBytes), new FutureCallback<List<Row>>() {
@Override
public void onSuccess(@Nullable List<Row> result) {
cursor.addData(convertResultToTsKvEntryList(result == null ? Collections.emptyList() : result));
@ -382,7 +384,13 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to fetch data for query {}-{}", stmt, t);
if (t instanceof ResultSetSizeLimitExceededException e) {
log.warn("[{}][{}][{}] Result set size limit exceeded for key [{}], query [{}]: {} bytes, limit {} bytes",
tenantId, cursor.getEntityType(), cursor.getEntityId(), cursor.getKey(), stmt.getPreparedStatement().getQuery(), e.getActualBytes(), e.getLimitBytes());
} else {
log.error("[{}][{}][{}] Failed to fetch data for key [{}], query [{}]", tenantId, cursor.getEntityType(), cursor.getEntityId(), cursor.getKey(), stmt.getPreparedStatement().getQuery(), t);
}
resultFuture.setException(t);
}
}, readResultsProcessingExecutor);
@ -392,7 +400,8 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to fetch data for query {}-{}", stmt, t);
log.error("[{}][{}][{}] Failed to fetch data for key [{}], query [{}]", tenantId, cursor.getEntityType(), cursor.getEntityId(), cursor.getKey(), stmt.getPreparedStatement().getQuery(), t);
resultFuture.setException(t);
}
}, readResultsProcessingExecutor);
}
@ -425,7 +434,7 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
}
if (!isUseTsKeyValuePartitioningOnRead()) {
final long estimatedPartitionCount = estimatePartitionCount(minPartition, maxPartition);
if (estimatedPartitionCount <= useTsKeyValuePartitioningOnReadMaxEstimatedPartitionCount) {
if (estimatedPartitionCount <= useTsKeyValuePartitioningOnReadMaxEstimatedPartitionCount) {
return Futures.immediateFuture(calculatePartitions(minPartition, maxPartition, (int) estimatedPartitionCount));
}
}
@ -446,7 +455,7 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
}
List<Long> calculatePartitions(long minPartition, long maxPartition) {
return calculatePartitions(minPartition, maxPartition, 0);
return calculatePartitions(minPartition, maxPartition, 0);
}
List<Long> calculatePartitions(long minPartition, long maxPartition, int estimatedPartitionCount) {
@ -533,6 +542,7 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
public void onFailure(Throwable t) {
}
}
private long computeTtl(long ttl) {
@ -569,7 +579,8 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to delete data for query {}-{}", stmt, t);
log.error("[{}][{}][{}] Failed to delete data for key [{}], query [{}]", tenantId, cursor.getEntityType(), cursor.getEntityId(), cursor.getKey(), stmt.getPreparedStatement().getQuery(), t);
resultFuture.setException(t);
}
}, readResultsProcessingExecutor);
}
@ -581,12 +592,12 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
try {
if (deleteStmt == null) {
deleteStmt = prepare("DELETE FROM " + ModelConstants.TS_KV_CF +
" WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.TS_COLUMN + " >= ? "
+ "AND " + ModelConstants.TS_COLUMN + " < ?");
" WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.TS_COLUMN + " >= ? "
+ "AND " + ModelConstants.TS_COLUMN + " < ?");
}
} finally {
stmtCreationLock.unlock();
@ -661,13 +672,13 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
private String getPreparedStatementQuery(DataType type) {
return INSERT_INTO + ModelConstants.TS_KV_CF +
"(" + ModelConstants.ENTITY_TYPE_COLUMN +
"," + ModelConstants.ENTITY_ID_COLUMN +
"," + ModelConstants.KEY_COLUMN +
"," + ModelConstants.PARTITION_COLUMN +
"," + ModelConstants.TS_COLUMN +
"," + getColumnName(type) + ")" +
" VALUES(?, ?, ?, ?, ?, ?)";
"(" + ModelConstants.ENTITY_TYPE_COLUMN +
"," + ModelConstants.ENTITY_ID_COLUMN +
"," + ModelConstants.KEY_COLUMN +
"," + ModelConstants.PARTITION_COLUMN +
"," + ModelConstants.TS_COLUMN +
"," + getColumnName(type) + ")" +
" VALUES(?, ?, ?, ?, ?, ?)";
}
private String getPreparedStatementQueryWithTtl(DataType type) {
@ -680,11 +691,11 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
try {
if (partitionInsertStmt == null) {
partitionInsertStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF +
"(" + ModelConstants.ENTITY_TYPE_COLUMN +
"," + ModelConstants.ENTITY_ID_COLUMN +
"," + ModelConstants.PARTITION_COLUMN +
"," + ModelConstants.KEY_COLUMN + ")" +
" VALUES(?, ?, ?, ?)");
"(" + ModelConstants.ENTITY_TYPE_COLUMN +
"," + ModelConstants.ENTITY_ID_COLUMN +
"," + ModelConstants.PARTITION_COLUMN +
"," + ModelConstants.KEY_COLUMN + ")" +
" VALUES(?, ?, ?, ?)");
}
} finally {
stmtCreationLock.unlock();
@ -699,11 +710,11 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
try {
if (partitionInsertTtlStmt == null) {
partitionInsertTtlStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF +
"(" + ModelConstants.ENTITY_TYPE_COLUMN +
"," + ModelConstants.ENTITY_ID_COLUMN +
"," + ModelConstants.PARTITION_COLUMN +
"," + ModelConstants.KEY_COLUMN + ")" +
" VALUES(?, ?, ?, ?) USING TTL ?");
"(" + ModelConstants.ENTITY_TYPE_COLUMN +
"," + ModelConstants.ENTITY_ID_COLUMN +
"," + ModelConstants.PARTITION_COLUMN +
"," + ModelConstants.KEY_COLUMN + ")" +
" VALUES(?, ?, ?, ?) USING TTL ?");
}
} finally {
stmtCreationLock.unlock();
@ -809,16 +820,17 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
fetchStmts[type.ordinal()] = fetchStmts[Aggregation.SUM.ordinal()];
} else {
fetchStmts[type.ordinal()] = prepare(SELECT_PREFIX +
String.join(", ", ModelConstants.getFetchColumnNames(type)) + " FROM " + ModelConstants.TS_KV_CF
+ " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.TS_COLUMN + " >= ? "
+ "AND " + ModelConstants.TS_COLUMN + " < ?"
+ (type == Aggregation.NONE ? " ORDER BY " + ModelConstants.TS_COLUMN + " " + orderBy + " LIMIT ?" : ""));
String.join(", ", ModelConstants.getFetchColumnNames(type)) + " FROM " + ModelConstants.TS_KV_CF
+ " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.TS_COLUMN + " >= ? "
+ "AND " + ModelConstants.TS_COLUMN + " < ?"
+ (type == Aggregation.NONE ? " ORDER BY " + ModelConstants.TS_COLUMN + " " + orderBy + " LIMIT ?" : ""));
}
}
return fetchStmts;
}
}

2
dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java

@ -184,7 +184,7 @@ public class CassandraBaseTimeseriesLatestDao extends AbstractCassandraBaseTimes
}
private ListenableFuture<List<TsKvEntry>> convertAsyncResultSetToTsKvEntryList(TbResultSet rs) {
return Futures.transform(rs.allRows(readResultsProcessingExecutor),
return Futures.transform(rs.allRows(readResultsProcessingExecutor, maxResultSetSizeBytes),
rows -> this.convertResultToTsKvEntryList(rows), readResultsProcessingExecutor);
}

4
dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java

@ -26,4 +26,8 @@ public class SimpleListenableFuture<V> extends AbstractFuture<V> {
return super.set(value);
}
public boolean setException(Throwable throwable) {
return super.setException(throwable);
}
}

23
dao/src/test/java/org/thingsboard/server/dao/service/AdminSettingsServiceTest.java

@ -25,9 +25,12 @@ import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.settings.AdminSettingsService;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@ -113,4 +116,24 @@ public class AdminSettingsServiceTest extends AbstractServiceTest {
}).hasMessageContaining("already exists");
}
@Test
public void testFindAllByTenantId() {
int pageSize = 10;
int totalElements = 100;
for (int i = 0; i < totalElements; i++) {
AdminSettings settings = new AdminSettings();
settings.setTenantId(tenantId);
String key = RandomStringUtils.randomAlphanumeric(15);
settings.setKey(key);
settings.setJsonValue(JacksonUtil.newObjectNode().put("value", i));
adminSettingsService.saveAdminSettings(tenantId, settings);
}
PageData<AdminSettings> pageData = adminSettingsService.findAllByTenantId(tenantId, new PageLink(pageSize));
assertThat(pageData.getData().size()).isEqualTo(pageSize);
assertThat(pageData.getTotalElements()).isEqualTo(totalElements);
}
}

18
dao/src/test/java/org/thingsboard/server/dao/service/ConstraintValidatorTest.java

@ -16,16 +16,12 @@
package org.thingsboard.server.dao.service;
import org.junit.Assert;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.dao.exception.DataValidationException;
class ConstraintValidatorTest {
private static final int MIN_IN_MS = 60000;
private static final int _1M = 1_000_000;
@Test
void validateFields() {
StringDataEntry stringDataEntryValid = new StringDataEntry("key", "value");
@ -35,16 +31,4 @@ class ConstraintValidatorTest {
ConstraintValidator.validateFields(stringDataEntryValid);
}
@Test
void validatePerMinute() {
StringDataEntry stringDataEntryValid = new StringDataEntry("key", "value");
long start = System.currentTimeMillis();
for (int i = 0; i < _1M; i++) {
ConstraintValidator.validateFields(stringDataEntryValid);
}
long end = System.currentTimeMillis();
Assertions.assertTrue(MIN_IN_MS > end - start);
}
}
}

42
dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java

@ -16,7 +16,10 @@
package org.thingsboard.server.dao.service.timeseries.nosql;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.kv.Aggregation;
import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery;
@ -27,9 +30,12 @@ import org.thingsboard.server.common.data.kv.JsonDataEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.dao.nosql.ResultSetSizeLimitExceededException;
import org.thingsboard.server.dao.service.DaoNoSqlTest;
import org.thingsboard.server.dao.service.timeseries.BaseTimeseriesServiceTest;
import org.thingsboard.server.dao.timeseries.CassandraBaseTimeseriesDao;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutionException;
@ -37,6 +43,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@ -44,6 +51,9 @@ import static org.junit.Assert.assertTrue;
@DaoNoSqlTest
public class TimeseriesServiceNoSqlTest extends BaseTimeseriesServiceTest {
@Autowired
private CassandraBaseTimeseriesDao cassandraBaseTimeseriesDao;
@Test
public void shouldSaveEntryOfEachTypeWithTtl() throws ExecutionException, InterruptedException, TimeoutException {
long ttlInSec = TimeUnit.SECONDS.toSeconds(3);
@ -94,4 +104,36 @@ public class TimeseriesServiceNoSqlTest extends BaseTimeseriesServiceTest {
double expectedValue = (doubleValue + longValue)/ 2;
assertThat(listWithAgg.get(0).getDoubleValue().get()).isEqualTo(expectedValue);
}
@Test
public void testResultSetSizeLimitExceeded() throws Exception {
DeviceId deviceId = new DeviceId(Uuids.timeBased());
String value = "x".repeat(500);
List<TsKvEntry> entries = new ArrayList<>();
for (int i = 0; i < 5; i++) {
entries.add(new BasicTsKvEntry(TimeUnit.MINUTES.toMillis(i + 1), new StringDataEntry("bigKey", value)));
}
tsService.save(tenantId, deviceId, entries, 0).get(MAX_TIMEOUT, TimeUnit.SECONDS);
long originalLimit = (long) ReflectionTestUtils.getField(cassandraBaseTimeseriesDao, "maxResultSetSizeBytes");
try {
// Set a very low limit to trigger the exception
ReflectionTestUtils.setField(cassandraBaseTimeseriesDao, "maxResultSetSizeBytes", 1024L);
assertThatThrownBy(() -> tsService.findAll(tenantId, deviceId, Collections.singletonList(
new BaseReadTsKvQuery("bigKey", 0L, TimeUnit.MINUTES.toMillis(6), 1000, 10, Aggregation.NONE)
)).get(MAX_TIMEOUT, TimeUnit.SECONDS))
.isInstanceOf(ExecutionException.class)
.satisfies(e -> assertThat(ExceptionUtils.getRootCause(e)).isInstanceOf(ResultSetSizeLimitExceededException.class));
} finally {
ReflectionTestUtils.setField(cassandraBaseTimeseriesDao, "maxResultSetSizeBytes", originalLimit);
}
// Verify query succeeds with original limit restored
List<TsKvEntry> result = tsService.findAll(tenantId, deviceId, Collections.singletonList(
new BaseReadTsKvQuery("bigKey", 0L, TimeUnit.MINUTES.toMillis(6), 1000, 10, Aggregation.NONE)
)).get(MAX_TIMEOUT, TimeUnit.SECONDS);
assertThat(result).hasSize(5);
}
}

154
dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/LatestTimeseriesPerformanceTest.java

@ -1,154 +0,0 @@
/**
* 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.timeseries.sql;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.dao.service.AbstractServiceTest;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
@DaoSqlTest
@Slf4j
public class LatestTimeseriesPerformanceTest extends AbstractServiceTest {
private static final String STRING_KEY = "stringKey";
private static final String LONG_KEY = "longKey";
private static final String DOUBLE_KEY = "doubleKey";
private static final String BOOLEAN_KEY = "booleanKey";
private static final int AMOUNT_OF_UNIQ_KEY = 10000;
private static final int TIMEOUT = 100;
private final Random random = new Random();
@Autowired
private TimeseriesLatestDao timeseriesLatestDao;
private ListeningExecutorService testExecutor;
private EntityId entityId;
private AtomicLong saveCounter;
@Before
public void before() {
Tenant tenant = new Tenant();
tenant.setTitle("My tenant");
Tenant savedTenant = tenantService.saveTenant(tenant);
Assert.assertNotNull(savedTenant);
tenantId = savedTenant.getId();
entityId = new DeviceId(UUID.randomUUID());
saveCounter = new AtomicLong(0);
testExecutor = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(200, ThingsBoardThreadFactory.forName(getClass().getSimpleName() + "-test-scope")));
}
@After
public void after() {
tenantService.deleteTenant(tenantId);
if (testExecutor != null) {
testExecutor.shutdownNow();
}
}
@Test
public void test_save_latest_timeseries() throws Exception {
warmup();
saveCounter.set(0);
long startTime = System.currentTimeMillis();
List<ListenableFuture<?>> futures = new ArrayList<>();
for (int i = 0; i < 25_000; i++) {
futures.add(save(generateStrEntry(getRandomKey())));
futures.add(save(generateLngEntry(getRandomKey())));
futures.add(save(generateDblEntry(getRandomKey())));
futures.add(save(generateBoolEntry(getRandomKey())));
}
Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS);
long endTime = System.currentTimeMillis();
long totalTime = endTime - startTime;
log.info("Total time: {}", totalTime);
log.info("Saved count: {}", saveCounter.get());
log.warn("Saved per 1 sec: {}", saveCounter.get() * 1000 / totalTime);
}
private void warmup() throws Exception {
List<ListenableFuture<?>> futures = new ArrayList<>();
for (int i = 0; i < AMOUNT_OF_UNIQ_KEY; i++) {
futures.add(save(generateStrEntry(i)));
futures.add(save(generateLngEntry(i)));
futures.add(save(generateDblEntry(i)));
futures.add(save(generateBoolEntry(i)));
}
Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS);
}
private ListenableFuture<?> save(TsKvEntry tsKvEntry) {
return Futures.transformAsync(testExecutor.submit(() -> timeseriesLatestDao.saveLatest(tenantId, entityId, tsKvEntry)), result -> {
saveCounter.incrementAndGet();
return result;
}, testExecutor);
}
private TsKvEntry generateStrEntry(int keyIndex) {
return new BasicTsKvEntry(System.currentTimeMillis(), new StringDataEntry(STRING_KEY + keyIndex, RandomStringUtils.random(10)));
}
private TsKvEntry generateLngEntry(int keyIndex) {
return new BasicTsKvEntry(System.currentTimeMillis(), new LongDataEntry(LONG_KEY + keyIndex, random.nextLong()));
}
private TsKvEntry generateDblEntry(int keyIndex) {
return new BasicTsKvEntry(System.currentTimeMillis(), new DoubleDataEntry(DOUBLE_KEY + keyIndex, random.nextDouble()));
}
private TsKvEntry generateBoolEntry(int keyIndex) {
return new BasicTsKvEntry(System.currentTimeMillis(), new BooleanDataEntry(BOOLEAN_KEY + keyIndex, random.nextBoolean()));
}
private int getRandomKey() {
return random.nextInt(AMOUNT_OF_UNIQ_KEY);
}
}

7
dao/src/test/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDaoTest.java

@ -18,6 +18,8 @@ package org.thingsboard.server.dao.sqlts;
import com.google.common.util.concurrent.Futures;
import org.junit.Before;
import org.junit.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.DirectListeningExecutor;
import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery;
import org.thingsboard.server.common.data.kv.ReadTsKvQuery;
import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult;
@ -48,10 +50,11 @@ public class AbstractChunkedAggregationTimeseriesDaoTest {
@Before
public void setUp() throws Exception {
tsDao = spy(AbstractChunkedAggregationTimeseriesDao.class);
ReflectionTestUtils.setField(tsDao, "service", DirectListeningExecutor.INSTANCE);
Optional<TsKvEntry> optionalListenableFuture = Optional.of(mock(TsKvEntry.class));
willReturn(Futures.immediateFuture(optionalListenableFuture)).given(tsDao).findAndAggregateAsync(any(), anyString(), anyLong(), anyLong(), anyLong(), any());
willReturn(Futures.immediateFuture(mock(ReadTsKvQueryResult.class))).given(tsDao).getReadTsKvQueryResultFuture(any(), any());
willReturn(mock(ReadTsKvQueryResult.class)).given(tsDao).findAllAsyncWithLimit(any(), any());
willReturn(mock(ReadTsKvQueryResult.class)).given(tsDao).findAllWithLimit(any(), any());
}
@Test
@ -161,7 +164,7 @@ public class AbstractChunkedAggregationTimeseriesDaoTest {
ReadTsKvQuery query = new BaseReadTsKvQuery(TEMP, 1, 3000, interval, LIMIT, COUNT, DESC);
willCallRealMethod().given(tsDao).findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query);
tsDao.findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query);
verify(tsDao, times(1)).findAllAsyncWithLimit(any(), any());
verify(tsDao, times(1)).findAllWithLimit(any(), any());
verify(tsDao, times(0)).findAndAggregateAsync(any(), any(), anyLong(), anyLong(), anyLong(), any());
}

2
edqs/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>thingsboard</artifactId>
</parent>
<artifactId>edqs</artifactId>

2
edqs/src/main/resources/edqs.yml

@ -210,7 +210,7 @@ management:
web:
exposure:
# Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics).
include: '${METRICS_ENDPOINTS_EXPOSE:info}'
include: "${METRICS_ENDPOINTS_EXPOSE:info}"
health:
elasticsearch:
# Enable the org.springframework.boot.actuate.elasticsearch.ElasticsearchRestClientHealthIndicator.doHealthCheck

2
monitoring/pom.xml

@ -21,7 +21,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>thingsboard</artifactId>
</parent>

2
msa/black-box-tests/pom.xml

@ -21,7 +21,7 @@
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>msa</artifactId>
</parent>
<groupId>org.thingsboard.msa</groupId>

2
msa/edqs/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>msa</artifactId>
</parent>
<groupId>org.thingsboard.msa</groupId>

5
msa/js-executor/package.json

@ -1,7 +1,7 @@
{
"name": "thingsboard-js-executor",
"private": true,
"version": "4.2.1.2",
"version": "4.2.2",
"description": "ThingsBoard JavaScript Executor Microservice",
"main": "server.ts",
"bin": "server.js",
@ -41,6 +41,9 @@
"ts-node": "^10.9.2",
"typescript": "5.9.2"
},
"resolutions": {
"@yao-pkg/pkg/tar": ">=7.5.8"
},
"pkg": {
"assets": [
"node_modules/config/**/*.*"

2
msa/js-executor/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
<version>4.2.1.2-SNAPSHOT</version>
<version>4.2.2-SNAPSHOT</version>
<artifactId>msa</artifactId>
</parent>
<groupId>org.thingsboard.msa</groupId>

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save