Browse Source

Merge remote-tracking branch 'thingsboard-ce/master' into fix/new-doc-links-lts-4.3

pull/15832/head
Maksym Tsymbarov 3 months ago
parent
commit
f7efee6122
  1. 2
      .github/workflows/license-header-format.yml
  2. 10
      README.md
  3. 2
      application/pom.xml
  4. 36
      application/src/main/data/json/edge/instructions/install/centos/instructions.md
  5. 12
      application/src/main/data/json/edge/instructions/install/ubuntu/instructions.md
  6. 2
      application/src/main/data/json/system/widget_types/label_widget.json
  7. 1
      application/src/main/data/json/system/widget_types/unread_notifications.json
  8. 67
      application/src/main/data/upgrade/basic/schema_update.sql
  9. 3
      application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java
  10. 4
      application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
  11. 28
      application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java
  12. 1
      application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java
  13. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
  14. 18
      application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java
  15. 85
      application/src/main/java/org/thingsboard/server/service/edge/rpc/DownlinkMessageMapper.java
  16. 712
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java
  17. 1029
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java
  18. 1
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java
  19. 57
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java
  20. 238
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeUplinkMessageDispatcher.java
  21. 131
      application/src/main/java/org/thingsboard/server/service/edge/rpc/GrpcServer.java
  22. 196
      application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java
  23. 46
      application/src/main/java/org/thingsboard/server/service/edge/rpc/PostgresEdgeGrpcSession.java
  24. 23
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeEventsDispatcher.java
  25. 218
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/PostgresGeneralEdgeEventsDispatcher.java
  26. 392
      application/src/main/java/org/thingsboard/server/service/edge/rpc/service/EdgeGrpcService.java
  27. 131
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/DefaultZombieSessionCleanupService.java
  28. 16
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeAttributeSaveCallback.java
  29. 621
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSession.java
  30. 34
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSessionDelegate.java
  31. 43
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSession.java
  32. 83
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSessionsHolder.java
  33. 23
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/ZombieSessionCleanupService.java
  34. 149
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/AbstractEdgeGrpcSessionManager.java
  35. 47
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/EdgeGrpcSessionManager.java
  36. 313
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java
  37. 183
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/PostgresBasedEdgeGrpcSessionManager.java
  38. 4
      application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java
  39. 2
      application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java
  40. 8
      application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java
  41. 23
      application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java
  42. 19
      application/src/main/java/org/thingsboard/server/service/iot_hub/InstallReport.java
  43. 4
      application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java
  44. 4
      application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
  45. 6
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
  46. 6
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
  47. 6
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java
  48. 6
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java
  49. 35
      application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java
  50. 6
      application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java
  51. 12
      application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java
  52. 2
      application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java
  53. 2
      application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java
  54. 14
      application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java
  55. 10
      application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java
  56. 6
      application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java
  57. 8
      application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java
  58. 2
      application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java
  59. 2
      application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java
  60. 2
      application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java
  61. 249
      application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java
  62. 3
      application/src/main/java/org/thingsboard/server/service/ws/AuthCmd.java
  63. 81
      application/src/main/resources/thingsboard.yml
  64. 2
      application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
  65. 11
      application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java
  66. 71
      application/src/test/java/org/thingsboard/server/controller/ApiKeyWebSocketApiTest.java
  67. 527
      application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
  68. 196
      application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java
  69. 24
      application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java
  70. 195
      application/src/test/java/org/thingsboard/server/controller/WebSocketApiTest.java
  71. 101
      application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java
  72. 12
      application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java
  73. 112
      application/src/test/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManagerTest.java
  74. 46
      application/src/test/java/org/thingsboard/server/service/iot_hub/InstallReportTest.java
  75. 260
      application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java
  76. 281
      application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java
  77. 2
      application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java
  78. 2
      application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java
  79. 9
      application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java
  80. 1
      application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java
  81. 8
      application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java
  82. 114
      application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java
  83. 2
      application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java
  84. 350
      application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java
  85. 30
      application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java
  86. 72
      application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionDevicesCreatingBeforeTest.java
  87. 1
      application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java
  88. 5
      application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java
  89. 3
      application/src/test/resources/logback-test.xml
  90. 2
      common/actor/pom.xml
  91. 2
      common/cache/pom.xml
  92. 2
      common/cluster-api/pom.xml
  93. 2
      common/coap-server/pom.xml
  94. 2
      common/dao-api/pom.xml
  95. 2
      common/data/pom.xml
  96. 1
      common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
  97. 1
      common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java
  98. 1
      common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java
  99. 2
      common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java
  100. 12
      common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java

2
.github/workflows/license-header-format.yml

@ -35,7 +35,7 @@ jobs:
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: 'corretto' # https://github.com/actions/setup-java?tab=readme-ov-file#supported-distributions distribution: 'corretto' # https://github.com/actions/setup-java?tab=readme-ov-file#supported-distributions
java-version: '21' java-version: '25'
cache: 'maven' # https://github.com/actions/setup-java?tab=readme-ov-file#caching-sbt-dependencies cache: 'maven' # https://github.com/actions/setup-java?tab=readme-ov-file#caching-sbt-dependencies
- name: License header format - name: License header format

10
README.md

@ -8,15 +8,13 @@
<br> <br>
<div align="center"> <div align="center">
💡 [Get started](https://thingsboard.io/docs/getting-started-guides/helloworld/)&ensp;•&ensp;🌐 [Website](https://thingsboard.io/)&ensp;•&ensp;📚 [Documentation](https://thingsboard.io/docs/)&ensp;•&ensp;📔 [Blog](https://thingsboard.io/blog/)&ensp;•&ensp;▶️ [Live demo](https://demo.thingsboard.io/signup)&ensp;•&ensp;🔗 [LinkedIn](https://www.linkedin.com/company/thingsboard/posts/?feedView=all) 💡 [Get started](https://thingsboard.io/docs/getting-started-guides/helloworld/)&ensp;•&ensp;🌐 [Website](https://thingsboard.io/)&ensp;•&ensp;📚 [Documentation](https://thingsboard.io/docs/)&ensp;•&ensp;📔 [Blog](https://thingsboard.io/blog/)&ensp;•&ensp;🔗 [LinkedIn](https://www.linkedin.com/company/thingsboard/posts/?feedView=all)
</div> </div>
## 🚀 Installation options ## 🚀 Installation options
* Install ThingsBoard [On-premise](https://thingsboard.io/docs/user-guide/install/installation-options/?ceInstallType=onPremise) Install ThingsBoard [on-premises](https://thingsboard.io/docs/user-guide/install/installation-options/?ceInstallType=onPremise) or use [ThingsBoard Cloud](https://thingsboard.io/installations/).
* Try [ThingsBoard Cloud](https://thingsboard.io/installations/)
* or [Use our Live demo](https://demo.thingsboard.io/signup)
## 💡 Getting started with ThingsBoard ## 💡 Getting started with ThingsBoard
@ -123,9 +121,9 @@ ThingsBoard is a scalable, user-friendly, and device-agnostic IoT platform that
[![SCADA Swimming pool](https://github.com/user-attachments/assets/68fd9e29-99f1-4c16-8c4c-476f4ccb20c0 "SCADA Swimming pool")](https://thingsboard.io/use-cases/scada/) [![SCADA Swimming pool](https://github.com/user-attachments/assets/68fd9e29-99f1-4c16-8c4c-476f4ccb20c0 "SCADA Swimming pool")](https://thingsboard.io/use-cases/scada/)
[**Fleet tracking**](https://thingsboard.io/use-cases/fleet-tracking/) [**Site fleet tracking**](https://thingsboard.io/use-cases/site-fleet-tracking/)
[![Fleet tracking](https://github.com/user-attachments/assets/9e8938ba-ee0c-4599-9494-d74b7de8a63d "Fleet tracking")](https://thingsboard.io/use-cases/fleet-tracking/) [![Site fleet tracking](https://github.com/user-attachments/assets/d6ce0766-b138-4a42-86aa-7112a543026c "Site fleet tracking")](https://thingsboard.io/use-cases/site-fleet-tracking/)
[**Smart farming**](https://thingsboard.io/use-cases/smart-farming/) [**Smart farming**](https://thingsboard.io/use-cases/smart-farming/)

2
application/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.3-SNAPSHOT</version> <version>4.4.0-SNAPSHOT</version>
<artifactId>thingsboard</artifactId> <artifactId>thingsboard</artifactId>
</parent> </parent>
<artifactId>application</artifactId> <artifactId>application</artifactId>

36
application/src/main/data/json/edge/instructions/install/centos/instructions.md

@ -1,22 +1,24 @@
Here is the list of commands that can be used to quickly install ThingsBoard Edge on RHEL/CentOS 7/8 and connect to the server. Here is the list of commands that can be used to quickly install ThingsBoard Edge on RHEL/CentOS 9/10 and connect to the server.
**Note:** OpenJDK 25 requires RHEL/CentOS 9+ or RHEL/CentOS 10+. Earlier versions (RHEL/CentOS 7, 8) are not supported.
#### Prerequisites #### Prerequisites
Before continuing to installation, execute the following commands to install the necessary tools: Before continuing to installation, execute the following commands to install the necessary tools:
```bash ```bash
sudo yum install -y nano wget && sudo yum install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm sudo dnf install -y nano wget
{:copy-code} {:copy-code}
``` ```
#### Step 1. Install Java 17 (OpenJDK) #### Step 1. Install Java 25 (OpenJDK)
ThingsBoard service is running on Java 17. To install OpenJDK 17, follow these instructions: ThingsBoard service is running on Java 25. To install OpenJDK 25, follow these instructions:
```bash ```bash
sudo dnf install java-17-openjdk sudo dnf install -y java-25-openjdk
{:copy-code} {:copy-code}
``` ```
Configure your operating system to use OpenJDK 17 by default. You can configure the default version by running the following command: Configure your operating system to use OpenJDK 25 by default. You can configure the default version by running the following command:
```bash ```bash
sudo update-alternatives --config java sudo update-alternatives --config java
@ -33,7 +35,7 @@ java -version
The expected result is: The expected result is:
```text ```text
openjdk version "17.x.xx" openjdk version "25.x.xx"
OpenJDK Runtime Environment (...) OpenJDK Runtime Environment (...)
OpenJDK 64-Bit Server VM (build ...) OpenJDK 64-Bit Server VM (build ...)
``` ```
@ -41,7 +43,7 @@ OpenJDK 64-Bit Server VM (build ...)
#### Step 2. Configure ThingsBoard Edge Database #### Step 2. Configure ThingsBoard Edge Database
ThingsBoard Edge supports **SQL** and **hybrid** database configurations. ThingsBoard Edge supports **SQL** and **hybrid** database configurations.
In this guide, we’ll use an **SQL** database. In this guide, we'll use an **SQL** database.
For more details about the hybrid setup, please refer to the official installation instructions on the <a href="https://thingsboard.io/docs/user-guide/install/edge/rhel/#step-2-configure-thingsboard-database" target="_blank">ThingsBoard documentation site</a>. For more details about the hybrid setup, please refer to the official installation instructions on the <a href="https://thingsboard.io/docs/user-guide/install/edge/rhel/#step-2-configure-thingsboard-database" target="_blank">ThingsBoard documentation site</a>.
To install the PostgreSQL database, run these commands: To install the PostgreSQL database, run these commands:
@ -54,19 +56,19 @@ sudo dnf update
Install the repository RPM: Install the repository RPM:
* **For CentOS/RHEL 8:** * **For RHEL/CentOS 9:**
```bash ```bash
# Install the repository RPM (For CentOS/RHEL 8): # Install the repository RPM (for RHEL/CentOS 9):
sudo sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-8-x86_64/pgdg-redhat-repo-latest.noarch.rpm sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm
{:copy-code} {:copy-code}
``` ```
* **For CentOS/RHEL 9:** * **For RHEL/CentOS 10:**
```bash ```bash
# Install the repository RPM (for CentOS 9): # Install the repository RPM (for RHEL/CentOS 10):
sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-10-x86_64/pgdg-redhat-repo-latest.noarch.rpm
{:copy-code} {:copy-code}
``` ```
@ -91,8 +93,8 @@ sudo -u postgres psql -c "\password"
Then, enter and confirm the password. Then, enter and confirm the password.
Since ThingsBoard Edge uses the PostgreSQL database for local storage, configuring MD5 authentication ensures that only authenticated users or Since ThingsBoard Edge uses the PostgreSQL database for local storage, configuring MD5 authentication ensures that only authenticated users or
applications can access the database, thus protecting your data. After configuring the password, applications can access the database, thus protecting your data. After configuring the password,
edit the pg_hba.conf file to use MD5 hashing for authentication instead of the default method (ident) for local IPv4 connections. edit the pg_hba.conf file to use MD5 hashing for authentication instead of the default method (ident) for local IPv4 connections.
To replace ident with md5, run the following command: To replace ident with md5, run the following command:
@ -102,7 +104,7 @@ sudo sed -i 's/^host\s\+all\s\+all\s\+127\.0\.0\.1\/32\s\+ident/host all
{:copy-code} {:copy-code}
``` ```
Then run the command that will restart the PostgreSQL service to apply configuration changes, connect to the database as a postgres user, Then run the command that will restart the PostgreSQL service to apply configuration changes, connect to the database as a postgres user,
and create the ThingsBoard Edge database (tb_edge). To connect to the PostgreSQL database, enter the PostgreSQL password. and create the ThingsBoard Edge database (tb_edge). To connect to the PostgreSQL database, enter the PostgreSQL password.
```bash ```bash

12
application/src/main/data/json/edge/instructions/install/ubuntu/instructions.md

@ -1,14 +1,16 @@
Here is the list of commands that can be used to quickly install ThingsBoard Edge on Ubuntu Server and connect to the server. Here is the list of commands that can be used to quickly install ThingsBoard Edge on Ubuntu Server and connect to the server.
#### Step 1. Install Java 17 (OpenJDK) **Note:** OpenJDK 25 requires Ubuntu 22.04 LTS or newer. Earlier versions (20.04 and below) are not supported.
ThingsBoard service is running on Java 17. To install OpenJDK 17, follow these instructions:
#### Step 1. Install Java 25 (OpenJDK)
ThingsBoard service is running on Java 25. To install OpenJDK 25, follow these instructions:
```bash ```bash
sudo apt update && sudo apt install openjdk-17-jdk sudo apt update && sudo apt install openjdk-25-jdk
{:copy-code} {:copy-code}
``` ```
Configure your operating system to use OpenJDK 17 by default. You can configure the default version by running the following command: Configure your operating system to use OpenJDK 25 by default. You can configure the default version by running the following command:
```bash ```bash
sudo update-alternatives --config java sudo update-alternatives --config java
@ -25,7 +27,7 @@ java -version
The expected result is: The expected result is:
```text ```text
openjdk version "17.x.xx" openjdk version "25.x.xx"
OpenJDK Runtime Environment (...) OpenJDK Runtime Environment (...)
OpenJDK 64-Bit Server VM (build ...) OpenJDK 64-Bit Server VM (build ...)
``` ```

2
application/src/main/data/json/system/widget_types/label_widget.json

File diff suppressed because one or more lines are too long

1
application/src/main/data/json/system/widget_types/unread_notifications.json

@ -3,6 +3,7 @@
"name": "Unread notifications", "name": "Unread notifications",
"deprecated": false, "deprecated": false,
"image": "tb-image;/api/images/system/unread_notification_system_widget_image.png", "image": "tb-image;/api/images/system/unread_notification_system_widget_image.png",
"description": "Displays the latest unread notifications and alarms for the current user.",
"descriptor": { "descriptor": {
"type": "static", "type": "static",
"sizeX": 5.5, "sizeX": 5.5,

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

@ -14,69 +14,14 @@
-- limitations under the License. -- limitations under the License.
-- --
-- UPDATE TENANT PROFILE CONFIGURATION START -- CALCULATED FIELD ADDITIONAL INFO ADDITION START
UPDATE tenant_profile ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar;
SET profile_data = jsonb_set(
profile_data,
'{configuration}',
jsonb_build_object(
'minAllowedScheduledUpdateIntervalInSecForCF', 10,
'maxRelationLevelPerCfArgument', 2,
'maxRelatedEntitiesToReturnPerCfArgument', 100,
'minAllowedDeduplicationIntervalInSecForCF', 10,
'minAllowedAggregationIntervalInSecForCF', 60,
'intermediateAggregationIntervalInSecForCF', 300,
'cfReevaluationCheckInterval', 60,
'alarmsReevaluationInterval', 60
)
||
jsonb_strip_nulls(profile_data -> 'configuration')
)
WHERE NOT (
jsonb_strip_nulls(profile_data -> 'configuration') ?& ARRAY[
'minAllowedScheduledUpdateIntervalInSecForCF',
'maxRelationLevelPerCfArgument',
'maxRelatedEntitiesToReturnPerCfArgument',
'minAllowedDeduplicationIntervalInSecForCF',
'minAllowedAggregationIntervalInSecForCF',
'intermediateAggregationIntervalInSecForCF',
'cfReevaluationCheckInterval',
'alarmsReevaluationInterval'
]
);
-- UPDATE TENANT PROFILE CONFIGURATION END -- CALCULATED FIELD ADDITIONAL INFO ADDITION END
-- CALCULATED FIELD UNIQUE CONSTRAINT UPDATE START -- RULE CHAIN NOTES MIGRATION START
ALTER TABLE calculated_field DROP CONSTRAINT IF EXISTS calculated_field_unq_key; ALTER TABLE rule_chain ADD COLUMN IF NOT EXISTS notes varchar(1000000);
ALTER TABLE calculated_field ADD CONSTRAINT calculated_field_unq_key UNIQUE (entity_id, type, name);
-- CALCULATED FIELD UNIQUE CONSTRAINT UPDATE END -- RULE CHAIN NOTES MIGRATION END
-- CALCULATED FIELD OUTPUT STRATEGY UPDATE START
UPDATE calculated_field
SET configuration = jsonb_set(
configuration::jsonb,
'{output}',
(configuration::jsonb -> 'output')
|| jsonb_build_object(
'strategy',
jsonb_build_object(
'type', 'RULE_CHAIN'
)
),
false
)
WHERE (configuration::jsonb -> 'output' -> 'strategy') IS NULL;
-- CALCULATED FIELD OUTPUT STRATEGY UPDATE END
-- REMOVAL OF CALCULATED FIELD LINKS PERSISTENCE START
DROP TABLE IF EXISTS calculated_field_link;
ANALYZE calculated_field;
-- REMOVAL OF CALCULATED FIELD LINKS PERSISTENCE END

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

@ -60,7 +60,8 @@ public class WebSocketConfiguration implements WebSocketConfigurer {
log.error("TbWebSocketHandler expected but [{}] provided", wsHandler); log.error("TbWebSocketHandler expected but [{}] provided", wsHandler);
throw new RuntimeException("TbWebSocketHandler expected but " + wsHandler + " provided"); throw new RuntimeException("TbWebSocketHandler expected but " + wsHandler + " provided");
} }
registry.addHandler(wsHandler, WS_API_MAPPING).setAllowedOriginPatterns("*"); registry.addHandler(wsHandler, WS_API_MAPPING)
.setAllowedOriginPatterns("*");
} }
} }

4
application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java

@ -78,6 +78,9 @@ public class SystemInfoController extends BaseController {
@Value("${debug.settings.default_duration:15}") @Value("${debug.settings.default_duration:15}")
private int defaultDebugDurationMinutes; private int defaultDebugDurationMinutes;
@Value("${sql.query.key-filters-or-conditions.enabled:true}")
private boolean keyFiltersOrConditionsEnabled;
@Value("${sql.entity_data_query_nulls_order_strategy:default}") @Value("${sql.entity_data_query_nulls_order_strategy:default}")
private String nullsOrderStrategy; private String nullsOrderStrategy;
@ -189,6 +192,7 @@ public class SystemInfoController extends BaseController {
systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID)) systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID))
.map(QrCodeSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage) .map(QrCodeSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage)
.orElse(false)); .orElse(false));
systemParams.setAllowKeyFiltersOrConditions(keyFiltersOrConditionsEnabled);
return systemParams; return systemParams;
} }

28
application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java

@ -52,6 +52,7 @@ import org.thingsboard.server.config.WebSocketConfiguration;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider; import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider;
import org.thingsboard.server.service.security.auth.pat.ApiKeyAuthenticationProvider;
import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; import org.thingsboard.server.service.security.exception.JwtExpiredTokenException;
import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.UserPrincipal; import org.thingsboard.server.service.security.model.UserPrincipal;
@ -100,6 +101,8 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
private RateLimitService rateLimitService; private RateLimitService rateLimitService;
@Autowired @Autowired
private JwtAuthenticationProvider authenticationProvider; private JwtAuthenticationProvider authenticationProvider;
@Autowired
private ApiKeyAuthenticationProvider apiKeyAuthenticationProvider;
@Value("${server.ws.send_timeout:5000}") @Value("${server.ws.send_timeout:5000}")
private long sendTimeout; private long sendTimeout;
@ -194,7 +197,11 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
log.trace("{} Authenticating session", sessionRef); log.trace("{} Authenticating session", sessionRef);
SecurityUser securityCtx; SecurityUser securityCtx;
try { try {
securityCtx = authenticationProvider.authenticate(authCmd.getToken()); if (StringUtils.isNotEmpty(authCmd.getApiKey())) {
securityCtx = apiKeyAuthenticationProvider.authenticate(authCmd.getApiKey());
} else {
securityCtx = authenticationProvider.authenticate(authCmd.getToken());
}
} catch (Exception e) { } catch (Exception e) {
close(sessionRef, CloseStatus.BAD_DATA.withReason(e.getMessage())); close(sessionRef, CloseStatus.BAD_DATA.withReason(e.getMessage()));
return; return;
@ -328,9 +335,12 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
} }
SecurityUser securityCtx = null; SecurityUser securityCtx = null;
String token = StringUtils.substringAfter(session.getUri().getQuery(), "token="); String query = session.getUri().getQuery();
if (StringUtils.isNotEmpty(token)) { if (query != null) {
securityCtx = authenticationProvider.authenticate(token); String token = extractQueryParam(query, "token");
if (StringUtils.isNotEmpty(token)) {
securityCtx = authenticationProvider.authenticate(token);
}
} }
return WebSocketSessionRef.builder() return WebSocketSessionRef.builder()
.sessionId(UUID.randomUUID().toString()) .sessionId(UUID.randomUUID().toString())
@ -341,6 +351,15 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
.build(); .build();
} }
private String extractQueryParam(String query, String paramName) {
for (String param : query.split("&")) {
if (param.startsWith(paramName + "=")) {
return param.substring(paramName.length() + 1);
}
}
return null;
}
private SessionMetaData getSessionMd(String internalSessionId) { private SessionMetaData getSessionMd(String internalSessionId) {
SessionMetaData sessionMd = internalSessionMap.get(internalSessionId); SessionMetaData sessionMd = internalSessionMap.get(internalSessionId);
if (sessionMd == null) { if (sessionMd == null) {
@ -482,6 +501,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
} }
} }
} }
} }
@Override @Override

1
application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java

@ -116,7 +116,6 @@ public class ThingsboardInstallService {
entityDatabaseSchemaService.createDatabaseIndexes(); entityDatabaseSchemaService.createDatabaseIndexes();
// TODO: cleanup update code after each release // TODO: cleanup update code after each release
systemDataLoaderService.updateDefaultNotificationConfigs(false);
// Runs upgrade scripts that are not possible in plain SQL. // Runs upgrade scripts that are not possible in plain SQL.
dataUpdateService.updateData(); dataUpdateService.updateData();

2
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java

@ -40,7 +40,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionTyp
import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate;

18
application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java

@ -15,11 +15,14 @@
*/ */
package org.thingsboard.server.service.edge; package org.thingsboard.server.service.edge;
import jakarta.annotation.PreDestroy;
import lombok.Data; import lombok.Data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.cache.limits.RateLimitService;
import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.edge.EdgeEventType;
@ -86,6 +89,8 @@ import java.util.EnumMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
@Lazy @Lazy
@Data @Data
@ -96,14 +101,25 @@ public class EdgeContextComponent {
private final Map<EdgeEventType, EdgeProcessor> processorMap = new EnumMap<>(EdgeEventType.class); private final Map<EdgeEventType, EdgeProcessor> processorMap = new EnumMap<>(EdgeEventType.class);
private ScheduledExecutorService edgeEventProcessingExecutorService;
@Autowired @Autowired
public EdgeContextComponent(List<EdgeProcessor> processors) { public EdgeContextComponent(List<EdgeProcessor> processors,
@Value("${edges.scheduler_pool_size}") int schedulerPoolSize) {
processors.forEach(processor -> { processors.forEach(processor -> {
EdgeEventType eventType = processor.getEdgeEventType(); EdgeEventType eventType = processor.getEdgeEventType();
if (eventType != null) { if (eventType != null) {
processorMap.put(eventType, processor); processorMap.put(eventType, processor);
} }
}); });
this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler");
}
@PreDestroy
private void destroy() {
if (edgeEventProcessingExecutorService != null && !edgeEventProcessingExecutorService.isShutdown()) {
edgeEventProcessingExecutorService.shutdownNow();
}
} }
// services // services

85
application/src/main/java/org/thingsboard/server/service/edge/rpc/DownlinkMessageMapper.java

@ -0,0 +1,85 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.edge.EdgeEventType;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.EdgeVersion;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils;
import org.thingsboard.server.service.edge.rpc.utils.EdgeVersionUtils;
import java.util.ArrayList;
import java.util.List;
@Component
@Slf4j
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class DownlinkMessageMapper {
private final EdgeContextComponent ctx;
public List<DownlinkMsg> convertToDownlinkMsgsPack(EdgeSessionState state, List<EdgeEvent> edgeEvents) {
List<DownlinkMsg> result = new ArrayList<>();
List<EdgeEvent> filtered = EdgeMsgConstructorUtils.mergeAndFilterDownlinkDuplicates(edgeEvents);
for (EdgeEvent edgeEvent : filtered) {
log.trace("[{}][{}] converting edge event to downlink msg [{}]", state.getTenantId(), state.getEdgeId(), edgeEvent);
DownlinkMsg downlinkMsg = null;
try {
switch (edgeEvent.getAction()) {
case UPDATED, ADDED, DELETED, ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE, ALARM_ACK, ALARM_CLEAR,
ALARM_DELETE, CREDENTIALS_UPDATED, RELATION_ADD_OR_UPDATE, RELATION_DELETED, RPC_CALL,
ASSIGNED_TO_CUSTOMER, UNASSIGNED_FROM_CUSTOMER, ADDED_COMMENT, UPDATED_COMMENT, DELETED_COMMENT -> {
downlinkMsg = convertEntityEventToDownlink(state, edgeEvent);
if (downlinkMsg != null && downlinkMsg.getWidgetTypeUpdateMsgCount() > 0) {
log.trace("[{}][{}] widgetTypeUpdateMsg message processed, downlinkMsgId = {}",
state.getTenantId(), state.getEdgeId(), downlinkMsg.getDownlinkMsgId());
} else {
log.trace("[{}][{}] entity message processed [{}]", state.getTenantId(), state.getEdgeId(), downlinkMsg);
}
}
case ATTRIBUTES_UPDATED, POST_ATTRIBUTES, ATTRIBUTES_DELETED, TIMESERIES_UPDATED ->
downlinkMsg = ctx.getTelemetryProcessor().convertTelemetryEventToDownlink(state.getEdge(), edgeEvent);
default -> log.warn("[{}][{}] Unsupported action type [{}]", state.getTenantId(), state.getEdgeId(), edgeEvent.getAction());
}
} catch (Exception e) {
log.trace("[{}][{}] Exception during converting edge event to downlink msg", state.getTenantId(), state.getEdgeId(), e);
}
if (downlinkMsg != null) {
result.add(downlinkMsg);
}
}
return result;
}
protected DownlinkMsg convertEntityEventToDownlink(EdgeSessionState state, EdgeEvent edgeEvent) {
log.trace("[{}] Executing convertEntityEventToDownlink, edgeEvent [{}], action [{}]", edgeEvent.getTenantId(), edgeEvent, edgeEvent.getAction());
if ((EdgeEventType.OAUTH2_CLIENT.equals(edgeEvent.getType()) || EdgeEventType.DOMAIN.equals(edgeEvent.getType())) &&
(EdgeVersionUtils.isEdgeVersionOlderThan(state.getEdgeVersion(), EdgeVersion.V_3_8_0))) {
return null;
}
return ctx.getProcessor(edgeEvent.getType()).convertEdgeEventToDownlink(edgeEvent, state.getEdgeVersion());
}
}

712
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java

@ -1,712 +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.service.edge.rpc;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import io.grpc.Server;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder;
import io.grpc.stub.StreamObserver;
import jakarta.annotation.Nullable;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.rule.engine.api.AttributesSaveRequest;
import org.thingsboard.rule.engine.api.TimeseriesSaveRequest;
import org.thingsboard.server.cache.TbTransactionalCache;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.notification.rule.trigger.EdgeConnectionTrigger;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgDataType;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg;
import org.thingsboard.server.common.msg.edge.EdgeHighPriorityMsg;
import org.thingsboard.server.common.msg.edge.EdgeSessionMsg;
import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse;
import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest;
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
import org.thingsboard.server.queue.discovery.TopicService;
import org.thingsboard.server.queue.kafka.KafkaAdmin;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
import java.util.function.Function;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.ACTIVITY_STATE;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_CONNECT_TIME;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_DISCONNECT_TIME;
@Service
@Slf4j
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService {
private final ConcurrentMap<EdgeId, EdgeGrpcSession> sessions = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, EdgeGrpcSession> sessionsById = new ConcurrentHashMap<>();
private final ConcurrentMap<EdgeId, Lock> sessionNewEventsLocks = new ConcurrentHashMap<>();
private final Map<EdgeId, Boolean> sessionNewEvents = new HashMap<>();
private final ConcurrentMap<EdgeId, ScheduledFuture<?>> sessionEdgeEventChecks = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, Consumer<FromEdgeSyncResponse>> localSyncEdgeRequests = new ConcurrentHashMap<>();
private final ConcurrentMap<EdgeId, Boolean> edgeEventsMigrationProcessed = new ConcurrentHashMap<>();
private final List<EdgeGrpcSession> zombieSessions = new ArrayList<>();
@Value("${edges.rpc.port}")
private int rpcPort;
@Value("${edges.rpc.ssl.enabled}")
private boolean sslEnabled;
@Value("${edges.rpc.ssl.cert}")
private String certFileResource;
@Value("${edges.rpc.ssl.private_key}")
private String privateKeyResource;
@Value("${edges.rpc.ssl.key_password:}")
private String keyPassword;
@Value("${edges.state.persistToTelemetry:false}")
private boolean persistToTelemetry;
@Value("${edges.rpc.client_max_keep_alive_time_sec:1}")
private int clientMaxKeepAliveTimeSec;
@Value("${edges.rpc.max_inbound_message_size:4194304}")
private int maxInboundMessageSize;
@Value("${edges.rpc.keep_alive_time_sec:10}")
private int keepAliveTimeSec;
@Value("${edges.rpc.keep_alive_timeout_sec:5}")
private int keepAliveTimeoutSec;
@Value("${edges.scheduler_pool_size}")
private int schedulerPoolSize;
@Value("${edges.send_scheduler_pool_size}")
private int sendSchedulerPoolSize;
@Value("${edges.max_high_priority_queue_size_per_session:10000}")
private int maxHighPriorityQueueSizePerSession;
@Autowired
@Lazy
private EdgeContextComponent ctx;
@Autowired
private TbClusterService clusterService;
@Autowired
private TbServiceInfoProvider serviceInfoProvider;
@Autowired
private TbTransactionalCache<EdgeId, String> edgeIdServiceIdCache;
@Autowired
private TopicService topicService;
@Autowired
private TbCoreQueueFactory tbCoreQueueFactory;
@Autowired
private Optional<KafkaAdmin> kafkaAdmin;
private Server server;
private ScheduledExecutorService edgeEventProcessingExecutorService;
private ScheduledExecutorService sendDownlinkExecutorService;
private ScheduledExecutorService executorService;
@AfterStartUp(order = AfterStartUp.REGULAR_SERVICE)
public void onStartUp() {
log.info("Initializing Edge RPC service!");
NettyServerBuilder builder = NettyServerBuilder.forPort(rpcPort)
.permitKeepAliveTime(clientMaxKeepAliveTimeSec, TimeUnit.SECONDS)
.keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS)
.keepAliveTimeout(keepAliveTimeoutSec, TimeUnit.SECONDS)
.permitKeepAliveWithoutCalls(true)
.maxInboundMessageSize(maxInboundMessageSize)
.addService(this);
if (sslEnabled) {
try {
setupSsl(builder);
} catch (Exception e) {
log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e);
throw new RuntimeException("Unable to set up SSL context!", e);
}
}
server = builder.build();
log.info("Going to start Edge RPC server using port: {}", rpcPort);
try {
server.start();
} catch (IOException e) {
log.error("Failed to start Edge RPC server!", e);
throw new RuntimeException("Failed to start Edge RPC server!");
}
this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler");
this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler");
this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service");
this.executorService.scheduleAtFixedRate(this::cleanupZombieSessions, 60, 60, TimeUnit.SECONDS);
log.info("Edge RPC service initialized!");
}
/**
* Configures TLS for the Edge gRPC server.
* <p>
* Delegates PEM parsing and key management to {@link PemSslCredentials} — the same
* class used by MQTT, CoAP, and LwM2M transports — which supports:
* <ul>
* <li>Separate certificate and private key files (classic two-file setup)</li>
* <li>Combined PEM: certificate chain + private key in a single {@code cert} file
* ({@code private_key} left empty)</li>
* <li>Encrypted private keys (password supplied via {@code key_password})</li>
* </ul>
* Path resolution (for both {@code cert} and {@code private_key}) is handled by
* {@link org.thingsboard.server.common.data.ResourceUtils#getInputStream ResourceUtils}:
* absolute path → relative / working-dir → classpath → {@code classpath:} prefix.
*/
void setupSsl(NettyServerBuilder builder) throws Exception {
PemSslCredentials credentials = new PemSslCredentials();
credentials.setCertFile(certFileResource);
credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource);
credentials.setKeyPassword(keyPassword);
credentials.init(false);
SslContext sslContext = GrpcSslContexts.configure(
SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build();
builder.sslContext(sslContext);
}
@PreDestroy
public void destroy() {
if (server != null) {
server.shutdownNow();
}
for (Map.Entry<EdgeId, ScheduledFuture<?>> entry : sessionEdgeEventChecks.entrySet()) {
EdgeId edgeId = entry.getKey();
ScheduledFuture<?> sessionEdgeEventCheck = entry.getValue();
if (sessionEdgeEventCheck != null && !sessionEdgeEventCheck.isCancelled() && !sessionEdgeEventCheck.isDone()) {
sessionEdgeEventCheck.cancel(true);
sessionEdgeEventChecks.remove(edgeId);
}
}
if (edgeEventProcessingExecutorService != null) {
edgeEventProcessingExecutorService.shutdownNow();
}
if (sendDownlinkExecutorService != null) {
sendDownlinkExecutorService.shutdownNow();
}
if (executorService != null) {
executorService.shutdownNow();
}
}
@Override
public StreamObserver<RequestMsg> handleMsgs(StreamObserver<ResponseMsg> outputStream) {
EdgeGrpcSession session = createEdgeGrpcSession(outputStream);
return session.getInputStream();
}
private EdgeGrpcSession createEdgeGrpcSession(StreamObserver<ResponseMsg> outputStream) {
return kafkaAdmin.isPresent()
? new KafkaEdgeGrpcSession(ctx, topicService, tbCoreQueueFactory, kafkaAdmin.get(), outputStream, this::onEdgeConnect, this::onEdgeDisconnect,
sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession)
: new PostgresEdgeGrpcSession(ctx, outputStream, this::onEdgeConnect, this::onEdgeDisconnect,
sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession);
}
@Override
public void onToEdgeSessionMsg(TenantId tenantId, EdgeSessionMsg msg) {
switch (msg.getMsgType()) {
case EDGE_HIGH_PRIORITY_TO_EDGE_SESSION_MSG -> {
EdgeHighPriorityMsg edgeHighPriorityMsg = (EdgeHighPriorityMsg) msg;
log.trace("[{}] edgeEventMsg [{}]", tenantId, msg);
onEdgeHighPriorityEvent(edgeHighPriorityMsg);
}
case EDGE_EVENT_UPDATE_TO_EDGE_SESSION_MSG -> {
EdgeEventUpdateMsg edgeEventUpdateMsg = (EdgeEventUpdateMsg) msg;
log.trace("[{}] onToEdgeEventUpdateMsg [{}]", tenantId, msg);
onEdgeEventUpdate(tenantId, edgeEventUpdateMsg.getEdgeId());
}
case EDGE_SYNC_REQUEST_TO_EDGE_SESSION_MSG -> {
ToEdgeSyncRequest toEdgeSyncRequest = (ToEdgeSyncRequest) msg;
log.trace("[{}] toEdgeSyncRequest [{}]", tenantId, msg);
startSyncProcess(tenantId, toEdgeSyncRequest.getEdgeId(), toEdgeSyncRequest.getId(), toEdgeSyncRequest.getServiceId());
}
case EDGE_SYNC_RESPONSE_FROM_EDGE_SESSION_MSG -> {
FromEdgeSyncResponse fromEdgeSyncResponse = (FromEdgeSyncResponse) msg;
log.trace("[{}] fromEdgeSyncResponse [{}]", tenantId, msg);
processSyncResponse(fromEdgeSyncResponse);
}
}
}
@Override
public void updateEdge(TenantId tenantId, Edge edge) {
if (edge == null) {
log.warn("[{}] Edge is null - edge is removed and outdated notification is in process!", tenantId);
return;
}
EdgeGrpcSession session = sessions.get(edge.getId());
if (session != null && session.isConnected()) {
log.debug("[{}] Updating configuration for edge [{}] [{}]", tenantId, edge.getName(), edge.getId());
session.onConfigurationUpdate(edge);
} else {
log.debug("[{}] Session doesn't exist for edge [{}] [{}]", tenantId, edge.getName(), edge.getId());
}
}
@Override
public void deleteEdge(TenantId tenantId, EdgeId edgeId) {
EdgeGrpcSession session = sessions.get(edgeId);
if (session != null && session.isConnected()) {
log.info("[{}] Closing and removing session for edge [{}]", tenantId, edgeId);
destroySession(session);
session.cleanUp();
sessions.remove(edgeId);
sessionsById.remove(session.getSessionId());
final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock());
newEventLock.lock();
try {
sessionNewEvents.remove(edgeId);
} finally {
newEventLock.unlock();
}
cancelScheduleEdgeEventsCheck(edgeId);
}
}
private void onEdgeEventUpdate(TenantId tenantId, EdgeId edgeId) {
EdgeGrpcSession session = sessions.get(edgeId);
if (session != null && session.isConnected()) {
log.trace("[{}] onEdgeEventUpdate [{}]", tenantId, edgeId.getId());
updateSessionEventsFlag(tenantId, edgeId);
}
}
private void onEdgeHighPriorityEvent(EdgeHighPriorityMsg msg) {
TenantId tenantId = msg.getTenantId();
EdgeEvent edgeEvent = msg.getEdgeEvent();
EdgeId edgeId = edgeEvent.getEdgeId();
EdgeGrpcSession session = sessions.get(edgeId);
if (session != null && session.isConnected()) {
log.trace("[{}] onEdgeEvent [{}]", tenantId, edgeId);
session.addEventToHighPriorityQueue(edgeEvent);
updateSessionEventsFlag(tenantId, edgeId);
}
}
private void updateSessionEventsFlag(TenantId tenantId, EdgeId edgeId) {
final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock());
newEventLock.lock();
try {
if (Boolean.FALSE.equals(sessionNewEvents.get(edgeId))) {
log.trace("[{}] set session new events flag to true [{}]", tenantId, edgeId.getId());
sessionNewEvents.put(edgeId, true);
}
} finally {
newEventLock.unlock();
}
}
private void onEdgeConnect(EdgeId edgeId, EdgeGrpcSession edgeGrpcSession) {
Edge edge = edgeGrpcSession.getEdge();
TenantId tenantId = edge.getTenantId();
log.info("[{}][{}] edge [{}] connected successfully.", tenantId, edgeGrpcSession.getSessionId(), edgeId);
if (sessions.containsKey(edgeId)) {
EdgeGrpcSession existing = sessions.get(edgeId);
if (existing != null) {
log.info("[{}][{}] Replacing existing session [{}] for edge [{}]", tenantId, edgeGrpcSession.getSessionId(), existing.getSessionId(), edgeId);
destroySession(existing);
sessionsById.remove(existing.getSessionId());
}
}
sessions.put(edgeId, edgeGrpcSession);
sessionsById.put(edgeGrpcSession.getSessionId(), edgeGrpcSession);
final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock());
newEventLock.lock();
try {
sessionNewEvents.put(edgeId, true);
} finally {
newEventLock.unlock();
}
save(tenantId, edgeId, ACTIVITY_STATE, true);
long lastConnectTs = System.currentTimeMillis();
save(tenantId, edgeId, LAST_CONNECT_TIME, lastConnectTs);
edgeIdServiceIdCache.put(edgeId, serviceInfoProvider.getServiceId());
pushRuleEngineMessage(tenantId, edge, lastConnectTs, TbMsgType.CONNECT_EVENT);
cancelScheduleEdgeEventsCheck(edgeId);
edgeEventsMigrationProcessed.putIfAbsent(edgeId, Boolean.FALSE);
scheduleEdgeEventsCheck(edgeGrpcSession);
}
private void startSyncProcess(TenantId tenantId, EdgeId edgeId, UUID requestId, String requestServiceId) {
EdgeGrpcSession session = sessions.get(edgeId);
if (session != null) {
if (session.isSyncInProgress()) {
clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, false, "Sync process is active at the moment"), requestServiceId);
} else {
boolean success = false;
if (session.isConnected()) {
session.startSyncProcess(true);
success = true;
}
clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, success, ""), requestServiceId);
}
}
}
@Override
public void processSyncRequest(TenantId tenantId, EdgeId edgeId, Consumer<FromEdgeSyncResponse> responseConsumer) {
ToEdgeSyncRequest request = new ToEdgeSyncRequest(UUID.randomUUID(), tenantId, edgeId, serviceInfoProvider.getServiceId());
UUID requestId = request.getId();
EdgeGrpcSession session = sessions.get(request.getEdgeId());
if (session != null && session.isSyncInProgress()) {
responseConsumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Sync process is active at the moment"));
} else {
log.trace("[{}][{}] Processing sync edge request [{}], serviceId [{}]", request.getTenantId(), request.getId(), request.getEdgeId(), request.getServiceId());
localSyncEdgeRequests.put(requestId, responseConsumer);
clusterService.pushEdgeSyncRequestToEdge(request);
scheduleSyncRequestTimeout(request, requestId);
}
}
private void scheduleSyncRequestTimeout(ToEdgeSyncRequest request, UUID requestId) {
log.trace("[{}] scheduling sync edge request", requestId);
executorService.schedule(() -> {
log.trace("[{}] checking if sync edge request is not processed...", requestId);
Consumer<FromEdgeSyncResponse> consumer = localSyncEdgeRequests.remove(requestId);
if (consumer != null) {
log.trace("[{}] timeout for processing sync edge request.", requestId);
consumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Edge is not connected"));
}
}, 20, TimeUnit.SECONDS);
}
private void processSyncResponse(FromEdgeSyncResponse response) {
log.trace("[{}] Received response from sync service: [{}]", response.getId(), response);
UUID requestId = response.getId();
Consumer<FromEdgeSyncResponse> consumer = localSyncEdgeRequests.remove(requestId);
if (consumer != null) {
consumer.accept(response);
} else {
log.trace("[{}] Unknown or stale sync response received [{}]", requestId, response);
}
}
private void scheduleEdgeEventsCheck(EdgeGrpcSession session) {
EdgeId edgeId = session.getEdge().getId();
TenantId tenantId = session.getEdge().getTenantId();
cancelScheduleEdgeEventsCheck(edgeId);
if (sessions.containsKey(edgeId)) {
ScheduledFuture<?> edgeEventCheckTask = edgeEventProcessingExecutorService.schedule(() -> {
try {
final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock());
newEventLock.lock();
try {
if (Boolean.TRUE.equals(sessionNewEvents.get(edgeId))) {
log.trace("[{}][{}] set session new events flag to false", tenantId, edgeId.getId());
sessionNewEvents.put(edgeId, false);
session.processHighPriorityEvents();
processEdgeEventMigrationIfNeeded(session, edgeId);
if (Boolean.TRUE.equals(edgeEventsMigrationProcessed.get(edgeId))) {
Futures.addCallback(session.processEdgeEvents(), new FutureCallback<>() {
@Override
public void onSuccess(Boolean newEventsAdded) {
if (Boolean.TRUE.equals(newEventsAdded)) {
log.trace("[{}][{}] new events added. set session new events flag to true", tenantId, edgeId.getId());
sessionNewEvents.put(edgeId, true);
}
scheduleEdgeEventsCheck(session);
}
@Override
public void onFailure(Throwable t) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, session.getEdge().getId().getId(), t);
scheduleEdgeEventsCheck(session);
}
}, ctx.getGrpcCallbackExecutorService());
} else {
scheduleEdgeEventsCheck(session);
}
} else {
scheduleEdgeEventsCheck(session);
}
} finally {
newEventLock.unlock();
}
} catch (Exception e) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, session.getEdge().getId().getId(), e);
}
}, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS);
sessionEdgeEventChecks.put(edgeId, edgeEventCheckTask);
log.trace("[{}] Check edge event scheduled for edge [{}]", tenantId, edgeId.getId());
} else {
log.debug("[{}] Session was removed and edge event check schedule must not be started [{}]",
tenantId, edgeId.getId());
}
}
private void processEdgeEventMigrationIfNeeded(EdgeGrpcSession session, EdgeId edgeId) throws Exception {
boolean isMigrationProcessed = edgeEventsMigrationProcessed.getOrDefault(edgeId, Boolean.FALSE);
if (!isMigrationProcessed) {
log.info("Starting edge event migration for edge [{}]", edgeId.getId());
Boolean eventsExist = session.migrateEdgeEvents().get();
if (Boolean.TRUE.equals(eventsExist)) {
log.info("Migration still in progress for edge [{}]", edgeId.getId());
sessionNewEvents.put(edgeId, true);
scheduleEdgeEventsCheck(session);
} else if (Boolean.FALSE.equals(eventsExist)) {
log.info("Migration completed for edge [{}]", edgeId.getId());
edgeEventsMigrationProcessed.put(edgeId, true);
}
}
}
private void cancelScheduleEdgeEventsCheck(EdgeId edgeId) {
log.trace("[{}] cancelling edge event check for edge", edgeId);
if (sessionEdgeEventChecks.containsKey(edgeId)) {
ScheduledFuture<?> sessionEdgeEventCheck = sessionEdgeEventChecks.get(edgeId);
if (sessionEdgeEventCheck != null && !sessionEdgeEventCheck.isCancelled() && !sessionEdgeEventCheck.isDone()) {
sessionEdgeEventCheck.cancel(true);
sessionEdgeEventChecks.remove(edgeId);
}
}
}
private void onEdgeDisconnect(Edge edge, UUID sessionId) {
EdgeId edgeId = edge.getId();
log.info("[{}][{}] edge disconnected!", edgeId, sessionId);
EdgeGrpcSession current = sessions.get(edgeId);
if (current != null && current.getSessionId().equals(sessionId)) {
EdgeGrpcSession toRemove = sessions.remove(edgeId);
final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock());
newEventLock.lock();
try {
sessionNewEvents.remove(edgeId);
} finally {
newEventLock.unlock();
}
destroySession(toRemove);
sessionsById.remove(sessionId);
TenantId tenantId = toRemove.getEdge().getTenantId();
save(tenantId, edgeId, ACTIVITY_STATE, false);
long lastDisconnectTs = System.currentTimeMillis();
save(tenantId, edgeId, LAST_DISCONNECT_TIME, lastDisconnectTs);
pushRuleEngineMessage(toRemove.getEdge().getTenantId(), edge, lastDisconnectTs, TbMsgType.DISCONNECT_EVENT);
cancelScheduleEdgeEventsCheck(edgeId);
} else {
log.info("[{}] edge session [{}] is not current anymore. Attempting to destroy it by sessionId.", edgeId, sessionId);
EdgeGrpcSession stale = sessionsById.remove(sessionId);
if (stale != null) {
try {
destroySession(stale);
log.info("[{}][{}] Successfully destroyed stale session for edge [{}]", stale.getTenantId(), sessionId, edgeId);
} catch (Exception e) {
log.warn("[{}][{}] Failed to destroy stale session for edge [{}]", stale.getTenantId(), sessionId, edgeId, e);
}
} else {
log.debug("[{}] No session found by sessionId [{}] to destroy", edgeId, sessionId);
}
}
edgeIdServiceIdCache.evict(edgeId);
}
private void destroySession(EdgeGrpcSession session) {
try (session) {
if (!session.destroy()) {
log.warn("[{}][{}] Session destroy failed for edge [{}] with session id [{}]. Adding to zombie queue for later cleanup.",
session.getTenantId(), session.getEdge().getId(), session.getEdge().getName(), session.getSessionId());
zombieSessions.add(session);
}
} catch (Exception e) {
log.warn("[{}][{}] Exception during session destroy for edge [{}] with session id [{}]",
session.getTenantId(), session.getEdge().getId(), session.getEdge().getName(), session.getSessionId(), e);
}
}
private void save(TenantId tenantId, EdgeId edgeId, String key, long value) {
log.debug("[{}][{}] Updating long edge telemetry [{}] [{}]", tenantId, edgeId, key, value);
if (persistToTelemetry) {
ctx.getTsSubService().saveTimeseries(TimeseriesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.entry(new LongDataEntry(key, value))
.callback(new AttributeSaveCallback(tenantId, edgeId, key, value))
.build());
} else {
ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(new LongDataEntry(key, value))
.callback(new AttributeSaveCallback(tenantId, edgeId, key, value))
.build());
}
}
private void save(TenantId tenantId, EdgeId edgeId, String key, boolean value) {
log.debug("[{}][{}] Updating boolean edge telemetry [{}] [{}]", tenantId, edgeId, key, value);
if (persistToTelemetry) {
ctx.getTsSubService().saveTimeseries(TimeseriesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.entry(new BooleanDataEntry(key, value))
.callback(new AttributeSaveCallback(tenantId, edgeId, key, value))
.build());
} else {
ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(new BooleanDataEntry(key, value))
.callback(new AttributeSaveCallback(tenantId, edgeId, key, value))
.build());
}
}
private void pushRuleEngineMessage(TenantId tenantId, Edge edge, long ts, TbMsgType msgType) {
try {
EdgeId edgeId = edge.getId();
ObjectNode edgeState = JacksonUtil.newObjectNode();
boolean isConnected = TbMsgType.CONNECT_EVENT.equals(msgType);
if (isConnected) {
edgeState.put(ACTIVITY_STATE, true);
edgeState.put(LAST_CONNECT_TIME, ts);
} else {
edgeState.put(ACTIVITY_STATE, false);
edgeState.put(LAST_DISCONNECT_TIME, ts);
}
ctx.getRuleProcessor().process(EdgeConnectionTrigger.builder()
.tenantId(tenantId)
.customerId(edge.getCustomerId())
.edgeId(edgeId)
.edgeName(edge.getName())
.connected(isConnected).build());
String data = JacksonUtil.toString(edgeState);
TbMsgMetaData md = new TbMsgMetaData();
if (!persistToTelemetry) {
md.putValue(DataConstants.SCOPE, DataConstants.SERVER_SCOPE);
md.putValue("edgeName", edge.getName());
md.putValue("edgeType", edge.getType());
}
TbMsg tbMsg = TbMsg.newMsg()
.type(msgType)
.originator(edgeId)
.copyMetaData(md)
.dataType(TbMsgDataType.JSON)
.data(data)
.build();
clusterService.pushMsgToRuleEngine(tenantId, edgeId, tbMsg, null);
} catch (Exception e) {
log.warn("[{}][{}] Failed to push {}", tenantId, edge.getId(), msgType, e);
}
}
private void cleanupZombieSessions() {
try {
tryToDestroyZombieSessions(getZombieSessions(sessions.values()), s -> sessions.remove(s.getEdge().getId()));
tryToDestroyZombieSessions(getZombieSessions(sessionsById.values()), s -> sessionsById.remove(s.getSessionId()));
zombieSessions.removeIf(zombie -> {
if (zombie.destroy()) {
log.info("[{}][{}] Successfully cleaned up zombie session [{}] for edge [{}].",
zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName());
return true;
} else {
log.warn("[{}][{}] Failed to remove zombie session [{}] for edge [{}].",
zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName());
return false;
}
});
} catch (Exception e) {
log.warn("Failed to cleanup kafka sessions", e);
}
}
private List<EdgeGrpcSession> getZombieSessions(Collection<EdgeGrpcSession> sessions) {
List<EdgeGrpcSession> result = new ArrayList<>();
for (EdgeGrpcSession session : sessions) {
if (isKafkaSessionAndZombie(session)) {
result.add(session);
}
}
return result;
}
private void tryToDestroyZombieSessions(List<EdgeGrpcSession> sessionsToRemove, Function<EdgeGrpcSession, EdgeGrpcSession> removeFunc) {
for (EdgeGrpcSession toRemove : sessionsToRemove) {
log.info("[{}] Destroying session for edge because edge is not connected", toRemove.getEdge().getId());
if (toRemove.destroy()) {
removeFunc.apply(toRemove);
}
}
}
private boolean isKafkaSessionAndZombie(EdgeGrpcSession session) {
if (session instanceof KafkaEdgeGrpcSession kafkaSession) {
log.debug("[{}] kafkaSession.isConnected() = {}, kafkaSession.getConsumer().getConsumer().isStopped() = {}",
kafkaSession.getEdge().getId(),
kafkaSession.isConnected(),
kafkaSession.getConsumer() != null ? kafkaSession.getConsumer().getConsumer() != null ? kafkaSession.getConsumer().getConsumer().isStopped() : null : null);
return !kafkaSession.isConnected() &&
kafkaSession.getConsumer() != null &&
kafkaSession.getConsumer().getConsumer() != null &&
!kafkaSession.getConsumer().getConsumer().isStopped();
}
return false;
}
}

1029
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java

File diff suppressed because it is too large

1
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java

@ -20,7 +20,6 @@ import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.edge.EdgeSessionMsg; import org.thingsboard.server.common.msg.edge.EdgeSessionMsg;
import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse; import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse;
import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest;
import java.util.function.Consumer; import java.util.function.Consumer;

57
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java

@ -16,19 +16,70 @@
package org.thingsboard.server.service.edge.rpc; package org.thingsboard.server.service.edge.rpc;
import com.google.common.util.concurrent.SettableFuture; import com.google.common.util.concurrent.SettableFuture;
import lombok.Data; import lombok.Getter;
import lombok.Setter;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.EdgeVersion;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Data @Getter
public class EdgeSessionState { public class EdgeSessionState {
private final Map<Integer, DownlinkMsg> pendingMsgsMap = Collections.synchronizedMap(new LinkedHashMap<>()); @Setter
private SettableFuture<Boolean> sendDownlinkMsgsFuture; private SettableFuture<Boolean> sendDownlinkMsgsFuture;
@Setter
private ScheduledFuture<?> scheduledSendDownlinkTask; private ScheduledFuture<?> scheduledSendDownlinkTask;
@Setter
private volatile boolean connected;
@Setter
private EdgeVersion edgeVersion;
private final UUID sessionId = UUID.randomUUID();
private final Map<Integer, DownlinkMsg> pendingMsgsMap = Collections.synchronizedMap(new LinkedHashMap<>());
private final Lock sequenceDependencyLock = new ReentrantLock();
private final AtomicBoolean syncInProgress = new AtomicBoolean(false);
private TenantId tenantId;
private Edge edge;
public void setEdge(Edge edge) {
if (edge == null) {
return;
}
this.edge = edge;
this.tenantId = edge.getTenantId();
}
public EdgeId getEdgeId() {
return edge != null ? edge.getId() : null;
}
public boolean tryStartSync() {
return syncInProgress.compareAndSet(false, true);
}
public void finishSync() {
syncInProgress.set(false);
}
public boolean isSyncInProgress() {
return syncInProgress.get();
}
public void lockSequenceDependency() {
sequenceDependencyLock.lock();
}
public void unlockSequenceDependency() {
sequenceDependencyLock.unlock();
}
} }

238
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeUplinkMessageDispatcher.java

@ -0,0 +1,238 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.notification.rule.trigger.EdgeCommunicationFailureTrigger;
import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg;
import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AttributesRequestMsg;
import org.thingsboard.server.gen.edge.v1.CalculatedFieldRequestMsg;
import org.thingsboard.server.gen.edge.v1.CalculatedFieldUpdateMsg;
import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg;
import org.thingsboard.server.gen.edge.v1.DeviceCredentialsRequestMsg;
import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg;
import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg;
import org.thingsboard.server.gen.edge.v1.DeviceRpcCallMsg;
import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg;
import org.thingsboard.server.gen.edge.v1.EntityDataProto;
import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg;
import org.thingsboard.server.gen.edge.v1.EntityViewsRequestMsg;
import org.thingsboard.server.gen.edge.v1.RelationRequestMsg;
import org.thingsboard.server.gen.edge.v1.RelationUpdateMsg;
import org.thingsboard.server.gen.edge.v1.ResourceUpdateMsg;
import org.thingsboard.server.gen.edge.v1.RuleChainMetadataRequestMsg;
import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg;
import org.thingsboard.server.gen.edge.v1.RuleChainUpdateMsg;
import org.thingsboard.server.gen.edge.v1.UplinkMsg;
import org.thingsboard.server.gen.edge.v1.UserCredentialsRequestMsg;
import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg;
import org.thingsboard.server.gen.edge.v1.UserUpdateMsg;
import org.thingsboard.server.gen.edge.v1.WidgetBundleTypesRequestMsg;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import java.util.ArrayList;
import java.util.List;
@Service
@Slf4j
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class EdgeUplinkMessageDispatcher {
private final EdgeContextComponent ctx;
public ListenableFuture<List<Void>> processUplinkMsg(EdgeSessionState state, UplinkMsg uplinkMsg) {
Edge edge = state.getEdge();
List<ListenableFuture<Void>> result = new ArrayList<>();
try {
if (uplinkMsg.getDeviceProfileUpdateMsgCount() > 0) {
for (DeviceProfileUpdateMsg deviceProfileUpdateMsg : uplinkMsg.getDeviceProfileUpdateMsgList()) {
result.add(ctx.getDeviceProfileProcessor().processDeviceProfileMsgFromEdge(edge.getTenantId(), edge, deviceProfileUpdateMsg));
}
}
if (uplinkMsg.getDeviceUpdateMsgCount() > 0) {
for (DeviceUpdateMsg deviceUpdateMsg : uplinkMsg.getDeviceUpdateMsgList()) {
result.add(ctx.getDeviceProcessor().processDeviceMsgFromEdge(edge.getTenantId(), edge, deviceUpdateMsg));
}
}
if (uplinkMsg.getDeviceCredentialsUpdateMsgCount() > 0) {
for (DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg : uplinkMsg.getDeviceCredentialsUpdateMsgList()) {
result.add(ctx.getDeviceProcessor().processDeviceCredentialsMsgFromEdge(edge.getTenantId(), edge.getId(), deviceCredentialsUpdateMsg));
}
}
if (uplinkMsg.getAssetProfileUpdateMsgCount() > 0) {
for (AssetProfileUpdateMsg assetProfileUpdateMsg : uplinkMsg.getAssetProfileUpdateMsgList()) {
result.add(ctx.getAssetProfileProcessor().processAssetProfileMsgFromEdge(edge.getTenantId(), edge, assetProfileUpdateMsg));
}
}
if (uplinkMsg.getAssetUpdateMsgCount() > 0) {
for (AssetUpdateMsg assetUpdateMsg : uplinkMsg.getAssetUpdateMsgList()) {
result.add(ctx.getAssetProcessor().processAssetMsgFromEdge(edge.getTenantId(), edge, assetUpdateMsg));
}
}
if (uplinkMsg.getRuleChainUpdateMsgCount() > 0) {
for (RuleChainUpdateMsg ruleChainUpdateMsg : uplinkMsg.getRuleChainUpdateMsgList()) {
result.add(ctx.getRuleChainProcessor().processRuleChainMsgFromEdge(edge.getTenantId(), edge, ruleChainUpdateMsg));
}
}
if (uplinkMsg.getRuleChainMetadataUpdateMsgCount() > 0) {
for (RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg : uplinkMsg.getRuleChainMetadataUpdateMsgList()) {
result.add(ctx.getRuleChainProcessor().processRuleChainMetadataMsgFromEdge(edge.getTenantId(), edge, ruleChainMetadataUpdateMsg));
}
}
if (uplinkMsg.getEntityViewUpdateMsgCount() > 0) {
for (EntityViewUpdateMsg entityViewUpdateMsg : uplinkMsg.getEntityViewUpdateMsgList()) {
result.add(ctx.getEntityViewProcessor().processEntityViewMsgFromEdge(edge.getTenantId(), edge, entityViewUpdateMsg));
}
}
if (uplinkMsg.getEntityDataCount() > 0) {
for (EntityDataProto entityData : uplinkMsg.getEntityDataList()) {
result.addAll(ctx.getTelemetryProcessor().processTelemetryMsg(edge.getTenantId(), entityData));
}
}
if (uplinkMsg.getAlarmUpdateMsgCount() > 0) {
for (AlarmUpdateMsg alarmUpdateMsg : uplinkMsg.getAlarmUpdateMsgList()) {
result.add(ctx.getAlarmProcessor().processAlarmMsgFromEdge(edge.getTenantId(), edge.getId(), alarmUpdateMsg));
}
}
if (uplinkMsg.getAlarmCommentUpdateMsgCount() > 0) {
for (AlarmCommentUpdateMsg alarmCommentUpdateMsg : uplinkMsg.getAlarmCommentUpdateMsgList()) {
result.add(ctx.getAlarmCommentProcessor().processAlarmCommentMsgFromEdge(edge.getTenantId(), edge.getId(), alarmCommentUpdateMsg));
}
}
if (uplinkMsg.getRelationUpdateMsgCount() > 0) {
for (RelationUpdateMsg relationUpdateMsg : uplinkMsg.getRelationUpdateMsgList()) {
result.add(ctx.getRelationProcessor().processRelationMsgFromEdge(edge.getTenantId(), edge, relationUpdateMsg));
}
}
if (uplinkMsg.getDashboardUpdateMsgCount() > 0) {
for (DashboardUpdateMsg dashboardUpdateMsg : uplinkMsg.getDashboardUpdateMsgList()) {
result.add(ctx.getDashboardProcessor().processDashboardMsgFromEdge(edge.getTenantId(), edge, dashboardUpdateMsg));
}
}
if (uplinkMsg.getResourceUpdateMsgCount() > 0) {
for (ResourceUpdateMsg resourceUpdateMsg : uplinkMsg.getResourceUpdateMsgList()) {
result.add(ctx.getResourceProcessor().processResourceMsgFromEdge(edge.getTenantId(), edge, resourceUpdateMsg));
}
}
if (uplinkMsg.getRuleChainMetadataRequestMsgCount() > 0) {
for (RuleChainMetadataRequestMsg ruleChainMetadataRequestMsg : uplinkMsg.getRuleChainMetadataRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processRuleChainMetadataRequestMsg(edge.getTenantId(), edge, ruleChainMetadataRequestMsg));
}
}
if (uplinkMsg.getAttributesRequestMsgCount() > 0) {
for (AttributesRequestMsg attributesRequestMsg : uplinkMsg.getAttributesRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processAttributesRequestMsg(edge.getTenantId(), edge, attributesRequestMsg));
}
}
if (uplinkMsg.getRelationRequestMsgCount() > 0) {
for (RelationRequestMsg relationRequestMsg : uplinkMsg.getRelationRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processRelationRequestMsg(edge.getTenantId(), edge, relationRequestMsg));
}
}
if (uplinkMsg.getCalculatedFieldRequestMsgCount() > 0) {
for (CalculatedFieldRequestMsg calculatedFieldRequestMsg : uplinkMsg.getCalculatedFieldRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processCalculatedFieldRequestMsg(edge.getTenantId(), edge, calculatedFieldRequestMsg));
}
}
if (uplinkMsg.getUserCredentialsRequestMsgCount() > 0) {
for (UserCredentialsRequestMsg userCredentialsRequestMsg : uplinkMsg.getUserCredentialsRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processUserCredentialsRequestMsg(edge.getTenantId(), edge, userCredentialsRequestMsg));
}
}
if (uplinkMsg.getDeviceCredentialsRequestMsgCount() > 0) {
for (DeviceCredentialsRequestMsg deviceCredentialsRequestMsg : uplinkMsg.getDeviceCredentialsRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processDeviceCredentialsRequestMsg(edge.getTenantId(), edge, deviceCredentialsRequestMsg));
}
}
if (uplinkMsg.getDeviceRpcCallMsgCount() > 0) {
for (DeviceRpcCallMsg deviceRpcCallMsg : uplinkMsg.getDeviceRpcCallMsgList()) {
result.add(ctx.getDeviceProcessor().processDeviceRpcCallFromEdge(edge.getTenantId(), edge, deviceRpcCallMsg));
}
}
if (uplinkMsg.getWidgetBundleTypesRequestMsgCount() > 0) {
for (WidgetBundleTypesRequestMsg widgetBundleTypesRequestMsg : uplinkMsg.getWidgetBundleTypesRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processWidgetBundleTypesRequestMsg(edge.getTenantId(), edge, widgetBundleTypesRequestMsg));
}
}
if (uplinkMsg.getEntityViewsRequestMsgCount() > 0) {
for (EntityViewsRequestMsg entityViewRequestMsg : uplinkMsg.getEntityViewsRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processEntityViewsRequestMsg(edge.getTenantId(), edge, entityViewRequestMsg));
}
}
if (uplinkMsg.getCalculatedFieldUpdateMsgCount() > 0) {
for (CalculatedFieldUpdateMsg calculatedFieldUpdateMsg : uplinkMsg.getCalculatedFieldUpdateMsgList()) {
result.add(ctx.getCalculatedFieldProcessor().processCalculatedFieldMsgFromEdge(edge.getTenantId(), edge, calculatedFieldUpdateMsg));
}
}
if (uplinkMsg.getAiModelUpdateMsgCount() > 0) {
for (AiModelUpdateMsg aiModelUpdateMsg : uplinkMsg.getAiModelUpdateMsgList()) {
result.add(ctx.getAiModelProcessor().processAiModelMsgFromEdge(edge.getTenantId(), edge, aiModelUpdateMsg));
}
}
if (uplinkMsg.getUserUpdateMsgCount() > 0) {
for (UserUpdateMsg userUpdateMsg : uplinkMsg.getUserUpdateMsgList()) {
state.lockSequenceDependency();
try {
result.add(ctx.getUserProcessor().processUserMsgFromEdge(edge.getTenantId(), edge, userUpdateMsg));
} finally {
state.unlockSequenceDependency();
}
}
}
if (uplinkMsg.getUserCredentialsUpdateMsgCount() > 0) {
for (UserCredentialsUpdateMsg userCredentialsUpdateMsg : uplinkMsg.getUserCredentialsUpdateMsgList()) {
state.lockSequenceDependency();
try {
result.add(ctx.getUserProcessor().processUserCredentialsMsgFromEdge(edge.getTenantId(), edge, userCredentialsUpdateMsg));
} finally {
state.unlockSequenceDependency();
}
}
}
if (uplinkMsg.getApiKeyUpdateMsgCount() > 0) {
for (ApiKeyUpdateMsg apiKeyUpdateMsg : uplinkMsg.getApiKeyUpdateMsgList()) {
state.lockSequenceDependency();
try {
result.add(ctx.getApiKeyProcessor().processApiKeyMsgFromEdge(edge.getTenantId(), edge, apiKeyUpdateMsg));
} finally {
state.unlockSequenceDependency();
}
}
}
} catch (Exception e) {
String failureMsg = String.format("Can't process uplink msg [%s] from edge", uplinkMsg);
log.trace("[{}][{}] Can't process uplink msg [{}]", edge.getTenantId(), edge.getId(), uplinkMsg, e);
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(edge.getTenantId()).edgeId(edge.getId())
.customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(e.getMessage()).build());
return Futures.immediateFailedFuture(e);
}
return Futures.allAsList(result);
}
}

131
application/src/main/java/org/thingsboard/server/service/edge/rpc/GrpcServer.java

@ -0,0 +1,131 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import io.grpc.Server;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials;
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc.EdgeRpcServiceImplBase;
import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.queue.util.TbCoreComponent;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
@Component
@Slf4j
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class GrpcServer {
@Value("${edges.rpc.port}")
private int rpcPort;
@Value("${edges.rpc.ssl.enabled}")
private boolean sslEnabled;
@Value("${edges.rpc.ssl.cert}")
private String certFileResource;
@Value("${edges.rpc.ssl.private_key}")
private String privateKeyResource;
@Value("${edges.rpc.ssl.key_password:}")
private String keyPassword;
@Value("${edges.rpc.client_max_keep_alive_time_sec:1}")
private int clientMaxKeepAliveTimeSec;
@Value("${edges.rpc.max_inbound_message_size:4194304}")
private int maxInboundMessageSize;
@Value("${edges.rpc.keep_alive_time_sec:10}")
private int keepAliveTimeSec;
@Value("${edges.rpc.keep_alive_timeout_sec:5}")
private int keepAliveTimeoutSec;
private final EdgeRpcServiceImplBase edgeGrpcService;
private Server server;
@AfterStartUp(order = AfterStartUp.REGULAR_SERVICE)
public void onStartUp() {
log.info("Initializing Edge RPC server!");
NettyServerBuilder serverBuilder = NettyServerBuilder.forPort(rpcPort)
.permitKeepAliveTime(clientMaxKeepAliveTimeSec, TimeUnit.SECONDS)
.keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS)
.keepAliveTimeout(keepAliveTimeoutSec, TimeUnit.SECONDS)
.permitKeepAliveWithoutCalls(true)
.maxInboundMessageSize(maxInboundMessageSize)
.addService(edgeGrpcService);
if (sslEnabled) {
setupSsl(serverBuilder);
}
server = serverBuilder.build();
log.info("Going to start Edge RPC server using port: {}", rpcPort);
try {
server.start();
} catch (IOException e) {
log.error("Failed to start Edge RPC server!", e);
throw new RuntimeException("Failed to start Edge RPC server!", e);
}
log.info("Edge RPC server initialized!");
}
/**
* Configures TLS for the Edge gRPC server.
* <p>
* Delegates PEM parsing and key management to {@link PemSslCredentials} — the same
* class used by MQTT, CoAP, and LwM2M transports — which supports:
* <ul>
* <li>Separate certificate and private key files (classic two-file setup)</li>
* <li>Combined PEM: certificate chain + private key in a single {@code cert} file
* ({@code private_key} left empty)</li>
* <li>Encrypted private keys (password supplied via {@code key_password})</li>
* </ul>
* Path resolution (for both {@code cert} and {@code private_key}) is handled by
* {@link org.thingsboard.server.common.data.ResourceUtils#getInputStream ResourceUtils}:
* absolute path → relative / working-dir → classpath → {@code classpath:} prefix.
*/
void setupSsl(NettyServerBuilder builder) {
try {
PemSslCredentials credentials = new PemSslCredentials();
credentials.setCertFile(certFileResource);
credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource);
credentials.setKeyPassword(keyPassword);
credentials.init(false);
SslContext sslContext = GrpcSslContexts.configure(
SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build();
builder.sslContext(sslContext);
} catch (Exception e) {
log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e);
throw new RuntimeException("Unable to set up SSL context!", e);
}
}
@PreDestroy
public void destroy() {
if (server != null) {
server.shutdownNow();
}
}
}

196
application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java

@ -1,196 +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.service.edge.rpc;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import io.grpc.stub.StreamObserver;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg;
import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import org.thingsboard.server.queue.common.consumer.QueueConsumerManager;
import org.thingsboard.server.queue.discovery.TopicService;
import org.thingsboard.server.queue.kafka.KafkaAdmin;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.BiConsumer;
@Slf4j
public class KafkaEdgeGrpcSession extends EdgeGrpcSession {
private final TopicService topicService;
private final TbCoreQueueFactory tbCoreQueueFactory;
private final KafkaAdmin kafkaAdmin;
private volatile boolean isHighPriorityProcessing;
@Getter
private QueueConsumerManager<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> consumer;
private ExecutorService consumerExecutor;
public KafkaEdgeGrpcSession(EdgeContextComponent ctx, TopicService topicService, TbCoreQueueFactory tbCoreQueueFactory,
KafkaAdmin kafkaAdmin, StreamObserver<ResponseMsg> outputStream,
BiConsumer<EdgeId, EdgeGrpcSession> sessionOpenListener, BiConsumer<Edge, UUID> sessionCloseListener,
ScheduledExecutorService sendDownlinkExecutorService, int maxInboundMessageSize, int maxHighPriorityQueueSizePerSession) {
super(ctx, outputStream, sessionOpenListener, sessionCloseListener, sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession);
this.topicService = topicService;
this.tbCoreQueueFactory = tbCoreQueueFactory;
this.kafkaAdmin = kafkaAdmin;
}
private void processMsgs(List<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> msgs, TbQueueConsumer<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> consumer) {
log.trace("[{}][{}] starting processing edge events", tenantId, edge.getId());
// Defensive backstop: the loop already gates polling on readiness; this only fires on the narrow race
// where readiness flips during poll(), and that already-polled batch is dropped here (can't rewind).
if (!isReadyToProcessGeneralEvents()) {
log.debug("[{}][{}] edge not connected, edge sync is not completed or high priority processing in progress, " +
"connected = {}, sync in progress = {}, high priority in progress = {}. Skipping iteration",
tenantId, edge.getId(), isConnected(), isSyncInProgress(), isHighPriorityProcessing);
return;
}
List<EdgeEvent> edgeEvents = new ArrayList<>();
for (TbProtoQueueMsg<ToEdgeEventNotificationMsg> msg : msgs) {
EdgeEvent edgeEvent = ProtoUtils.fromProto(msg.getValue().getEdgeEventMsg());
edgeEvents.add(edgeEvent);
}
List<DownlinkMsg> downlinkMsgsPack = convertToDownlinkMsgsPack(edgeEvents);
boolean isInterrupted = true;
try {
isInterrupted = sendDownlinkMsgsPack(downlinkMsgsPack).get();
if (isInterrupted) {
log.debug("[{}][{}] Send downlink messages task was interrupted", tenantId, edge.getId());
}
} catch (Exception e) {
log.error("[{}][{}] Failed to process downlink messages", tenantId, edge.getId(), e);
}
if (!isInterrupted) {
consumer.commit();
}
}
private boolean isReadyToProcessGeneralEvents() {
return isConnected() && !isSyncInProgress() && !isHighPriorityProcessing;
}
@Override
public ListenableFuture<Boolean> migrateEdgeEvents() throws Exception {
return super.processEdgeEvents();
}
@Override
public ListenableFuture<Boolean> processEdgeEvents() {
if (!isReadyToProcessGeneralEvents()) {
log.warn("[{}][{}] Session is not ready (connected={}, syncInProgress={}, highPriority={}), skip starting edge event consumer",
tenantId, edge != null ? edge.getId() : null, isConnected(), isSyncInProgress(), isHighPriorityProcessing);
return Futures.immediateFuture(Boolean.FALSE);
}
if (consumer == null || (consumer.getConsumer() != null && consumer.getConsumer().isStopped())) {
try {
if (consumerExecutor != null && !consumerExecutor.isShutdown()) {
try {
consumerExecutor.shutdown();
awaitConsumerTermination();
} catch (Exception e) {
log.warn("[{}][{}] Failed to shutdown previous consumer executor", tenantId, edge.getId(), e);
}
}
this.consumerExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("edge-event-consumer"));
this.consumer = QueueConsumerManager.<TbProtoQueueMsg<ToEdgeEventNotificationMsg>>builder()
.name("TB Edge events [" + edge.getId() + "]")
.msgPackProcessor(this::processMsgs)
.pollInterval(ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval())
.consumerCreator(() -> tbCoreQueueFactory.createEdgeEventMsgConsumer(tenantId, edge.getId()))
.consumerExecutor(consumerExecutor)
.threadPrefix("edge-events-" + edge.getId())
.readinessCheck(this::isReadyToProcessGeneralEvents)
.build();
consumer.subscribe();
consumer.launch();
} catch (Exception e) {
destroy();
log.warn("[{}][{}] Failed to start edge event consumer", sessionId, edge.getId(), e);
}
}
return Futures.immediateFuture(Boolean.FALSE);
}
@Override
public void processHighPriorityEvents() {
isHighPriorityProcessing = true;
try {
super.processHighPriorityEvents();
} finally {
isHighPriorityProcessing = false;
}
}
@Override
public boolean destroy() {
try {
if (consumer != null) {
log.info("[{}][{}] Stopping edge event consumer...", tenantId, edge != null ? edge.getId() : null);
consumer.stop();
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to stop edge event consumer", tenantId, edge.getId(), e);
return false;
}
consumer = null;
try {
if (consumerExecutor != null && !consumerExecutor.isShutdown()) {
consumerExecutor.shutdown();
awaitConsumerTermination();
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to shutdown edge event consumer executor", tenantId, edge.getId(), e);
return false;
}
return true;
}
private void awaitConsumerTermination() {
try {
consumerExecutor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS);
} catch (InterruptedException ie) {
log.warn("[{}][{}] Interrupted while awaiting consumer executor termination", tenantId, edge.getId());
}
}
@Override
public void cleanUp() {
String topic = topicService.buildEdgeEventNotificationsTopicPartitionInfo(tenantId, edge.getId()).getTopic();
kafkaAdmin.deleteTopic(topic);
kafkaAdmin.deleteConsumerGroup(topic);
}
}

46
application/src/main/java/org/thingsboard/server/service/edge/rpc/PostgresEdgeGrpcSession.java

@ -1,46 +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.service.edge.rpc;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import io.grpc.stub.StreamObserver;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.BiConsumer;
@Slf4j
public class PostgresEdgeGrpcSession extends EdgeGrpcSession {
PostgresEdgeGrpcSession(EdgeContextComponent ctx, StreamObserver<ResponseMsg> outputStream,
BiConsumer<EdgeId, EdgeGrpcSession> sessionOpenListener,
BiConsumer<Edge, UUID> sessionCloseListener, ScheduledExecutorService sendDownlinkExecutorService,
int maxInboundMessageSize, int maxHighPriorityQueueSizePerSession) {
super(ctx, outputStream, sessionOpenListener, sessionCloseListener, sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession);
}
@Override
public ListenableFuture<Boolean> migrateEdgeEvents() {
return Futures.immediateFuture(Boolean.FALSE);
}
}

23
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeEventsDispatcher.java

@ -0,0 +1,23 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.processor;
import com.google.common.util.concurrent.ListenableFuture;
public interface EdgeEventsDispatcher {
ListenableFuture<Boolean> processNewEvents() throws Exception;
}

218
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/PostgresGeneralEdgeEventsDispatcher.java

@ -0,0 +1,218 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.processor;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.springframework.data.util.Pair;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.AttributesSaveResult;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.TimePageLink;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.fetch.GeneralEdgeEventFetcher;
import org.thingsboard.server.service.edge.rpc.session.EdgeSession;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
@Slf4j
@RequiredArgsConstructor
public class PostgresGeneralEdgeEventsDispatcher implements EdgeEventsDispatcher {
private static final String QUEUE_START_TS_ATTR_KEY = "queueStartTs";
private static final String QUEUE_START_SEQ_ID_ATTR_KEY = "queueStartSeqId";
private final EdgeSession session;
private final EdgeContextComponent ctx;
private Long newStartTs;
private Long previousStartTs;
private Long newStartSeqId;
private Long previousStartSeqId;
@Override
public ListenableFuture<Boolean> processNewEvents() throws Exception {
EdgeSessionState state = session.getState();
TenantId tenantId = state.getTenantId();
EdgeId edgeId = state.getEdgeId();
UUID sessionId = state.getSessionId();
SettableFuture<Boolean> result = SettableFuture.create();
if (state.isConnected() && !state.isSyncInProgress()) {
setLocalPrevStartTsAndSeqId(tenantId, edgeId, sessionId);
GeneralEdgeEventFetcher fetcher = initGeneralEdgeEventFetcher();
log.trace("[{}][{}] starting processing edge events, previousStartTs = {}, previousStartSeqId = {}",
tenantId, edgeId, previousStartTs, previousStartSeqId);
Futures.addCallback(session.fetchAndSendEdgeEvents(fetcher), new FutureCallback<>() {
@Override
public void onSuccess(@Nullable Pair<Long, Long> newStartTsAndSeqId) {
if (newStartTsAndSeqId != null) {
Futures.addCallback(updateQueueStartTsAndSeqId(tenantId, edgeId, newStartTsAndSeqId), new FutureCallback<>() {
@Override
public void onSuccess(@Nullable AttributesSaveResult saveResult) {
log.debug("[{}][{}] queue offset was updated [{}]", tenantId, edgeId, newStartTsAndSeqId);
boolean newEventsAvailable;
if (fetcher.isSeqIdNewCycleStarted()) {
newEventsAvailable = isNewEdgeEventsAvailable(tenantId, edgeId, sessionId);
} else {
newEventsAvailable = isSeqIdStartedNewCycle(tenantId, edgeId, sessionId);
if (!newEventsAvailable) {
newEventsAvailable = isNewEdgeEventsAvailable(tenantId, edgeId, sessionId);
}
}
result.set(newEventsAvailable);
}
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to update queue offset [{}]", tenantId, edgeId, newStartTsAndSeqId, t);
result.setException(t);
}
}, ctx.getGrpcCallbackExecutorService());
} else {
log.trace("[{}][{}] newStartTsAndSeqId is null. Skipping iteration without db update", tenantId, edgeId);
result.set(Boolean.FALSE);
}
}
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to process events", tenantId, edgeId, t);
result.setException(t);
}
}, ctx.getGrpcCallbackExecutorService());
} else {
if (state.isSyncInProgress()) {
log.trace("[{}][{}] edge sync is not completed yet. Skipping iteration", tenantId, edgeId);
result.set(Boolean.TRUE);
} else {
log.trace("[{}][{}] edge is not connected. Skipping iteration", tenantId, edgeId);
result.set(null);
}
}
return result;
}
private boolean isSeqIdStartedNewCycle(TenantId tenantId, EdgeId edgeId, UUID sessionId) {
try {
log.trace("[{}][{}][{}] Checking if seq id started new cycle", tenantId, edgeId, sessionId);
TimePageLink pageLink = new TimePageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount(), 0, null, null, newStartTs, System.currentTimeMillis());
PageData<EdgeEvent> edgeEvents = ctx.getEdgeEventService()
.findEdgeEvents(tenantId, edgeId, 0L, previousStartSeqId == 0 ? null : previousStartSeqId - 1, pageLink);
boolean result = !edgeEvents.getData().isEmpty();
log.trace("[{}][{}][{}] Result of check if seq id started new cycle, result = {}", tenantId, edgeId, sessionId, result);
return result;
} catch (Exception e) {
log.error("[{}][{}][{}] Failed to execute isSeqIdStartedNewCycle", tenantId, edgeId, sessionId, e);
}
return false;
}
private boolean isNewEdgeEventsAvailable(TenantId tenantId, EdgeId edgeId, UUID sessionId) {
try {
log.trace("[{}][{}][{}] Checking if new edge events available", tenantId, edgeId, sessionId);
TimePageLink pageLink = new TimePageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount(), 0, null, null, newStartTs, System.currentTimeMillis());
PageData<EdgeEvent> edgeEvents = ctx.getEdgeEventService().findEdgeEvents(tenantId, edgeId, newStartSeqId, null, pageLink);
boolean available = !edgeEvents.getData().isEmpty() || session.hasHighPriorityEvents();
log.trace("[{}][{}][{}] Result of check if new edge events available, result = {}", tenantId, edgeId, sessionId, available);
return available;
} catch (Exception e) {
log.error("[{}][{}][{}] Failed to execute isNewEdgeEventsAvailable", tenantId, edgeId, sessionId, e);
}
return false;
}
private ListenableFuture<AttributesSaveResult> updateQueueStartTsAndSeqId(TenantId tenantId, EdgeId edgeId, Pair<Long, Long> pair) {
newStartTs = pair.getFirst();
newStartSeqId = pair.getSecond();
log.trace("[{}] updateQueueStartTsAndSeqId [{}][{}][{}]", session.getState().getSessionId(), edgeId, newStartTs, newStartSeqId);
List<AttributeKvEntry> attributes = List.of(
new BaseAttributeKvEntry(new LongDataEntry(QUEUE_START_TS_ATTR_KEY, newStartTs), System.currentTimeMillis()),
new BaseAttributeKvEntry(new LongDataEntry(QUEUE_START_SEQ_ID_ATTR_KEY, newStartSeqId), System.currentTimeMillis())
);
return ctx.getAttributesService().save(tenantId, edgeId, AttributeScope.SERVER_SCOPE, attributes);
}
private GeneralEdgeEventFetcher initGeneralEdgeEventFetcher() {
return new GeneralEdgeEventFetcher(
previousStartTs,
previousStartSeqId,
false,
Integer.toUnsignedLong(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount()),
ctx.getEdgeEventService(),
ctx.getEdgeEventStorageSettings().getMisorderingCompensationMillis());
}
private void setLocalPrevStartTsAndSeqId(TenantId tenantId, EdgeId edgeId, UUID sessionId) throws InterruptedException, ExecutionException {
Pair<Long, Long> startTsAndSeqId = getQueueStartTsAndSeqId(tenantId, edgeId, sessionId).get();
previousStartTs = startTsAndSeqId.getFirst();
previousStartSeqId = startTsAndSeqId.getSecond();
}
private ListenableFuture<Pair<Long, Long>> getQueueStartTsAndSeqId(TenantId tenantId, EdgeId edgeId, UUID sessionId) {
ListenableFuture<List<AttributeKvEntry>> future = ctx.getAttributesService()
.find(tenantId, edgeId, AttributeScope.SERVER_SCOPE, Arrays.asList(QUEUE_START_TS_ATTR_KEY, QUEUE_START_SEQ_ID_ATTR_KEY));
return Futures.transform(future, attributeKvEntries -> {
long startTs = 0L;
long startSeqId = 0L;
for (AttributeKvEntry attributeKvEntry : attributeKvEntries) {
if (QUEUE_START_TS_ATTR_KEY.equals(attributeKvEntry.getKey())) {
startTs = attributeKvEntry.getLongValue().isPresent() ? attributeKvEntry.getLongValue().get() : 0L;
}
if (QUEUE_START_SEQ_ID_ATTR_KEY.equals(attributeKvEntry.getKey())) {
startSeqId = attributeKvEntry.getLongValue().isPresent() ? attributeKvEntry.getLongValue().get() : 0L;
}
}
if (startSeqId == 0L) {
startSeqId = findStartSeqIdFromOldestEventIfAny(tenantId, edgeId, sessionId);
}
return Pair.of(startTs, startSeqId);
}, ctx.getGrpcCallbackExecutorService());
}
private long findStartSeqIdFromOldestEventIfAny(TenantId tenantId, EdgeId edgeId, UUID sessionId) {
long startSeqId = 0L;
try {
TimePageLink pageLink = new TimePageLink(1, 0, null, null, null, null);
PageData<EdgeEvent> edgeEvents = ctx.getEdgeEventService()
.findEdgeEvents(tenantId, edgeId, null, null, pageLink);
if (!edgeEvents.getData().isEmpty()) {
startSeqId = edgeEvents.getData().get(0).getSeqId() - 1;
}
} catch (Exception e) {
log.error("[{}][{}][{}] Failed to execute findStartSeqIdFromOldestEventIfAny", tenantId, edgeId, sessionId, e);
}
return startSeqId;
}
}

392
application/src/main/java/org/thingsboard/server/service/edge/rpc/service/EdgeGrpcService.java

@ -0,0 +1,392 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.service;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.grpc.stub.StreamObserver;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.rule.engine.api.AttributesSaveRequest;
import org.thingsboard.rule.engine.api.TimeseriesSaveRequest;
import org.thingsboard.server.cache.TbTransactionalCache;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.notification.rule.trigger.EdgeConnectionTrigger;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgDataType;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg;
import org.thingsboard.server.common.msg.edge.EdgeHighPriorityMsg;
import org.thingsboard.server.common.msg.edge.EdgeSessionMsg;
import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse;
import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest;
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.rpc.EdgeRpcService;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.session.EdgeAttributeSaveCallback;
import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.ACTIVITY_STATE;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_CONNECT_TIME;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_DISCONNECT_TIME;
@Service
@Slf4j
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService {
@Value("${edges.send_scheduler_pool_size}")
private int sendSchedulerPoolSize;
@Value("${edges.state.persistToTelemetry:false}")
private boolean persistToTelemetry;
@Autowired
@Lazy
private EdgeContextComponent ctx;
private final EdgeSessionsHolder sessions;
private final ApplicationContext applicationContext;
private final TbClusterService clusterService;
private final TbServiceInfoProvider serviceInfoProvider;
private final TelemetrySubscriptionService tsSubService;
private final TbTransactionalCache<EdgeId, String> edgeIdServiceIdCache;
private final ConcurrentMap<UUID, Consumer<FromEdgeSyncResponse>> localSyncEdgeRequests = new ConcurrentHashMap<>();
private ScheduledExecutorService executorService;
private ScheduledExecutorService sendDownlinkExecutorService;
@PostConstruct
public void onStartUp() {
this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service");
this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler");
}
@PreDestroy
private void preDestroy() {
shutdownExecutorSafely(executorService);
shutdownExecutorSafely(sendDownlinkExecutorService);
sessions.forEach(EdgeGrpcSessionManager::onEdgeDisconnect);
}
@Override
public StreamObserver<RequestMsg> handleMsgs(StreamObserver<ResponseMsg> outputStream) {
EdgeGrpcSessionManager sessionManager = applicationContext.getBean(EdgeGrpcSessionManager.class);
return sessionManager.initInputStream(outputStream, this::onEdgeConnect, this::onEdgeDisconnect, sendDownlinkExecutorService);
}
@Override
public void onToEdgeSessionMsg(TenantId tenantId, EdgeSessionMsg msg) {
switch (msg.getMsgType()) {
case EDGE_HIGH_PRIORITY_TO_EDGE_SESSION_MSG -> {
EdgeHighPriorityMsg edgeHighPriorityMsg = (EdgeHighPriorityMsg) msg;
log.trace("[{}] edgeEventMsg [{}]", tenantId, msg);
onEdgeHighPriorityEvent(edgeHighPriorityMsg);
}
case EDGE_EVENT_UPDATE_TO_EDGE_SESSION_MSG -> {
EdgeEventUpdateMsg edgeEventUpdateMsg = (EdgeEventUpdateMsg) msg;
log.trace("[{}] onToEdgeEventUpdateMsg [{}]", tenantId, msg);
onEdgeEventUpdate(tenantId, edgeEventUpdateMsg.getEdgeId());
}
case EDGE_SYNC_REQUEST_TO_EDGE_SESSION_MSG -> {
ToEdgeSyncRequest toEdgeSyncRequest = (ToEdgeSyncRequest) msg;
log.trace("[{}] toEdgeSyncRequest [{}]", tenantId, msg);
startSyncProcess(tenantId, toEdgeSyncRequest.getEdgeId(), toEdgeSyncRequest.getId(), toEdgeSyncRequest.getServiceId());
}
case EDGE_SYNC_RESPONSE_FROM_EDGE_SESSION_MSG -> {
FromEdgeSyncResponse fromEdgeSyncResponse = (FromEdgeSyncResponse) msg;
log.trace("[{}] fromEdgeSyncResponse [{}]", tenantId, msg);
processSyncResponse(fromEdgeSyncResponse);
}
}
}
@Override
public void updateEdge(TenantId tenantId, Edge edge) {
if (edge == null) {
log.warn("[{}] Edge is null - edge is removed and outdated notification is in process!", tenantId);
return;
}
EdgeGrpcSessionManager session = sessions.getByEdgeId(edge.getId());
if (session != null && session.getState().isConnected()) {
log.debug("[{}] Updating configuration for edge [{}] [{}]", tenantId, edge.getName(), edge.getId());
session.onConfigurationUpdate(edge);
} else {
log.debug("[{}] Session doesn't exist for edge [{}] [{}]", tenantId, edge.getName(), edge.getId());
}
}
@Override
public void deleteEdge(TenantId tenantId, EdgeId edgeId) {
EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId);
if (session != null && session.getState().isConnected()) {
log.info("[{}] Closing and removing session for edge [{}]", tenantId, edgeId);
session.destroyAndMarkAsZombieIfFailed();
session.onEdgeRemoval();
sessions.remove(session);
}
}
@Override
public void processSyncRequest(TenantId tenantId, EdgeId edgeId, Consumer<FromEdgeSyncResponse> responseConsumer) {
ToEdgeSyncRequest request = new ToEdgeSyncRequest(UUID.randomUUID(), tenantId, edgeId, serviceInfoProvider.getServiceId());
UUID requestId = request.getId();
EdgeGrpcSessionManager session = sessions.getByEdgeId(request.getEdgeId());
if (session != null && session.getState().isSyncInProgress()) {
responseConsumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Sync process is active at the moment"));
} else {
log.trace("[{}][{}] Processing sync edge request [{}], serviceId [{}]", request.getTenantId(), request.getId(), request.getEdgeId(), request.getServiceId());
localSyncEdgeRequests.put(requestId, responseConsumer);
clusterService.pushEdgeSyncRequestToEdge(request);
scheduleSyncRequestTimeout(request, requestId);
}
}
private void onEdgeConnect(EdgeId edgeId, EdgeGrpcSessionManager edgeSession) {
EdgeSessionState state = edgeSession.getState();
Edge edge = state.getEdge();
TenantId tenantId = state.getTenantId();
log.info("[{}][{}] edge [{}] connected successfully.", tenantId, state.getSessionId(), edgeId);
if (sessions.hasByEdgeId(edgeId)) {
EdgeGrpcSessionManager existingSession = sessions.getByEdgeId(edgeId);
if (existingSession != null) {
UUID sessionId = existingSession.getState().getSessionId();
log.info("[{}][{}] Replacing existing session [{}] for edge [{}]", tenantId, state.getSessionId(), sessionId, edgeId);
existingSession.destroyAndMarkAsZombieIfFailed();
sessions.removeBySessionId(sessionId);
}
}
sessions.put(edgeSession);
save(tenantId, edgeId, ACTIVITY_STATE, true);
long lastConnectTs = System.currentTimeMillis();
save(tenantId, edgeId, LAST_CONNECT_TIME, lastConnectTs);
edgeIdServiceIdCache.put(edgeId, serviceInfoProvider.getServiceId());
pushRuleEngineMessage(tenantId, edge, lastConnectTs, TbMsgType.CONNECT_EVENT);
edgeSession.onEdgeConnect();
}
private void onEdgeDisconnect(Edge edge, UUID sessionId) {
TenantId tenantId = edge.getTenantId();
EdgeId edgeId = edge.getId();
log.info("[{}][{}] edge disconnected!", edgeId, sessionId);
EdgeGrpcSessionManager current = sessions.getByEdgeId(edgeId);
if (current != null && current.getState().getSessionId().equals(sessionId)) {
EdgeGrpcSessionManager toRemove = sessions.removeByEdgeId(edgeId);
if (toRemove != null) {
toRemove.onEdgeDisconnect();
toRemove.destroyAndMarkAsZombieIfFailed();
}
sessions.removeBySessionId(sessionId);
save(tenantId, edgeId, ACTIVITY_STATE, false);
long lastDisconnectTs = System.currentTimeMillis();
save(tenantId, edgeId, LAST_DISCONNECT_TIME, lastDisconnectTs);
pushRuleEngineMessage(tenantId, edge, lastDisconnectTs, TbMsgType.DISCONNECT_EVENT);
} else {
log.info("[{}] edge session [{}] is not current anymore. Attempting to destroy it by sessionId.", edgeId, sessionId);
EdgeGrpcSessionManager stale = sessions.removeBySessionId(sessionId);
if (stale != null) {
try {
stale.destroyAndMarkAsZombieIfFailed();
log.info("[{}][{}] Successfully destroyed stale session for edge [{}]", stale.getState().getTenantId(), sessionId, edgeId);
} catch (Exception e) {
log.warn("[{}][{}] Failed to destroy stale session for edge [{}]", stale.getState().getTenantId(), sessionId, edgeId, e);
}
} else {
log.debug("[{}] No session found by sessionId [{}] to destroy", edgeId, sessionId);
}
}
edgeIdServiceIdCache.evict(edgeId);
}
private void scheduleSyncRequestTimeout(ToEdgeSyncRequest request, UUID requestId) {
log.trace("[{}] scheduling sync edge request", requestId);
executorService.schedule(() -> {
log.trace("[{}] checking if sync edge request is not processed...", requestId);
Consumer<FromEdgeSyncResponse> consumer = localSyncEdgeRequests.remove(requestId);
if (consumer != null) {
log.trace("[{}] timeout for processing sync edge request.", requestId);
consumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Edge is not connected"));
}
}, 20, TimeUnit.SECONDS);
}
private void onEdgeHighPriorityEvent(EdgeHighPriorityMsg msg) {
TenantId tenantId = msg.getTenantId();
EdgeEvent edgeEvent = msg.getEdgeEvent();
EdgeId edgeId = edgeEvent.getEdgeId();
EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId);
if (session != null && session.getState().isConnected()) {
log.trace("[{}] onEdgeEvent [{}]", tenantId, edgeId);
session.addEventToHighPriorityQueue(edgeEvent);
}
}
private void onEdgeEventUpdate(TenantId tenantId, EdgeId edgeId) {
EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId);
if (session != null && session.getState().isConnected()) {
log.trace("[{}] onEdgeEventUpdate [{}]", tenantId, edgeId.getId());
session.onEdgeEventUpdate();
}
}
private void startSyncProcess(TenantId tenantId, EdgeId edgeId, UUID requestId, String requestServiceId) {
EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId);
if (session != null) {
EdgeSessionState sessionState = session.getState();
if (sessionState.isSyncInProgress()) {
clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, false, "Sync process is active at the moment"), requestServiceId);
} else {
boolean success = false;
if (sessionState.isConnected()) {
session.startSyncProcess(true);
success = true;
}
clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, success, ""), requestServiceId);
}
}
}
private void processSyncResponse(FromEdgeSyncResponse response) {
log.trace("[{}] Received response from sync service: [{}]", response.getId(), response);
UUID requestId = response.getId();
Consumer<FromEdgeSyncResponse> consumer = localSyncEdgeRequests.remove(requestId);
if (consumer != null) {
consumer.accept(response);
} else {
log.trace("[{}] Unknown or stale sync response received [{}]", requestId, response);
}
}
private void pushRuleEngineMessage(TenantId tenantId, Edge edge, long ts, TbMsgType msgType) {
try {
EdgeId edgeId = edge.getId();
ObjectNode edgeState = JacksonUtil.newObjectNode();
boolean isConnected = TbMsgType.CONNECT_EVENT.equals(msgType);
if (isConnected) {
edgeState.put(ACTIVITY_STATE, true);
edgeState.put(LAST_CONNECT_TIME, ts);
} else {
edgeState.put(ACTIVITY_STATE, false);
edgeState.put(LAST_DISCONNECT_TIME, ts);
}
ctx.getRuleProcessor().process(EdgeConnectionTrigger.builder()
.tenantId(tenantId)
.customerId(edge.getCustomerId())
.edgeId(edgeId)
.edgeName(edge.getName())
.connected(isConnected).build());
String data = JacksonUtil.toString(edgeState);
TbMsgMetaData md = new TbMsgMetaData();
if (!persistToTelemetry) {
md.putValue(DataConstants.SCOPE, DataConstants.SERVER_SCOPE);
md.putValue("edgeName", edge.getName());
md.putValue("edgeType", edge.getType());
}
TbMsg tbMsg = TbMsg.newMsg()
.type(msgType)
.originator(edgeId)
.copyMetaData(md)
.dataType(TbMsgDataType.JSON)
.data(data)
.build();
clusterService.pushMsgToRuleEngine(tenantId, edgeId, tbMsg, null);
} catch (Exception e) {
log.warn("[{}][{}] Failed to push {}", tenantId, edge.getId(), msgType, e);
}
}
private void save(TenantId tenantId, EdgeId edgeId, String key, long value) {
log.debug("[{}][{}] Updating long edge telemetry [{}] [{}]", tenantId, edgeId, key, value);
if (persistToTelemetry) {
tsSubService.saveTimeseries(TimeseriesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.entry(new LongDataEntry(key, value))
.callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value))
.build());
} else {
tsSubService.saveAttributes(AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(new LongDataEntry(key, value))
.callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value))
.build());
}
}
private void save(TenantId tenantId, EdgeId edgeId, String key, boolean value) {
log.debug("[{}][{}] Updating boolean edge telemetry [{}] [{}]", tenantId, edgeId, key, value);
if (persistToTelemetry) {
tsSubService.saveTimeseries(TimeseriesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.entry(new BooleanDataEntry(key, value))
.callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value))
.build());
} else {
tsSubService.saveAttributes(AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(new BooleanDataEntry(key, value))
.callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value))
.build());
}
}
private void shutdownExecutorSafely(ExecutorService e) {
if (e != null && !e.isShutdown()) {
e.shutdown();
}
}
}

131
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/DefaultZombieSessionCleanupService.java

@ -0,0 +1,131 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
import org.thingsboard.server.service.edge.rpc.session.manager.KafkaBasedEdgeGrpcSessionManager;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
@Service
@Slf4j
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class DefaultZombieSessionCleanupService implements ZombieSessionCleanupService {
@Autowired
private EdgeSessionsHolder edgeSessionsHolder;
private final Queue<EdgeGrpcSessionManager> zombieSessions;
private final ScheduledExecutorService zombieSessionsExecutorService;
public DefaultZombieSessionCleanupService() {
this.zombieSessions = new ConcurrentLinkedQueue<>();
this.zombieSessionsExecutorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("zombie-sessions");
}
@PostConstruct
public void init() {
this.zombieSessionsExecutorService.scheduleAtFixedRate(this::cleanupZombieSessions, 30, 60, TimeUnit.SECONDS);
}
@PreDestroy
public void destroy() {
if (zombieSessionsExecutorService != null && !zombieSessionsExecutorService.isShutdown()) {
zombieSessionsExecutorService.shutdown();
}
}
@Override
public void add(EdgeGrpcSessionManager session) {
zombieSessions.add(session);
}
private void cleanupZombieSessions() {
try {
tryToDestroyZombieSessions(getZombieSessions(edgeSessionsHolder.getSessions().values()),
s -> edgeSessionsHolder.removeByEdgeId(s.getState().getEdge().getId()));
tryToDestroyZombieSessions(getZombieSessions(edgeSessionsHolder.getSessionsById().values()),
s -> edgeSessionsHolder.removeBySessionId(s.getState().getSessionId()));
zombieSessions.removeIf(zombie -> {
EdgeSessionState state = zombie.getState();
if (zombie.destroy()) {
log.info("[{}][{}] Successfully cleaned up zombie session [{}] for edge [{}].",
state.getTenantId(), state.getEdgeId(), state.getSessionId(), state.getEdge().getName());
return true;
} else {
log.warn("[{}][{}] Failed to remove zombie session [{}] for edge [{}].",
state.getTenantId(), state.getEdgeId(), state.getSessionId(), state.getEdge().getName());
return false;
}
});
} catch (Exception e) {
log.warn("Failed to cleanup zombie sessions", e);
}
}
private List<EdgeGrpcSessionManager> getZombieSessions(Collection<EdgeGrpcSessionManager> sessions) {
List<EdgeGrpcSessionManager> result = new ArrayList<>();
for (EdgeGrpcSessionManager session : sessions) {
if (isKafkaSessionAndZombie(session)) {
result.add(session);
}
}
return result;
}
private boolean isKafkaSessionAndZombie(EdgeGrpcSessionManager session) {
if (session instanceof KafkaBasedEdgeGrpcSessionManager kafkaSession) {
EdgeSessionState sessionState = kafkaSession.getState();
log.debug("[{}] kafkaSession.isConnected() = {}, kafkaSession.getConsumer().getConsumer().isStopped() = {}",
sessionState.getEdgeId(),
sessionState.isConnected(),
kafkaSession.getConsumer() != null ? kafkaSession.getConsumer().getConsumer() != null ? kafkaSession.getConsumer().getConsumer().isStopped() : null : null);
return !sessionState.isConnected() &&
kafkaSession.getConsumer() != null &&
kafkaSession.getConsumer().getConsumer() != null &&
!kafkaSession.getConsumer().getConsumer().isStopped();
}
return false;
}
private void tryToDestroyZombieSessions(List<EdgeGrpcSessionManager> sessionsToRemove, Function<EdgeGrpcSessionManager, EdgeGrpcSessionManager> removeFunc) {
for (EdgeGrpcSessionManager toRemove : sessionsToRemove) {
log.info("[{}] Destroying session for edge because edge is not connected", toRemove.getState().getEdge().getId());
if (toRemove.destroy()) {
removeFunc.apply(toRemove);
}
}
}
}

16
application/src/main/java/org/thingsboard/server/service/edge/rpc/AttributeSaveCallback.java → application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeAttributeSaveCallback.java

@ -13,24 +13,29 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
package org.thingsboard.server.service.edge.rpc; package org.thingsboard.server.service.edge.rpc.session;
import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.FutureCallback;
import jakarta.annotation.Nullable; import jakarta.annotation.Nullable;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
@Slf4j @Slf4j
@AllArgsConstructor public class EdgeAttributeSaveCallback implements FutureCallback<Void> {
public class AttributeSaveCallback implements FutureCallback<Void> {
private final TenantId tenantId; private final TenantId tenantId;
private final EdgeId edgeId; private final EdgeId edgeId;
private final String key; private final String key;
private final Object value; private final Object value;
public EdgeAttributeSaveCallback(TenantId tenantId, EdgeId edgeId, String key, Object value) {
this.tenantId = tenantId;
this.edgeId = edgeId;
this.key = key;
this.value = value;
}
@Override @Override
public void onSuccess(@Nullable Void result) { public void onSuccess(@Nullable Void result) {
log.trace("[{}][{}] Successfully updated attribute [{}] with value [{}]", tenantId, edgeId, key, value); log.trace("[{}][{}] Successfully updated attribute [{}] with value [{}]", tenantId, edgeId, key, value);
@ -40,4 +45,5 @@ public class AttributeSaveCallback implements FutureCallback<Void> {
public void onFailure(Throwable t) { public void onFailure(Throwable t) {
log.warn("[{}][{}] Failed to update attribute [{}] with value [{}]", tenantId, edgeId, key, value, t); log.warn("[{}][{}] Failed to update attribute [{}] with value [{}]", tenantId, edgeId, key, value, t);
} }
}
}

621
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSession.java

@ -0,0 +1,621 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import io.grpc.stub.StreamObserver;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.springframework.data.util.Pair;
import org.thingsboard.rule.engine.api.AttributesSaveRequest;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EdgeUtils;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.limit.LimitedApi;
import org.thingsboard.server.common.data.notification.rule.trigger.EdgeCommunicationFailureTrigger;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg;
import org.thingsboard.server.dao.edge.stats.EdgeStatsKey;
import org.thingsboard.server.gen.edge.v1.ConnectRequestMsg;
import org.thingsboard.server.gen.edge.v1.ConnectResponseCode;
import org.thingsboard.server.gen.edge.v1.ConnectResponseMsg;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.DownlinkResponseMsg;
import org.thingsboard.server.gen.edge.v1.EdgeConfiguration;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.RequestMsgType;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.gen.edge.v1.SyncCompletedMsg;
import org.thingsboard.server.gen.edge.v1.UplinkMsg;
import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils;
import org.thingsboard.server.service.edge.rpc.DownlinkMessageMapper;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.EdgeSyncCursor;
import org.thingsboard.server.service.edge.rpc.EdgeUplinkMessageDispatcher;
import org.thingsboard.server.service.edge.rpc.fetch.EdgeEventFetcher;
import org.thingsboard.server.service.edge.rpc.fetch.GeneralEdgeEventFetcher;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
@Slf4j
@RequiredArgsConstructor
public class EdgeGrpcSession implements EdgeSession {
private static final int MAX_DOWNLINK_ATTEMPTS = 3;
private static final String RATE_LIMIT_REACHED = "Rate limit reached";
private final EdgeGrpcSessionManager parentManagerRef;
private final EdgeContextComponent ctx;
private final StreamObserver<ResponseMsg> outputStream;
private final DownlinkMessageMapper downlinkMessageMapper;
private final EdgeUplinkMessageDispatcher uplinkMessageDispatcher;
private final BiConsumer<EdgeId, EdgeGrpcSessionManager> sessionOpenListener;
private final BiConsumer<Edge, UUID> sessionCloseListener;
private final ScheduledExecutorService sendDownlinkExecutorService;
private final int maxInboundMessageSize;
private final int maxHighPriorityQueueSizePerSession;
private final EdgeSessionState state = new EdgeSessionState();
private final Lock downlinkMsgLock = new ReentrantLock();
private final ConcurrentLinkedQueue<EdgeEvent> highPriorityQueue = new ConcurrentLinkedQueue<>();
private int clientMaxInboundMessageSize;
@Override
public EdgeSessionState getState() {
return state;
}
@Override
public StreamObserver<RequestMsg> initInputStream() {
return new StreamObserver<>() {
@Override
public void onNext(RequestMsg requestMsg) {
if (!state.isConnected() && requestMsg.getMsgType().equals(RequestMsgType.CONNECT_RPC_MESSAGE)) {
ConnectResponseMsg responseMsg = processConnect(requestMsg.getConnectRequestMsg());
outputStream.onNext(ResponseMsg.newBuilder()
.setConnectResponseMsg(responseMsg)
.build());
if (ConnectResponseCode.ACCEPTED != responseMsg.getResponseCode()) {
outputStream.onError(new RuntimeException(responseMsg.getErrorMsg()));
} else {
if (requestMsg.getConnectRequestMsg().hasMaxInboundMessageSize()) {
log.debug("[{}][{}] Client max inbound message size: {}",
getTenantId(), getSessionId(), requestMsg.getConnectRequestMsg().getMaxInboundMessageSize());
clientMaxInboundMessageSize = requestMsg.getConnectRequestMsg().getMaxInboundMessageSize();
}
state.setConnected(true);
}
}
if (state.isConnected()) {
if (requestMsg.getMsgType().equals(RequestMsgType.SYNC_REQUEST_RPC_MESSAGE)) {
if (requestMsg.hasSyncRequestMsg()) {
boolean fullSync = false;
if (requestMsg.getSyncRequestMsg().hasFullSync()) {
fullSync = requestMsg.getSyncRequestMsg().getFullSync();
}
startSyncProcess(fullSync);
} else {
state.finishSync();
saveSyncInProgressAsAttribute(false);
}
}
if (requestMsg.getMsgType().equals(RequestMsgType.UPLINK_RPC_MESSAGE)) {
if (requestMsg.hasUplinkMsg()) {
onUplinkMsg(requestMsg.getUplinkMsg());
}
if (requestMsg.hasDownlinkResponseMsg()) {
onDownlinkResponse(requestMsg.getDownlinkResponseMsg());
}
}
}
}
@Override
public void onError(Throwable t) {
log.trace("[{}][{}] Stream was terminated due to error:", getTenantId(), getSessionId(), t);
closeSession();
}
@Override
public void onCompleted() {
log.info("[{}][{}] Stream was closed and completed successfully!", getTenantId(), getSessionId());
closeSession();
}
private void closeSession() {
state.setConnected(false);
if (state.getEdge() != null) {
try {
sessionCloseListener.accept(state.getEdge(), getSessionId());
} catch (Exception ignored) {
}
}
try {
outputStream.onCompleted();
} catch (Exception ignored) {
}
}
};
}
@Override
public void startSyncProcess(boolean fullSync) {
if (state.tryStartSync()) {
saveSyncInProgressAsAttribute(true);
log.info("[{}][{}][{}] Staring edge sync process", getTenantId(), getEdgeId(), getSessionId());
interruptGeneralProcessingOnSync();
doSync(new EdgeSyncCursor(ctx, state.getEdge(), fullSync));
} else {
log.info("[{}][{}][{}] Sync is already started, skipping starting it now", getTenantId(), getEdgeId(), getSessionId());
}
}
@Override
public void sendDownlinkMsg(ResponseMsg responseMsg) {
if (state.isConnected()) {
String responseMsgStr = StringUtils.truncate(responseMsg.toString(), 10000);
log.trace("[{}][{}] Sending downlink msg [{}]", getTenantId(), getEdgeId(), responseMsgStr);
downlinkMsgLock.lock();
String downlinkMsgStr = responseMsg.hasDownlinkMsg() ? String.valueOf(responseMsg.getDownlinkMsg().getDownlinkMsgId()) : responseMsgStr;
try {
outputStream.onNext(responseMsg);
} catch (Exception e) {
log.trace("[{}][{}] Failed to send downlink message [{}]", getTenantId(), getEdgeId(), downlinkMsgStr, e);
state.setConnected(false);
sessionCloseListener.accept(state.getEdge(), getSessionId());
} finally {
downlinkMsgLock.unlock();
}
log.trace("[{}][{}] downlink msg successfully sent [{}]", getTenantId(), getEdgeId(), downlinkMsgStr);
}
}
@Override
public void addHighPriorityEvent(EdgeEvent edgeEvent) {
while (highPriorityQueue.size() > maxHighPriorityQueueSizePerSession) {
EdgeEvent oldestHighPriority = highPriorityQueue.poll();
if (oldestHighPriority != null) {
log.warn("[{}][{}][{}] High priority queue is full. Removing oldest high priority event from queue {}",
state.getTenantId(), state.getEdgeId(), getSessionId(), oldestHighPriority);
}
}
highPriorityQueue.add(edgeEvent);
ctx.getStatsCounterService().ifPresent(statsCounterService ->
statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_ADDED, state.getEdge().getTenantId(), edgeEvent.getEdgeId(), 1));
}
@Override
public void processHighPriorityEvents() {
try {
if (state.isConnected() && !state.isSyncInProgress()) {
if (highPriorityQueue.isEmpty()) {
return;
}
List<EdgeEvent> highPriorityEvents = new ArrayList<>();
EdgeEvent event;
while ((event = highPriorityQueue.poll()) != null) {
highPriorityEvents.add(event);
}
log.trace("[{}][{}] Sending high priority events {}", getTenantId(), getEdgeId(), highPriorityEvents.size());
List<DownlinkMsg> downlinkMsgsPack = downlinkMessageMapper.convertToDownlinkMsgsPack(state, highPriorityEvents);
sendDownlinkMsgsPack(downlinkMsgsPack).get();
}
} catch (Exception e) {
log.error("[{}] Failed to process high priority events", getEdgeId(), e);
}
}
@Override
public boolean hasHighPriorityEvents() {
return !highPriorityQueue.isEmpty();
}
@Override
public ListenableFuture<Pair<Long, Long>> fetchAndSendEdgeEvents(EdgeEventFetcher fetcher) {
SettableFuture<Pair<Long, Long>> result = SettableFuture.create();
PageLink pageLink = fetcher.getPageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount());
fetchAndSendEdgeEvents(fetcher, pageLink, result);
return result;
}
@Override
public ListenableFuture<Boolean> sendDownlinkMsgsPack(List<DownlinkMsg> downlinkMsgsPack) {
interruptPreviousSendDownlinkMsgsTask();
state.setSendDownlinkMsgsFuture(SettableFuture.create());
state.getPendingMsgsMap().clear();
downlinkMsgsPack.forEach(msg -> state.getPendingMsgsMap().put(msg.getDownlinkMsgId(), msg));
scheduleDownlinkMsgsPackSend(1);
return state.getSendDownlinkMsgsFuture();
}
@Override
public void close() {
log.debug("[{}][{}] Closing session", getTenantId(), getSessionId());
state.setConnected(false);
try {
outputStream.onCompleted();
} catch (Exception e) {
log.debug("[{}][{}] Failed to close output stream: {}", getTenantId(), getSessionId(), e.getMessage());
}
}
private void onUplinkMsg(UplinkMsg uplinkMsg) {
if (isRateLimitViolated(uplinkMsg)) {
return;
}
Futures.addCallback(uplinkMessageDispatcher.processUplinkMsg(state, uplinkMsg), new FutureCallback<>() {
@Override
public void onSuccess(@Nullable List<Void> result) {
sendResponseMessage(uplinkMsg.getUplinkMsgId(), true, null);
}
@Override
public void onFailure(Throwable t) {
String errorMsg = EdgeUtils.createErrorMsgFromRootCauseAndStackTrace(t);
sendResponseMessage(uplinkMsg.getUplinkMsgId(), false, errorMsg);
}
}, ctx.getGrpcCallbackExecutorService());
}
private boolean isRateLimitViolated(UplinkMsg uplinkMsg) {
if (!ctx.getRateLimitService().checkRateLimit(LimitedApi.EDGE_UPLINK_MESSAGES, getTenantId()) ||
!ctx.getRateLimitService().checkRateLimit(LimitedApi.EDGE_UPLINK_MESSAGES_PER_EDGE, getTenantId(), getEdgeId())) {
String errorMsg = String.format("Failed to process uplink message. %s", RATE_LIMIT_REACHED);
sendResponseMessage(uplinkMsg.getUplinkMsgId(), false, errorMsg);
return true;
}
return false;
}
private void sendResponseMessage(int uplinkMsgId, boolean success, String errorMsg) {
UplinkResponseMsg.Builder responseBuilder = UplinkResponseMsg.newBuilder()
.setUplinkMsgId(uplinkMsgId)
.setSuccess(success);
if (errorMsg != null) {
responseBuilder.setErrorMsg(errorMsg);
}
sendDownlinkMsg(ResponseMsg.newBuilder()
.setUplinkResponseMsg(responseBuilder.build())
.build());
}
private void onDownlinkResponse(DownlinkResponseMsg msg) {
try {
if (msg.getSuccess()) {
state.getPendingMsgsMap().remove(msg.getDownlinkMsgId());
ctx.getStatsCounterService().ifPresent(statsCounterService -> statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PUSHED, getTenantId(), getEdgeId(), 1));
log.debug("[{}][{}][{}] Msg has been processed successfully! Msg Id: [{}], Msg: {}", getTenantId(), getEdgeId(), getSessionId(), msg.getDownlinkMsgId(), msg);
} else {
log.debug("[{}][{}][{}] Msg processing failed! Msg Id: [{}], Error msg: {}", getTenantId(), getEdgeId(), getSessionId(), msg.getDownlinkMsgId(), msg.getErrorMsg());
DownlinkMsg downlinkMsg = state.getPendingMsgsMap().get(msg.getDownlinkMsgId());
// if NOT timeseries or attributes failures - ack failed downlink
if (downlinkMsg != null && downlinkMsg.getEntityDataCount() == 0) {
state.getPendingMsgsMap().remove(msg.getDownlinkMsgId());
}
}
if (state.getPendingMsgsMap().isEmpty()) {
log.debug("[{}][{}][{}] Pending msgs map is empty. Stopping current iteration", getTenantId(), getEdgeId(), getSessionId());
stopCurrentSendDownlinkMsgsTask(false);
}
} catch (Exception e) {
log.error("[{}][{}] Can't process downlink response message [{}]", getTenantId(), getEdgeId(), msg, e);
}
}
private void interruptGeneralProcessingOnSync() {
log.debug("[{}][{}][{}] Sync process started. General processing interrupted!", getTenantId(), getEdgeId(), getSessionId());
stopCurrentSendDownlinkMsgsTask(true);
}
private void doSync(EdgeSyncCursor cursor) {
if (cursor.hasNext()) {
EdgeEventFetcher next = cursor.getNext();
log.debug("[{}][{}] starting sync process, cursor current idx = {}, class = {}",
getTenantId(), getEdgeId(), cursor.getCurrentIdx(), next.getClass().getSimpleName());
ListenableFuture<Pair<Long, Long>> future = fetchAndSendEdgeEvents(next);
Futures.addCallback(future, new FutureCallback<>() {
@Override
public void onSuccess(@Nullable Pair<Long, Long> result) {
doSync(cursor);
}
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Exception during sync process", getTenantId(), getEdgeId(), t);
}
}, ctx.getGrpcCallbackExecutorService());
} else {
log.info("[{}][{}] sync process completed", getTenantId(), getEdgeId());
DownlinkMsg syncCompleteDownlinkMsg = DownlinkMsg.newBuilder()
.setDownlinkMsgId(EdgeUtils.nextPositiveInt())
.setSyncCompletedMsg(SyncCompletedMsg.newBuilder().build())
.build();
Futures.addCallback(sendDownlinkMsgsPack(Collections.singletonList(syncCompleteDownlinkMsg)), new FutureCallback<>() {
@Override
public void onSuccess(Boolean isInterrupted) {
markSyncCompletedSendEdgeEventUpdate();
}
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Exception during sending sync complete", getTenantId(), getEdgeId(), t);
markSyncCompletedSendEdgeEventUpdate();
}
}, ctx.getGrpcCallbackExecutorService());
}
}
private void markSyncCompletedSendEdgeEventUpdate() {
state.finishSync();
saveSyncInProgressAsAttribute(false);
ctx.getClusterService().onEdgeEventUpdate(new EdgeEventUpdateMsg(getTenantId(), getEdgeId()));
}
private void fetchAndSendEdgeEvents(EdgeEventFetcher fetcher, PageLink pageLink, SettableFuture<Pair<Long, Long>> result) {
Edge edge = state.getEdge();
TenantId tenantId = getTenantId();
try {
log.trace("[{}] Start processing edge events, fetcher = {}, pageLink = {}", edge.getId(), fetcher.getClass().getSimpleName(), pageLink);
processHighPriorityEvents();
PageData<EdgeEvent> pageData = fetcher.fetchEdgeEvents(edge.getTenantId(), edge, pageLink);
if (state.isConnected() && !pageData.getData().isEmpty()) {
if (fetcher instanceof GeneralEdgeEventFetcher) {
long queueSize = pageData.getTotalElements() - ((long) pageLink.getPageSize() * pageLink.getPage());
ctx.getStatsCounterService().ifPresent(statsCounterService ->
statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_LAG, tenantId, edge.getId(), queueSize));
}
log.trace("[{}][{}][{}] event(s) are going to be processed.", tenantId, edge.getId(), pageData.getData().size());
List<DownlinkMsg> downlinkMsgsPack = downlinkMessageMapper.convertToDownlinkMsgsPack(state, pageData.getData());
Futures.addCallback(sendDownlinkMsgsPack(downlinkMsgsPack), new FutureCallback<>() {
@Override
public void onSuccess(@Nullable Boolean isInterrupted) {
if (Boolean.TRUE.equals(isInterrupted)) {
log.debug("[{}][{}][{}] Send downlink messages task was interrupted", tenantId, edge.getId(), getSessionId());
result.set(null);
} else {
if (state.isConnected() && pageData.hasNext()) {
fetchAndSendEdgeEvents(fetcher, pageLink.nextPageLink(), result);
} else {
EdgeEvent latestEdgeEvent = pageData.getData().get(pageData.getData().size() - 1);
UUID idOffset = latestEdgeEvent.getUuidId();
if (idOffset != null) {
Long newStartTs = Uuids.unixTimestamp(idOffset);
long newStartSeqId = latestEdgeEvent.getSeqId();
result.set(Pair.of(newStartTs, newStartSeqId));
} else {
result.set(null);
}
}
}
}
@Override
public void onFailure(Throwable t) {
log.error("[{}] Failed to send downlink msgs pack", edge.getId(), t);
result.setException(t);
}
}, ctx.getGrpcCallbackExecutorService());
} else {
log.trace("[{}] no event(s) found. Stop processing edge events, fetcher = {}, pageLink = {}", edge.getId(), fetcher.getClass().getSimpleName(), pageLink);
result.set(null);
}
} catch (Exception e) {
log.error("[{}] Failed to fetch edge events", edge.getId(), e);
result.setException(e);
}
}
private void scheduleDownlinkMsgsPackSend(int attempt) {
Runnable sendDownlinkMsgsTask = () -> {
try {
if (!state.isConnected()) {
stopCurrentSendDownlinkMsgsTask(true);
return;
}
if (!state.getPendingMsgsMap().values().isEmpty()) {
Edge edge = state.getEdge();
List<DownlinkMsg> copy = new ArrayList<>(state.getPendingMsgsMap().values());
if (attempt > 1) {
String error = "Failed to deliver the batch";
String failureMsg = String.format("{%s} (size: {%s})", error, copy.size());
if (attempt == 2) {
// Send a failure notification only on the second attempt.
// This ensures that failure alerts are sent just once to avoid redundant notifications.
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(getTenantId())
.edgeId(getEdgeId()).customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(error).build());
}
ctx.getStatsCounterService().ifPresent(statsCounterService ->
statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_TMP_FAILED, edge.getTenantId(), getEdgeId(), 1));
log.warn("[{}][{}] {} on attempt {}", getTenantId(), getEdgeId(), failureMsg, attempt);
log.debug("[{}][{}] entities in failed batch: {}", getTenantId(), getEdgeId(), copy);
}
log.trace("[{}][{}][{}] downlink msg(s) are going to be send.", getTenantId(), getEdgeId(), copy.size());
for (DownlinkMsg downlinkMsg : copy) {
if (clientMaxInboundMessageSize != 0 && downlinkMsg.getSerializedSize() > clientMaxInboundMessageSize) {
String error = String.format("Client max inbound message size %s is exceeded. Please increase value of CLOUD_RPC_MAX_INBOUND_MESSAGE_SIZE " +
"env variable on the edge and restart it.", clientMaxInboundMessageSize);
String message = String.format("Downlink msg size %s exceeds client max inbound message size %s. " +
"Please increase value of CLOUD_RPC_MAX_INBOUND_MESSAGE_SIZE env variable on the edge and restart it.", downlinkMsg.getSerializedSize(), clientMaxInboundMessageSize);
log.error("[{}][{}][{}] {} Message {}", getTenantId(), getEdgeId(), getSessionId(), message, downlinkMsg);
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(getTenantId())
.edgeId(getEdgeId()).customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(message).error(error).build());
ctx.getStatsCounterService().ifPresent(statsCounterService ->
statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PERMANENTLY_FAILED, edge.getTenantId(), getEdgeId(), 1));
state.getPendingMsgsMap().remove(downlinkMsg.getDownlinkMsgId());
} else {
sendDownlinkMsg(ResponseMsg.newBuilder()
.setDownlinkMsg(downlinkMsg)
.build());
}
}
if (attempt < MAX_DOWNLINK_ATTEMPTS) {
scheduleDownlinkMsgsPackSend(attempt + 1);
} else {
String failureMsg = String.format("Failed to deliver messages: %s", copy);
log.warn("[{}][{}] Failed to deliver the batch after {} attempts. Next messages are going to be discarded {}",
getTenantId(), getEdgeId(), MAX_DOWNLINK_ATTEMPTS, copy);
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(getTenantId()).edgeId(getEdgeId())
.customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg)
.error("Failed to deliver messages after " + MAX_DOWNLINK_ATTEMPTS + " attempts").build());
ctx.getStatsCounterService().ifPresent(statsCounterService ->
statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PERMANENTLY_FAILED, edge.getTenantId(), getEdgeId(), copy.size()));
stopCurrentSendDownlinkMsgsTask(false);
}
} else {
stopCurrentSendDownlinkMsgsTask(false);
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to send downlink msgs. Error msg {}", getTenantId(), getEdgeId(), e.getMessage(), e);
stopCurrentSendDownlinkMsgsTask(true);
}
};
if (attempt == 1) {
sendDownlinkExecutorService.submit(sendDownlinkMsgsTask);
} else {
state.setScheduledSendDownlinkTask(
sendDownlinkExecutorService.schedule(
sendDownlinkMsgsTask,
ctx.getEdgeEventStorageSettings().getSleepIntervalBetweenBatches(),
TimeUnit.MILLISECONDS)
);
}
}
private void interruptPreviousSendDownlinkMsgsTask() {
if (state.getSendDownlinkMsgsFuture() != null && !state.getSendDownlinkMsgsFuture().isDone()
|| state.getScheduledSendDownlinkTask() != null && !state.getScheduledSendDownlinkTask().isCancelled()) {
log.debug("[{}][{}][{}] Previous send downlink future was not properly completed, stopping it now!",
getTenantId(), getEdgeId(), getSessionId());
stopCurrentSendDownlinkMsgsTask(true);
} else {
log.trace("[{}][{}][{}] Previous send downlink future is not active",
getTenantId(), getEdgeId(), getSessionId());
}
}
private void stopCurrentSendDownlinkMsgsTask(Boolean isInterrupted) {
if (state.getSendDownlinkMsgsFuture() != null && !state.getSendDownlinkMsgsFuture().isDone()) {
state.getSendDownlinkMsgsFuture().set(isInterrupted);
}
if (state.getScheduledSendDownlinkTask() != null) {
state.getScheduledSendDownlinkTask().cancel(true);
}
}
private ConnectResponseMsg processConnect(ConnectRequestMsg request) {
log.trace("[{}] processConnect [{}]", getSessionId(), request);
Optional<Edge> optional = ctx.getEdgeService().findEdgeByRoutingKey(TenantId.SYS_TENANT_ID, request.getEdgeRoutingKey());
if (optional.isPresent()) {
Edge edge = optional.get();
TenantId tenantId = edge.getTenantId();
state.setEdge(edge);
try {
if (edge.getSecret().equals(request.getEdgeSecret())) {
sessionOpenListener.accept(edge.getId(), parentManagerRef);
state.setEdgeVersion(request.getEdgeVersion());
processSaveEdgeVersionAsAttribute(request.getEdgeVersion().name());
return ConnectResponseMsg.newBuilder()
.setResponseCode(ConnectResponseCode.ACCEPTED)
.setErrorMsg("")
.setConfiguration(EdgeMsgConstructorUtils.constructEdgeConfiguration(edge))
.setMaxInboundMessageSize(maxInboundMessageSize)
.build();
}
String error = "Failed to validate the edge!";
String failureMsg = String.format("%s Provided request secret: %s", error, request.getEdgeSecret());
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId())
.customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(error).build());
return ConnectResponseMsg.newBuilder()
.setResponseCode(ConnectResponseCode.BAD_CREDENTIALS)
.setErrorMsg(failureMsg)
.setConfiguration(EdgeConfiguration.getDefaultInstance()).build();
} catch (Exception e) {
String failureMsg = "Failed to process edge connection!";
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId())
.customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(e.getMessage()).build());
log.error(failureMsg, e);
return ConnectResponseMsg.newBuilder()
.setResponseCode(ConnectResponseCode.SERVER_UNAVAILABLE)
.setErrorMsg(failureMsg)
.setConfiguration(EdgeConfiguration.getDefaultInstance()).build();
}
}
return ConnectResponseMsg.newBuilder()
.setResponseCode(ConnectResponseCode.BAD_CREDENTIALS)
.setErrorMsg("Failed to find the edge! Routing key: " + request.getEdgeRoutingKey())
.setConfiguration(EdgeConfiguration.getDefaultInstance()).build();
}
private void processSaveEdgeVersionAsAttribute(String edgeVersion) {
AttributeKvEntry attributeKvEntry = new BaseAttributeKvEntry(new StringDataEntry(DataConstants.EDGE_VERSION_ATTR_KEY, edgeVersion), System.currentTimeMillis());
ctx.getAttributesService().save(getTenantId(), getEdgeId(), AttributeScope.SERVER_SCOPE, attributeKvEntry);
}
private void saveSyncInProgressAsAttribute(Boolean value) {
ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder()
.tenantId(getTenantId())
.entityId(getEdgeId())
.scope(AttributeScope.SERVER_SCOPE)
.entry(new BooleanDataEntry(DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY, value))
.callback(new EdgeAttributeSaveCallback(getTenantId(), getEdgeId(), DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY, value))
.build());
}
private TenantId getTenantId() {
return state.getTenantId();
}
private EdgeId getEdgeId() {
return state.getEdgeId();
}
private UUID getSessionId() {
return state.getSessionId();
}
}

34
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSessionDelegate.java

@ -0,0 +1,34 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
public abstract class EdgeGrpcSessionDelegate implements EdgeGrpcSessionManager {
protected abstract EdgeSession getSession();
@Override
public void addEventToHighPriorityQueue(EdgeEvent edgeEvent) {
getSession().addHighPriorityEvent(edgeEvent);
}
@Override
public void startSyncProcess(boolean fullSync) {
getSession().startSyncProcess(fullSync);
}
}

43
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSession.java

@ -0,0 +1,43 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import com.google.common.util.concurrent.ListenableFuture;
import io.grpc.stub.StreamObserver;
import org.springframework.data.util.Pair;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.fetch.EdgeEventFetcher;
import java.io.Closeable;
import java.util.List;
public interface EdgeSession extends Closeable {
StreamObserver<RequestMsg> initInputStream();
EdgeSessionState getState();
void startSyncProcess(boolean fullSync);
void sendDownlinkMsg(ResponseMsg responseMsg);
void addHighPriorityEvent(EdgeEvent edgeEvent);
void processHighPriorityEvents();
boolean hasHighPriorityEvents();
ListenableFuture<Pair<Long, Long>> fetchAndSendEdgeEvents(EdgeEventFetcher fetcher);
ListenableFuture<Boolean> sendDownlinkMsgsPack(List<DownlinkMsg> downlinkMsgsPack);
}

83
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSessionsHolder.java

@ -0,0 +1,83 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Consumer;
@Data
@Slf4j
@Component
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class EdgeSessionsHolder {
private final ConcurrentMap<EdgeId, EdgeGrpcSessionManager> sessions = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, EdgeGrpcSessionManager> sessionsById = new ConcurrentHashMap<>();
public void forEach(Consumer<EdgeGrpcSessionManager> consumer) {
Set<EdgeGrpcSessionManager> unique = new HashSet<>(sessions.values());
unique.addAll(sessionsById.values());
unique.forEach(consumer);
}
public void put(EdgeGrpcSessionManager session) {
UUID sessionId = session.getState().getSessionId();
EdgeId edgeId = session.getState().getEdgeId();
sessionsById.put(sessionId, session);
sessions.put(edgeId, session);
}
public EdgeGrpcSessionManager getByEdgeId(EdgeId id) {
return sessions.get(id);
}
public boolean hasByEdgeId(EdgeId id) {
return sessions.containsKey(id);
}
public EdgeGrpcSessionManager removeByEdgeId(EdgeId id) {
return sessions.remove(id);
}
public EdgeGrpcSessionManager removeBySessionId(UUID sessionId) {
return sessionsById.remove(sessionId);
}
public void remove(EdgeGrpcSessionManager session) {
if (session == null) {
log.warn("Can't remove session from holder because it's null");
return;
}
EdgeSessionState sessionState = session.getState();
removeByEdgeId(sessionState.getEdgeId());
removeBySessionId(sessionState.getSessionId());
}
}

23
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/ZombieSessionCleanupService.java

@ -0,0 +1,23 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
public interface ZombieSessionCleanupService {
void add(EdgeGrpcSessionManager session);
}

149
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/AbstractEdgeGrpcSessionManager.java

@ -0,0 +1,149 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session.manager;
import io.grpc.stub.StreamObserver;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.gen.edge.v1.EdgeUpdateMsg;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils;
import org.thingsboard.server.service.edge.rpc.DownlinkMessageMapper;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.EdgeUplinkMessageDispatcher;
import org.thingsboard.server.service.edge.rpc.session.EdgeGrpcSession;
import org.thingsboard.server.service.edge.rpc.session.EdgeGrpcSessionDelegate;
import org.thingsboard.server.service.edge.rpc.session.EdgeSession;
import org.thingsboard.server.service.edge.rpc.session.ZombieSessionCleanupService;
import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
@Slf4j
public abstract class AbstractEdgeGrpcSessionManager extends EdgeGrpcSessionDelegate implements EdgeGrpcSessionManager {
@Value("${edges.max_high_priority_queue_size_per_session:10000}")
protected int maxHighPriorityQueueSizePerSession;
@Value("${edges.rpc.max_inbound_message_size:4194304}")
protected int maxInboundMessageSize;
@Autowired
protected ZombieSessionCleanupService zombieSessionCleanupService;
@Autowired
@Lazy
protected EdgeContextComponent ctx;
@Autowired
protected DownlinkMessageMapper downlinkMessageMapper;
@Autowired
protected EdgeUplinkMessageDispatcher uplinkMessageDispatcher;
@Getter
protected EdgeSession session;
private final Lock initLock = new ReentrantLock();
@Override
public StreamObserver<RequestMsg> initInputStream(StreamObserver<ResponseMsg> outputStream,
BiConsumer<EdgeId, EdgeGrpcSessionManager> sessionOpenListener,
BiConsumer<Edge, UUID> sessionCloseListener,
ScheduledExecutorService sendDownlinkExecutorService) {
if (session != null) {
throw new IllegalStateException("Session already initialized");
}
initLock.lock();
try {
this.session = new EdgeGrpcSession(
this, ctx, outputStream, downlinkMessageMapper, uplinkMessageDispatcher,
sessionOpenListener, sessionCloseListener,
sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession);
return session.initInputStream();
} finally {
initLock.unlock();
}
}
@Override
public EdgeSessionState getState() {
if (session == null) {
return null;
}
return session.getState();
}
@Override
public void onConfigurationUpdate(Edge edge) {
EdgeSessionState state = getState();
if (state == null) {
log.warn("Session state is null, skipping configuration update for edge [{}]", edge.getId());
return;
}
CustomerId stateCustomerId = state.getEdge().getCustomerId();
state.setEdge(edge);
if (stateCustomerId != null && !stateCustomerId.equals(edge.getCustomerId())) {
// do not send edge configuration message on customer update
// message send by separate flow from assign_to or unassing_from customer
return;
}
EdgeUpdateMsg edgeConfig = EdgeUpdateMsg.newBuilder()
.setConfiguration(EdgeMsgConstructorUtils.constructEdgeConfiguration(edge)).build();
ResponseMsg edgeConfigMsg = ResponseMsg.newBuilder()
.setEdgeUpdateMsg(edgeConfig)
.build();
session.sendDownlinkMsg(edgeConfigMsg);
}
@Override
public void destroyAndMarkAsZombieIfFailed() {
EdgeSessionState state = getState();
final var finalSession = session;
try (finalSession) {
if (!destroy()) {
if (state != null) {
log.warn("[{}][{}] Session destroy failed for edge [{}] with session id [{}]. Adding to zombie queue for later cleanup.",
state.getTenantId(), state.getEdgeId(), state.getEdge().getName(), state.getSessionId());
} else {
log.warn("Session destroy failed (state is null). Adding to zombie queue for later cleanup.");
}
zombieSessionCleanupService.add(this);
}
} catch (Exception e) {
if (state != null) {
log.warn("[{}][{}] Exception during session destroy for edge [{}] with session id [{}]",
state.getTenantId(), state.getEdgeId(), state.getEdge().getName(), state.getSessionId(), e);
} else {
log.warn("Exception during session destroy (state is null)", e);
}
}
}
}

47
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/EdgeGrpcSessionManager.java

@ -0,0 +1,47 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session.manager;
import io.grpc.stub.StreamObserver;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.BiConsumer;
public interface EdgeGrpcSessionManager {
StreamObserver<RequestMsg> initInputStream(StreamObserver<ResponseMsg> outputStream,
BiConsumer<EdgeId, EdgeGrpcSessionManager> sessionOpenListener,
BiConsumer<Edge, UUID> sessionCloseListener,
ScheduledExecutorService sendDownlinkExecutorService);
EdgeSessionState getState();
void addEventToHighPriorityQueue(EdgeEvent edgeEvent);
void startSyncProcess(boolean fullSync);
void onEdgeConnect();
void onEdgeEventUpdate();
void onConfigurationUpdate(Edge edge);
void onEdgeDisconnect();
void onEdgeRemoval();
void destroyAndMarkAsZombieIfFailed();
boolean destroy();
}

313
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java

@ -0,0 +1,313 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session.manager;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg;
import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import org.thingsboard.server.queue.common.consumer.QueueConsumerManager;
import org.thingsboard.server.queue.discovery.TopicService;
import org.thingsboard.server.queue.kafka.KafkaAdmin;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.queue.util.TbKafkaComponent;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.processor.PostgresGeneralEdgeEventsDispatcher;
import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Service
@Slf4j
@TbKafkaComponent
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@RequiredArgsConstructor
public class KafkaBasedEdgeGrpcSessionManager extends AbstractEdgeGrpcSessionManager {
private static final int NO_INITIAL_DELAY_VALUE = 0;
private final TbCoreQueueFactory tbCoreQueueFactory;
private final TopicService topicService;
private final KafkaAdmin kafkaAdmin;
private final EdgeSessionsHolder sessions;
private final AtomicReference<ScheduledFuture<?>> initMigrationAndProcessingFutureRef = new AtomicReference<>();
private final AtomicReference<ScheduledFuture<?>> highPriorityProcessingFutureRef = new AtomicReference<>();
private final Lock initLock = new ReentrantLock();
private volatile boolean migrationProcessed;
private volatile boolean isHighPriorityProcessing;
@Getter
private QueueConsumerManager<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> consumer;
private PostgresGeneralEdgeEventsDispatcher psqlGeneralEdgeEventsDispatcher;
private ExecutorService consumerExecutor;
@Override
public void onEdgeConnect() {
scheduleMigrationAndProcessing();
}
@Override
public void onEdgeEventUpdate() {}
@Override
public void onEdgeDisconnect() {}
@Override
public void onEdgeRemoval() {
EdgeSessionState state = getState();
String topic = topicService
.buildEdgeEventNotificationsTopicPartitionInfo(state.getTenantId(), state.getEdgeId())
.getTopic();
kafkaAdmin.deleteTopic(topic);
kafkaAdmin.deleteConsumerGroup(topic);
}
@Override
public boolean destroy() {
cancelHighPriorityProcessing();
EdgeSessionState state = getState();
try {
if (consumer != null) {
log.info("[{}][{}] Stopping edge event consumer...", state.getTenantId(), state.getEdgeId());
consumer.stop();
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to stop edge event consumer", state.getTenantId(), state.getEdgeId(), e);
return false;
}
consumer = null;
try {
if (consumerExecutor != null && !consumerExecutor.isShutdown()) {
consumerExecutor.shutdown();
awaitConsumerTermination();
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to shutdown edge event consumer executor", state.getTenantId(), state.getEdgeId(), e);
return false;
}
return true;
}
private void scheduleMigrationAndProcessing() {
cancelMigrationAndProcessingInit();
EdgeSessionState state = getSession().getState();
TenantId tenantId = state.getTenantId();
EdgeId edgeId = state.getEdgeId();
if (!sessions.hasByEdgeId(edgeId)) {
log.debug("[{}] Session was removed and edge event consumer must not be started [{}]",
tenantId, edgeId.getId());
return;
}
ScheduledFuture<?> initMigrationAndProcessingTask = ctx.getEdgeEventProcessingExecutorService().schedule(() -> {
log.info("[{}] Edge event processing task started for edge [{}]", tenantId, edgeId);
initLock.lock();
try {
processRemainingPostgresEventsIfRequired(edgeId);
if (migrationProcessed) {
initAndLaunchConsumerOrRetry(tenantId, edgeId, state);
}
} catch (Exception e) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, e);
} finally {
initLock.unlock();
}
}, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS);
initMigrationAndProcessingFutureRef.set(initMigrationAndProcessingTask);
}
private void initAndLaunchConsumerOrRetry(TenantId tenantId, EdgeId edgeId, EdgeSessionState state) {
if (initAndLaunchConsumer(tenantId, edgeId, state)) {
launchProcessingOfHighPriorityEvents(tenantId, edgeId);
} else {
scheduleMigrationAndProcessing();
}
}
private void launchProcessingOfHighPriorityEvents(TenantId tenantId, EdgeId edgeId) {
cancelHighPriorityProcessing();
ScheduledFuture<?> highPriorityProcessingTask = ctx.getEdgeEventProcessingExecutorService().scheduleAtFixedRate(() -> {
try {
isHighPriorityProcessing = true;
session.processHighPriorityEvents();
} catch (Exception e) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, e);
} finally {
isHighPriorityProcessing = false;
}
}, NO_INITIAL_DELAY_VALUE, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS);
highPriorityProcessingFutureRef.set(highPriorityProcessingTask);
}
private boolean initAndLaunchConsumer(TenantId tenantId, EdgeId edgeId, EdgeSessionState state) {
if (!isSessionReady(state)) {
log.warn("[{}][{}] Session is not ready (connected={}, syncInProgress={}, highPriority={}), skip starting edge event consumer",
tenantId, edgeId, state.isConnected(), state.isSyncInProgress(), isHighPriorityProcessing);
return false;
}
if (isConsumerInitializationRequired()) {
initConsumerAndExecutor(tenantId, edgeId, state);
}
return true;
}
private void initConsumerAndExecutor(TenantId tenantId, EdgeId edgeId, EdgeSessionState state) {
try {
if (consumerExecutor != null && !consumerExecutor.isShutdown()) {
try {
consumerExecutor.shutdown();
awaitConsumerTermination();
} catch (Exception e) {
log.warn("[{}][{}] Failed to shutdown previous consumer executor", tenantId, edgeId, e);
}
}
this.consumerExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("edge-event-consumer"));
this.consumer = QueueConsumerManager.<TbProtoQueueMsg<ToEdgeEventNotificationMsg>>builder()
.name("TB Edge events [" + edgeId + "]")
.msgPackProcessor(this::processMsgs)
.pollInterval(ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval())
.consumerCreator(() -> tbCoreQueueFactory.createEdgeEventMsgConsumer(tenantId, edgeId))
.consumerExecutor(consumerExecutor)
.threadPrefix("edge-events-" + edgeId)
.readinessCheck(this::isReadyToProcessGeneralEvents)
.build();
consumer.subscribe();
consumer.launch();
} catch (Exception e) {
destroy();
log.warn("[{}][{}] Failed to start edge event consumer", state.getSessionId(), edgeId, e);
}
}
private boolean isConsumerInitializationRequired() {
return consumer == null || (consumer.getConsumer() != null && consumer.getConsumer().isStopped());
}
private void processMsgs(List<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> msgs, TbQueueConsumer<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> consumer) {
EdgeSessionState state = getSession().getState();
TenantId tenantId = state.getTenantId();
EdgeId edgeId = state.getEdgeId();
log.trace("[{}][{}] starting processing edge events", tenantId, edgeId);
// Defensive backstop: the loop already gates polling on readiness; this only fires on the narrow race
// where readiness flips during poll(), and that already-polled batch is dropped here (can't rewind).
if (!isSessionReady(state)) {
log.debug("[{}][{}] edge not connected, edge sync is not completed or high priority processing in progress, " +
"connected = {}, sync in progress = {}, high priority in progress = {}. Skipping iteration",
tenantId, edgeId, state.isConnected(), state.isSyncInProgress(), isHighPriorityProcessing);
return;
}
List<EdgeEvent> edgeEvents = new ArrayList<>();
for (TbProtoQueueMsg<ToEdgeEventNotificationMsg> msg : msgs) {
EdgeEvent edgeEvent = ProtoUtils.fromProto(msg.getValue().getEdgeEventMsg());
edgeEvents.add(edgeEvent);
}
List<DownlinkMsg> downlinkMsgsPack = downlinkMessageMapper.convertToDownlinkMsgsPack(state, edgeEvents);
boolean isInterrupted = true;
try {
isInterrupted = session.sendDownlinkMsgsPack(downlinkMsgsPack).get();
if (isInterrupted) {
log.debug("[{}][{}] Send downlink messages task was interrupted", tenantId, edgeId);
}
} catch (Exception e) {
log.error("[{}][{}] Failed to process downlink messages", tenantId, edgeId, e);
}
if (!isInterrupted) {
consumer.commit();
}
}
private boolean isReadyToProcessGeneralEvents() {
return isSessionReady(getState());
}
/** Single source of truth for "the session may process general edge events" - see {@link #isReadyToProcessGeneralEvents}. */
private boolean isSessionReady(EdgeSessionState state) {
return state != null && state.isConnected() && !state.isSyncInProgress() && !isHighPriorityProcessing;
}
private void cancelMigrationAndProcessingInit() {
EdgeSessionState state = getState();
log.trace("[{}] cancelling edge migration & processing init for edge", state != null ? state.getEdgeId() : "unknown");
cancelFuture(initMigrationAndProcessingFutureRef);
}
private void cancelHighPriorityProcessing() {
EdgeSessionState state = getState();
log.trace("[{}] cancelling high priority processing task for edge", state != null ? state.getEdgeId() : "unknown");
cancelFuture(highPriorityProcessingFutureRef);
}
private void cancelFuture(AtomicReference<? extends Future<?>> futureReference) {
Future<?> f = futureReference.getAndSet(null);
if (f != null && !f.isCancelled() && !f.isDone()) {
f.cancel(true);
}
}
private void processRemainingPostgresEventsIfRequired(EdgeId edgeId) throws Exception {
log.debug("Processing remaining postgres edge events for edge [{}]", edgeId);
if (psqlGeneralEdgeEventsDispatcher == null) {
psqlGeneralEdgeEventsDispatcher = new PostgresGeneralEdgeEventsDispatcher(session, ctx);
}
if (migrationProcessed) {
return;
}
Boolean hasMorePostgresEvents = psqlGeneralEdgeEventsDispatcher.processNewEvents().get();
if (Boolean.TRUE.equals(hasMorePostgresEvents)) {
log.info("Migration still in progress for edge [{}]", edgeId.getId());
scheduleMigrationAndProcessing();
} else if (Boolean.FALSE.equals(hasMorePostgresEvents)) {
log.info("Migration completed for edge [{}]", edgeId.getId());
migrationProcessed = true;
}
}
private void awaitConsumerTermination() {
EdgeSessionState state = getState();
try {
if (!consumerExecutor.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)) {
consumerExecutor.shutdownNow();
}
} catch (InterruptedException ie) {
log.warn("[{}][{}] Interrupted while awaiting consumer executor termination", state.getTenantId(), state.getEdgeId());
Thread.currentThread().interrupt();
}
}
}

183
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/PostgresBasedEdgeGrpcSessionManager.java

@ -0,0 +1,183 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session.manager;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.processor.PostgresGeneralEdgeEventsDispatcher;
import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Service
@Slf4j
@ConditionalOnExpression("'${queue.type:null}'!='kafka'")
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@RequiredArgsConstructor
public class PostgresBasedEdgeGrpcSessionManager extends AbstractEdgeGrpcSessionManager {
private final EdgeSessionsHolder edgeSessions;
private final Lock newEventsLock = new ReentrantLock();
private final AtomicReference<ScheduledFuture<?>> edgeEventCheckFutureRef = new AtomicReference<>();
private PostgresGeneralEdgeEventsDispatcher generalEdgeEventsDispatcher;
private volatile boolean hasNewEvents;
@Override
public void addEventToHighPriorityQueue(EdgeEvent edgeEvent) {
super.addEventToHighPriorityQueue(edgeEvent);
markHasNewEvents();
}
@Override
public void onEdgeConnect() {
markHasNewEvents();
scheduleEdgeEventsCheck();
}
@Override
public void onEdgeEventUpdate() {
markHasNewEvents();
}
@Override
public void onEdgeDisconnect() {
markHasNoEvents();
cancelScheduleEdgeEventsCheck();
}
@Override
public void onEdgeRemoval() {
markHasNoEvents();
cancelScheduleEdgeEventsCheck();
}
@Override
public boolean destroy() {
markHasNoEvents();
cancelScheduleEdgeEventsCheck();
return true;
}
private void scheduleEdgeEventsCheck() {
cancelScheduleEdgeEventsCheck();
EdgeId edgeId = getState().getEdgeId();
TenantId tenantId = getState().getTenantId();
if (!edgeSessions.hasByEdgeId(edgeId)) {
log.debug("[{}] Session was removed and edge event check schedule must not be started [{}]",
tenantId, edgeId.getId());
return;
}
ScheduledFuture<?> edgeEventCheckTask = ctx.getEdgeEventProcessingExecutorService().schedule(() -> {
try {
newEventsLock.lock();
try {
if (!hasNewEvents) {
scheduleEdgeEventsCheck();
return;
}
log.trace("[{}][{}] set session new events flag to false", tenantId, edgeId.getId());
hasNewEvents = false;
session.processHighPriorityEvents();
processEdgeEventsWithCallback(tenantId, edgeId);
} finally {
newEventsLock.unlock();
}
} catch (Exception e) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, e);
}
}, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS);
edgeEventCheckFutureRef.set(edgeEventCheckTask);
log.trace("[{}] Check edge event scheduled for edge [{}]", tenantId, edgeId.getId());
}
private void processEdgeEventsWithCallback(TenantId tenantId, EdgeId edgeId) throws Exception {
Futures.addCallback(processEdgeEvents(), new FutureCallback<>() {
@Override
public void onSuccess(Boolean newEventsAdded) {
if (Boolean.TRUE.equals(newEventsAdded)) {
log.trace("[{}][{}] new events added. set session new events flag to true", tenantId, edgeId.getId());
hasNewEvents = true;
}
scheduleEdgeEventsCheck();
}
@Override
public void onFailure(Throwable t) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, t);
scheduleEdgeEventsCheck();
}
}, ctx.getGrpcCallbackExecutorService());
}
public ListenableFuture<Boolean> processEdgeEvents() throws Exception {
if (generalEdgeEventsDispatcher == null) {
generalEdgeEventsDispatcher = new PostgresGeneralEdgeEventsDispatcher(session, ctx);
}
return generalEdgeEventsDispatcher.processNewEvents();
}
private void cancelScheduleEdgeEventsCheck() {
EdgeSessionState state = getState();
log.trace("[{}] cancelling edge event check for edge", state != null ? state.getEdgeId() : "unknown");
ScheduledFuture<?> sf = edgeEventCheckFutureRef.getAndSet(null);
if (sf != null && !sf.isCancelled() && !sf.isDone()) {
sf.cancel(true);
}
}
private void markHasNoEvents() {
markHasEvents(false);
}
private void markHasNewEvents() {
markHasEvents(true);
}
private void markHasEvents(boolean newEventsPresent) {
newEventsLock.lock();
try {
if (hasNewEvents != newEventsPresent) {
EdgeSessionState state = getState();
if (state != null) {
log.trace("[{}] set session new events flag to {} [{}]", state.getTenantId(), newEventsPresent, state.getEdgeId());
}
hasNewEvents = newEventsPresent;
}
} finally {
newEventsLock.unlock();
}
}
}

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

@ -32,8 +32,8 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti
// map of versions from which the upgrade to the current version is possible // map of versions from which the upgrade to the current version is possible
// key - supported version prefix, value - display name // key - supported version prefix, value - display name
private static final Map<String, String> SUPPORTED_VERSIONS_FOR_UPGRADE = Map.of( private static final Map<String, String> SUPPORTED_VERSIONS_FOR_UPGRADE = Map.of(
"4.2.1", "4.2.1.x", "4.3.0", "4.3.0.x",
"4.2.2", "4.2.2.x" "4.3.1", "4.3.1.x"
); );
private final ProjectInfo projectInfo; private final ProjectInfo projectInfo;

2
application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java

@ -53,7 +53,7 @@ import org.thingsboard.server.common.data.alarm.rule.AlarmRule;
import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue;
import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate;

8
application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java

@ -31,6 +31,7 @@ import org.thingsboard.server.service.component.RuleNodeClassInfo;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.DbUpgradeExecutorService; import org.thingsboard.server.service.install.DbUpgradeExecutorService;
import org.thingsboard.server.service.install.lts.LtsMigrationService; import org.thingsboard.server.service.install.lts.LtsMigrationService;
import org.thingsboard.server.service.install.lts.V4_3_1_3Migration;
import org.thingsboard.server.utils.TbNodeUpgradeUtils; import org.thingsboard.server.utils.TbNodeUpgradeUtils;
import java.util.ArrayList; import java.util.ArrayList;
@ -51,11 +52,18 @@ public class DefaultDataUpdateService implements DataUpdateService {
private final DbUpgradeExecutorService executorService; private final DbUpgradeExecutorService executorService;
private final DatabaseSchemaSettingsService schemaSettingsService; private final DatabaseSchemaSettingsService schemaSettingsService;
private final LtsMigrationService ltsMigrationService; private final LtsMigrationService ltsMigrationService;
private final V4_3_1_3Migration v4313Migration;
@Override @Override
public void updateData() throws Exception { public void updateData() throws Exception {
log.info("Updating data ..."); log.info("Updating data ...");
ltsMigrationService.runDataMigrations(schemaSettingsService.getDbSchemaVersion(), schemaSettingsService.getPackageSchemaVersion()); ltsMigrationService.runDataMigrations(schemaSettingsService.getDbSchemaVersion(), schemaSettingsService.getPackageSchemaVersion());
// 4.4-release data step: the 4.3.1.3 data migration (obsolete widget-bundle cleanup) is dormant on 4.4 because
// LtsMigrationService.select() filters out non-target-family migrations and there are no 4.4-family beans, so the
// 4.3-family V4_3_1_3Migration is never selected. The 4.4 SUPPORTED_VERSIONS_FOR_UPGRADE allows upgrading from
// sources below 4.3.1.3, so we run it explicitly for every supported 4.3.x -> 4.4 upgrade. It is idempotent
// (bundle deletion checks existence first), so re-running it for a source already at 4.3.1.3 is a safe no-op.
v4313Migration.apply();
log.info("Data updated."); log.info("Data updated.");
} }

23
application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java

@ -68,6 +68,7 @@ import org.thingsboard.server.service.entitiy.dashboard.TbDashboardService;
import org.thingsboard.server.service.entitiy.device.TbDeviceService; import org.thingsboard.server.service.entitiy.device.TbDeviceService;
import org.thingsboard.server.service.entitiy.device.profile.TbDeviceProfileService; import org.thingsboard.server.service.entitiy.device.profile.TbDeviceProfileService;
import org.thingsboard.server.service.entitiy.widgets.type.TbWidgetTypeService; import org.thingsboard.server.service.entitiy.widgets.type.TbWidgetTypeService;
import org.thingsboard.server.service.install.ProjectInfo;
import org.thingsboard.server.service.rule.TbRuleChainService; import org.thingsboard.server.service.rule.TbRuleChainService;
import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.SecurityUser;
@ -117,6 +118,7 @@ public class DefaultIotHubService implements IotHubService {
private final DeviceService deviceService; private final DeviceService deviceService;
private final TbDeviceService tbDeviceService; private final TbDeviceService tbDeviceService;
private final SolutionService solutionService; private final SolutionService solutionService;
private final ProjectInfo projectInfo;
// Field names of the marketplace version JSON payload. Both the install path and the // Field names of the marketplace version JSON payload. Both the install path and the
// install-plan resolver parse the same shape, so the contract lives here in one place. // install-plan resolver parse the same shape, so the contract lives here in one place.
@ -203,10 +205,11 @@ public class DefaultIotHubService implements IotHubService {
} }
try { try {
iotHubRestClient.reportVersionInstalled(versionId); InstallReport report = buildInstallReport(tenantId.getId(), user.getId().getId(),
projectInfo.getProjectVersion(), projectInfo.getProductType());
iotHubRestClient.reportVersionInstalled(versionId, report);
} catch (Exception e) { } catch (Exception e) {
// Counter ping is best-effort — do not fail the install if it errors. log.warn("[{}] Failed to report install for version {}: {}", tenantId, versionId, e.getMessage());
log.warn("[{}] Failed to report install counter for version {}: {}", tenantId, versionId, e.getMessage());
} }
log.info("[{}] Successfully installed IoT Hub item version: {} (type: {})", tenantId, itemName, itemType); log.info("[{}] Successfully installed IoT Hub item version: {} (type: {})", tenantId, itemName, itemType);
return installedItem; return installedItem;
@ -665,6 +668,13 @@ public class DefaultIotHubService implements IotHubService {
} }
} }
static InstallReport buildInstallReport(UUID tenantId, UUID userId, String tbVersion, String edition) {
String salt = tenantId.toString();
String tenantHash = sha256(salt + tenantId);
String userHash = sha256(salt + userId);
return new InstallReport(tenantHash, userHash, tbVersion, edition);
}
@Override @Override
public InstallItemVersionResult registerDeviceInstall(SecurityUser user, String versionId, DeviceInstalledItemDescriptor descriptor) { public InstallItemVersionResult registerDeviceInstall(SecurityUser user, String versionId, DeviceInstalledItemDescriptor descriptor) {
TenantId tenantId = user.getTenantId(); TenantId tenantId = user.getTenantId();
@ -704,10 +714,11 @@ public class DefaultIotHubService implements IotHubService {
} }
try { try {
iotHubRestClient.reportVersionInstalled(versionId); InstallReport report = buildInstallReport(tenantId.getId(), user.getId().getId(),
projectInfo.getProjectVersion(), projectInfo.getProductType());
iotHubRestClient.reportVersionInstalled(versionId, report);
} catch (Exception e) { } catch (Exception e) {
// Counter ping is best-effort — do not fail the install if it errors. log.warn("[{}] Failed to report install for version {}: {}", tenantId, versionId, e.getMessage());
log.warn("[{}] Failed to report install counter for version {}: {}", tenantId, versionId, e.getMessage());
} }
log.info("[{}] Registered device package install: {} (version {})", tenantId, itemName, version); log.info("[{}] Registered device package install: {} (version {})", tenantId, itemName, version);

19
application/src/main/java/org/thingsboard/server/service/iot_hub/InstallReport.java

@ -0,0 +1,19 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.iot_hub;
public record InstallReport(String tenantHash, String userHash, String tbVersion, String edition) {
}

4
application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java

@ -120,9 +120,9 @@ public class IotHubRestClient {
}); });
} }
public void reportVersionInstalled(String versionId) { public void reportVersionInstalled(String versionId, InstallReport report) {
String url = baseUrl + "/api/versions/" + versionId + "/install"; String url = baseUrl + "/api/versions/" + versionId + "/install";
log.debug("Reporting IoT Hub version installed: {}", url); log.debug("Reporting IoT Hub version installed: {}", url);
restTemplate.postForObject(url, null, Void.class); restTemplate.postForObject(url, report, Void.class);
} }
} }

4
application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java

@ -221,7 +221,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
private EntityDataQuery buildEntityDataQuery(AlarmCountQuery query) { private EntityDataQuery buildEntityDataQuery(AlarmCountQuery query) {
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null,
new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, EntityKeyMapping.CREATED_TIME))); new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, EntityKeyMapping.CREATED_TIME)));
return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters()); return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
} }
private EntityDataQuery buildEntityDataQuery(AlarmDataQuery query) { private EntityDataQuery buildEntityDataQuery(AlarmDataQuery query) {
@ -233,7 +233,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
entitiesSortOrder = sortOrder; entitiesSortOrder = sortOrder;
} }
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder);
return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
} }
@Override @Override

6
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java

@ -145,15 +145,15 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa
private void processMsgs(List<TbProtoQueueMsg<ToCalculatedFieldMsg>> msgs, TbQueueConsumer<TbProtoQueueMsg<ToCalculatedFieldMsg>> consumer, Object consumerKey, QueueConfig config) throws Exception { private void processMsgs(List<TbProtoQueueMsg<ToCalculatedFieldMsg>> msgs, TbQueueConsumer<TbProtoQueueMsg<ToCalculatedFieldMsg>> consumer, Object consumerKey, QueueConfig config) throws Exception {
List<IdMsgPair<ToCalculatedFieldMsg>> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); List<IdMsgPair<ToCalculatedFieldMsg>> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList();
ConcurrentMap<UUID, TbProtoQueueMsg<ToCalculatedFieldMsg>> pendingMap = orderedMsgList.stream().collect( ConcurrentMap<UUID, TbProtoQueueMsg<ToCalculatedFieldMsg>> pendingMap = orderedMsgList.stream().collect(
Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg));
CountDownLatch processingTimeoutLatch = new CountDownLatch(1); CountDownLatch processingTimeoutLatch = new CountDownLatch(1);
TbPackProcessingContext<TbProtoQueueMsg<ToCalculatedFieldMsg>> ctx = new TbPackProcessingContext<>( TbPackProcessingContext<TbProtoQueueMsg<ToCalculatedFieldMsg>> ctx = new TbPackProcessingContext<>(
processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>());
PendingMsgHolder<ToCalculatedFieldMsg> pendingMsgHolder = new PendingMsgHolder<>(); PendingMsgHolder<ToCalculatedFieldMsg> pendingMsgHolder = new PendingMsgHolder<>();
Future<?> packSubmitFuture = consumersExecutor.submit(() -> { Future<?> packSubmitFuture = consumersExecutor.submit(() -> {
orderedMsgList.forEach((element) -> { orderedMsgList.forEach((element) -> {
UUID id = element.getUuid(); UUID id = element.uuid();
TbProtoQueueMsg<ToCalculatedFieldMsg> msg = element.getMsg(); TbProtoQueueMsg<ToCalculatedFieldMsg> msg = element.msg();
log.trace("[{}] Creating main callback for message: {}", id, msg.getValue()); log.trace("[{}] Creating main callback for message: {}", id, msg.getValue());
TbCallback callback = new TbPackCallback<>(id, ctx); TbCallback callback = new TbPackCallback<>(id, ctx);
try { try {

6
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java

@ -258,15 +258,15 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService<ToCore
private void processMsgs(List<TbProtoQueueMsg<ToCoreMsg>> msgs, TbQueueConsumer<TbProtoQueueMsg<ToCoreMsg>> consumer, Object consumerKey, QueueConfig config) throws Exception { private void processMsgs(List<TbProtoQueueMsg<ToCoreMsg>> msgs, TbQueueConsumer<TbProtoQueueMsg<ToCoreMsg>> consumer, Object consumerKey, QueueConfig config) throws Exception {
List<IdMsgPair<ToCoreMsg>> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); List<IdMsgPair<ToCoreMsg>> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList();
ConcurrentMap<UUID, TbProtoQueueMsg<ToCoreMsg>> pendingMap = orderedMsgList.stream().collect( ConcurrentMap<UUID, TbProtoQueueMsg<ToCoreMsg>> pendingMap = orderedMsgList.stream().collect(
Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg));
CountDownLatch processingTimeoutLatch = new CountDownLatch(1); CountDownLatch processingTimeoutLatch = new CountDownLatch(1);
TbPackProcessingContext<TbProtoQueueMsg<ToCoreMsg>> ctx = new TbPackProcessingContext<>( TbPackProcessingContext<TbProtoQueueMsg<ToCoreMsg>> ctx = new TbPackProcessingContext<>(
processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>());
PendingMsgHolder<ToCoreMsg> pendingMsgHolder = new PendingMsgHolder<>(); PendingMsgHolder<ToCoreMsg> pendingMsgHolder = new PendingMsgHolder<>();
Future<?> packSubmitFuture = consumersExecutor.submit(() -> { Future<?> packSubmitFuture = consumersExecutor.submit(() -> {
orderedMsgList.forEach((element) -> { orderedMsgList.forEach((element) -> {
UUID id = element.getUuid(); UUID id = element.uuid();
TbProtoQueueMsg<ToCoreMsg> msg = element.getMsg(); TbProtoQueueMsg<ToCoreMsg> msg = element.msg();
log.trace("[{}] Creating main callback for message: {}", id, msg.getValue()); log.trace("[{}] Creating main callback for message: {}", id, msg.getValue());
TbCallback callback = new TbPackCallback<>(id, ctx); TbCallback callback = new TbPackCallback<>(id, ctx);
try { try {

6
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java

@ -128,15 +128,15 @@ public class DefaultTbEdgeConsumerService extends AbstractConsumerService<ToEdge
private void processMsgs(List<TbProtoQueueMsg<ToEdgeMsg>> msgs, TbQueueConsumer<TbProtoQueueMsg<ToEdgeMsg>> consumer, Object consumerKey, QueueConfig edgeQueueConfig) throws InterruptedException { private void processMsgs(List<TbProtoQueueMsg<ToEdgeMsg>> msgs, TbQueueConsumer<TbProtoQueueMsg<ToEdgeMsg>> consumer, Object consumerKey, QueueConfig edgeQueueConfig) throws InterruptedException {
List<IdMsgPair<ToEdgeMsg>> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); List<IdMsgPair<ToEdgeMsg>> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList();
ConcurrentMap<UUID, TbProtoQueueMsg<ToEdgeMsg>> pendingMap = orderedMsgList.stream().collect( ConcurrentMap<UUID, TbProtoQueueMsg<ToEdgeMsg>> pendingMap = orderedMsgList.stream().collect(
Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg));
CountDownLatch processingTimeoutLatch = new CountDownLatch(1); CountDownLatch processingTimeoutLatch = new CountDownLatch(1);
TbPackProcessingContext<TbProtoQueueMsg<ToEdgeMsg>> ctx = new TbPackProcessingContext<>( TbPackProcessingContext<TbProtoQueueMsg<ToEdgeMsg>> ctx = new TbPackProcessingContext<>(
processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>());
PendingMsgHolder<ToEdgeMsg> pendingMsgHolder = new PendingMsgHolder<>(); PendingMsgHolder<ToEdgeMsg> pendingMsgHolder = new PendingMsgHolder<>();
Future<?> submitFuture = consumersExecutor.submit(() -> { Future<?> submitFuture = consumersExecutor.submit(() -> {
orderedMsgList.forEach((element) -> { orderedMsgList.forEach((element) -> {
UUID id = element.getUuid(); UUID id = element.uuid();
TbProtoQueueMsg<ToEdgeMsg> msg = element.getMsg(); TbProtoQueueMsg<ToEdgeMsg> msg = element.msg();
TbCallback callback = new TbPackCallback<>(id, ctx); TbCallback callback = new TbPackCallback<>(id, ctx);
try { try {
ToEdgeMsg toEdgeMsg = msg.getValue(); ToEdgeMsg toEdgeMsg = msg.getValue();

6
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java

@ -69,6 +69,7 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo
private final TbRuleEngineConsumerContext ctx; private final TbRuleEngineConsumerContext ctx;
private final QueueService queueService; private final QueueService queueService;
private final TbRuleEngineDeviceRpcService tbDeviceRpcService; private final TbRuleEngineDeviceRpcService tbDeviceRpcService;
private final TbMsgPackProcessingContextFactory packProcessingContextFactory;
private final ConcurrentMap<QueueKey, TbRuleEngineQueueConsumerManager> consumers = new ConcurrentHashMap<>(); private final ConcurrentMap<QueueKey, TbRuleEngineQueueConsumerManager> consumers = new ConcurrentHashMap<>();
@ -83,11 +84,13 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo
TbApiUsageStateService apiUsageStateService, TbApiUsageStateService apiUsageStateService,
PartitionService partitionService, PartitionService partitionService,
ApplicationEventPublisher eventPublisher, ApplicationEventPublisher eventPublisher,
JwtSettingsService jwtSettingsService) { JwtSettingsService jwtSettingsService,
TbMsgPackProcessingContextFactory packProcessingContextFactory) {
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService); super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService);
this.ctx = ctx; this.ctx = ctx;
this.tbDeviceRpcService = tbDeviceRpcService; this.tbDeviceRpcService = tbDeviceRpcService;
this.queueService = queueService; this.queueService = queueService;
this.packProcessingContextFactory = packProcessingContextFactory;
} }
@Override @Override
@ -253,6 +256,7 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo
.consumerExecutor(consumersExecutor) .consumerExecutor(consumersExecutor)
.scheduler(scheduler) .scheduler(scheduler)
.taskExecutor(mgmtExecutor) .taskExecutor(mgmtExecutor)
.packProcessingContextFactory(packProcessingContextFactory)
.build(); .build();
consumers.put(queueKey, consumer); consumers.put(queueKey, consumer);
consumer.init(queue); consumer.init(queue);

35
application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java

@ -0,0 +1,35 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.queue;
import org.springframework.stereotype.Component;
import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy;
public interface TbMsgPackProcessingContextFactory {
TbMsgPackProcessingContext create(String queueName, TbRuleEngineSubmitStrategy submitStrategy, boolean skipTimeouts);
@Component
class DefaultTbMsgPackProcessingContextFactory implements TbMsgPackProcessingContextFactory {
@Override
public TbMsgPackProcessingContext create(String queueName, TbRuleEngineSubmitStrategy submitStrategy, boolean skipTimeouts) {
return new TbMsgPackProcessingContext(queueName, submitStrategy, skipTimeouts);
}
}
}

6
application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java

@ -131,13 +131,13 @@ public abstract class AbstractConsumerService<N extends com.google.protobuf.Gene
protected void processNotifications(List<TbProtoQueueMsg<N>> msgs, TbQueueConsumer<TbProtoQueueMsg<N>> consumer) throws Exception { protected void processNotifications(List<TbProtoQueueMsg<N>> msgs, TbQueueConsumer<TbProtoQueueMsg<N>> consumer) throws Exception {
List<IdMsgPair<N>> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); List<IdMsgPair<N>> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList();
ConcurrentMap<UUID, TbProtoQueueMsg<N>> pendingMap = orderedMsgList.stream().collect( ConcurrentMap<UUID, TbProtoQueueMsg<N>> pendingMap = orderedMsgList.stream().collect(
Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg));
CountDownLatch processingTimeoutLatch = new CountDownLatch(1); CountDownLatch processingTimeoutLatch = new CountDownLatch(1);
TbPackProcessingContext<TbProtoQueueMsg<N>> ctx = new TbPackProcessingContext<>( TbPackProcessingContext<TbProtoQueueMsg<N>> ctx = new TbPackProcessingContext<>(
processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>());
orderedMsgList.forEach(element -> { orderedMsgList.forEach(element -> {
UUID id = element.getUuid(); UUID id = element.uuid();
TbProtoQueueMsg<N> msg = element.getMsg(); TbProtoQueueMsg<N> msg = element.msg();
log.trace("[{}] Creating notification callback for message: {}", id, msg.getValue()); log.trace("[{}] Creating notification callback for message: {}", id, msg.getValue());
TbCallback callback = new TbPackCallback<>(id, ctx); TbCallback callback = new TbPackCallback<>(id, ctx);
try { try {

12
application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java

@ -44,21 +44,21 @@ public abstract class AbstractTbRuleEngineSubmitStrategy implements TbRuleEngine
@Override @Override
public ConcurrentMap<UUID, TbProtoQueueMsg<TransportProtos.ToRuleEngineMsg>> getPendingMap() { public ConcurrentMap<UUID, TbProtoQueueMsg<TransportProtos.ToRuleEngineMsg>> getPendingMap() {
return orderedMsgList.stream().collect(Collectors.toConcurrentMap(pair -> pair.uuid, pair -> pair.msg)); return orderedMsgList.stream().collect(Collectors.toConcurrentMap(pair -> pair.uuid(), pair -> pair.msg()));
} }
@Override @Override
public void update(ConcurrentMap<UUID, TbProtoQueueMsg<TransportProtos.ToRuleEngineMsg>> reprocessMap) { public void update(ConcurrentMap<UUID, TbProtoQueueMsg<TransportProtos.ToRuleEngineMsg>> reprocessMap) {
List<IdMsgPair<TransportProtos.ToRuleEngineMsg>> newOrderedMsgList = new ArrayList<>(reprocessMap.size()); List<IdMsgPair<TransportProtos.ToRuleEngineMsg>> newOrderedMsgList = new ArrayList<>(reprocessMap.size());
for (IdMsgPair<TransportProtos.ToRuleEngineMsg> pair : orderedMsgList) { for (IdMsgPair<TransportProtos.ToRuleEngineMsg> pair : orderedMsgList) {
if (reprocessMap.containsKey(pair.uuid)) { if (reprocessMap.containsKey(pair.uuid())) {
if (StringUtils.isNotEmpty(pair.getMsg().getValue().getFailureMessage())) { if (StringUtils.isNotEmpty(pair.msg().getValue().getFailureMessage())) {
var toRuleEngineMsg = TransportProtos.ToRuleEngineMsg.newBuilder(pair.getMsg().getValue()) var toRuleEngineMsg = TransportProtos.ToRuleEngineMsg.newBuilder(pair.msg().getValue())
.clearFailureMessage() .clearFailureMessage()
.clearRelationTypes() .clearRelationTypes()
.build(); .build();
var newMsg = new TbProtoQueueMsg<>(pair.getMsg().getKey(), toRuleEngineMsg, pair.getMsg().getHeaders()); var newMsg = new TbProtoQueueMsg<>(pair.msg().getKey(), toRuleEngineMsg, pair.msg().getHeaders());
newOrderedMsgList.add(new IdMsgPair<>(pair.getUuid(), newMsg)); newOrderedMsgList.add(new IdMsgPair<>(pair.uuid(), newMsg));
} else { } else {
newOrderedMsgList.add(pair); newOrderedMsgList.add(pair);
} }

2
application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java

@ -73,7 +73,7 @@ public class BatchTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSubmitS
pendingPack.clear(); pendingPack.clear();
for (int i = startIdx; i < endIdx; i++) { for (int i = startIdx; i < endIdx; i++) {
IdMsgPair<TransportProtos.ToRuleEngineMsg> pair = orderedMsgList.get(i); IdMsgPair<TransportProtos.ToRuleEngineMsg> pair = orderedMsgList.get(i);
pendingPack.put(pair.uuid, pair.msg); pendingPack.put(pair.uuid(), pair.msg());
} }
tmpPack = new LinkedHashMap<>(pendingPack); tmpPack = new LinkedHashMap<>(pendingPack);
} }

2
application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java

@ -34,7 +34,7 @@ public class BurstTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSubmitS
if (log.isDebugEnabled()) { if (log.isDebugEnabled()) {
log.debug("[{}] submitting [{}] messages to rule engine", queueName, orderedMsgList.size()); log.debug("[{}] submitting [{}] messages to rule engine", queueName, orderedMsgList.size());
} }
orderedMsgList.forEach(pair -> msgConsumer.accept(pair.uuid, pair.msg)); orderedMsgList.forEach(pair -> msgConsumer.accept(pair.uuid(), pair.msg()));
} }
@Override @Override

14
application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java

@ -15,19 +15,9 @@
*/ */
package org.thingsboard.server.service.queue.processing; package org.thingsboard.server.service.queue.processing;
import lombok.Getter; import com.google.protobuf.GeneratedMessageV3;
import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import java.util.UUID; import java.util.UUID;
public class IdMsgPair<T extends com.google.protobuf.GeneratedMessageV3> { public record IdMsgPair<T extends GeneratedMessageV3>(UUID uuid, TbProtoQueueMsg<T> msg) {}
@Getter
final UUID uuid;
@Getter
final TbProtoQueueMsg<T> msg;
public IdMsgPair(UUID uuid, TbProtoQueueMsg<T> msg) {
this.uuid = uuid;
this.msg = msg;
}
}

10
application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java

@ -51,7 +51,7 @@ public abstract class SequentialByEntityIdTbRuleEngineSubmitStrategy extends Abs
entityIdToListMap.forEach((entityId, queue) -> { entityIdToListMap.forEach((entityId, queue) -> {
IdMsgPair<TransportProtos.ToRuleEngineMsg> msg = queue.peek(); IdMsgPair<TransportProtos.ToRuleEngineMsg> msg = queue.peek();
if (msg != null) { if (msg != null) {
msgConsumer.accept(msg.uuid, msg.msg); msgConsumer.accept(msg.uuid(), msg.msg());
} }
}); });
} }
@ -71,13 +71,13 @@ public abstract class SequentialByEntityIdTbRuleEngineSubmitStrategy extends Abs
IdMsgPair<TransportProtos.ToRuleEngineMsg> next = null; IdMsgPair<TransportProtos.ToRuleEngineMsg> next = null;
synchronized (queue) { synchronized (queue) {
IdMsgPair<TransportProtos.ToRuleEngineMsg> expected = queue.peek(); IdMsgPair<TransportProtos.ToRuleEngineMsg> expected = queue.peek();
if (expected != null && expected.uuid.equals(id)) { if (expected != null && expected.uuid().equals(id)) {
queue.poll(); queue.poll();
next = queue.peek(); next = queue.peek();
} }
} }
if (next != null) { if (next != null) {
msgConsumer.accept(next.uuid, next.msg); msgConsumer.accept(next.uuid(), next.msg());
} }
} }
} }
@ -87,9 +87,9 @@ public abstract class SequentialByEntityIdTbRuleEngineSubmitStrategy extends Abs
msgToEntityIdMap.clear(); msgToEntityIdMap.clear();
entityIdToListMap.clear(); entityIdToListMap.clear();
for (IdMsgPair<TransportProtos.ToRuleEngineMsg> pair : orderedMsgList) { for (IdMsgPair<TransportProtos.ToRuleEngineMsg> pair : orderedMsgList) {
EntityId entityId = getEntityId(pair.msg.getValue()); EntityId entityId = getEntityId(pair.msg().getValue());
if (entityId != null) { if (entityId != null) {
msgToEntityIdMap.put(pair.uuid, entityId); msgToEntityIdMap.put(pair.uuid(), entityId);
entityIdToListMap.computeIfAbsent(entityId, id -> new LinkedList<>()).add(pair); entityIdToListMap.computeIfAbsent(entityId, id -> new LinkedList<>()).add(pair);
} }
} }

6
application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java

@ -60,11 +60,11 @@ public class SequentialTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSu
int idx = msgIdx.get(); int idx = msgIdx.get();
if (idx < listSize) { if (idx < listSize) {
IdMsgPair<TransportProtos.ToRuleEngineMsg> pair = orderedMsgList.get(idx); IdMsgPair<TransportProtos.ToRuleEngineMsg> pair = orderedMsgList.get(idx);
expectedMsgId = pair.uuid; expectedMsgId = pair.uuid();
if (log.isDebugEnabled()) { if (log.isDebugEnabled()) {
log.debug("[{}] submitting [{}] message to rule engine", queueName, pair.msg); log.debug("[{}] submitting [{}] message to rule engine", queueName, pair.msg());
} }
msgConsumer.accept(pair.uuid, pair.msg); msgConsumer.accept(pair.uuid(), pair.msg());
} }
} }

8
application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java

@ -42,6 +42,7 @@ import org.thingsboard.server.queue.common.consumer.TbQueueConsumerTask.Consumer
import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.queue.discovery.QueueKey;
import org.thingsboard.server.service.queue.TbMsgPackCallback; import org.thingsboard.server.service.queue.TbMsgPackCallback;
import org.thingsboard.server.service.queue.TbMsgPackProcessingContext; import org.thingsboard.server.service.queue.TbMsgPackProcessingContext;
import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory;
import org.thingsboard.server.service.queue.TbRuleEngineConsumerStats; import org.thingsboard.server.service.queue.TbRuleEngineConsumerStats;
import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingDecision; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingDecision;
import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingResult; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingResult;
@ -67,13 +68,15 @@ public class TbRuleEngineQueueConsumerManager extends MainQueueConsumerManager<T
private final TbRuleEngineConsumerContext ctx; private final TbRuleEngineConsumerContext ctx;
private final TbRuleEngineConsumerStats stats; private final TbRuleEngineConsumerStats stats;
private final TbMsgPackProcessingContextFactory packProcessingContextFactory;
@Builder(builderMethodName = "create") // not to conflict with super.builder() @Builder(builderMethodName = "create") // not to conflict with super.builder()
public TbRuleEngineQueueConsumerManager(TbRuleEngineConsumerContext ctx, public TbRuleEngineQueueConsumerManager(TbRuleEngineConsumerContext ctx,
QueueKey queueKey, QueueKey queueKey,
ExecutorService consumerExecutor, ExecutorService consumerExecutor,
ScheduledExecutorService scheduler, ScheduledExecutorService scheduler,
ExecutorService taskExecutor) { ExecutorService taskExecutor,
TbMsgPackProcessingContextFactory packProcessingContextFactory) {
super(queueKey, null, null, super(queueKey, null, null,
(queueConfig, tpi) -> { (queueConfig, tpi) -> {
Integer partitionId = tpi != null ? tpi.getPartition().orElse(-1) : null; Integer partitionId = tpi != null ? tpi.getPartition().orElse(-1) : null;
@ -82,6 +85,7 @@ public class TbRuleEngineQueueConsumerManager extends MainQueueConsumerManager<T
consumerExecutor, scheduler, taskExecutor, null); consumerExecutor, scheduler, taskExecutor, null);
this.ctx = ctx; this.ctx = ctx;
this.stats = new TbRuleEngineConsumerStats(queueKey, ctx.getStatsFactory()); this.stats = new TbRuleEngineConsumerStats(queueKey, ctx.getStatsFactory());
this.packProcessingContextFactory = packProcessingContextFactory;
} }
public void delete(boolean drainQueue) { public void delete(boolean drainQueue) {
@ -134,7 +138,7 @@ public class TbRuleEngineQueueConsumerManager extends MainQueueConsumerManager<T
TbRuleEngineProcessingStrategy ackStrategy = getProcessingStrategy(queue); TbRuleEngineProcessingStrategy ackStrategy = getProcessingStrategy(queue);
submitStrategy.init(msgs); submitStrategy.init(msgs);
while (!stopped && !consumer.isStopped()) { while (!stopped && !consumer.isStopped()) {
TbMsgPackProcessingContext packCtx = new TbMsgPackProcessingContext(queue.getName(), submitStrategy, ackStrategy.isSkipTimeoutMsgs()); TbMsgPackProcessingContext packCtx = packProcessingContextFactory.create(queue.getName(), submitStrategy, ackStrategy.isSkipTimeoutMsgs());
submitStrategy.submitAttempt((id, msg) -> submitMessage(packCtx, id, msg)); submitStrategy.submitAttempt((id, msg) -> submitMessage(packCtx, id, msg));
final boolean timeout = !packCtx.await(queue.getPackProcessingTimeout(), TimeUnit.MILLISECONDS); final boolean timeout = !packCtx.await(queue.getPackProcessingTimeout(), TimeUnit.MILLISECONDS);

2
application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java

@ -46,7 +46,7 @@ public class ApiKeyAuthenticationProvider extends AbstractAuthenticationProvider
return new ApiKeyAuthenticationToken(securityUser); return new ApiKeyAuthenticationToken(securityUser);
} }
private SecurityUser authenticate(String key) { public SecurityUser authenticate(String key) {
if (StringUtils.isEmpty(key)) { if (StringUtils.isEmpty(key)) {
throw new BadCredentialsException("Empty API key"); throw new BadCredentialsException("Empty API key");
} }

2
application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java

@ -130,7 +130,7 @@ public class TbAlarmCountSubCtx extends TbAbstractEntityQuerySubCtx<AlarmCountQu
private EntityDataQuery buildEntityDataQuery() { private EntityDataQuery buildEntityDataQuery() {
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null,
new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, ModelConstants.CREATED_TIME_PROPERTY))); new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, ModelConstants.CREATED_TIME_PROPERTY)));
return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters()); return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
} }
private void resetInvocationCounter() { private void resetInvocationCounter() {

2
application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java

@ -363,7 +363,7 @@ public class TbAlarmDataSubCtx extends TbAbstractDataSubCtx<AlarmDataQuery> {
entitiesSortOrder = sortOrder; entitiesSortOrder = sortOrder;
} }
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder);
return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
} }
} }

249
application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java

@ -22,6 +22,7 @@ import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.MoreExecutors;
import com.google.protobuf.ByteString; import com.google.protobuf.ByteString;
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.exception.EntitiesLimitExceededException; import org.thingsboard.server.exception.EntitiesLimitExceededException;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
@ -114,6 +115,7 @@ import java.util.stream.Collectors;
import static org.thingsboard.server.service.transport.BasicCredentialsValidationResult.PASSWORD_MISMATCH; import static org.thingsboard.server.service.transport.BasicCredentialsValidationResult.PASSWORD_MISMATCH;
import static org.thingsboard.server.service.transport.BasicCredentialsValidationResult.VALID; import static org.thingsboard.server.service.transport.BasicCredentialsValidationResult.VALID;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.DEVICE_NAME_SPLIT_SEPARATOR;
/** /**
* Created by ashvayka on 05.10.18. * Created by ashvayka on 05.10.18.
@ -139,6 +141,7 @@ public class DefaultTransportApiService implements TransportApiService {
private final OtaPackageService otaPackageService; private final OtaPackageService otaPackageService;
private final OtaPackageDataCache otaPackageDataCache; private final OtaPackageDataCache otaPackageDataCache;
private final QueueService queueService; private final QueueService queueService;
public static final String GATEWAY_CREATED_RELATION = "Created";
private final ConcurrentMap<String, ReentrantLock> deviceCreationLocks = new ConcurrentReferenceHashMap<>(16, ConcurrentReferenceHashMap.ReferenceType.WEAK); private final ConcurrentMap<String, ReentrantLock> deviceCreationLocks = new ConcurrentReferenceHashMap<>(16, ConcurrentReferenceHashMap.ReferenceType.WEAK);
@ -330,84 +333,188 @@ public class DefaultTransportApiService implements TransportApiService {
} }
private TransportApiResponseMsg handle(GetOrCreateDeviceFromGatewayRequestMsg requestMsg) { private TransportApiResponseMsg handle(GetOrCreateDeviceFromGatewayRequestMsg requestMsg) {
DeviceId gatewayId = new DeviceId(new UUID(requestMsg.getGatewayIdMSB(), requestMsg.getGatewayIdLSB())); DeviceId gatewayId = toDeviceId(requestMsg);
Device gateway = deviceService.findDeviceById(TenantId.SYS_TENANT_ID, gatewayId); Device gateway = deviceService.findDeviceById(TenantId.SYS_TENANT_ID, gatewayId);
Lock deviceCreationLock = deviceCreationLocks.computeIfAbsent(requestMsg.getDeviceName(), id -> new ReentrantLock()); String deviceName = requestMsg.getDeviceName();
deviceCreationLock.lock(); Lock lock = deviceCreationLocks.computeIfAbsent(deviceName, k -> new ReentrantLock());
lock.lock();
try { try {
Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), requestMsg.getDeviceName()); Device device = findOrCreateDevice(requestMsg, gateway, gatewayId);
if (device == null) { updateLastConnectedGateway(device, gatewayId);
TenantId tenantId = gateway.getTenantId(); return buildResponse(device);
device = new Device();
device.setTenantId(tenantId);
device.setName(requestMsg.getDeviceName());
device.setType(requestMsg.getDeviceType());
device.setCustomerId(gateway.getCustomerId());
DeviceProfile deviceProfile = deviceProfileCache.findOrCreateDeviceProfile(gateway.getTenantId(), requestMsg.getDeviceType());
device.setDeviceProfileId(deviceProfile.getId());
ObjectNode additionalInfo = JacksonUtil.newObjectNode();
additionalInfo.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayId.toString());
device.setAdditionalInfo(additionalInfo);
device = deviceService.saveDevice(device);
relationService.saveRelation(tenantId, new EntityRelation(gateway.getId(), device.getId(), "Created"));
TbMsgMetaData metaData = new TbMsgMetaData();
CustomerId customerId = gateway.getCustomerId();
if (customerId != null && !customerId.isNullUid()) {
metaData.putValue("customerId", customerId.toString());
}
metaData.putValue("gatewayId", gatewayId.toString());
DeviceId deviceId = device.getId();
JsonNode entityNode = JacksonUtil.valueToTree(device);
TbMsg tbMsg = TbMsg.newMsg()
.type(TbMsgType.ENTITY_CREATED)
.originator(deviceId)
.customerId(customerId)
.copyMetaData(metaData)
.dataType(TbMsgDataType.JSON)
.data(JacksonUtil.toString(entityNode))
.build();
tbClusterService.pushMsgToRuleEngine(tenantId, deviceId, tbMsg, null);
} else {
JsonNode deviceAdditionalInfo = device.getAdditionalInfo();
if (deviceAdditionalInfo == null) {
deviceAdditionalInfo = JacksonUtil.newObjectNode();
}
if (deviceAdditionalInfo.isObject() &&
(!deviceAdditionalInfo.has(DataConstants.LAST_CONNECTED_GATEWAY)
|| !gatewayId.toString().equals(deviceAdditionalInfo.get(DataConstants.LAST_CONNECTED_GATEWAY).asText()))) {
ObjectNode newDeviceAdditionalInfo = (ObjectNode) deviceAdditionalInfo;
newDeviceAdditionalInfo.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayId.toString());
deviceService.saveDevice(device);
}
}
GetOrCreateDeviceFromGatewayResponseMsg.Builder builder = GetOrCreateDeviceFromGatewayResponseMsg.newBuilder()
.setDeviceInfo(ProtoUtils.toDeviceInfoProto(device));
DeviceProfile deviceProfile = deviceProfileCache.get(device.getTenantId(), device.getDeviceProfileId());
if (deviceProfile != null) {
builder.setDeviceProfile(ProtoUtils.toProto(deviceProfile));
} else {
log.warn("[{}] Failed to find device profile [{}] for device. ", device.getId(), device.getDeviceProfileId());
}
return TransportApiResponseMsg.newBuilder()
.setGetOrCreateDeviceResponseMsg(builder.build())
.build();
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
log.warn("[{}] Failed to lookup device by gateway id and name: [{}]", gatewayId, requestMsg.getDeviceName(), e); log.warn("[{}] Failed to process device [{}]", gatewayId, deviceName, e);
throw new RuntimeException(e); throw new RuntimeException(e);
} catch (EntitiesLimitExceededException e) { } catch (EntitiesLimitExceededException e) {
log.warn("[{}][{}] API limit exception: [{}]", e.getTenantId(), gatewayId, e.getMessage()); return buildLimitErrorResponse(e, gatewayId);
return TransportApiResponseMsg.newBuilder()
.setGetOrCreateDeviceResponseMsg(
GetOrCreateDeviceFromGatewayResponseMsg.newBuilder()
.setError(TransportProtos.TransportApiRequestErrorCode.ENTITY_LIMIT))
.build();
} finally { } finally {
deviceCreationLock.unlock(); lock.unlock();
}
}
private DeviceId toDeviceId(GetOrCreateDeviceFromGatewayRequestMsg requestMsg) {
return new DeviceId(new UUID(
requestMsg.getGatewayIdMSB(),
requestMsg.getGatewayIdLSB()
));
}
private Device findOrCreateDevice(GetOrCreateDeviceFromGatewayRequestMsg requestMsg,
Device gateway,
DeviceId gatewayId) throws JsonProcessingException {
TenantId tenantId = gateway.getTenantId();
String deviceName = requestMsg.getDeviceName();
Device device = deviceService.findDeviceByTenantIdAndName(tenantId, deviceName);
if (device != null) {
return device;
}
String[] topicPath = requestMsg.getDeviceName().split(DEVICE_NAME_SPLIT_SEPARATOR);
device = tryRenameSparkplugDevice(requestMsg, gateway, topicPath);
if (device != null) {
return device;
}
device = createNewDevice(requestMsg, gateway, gatewayId, topicPath);
pushCreatedEvent(device, gateway);
return device;
}
private Device tryRenameSparkplugDevice(GetOrCreateDeviceFromGatewayRequestMsg requestMsg, Device gateway, String[] topicPath) {
if (!requestMsg.getIsSparkplug()) {
return null;
}
if (topicPath.length != 3) {
return null;
}
String deviceId = topicPath[2];
Device existingDevice = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), deviceId);
if (existingDevice == null) {
return null;
}
// Security check: verify that the device was created by this gateway
boolean isRelated = false;
isRelated = relationService.checkRelation(
gateway.getTenantId(),
gateway.getId(),
existingDevice.getId(),
GATEWAY_CREATED_RELATION,
RelationTypeGroup.COMMON
);
// If the device is found but not related to this gateway
if (!isRelated) {
log.debug("[{}] Device [{}] exists but is not related to gateway. " +
"Skipping rename and allowing creation of Sparkplug device [{}].",
gateway.getId(),
existingDevice.getId(),
requestMsg.getDeviceName());
return null;
}
// Logic for renaming the device if it's related and no naming conflicts exist
boolean changed = false;
String newName = requestMsg.getDeviceName();
if (!newName.equals(existingDevice.getName())) {
existingDevice.setName(newName);
if (StringUtils.isEmpty(existingDevice.getLabel())) {
existingDevice.setLabel(deviceId);
}
changed = true;
}
return changed ? deviceService.saveDevice(existingDevice) : existingDevice;
}
private Device createNewDevice(GetOrCreateDeviceFromGatewayRequestMsg requestMsg,
Device gateway,
DeviceId gatewayId, String[] topicPath) {
TenantId tenantId = gateway.getTenantId();
Device device = new Device();
device.setTenantId(tenantId);
device.setName(requestMsg.getDeviceName());
if (requestMsg.getIsSparkplug() && topicPath.length == 3) {
device.setLabel(topicPath[2]);
} }
device.setType(requestMsg.getDeviceType());
device.setCustomerId(gateway.getCustomerId());
DeviceProfile profile =
deviceProfileCache.findOrCreateDeviceProfile(tenantId, requestMsg.getDeviceType());
device.setDeviceProfileId(profile.getId());
ObjectNode additionalInfo = JacksonUtil.newObjectNode();
additionalInfo.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayId.toString());
device.setAdditionalInfo(additionalInfo);
device = deviceService.saveDevice(device);
relationService.saveRelation(
tenantId,
new EntityRelation(gateway.getId(), device.getId(), GATEWAY_CREATED_RELATION)
);
return device;
}
private void updateLastConnectedGateway(Device device, DeviceId gatewayId) {
String gatewayIdStr = gatewayId.toString();
JsonNode info = device.getAdditionalInfo();
ObjectNode objectNode = (info instanceof ObjectNode)
? (ObjectNode) info
: JacksonUtil.newObjectNode();
if (!objectNode.has(DataConstants.LAST_CONNECTED_GATEWAY)
|| !gatewayIdStr.equals(objectNode.get(DataConstants.LAST_CONNECTED_GATEWAY).asText())) {
objectNode.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayIdStr);
device.setAdditionalInfo(objectNode);
deviceService.saveDevice(device);
}
}
private void pushCreatedEvent(Device device, Device gateway) {
TenantId tenantId = gateway.getTenantId();
CustomerId customerId = gateway.getCustomerId();
TbMsgMetaData metaData = new TbMsgMetaData();
metaData.putValue("gatewayId", gateway.getId().toString());
if (customerId != null && !customerId.isNullUid()) {
metaData.putValue("customerId", customerId.toString());
}
JsonNode entityNode = JacksonUtil.valueToTree(device);
TbMsg msg = TbMsg.newMsg()
.type(TbMsgType.ENTITY_CREATED)
.originator(device.getId())
.customerId(customerId)
.copyMetaData(metaData)
.dataType(TbMsgDataType.JSON)
.data(JacksonUtil.toString(entityNode))
.build();
tbClusterService.pushMsgToRuleEngine(tenantId, device.getId(), msg, null);
}
private TransportApiResponseMsg buildResponse(Device device) throws JsonProcessingException {
GetOrCreateDeviceFromGatewayResponseMsg.Builder builder =
GetOrCreateDeviceFromGatewayResponseMsg.newBuilder()
.setDeviceInfo(ProtoUtils.toDeviceInfoProto(device));
DeviceProfile profile =
deviceProfileCache.get(device.getTenantId(), device.getDeviceProfileId());
if (profile != null) {
builder.setDeviceProfile(ProtoUtils.toProto(profile));
}
return TransportApiResponseMsg.newBuilder()
.setGetOrCreateDeviceResponseMsg(builder.build())
.build();
}
private TransportApiResponseMsg buildLimitErrorResponse(EntitiesLimitExceededException e,
DeviceId gatewayId) {
log.warn("[{}][{}] API limit exception: [{}]",
e.getTenantId(), gatewayId, e.getMessage());
return TransportApiResponseMsg.newBuilder()
.setGetOrCreateDeviceResponseMsg(
GetOrCreateDeviceFromGatewayResponseMsg.newBuilder()
.setError(TransportProtos.TransportApiRequestErrorCode.ENTITY_LIMIT)
)
.build();
} }
private TransportApiResponseMsg handle(ProvisionDeviceRequestMsg requestMsg) { private TransportApiResponseMsg handle(ProvisionDeviceRequestMsg requestMsg) {

3
application/src/main/java/org/thingsboard/server/service/ws/AuthCmd.java

@ -23,11 +23,14 @@ import lombok.NoArgsConstructor;
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
public class AuthCmd implements WsCmd { public class AuthCmd implements WsCmd {
private int cmdId; private int cmdId;
private String token; private String token;
private String apiKey;
@Override @Override
public WsCmdType getType() { public WsCmdType getType() {
return WsCmdType.AUTH; return WsCmdType.AUTH;
} }
} }

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

@ -15,6 +15,7 @@
# #
# Server common parameters # Server common parameters
# Configures HTTP/HTTPS bind address, port, SSL, WebSocket, and REST API settings.
server: server:
# Server bind-address # Server bind-address
address: "${HTTP_BIND_ADDRESS:0.0.0.0}" address: "${HTTP_BIND_ADDRESS:0.0.0.0}"
@ -117,23 +118,25 @@ server:
response_timeout: "${DEFAULT_RULE_ENGINE_RESPONSE_TIMEOUT:10000}" response_timeout: "${DEFAULT_RULE_ENGINE_RESPONSE_TIMEOUT:10000}"
# Application info parameters # Application info parameters
# Exposes application metadata such as version string injected at build time.
app: app:
# Application version # Application version
version: "@project.version@" version: "@project.version@"
# Zookeeper connection parameters # ZooKeeper connection parameters
# Controls ZooKeeper-based service discovery and cluster coordination for microservice deployments.
zk: zk:
# Enable/disable zookeeper discovery service. # Enable/disable ZooKeeper discovery service.
enabled: "${ZOOKEEPER_ENABLED:false}" enabled: "${ZOOKEEPER_ENABLED:false}"
# Zookeeper connect string # ZooKeeper connect string
url: "${ZOOKEEPER_URL:localhost:2181}" url: "${ZOOKEEPER_URL:localhost:2181}"
# Zookeeper retry interval in milliseconds # ZooKeeper retry interval in milliseconds
retry_interval_ms: "${ZOOKEEPER_RETRY_INTERVAL_MS:3000}" retry_interval_ms: "${ZOOKEEPER_RETRY_INTERVAL_MS:3000}"
# Zookeeper connection timeout in milliseconds # ZooKeeper connection timeout in milliseconds
connection_timeout_ms: "${ZOOKEEPER_CONNECTION_TIMEOUT_MS:3000}" connection_timeout_ms: "${ZOOKEEPER_CONNECTION_TIMEOUT_MS:3000}"
# Zookeeper session timeout in milliseconds # ZooKeeper session timeout in milliseconds
session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}" session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}"
# Name of the directory in zookeeper 'filesystem' # Name of the directory in ZooKeeper 'filesystem'
zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}" zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}"
# The recalculate_delay property is recommended in a microservices architecture setup for rule-engine services. # The recalculate_delay property is recommended in a microservices architecture setup for rule-engine services.
# This property provides a pause to ensure that when a rule-engine service is restarted, other nodes don't immediately attempt to recalculate their partitions. # This property provides a pause to ensure that when a rule-engine service is restarted, other nodes don't immediately attempt to recalculate their partitions.
@ -141,6 +144,7 @@ zk:
recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}"
# Cluster parameters # Cluster parameters
# Controls cluster statistics — tracks the number of messages exchanged between cluster nodes.
cluster: cluster:
stats: stats:
# Enable/Disable the cluster statistics. Calculates the number of messages sent between cluster nodes based on each type # Enable/Disable the cluster statistics. Calculates the number of messages sent between cluster nodes based on each type
@ -149,11 +153,13 @@ cluster:
print_interval_ms: "${TB_CLUSTER_STATS_PRINT_INTERVAL_MS:10000}" print_interval_ms: "${TB_CLUSTER_STATS_PRINT_INTERVAL_MS:10000}"
# Plugins configuration parameters # Plugins configuration parameters
# Defines classpath scan packages used to discover and register ThingsBoard extension plugins.
plugins: plugins:
# Comma-separated package list used during classpath scanning for plugins # Comma-separated package list used during classpath scanning for plugins
scan_packages: "${PLUGINS_SCAN_PACKAGES:org.thingsboard.server.extensions,org.thingsboard.rule.engine}" scan_packages: "${PLUGINS_SCAN_PACKAGES:org.thingsboard.server.extensions,org.thingsboard.rule.engine}"
# Security parameters # Security parameters
# Configures JWT tokens, user login, device claiming, OAuth2, and CA certificate trust store.
security: security:
# JWT Token parameters # JWT Token parameters
jwt: # Since 3.4.2 values are persisted in the database during installation or upgrade. On Install, the key will be generated randomly if no custom value set. You can change it later from Web UI under SYS_ADMIN jwt: # Since 3.4.2 values are persisted in the database during installation or upgrade. On Install, the key will be generated randomly if no custom value set. You can change it later from Web UI under SYS_ADMIN
@ -246,6 +252,7 @@ security:
report-only: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY:false}" report-only: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY:false}"
# Mail settings parameters # Mail settings parameters
# Configures mail service OAuth2 token refresh and per-tenant sending rate limits.
mail: mail:
oauth2: oauth2:
# Interval for checking refresh token expiration in seconds(by default, 1 day). # Interval for checking refresh token expiration in seconds(by default, 1 day).
@ -254,6 +261,7 @@ mail:
per_tenant_rate_limits: "${MAIL_PER_TENANT_RATE_LIMITS:}" per_tenant_rate_limits: "${MAIL_PER_TENANT_RATE_LIMITS:}"
# Usage statistics parameters # Usage statistics parameters
# Controls collection and reporting intervals for API usage stats at system, tenant, and customer levels.
usage: usage:
stats: stats:
report: report:
@ -277,6 +285,7 @@ usage:
report_interval: "${DEVICES_STATS_REPORT_INTERVAL:60}" report_interval: "${DEVICES_STATS_REPORT_INTERVAL:60}"
# UI settings parameters # UI settings parameters
# Configures dashboard data limits and base URL for UI help assets.
ui: ui:
# Dashboard parameters # Dashboard parameters
dashboard: dashboard:
@ -285,9 +294,10 @@ ui:
# Help parameters # Help parameters
help: help:
# Base URL for UI help assets # Base URL for UI help assets
base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.3}" base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.4}"
# Database telemetry parameters # Database telemetry parameters
# Selects the storage backend (SQL, Cassandra, or TimescaleDB) for time-series and latest telemetry data.
database: database:
ts_max_intervals: "${DATABASE_TS_MAX_INTERVALS:700}" # Max number of DB queries generated by a single API call to fetch telemetry records ts_max_intervals: "${DATABASE_TS_MAX_INTERVALS:700}" # Max number of DB queries generated by a single API call to fetch telemetry records
ts: ts:
@ -296,6 +306,7 @@ database:
type: "${DATABASE_TS_LATEST_TYPE:sql}" # cassandra, sql, or timescale (for hybrid mode, DATABASE_TS_TYPE value should be cassandra, or timescale) type: "${DATABASE_TS_LATEST_TYPE:sql}" # cassandra, sql, or timescale (for hybrid mode, DATABASE_TS_TYPE value should be cassandra, or timescale)
# Cassandra driver configuration parameters # Cassandra driver configuration parameters
# Configures cluster name, keyspace, SSL, credentials, socket, and query settings for the Cassandra driver.
cassandra: cassandra:
# Thingsboard cluster name # Thingsboard cluster name
cluster_name: "${CASSANDRA_CLUSTER_NAME:Thingsboard Cluster}" cluster_name: "${CASSANDRA_CLUSTER_NAME:Thingsboard Cluster}"
@ -414,6 +425,7 @@ cassandra:
print_tenant_names: "${CASSANDRA_QUERY_TENANT_RATE_LIMITS_PRINT_TENANT_NAMES:false}" print_tenant_names: "${CASSANDRA_QUERY_TENANT_RATE_LIMITS_PRINT_TENANT_NAMES:false}"
# SQL configuration parameters # SQL configuration parameters
# Tunes batch sizes, delays, thread counts, TTL, and partitioning for SQL-backed persistence of telemetry, events, and audit logs.
sql: sql:
# Specify batch size for persisting attribute updates # Specify batch size for persisting attribute updates
attributes: attributes:
@ -466,6 +478,10 @@ sql:
# Interval in milliseconds for printing the latest statistic information about the tenant # Interval in milliseconds for printing the latest statistic information about the tenant
log_tenant_stats_interval_ms: "${SQL_LOG_TENANT_STATS_INTERVAL_MS:60000}" log_tenant_stats_interval_ms: "${SQL_LOG_TENANT_STATS_INTERVAL_MS:60000}"
entity_data_query_nulls_order_strategy: "${SQL_ENTITY_DATA_QUERY_NULLS_ORDER_STRATEGY:default}" # Nulls ordering strategy for sql entity data query. Possible values: default, nulls_first, nulls_last. The default value is 'default', which means postgres default behavior will be applied: NULLS LAST for ASC and NULLS FIRST for DESC. The 'nulls_first' value means that NULL values will be ordered before non-NULL values regardless of the sort order. The 'nulls_last' value means that NULL values will be ordered after non-NULL values regardless of the sort order. entity_data_query_nulls_order_strategy: "${SQL_ENTITY_DATA_QUERY_NULLS_ORDER_STRATEGY:default}" # Nulls ordering strategy for sql entity data query. Possible values: default, nulls_first, nulls_last. The default value is 'default', which means postgres default behavior will be applied: NULLS LAST for ASC and NULLS FIRST for DESC. The 'nulls_first' value means that NULL values will be ordered before non-NULL values regardless of the sort order. The 'nulls_last' value means that NULL values will be ordered after non-NULL values regardless of the sort order.
query:
key-filters-or-conditions:
# Enable/disable OR conditions between key filters in entity data queries
enabled: "${SQL_QUERY_KEY_FILTERS_OR_CONDITIONS_ENABLED:true}"
postgres: postgres:
# Specify partitioning size for timestamp key-value storage. Example: DAYS, MONTHS, YEARS, INDEFINITE. # Specify partitioning size for timestamp key-value storage. Example: DAYS, MONTHS, YEARS, INDEFINITE.
ts_key_value_partitioning: "${SQL_POSTGRES_TS_KV_PARTITIONING:MONTHS}" ts_key_value_partitioning: "${SQL_POSTGRES_TS_KV_PARTITIONING:MONTHS}"
@ -518,6 +534,7 @@ sql:
query_timeout: "${SQL_RELATIONS_QUERY_TIMEOUT_SEC:20}" # This value has to be reasonably small to prevent the relation query from blocking all other DB calls query_timeout: "${SQL_RELATIONS_QUERY_TIMEOUT_SEC:20}" # This value has to be reasonably small to prevent the relation query from blocking all other DB calls
# Actor system parameters # Actor system parameters
# Configures thread pools, timeouts, and behavior for the internal actor system processing devices, rules, and RPCs.
actors: actors:
system: system:
throughput: "${ACTORS_SYSTEM_THROUGHPUT:5}" # Number of messages the actor system will process per actor before switching to processing of messages for the next actor throughput: "${ACTORS_SYSTEM_THROUGHPUT:5}" # Number of messages the actor system will process per actor before switching to processing of messages for the next actor
@ -634,6 +651,8 @@ actors:
# Time in seconds to receive calculation result. # Time in seconds to receive calculation result.
calculation_timeout: "${ACTORS_CALCULATION_TIMEOUT_SEC:5}" calculation_timeout: "${ACTORS_CALCULATION_TIMEOUT_SEC:5}"
# Debug settings parameters
# Configures the global default duration for debug mode used by rule chains and calculated fields.
debug: debug:
settings: settings:
# Default duration (in minutes) for debug mode. Min value is 1 minute. Tenant profile settings override this one. # Default duration (in minutes) for debug mode. Min value is 1 minute. Tenant profile settings override this one.
@ -641,6 +660,7 @@ debug:
default_duration: "${DEBUG_SETTINGS_DEFAULT_DURATION_MINUTES:15}" default_duration: "${DEBUG_SETTINGS_DEFAULT_DURATION_MINUTES:15}"
# Cache settings parameters # Cache settings parameters
# Configures cache type (Caffeine or Redis), pool size, and per-entity TTL and max-size limits for all caches.
cache: cache:
# caffeine or redis(7.2 - latest compatible version) # caffeine or redis(7.2 - latest compatible version)
type: "${CACHE_TYPE:caffeine}" type: "${CACHE_TYPE:caffeine}"
@ -800,6 +820,7 @@ cache:
spring.data.redis.repositories.enabled: false # Disable this because it is not required. spring.data.redis.repositories.enabled: false # Disable this because it is not required.
# Redis/Valkey configuration parameters # Redis/Valkey configuration parameters
# Configures standalone, cluster, or sentinel Redis connection, SSL, and connection pool settings used for caching.
redis: redis:
# standalone or cluster or sentinel # standalone or cluster or sentinel
connection: connection:
@ -836,9 +857,9 @@ redis:
password: "${REDIS_SENTINEL_PASSWORD:}" password: "${REDIS_SENTINEL_PASSWORD:}"
# If set false will be used pool config build from values of the pool config section # If set false will be used pool config build from values of the pool config section
useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}" useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}"
# db index # Redis logical database index to select after connecting.
db: "${REDIS_DB:0}" db: "${REDIS_DB:0}"
# db password # Password for Redis authentication (leave empty if not required).
password: "${REDIS_PASSWORD:}" password: "${REDIS_PASSWORD:}"
# Redis username for ACL authentication (Redis 6.0+). Leave empty for legacy password-only auth # Redis username for ACL authentication (Redis 6.0+). Leave empty for legacy password-only auth
username: "${REDIS_USERNAME:}" username: "${REDIS_USERNAME:}"
@ -883,6 +904,7 @@ redis:
# Update version parameters # Update version parameters
# Controls whether the platform periodically checks for new ThingsBoard releases.
updates: updates:
# Enable/disable checks for the new version # Enable/disable checks for the new version
enabled: "${UPDATES_ENABLED:true}" enabled: "${UPDATES_ENABLED:true}"
@ -911,6 +933,7 @@ spring.mvc.cors:
allow-credentials: "${TB_CORS_ALLOW_CREDENTIALS:true}" allow-credentials: "${TB_CORS_ALLOW_CREDENTIALS:true}"
# General spring parameters # General spring parameters
# Miscellaneous Spring Boot settings for circular references, Freemarker, MVC, multipart, and JPA dialect.
spring.main.allow-circular-references: "true" # Spring Boot configuration property that controls whether circular dependencies between beans are allowed. spring.main.allow-circular-references: "true" # Spring Boot configuration property that controls whether circular dependencies between beans are allowed.
spring.freemarker.checkTemplateLocation: "false" # spring freemarker configuration spring.freemarker.checkTemplateLocation: "false" # spring freemarker configuration
spring.mvc.async.request-timeout: "${SPRING_MVC_ASYNC_REQUEST_TIMEOUT:30000}" # The default timeout for asynchronous requests in milliseconds spring.mvc.async.request-timeout: "${SPRING_MVC_ASYNC_REQUEST_TIMEOUT:30000}" # The default timeout for asynchronous requests in milliseconds
@ -929,6 +952,7 @@ spring.jpa.properties.hibernate.order_by.default_null_ordering: "${SPRING_JPA_PR
spring.jpa.properties.hibernate.dialect: "${SPRING_JPA_DIALECT:org.thingsboard.server.dao.ThingsboardPostgreSQLDialect}" # we use custom dialect that contains ilike(arg1, arg2) function (is interpreted to postgres ILIKE operator) spring.jpa.properties.hibernate.dialect: "${SPRING_JPA_DIALECT:org.thingsboard.server.dao.ThingsboardPostgreSQLDialect}" # we use custom dialect that contains ilike(arg1, arg2) function (is interpreted to postgres ILIKE operator)
# SQL DAO Configuration parameters # SQL DAO Configuration parameters
# Configures Spring JPA, Hibernate DDL, and HikariCP datasource connections for the primary and events databases.
spring: spring:
data: data:
jpa: jpa:
@ -979,6 +1003,7 @@ spring:
registerMbeans: "${SPRING_EVENTS_DATASOURCE_HIKARI_REGISTER_MBEANS:false}" registerMbeans: "${SPRING_EVENTS_DATASOURCE_HIKARI_REGISTER_MBEANS:false}"
# Audit log parameters # Audit log parameters
# Configures audit logging levels per entity type and optional forwarding to an external sink such as Elasticsearch.
audit-log: audit-log:
# Enable/disable audit log functionality. # Enable/disable audit log functionality.
enabled: "${AUDIT_LOG_ENABLED:true}" enabled: "${AUDIT_LOG_ENABLED:true}"
@ -1021,6 +1046,7 @@ audit-log:
password: "${AUDIT_LOG_SINK_PASSWORD:}" # Password used to access external sink system password: "${AUDIT_LOG_SINK_PASSWORD:}" # Password used to access external sink system
# Device state parameters # Device state parameters
# Configures device inactivity detection, state persistence strategy, and rate limits for state rule nodes.
state: state:
# Device inactivity timeout is a global configuration parameter that defines when the device will be marked as "inactive" by the server. # Device inactivity timeout is a global configuration parameter that defines when the device will be marked as "inactive" by the server.
# The parameter value is in seconds. A user can overwrite this parameter for an individual device by setting the “inactivityTimeout” server-side attribute (NOTE: expects value in milliseconds). # The parameter value is in seconds. A user can overwrite this parameter for an individual device by setting the “inactivityTimeout” server-side attribute (NOTE: expects value in milliseconds).
@ -1051,7 +1077,8 @@ state:
# Refill is set to be greedy. Please refer to Bucket4j library documentation for more details. # Refill is set to be greedy. Please refer to Bucket4j library documentation for more details.
rateLimit: "${DEVICE_STATE_NODE_RATE_LIMIT_CONFIGURATION:1:1,30:60,60:3600}" rateLimit: "${DEVICE_STATE_NODE_RATE_LIMIT_CONFIGURATION:1:1,30:60,60:3600}"
# Tbel parameters # TBEL parameters
# Configures the ThingsBoard Expression Language (TBEL) engine: limits, timeouts, thread pool, and blacklisting.
tbel: tbel:
# Enable/Disable TBEL feature. # Enable/Disable TBEL feature.
enabled: "${TBEL_ENABLED:true}" enabled: "${TBEL_ENABLED:true}"
@ -1080,6 +1107,7 @@ tbel:
print_interval_ms: "${TB_TBEL_STATS_PRINT_INTERVAL_MS:10000}" print_interval_ms: "${TB_TBEL_STATS_PRINT_INTERVAL_MS:10000}"
# JS parameters # JS parameters
# Configures the JavaScript execution engine (local Nashorn or remote Node.js): limits, sandboxing, and thread pools.
js: js:
# local (Nashorn Engine, deprecated) OR remote JS-Executors (NodeJS) # local (Nashorn Engine, deprecated) OR remote JS-Executors (NodeJS)
evaluator: "${JS_EVALUATOR:local}" evaluator: "${JS_EVALUATOR:local}"
@ -1127,6 +1155,7 @@ js:
print_interval_ms: "${TB_JS_REMOTE_STATS_PRINT_INTERVAL_MS:10000}" print_interval_ms: "${TB_JS_REMOTE_STATS_PRINT_INTERVAL_MS:10000}"
# Transport configuration parameters # Transport configuration parameters
# Configures session management, rate limits, and protocol-specific settings for HTTP, MQTT, CoAP, LwM2M, and SNMP transports.
transport: transport:
sessions: sessions:
# Session inactivity timeout is a global configuration parameter that defines how long the device transport session will be opened after the last message arrives from the device. # Session inactivity timeout is a global configuration parameter that defines how long the device transport session will be opened after the last message arrives from the device.
@ -1448,6 +1477,7 @@ transport:
check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}"
# CoAP server parameters # CoAP server parameters
# Configures the standalone CoAP server bind address, port, DTLS encryption, and credential settings.
coap: coap:
server: server:
# Enable/disable coap server. # Enable/disable coap server.
@ -1532,6 +1562,7 @@ coap:
dtls_session_report_timeout: "${TB_COAP_X509_DTLS_SESSION_REPORT_TIMEOUT:1800000}" dtls_session_report_timeout: "${TB_COAP_X509_DTLS_SESSION_REPORT_TIMEOUT:1800000}"
# Device connectivity parameters # Device connectivity parameters
# Specifies the hosts, ports, and credentials exposed to the UI for generating device connection check commands.
device: device:
connectivity: connectivity:
http: http:
@ -1585,6 +1616,7 @@ device:
image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.8-stable}" image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.8-stable}"
# Edges parameters # Edges parameters
# Controls Edge instance gRPC communication, event storage, state persistence, and statistics reporting.
edges: edges:
# Enable/disable Edge instance # Enable/disable Edge instance
enabled: "${EDGES_ENABLED:true}" enabled: "${EDGES_ENABLED:true}"
@ -1653,6 +1685,7 @@ edges:
report-interval-millis: "${EDGES_STATS_REPORT_INTERVAL_MS:600000}" report-interval-millis: "${EDGES_STATS_REPORT_INTERVAL_MS:600000}"
# Spring doc common parameters # Spring doc common parameters
# Enables or disables the OpenAPI/Swagger documentation endpoint and sets the default media type.
springdoc: springdoc:
# If false swagger API docs will be unavailable # If false swagger API docs will be unavailable
api-docs.enabled: "${SWAGGER_ENABLED:true}" api-docs.enabled: "${SWAGGER_ENABLED:true}"
@ -1660,6 +1693,7 @@ springdoc:
default-produces-media-type: "${SWAGGER_DEFAULT_PRODUCES_MEDIA_TYPE:application/json}" default-produces-media-type: "${SWAGGER_DEFAULT_PRODUCES_MEDIA_TYPE:application/json}"
# Swagger common parameters # Swagger common parameters
# Configures Swagger UI metadata: title, description, contact, license, version, and API path patterns.
swagger: swagger:
# General swagger match pattern of swagger UI links # General swagger match pattern of swagger UI links
api_path: "${SWAGGER_API_PATH:/api/**}" api_path: "${SWAGGER_API_PATH:/api/**}"
@ -1691,6 +1725,7 @@ swagger:
doc_expansion: "${SWAGGER_DOC_EXPANSION:list}" doc_expansion: "${SWAGGER_DOC_EXPANSION:list}"
# Queue configuration parameters # Queue configuration parameters
# Configures the message queue backend (in-memory or Kafka) and all topic/partition/consumer settings for each service.
queue: queue:
type: "${TB_QUEUE_TYPE:in-memory}" # in-memory or kafka (Apache Kafka) type: "${TB_QUEUE_TYPE:in-memory}" # in-memory or kafka (Apache Kafka)
prefix: "${TB_QUEUE_PREFIX:}" # Global queue prefix. If specified, prefix is added before default topic name: 'prefix.default_topic_name'. Prefix is applied to all topics (and consumer groups for kafka). prefix: "${TB_QUEUE_PREFIX:}" # Global queue prefix. If specified, prefix is added before default topic name: 'prefix.default_topic_name'. Prefix is applied to all topics (and consumer groups for kafka).
@ -1749,11 +1784,23 @@ queue:
# The endpoint identification algorithm used by clients to validate server hostname. The default value is https # The endpoint identification algorithm used by clients to validate server hostname. The default value is https
ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}"
# The mechanism used to authenticate Schema Registry requests. SASL/PLAIN should only be used with TLS/SSL as a transport layer to ensure that clear passwords are not transmitted on the wire without encryption # The mechanism used to authenticate Schema Registry requests. SASL/PLAIN should only be used with TLS/SSL as a transport layer to ensure that clear passwords are not transmitted on the wire without encryption
# Set to OAUTHBEARER to authenticate via OAuth2/OIDC client-credentials (configure the oauth.* settings below)
sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}"
# Using JAAS Configuration for specifying multiple SASL mechanisms on a broker # Using JAAS Configuration for specifying multiple SASL mechanisms on a broker
sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}"
# Protocol used to communicate with brokers. Valid values are: PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL # Protocol used to communicate with brokers. Valid values are: PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL
security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}"
# Optional: OAuth2 (OIDC) client-credentials settings. Applied only when sasl.mechanism is OAUTHBEARER.
# ThingsBoard wires Kafka's built-in OAuthBearerLoginCallbackHandler; the token is fetched and refreshed automatically.
oauth:
# Optional: OAuth2 client id
client-id: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_ID:}"
# Optional: OAuth2 client secret
client-secret: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_SECRET:}"
# Optional: OAuth2/OIDC token endpoint URL to request the bearer token from
endpoint-url: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_ENDPOINT_URL:}"
# Optional: OAuth2 scope/audience requested for the token. Required by some IdPs for client-credentials (e.g. Azure AD's "api://<id>/.default")
scope: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_SCOPE:}"
# Key-value properties for Kafka consumer per specific topic, e.g. tb_ota_package is a topic name for ota, tb_rule_engine.sq is a topic name for default SequentialByOriginator queue. # Key-value properties for Kafka consumer per specific topic, e.g. tb_ota_package is a topic name for ota, tb_rule_engine.sq is a topic name for default SequentialByOriginator queue.
# Check TB_QUEUE_CORE_OTA_TOPIC and TB_QUEUE_RE_SQ_TOPIC params # Check TB_QUEUE_CORE_OTA_TOPIC and TB_QUEUE_RE_SQ_TOPIC params
consumer-properties-per-topic: consumer-properties-per-topic:
@ -2030,6 +2077,9 @@ queue:
print-interval-ms: "${TB_QUEUE_RULE_ENGINE_STATS_PRINT_INTERVAL_MS:60000}" print-interval-ms: "${TB_QUEUE_RULE_ENGINE_STATS_PRINT_INTERVAL_MS:60000}"
# Max length of the error message that is printed by statistics # Max length of the error message that is printed by statistics
max-error-message-length: "${TB_QUEUE_RULE_ENGINE_MAX_ERROR_MESSAGE_LENGTH:4096}" max-error-message-length: "${TB_QUEUE_RULE_ENGINE_MAX_ERROR_MESSAGE_LENGTH:4096}"
prometheus-stats:
# Enable/disable Prometheus statistics for individual Rule Engine message processing (records time in ms for success/failure).
enabled: "${TB_QUEUE_RULE_ENGINE_PROMETHEUS_STATS_ENABLED:false}"
# After a queue is deleted (or the profile's isolation option was disabled), Rule Engine will continue reading related topics during this period before deleting the actual topics # After a queue is deleted (or the profile's isolation option was disabled), Rule Engine will continue reading related topics during this period before deleting the actual topics
topic-deletion-delay: "${TB_QUEUE_RULE_ENGINE_TOPIC_DELETION_DELAY_SEC:15}" topic-deletion-delay: "${TB_QUEUE_RULE_ENGINE_TOPIC_DELETION_DELAY_SEC:15}"
# Size of the thread pool that handles such operations as partition changes, config updates, queue deletion # Size of the thread pool that handles such operations as partition changes, config updates, queue deletion
@ -2101,12 +2151,14 @@ queue:
processing_interval: "${TB_QUEUE_TASKS_STATS_PROCESSING_INTERVAL_MS:1000}" processing_interval: "${TB_QUEUE_TASKS_STATS_PROCESSING_INTERVAL_MS:1000}"
# Event configuration parameters # Event configuration parameters
# Controls limits on debug event content size stored for rule chain and rule node execution.
event: event:
debug: debug:
# Maximum number of symbols per debug event. The event content will be truncated if needed # Maximum number of symbols per debug event. The event content will be truncated if needed
max-symbols: "${TB_MAX_DEBUG_EVENT_SYMBOLS:4096}" max-symbols: "${TB_MAX_DEBUG_EVENT_SYMBOLS:4096}"
# General service parameters # General service parameters
# Sets the deployment type (monolith or microservice) and assigns tenant profiles to specific Rule Engine instances.
service: service:
type: "${TB_SERVICE_TYPE:monolith}" # monolith or tb-core or tb-rule-engine type: "${TB_SERVICE_TYPE:monolith}" # monolith or tb-core or tb-rule-engine
# Unique id for this service (autogenerated if empty) # Unique id for this service (autogenerated if empty)
@ -2120,6 +2172,7 @@ service:
executor_thread_pool_size: "${TB_RULE_ENGINE_PUBSUB_EXECUTOR_THREAD_POOL_SIZE:0}" executor_thread_pool_size: "${TB_RULE_ENGINE_PUBSUB_EXECUTOR_THREAD_POOL_SIZE:0}"
# Metrics parameters # Metrics parameters
# Enables actuator metrics endpoint and configures system info (CPU, memory) persistence frequency and TTL.
metrics: metrics:
# Enable/disable actuator metrics. # Enable/disable actuator metrics.
enabled: "${METRICS_ENABLED:false}" enabled: "${METRICS_ENABLED:false}"
@ -2133,6 +2186,7 @@ metrics:
ttl: "${METRICS_SYSTEM_INFO_TTL_DAYS:7}" ttl: "${METRICS_SYSTEM_INFO_TTL_DAYS:7}"
# Version control parameters # Version control parameters
# Configures thread pools and Git repository folder for entity version control (export/import) operations.
vc: vc:
# Pool size for handling export tasks # Pool size for handling export tasks
thread_pool_size: "${TB_VC_POOL_SIZE:6}" thread_pool_size: "${TB_VC_POOL_SIZE:6}"
@ -2143,6 +2197,7 @@ vc:
repositories-folder: "${TB_VC_GIT_REPOSITORIES_FOLDER:${java.io.tmpdir}/repositories}" repositories-folder: "${TB_VC_GIT_REPOSITORIES_FOLDER:${java.io.tmpdir}/repositories}"
# Notification system parameters # Notification system parameters
# Configures thread pool size and deduplication intervals for the notification rule processing engine.
notification_system: notification_system:
# Specify thread pool size for Notification System processing notification rules and notification sending. Recommend value <= 10 # Specify thread pool size for Notification System processing notification rules and notification sending. Recommend value <= 10
thread_pool_size: "${TB_NOTIFICATION_SYSTEM_THREAD_POOL_SIZE:10}" thread_pool_size: "${TB_NOTIFICATION_SYSTEM_THREAD_POOL_SIZE:10}"
@ -2151,6 +2206,7 @@ notification_system:
deduplication_durations: "${TB_NOTIFICATION_RULES_DEDUPLICATION_DURATIONS:NEW_PLATFORM_VERSION:0;RATE_LIMITS:14400000;}" deduplication_durations: "${TB_NOTIFICATION_RULES_DEDUPLICATION_DURATIONS:NEW_PLATFORM_VERSION:0;RATE_LIMITS:14400000;}"
# General management parameters # General management parameters
# Configures Spring Boot Actuator endpoint exposure and health indicator settings.
management: management:
endpoints: endpoints:
web: web:
@ -2163,6 +2219,7 @@ management:
enabled: "false" enabled: "false"
# Mobile application settings for Thingsboard mobile application # Mobile application settings for Thingsboard mobile application
# Specifies domain name and store links used for the ThingsBoard Live mobile application QR code and deep links.
mobileApp: mobileApp:
# Server domain name for Thingsboard Live mobile application # Server domain name for Thingsboard Live mobile application
domain: "${TB_MOBILE_APP_DOMAIN:demo.thingsboard.io}" domain: "${TB_MOBILE_APP_DOMAIN:demo.thingsboard.io}"
@ -2171,6 +2228,8 @@ mobileApp:
# Link to App Store for Thingsboard Live mobile application # Link to App Store for Thingsboard Live mobile application
appStoreLink: "${TB_MOBILE_APP_APP_STORE_LINK:https://apps.apple.com/us/app/thingsboard-live/id1594355695}" appStoreLink: "${TB_MOBILE_APP_APP_STORE_LINK:https://apps.apple.com/us/app/thingsboard-live/id1594355695}"
# MQTT client parameters
# Configures MQTT client retransmission behavior including max attempts, initial delay, and jitter factor.
mqtt: mqtt:
# MQTT client configuration parameters # MQTT client configuration parameters
client: client:

2
application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java

@ -38,7 +38,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.RepeatingAlarmCon
import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NoDataFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NoDataFilterPredicate;

11
application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java

@ -86,12 +86,12 @@ public abstract class AbstractControllerTest extends AbstractNotifyEntityTest {
} }
@Before @Before
public void beforeWsTest() throws Exception { public void beforeWsTest() {
// placeholder // placeholder
} }
@After @After
public void afterWsTest() throws Exception { public void afterWsTest() {
if (wsClient != null) { if (wsClient != null) {
wsClient.close(); wsClient.close();
} }
@ -113,4 +113,11 @@ public abstract class AbstractControllerTest extends AbstractNotifyEntityTest {
return wsClient; return wsClient;
} }
protected TbTestWebSocketClient buildAndConnectWebSocketClientWithApiKey(String apiKey) throws URISyntaxException, InterruptedException {
TbTestWebSocketClient wsClient = new TbTestWebSocketClient(new URI(WS_URL + wsPort + "/api/ws"));
assertThat(wsClient.connectBlocking(TIMEOUT, TimeUnit.SECONDS)).isTrue();
wsClient.authenticateWithApiKey(apiKey);
return wsClient;
}
} }

71
application/src/test/java/org/thingsboard/server/controller/ApiKeyWebSocketApiTest.java

@ -0,0 +1,71 @@
/**
* 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.controller;
import lombok.extern.slf4j.Slf4j;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.thingsboard.server.common.data.pat.ApiKey;
import org.thingsboard.server.common.data.pat.ApiKeyInfo;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Slf4j
@DaoSqlTest
public class ApiKeyWebSocketApiTest extends WebSocketApiTest {
private ApiKey apiKey;
@Before
public void setUpApiKey() {
ApiKeyInfo apiKeyInfo = new ApiKeyInfo();
apiKeyInfo.setDescription("WS test API key");
apiKeyInfo.setEnabled(true);
apiKeyInfo.setUserId(tenantAdminUserId);
apiKey = doPost("/api/apiKey", apiKeyInfo, ApiKey.class);
}
@After
public void tearDownApiKey() throws Exception {
loginTenantAdmin();
doDelete("/api/apiKey/" + apiKey.getId()).andExpect(status().isOk());
}
@Override
protected TbTestWebSocketClient buildAndConnectWebSocketClient() throws URISyntaxException, InterruptedException {
return buildAndConnectWebSocketClientWithApiKey(apiKey.getValue());
}
@Test
public void testInvalidApiKeyAuthCmd_connectionClosed() throws Exception {
TbTestWebSocketClient client = new TbTestWebSocketClient(new URI(WS_URL + wsPort + "/api/ws"));
assertThat(client.connectBlocking(TIMEOUT, TimeUnit.SECONDS)).isTrue();
try {
client.authenticateWithApiKey("invalid-key");
assertThat(client.waitForClose()).isTrue();
} finally {
client.close();
}
}
}

527
application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java

@ -28,6 +28,7 @@ import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultActions;
import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
@ -62,6 +63,8 @@ import org.thingsboard.server.common.data.query.AlarmDataQuery;
import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.AliasEntityId;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; import org.thingsboard.server.common.data.query.AvailableEntityKeysV2;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo; import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo;
import org.thingsboard.server.common.data.query.ComplexFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.DeviceTypeFilter;
import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.DynamicValue;
import org.thingsboard.server.common.data.query.DynamicValueSourceType; import org.thingsboard.server.common.data.query.DynamicValueSourceType;
@ -89,6 +92,7 @@ import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
import org.thingsboard.server.dao.entity.BaseEntityService;
import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.edqs.util.EdqsRocksDb; import org.thingsboard.server.edqs.util.EdqsRocksDb;
@ -123,6 +127,8 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
@Autowired @Autowired
private QueueStatsService queueStatsService; private QueueStatsService queueStatsService;
@Autowired
private BaseEntityService baseEntityService;
@MockitoBean @MockitoBean
private EdqsRocksDb edqsRocksDb; private EdqsRocksDb edqsRocksDb;
@ -1343,7 +1349,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
//assign dashboard //assign dashboard
doPost("/api/customer/" + savedCustomer.getId().getId().toString() doPost("/api/customer/" + savedCustomer.getId().getId().toString()
+ "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class);
// check entity data query by customer // check entity data query by customer
User customerUser = new User(); User customerUser = new User();
@ -1729,4 +1735,523 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
return new EntityDataQuery(filter, new EntityDataPageLink(1, 0, null, null), Collections.emptyList(), null, null); return new EntityDataQuery(filter, new EntityDataPageLink(1, 0, null, null), Collections.emptyList(), null, null);
} }
// --- OR conditions tests ---
private Device createDeviceWithSharedAttributes(String name, String type, String sharedAttributesPayload) throws Exception {
Device device = new Device();
device.setName(name);
device.setType(type);
device = doPost("/api/device", device, Device.class);
if (sharedAttributesPayload != null) {
doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE,
sharedAttributesPayload, String.class, status().isOk());
}
return device;
}
private Device createDeviceWithTimeseries(String name, String type, String timeseriesPayload) throws Exception {
Device device = new Device();
device.setName(name);
device.setType(type);
device = doPost("/api/device", device, Device.class);
JsonNode payload = JacksonUtil.toJsonNode(timeseriesPayload);
doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + device.getUuidId() + "/timeseries/SERVER_SCOPE", payload)
.andExpect(status().isOk());
return device;
}
private Alarm createAlarm(DeviceId originator, String type, AlarmSeverity severity) throws Exception {
Alarm alarm = new Alarm();
alarm.setOriginator(originator);
alarm.setType(type);
alarm.setSeverity(severity);
return doPost("/api/alarm", alarm, Alarm.class);
}
private static DeviceTypeFilter deviceTypeFilter(String type) {
DeviceTypeFilter filter = new DeviceTypeFilter();
filter.setDeviceTypes(List.of(type));
filter.setDeviceNameFilter("");
return filter;
}
private static KeyFilter numericKeyFilter(EntityKeyType keyType, String key,
NumericFilterPredicate.NumericOperation operation, double value) {
KeyFilter keyFilter = new KeyFilter();
keyFilter.setKey(new EntityKey(keyType, key));
keyFilter.setValueType(EntityKeyValueType.NUMERIC);
NumericFilterPredicate predicate = new NumericFilterPredicate();
predicate.setValue(FilterPredicateValue.fromDouble(value));
predicate.setOperation(operation);
keyFilter.setPredicate(predicate);
return keyFilter;
}
private static KeyFilter numericAttributeKeyFilter(String key,
NumericFilterPredicate.NumericOperation operation, double value) {
return numericKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value);
}
private static KeyFilter stringKeyFilter(EntityKeyType keyType, String key,
StringFilterPredicate.StringOperation operation, String value) {
KeyFilter keyFilter = new KeyFilter();
keyFilter.setKey(new EntityKey(keyType, key));
keyFilter.setValueType(EntityKeyValueType.STRING);
StringFilterPredicate predicate = new StringFilterPredicate();
predicate.setValue(FilterPredicateValue.fromString(value));
predicate.setOperation(operation);
keyFilter.setPredicate(predicate);
return keyFilter;
}
private static KeyFilter stringAttributeKeyFilter(String key,
StringFilterPredicate.StringOperation operation, String value) {
return stringKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value);
}
private static EntityDataPageLink pageLinkSortedByName(int pageSize, int page, String textSearch) {
EntityDataSortOrder sortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC);
return new EntityDataPageLink(pageSize, page, textSearch, sortOrder);
}
private static List<EntityKey> nameEntityField() {
return Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
}
private static List<String> extractNames(PageData<EntityData> result) {
return result.getData().stream()
.map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue())
.collect(Collectors.toList());
}
@Test
public void testCountEntitiesWithOrKeyFiltersOperation() throws Exception {
String type = "orTestType";
createDeviceWithSharedAttributes("OrTestDeviceA", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("OrTestDeviceB", type, "{\"temperature\":5}");
createDeviceWithSharedAttributes("OrTestDeviceC", type, "{\"temperature\":30}");
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR: deviceA (60>50) and deviceB (5<10) match => count=2
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// AND: no device has temperature both >50 AND <10 => count=0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testFindEntityDataWithOrKeyFiltersOperation() throws Exception {
String type = "orDataType";
createDeviceWithSharedAttributes("OrDataDeviceX", type, "{\"status\":\"active\"}");
createDeviceWithSharedAttributes("OrDataDeviceY", type, "{\"humidity\":80}");
createDeviceWithSharedAttributes("OrDataDeviceZ", type, null); // no matching attribute
List<KeyFilter> keyFilters = List.of(
stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"),
numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 70));
// OR: deviceX matches status=active, deviceY matches humidity>70
EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2));
PageData<EntityData> result = findByQueryAndCheck(orQuery, 2);
assertThat(extractNames(result)).containsExactlyInAnyOrder("OrDataDeviceX", "OrDataDeviceY");
}
@Test
public void testFindEntityDataWithOrDoesNotLeakFilterOnlyEntityFields() throws Exception {
// Regression test: under OR, an entity-field filter (e.g. label) that isn't declared in
// entityFields must not leak its value into EntityData.latest[ENTITY_FIELD].
String type = "orLeakGuardType";
Device d1 = createDeviceWithSharedAttributes("OrLeakDeviceA", type, "{\"status\":\"active\"}");
d1.setLabel("leak-label-A");
doPost("/api/device", d1, Device.class);
Device d2 = createDeviceWithSharedAttributes("OrLeakDeviceB", type, null);
d2.setLabel("leak-label-B");
doPost("/api/device", d2, Device.class);
List<KeyFilter> keyFilters = List.of(
stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"),
buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "label", StringFilterPredicate.StringOperation.EQUAL, "leak-label-B"));
EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2));
PageData<EntityData> result = findByQueryAndCheck(orQuery, 2);
assertThat(extractNames(result)).containsExactlyInAnyOrder("OrLeakDeviceA", "OrLeakDeviceB");
for (EntityData entity : result.getData()) {
assertThat(entity.getLatest().get(EntityKeyType.ENTITY_FIELD)).containsOnlyKeys("name");
}
}
@Test
public void testFindEntityDataWithOrSameKeyFilters() throws Exception {
String type = "orSameKeyType";
createDeviceWithSharedAttributes("OrSameKeyDeviceA", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("OrSameKeyDeviceB", type, "{\"temperature\":5}");
// Same key "temperature" with two different predicates exercises the OR-ungrouping path
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2));
PageData<EntityData> result = findByQueryAndCheck(orQuery, 2);
assertThat(extractNames(result)).containsExactlyInAnyOrder("OrSameKeyDeviceA", "OrSameKeyDeviceB");
}
@Test
public void testCountEntitiesWithoutKeyFiltersOperation() throws Exception {
String type = "backCompatType";
createDeviceWithSharedAttributes("BackCompatDeviceA", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("BackCompatDeviceB", type, "{\"temperature\":5}");
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// Await attribute propagation via an OR query that should find 2 when propagated
EntityCountQuery orCheckQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orCheckQuery, 2));
// Query without keyFiltersOperation (null) -- should behave as AND
EntityCountQuery nullOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters);
Long nullResult = countByQueryAndCheck(nullOpQuery, 0);
// Query with explicit AND -- should produce the same result
EntityCountQuery andOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
Long andResult = countByQueryAndCheck(andOpQuery, 0);
Assert.assertEquals(nullResult, andResult);
}
@Test
public void testAlarmDataQueryWithOrKeyFiltersOperation() throws Exception {
loginTenantAdmin();
String type = "orAlarmType";
Device deviceHot = createDeviceWithSharedAttributes("OrAlarmDeviceHot", type, "{\"temperature\":60}");
Device deviceCold = createDeviceWithSharedAttributes("OrAlarmDeviceCold", type, "{\"temperature\":5}");
Device deviceMid = createDeviceWithSharedAttributes("OrAlarmDeviceMid", type, "{\"temperature\":30}");
createAlarm(deviceHot.getId(), "highTemp", AlarmSeverity.CRITICAL);
createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING);
createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING);
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
AlarmDataPageLink pageLink = new AlarmDataPageLink();
pageLink.setPage(0);
pageLink.setPageSize(100);
pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "createdTime")));
List<EntityKey> alarmFields = List.of(new EntityKey(EntityKeyType.ALARM_FIELD, "type"));
// OR query: should return alarms for deviceHot (60>50) and deviceCold (5<10) = 2 alarms
AlarmDataQuery orAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findAlarmsByQueryAndCheck(orAlarmQuery, 2));
PageData<AlarmData> alarmResult = findAlarmsByQueryAndCheck(orAlarmQuery, 2);
List<String> alarmTypes = alarmResult.getData().stream().map(AlarmData::getType).collect(Collectors.toList());
assertThat(alarmTypes).containsExactlyInAnyOrder("highTemp", "lowTemp");
// AND query: no device has temp both >50 AND <10 => 0 alarms
AlarmDataQuery andAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.AND);
findAlarmsByQueryAndCheck(andAlarmQuery, 0);
}
@Test
public void testCountAlarmsByQueryWithOrKeyFiltersOperation() throws Exception {
loginTenantAdmin();
String type = "orAlarmCntType";
Device deviceHot = createDeviceWithSharedAttributes("OrAlarmCntDeviceHot", type, "{\"temperature\":60}");
Device deviceCold = createDeviceWithSharedAttributes("OrAlarmCntDeviceCold", type, "{\"temperature\":5}");
Device deviceMid = createDeviceWithSharedAttributes("OrAlarmCntDeviceMid", type, "{\"temperature\":30}");
// 2 alarms for deviceHot, 1 for deviceCold, 1 for deviceMid
createAlarm(deviceHot.getId(), "highTemp1", AlarmSeverity.CRITICAL);
createAlarm(deviceHot.getId(), "highTemp2", AlarmSeverity.CRITICAL);
createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING);
createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING);
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR: deviceHot (2 alarms) + deviceCold (1 alarm) match => 3 alarms total
AlarmCountQuery orQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countAlarmsByQueryAndCheck(orQuery, 3));
// AND: no device matches both filters => 0
AlarmCountQuery andQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countAlarmsByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrMixedEntityFieldAndAttribute() throws Exception {
// Exercises the entity field predicate relocation to middle-layer WHERE under OR
String type = "orMixedType";
createDeviceWithSharedAttributes("OrMixedAlpha", type, "{\"temperature\":10}");
createDeviceWithSharedAttributes("OrMixedBeta", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("OrMixedGamma", type, "{\"temperature\":10}");
List<KeyFilter> keyFilters = List.of(
stringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.CONTAINS, "Alpha"),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50));
// OR: Alpha matches name contains "Alpha", Beta matches temp>50 => count=2
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// AND: only Alpha has name "Alpha" AND temp is 10 (not >50) => count=0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrStringAttributes() throws Exception {
String type = "orStrType";
createDeviceWithSharedAttributes("OrStrDeviceA", type, "{\"color\":\"red\"}");
createDeviceWithSharedAttributes("OrStrDeviceB", type, "{\"color\":\"blue\"}");
createDeviceWithSharedAttributes("OrStrDeviceC", type, "{\"color\":\"green\"}");
List<KeyFilter> keyFilters = List.of(
stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "red"),
stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "blue"));
// OR: red and blue match => count=2
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// AND: no device is both red AND blue => count=0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrSingleFilter() throws Exception {
String type = "orSingleType";
createDeviceWithSharedAttributes("OrSingleDeviceA", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("OrSingleDeviceB", type, "{\"temperature\":30}");
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50));
// Single filter with OR should behave identically to AND
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 1));
Long orResult = countByQueryAndCheck(orQuery, 1);
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
Long andResult = countByQueryAndCheck(andQuery, 1);
Assert.assertEquals(orResult, andResult);
}
@Test
public void testCountEntitiesWithOrThreeFilters() throws Exception {
String type = "or3fType";
createDeviceWithSharedAttributes("Or3fDeviceA", type, "{\"temperature\":60,\"humidity\":50,\"pressure\":1000}");
createDeviceWithSharedAttributes("Or3fDeviceB", type, "{\"temperature\":20,\"humidity\":90,\"pressure\":1000}");
createDeviceWithSharedAttributes("Or3fDeviceC", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1050}");
createDeviceWithSharedAttributes("Or3fDeviceD", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1000}");
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80),
numericAttributeKeyFilter("pressure", NumericFilterPredicate.NumericOperation.GREATER, 1040));
// OR: A matches temp>50, B matches hum>80, C matches press>1040, D matches none => 3
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 3));
// AND: no device matches all three => 0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testFindEntityDataWithOrPagination() throws Exception {
// 5 devices, 4 match OR filters; explicit temperatures keep the setup easy to read.
String type = "orPageType";
int[] temperatures = {61, 62, 2, 1, 25}; // devices 1,2: temp>50; 3,4: temp<10; 5: no match
for (int i = 0; i < temperatures.length; i++) {
createDeviceWithSharedAttributes(
String.format("OrPageDevice%02d", i + 1), type,
"{\"temperature\":" + temperatures[i] + "}");
}
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// Page 1: pageSize=2, totalElements=4, data.size()=2
EntityDataQuery orQuery1 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 0, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery1, 4));
PageData<EntityData> page1 = findByQueryAndCheck(orQuery1, 4);
Assert.assertEquals(2, page1.getData().size());
Assert.assertTrue(page1.hasNext());
// Page 2: remaining 2 of 4
EntityDataQuery orQuery2 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 1, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
PageData<EntityData> page2 = findByQueryAndCheck(orQuery2, 4);
Assert.assertEquals(2, page2.getData().size());
Assert.assertFalse(page2.hasNext());
// All names across both pages should be from the 4 matching devices (01..04)
List<String> allNames = new ArrayList<>(extractNames(page1));
allNames.addAll(extractNames(page2));
assertThat(allNames).hasSize(4).doesNotContain("OrPageDevice05");
}
@Test
public void testCountEntitiesWithOrZeroMatches() throws Exception {
String type = "orZeroType";
createDeviceWithSharedAttributes("OrZeroDeviceA", type, "{\"temperature\":30}");
createDeviceWithSharedAttributes("OrZeroDeviceB", type, "{\"temperature\":40}");
// Await attribute propagation via a filter that actually matches both devices
EntityCountQuery propagationCheck = new EntityCountQuery(deviceTypeFilter(type), List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 20)),
ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(propagationCheck, 2));
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR with no matches: neither filter matches any device => count=0
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
countByQueryAndCheck(orQuery, 0);
}
@Test
public void testOrKeyFiltersOperationRejectedWhenDisabled() throws Exception {
loginTenantAdmin();
ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", false);
try {
DeviceTypeFilter filter = deviceTypeFilter("default");
// POST a query with OR operation -- should be rejected with 400
EntityCountQuery orQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.OR);
String errorMessage = getErrorMessage(
doPost("/api/entitiesQuery/count", orQuery).andExpect(status().isBadRequest())
);
assertThat(errorMessage).contains("OR conditions between key filters are disabled");
// POST a query without keyFiltersOperation (null/AND) -- should still succeed
EntityCountQuery andQuery = new EntityCountQuery(filter, Collections.emptyList());
doPost("/api/entitiesQuery/count", andQuery).andExpect(status().isOk());
// POST a query with explicit AND -- should also succeed
EntityCountQuery explicitAndQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.AND);
doPost("/api/entitiesQuery/count", explicitAndQuery).andExpect(status().isOk());
} finally {
ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", true);
}
}
@Test
public void testFindEntityDataWithOrAndTextSearch() throws Exception {
// 3 devices: 2 match OR filters, but only 1 also matches textSearch at a time
String type = "orTextType";
createDeviceWithSharedAttributes("OrTextAlpha", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("OrTextBeta", type, "{\"temperature\":5}");
createDeviceWithSharedAttributes("OrTextGamma", type, "{\"temperature\":30}");
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR without textSearch: Alpha and Beta match => 2
EntityDataQuery orQueryNoText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQueryNoText, 2));
// OR with textSearch="Alpha": only Alpha matches both OR filter AND text search
EntityDataQuery orQueryWithText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Alpha"),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
assertThat(extractNames(findByQueryAndCheck(orQueryWithText, 1))).containsExactly("OrTextAlpha");
// OR with textSearch="Beta": only Beta matches both OR filter AND text search
EntityDataQuery orQueryBeta = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Beta"),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
assertThat(extractNames(findByQueryAndCheck(orQueryBeta, 1))).containsExactly("OrTextBeta");
// OR with textSearch="Gamma": Gamma doesn't match any OR filter => 0
EntityDataQuery orQueryGamma = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Gamma"),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
findByQueryAndCheck(orQueryGamma, 0);
}
@Test
public void testCountEntitiesWithOrTimeSeriesKeyFilters() throws Exception {
String type = "orTsType";
createDeviceWithTimeseries("OrTsDeviceA", type, "{\"temperature\":60}");
createDeviceWithTimeseries("OrTsDeviceB", type, "{\"temperature\":5}");
createDeviceWithTimeseries("OrTsDeviceC", type, "{\"temperature\":30}");
List<KeyFilter> keyFilters = List.of(
numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR: deviceA (60>50) and deviceB (5<10) match => count=2
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// AND: no device has ts temperature both >50 AND <10 => count=0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrComplexFilterPredicate() throws Exception {
// Key-level ComplexFilterPredicate combined with query-level OR
String type = "orCplxType";
createDeviceWithSharedAttributes("OrCplxDeviceA", type, "{\"temperature\":65,\"humidity\":50}");
createDeviceWithSharedAttributes("OrCplxDeviceB", type, "{\"temperature\":25,\"humidity\":90}");
createDeviceWithSharedAttributes("OrCplxDeviceC", type, "{\"temperature\":25,\"humidity\":50}");
// Key filter 1: temperature > 50 AND temperature < 70 (complex predicate within key filter) — matches A only
NumericFilterPredicate gt50 = new NumericFilterPredicate();
gt50.setValue(FilterPredicateValue.fromDouble(50));
gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
NumericFilterPredicate lt70 = new NumericFilterPredicate();
lt70.setValue(FilterPredicateValue.fromDouble(70));
lt70.setOperation(NumericFilterPredicate.NumericOperation.LESS);
ComplexFilterPredicate complexTempPred = new ComplexFilterPredicate();
complexTempPred.setOperation(ComplexOperation.AND);
complexTempPred.setPredicates(List.of(gt50, lt70));
KeyFilter tempComplexFilter = new KeyFilter();
tempComplexFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
tempComplexFilter.setValueType(EntityKeyValueType.NUMERIC);
tempComplexFilter.setPredicate(complexTempPred);
// Key filter 2: humidity > 80 (simple predicate) — matches B only
List<KeyFilter> keyFilters = List.of(
tempComplexFilter,
numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80));
// Query-level OR: A matches key filter 1, B matches key filter 2 => 2
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// Query-level AND: no device matches both key filters => 0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
} }

196
application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java

@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.data.rule.RuleChainMetaData;
import org.thingsboard.server.common.data.rule.RuleChainNote;
import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.Authority;
@ -442,6 +443,201 @@ public class RuleChainControllerTest extends AbstractControllerTest {
assertThat(ruleChain.getVersion()).isEqualTo(5); assertThat(ruleChain.getVersion()).isEqualTo(5);
} }
@Test
public void testSaveAndLoadRuleChainMetaDataWithNotes() throws Exception {
RuleChain ruleChain = createRuleChain("RuleChain with notes");
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
ruleChainMetaData.setNodes(new ArrayList<>());
List<RuleChainNote> notes = new ArrayList<>();
RuleChainNote note1 = new RuleChainNote();
note1.setId("note-1");
note1.setX(100);
note1.setY(200);
note1.setWidth(300);
note1.setHeight(150);
note1.setContent("# Test Note\nSome markdown content");
note1.setBackgroundColor("#FFF9C4");
note1.setBorderColor("#E6C800");
note1.setBorderWidth(2);
note1.setApplyDefaultMarkdownStyle(true);
notes.add(note1);
RuleChainNote note2 = new RuleChainNote();
note2.setId("note-2");
note2.setX(500);
note2.setY(300);
note2.setWidth(200);
note2.setHeight(100);
note2.setContent("Simple note");
note2.setBackgroundColor("#C8E6C9");
notes.add(note2);
ruleChainMetaData.setNotes(notes);
RuleChainMetaData savedMetaData = doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
Assert.assertNotNull(savedMetaData);
Assert.assertNotNull(savedMetaData.getNotes());
Assert.assertEquals(2, savedMetaData.getNotes().size());
RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class);
Assert.assertNotNull(loadedMetaData);
Assert.assertNotNull(loadedMetaData.getNotes());
Assert.assertEquals(2, loadedMetaData.getNotes().size());
RuleChainNote loadedNote1 = loadedMetaData.getNotes().stream()
.filter(n -> "note-1".equals(n.getId())).findFirst().orElse(null);
Assert.assertNotNull(loadedNote1);
Assert.assertEquals(100, loadedNote1.getX());
Assert.assertEquals(200, loadedNote1.getY());
Assert.assertEquals(300, loadedNote1.getWidth());
Assert.assertEquals(150, loadedNote1.getHeight());
Assert.assertEquals("# Test Note\nSome markdown content", loadedNote1.getContent());
Assert.assertEquals("#FFF9C4", loadedNote1.getBackgroundColor());
Assert.assertEquals("#E6C800", loadedNote1.getBorderColor());
Assert.assertEquals(Integer.valueOf(2), loadedNote1.getBorderWidth());
Assert.assertEquals(Boolean.TRUE, loadedNote1.getApplyDefaultMarkdownStyle());
RuleChainNote loadedNote2 = loadedMetaData.getNotes().stream()
.filter(n -> "note-2".equals(n.getId())).findFirst().orElse(null);
Assert.assertNotNull(loadedNote2);
Assert.assertEquals(500, loadedNote2.getX());
Assert.assertEquals(300, loadedNote2.getY());
Assert.assertEquals("Simple note", loadedNote2.getContent());
Assert.assertEquals("#C8E6C9", loadedNote2.getBackgroundColor());
}
@Test
public void testUpdateRuleChainNotes() throws Exception {
RuleChain ruleChain = createRuleChain("RuleChain update notes");
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
ruleChainMetaData.setNodes(new ArrayList<>());
RuleChainNote note = new RuleChainNote();
note.setId("note-1");
note.setX(10);
note.setY(20);
note.setWidth(100);
note.setHeight(50);
note.setContent("Original content");
ruleChainMetaData.setNotes(List.of(note));
RuleChainMetaData savedMetaData = doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
note.setContent("Updated content");
note.setX(50);
RuleChainNote newNote = new RuleChainNote();
newNote.setId("note-2");
newNote.setX(200);
newNote.setY(300);
newNote.setWidth(150);
newNote.setHeight(75);
newNote.setContent("New note");
savedMetaData.setNotes(List.of(note, newNote));
RuleChainMetaData updatedMetaData = doPost("/api/ruleChain/metadata", savedMetaData, RuleChainMetaData.class);
Assert.assertEquals(2, updatedMetaData.getNotes().size());
RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class);
Assert.assertEquals(2, loadedMetaData.getNotes().size());
RuleChainNote updatedNote = loadedMetaData.getNotes().stream()
.filter(n -> "note-1".equals(n.getId())).findFirst().orElse(null);
Assert.assertNotNull(updatedNote);
Assert.assertEquals("Updated content", updatedNote.getContent());
Assert.assertEquals(50, updatedNote.getX());
}
@Test
public void testSaveRuleChainDoesNotOverwriteNotes() throws Exception {
RuleChain ruleChain = createRuleChain("RuleChain preserve notes");
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
ruleChainMetaData.setNodes(new ArrayList<>());
RuleChainNote note = new RuleChainNote();
note.setId("note-1");
note.setX(10);
note.setY(20);
note.setWidth(100);
note.setHeight(50);
note.setContent("Persistent note");
ruleChainMetaData.setNotes(List.of(note));
doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
// Save the RuleChain itself (not metadata) — e.g. rename
ruleChain = doGet("/api/ruleChain/" + ruleChain.getId().getId(), RuleChain.class);
ruleChain.setName("Renamed RuleChain");
ruleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class);
Assert.assertEquals("Renamed RuleChain", ruleChain.getName());
// Notes must still be present
RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class);
Assert.assertNotNull(loadedMetaData.getNotes());
Assert.assertEquals(1, loadedMetaData.getNotes().size());
Assert.assertEquals("Persistent note", loadedMetaData.getNotes().get(0).getContent());
}
@Test
public void testRemoveNotesByUpdatingMetadata() throws Exception {
RuleChain ruleChain = createRuleChain("RuleChain remove notes");
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
ruleChainMetaData.setNodes(new ArrayList<>());
RuleChainNote note = new RuleChainNote();
note.setId("note-1");
note.setX(10);
note.setY(20);
note.setWidth(100);
note.setHeight(50);
note.setContent("Will be removed");
ruleChainMetaData.setNotes(List.of(note));
RuleChainMetaData savedMetaData = doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
Assert.assertEquals(1, savedMetaData.getNotes().size());
// Save metadata without notes — should clear them
savedMetaData.setNotes(null);
RuleChainMetaData updatedMetaData = doPost("/api/ruleChain/metadata", savedMetaData, RuleChainMetaData.class);
RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class);
Assert.assertTrue(loadedMetaData.getNotes() == null || loadedMetaData.getNotes().isEmpty());
}
@Test
public void testSaveRuleChainNotesExceedsSizeLimit() throws Exception {
RuleChain ruleChain = createRuleChain("RuleChain oversized notes");
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
ruleChainMetaData.setNodes(new ArrayList<>());
List<RuleChainNote> notes = new ArrayList<>();
for (int i = 0; i < 20; i++) {
RuleChainNote note = new RuleChainNote();
note.setId("note-" + i);
note.setX(i * 10);
note.setY(i * 10);
note.setWidth(300);
note.setHeight(150);
note.setContent(StringUtils.randomAlphabetic(60000));
notes.add(note);
}
ruleChainMetaData.setNotes(notes);
String error = getErrorMessage(doPost("/api/ruleChain/metadata", ruleChainMetaData)
.andExpect(status().isBadRequest()));
assertThat(error).contains("Rule chain notes data is too large");
}
private RuleChain createRuleChain(String name) { private RuleChain createRuleChain(String name) {
RuleChain ruleChain = new RuleChain(); RuleChain ruleChain = new RuleChain();
ruleChain.setName(name); ruleChain.setName(name);

24
application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java

@ -39,7 +39,6 @@ import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityDataUpdate;
import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityHistoryCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityHistoryCmd;
import org.thingsboard.server.service.ws.telemetry.cmd.v2.LatestValueCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.LatestValueCmd;
import org.thingsboard.server.service.ws.telemetry.cmd.v2.TimeSeriesCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.TimeSeriesCmd;
import org.thingsboard.server.service.ws.telemetry.sub.TelemetrySubscriptionUpdate;
import java.net.URI; import java.net.URI;
import java.nio.channels.NotYetConnectedException; import java.nio.channels.NotYetConnectedException;
@ -53,6 +52,8 @@ public class TbTestWebSocketClient extends WebSocketClient {
private static final long TIMEOUT = TimeUnit.SECONDS.toMillis(30); private static final long TIMEOUT = TimeUnit.SECONDS.toMillis(30);
private final CountDownLatch closeLatch = new CountDownLatch(1);
@Getter @Getter
private volatile String lastMsg; private volatile String lastMsg;
private volatile CountDownLatch reply; private volatile CountDownLatch reply;
@ -62,6 +63,7 @@ public class TbTestWebSocketClient extends WebSocketClient {
super(serverUri); super(serverUri);
} }
@Override @Override
public void onOpen(ServerHandshake serverHandshake) { public void onOpen(ServerHandshake serverHandshake) {
@ -69,7 +71,13 @@ public class TbTestWebSocketClient extends WebSocketClient {
public void authenticate(String token) { public void authenticate(String token) {
WsCommandsWrapper cmdsWrapper = new WsCommandsWrapper(); WsCommandsWrapper cmdsWrapper = new WsCommandsWrapper();
cmdsWrapper.setAuthCmd(new AuthCmd(1, token)); cmdsWrapper.setAuthCmd(new AuthCmd(1, token, null));
send(JacksonUtil.toString(cmdsWrapper));
}
public void authenticateWithApiKey(String apiKey) {
WsCommandsWrapper cmdsWrapper = new WsCommandsWrapper();
cmdsWrapper.setAuthCmd(new AuthCmd(1, null, apiKey));
send(JacksonUtil.toString(cmdsWrapper)); send(JacksonUtil.toString(cmdsWrapper));
} }
@ -88,6 +96,16 @@ public class TbTestWebSocketClient extends WebSocketClient {
@Override @Override
public void onClose(int i, String s, boolean b) { public void onClose(int i, String s, boolean b) {
log.info("CLOSED."); log.info("CLOSED.");
closeLatch.countDown();
}
public boolean waitForClose() {
try {
return closeLatch.await(TIMEOUT, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
log.warn("Failed to await close", e);
return false;
}
} }
@Override @Override
@ -275,7 +293,7 @@ public class TbTestWebSocketClient extends WebSocketClient {
public JsonNode sendTimeseriesCmd(EntityId entityId, String scope) { public JsonNode sendTimeseriesCmd(EntityId entityId, String scope) {
log.warn("sendTimeseriesCmd entityId: {}, scope: {}", entityId, scope); log.warn("sendTimeseriesCmd entityId: {}, scope: {}", entityId, scope);
TimeseriesSubscriptionCmd cmd = new TimeseriesSubscriptionCmd(0, 0, 0, 10, null); TimeseriesSubscriptionCmd cmd = new TimeseriesSubscriptionCmd(0, 0, 0, 10, null);
cmd.setEntityId(entityId.getId().toString()); cmd.setEntityId(entityId.getId().toString());
cmd.setEntityType(entityId.getEntityType().toString()); cmd.setEntityType(entityId.getEntityType().toString());
cmd.setCmdId(1); cmd.setCmdId(1);

195
application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java → application/src/test/java/org/thingsboard/server/controller/WebSocketApiTest.java

@ -76,13 +76,11 @@ import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmStatusUpdate;
import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityCountCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityCountCmd;
import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityCountUpdate; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityCountUpdate;
import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityDataUpdate; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityDataUpdate;
import org.thingsboard.server.service.ws.telemetry.sub.TelemetrySubscriptionUpdate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -96,7 +94,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
"server.ws.alarms_per_alarm_status_subscription_cache_size=5", "server.ws.alarms_per_alarm_status_subscription_cache_size=5",
"server.ws.dynamic_page_link.refresh_interval=15" "server.ws.dynamic_page_link.refresh_interval=15"
}) })
public class WebsocketApiTest extends AbstractControllerTest { public class WebSocketApiTest extends AbstractControllerTest {
@Autowired @Autowired
private TelemetrySubscriptionService tsService; private TelemetrySubscriptionService tsService;
@ -136,8 +135,8 @@ public class WebsocketApiTest extends AbstractControllerTest {
PageData<EntityData> pageData = update.getData(); PageData<EntityData> pageData = update.getData();
Assert.assertNotNull(pageData); Assert.assertNotNull(pageData);
Assert.assertEquals(1, pageData.getData().size()); Assert.assertEquals(1, pageData.getData().size());
Assert.assertEquals(device.getId(), pageData.getData().get(0).getEntityId()); Assert.assertEquals(device.getId(), pageData.getData().getFirst().getEntityId());
Assert.assertEquals(0, pageData.getData().get(0).getTimeseries().get("temperature").length); Assert.assertEquals(0, pageData.getData().getFirst().getTimeseries().get("temperature").length);
TsKvEntry dataPoint1 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("temperature", 42L)); TsKvEntry dataPoint1 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("temperature", 42L));
TsKvEntry dataPoint2 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(2), new LongDataEntry("temperature", 42L)); TsKvEntry dataPoint2 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(2), new LongDataEntry("temperature", 42L));
@ -152,8 +151,8 @@ public class WebsocketApiTest extends AbstractControllerTest {
List<EntityData> dataList = update.getUpdate(); List<EntityData> dataList = update.getUpdate();
Assert.assertNotNull(dataList); Assert.assertNotNull(dataList);
Assert.assertEquals(1, dataList.size()); Assert.assertEquals(1, dataList.size());
Assert.assertEquals(device.getId(), dataList.get(0).getEntityId()); Assert.assertEquals(device.getId(), dataList.getFirst().getEntityId());
TsValue[] tsArray = dataList.get(0).getTimeseries().get("temperature"); TsValue[] tsArray = dataList.getFirst().getTimeseries().get("temperature");
Assert.assertEquals(3, tsArray.length); Assert.assertEquals(3, tsArray.length);
Assert.assertEquals(new TsValue(dataPoint1.getTs(), dataPoint1.getValueAsString()), tsArray[0]); Assert.assertEquals(new TsValue(dataPoint1.getTs(), dataPoint1.getValueAsString()), tsArray[0]);
Assert.assertEquals(new TsValue(dataPoint2.getTs(), dataPoint2.getValueAsString()), tsArray[1]); Assert.assertEquals(new TsValue(dataPoint2.getTs(), dataPoint2.getValueAsString()), tsArray[1]);
@ -170,7 +169,7 @@ public class WebsocketApiTest extends AbstractControllerTest {
PageData<EntityData> pageData = update.getData(); PageData<EntityData> pageData = update.getData();
Assert.assertNotNull(pageData); Assert.assertNotNull(pageData);
Assert.assertEquals(1, pageData.getData().size()); Assert.assertEquals(1, pageData.getData().size());
Assert.assertEquals(device.getId(), pageData.getData().get(0).getEntityId()); Assert.assertEquals(device.getId(), pageData.getData().getFirst().getEntityId());
TsKvEntry dataPoint1 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("temperature", 42L)); TsKvEntry dataPoint1 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("temperature", 42L));
TsKvEntry dataPoint2 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(2), new LongDataEntry("temperature", 43L)); TsKvEntry dataPoint2 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(2), new LongDataEntry("temperature", 43L));
@ -184,8 +183,8 @@ public class WebsocketApiTest extends AbstractControllerTest {
List<EntityData> listData = update.getUpdate(); List<EntityData> listData = update.getUpdate();
Assert.assertNotNull(listData); Assert.assertNotNull(listData);
Assert.assertEquals(1, listData.size()); Assert.assertEquals(1, listData.size());
Assert.assertEquals(device.getId(), listData.get(0).getEntityId()); Assert.assertEquals(device.getId(), listData.getFirst().getEntityId());
TsValue[] tsArray = listData.get(0).getTimeseries().get("temperature"); TsValue[] tsArray = listData.getFirst().getTimeseries().get("temperature");
Assert.assertEquals(3, tsArray.length); Assert.assertEquals(3, tsArray.length);
Assert.assertEquals(new TsValue(dataPoint1.getTs(), dataPoint1.getValueAsString()), tsArray[0]); Assert.assertEquals(new TsValue(dataPoint1.getTs(), dataPoint1.getValueAsString()), tsArray[0]);
Assert.assertEquals(new TsValue(dataPoint2.getTs(), dataPoint2.getValueAsString()), tsArray[1]); Assert.assertEquals(new TsValue(dataPoint2.getTs(), dataPoint2.getValueAsString()), tsArray[1]);
@ -194,7 +193,7 @@ public class WebsocketApiTest extends AbstractControllerTest {
now = System.currentTimeMillis(); now = System.currentTimeMillis();
TsKvEntry dataPoint4 = new BasicTsKvEntry(now, new LongDataEntry("temperature", 45L)); TsKvEntry dataPoint4 = new BasicTsKvEntry(now, new LongDataEntry("temperature", 45L));
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
sendTelemetry(device, Arrays.asList(dataPoint4)); sendTelemetry(device, List.of(dataPoint4));
String msg = getWsClient().waitForUpdate(); String msg = getWsClient().waitForUpdate();
update = JacksonUtil.fromString(msg, EntityDataUpdate.class); update = JacksonUtil.fromString(msg, EntityDataUpdate.class);
@ -202,9 +201,9 @@ public class WebsocketApiTest extends AbstractControllerTest {
List<EntityData> eData = update.getUpdate(); List<EntityData> eData = update.getUpdate();
Assert.assertNotNull(eData); Assert.assertNotNull(eData);
Assert.assertEquals(1, eData.size()); Assert.assertEquals(1, eData.size());
Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); Assert.assertEquals(device.getId(), eData.getFirst().getEntityId());
Assert.assertNotNull(eData.get(0).getTimeseries()); Assert.assertNotNull(eData.getFirst().getTimeseries());
TsValue[] tsValues = eData.get(0).getTimeseries().get("temperature"); TsValue[] tsValues = eData.getFirst().getTimeseries().get("temperature");
Assert.assertNotNull(tsValues); Assert.assertNotNull(tsValues);
Assert.assertEquals(new TsValue(dataPoint4.getTs(), dataPoint4.getValueAsString()), tsValues[0]); Assert.assertEquals(new TsValue(dataPoint4.getTs(), dataPoint4.getValueAsString()), tsValues[0]);
} }
@ -529,7 +528,7 @@ public class WebsocketApiTest extends AbstractControllerTest {
String msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); String msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1));
Assert.assertNull(msg); Assert.assertNull(msg);
// check device // check a device
AlarmStatusCmd deviceCmd = new AlarmStatusCmd(2, device.getId(), null, List.of(AlarmSeverity.CRITICAL)); AlarmStatusCmd deviceCmd = new AlarmStatusCmd(2, device.getId(), null, List.of(AlarmSeverity.CRITICAL));
getWsClient().send(deviceCmd); getWsClient().send(deviceCmd);
@ -597,13 +596,13 @@ public class WebsocketApiTest extends AbstractControllerTest {
PageData<EntityData> pageData = update.getData(); PageData<EntityData> pageData = update.getData();
Assert.assertNotNull(pageData); Assert.assertNotNull(pageData);
Assert.assertEquals(1, pageData.getData().size()); Assert.assertEquals(1, pageData.getData().size());
Assert.assertEquals(device.getId(), pageData.getData().get(0).getEntityId()); Assert.assertEquals(device.getId(), pageData.getData().getFirst().getEntityId());
Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature")); Assert.assertNotNull(pageData.getData().getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"));
Assert.assertEquals(0, pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getTs()); Assert.assertEquals(0, pageData.getData().getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getTs());
Assert.assertEquals("", pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()); Assert.assertEquals("", pageData.getData().getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue());
TsKvEntry dataPoint1 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("temperature", 42L)); TsKvEntry dataPoint1 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("temperature", 42L));
List<TsKvEntry> tsData = Arrays.asList(dataPoint1); List<TsKvEntry> tsData = List.of(dataPoint1);
sendTelemetry(device, tsData); sendTelemetry(device, tsData);
update = getWsClient().subscribeLatestUpdate(keys); update = getWsClient().subscribeLatestUpdate(keys);
@ -613,36 +612,36 @@ public class WebsocketApiTest extends AbstractControllerTest {
List<EntityData> listData = update.getUpdate(); List<EntityData> listData = update.getUpdate();
Assert.assertNotNull(listData); Assert.assertNotNull(listData);
Assert.assertEquals(1, listData.size()); Assert.assertEquals(1, listData.size());
Assert.assertEquals(device.getId(), listData.get(0).getEntityId()); Assert.assertEquals(device.getId(), listData.getFirst().getEntityId());
Assert.assertNotNull(listData.get(0).getLatest().get(EntityKeyType.TIME_SERIES)); Assert.assertNotNull(listData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES));
TsValue tsValue = listData.get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"); TsValue tsValue = listData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature");
Assert.assertEquals(new TsValue(dataPoint1.getTs(), dataPoint1.getValueAsString()), tsValue); Assert.assertEquals(new TsValue(dataPoint1.getTs(), dataPoint1.getValueAsString()), tsValue);
now = System.currentTimeMillis(); now = System.currentTimeMillis();
TsKvEntry dataPoint2 = new BasicTsKvEntry(now, new LongDataEntry("temperature", 52L)); TsKvEntry dataPoint2 = new BasicTsKvEntry(now, new LongDataEntry("temperature", 52L));
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
sendTelemetry(device, Arrays.asList(dataPoint2)); sendTelemetry(device, List.of(dataPoint2));
update = getWsClient().parseDataReply(getWsClient().waitForUpdate()); update = getWsClient().parseDataReply(getWsClient().waitForUpdate());
Assert.assertEquals(1, update.getCmdId()); Assert.assertEquals(1, update.getCmdId());
List<EntityData> eData = update.getUpdate(); List<EntityData> eData = update.getUpdate();
Assert.assertNotNull(eData); Assert.assertNotNull(eData);
Assert.assertEquals(1, eData.size()); Assert.assertEquals(1, eData.size());
Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); Assert.assertEquals(device.getId(), eData.getFirst().getEntityId());
Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.TIME_SERIES)); Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES));
tsValue = eData.get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"); tsValue = eData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature");
Assert.assertEquals(new TsValue(dataPoint2.getTs(), dataPoint2.getValueAsString()), tsValue); Assert.assertEquals(new TsValue(dataPoint2.getTs(), dataPoint2.getValueAsString()), tsValue);
//Sending update from the past, while latest value has new timestamp; //Sending update from the past, while latest value has new timestamp;
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
sendTelemetry(device, Arrays.asList(dataPoint1)); sendTelemetry(device, List.of(dataPoint1));
String msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); String msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1));
Assert.assertNull(msg); Assert.assertNull(msg);
//Sending duplicate update again //Sending duplicate update again
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
sendTelemetry(device, Arrays.asList(dataPoint2)); sendTelemetry(device, List.of(dataPoint2));
msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1));
Assert.assertNull(msg); Assert.assertNull(msg);
} }
@ -685,14 +684,14 @@ public class WebsocketApiTest extends AbstractControllerTest {
PageData<EntityData> pageData = update.getData(); PageData<EntityData> pageData = update.getData();
Assert.assertNotNull(pageData); Assert.assertNotNull(pageData);
Assert.assertEquals(1, pageData.getData().size()); Assert.assertEquals(1, pageData.getData().size());
Assert.assertEquals(device.getId(), pageData.getData().get(0).getEntityId()); Assert.assertEquals(device.getId(), pageData.getData().getFirst().getEntityId());
Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature")); Assert.assertNotNull(pageData.getData().getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"));
Assert.assertEquals(0, pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getTs()); Assert.assertEquals(0, pageData.getData().getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getTs());
Assert.assertEquals("", pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()); Assert.assertEquals("", pageData.getData().getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue());
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
TsKvEntry dataPoint1 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("temperature", 42L)); TsKvEntry dataPoint1 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("temperature", 42L));
List<TsKvEntry> tsData = Arrays.asList(dataPoint1); List<TsKvEntry> tsData = List.of(dataPoint1);
sendTelemetry(device, tsData); sendTelemetry(device, tsData);
update = getWsClient().parseDataReply(getWsClient().waitForUpdate()); update = getWsClient().parseDataReply(getWsClient().waitForUpdate());
@ -702,34 +701,34 @@ public class WebsocketApiTest extends AbstractControllerTest {
List<EntityData> listData = update.getUpdate(); List<EntityData> listData = update.getUpdate();
Assert.assertNotNull(listData); Assert.assertNotNull(listData);
Assert.assertEquals(1, listData.size()); Assert.assertEquals(1, listData.size());
Assert.assertEquals(device.getId(), listData.get(0).getEntityId()); Assert.assertEquals(device.getId(), listData.getFirst().getEntityId());
Assert.assertNotNull(listData.get(0).getLatest().get(EntityKeyType.TIME_SERIES)); Assert.assertNotNull(listData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES));
TsValue tsValue = listData.get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"); TsValue tsValue = listData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature");
Assert.assertEquals(new TsValue(dataPoint1.getTs(), dataPoint1.getValueAsString()), tsValue); Assert.assertEquals(new TsValue(dataPoint1.getTs(), dataPoint1.getValueAsString()), tsValue);
now = System.currentTimeMillis(); now = System.currentTimeMillis();
TsKvEntry dataPoint2 = new BasicTsKvEntry(now, new LongDataEntry("temperature", 52L)); TsKvEntry dataPoint2 = new BasicTsKvEntry(now, new LongDataEntry("temperature", 52L));
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
sendTelemetry(device, Arrays.asList(dataPoint2)); sendTelemetry(device, List.of(dataPoint2));
update = getWsClient().parseDataReply(getWsClient().waitForUpdate()); update = getWsClient().parseDataReply(getWsClient().waitForUpdate());
Assert.assertEquals(1, update.getCmdId()); Assert.assertEquals(1, update.getCmdId());
List<EntityData> eData = update.getUpdate(); List<EntityData> eData = update.getUpdate();
Assert.assertNotNull(eData); Assert.assertNotNull(eData);
Assert.assertEquals(1, eData.size()); Assert.assertEquals(1, eData.size());
Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); Assert.assertEquals(device.getId(), eData.getFirst().getEntityId());
Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.TIME_SERIES)); Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES));
tsValue = eData.get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"); tsValue = eData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature");
Assert.assertEquals(new TsValue(dataPoint2.getTs(), dataPoint2.getValueAsString()), tsValue); Assert.assertEquals(new TsValue(dataPoint2.getTs(), dataPoint2.getValueAsString()), tsValue);
//Sending update from the past, while latest value has new timestamp; //Sending update from the past, while the latest value has new timestamp;
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
sendTelemetry(device, Arrays.asList(dataPoint1)); sendTelemetry(device, List.of(dataPoint1));
String msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); String msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1));
Assert.assertNull(msg); Assert.assertNull(msg);
//Sending duplicate update again //Sending duplicate update again
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
sendTelemetry(device, Arrays.asList(dataPoint2)); sendTelemetry(device, List.of(dataPoint2));
msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1));
Assert.assertNull(msg); Assert.assertNull(msg);
} }
@ -779,21 +778,21 @@ public class WebsocketApiTest extends AbstractControllerTest {
PageData<EntityData> pageData = update.getData(); PageData<EntityData> pageData = update.getData();
Assert.assertNotNull(pageData); Assert.assertNotNull(pageData);
Assert.assertEquals(1, pageData.getData().size()); Assert.assertEquals(1, pageData.getData().size());
Assert.assertEquals(device.getId(), pageData.getData().get(0).getEntityId()); Assert.assertEquals(device.getId(), pageData.getData().getFirst().getEntityId());
Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey")); Assert.assertNotNull(pageData.getData().getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey"));
Assert.assertEquals(0, pageData.getData().get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getTs()); Assert.assertEquals(0, pageData.getData().getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getTs());
Assert.assertEquals("", pageData.getData().get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getValue()); Assert.assertEquals("", pageData.getData().getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getValue());
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
// Pushing update with wrong scope and make sure it will not arrive. // Pushing update with the wrong scope and make sure it will not arrive.
AttributeKvEntry invalidDataPoint = new BaseAttributeKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("serverAttributeKey", 55L)); AttributeKvEntry invalidDataPoint = new BaseAttributeKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("serverAttributeKey", 55L));
sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, Arrays.asList(invalidDataPoint)); sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, List.of(invalidDataPoint));
Assert.assertNull(getWsClient().waitForUpdate(3000)); Assert.assertNull(getWsClient().waitForUpdate(3000));
AttributeKvEntry dataPoint1 = new BaseAttributeKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("serverAttributeKey", 42L)); AttributeKvEntry dataPoint1 = new BaseAttributeKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("serverAttributeKey", 42L));
List<AttributeKvEntry> tsData = Arrays.asList(dataPoint1); List<AttributeKvEntry> tsData = List.of(dataPoint1);
sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, tsData); sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, tsData);
String msg = getWsClient().waitForUpdate(); String msg = getWsClient().waitForUpdate();
@ -804,16 +803,16 @@ public class WebsocketApiTest extends AbstractControllerTest {
List<EntityData> listData = update.getUpdate(); List<EntityData> listData = update.getUpdate();
Assert.assertNotNull(listData); Assert.assertNotNull(listData);
Assert.assertEquals(1, listData.size()); Assert.assertEquals(1, listData.size());
Assert.assertEquals(device.getId(), listData.get(0).getEntityId()); Assert.assertEquals(device.getId(), listData.getFirst().getEntityId());
Assert.assertNotNull(listData.get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE)); Assert.assertNotNull(listData.getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE));
TsValue tsValue = listData.get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey"); TsValue tsValue = listData.getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey");
Assert.assertEquals(new TsValue(dataPoint1.getLastUpdateTs(), dataPoint1.getValueAsString()), tsValue); Assert.assertEquals(new TsValue(dataPoint1.getLastUpdateTs(), dataPoint1.getValueAsString()), tsValue);
now = System.currentTimeMillis(); now = System.currentTimeMillis();
AttributeKvEntry dataPoint2 = new BaseAttributeKvEntry(now, new LongDataEntry("serverAttributeKey", 52L)); AttributeKvEntry dataPoint2 = new BaseAttributeKvEntry(now, new LongDataEntry("serverAttributeKey", 52L));
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, Arrays.asList(dataPoint2)); sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, List.of(dataPoint2));
msg = getWsClient().waitForUpdate(); msg = getWsClient().waitForUpdate();
Assert.assertNotNull(msg); Assert.assertNotNull(msg);
@ -822,20 +821,20 @@ public class WebsocketApiTest extends AbstractControllerTest {
List<EntityData> eData = update.getUpdate(); List<EntityData> eData = update.getUpdate();
Assert.assertNotNull(eData); Assert.assertNotNull(eData);
Assert.assertEquals(1, eData.size()); Assert.assertEquals(1, eData.size());
Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); Assert.assertEquals(device.getId(), eData.getFirst().getEntityId());
Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE)); Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE));
tsValue = eData.get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey"); tsValue = eData.getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey");
Assert.assertEquals(new TsValue(dataPoint2.getLastUpdateTs(), dataPoint2.getValueAsString()), tsValue); Assert.assertEquals(new TsValue(dataPoint2.getLastUpdateTs(), dataPoint2.getValueAsString()), tsValue);
//Sending update from the past, while latest value has new timestamp; //Sending update from the past, while the latest value has new timestamp;
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, Arrays.asList(dataPoint1)); sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, List.of(dataPoint1));
msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1));
Assert.assertNull(msg); Assert.assertNull(msg);
//Sending duplicate update again //Sending duplicate update again
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, Arrays.asList(dataPoint2)); sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, List.of(dataPoint2));
msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1));
Assert.assertNull(msg); Assert.assertNull(msg);
} }
@ -855,23 +854,23 @@ public class WebsocketApiTest extends AbstractControllerTest {
PageData<EntityData> pageData = update.getData(); PageData<EntityData> pageData = update.getData();
Assert.assertNotNull(pageData); Assert.assertNotNull(pageData);
Assert.assertEquals(1, pageData.getData().size()); Assert.assertEquals(1, pageData.getData().size());
Assert.assertEquals(device.getId(), pageData.getData().get(0).getEntityId()); Assert.assertEquals(device.getId(), pageData.getData().getFirst().getEntityId());
Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey")); Assert.assertNotNull(pageData.getData().getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey"));
Assert.assertEquals(0, pageData.getData().get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getTs()); Assert.assertEquals(0, pageData.getData().getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getTs());
Assert.assertEquals("", pageData.getData().get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getValue()); Assert.assertEquals("", pageData.getData().getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getValue());
Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey")); Assert.assertNotNull(pageData.getData().getFirst().getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey"));
Assert.assertEquals(0, pageData.getData().get(0).getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey").getTs()); Assert.assertEquals(0, pageData.getData().getFirst().getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey").getTs());
Assert.assertEquals("", pageData.getData().get(0).getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey").getValue()); Assert.assertEquals("", pageData.getData().getFirst().getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey").getValue());
Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey")); Assert.assertNotNull(pageData.getData().getFirst().getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey"));
Assert.assertEquals(0, pageData.getData().get(0).getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey").getTs()); Assert.assertEquals(0, pageData.getData().getFirst().getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey").getTs());
Assert.assertEquals("", pageData.getData().get(0).getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey").getValue()); Assert.assertEquals("", pageData.getData().getFirst().getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey").getValue());
Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey")); Assert.assertNotNull(pageData.getData().getFirst().getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey"));
Assert.assertEquals(0, pageData.getData().get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey").getTs()); Assert.assertEquals(0, pageData.getData().getFirst().getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey").getTs());
Assert.assertEquals("", pageData.getData().get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey").getValue()); Assert.assertEquals("", pageData.getData().getFirst().getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey").getValue());
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
AttributeKvEntry dataPoint1 = new BaseAttributeKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("serverAttributeKey", 42L)); AttributeKvEntry dataPoint1 = new BaseAttributeKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("serverAttributeKey", 42L));
List<AttributeKvEntry> tsData = Arrays.asList(dataPoint1); List<AttributeKvEntry> tsData = List.of(dataPoint1);
sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, tsData); sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, tsData);
@ -882,78 +881,78 @@ public class WebsocketApiTest extends AbstractControllerTest {
List<EntityData> eData = update.getUpdate(); List<EntityData> eData = update.getUpdate();
Assert.assertNotNull(eData); Assert.assertNotNull(eData);
Assert.assertEquals(1, eData.size()); Assert.assertEquals(1, eData.size());
Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); Assert.assertEquals(device.getId(), eData.getFirst().getEntityId());
Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE)); Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE));
TsValue attrValue = eData.get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey"); TsValue attrValue = eData.getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey");
Assert.assertEquals(new TsValue(dataPoint1.getLastUpdateTs(), dataPoint1.getValueAsString()), attrValue); Assert.assertEquals(new TsValue(dataPoint1.getLastUpdateTs(), dataPoint1.getValueAsString()), attrValue);
//Sending update from the past, while latest value has new timestamp; //Sending update from the past, while the latest value has new timestamp;
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
sendAttributes(device, TbAttributeSubscriptionScope.SHARED_SCOPE, Arrays.asList(dataPoint1)); sendAttributes(device, TbAttributeSubscriptionScope.SHARED_SCOPE, List.of(dataPoint1));
msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1));
Assert.assertNull(msg); Assert.assertNull(msg);
//Sending duplicate update again //Sending duplicate update again
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, Arrays.asList(dataPoint1)); sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, List.of(dataPoint1));
msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1));
Assert.assertNull(msg); Assert.assertNull(msg);
//Sending update from the past, while latest value has new timestamp; //Sending update from the past, while latest value has new timestamp;
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
AttributeKvEntry dataPoint2 = new BaseAttributeKvEntry(now, new LongDataEntry("sharedAttributeKey", 42L)); AttributeKvEntry dataPoint2 = new BaseAttributeKvEntry(now, new LongDataEntry("sharedAttributeKey", 42L));
sendAttributes(device, TbAttributeSubscriptionScope.SHARED_SCOPE, Arrays.asList(dataPoint2)); sendAttributes(device, TbAttributeSubscriptionScope.SHARED_SCOPE, List.of(dataPoint2));
msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1));
update = JacksonUtil.fromString(msg, EntityDataUpdate.class); update = JacksonUtil.fromString(msg, EntityDataUpdate.class);
Assert.assertEquals(1, update.getCmdId()); Assert.assertEquals(1, update.getCmdId());
eData = update.getUpdate(); eData = update.getUpdate();
Assert.assertNotNull(eData); Assert.assertNotNull(eData);
Assert.assertEquals(1, eData.size()); Assert.assertEquals(1, eData.size());
Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); Assert.assertEquals(device.getId(), eData.getFirst().getEntityId());
Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.SHARED_ATTRIBUTE)); Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.SHARED_ATTRIBUTE));
attrValue = eData.get(0).getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey"); attrValue = eData.getFirst().getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey");
Assert.assertEquals(new TsValue(dataPoint2.getLastUpdateTs(), dataPoint2.getValueAsString()), attrValue); Assert.assertEquals(new TsValue(dataPoint2.getLastUpdateTs(), dataPoint2.getValueAsString()), attrValue);
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
AttributeKvEntry dataPoint3 = new BaseAttributeKvEntry(now, new LongDataEntry("clientAttributeKey", 42L)); AttributeKvEntry dataPoint3 = new BaseAttributeKvEntry(now, new LongDataEntry("clientAttributeKey", 42L));
sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, Arrays.asList(dataPoint3)); sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, List.of(dataPoint3));
msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1));
update = JacksonUtil.fromString(msg, EntityDataUpdate.class); update = JacksonUtil.fromString(msg, EntityDataUpdate.class);
Assert.assertEquals(1, update.getCmdId()); Assert.assertEquals(1, update.getCmdId());
eData = update.getUpdate(); eData = update.getUpdate();
Assert.assertNotNull(eData); Assert.assertNotNull(eData);
Assert.assertEquals(1, eData.size()); Assert.assertEquals(1, eData.size());
Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); Assert.assertEquals(device.getId(), eData.getFirst().getEntityId());
Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE)); Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE));
attrValue = eData.get(0).getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey"); attrValue = eData.getFirst().getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey");
Assert.assertEquals(new TsValue(dataPoint3.getLastUpdateTs(), dataPoint3.getValueAsString()), attrValue); Assert.assertEquals(new TsValue(dataPoint3.getLastUpdateTs(), dataPoint3.getValueAsString()), attrValue);
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
AttributeKvEntry dataPoint4 = new BaseAttributeKvEntry(now, new LongDataEntry("anyAttributeKey", 42L)); AttributeKvEntry dataPoint4 = new BaseAttributeKvEntry(now, new LongDataEntry("anyAttributeKey", 42L));
sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, Arrays.asList(dataPoint4)); sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, List.of(dataPoint4));
msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1));
update = JacksonUtil.fromString(msg, EntityDataUpdate.class); update = JacksonUtil.fromString(msg, EntityDataUpdate.class);
Assert.assertEquals(1, update.getCmdId()); Assert.assertEquals(1, update.getCmdId());
eData = update.getUpdate(); eData = update.getUpdate();
Assert.assertNotNull(eData); Assert.assertNotNull(eData);
Assert.assertEquals(1, eData.size()); Assert.assertEquals(1, eData.size());
Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); Assert.assertEquals(device.getId(), eData.getFirst().getEntityId());
Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.ATTRIBUTE)); Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.ATTRIBUTE));
attrValue = eData.get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey"); attrValue = eData.getFirst().getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey");
Assert.assertEquals(new TsValue(dataPoint4.getLastUpdateTs(), dataPoint4.getValueAsString()), attrValue); Assert.assertEquals(new TsValue(dataPoint4.getLastUpdateTs(), dataPoint4.getValueAsString()), attrValue);
getWsClient().registerWaitForUpdate(); getWsClient().registerWaitForUpdate();
AttributeKvEntry dataPoint5 = new BaseAttributeKvEntry(now, new LongDataEntry("anyAttributeKey", 43L)); AttributeKvEntry dataPoint5 = new BaseAttributeKvEntry(now, new LongDataEntry("anyAttributeKey", 43L));
sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, Arrays.asList(dataPoint5)); sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, List.of(dataPoint5));
msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1));
update = JacksonUtil.fromString(msg, EntityDataUpdate.class); update = JacksonUtil.fromString(msg, EntityDataUpdate.class);
Assert.assertEquals(1, update.getCmdId()); Assert.assertEquals(1, update.getCmdId());
eData = update.getUpdate(); eData = update.getUpdate();
Assert.assertNotNull(eData); Assert.assertNotNull(eData);
Assert.assertEquals(1, eData.size()); Assert.assertEquals(1, eData.size());
Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); Assert.assertEquals(device.getId(), eData.getFirst().getEntityId());
Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.ATTRIBUTE)); Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.ATTRIBUTE));
attrValue = eData.get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey"); attrValue = eData.getFirst().getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey");
Assert.assertEquals(new TsValue(dataPoint5.getLastUpdateTs(), dataPoint5.getValueAsString()), attrValue); Assert.assertEquals(new TsValue(dataPoint5.getLastUpdateTs(), dataPoint5.getValueAsString()), attrValue);
} }
@ -1042,7 +1041,7 @@ public class WebsocketApiTest extends AbstractControllerTest {
.tenantId(device.getTenantId()) .tenantId(device.getTenantId())
.entityId(device.getId()) .entityId(device.getId())
.entries(tsData) .entries(tsData)
.callback(new FutureCallback<Void>() { .callback(new FutureCallback<>() {
@Override @Override
public void onSuccess(@Nullable Void result) { public void onSuccess(@Nullable Void result) {
log.debug("sendTelemetry callback onSuccess"); log.debug("sendTelemetry callback onSuccess");

101
application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java

@ -36,9 +36,13 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider;
import org.thingsboard.server.service.security.auth.pat.ApiKeyAuthenticationProvider;
import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.UserPrincipal; import org.thingsboard.server.service.security.model.UserPrincipal;
import org.thingsboard.server.service.ws.WebSocketService;
import org.thingsboard.server.service.ws.WebSocketSessionRef; import org.thingsboard.server.service.ws.WebSocketSessionRef;
import org.thingsboard.server.service.ws.WebSocketSessionType;
import java.io.IOException; import java.io.IOException;
import java.lang.reflect.Method; import java.lang.reflect.Method;
@ -262,4 +266,101 @@ class TbWebSocketHandlerTest {
return ref; return ref;
} }
private AuthTestFixture createAuthTestFixture() throws IOException {
TbWebSocketHandler handler = spy(new TbWebSocketHandler());
willDoNothing().given(handler).close(any(), any());
ApiKeyAuthenticationProvider apiKeyProvider = mock(ApiKeyAuthenticationProvider.class);
JwtAuthenticationProvider jwtProvider = mock(JwtAuthenticationProvider.class);
WebSocketService wsService = mock(WebSocketService.class);
ReflectionTestUtils.setField(handler, "apiKeyAuthenticationProvider", apiKeyProvider);
ReflectionTestUtils.setField(handler, "authenticationProvider", jwtProvider);
ReflectionTestUtils.setField(handler, "webSocketService", wsService);
ReflectionTestUtils.setField(handler, "tenantProfileCache", mock(TbTenantProfileCache.class));
ReflectionTestUtils.setField(handler, "authTimeoutMs", 10000);
ReflectionTestUtils.invokeMethod(handler, "init");
WebSocketSessionRef ref = WebSocketSessionRef.builder()
.sessionId(UUID.randomUUID().toString())
.sessionType(WebSocketSessionType.GENERAL)
.build();
NativeWebSocketSession wsSession = mock(NativeWebSocketSession.class);
Session nativeSess = mock(Session.class);
willReturn(nativeSess).given(wsSession).getNativeSession(Session.class);
RemoteEndpoint.Async async = mock(RemoteEndpoint.Async.class);
willReturn(async).given(nativeSess).getAsyncRemote();
willReturn("test-session-id").given(wsSession).getId();
TbWebSocketHandler.SessionMetaData sessionMd = handler.new SessionMetaData(wsSession, ref);
return new AuthTestFixture(handler, apiKeyProvider, jwtProvider, ref, sessionMd);
}
@Test
void processMsg_authenticatesWithApiKey() throws Exception {
AuthTestFixture f = createAuthTestFixture();
SecurityUser securityUser = mock(SecurityUser.class, Mockito.RETURNS_DEEP_STUBS);
willReturn(securityUser).given(f.apiKeyProvider).authenticate("my-api-key");
String msg = "{\"authCmd\":{\"cmdId\":1,\"apiKey\":\"my-api-key\"},\"cmds\":[]}";
f.handler.processMsg(f.sessionMd, msg);
verify(f.apiKeyProvider).authenticate("my-api-key");
verify(f.jwtProvider, never()).authenticate(anyString());
assertThat(f.ref.getSecurityCtx()).isSameAs(securityUser);
}
@Test
void processMsg_authenticatesWithJwtToken() throws Exception {
AuthTestFixture f = createAuthTestFixture();
SecurityUser securityUser = mock(SecurityUser.class, Mockito.RETURNS_DEEP_STUBS);
willReturn(securityUser).given(f.jwtProvider).authenticate("my-jwt-token");
String msg = "{\"authCmd\":{\"cmdId\":1,\"token\":\"my-jwt-token\"},\"cmds\":[]}";
f.handler.processMsg(f.sessionMd, msg);
verify(f.jwtProvider).authenticate("my-jwt-token");
verify(f.apiKeyProvider, never()).authenticate(anyString());
assertThat(f.ref.getSecurityCtx()).isSameAs(securityUser);
}
@Test
void processMsg_apiKeyTakesPrecedenceOverToken() throws Exception {
AuthTestFixture f = createAuthTestFixture();
SecurityUser securityUser = mock(SecurityUser.class, Mockito.RETURNS_DEEP_STUBS);
willReturn(securityUser).given(f.apiKeyProvider).authenticate("my-api-key");
String msg = "{\"authCmd\":{\"cmdId\":1,\"apiKey\":\"my-api-key\",\"token\":\"my-jwt-token\"},\"cmds\":[]}";
f.handler.processMsg(f.sessionMd, msg);
verify(f.apiKeyProvider).authenticate("my-api-key");
verify(f.jwtProvider, never()).authenticate(anyString());
assertThat(f.ref.getSecurityCtx()).isSameAs(securityUser);
}
@Test
void extractQueryParam_parsesCorrectly() throws Exception {
TbWebSocketHandler handler = new TbWebSocketHandler();
Method method = TbWebSocketHandler.class.getDeclaredMethod("extractQueryParam", String.class, String.class);
method.setAccessible(true);
assertThat(method.invoke(handler, "token=jwt123", "token")).isEqualTo("jwt123");
assertThat(method.invoke(handler, "token=jwt123&other=abc123", "token")).isEqualTo("jwt123");
assertThat(method.invoke(handler, "other=value", "token")).isNull();
assertThat(method.invoke(handler, "tokenExtra=value", "token")).isNull();
}
private record AuthTestFixture(
TbWebSocketHandler handler,
ApiKeyAuthenticationProvider apiKeyProvider,
JwtAuthenticationProvider jwtProvider,
WebSocketSessionRef ref,
TbWebSocketHandler.SessionMetaData sessionMd
) {}
} }

12
application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java

@ -32,7 +32,6 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.controller.AbstractWebTest;
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
@ -56,7 +55,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
/** /**
* Tests for Edge gRPC SSL setup using the production {@link EdgeGrpcService#setupSsl} method. * Tests for Edge gRPC SSL setup using the production {@link GrpcServer#setupSsl} method.
* <p> * <p>
* Covers: * Covers:
* 1. Separate cert and key PEM inputs * 1. Separate cert and key PEM inputs
@ -68,6 +67,8 @@ import static org.awaitility.Awaitility.await;
*/ */
class EdgeGrpcSslTest { class EdgeGrpcSslTest {
private static final int TIMEOUT = 30;
static { static {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider()); Security.addProvider(new BouncyCastleProvider());
@ -170,13 +171,14 @@ class EdgeGrpcSslTest {
Path certOnlyFile = writeTempPem("cert-only", cert); Path certOnlyFile = writeTempPem("cert-only", cert);
assertThatThrownBy(() -> startServer(certOnlyFile.toString(), "", null)) assertThatThrownBy(() -> startServer(certOnlyFile.toString(), "", null))
.isInstanceOf(IllegalArgumentException.class); .isInstanceOf(RuntimeException.class)
.hasCauseInstanceOf(IllegalArgumentException.class);
} }
// --- Server startup using production EdgeGrpcService.setupSsl() --- // --- Server startup using production EdgeGrpcService.setupSsl() ---
private Server startServer(String certFileResource, String privateKeyResource, String keyPassword) throws Exception { private Server startServer(String certFileResource, String privateKeyResource, String keyPassword) throws Exception {
EdgeGrpcService edgeGrpcService = new EdgeGrpcService(); GrpcServer edgeGrpcService = new GrpcServer(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {});
ReflectionTestUtils.setField(edgeGrpcService, "certFileResource", certFileResource); ReflectionTestUtils.setField(edgeGrpcService, "certFileResource", certFileResource);
ReflectionTestUtils.setField(edgeGrpcService, "privateKeyResource", privateKeyResource); ReflectionTestUtils.setField(edgeGrpcService, "privateKeyResource", privateKeyResource);
ReflectionTestUtils.setField(edgeGrpcService, "keyPassword", keyPassword != null ? keyPassword : ""); ReflectionTestUtils.setField(edgeGrpcService, "keyPassword", keyPassword != null ? keyPassword : "");
@ -200,7 +202,7 @@ class EdgeGrpcSslTest {
.build(); .build();
channel.getState(true); // trigger connection attempt channel.getState(true); // trigger connection attempt
await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS) await().atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(50, TimeUnit.MILLISECONDS) .pollInterval(50, TimeUnit.MILLISECONDS)
.untilAsserted(() -> { .untilAsserted(() -> {
var state = channel.getState(false); var state = channel.getState(false);

112
application/src/test/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSessionTest.java → application/src/test/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManagerTest.java

@ -13,9 +13,8 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
package org.thingsboard.server.service.edge.rpc; package org.thingsboard.server.service.edge.rpc.session.manager;
import io.grpc.stub.StreamObserver;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -24,7 +23,6 @@ import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg;
import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg;
@ -33,6 +31,11 @@ import org.thingsboard.server.queue.discovery.TopicService;
import org.thingsboard.server.queue.kafka.KafkaAdmin; import org.thingsboard.server.queue.kafka.KafkaAdmin;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory; import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.service.edge.EdgeContextComponent; import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.rpc.DownlinkMessageMapper;
import org.thingsboard.server.service.edge.rpc.EdgeEventStorageSettings;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.session.EdgeSession;
import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@ -51,40 +54,48 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class KafkaEdgeGrpcSessionTest { class KafkaBasedEdgeGrpcSessionManagerTest {
private static final long POLL_INTERVAL_MS = 20L; private static final long POLL_INTERVAL_MS = 20L;
private EdgeContextComponent ctx; private EdgeContextComponent ctx;
private TbCoreQueueFactory tbCoreQueueFactory; private TbCoreQueueFactory tbCoreQueueFactory;
private KafkaEdgeGrpcSession session; private EdgeSessionState state;
private KafkaBasedEdgeGrpcSessionManager manager;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
ctx = mock(EdgeContextComponent.class); ctx = mock(EdgeContextComponent.class);
TopicService topicService = mock(TopicService.class);
tbCoreQueueFactory = mock(TbCoreQueueFactory.class); tbCoreQueueFactory = mock(TbCoreQueueFactory.class);
TopicService topicService = mock(TopicService.class);
KafkaAdmin kafkaAdmin = mock(KafkaAdmin.class); KafkaAdmin kafkaAdmin = mock(KafkaAdmin.class);
@SuppressWarnings("unchecked") EdgeSessionsHolder sessions = mock(EdgeSessionsHolder.class);
StreamObserver<ResponseMsg> outputStream = mock(StreamObserver.class);
manager = new KafkaBasedEdgeGrpcSessionManager(tbCoreQueueFactory, topicService, kafkaAdmin, sessions);
session = new KafkaEdgeGrpcSession(ctx, topicService, tbCoreQueueFactory, kafkaAdmin, outputStream, Edge edge = new Edge(new EdgeId(UUID.randomUUID()));
(edgeId, s) -> {}, (edge, uuid) -> {}, null, 0, 0); edge.setTenantId(TenantId.fromUUID(UUID.randomUUID()));
state = new EdgeSessionState();
state.setEdge(edge);
ReflectionTestUtils.setField(session, "edge", new Edge(new EdgeId(UUID.randomUUID()))); EdgeSession session = mock(EdgeSession.class);
ReflectionTestUtils.setField(session, "tenantId", TenantId.fromUUID(UUID.randomUUID())); when(session.getState()).thenReturn(state);
ReflectionTestUtils.setField(manager, "session", session);
ReflectionTestUtils.setField(manager, "ctx", ctx);
ReflectionTestUtils.setField(manager, "downlinkMessageMapper", mock(DownlinkMessageMapper.class));
} }
@AfterEach @AfterEach
void tearDown() { void tearDown() {
if (session != null) { if (manager != null) {
session.destroy(); manager.destroy();
} }
} }
@Test @Test
void readyOnlyWhenConnectedNotSyncingNotHighPriority() { void readyOnlyWhenConnectedNotSyncingNotHighPriority() {
setReadinessFlags(true, false, false); setReadiness(true, false, false);
assertThat(isReadyToProcessGeneralEvents()) assertThat(isReadyToProcessGeneralEvents())
.as("connected, not syncing, no high-priority work -> ready") .as("connected, not syncing, no high-priority work -> ready")
.isTrue(); .isTrue();
@ -92,7 +103,7 @@ class KafkaEdgeGrpcSessionTest {
@Test @Test
void notReadyWhenDisconnected() { void notReadyWhenDisconnected() {
setReadinessFlags(false, false, false); setReadiness(false, false, false);
assertThat(isReadyToProcessGeneralEvents()) assertThat(isReadyToProcessGeneralEvents())
.as("disconnected -> not ready") .as("disconnected -> not ready")
.isFalse(); .isFalse();
@ -100,7 +111,7 @@ class KafkaEdgeGrpcSessionTest {
@Test @Test
void notReadyWhileSyncInProgress() { void notReadyWhileSyncInProgress() {
setReadinessFlags(true, true, false); setReadiness(true, true, false);
assertThat(isReadyToProcessGeneralEvents()) assertThat(isReadyToProcessGeneralEvents())
.as("sync in progress -> not ready (this is the window where events were being dropped)") .as("sync in progress -> not ready (this is the window where events were being dropped)")
.isFalse(); .isFalse();
@ -108,18 +119,15 @@ class KafkaEdgeGrpcSessionTest {
@Test @Test
void notReadyWhileHighPriorityProcessing() { void notReadyWhileHighPriorityProcessing() {
setReadinessFlags(true, false, true); setReadiness(true, false, true);
assertThat(isReadyToProcessGeneralEvents()) assertThat(isReadyToProcessGeneralEvents())
.as("high-priority processing -> not ready") .as("high-priority processing -> not ready")
.isFalse(); .isFalse();
} }
@Test @Test
void processEdgeEventsWiresReadinessPredicateIntoConsumerGate() { void initConsumerWiresReadinessPredicateIntoConsumerGate() {
// processEdgeEvents() builds the consumer lazily; stub just enough of that path. stubStorageSettings();
EdgeEventStorageSettings storageSettings = new EdgeEventStorageSettings();
storageSettings.setNoRecordsSleepInterval(1000L);
when(ctx.getEdgeEventStorageSettings()).thenReturn(storageSettings);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
TbQueueConsumer<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> queueConsumer = mock(TbQueueConsumer.class); TbQueueConsumer<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> queueConsumer = mock(TbQueueConsumer.class);
@ -127,40 +135,37 @@ class KafkaEdgeGrpcSessionTest {
when(queueConsumer.isStopped()).thenReturn(true); when(queueConsumer.isStopped()).thenReturn(true);
when(tbCoreQueueFactory.createEdgeEventMsgConsumer(any(), any())).thenReturn(queueConsumer); when(tbCoreQueueFactory.createEdgeEventMsgConsumer(any(), any())).thenReturn(queueConsumer);
// The consumer is only started when the session is ready. setReadiness(true, false, false);
setReadinessFlags(true, false, false); initConsumer();
session.processEdgeEvents();
QueueConsumerManager<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> manager = session.getConsumer(); QueueConsumerManager<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> consumer = manager.getConsumer();
assertThat(manager).as("processEdgeEvents must build the consumer when ready").isNotNull(); assertThat(consumer).as("consumer must be built").isNotNull();
BooleanSupplier readinessCheck = (BooleanSupplier) ReflectionTestUtils.getField(manager, "readinessCheck"); BooleanSupplier readinessCheck = (BooleanSupplier) ReflectionTestUtils.getField(consumer, "readinessCheck");
assertThat(readinessCheck) assertThat(readinessCheck)
.as("the edge consumer must be wired with a readinessCheck (the .readinessCheck(...) builder line)") .as("the edge consumer must be wired with a readinessCheck (the .readinessCheck(...) builder line)")
.isNotNull(); .isNotNull();
// It must be the live predicate, not a snapshot: flipping the session's state must flip the gate. // It must be the live predicate, not a snapshot: flipping the session state must flip the gate.
assertThat(readinessCheck.getAsBoolean()).as("ready session -> gate open").isTrue(); assertThat(readinessCheck.getAsBoolean()).as("ready session -> gate open").isTrue();
setReadinessFlags(true, true, false); state.tryStartSync();
assertThat(readinessCheck.getAsBoolean()).as("sync starts -> gate closes, consumer pauses polling").isFalse(); assertThat(readinessCheck.getAsBoolean()).as("sync starts -> gate closes, consumer pauses polling").isFalse();
} }
@Test @Test
void eventArrivingDuringSyncIsHeldByTheEdgeConsumerUntilSyncCompletes() { void eventArrivingDuringSyncIsHeldByTheEdgeConsumerUntilSyncCompletes() {
EdgeEventStorageSettings storageSettings = new EdgeEventStorageSettings(); stubStorageSettings();
storageSettings.setNoRecordsSleepInterval(POLL_INTERVAL_MS);
when(ctx.getEdgeEventStorageSettings()).thenReturn(storageSettings);
RecordingEdgeEventConsumer queueConsumer = new RecordingEdgeEventConsumer(); RecordingEdgeEventConsumer queueConsumer = new RecordingEdgeEventConsumer();
when(tbCoreQueueFactory.createEdgeEventMsgConsumer(any(), any())).thenReturn(queueConsumer); when(tbCoreQueueFactory.createEdgeEventMsgConsumer(any(), any())).thenReturn(queueConsumer);
// The consumer is launched only while the session is ready - that is how it starts in production. // The consumer is launched only while the session is ready.
setReadinessFlags(true, false, false); setReadiness(true, false, false);
session.processEdgeEvents(); initConsumer();
// Sync starts: the gate closes. Wait until the loop has actually parked on it (poll count stops advancing) // Sync starts: the gate closes. Wait until the loop has actually parked on it (poll count stops advancing)
// before enqueuing - otherwise we would race an in-flight poll() and the test would be non-deterministic. // before enqueuing - otherwise we would race an in-flight poll() and the test would be non-deterministic.
setReadinessFlags(true, true, false); state.tryStartSync();
awaitParkedOnClosedGate(queueConsumer); awaitParkedOnClosedGate(queueConsumer);
// An event lands in the edge-event topic during the sync window - exactly the case that used to be dropped. // An event lands in the edge-event topic during the sync window - exactly the case that used to be dropped.
@ -169,8 +174,7 @@ class KafkaEdgeGrpcSessionTest {
int pollsBeforeEvent = queueConsumer.getPollCount(); int pollsBeforeEvent = queueConsumer.getPollCount();
queueConsumer.enqueue(List.of(event)); queueConsumer.enqueue(List.of(event));
// While sync is in progress the consumer stays parked: it neither polls nor consumes the event, // While sync is in progress the consumer stays parked: it neither polls nor consumes the event.
// so the event survives in the queue instead of being read-and-skipped.
sleepQuietly(POLL_INTERVAL_MS * 5); sleepQuietly(POLL_INTERVAL_MS * 5);
assertThat(queueConsumer.getPolledEvents()) assertThat(queueConsumer.getPolledEvents())
.as("event must not be polled while sync is in progress (it must stay queued, not be dropped)") .as("event must not be polled while sync is in progress (it must stay queued, not be dropped)")
@ -180,23 +184,35 @@ class KafkaEdgeGrpcSessionTest {
.isEqualTo(pollsBeforeEvent); .isEqualTo(pollsBeforeEvent);
// Sync completes: the gate opens and the held event is finally picked up by the consumer. // Sync completes: the gate opens and the held event is finally picked up by the consumer.
// (We assert at the poll boundary - the actual drop point - since the downlink-send path that state.finishSync();
// processMsgs drives afterwards is not reachable from a unit test.)
setReadinessFlags(true, false, false);
await().atMost(5, TimeUnit.SECONDS) await().atMost(5, TimeUnit.SECONDS)
.untilAsserted(() -> assertThat(queueConsumer.getPolledEvents()) .untilAsserted(() -> assertThat(queueConsumer.getPolledEvents())
.as("event held during sync must be picked up once sync completes, not lost") .as("event held during sync must be picked up once sync completes, not lost")
.hasSize(1)); .hasSize(1));
} }
private void stubStorageSettings() {
EdgeEventStorageSettings storageSettings = mock(EdgeEventStorageSettings.class);
when(storageSettings.getNoRecordsSleepInterval()).thenReturn(POLL_INTERVAL_MS);
when(ctx.getEdgeEventStorageSettings()).thenReturn(storageSettings);
}
private void initConsumer() {
ReflectionTestUtils.invokeMethod(manager, "initConsumerAndExecutor", state.getTenantId(), state.getEdgeId(), state);
}
private boolean isReadyToProcessGeneralEvents() { private boolean isReadyToProcessGeneralEvents() {
return Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(session, "isReadyToProcessGeneralEvents")); return Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(manager, "isReadyToProcessGeneralEvents"));
} }
private void setReadinessFlags(boolean connected, boolean syncInProgress, boolean highPriorityProcessing) { private void setReadiness(boolean connected, boolean syncInProgress, boolean highPriorityProcessing) {
ReflectionTestUtils.setField(session, "connected", connected); state.setConnected(connected);
ReflectionTestUtils.setField(session, "syncInProgress", syncInProgress); if (syncInProgress) {
ReflectionTestUtils.setField(session, "isHighPriorityProcessing", highPriorityProcessing); state.tryStartSync();
} else {
state.finishSync();
}
ReflectionTestUtils.setField(manager, "isHighPriorityProcessing", highPriorityProcessing);
} }
private static void awaitParkedOnClosedGate(RecordingEdgeEventConsumer consumer) { private static void awaitParkedOnClosedGate(RecordingEdgeEventConsumer consumer) {

46
application/src/test/java/org/thingsboard/server/service/iot_hub/InstallReportTest.java

@ -0,0 +1,46 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.iot_hub;
import org.junit.jupiter.api.Test;
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
class InstallReportTest {
private static String sha256(String s) throws Exception {
MessageDigest d = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(d.digest(s.getBytes(StandardCharsets.UTF_8)));
}
@Test
void buildInstallReport_hashesMatchSaltedFormula() throws Exception {
UUID tenantId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
InstallReport r = DefaultIotHubService.buildInstallReport(tenantId, userId, "4.2.0", "CE");
assertEquals(sha256(tenantId.toString() + tenantId), r.tenantHash());
assertEquals(sha256(tenantId.toString() + userId), r.userHash());
assertEquals("4.2.0", r.tbVersion());
assertEquals("CE", r.edition());
}
}

260
application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java

@ -15,14 +15,17 @@
*/ */
package org.thingsboard.server.service.queue; package org.thingsboard.server.service.queue;
import lombok.extern.slf4j.Slf4j; import com.google.common.util.concurrent.MoreExecutors;
import org.junit.After; import org.junit.jupiter.api.AfterEach;
import org.junit.Assert; import org.junit.jupiter.api.BeforeEach;
import org.junit.Test; import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.MockitoJUnitRunner; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.queue.RuleEngineException;
import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy; import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy;
@ -35,30 +38,241 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertTrue; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@Slf4j @ExtendWith(MockitoExtension.class)
@RunWith(MockitoJUnitRunner.class) class TbMsgPackProcessingContextTest {
public class TbMsgPackProcessingContextTest {
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
@Mock
TbRuleEngineSubmitStrategy submitStrategy;
@Mock
TbProtoQueueMsg<TransportProtos.ToRuleEngineMsg> mockMsg;
ConcurrentMap<UUID, TbProtoQueueMsg<TransportProtos.ToRuleEngineMsg>> pendingMap;
public static final int TIMEOUT = 10;
ExecutorService executorService; ExecutorService executorService;
@After @BeforeEach
public void tearDown() { void setup() {
pendingMap = new ConcurrentHashMap<>();
lenient().when(submitStrategy.getPendingMap()).thenReturn(pendingMap);
}
@AfterEach
void tearDown() {
if (executorService != null) { if (executorService != null) {
executorService.shutdownNow(); MoreExecutors.shutdownAndAwaitTermination(executorService, 5, TimeUnit.SECONDS);
} }
} }
@Test @Test
public void testHighConcurrencyCase() throws InterruptedException { void testAwait_shouldReturnTrue_whenOnSuccessIsCalledBeforeTimeout() throws InterruptedException {
//log.warn("preparing the test..."); // GIVEN - a context with one pending message
executorService = Executors.newSingleThreadExecutor();
UUID msgId = UUID.randomUUID();
pendingMap.put(msgId, mockMsg);
var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false);
// WHEN - onSuccess() is called in another thread before timeout
executorService.submit(() -> {
try {
Thread.sleep(100);
context.onSuccess(msgId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// THEN - await() should return true (successful completion)
boolean result = context.await(5000, TimeUnit.MILLISECONDS);
assertThat(result).as("await() should return true when latch is counted down before timeout").isTrue();
// Verify the message was moved to success map
assertThat(context.getSuccessMap()).containsKey(msgId);
assertThat(context.getPendingMap()).isEmpty();
assertThat(context.getExceptionsMap()).isEmpty();
// Verify submit strategy was notified about successful message processing
then(submitStrategy).should().onSuccess(msgId);
}
@Test
void testAwait_shouldReturnTrue_whenOnFailureIsCalledBeforeTimeout() throws InterruptedException {
// GIVEN - a context with one pending message
executorService = Executors.newSingleThreadExecutor();
UUID msgId = UUID.randomUUID();
pendingMap.put(msgId, mockMsg);
var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false);
var exception = new RuleEngineException("Test exception");
// WHEN - onFailure() is called in another thread before timeout
executorService.submit(() -> {
try {
Thread.sleep(100);
context.onFailure(tenantId, msgId, exception);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// THEN - await() should return true (successful completion, even if message processing failed)
boolean result = context.await(5000, TimeUnit.MILLISECONDS);
assertThat(result).as("await() should return true when latch is counted down before timeout").isTrue();
// Verify the exception was added to exceptions map
assertThat(context.getSuccessMap()).isEmpty();
assertThat(context.getPendingMap()).isEmpty();
assertThat(context.getExceptionsMap()).containsEntry(tenantId, exception);
}
@Test
void testAwait_shouldReturnFalse_whenTimeoutOccurs() throws InterruptedException {
// GIVEN - a context with one pending message and no processing
UUID msgId = UUID.randomUUID();
pendingMap.put(msgId, mockMsg);
var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false);
// WHEN - await() is called with short timeout and no message processing happens
long startTime = System.nanoTime();
boolean result = context.await(100, TimeUnit.MILLISECONDS);
long elapsedTime = System.nanoTime() - startTime;
// THEN - await() should return false (timeout occurred)
assertThat(result).as("await() should return false when timeout occurs").isFalse();
assertThat(elapsedTime).as("await() should wait for at least the timeout duration").isGreaterThanOrEqualTo(100L);
// Message should still be in pending map
assertThat(context.getSuccessMap()).isEmpty();
assertThat(context.getPendingMap()).containsKey(msgId);
assertThat(context.getExceptionsMap()).isEmpty();
}
@Test
void testAwait_shouldHandleMultiplePendingMessages() throws InterruptedException {
// GIVEN - a context with multiple pending messages
executorService = Executors.newSingleThreadExecutor();
UUID msgId1 = UUID.randomUUID();
UUID msgId2 = UUID.randomUUID();
UUID msgId3 = UUID.randomUUID();
pendingMap.put(msgId1, mockMsg);
pendingMap.put(msgId2, mockMsg);
pendingMap.put(msgId3, mockMsg);
var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false);
// WHEN - messages are processed one by one
executorService.submit(() -> {
try {
Thread.sleep(50);
context.onSuccess(msgId1);
Thread.sleep(50);
context.onSuccess(msgId2);
Thread.sleep(50);
context.onSuccess(msgId3);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// THEN - await() should return true only after all messages are processed
boolean result = context.await(5000, TimeUnit.MILLISECONDS);
assertThat(result).as("await() should return true after all messages are processed").isTrue();
// All messages should be in success map
assertThat(context.getSuccessMap()).containsKeys(msgId1, msgId2, msgId3);
assertThat(context.getPendingMap()).isEmpty();
assertThat(context.getExceptionsMap()).isEmpty();
}
@Test
void testAwait_shouldNotCountDownPrematurely_withMultipleMessages() throws InterruptedException {
// GIVEN - a context with multiple pending messages
executorService = Executors.newSingleThreadExecutor();
UUID msgId1 = UUID.randomUUID();
UUID msgId2 = UUID.randomUUID();
pendingMap.put(msgId1, mockMsg);
pendingMap.put(msgId2, mockMsg);
var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false);
// WHEN - only one message is processed
executorService.submit(() -> {
try {
Thread.sleep(100);
context.onSuccess(msgId1);
// msgId2 still in processing
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// THEN: await should timeout because not all messages were processed
boolean result = context.await(2000, TimeUnit.MILLISECONDS);
assertThat(result).as("await() should timeout when not all messages are processed").isFalse();
// One message in success, one still pending
assertThat(context.getSuccessMap()).containsOnlyKeys(msgId1);
assertThat(context.getPendingMap()).containsOnlyKeys(msgId2);
assertThat(context.getExceptionsMap()).isEmpty();
}
@Test
void testAwait_shouldHandleMixedSuccessAndFailure() throws InterruptedException {
// GIVEN - multiple messages
executorService = Executors.newSingleThreadExecutor();
UUID msgId1 = UUID.randomUUID();
UUID msgId2 = UUID.randomUUID();
pendingMap.put(msgId1, mockMsg);
pendingMap.put(msgId2, mockMsg);
var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false);
var exception = new RuleEngineException("Test exception");
// WHEN - one succeeds, one fails
executorService.submit(() -> {
try {
Thread.sleep(50);
context.onSuccess(msgId1);
Thread.sleep(50);
context.onFailure(tenantId, msgId2, exception);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// THEN - await() should complete successfully
boolean result = context.await(5000, TimeUnit.MILLISECONDS);
assertThat(result).as("await() should return true when all messages are processed").isTrue();
assertThat(context.getSuccessMap()).containsOnlyKeys(msgId1);
assertThat(context.getPendingMap()).isEmpty();
assertThat(context.getExceptionsMap()).containsEntry(tenantId, exception);
}
@Test
void testHighConcurrencyCase() throws InterruptedException {
int msgCount = 1000; int msgCount = 1000;
int parallelCount = 5; int parallelCount = 5;
executorService = Executors.newFixedThreadPool(parallelCount, ThingsBoardThreadFactory.forName(getClass().getSimpleName() + "-test-scope")); executorService = Executors.newFixedThreadPool(parallelCount, ThingsBoardThreadFactory.forName(getClass().getSimpleName() + "-test-scope"));
@ -76,28 +290,24 @@ public class TbMsgPackProcessingContextTest {
final CountDownLatch startLatch = new CountDownLatch(1); final CountDownLatch startLatch = new CountDownLatch(1);
final CountDownLatch finishLatch = new CountDownLatch(parallelCount); final CountDownLatch finishLatch = new CountDownLatch(parallelCount);
for (int i = 0; i < parallelCount; i++) { for (int i = 0; i < parallelCount; i++) {
//final String taskName = "" + uuid + " " + i;
executorService.submit(() -> { executorService.submit(() -> {
//log.warn("ready {}", taskName);
readyLatch.countDown(); readyLatch.countDown();
try { try {
startLatch.await(); startLatch.await();
} catch (InterruptedException e) { } catch (InterruptedException e) {
Assert.fail("failed to await"); fail("failed to await");
} }
//log.warn("go {}", taskName);
context.onSuccess(uuid); context.onSuccess(uuid);
finishLatch.countDown(); finishLatch.countDown();
}); });
} }
assertTrue(readyLatch.await(TIMEOUT, TimeUnit.SECONDS)); assertTrue(readyLatch.await(10, TimeUnit.SECONDS));
Thread.yield(); Thread.yield();
startLatch.countDown(); //run all-at-once submitted tasks startLatch.countDown(); //run all-at-once submitted tasks
assertTrue(finishLatch.await(TIMEOUT, TimeUnit.SECONDS)); assertTrue(finishLatch.await(10, TimeUnit.SECONDS));
} }
assertTrue(context.await(TIMEOUT, TimeUnit.SECONDS)); assertTrue(context.await(10, TimeUnit.SECONDS));
verify(strategyMock, times(msgCount)).onSuccess(any(UUID.class)); verify(strategyMock, times(msgCount)).onSuccess(any(UUID.class));
} }
} }

281
application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java

@ -0,0 +1,281 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.queue.ruleengine;
import com.google.common.util.concurrent.MoreExecutors;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.queue.ProcessingStrategy;
import org.thingsboard.server.common.data.queue.ProcessingStrategyType;
import org.thingsboard.server.common.data.queue.Queue;
import org.thingsboard.server.common.data.queue.SubmitStrategy;
import org.thingsboard.server.common.data.queue.SubmitStrategyType;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.common.stats.StatsFactory;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.queue.TbQueueAdmin;
import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.TbQueueMsg;
import org.thingsboard.server.queue.common.DefaultTbQueueMsgHeaders;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import org.thingsboard.server.queue.discovery.PartitionService;
import org.thingsboard.server.queue.discovery.QueueKey;
import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
import org.thingsboard.server.queue.memory.DefaultInMemoryStorage;
import org.thingsboard.server.queue.memory.InMemoryStorage;
import org.thingsboard.server.queue.memory.InMemoryTbQueueConsumer;
import org.thingsboard.server.queue.provider.TbQueueProducerProvider;
import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory;
import org.thingsboard.server.service.queue.TbMsgPackProcessingContext;
import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory;
import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory;
import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy;
import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory;
import org.thingsboard.server.service.stats.RuleEngineStatisticsService;
import java.time.Duration;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class RuleEngineConsumerLoopTest {
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
DeviceId deviceId = new DeviceId(UUID.randomUUID());
InMemoryStorage storage;
@Mock
ActorSystemContext actorContext;
@Mock
StatsFactory statsFactory;
@Mock
TbRuleEngineQueueFactory queueFactory;
@Mock
RuleEngineStatisticsService statisticsService;
@Mock
TbServiceInfoProvider serviceInfoProvider;
@Mock
PartitionService partitionService;
@Mock
TbQueueProducerProvider producerProvider;
@Mock
TbQueueAdmin queueAdmin;
@Mock
TbMsgPackProcessingContextFactory packProcessingContextFactory;
@Mock
TbMsgPackProcessingContext packCtx;
Queue mainQueue;
TbQueueConsumer<TbProtoQueueMsg<TransportProtos.ToRuleEngineMsg>> consumer;
TbRuleEngineConsumerContext ruleEngineConsumerContext;
TbRuleEngineQueueConsumerManager consumerManager;
ExecutorService consumersExecutor;
ScheduledExecutorService scheduler;
ExecutorService mgmtExecutor;
@BeforeEach
void setup() throws InterruptedException {
consumersExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName("tb-rule-engine-consumer"));
scheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("tb-rule-engine-consumer-scheduler");
mgmtExecutor = ThingsBoardExecutors.newWorkStealingPool(1, "tb-rule-engine-mgmt");
mainQueue = new Queue();
mainQueue.setTenantId(TenantId.SYS_TENANT_ID);
mainQueue.setName("Main");
mainQueue.setTopic("tb_rule_engine.main");
mainQueue.setPollInterval(25);
mainQueue.setPartitions(1);
mainQueue.setConsumerPerPartition(false);
mainQueue.setPackProcessingTimeout(2000L);
var submitStrategy = new SubmitStrategy();
submitStrategy.setType(SubmitStrategyType.BURST);
submitStrategy.setBatchSize(1000);
mainQueue.setSubmitStrategy(submitStrategy);
var processingStrategy = new ProcessingStrategy();
processingStrategy.setType(ProcessingStrategyType.SKIP_ALL_FAILURES);
processingStrategy.setRetries(3);
processingStrategy.setFailurePercentage(0.0);
processingStrategy.setPauseBetweenRetries(3);
processingStrategy.setMaxPauseBetweenRetries(3);
mainQueue.setProcessingStrategy(processingStrategy);
storage = new DefaultInMemoryStorage();
consumer = spy(new InMemoryTbQueueConsumer<>(storage, mainQueue.getTopic()));
given(queueFactory.createToRuleEngineMsgConsumer(eq(mainQueue), isNull())).willReturn(consumer);
ruleEngineConsumerContext = new TbRuleEngineConsumerContext(
actorContext, statsFactory, new TbRuleEngineSubmitStrategyFactory(), new TbRuleEngineProcessingStrategyFactory(),
queueFactory, statisticsService, serviceInfoProvider, partitionService, producerProvider, queueAdmin
);
ruleEngineConsumerContext.setPollDuration(25);
ruleEngineConsumerContext.setPackProcessingTimeout(2000);
ruleEngineConsumerContext.setStatsEnabled(false); // true by default
ruleEngineConsumerContext.setPrometheusStatsEnabled(false);
ruleEngineConsumerContext.setTopicDeletionDelayInSec(15);
ruleEngineConsumerContext.setMgmtThreadPoolSize(12);
// Tell the (mock) context factory to return (mock) message pack context
given(packProcessingContextFactory.create(
eq(mainQueue.getName()),
any(TbRuleEngineSubmitStrategy.class),
eq(false)
)).willAnswer(invocation -> {
TbRuleEngineSubmitStrategy realStrategy = invocation.getArgument(1);
when(packCtx.getPendingMap()).thenAnswer(i -> realStrategy.getPendingMap());
when(packCtx.getFailedMap()).thenReturn(new ConcurrentHashMap<>());
return packCtx;
});
// Tell the (mock) context's await() to return 'false' (always timeout) immediately
given(packCtx.await(anyLong(), any(TimeUnit.class))).willReturn(false);
consumerManager = TbRuleEngineQueueConsumerManager.create()
.ctx(ruleEngineConsumerContext)
.queueKey(new QueueKey(ServiceType.TB_RULE_ENGINE, mainQueue))
.consumerExecutor(consumersExecutor)
.scheduler(scheduler)
.taskExecutor(mgmtExecutor)
.packProcessingContextFactory(packProcessingContextFactory)
.build();
}
@AfterEach
void destroy() {
MoreExecutors.shutdownAndAwaitTermination(scheduler, Duration.ofSeconds(30));
MoreExecutors.shutdownAndAwaitTermination(mgmtExecutor, Duration.ofSeconds(30));
MoreExecutors.shutdownAndAwaitTermination(consumersExecutor, Duration.ofSeconds(30));
}
@Test
void consumerLoopTest_verifyOperationsOrder() throws InterruptedException {
// Create partition
var partition = TopicPartitionInfo.builder()
.tenantId(TenantId.SYS_TENANT_ID)
.topic(mainQueue.getTopic())
.partition(0)
.myPartition(true)
.useInternalPartition(false)
.build();
// Put 10k messages to the queue
for (int i = 0; i < 10_000; i++) {
var tbMsg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(deviceId)
.data("{\"temperature\":123}")
.metaData(TbMsgMetaData.EMPTY)
.build();
var toRuleEngineMsg = TransportProtos.ToRuleEngineMsg.newBuilder()
.setTenantIdLSB(tenantId.getId().getLeastSignificantBits())
.setTenantIdMSB(tenantId.getId().getMostSignificantBits())
.setTbMsgProto(TbMsg.toProto(tbMsg))
.addAllRelationTypes(Set.of("Success"))
.build();
storage.put(partition.getFullTopicName(), new TbProtoQueueMsg<>(UUID.randomUUID(), toRuleEngineMsg, new DefaultTbQueueMsgHeaders()));
}
// Count how many polls were made
var totalPolls = new AtomicInteger(0);
var emptyPolls = new AtomicInteger(0);
doAnswer(invocation -> {
totalPolls.incrementAndGet();
@SuppressWarnings("unchecked")
var messages = (List<TbQueueMsg>) invocation.callRealMethod();
if (messages.isEmpty()) {
emptyPolls.incrementAndGet();
}
return messages;
}).when(consumer).poll(mainQueue.getPollInterval());
// Count how many commits were made
var totalCommits = new AtomicInteger(0);
doAnswer(invocation -> {
totalCommits.incrementAndGet();
return invocation.callRealMethod();
}).when(consumer).commit();
// Initialize consumer
consumerManager.init(mainQueue);
// Assign partition to the consumer
consumerManager.update(Set.of(partition));
// Give some time for the consumer to get all messages
await().atMost(Duration.ofSeconds(10L)).until(() -> storage.getLagTotal() == 0);
// Stop consumer
consumerManager.stop();
consumerManager.awaitStop();
// Determine number of non-empty consumer iterations made, since polling does not stop immediately after consuming all messages and may do a few empty polls
int nonEmptyPolls = totalPolls.get() - emptyPolls.get();
// Verify that there is 10 polls and 10 matching commits
// Each poll consumes 1k messages and queue has 10k total, so that means 10k total msgs / 1k msgs per poll = 10 polls
assertThat(nonEmptyPolls).isEqualTo(10).isEqualTo(totalCommits.get());
// Verify that poll-await-commit cycle happened in order with correct await timeout
InOrder inOrder = inOrder(consumer, packCtx);
for (int i = 0; i < nonEmptyPolls; i++) {
inOrder.verify(consumer).poll(mainQueue.getPollInterval());
inOrder.verify(packCtx).await(mainQueue.getPackProcessingTimeout(), TimeUnit.MILLISECONDS);
inOrder.verify(consumer).commit();
}
}
}

2
application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java

@ -59,6 +59,7 @@ import org.thingsboard.server.queue.provider.KafkaMonolithQueueFactory;
import org.thingsboard.server.queue.provider.KafkaTbRuleEngineQueueFactory; import org.thingsboard.server.queue.provider.KafkaTbRuleEngineQueueFactory;
import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.provider.TbQueueProducerProvider;
import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory;
import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory;
import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory;
import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory;
import org.thingsboard.server.service.stats.RuleEngineStatisticsService; import org.thingsboard.server.service.stats.RuleEngineStatisticsService;
@ -194,6 +195,7 @@ public class TbRuleEngineQueueConsumerManagerTest {
.consumerExecutor(consumersExecutor) .consumerExecutor(consumersExecutor)
.scheduler(scheduler) .scheduler(scheduler)
.taskExecutor(mgmtExecutor) .taskExecutor(mgmtExecutor)
.packProcessingContextFactory(new TbMsgPackProcessingContextFactory.DefaultTbMsgPackProcessingContextFactory())
.build(); .build();
} }

2
application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java

@ -45,6 +45,7 @@ import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import org.thingsboard.server.queue.common.consumer.TbQueueConsumerTask.ConsumerKey; import org.thingsboard.server.queue.common.consumer.TbQueueConsumerTask.ConsumerKey;
import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.queue.discovery.QueueKey;
import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory;
import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory;
import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory;
@ -196,6 +197,7 @@ public class TbRuleEngineStrategyTest {
var consumerManager = TbRuleEngineQueueConsumerManager.create() var consumerManager = TbRuleEngineQueueConsumerManager.create()
.ctx(ruleEngineConsumerContext) .ctx(ruleEngineConsumerContext)
.queueKey(queueKey) .queueKey(queueKey)
.packProcessingContextFactory(new TbMsgPackProcessingContextFactory.DefaultTbMsgPackProcessingContextFactory())
.build(); .build();
consumerManager.init(queue); consumerManager.init(queue);

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

@ -2356,7 +2356,8 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
expected.put("date_1", d1.toString()); expected.put("date_1", d1.toString());
TbDate d2 = new TbDate(2023, 8, 6, 4, 4, 5, "Europe/Berlin"); TbDate d2 = new TbDate(2023, 8, 6, 4, 4, 5, "Europe/Berlin");
expected.put("dLocal_2", d2.toLocaleString()); expected.put("dLocal_2", d2.toLocaleString());
expected.put("dLocal_2_us", "8/5/23, 10:04:05 PM"); // Java 21+ uses narrow no-break space (U+202F) before AM/PM per CLDR 42+
expected.put("dLocal_2_us", Runtime.version().feature() >= 21 ? "8/5/23, 10:04:05\u202FPM" : "8/5/23, 10:04:05 PM");
expected.put("dIso_2", d2.toISOString()); expected.put("dIso_2", d2.toISOString());
expected.put("date_2", d2.toString()); expected.put("date_2", d2.toString());
Object actual = invokeScript(evalScript(decoderStr), msgStr); Object actual = invokeScript(evalScript(decoderStr), msgStr);
@ -2388,7 +2389,8 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
""", s2); """, s2);
LinkedHashMap<String, Object> expected = new LinkedHashMap<>(); LinkedHashMap<String, Object> expected = new LinkedHashMap<>();
TbDate d1 = new TbDate(2023, 8, 6, 4, 4, 5); TbDate d1 = new TbDate(2023, 8, 6, 4, 4, 5);
expected.put("dLocal_1_us", "8/6/23, 4:04:05 AM"); // Java 21+ uses narrow no-break space (U+202F) before AM/PM per CLDR 42+
expected.put("dLocal_1_us", Runtime.version().feature() >= 21 ? "8/6/23, 4:04:05\u202FAM" : "8/6/23, 4:04:05 AM");
expected.put("dIso_1", d1.toISOString()); expected.put("dIso_1", d1.toISOString());
expected.put("d1", d1.toString()); expected.put("d1", d1.toString());
TbDate d2 = new TbDate(s2); TbDate d2 = new TbDate(s2);
@ -2420,7 +2422,8 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
TbDate d = new TbDate(2023, 8, 6, 4, 4, 5, "Europe/Berlin"); TbDate d = new TbDate(2023, 8, 6, 4, 4, 5, "Europe/Berlin");
expected.put("dIso", d.toISOString()); expected.put("dIso", d.toISOString());
expected.put("dLocal_utc", d.toLocaleString("UTC")); expected.put("dLocal_utc", d.toLocaleString("UTC"));
expected.put("dLocal_us", "8/5/23, 10:04:05 PM"); // Java 21+ uses narrow no-break space (U+202F) before AM/PM per CLDR 42+
expected.put("dLocal_us", Runtime.version().feature() >= 21 ? "8/5/23, 10:04:05\u202FPM" : "8/5/23, 10:04:05 PM");
expected.put("dLocal_de", "06.08.23, 04:04:05"); expected.put("dLocal_de", "06.08.23, 04:04:05");
Object actual = invokeScript(evalScript(decoderStr), msgStr); Object actual = invokeScript(evalScript(decoderStr), msgStr);
assertEquals(expected, actual); assertEquals(expected, actual);

1
application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java

@ -942,7 +942,6 @@ public class VersionControlTest extends AbstractControllerTest {
} }
return result.getResult().stream().collect(Collectors.toMap(EntityTypeLoadResult::getEntityType, r -> r)); return result.getResult().stream().collect(Collectors.toMap(EntityTypeLoadResult::getEntityType, r -> r));
} }
private List<EntityVersion> listVersions() throws Exception { private List<EntityVersion> listVersions() throws Exception {
PageData<EntityVersion> versions = doGetAsyncTyped("/api/entities/vc/version?branch=" + branch + "&pageSize=100&page=0&sortProperty=timestamp&sortOrder=DESC", new TypeReference<PageData<EntityVersion>>() {}); PageData<EntityVersion> versions = doGetAsyncTyped("/api/entities/vc/version?branch=" + branch + "&pageSize=100&page=0&sortProperty=timestamp&sortOrder=DESC", new TypeReference<PageData<EntityVersion>>() {});
return versions.getData(); return versions.getData();

8
application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java

@ -160,7 +160,13 @@ public class SimpleLwM2MDevice extends BaseInstanceEnabler implements Destroyabl
if (!arguments.isEmpty()) if (!arguments.isEmpty())
withArguments = " with arguments " + arguments; withArguments = " with arguments " + arguments;
log.info("Execute on Device resource /{}/{}/{} {}", getModel().id, getId(), resourceId, withArguments); log.info("Execute on Device resource /{}/{}/{} {}", getModel().id, getId(), resourceId, withArguments);
return ExecuteResponse.success(); switch (resourceId) {
case 4:
case 5:
return ExecuteResponse.success();
default:
return super.execute(identity, resourceId, arguments);
}
} }
@Override @Override

114
application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java

@ -29,6 +29,7 @@ import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INST
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_2; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_2;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_3; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_3;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_4; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_4;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_5;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_8; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_8;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_9; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_9;
@ -90,26 +91,101 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT
/** /**
* execute_resource_with_parameters (execute reboot after 60 seconds on device) * execute_resource_with_parameters (execute reboot if digit = 5 on device)
* Execute {"id":"3/0/4","value":60} * Execute {"id":"3/0/4","value":5}
* {"result":"CHANGED"} * {"result":"CHANGED"}
*/ */
@Test @Test
public void testExecuteResourceWithParametersById_Result_CHANGED() throws Exception { public void testExecuteResourceWithParametersSingleDigitValueById_Result_Ok() throws Exception {
String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_4; String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_4;
Object expectedValue = 60; Object expectedValue = 5;
String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText());
} }
/**
* execute_resource_with_parameters (execute Factory Reset if digit = 2 -> after 60 seconds on device)
* Execute {"id":"3/0/5","value":"2='60'"}
*/
@Test
public void testExecuteResourceWithParametersArgumentIdAndValueById_Result_Ok() throws Exception {
String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5;
Object expectedValue = "2='60'";
String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText());
}
/**
* execute_resource_with_parameters (execute Factory Reset with two arguments:
* digit 2 without a value and digit 0 with the link value on device)
* Execute {"id":"3/0/5","value":"2,0='https://thingsboard.io/docs/reference/lwm2m-api/'"}
*/
@Test
public void testExecuteResourceWithParametersMultipleArgumentsIncludingLinkById_Result_Ok() throws Exception {
String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5;
Object expectedValue = "2,0='https://thingsboard.io/docs/reference/lwm2m-api/'";
String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText());
}
/**
* execute_resource_with_parameters (execute Factory Reset with multiple arguments without values)
* According to the OMA LwM2M execute arguments format, this represents ten arguments (digits 0-9), all without values.
* Execute {"id":"3/0/5","value":"0,1,2,3,4,5,6,7,8,9"}
*/
@Test
public void testExecuteResourceWithParametersMultipleArgumentsById_Result_Ok() throws Exception {
String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5;
Object expectedValue = "0,1,2,3,4,5,6,7,8,9";
String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText());
}
/**
* execute_resource_with_parameters (execute Factory Reset after 60 seconds on device)
* Execute {"id":"3/0/5","value":"'60'"}
*/
@Test
public void testExecuteResourceWithParametersSingleDigitValueInvalidById_Result_BAD_REQUEST_Error_IntegerBetween_0_And_9_Expected() throws Exception {
String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5;
Object expectedValue = "'60'";
String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
assertEquals(ResponseCode.BAD_REQUEST.getName(), rpcActualResult.get("result").asText());
String expected = "Unable to parse Arguments [" + expectedValue + "] : Invalid digit ['] (an integer between 0 and 9 is expected)";
String actual = rpcActualResult.get("error").asText();
assertTrue(actual.contains(expected));
}
/**
* execute_resource_with_parameters (execute Bad with Unable to parse Arguments)
* Execute {"id":"3/0/5","value":"0,1,2,3,4,5,6,7,8,9,60"}
*/
@Test
public void testExecuteResourceWithParametersMultipleArgumentsById_Result_BAD_REQUEST_Error_UnableParseArguments() throws Exception {
String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5;
Object expectedValue = "0,1,2,3,4,5,6,7,8,9,60";
String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue);;
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
assertEquals(ResponseCode.BAD_REQUEST.getName(), rpcActualResult.get("result").asText());
String expected = "Unable to parse Arguments [" + expectedValue + "] : [,] separator expected at index 21 after [0,1,2,3,4,5,6,7,8,9,6]";
String actual = rpcActualResult.get("error").asText();
assertTrue(actual.contains(expected));
}
/** /**
* Bootstrap-Request Trigger * Bootstrap-Request Trigger
* Execute {"id":"1/0/9"} * Execute {"id":"1/0/9"}
* {"result":"BAD_REQUEST","error":"probably no bootstrap server configured"} * {"result":"BAD_REQUEST","error":"probably no bootstrap server configured"}
*/ */
@Test @Test
public void testExecuteBootstrapRequestTriggerById_Result_BAD_REQUEST_Error_NoBootstrapServerConfigured() throws Exception { public void testExecuteBootstrapRequestTriggerById_Result_BAD_REQUEST_Error_NoBootstrapServer() throws Exception {
String expectedPath = objectInstanceIdVer_1 + "/" + RESOURCE_ID_9; String expectedPath = objectInstanceIdVer_1 + "/" + RESOURCE_ID_9;
String actualResult = sendRPCExecuteById(expectedPath); String actualResult = sendRPCExecuteById(expectedPath);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
@ -125,7 +201,7 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT
* {"result":"BAD_REQUEST","error":"Resource with /5_1.0/0/3 is not executable."} * {"result":"BAD_REQUEST","error":"Resource with /5_1.0/0/3 is not executable."}
*/ */
@Test @Test
public void testExecuteResourceWithOperationNotExecuteById_Result_METHOD_NOT_ALLOWED() throws Exception { public void testExecuteResourceWithOperationNotExecuteById_Result_BAD_REQUEST_Error_Is_Not_Executable() throws Exception {
String expectedPath = objectInstanceIdVer_5 + "/" + RESOURCE_ID_3; String expectedPath = objectInstanceIdVer_5 + "/" + RESOURCE_ID_3;
String actualResult = sendRPCExecuteById(expectedPath); String actualResult = sendRPCExecuteById(expectedPath);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
@ -141,7 +217,7 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT
* {"result":"BAD_REQUEST","error":"Specified object id 50 absent in the list supported objects of the client or is security object!"} * {"result":"BAD_REQUEST","error":"Specified object id 50 absent in the list supported objects of the client or is security object!"}
*/ */
@Test @Test
public void testExecuteNonExistingResourceOnNonExistingObjectById_Result_BAD_REQUEST() throws Exception { public void testExecuteNonExistingResourceOnNonExistingObjectById_Result_BAD_REQUEST_Error_Specified_Object_Absent_List_Supported() throws Exception {
String expectedPath = OBJECT_ID_VER_50 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_3; String expectedPath = OBJECT_ID_VER_50 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_3;
String actualResult = sendRPCExecuteById(expectedPath); String actualResult = sendRPCExecuteById(expectedPath);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
@ -159,7 +235,7 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT
* {"result":"BAD_REQUEST","error":"Specified object id 0 absent in the list supported objects of the client or is security object!"} * {"result":"BAD_REQUEST","error":"Specified object id 0 absent in the list supported objects of the client or is security object!"}
*/ */
@Test @Test
public void testExecuteSecurityObjectById_Result_NOT_FOUND() throws Exception { public void testExecuteSecurityObjectById_Result_BAD_REQUEST_Error_SpecifiedObjectAbsent() throws Exception {
String expectedPath = objectIdVer_0 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_3; String expectedPath = objectIdVer_0 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_3;
String actualResult = sendRPCExecuteById(expectedPath); String actualResult = sendRPCExecuteById(expectedPath);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
@ -178,8 +254,26 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT
} }
private String sendRPCExecuteWithValueById(String path, Object value) throws Exception { private String sendRPCExecuteWithValueById(String path, Object value) throws Exception {
String setRpcRequest = "{\"method\": \"Execute\", \"params\": {\"id\": \"" + path + "\", \"value\": " + value + " }}"; ObjectNode params = JacksonUtil.newObjectNode();
return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), setRpcRequest, String.class, status().isOk()); params.put("id", path);
// Jackson сам вирішить: ставити лапки (рядок) чи ні (число/boolean/null)
if (value instanceof String) {
params.put("value", (String) value);
} else if (value instanceof Integer) {
params.put("value", (Integer) value);
} else if (value instanceof Boolean) {
params.put("value", (Boolean) value);
} else {
params.set("value", JacksonUtil.valueToTree(value));
}
ObjectNode setRpcRequest = JacksonUtil.newObjectNode();
setRpcRequest.put("method", "Execute");
setRpcRequest.set("params", params);
return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(),
JacksonUtil.toString(setRpcRequest), String.class, status().isOk());
} }
} }

2
application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java

@ -88,7 +88,7 @@ public abstract class AbstractMqttIntegrationTest extends AbstractTransportInteg
assertNotNull(accessToken); assertNotNull(accessToken);
} }
if (config.getGatewayName() != null) { if (config.getGatewayName() != null) {
savedGateway = createDevice(config.getGatewayName(), deviceProfile.getName(), true); savedGateway = createDevice(config.getGatewayName(), deviceProfile.getName(), !config.isSparkplug);
DeviceCredentials gatewayCredentials = DeviceCredentials gatewayCredentials =
doGet("/api/device/" + savedGateway.getId().getId().toString() + "/credentials", DeviceCredentials.class); doGet("/api/device/" + savedGateway.getId().getId().toString() + "/credentials", DeviceCredentials.class);
assertNotNull(gatewayCredentials); assertNotNull(gatewayCredentials);

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

@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.TransportPayloadType; import org.thingsboard.server.common.data.TransportPayloadType;
import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry;
@ -39,6 +40,7 @@ import org.thingsboard.server.common.data.kv.JsonDataEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.gen.transport.mqtt.SparkplugBProto; import org.thingsboard.server.gen.transport.mqtt.SparkplugBProto;
import org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest; import org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest;
import org.thingsboard.server.transport.mqtt.MqttTestConfigProperties; import org.thingsboard.server.transport.mqtt.MqttTestConfigProperties;
@ -51,13 +53,19 @@ import java.util.Calendar;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import static java.util.concurrent.Executors.newFixedThreadPool;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
import static org.eclipse.paho.mqttv5.common.packet.MqttWireMessage.MESSAGE_TYPE_CONNACK; import static org.eclipse.paho.mqttv5.common.packet.MqttWireMessage.MESSAGE_TYPE_CONNACK;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.common.util.JacksonUtil.newArrayNode; import static org.thingsboard.common.util.JacksonUtil.newArrayNode;
import static org.thingsboard.server.service.transport.DefaultTransportApiService.GATEWAY_CREATED_RELATION;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.Bytes; import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.Bytes;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.Int16; import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.Int16;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.Int32; import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.Int32;
@ -67,8 +75,13 @@ import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataTyp
import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.UInt32; import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.UInt32;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.UInt64; import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.UInt64;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.UInt8; import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.UInt8;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.ONLINE;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.STATE;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.messageName;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.createMetric; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.createMetric;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.DEVICE_NAME_SPLIT_SEPARATOR;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_SPLIT_SEPARATOR;
/** /**
* Created by nickAS21 on 12.01.23 * Created by nickAS21 on 12.01.23
@ -82,6 +95,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
protected ThreadLocalRandom random = ThreadLocalRandom.current(); protected ThreadLocalRandom random = ThreadLocalRandom.current();
protected static final String groupId = "SparkplugBGroupId"; protected static final String groupId = "SparkplugBGroupId";
protected static final String edgeNodeDeviceName = "Test Connect Sparkplug client node";
protected static final String edgeNode = "SparkpluBNode"; protected static final String edgeNode = "SparkpluBNode";
protected static final String keysBdSeq = "bdSeq"; protected static final String keysBdSeq = "bdSeq";
protected static final String alias = "Failed Telemetry/Attribute proto sparkplug payload. SparkplugMessageType "; protected static final String alias = "Failed Telemetry/Attribute proto sparkplug payload. SparkplugMessageType ";
@ -99,8 +113,9 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
protected Set<String> sparkplugAttributesMetricNames; protected Set<String> sparkplugAttributesMetricNames;
public void beforeSparkplugTest() throws Exception { public void beforeSparkplugTest() throws Exception {
MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder() MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder()
.gatewayName("Test Connect Sparkplug client node") .gatewayName(edgeNodeDeviceName)
.isSparkplug(true) .isSparkplug(true)
.sparkplugAttributesMetricNames(sparkplugAttributesMetricNames) .sparkplugAttributesMetricNames(sparkplugAttributesMetricNames)
.transportPayloadType(TransportPayloadType.PROTOBUF) .transportPayloadType(TransportPayloadType.PROTOBUF)
@ -108,6 +123,25 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
processBeforeTest(configProperties); processBeforeTest(configProperties);
} }
public void seedLegacyAndFullPathDevices() throws Exception {
// 1. Create the first device with a short name (legacy style)
String deviceName1 = deviceId + "_1";
Device device1 = createDevice(deviceName1, deviceProfile.getName(), false);
// 2. Establish 'Created' relation so the transport identifies this gateway as the owner
EntityRelation relation1 = createFromRelation(savedGateway, device1, GATEWAY_CREATED_RELATION);
doPost("/api/relation", relation1).andExpect(status().isOk());
// 3. Create the second device with a full-path name
String deviceName2 = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + deviceId + "_2";
Device device2 = createDevice(deviceName2, deviceProfile.getName(), false);
// 4. Establish 'Created' relation for the second device as well
EntityRelation relation2 = createFromRelation(savedGateway, device2, GATEWAY_CREATED_RELATION);
doPost("/api/relation", relation2).andExpect(status().isOk());
}
public void clientWithCorrectNodeAccessTokenWithNDEATH() throws Exception { public void clientWithCorrectNodeAccessTokenWithNDEATH() throws Exception {
long ts = calendar.getTimeInMillis(); long ts = calendar.getTimeInMillis();
long value = bdSeq = 0; long value = bdSeq = 0;
@ -122,9 +156,9 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
public void clientWithCorrectNodeAccessTokenWithNDEATH(long ts, long value) throws Exception { public void clientWithCorrectNodeAccessTokenWithNDEATH(long ts, long value) throws Exception {
IMqttToken connectionResult = clientMqttV5ConnectWithNDEATH(ts, value, -1L); IMqttToken connectionResult = clientMqttV5ConnectWithNDEATH(ts, value, -1L);
MqttWireMessage response = connectionResult.getResponse(); MqttWireMessage response = connectionResult.getResponse();
Assert.assertEquals(MESSAGE_TYPE_CONNACK, response.getType()); assertEquals(MESSAGE_TYPE_CONNACK, response.getType());
MqttConnAck connAckMsg = (MqttConnAck) response; MqttConnAck connAckMsg = (MqttConnAck) response;
Assert.assertEquals(MqttReturnCode.RETURN_CODE_SUCCESS, connAckMsg.getReturnCode()); assertEquals(MqttReturnCode.RETURN_CODE_SUCCESS, connAckMsg.getReturnCode());
} }
public IMqttToken clientMqttV5ConnectWithNDEATH(long ts, long value, Long alias, String... nameSpaceBad) throws Exception { public IMqttToken clientMqttV5ConnectWithNDEATH(long ts, long value, Long alias, String... nameSpaceBad) throws Exception {
@ -146,7 +180,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
options.setSessionExpiryInterval(0L); options.setSessionExpiryInterval(0L);
options.setUserName(gatewayAccessToken); options.setUserName(gatewayAccessToken);
String nameSpace = nameSpaceBad.length == 0 ? TOPIC_ROOT_SPB_V_1_0 : nameSpaceBad[0]; String nameSpace = nameSpaceBad.length == 0 ? TOPIC_ROOT_SPB_V_1_0 : nameSpaceBad[0];
String topic = nameSpace + "/" + groupId + "/" + SparkplugMessageType.NDEATH.name() + "/" + edgeNode; String topic = nameSpace + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.NDEATH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode;
// The NDEATH message MUST set the MQTT Will QoS to 1 and Retained flag to false // The NDEATH message MUST set the MQTT Will QoS to 1 and Retained flag to false
MqttMessage msg = new MqttMessage(); MqttMessage msg = new MqttMessage();
msg.setId(0); msg.setId(0);
@ -159,38 +193,35 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
protected List<Device> connectClientWithCorrectAccessTokenWithNDEATHCreatedDevices(int cntDevices, long ts) throws Exception { protected List<Device> connectClientWithCorrectAccessTokenWithNDEATHCreatedDevices(int cntDevices, long ts) throws Exception {
List<Device> devices = new ArrayList<>(); List<Device> devices = new ArrayList<>();
clientWithCorrectNodeAccessTokenWithNDEATH(); clientWithCorrectNodeAccessTokenWithNDEATH();
String keyInt = "Node Metric int32"; MetricDataType metricDataType = Int32;
String key = "Node Metric int32";
int valueDeviceInt32 = 1024; int valueDeviceInt32 = 1024;
SparkplugBProto.Payload.Metric metricInt = createMetric(valueDeviceInt32, ts, keyInt, Int32, -1L); SparkplugBProto.Payload.Metric metric = createMetric(valueDeviceInt32, ts, key, metricDataType, -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() SparkplugBProto.Payload.Builder payloadBirthNode = SparkplugBProto.Payload.newBuilder()
.setTimestamp(ts) .setTimestamp(ts)
.setSeq(getBdSeqNum()); .setSeq(getBdSeqNum());
payloadBirthNode.addMetrics(metricInt); payloadBirthNode.addMetrics(metric);
payloadBirthNode.addMetrics(metricStringEmpty);
payloadBirthNode.setTimestamp(ts); payloadBirthNode.setTimestamp(ts);
if (client.isConnected()) { if (client.isConnected()) {
client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.NBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode,
payloadBirthNode.build().toByteArray(), 0, false); payloadBirthNode.build().toByteArray(), 0, false);
} }
valueDeviceInt32 = 4024; valueDeviceInt32 = 4024;
metricInt = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L); metric = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L);
for (int i = 0; i < cntDevices; i++) { for (int i = 0; i < cntDevices; i++) {
SparkplugBProto.Payload.Builder payloadBirthDevice = SparkplugBProto.Payload.newBuilder() SparkplugBProto.Payload.Builder payloadBirthDevice = SparkplugBProto.Payload.newBuilder()
.setTimestamp(ts) .setTimestamp(ts)
.setSeq(getSeqNum()); .setSeq(getSeqNum());
String deviceName = deviceId + "_" + i; String deviceIdName = deviceId + "_" + i;
String deviceName = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + deviceIdName;
payloadBirthDevice.addMetrics(metricInt); payloadBirthDevice.addMetrics(metric);
if (client.isConnected()) { if (client.isConnected()) {
client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.DBIRTH.name() + "/" + edgeNode + "/" + deviceName, client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.DBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode + TOPIC_SPLIT_SEPARATOR + deviceIdName,
payloadBirthDevice.build().toByteArray(), 0, false); payloadBirthDevice.build().toByteArray(), 0, false);
AtomicReference<Device> device = new AtomicReference<>(); AtomicReference<Device> device = new AtomicReference<>();
await(alias + "find device [" + deviceName + "] after created") await(alias + "find device [" + deviceIdName + "] after created")
.atMost(200, TimeUnit.SECONDS) .atMost(40, TimeUnit.SECONDS)
.ignoreExceptions() .ignoreExceptions()
.until(() -> { .until(() -> {
device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class)); device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class));
@ -198,13 +229,272 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
}); });
devices.add(device.get()); devices.add(device.get());
} }
} }
Assert.assertEquals(cntDevices, devices.size()); assertEquals(cntDevices, devices.size());
return devices; return devices;
} }
protected void connectClientWithCorrectAccessTokenWithNDEATHDevicesCreatingBefore_Test(int cntDevices) throws Exception {
long ts = calendar.getTimeInMillis();
List<Device> devices = new ArrayList<>();
clientWithCorrectNodeAccessTokenWithNDEATH();
MetricDataType metricDataType = Int32;
String key = "Node Metric int32";
int valueDeviceInt32 = 1024;
SparkplugBProto.Payload.Metric metric = createMetric(valueDeviceInt32, ts, key, metricDataType, -1L);
SparkplugBProto.Payload.Builder payloadBirthNode = SparkplugBProto.Payload.newBuilder()
.setTimestamp(ts)
.setSeq(getBdSeqNum());
payloadBirthNode.addMetrics(metric);
payloadBirthNode.setTimestamp(ts);
if (client.isConnected()) {
client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.NBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode,
payloadBirthNode.build().toByteArray(), 0, false);
}
valueDeviceInt32 = 4024;
metric = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L);
// as old device name -> deviceId
String deviceIdNameLabel1 = deviceId + "_1";
if (client.isConnected()) {
SparkplugBProto.Payload.Builder payloadBirthDevice1 = SparkplugBProto.Payload.newBuilder()
.setTimestamp(ts)
.setSeq(getSeqNum());
payloadBirthDevice1.addMetrics(metric);
client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.DBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode + TOPIC_SPLIT_SEPARATOR + deviceIdNameLabel1,
payloadBirthDevice1.build().toByteArray(), 0, false);
}
String deviceName1 = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + deviceIdNameLabel1;
AtomicReference<Device> device1 = new AtomicReference<>();
await(alias + "find device [" + deviceName1 + "] before connecting")
.atMost(40, TimeUnit.SECONDS)
.until(() -> {
device1.set(doGet("/api/tenant/devices?deviceName=" + deviceName1, Device.class));
return device1.get() != null;
});
devices.add(device1.get());
// as new device name -> groupId + ":" + edgeNode + ":" + deviceId;
String deviceIdName2 = deviceId + "_2";
if (client.isConnected()) {
SparkplugBProto.Payload.Builder payloadBirthDevice2 = SparkplugBProto.Payload.newBuilder()
.setTimestamp(ts)
.setSeq(getSeqNum());
payloadBirthDevice2.addMetrics(metric);
client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.DBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode + TOPIC_SPLIT_SEPARATOR + deviceIdName2,
payloadBirthDevice2.build().toByteArray(), 0, false);
}
String deviceName2 = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + deviceIdName2;
AtomicReference<Device> device2 = new AtomicReference<>();
await(alias + "find device [" + deviceName2 + "] before connecting")
.atMost(40, TimeUnit.SECONDS)
.until(() -> {
device2.set(doGet("/api/tenant/devices?deviceName=" + deviceName2, Device.class));
return device2.get() != null;
});
devices.add(device2.get());
assertEquals(cntDevices, devices.size());
state_ONLINE_ALL (devices, calendar.getTimeInMillis());
// Without full topic: as it was in the old version. When deviceId is updated to full theme, Label is also updated to old deviceId
assertEquals(deviceIdNameLabel1, device1.get().getLabel());
// // With a full topic: if new. When creating a device by a client to a full topic, if the Label was not filled in - we do not touch it.
Assert.assertNull(device2.get().getLabel());
}
/**
* Coverage: Rename when a device with the target full-path name already exists (collision).
*/
protected void renameCollisionWhenTargetNameAlreadyExists_Test() throws Exception {
long ts = calendar.getTimeInMillis();
String shortName = deviceId + "_1"; // Created in beforeTest
String fullPathName = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + shortName;
// Manually create a device that already has the "new" full-path name to trigger a collision
createDevice(fullPathName, deviceProfile.getName(), false);
clientWithCorrectNodeAccessTokenWithNDEATH();
SparkplugBProto.Payload.Builder payload = SparkplugBProto.Payload.newBuilder()
.setTimestamp(ts)
.setSeq(getSeqNum());
payload.addMetrics(createMetric(123, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L));
// Gateway sends DBIRTH for the short name.
// Transport will try to rename it but should find a conflict and handle it gracefully.
client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + "/DBIRTH/" + edgeNode + TOPIC_SPLIT_SEPARATOR + shortName,
payload.build().toByteArray(), 0, false);
await("Checking stability after collision")
.atMost(40, TimeUnit.SECONDS)
.until(() -> {
Device oldDevice = doGet("/api/tenant/devices?deviceName=" + shortName, Device.class);
Device conflictDevice = doGet("/api/tenant/devices?deviceName=" + fullPathName, Device.class);
// Both devices must still exist, proving no exception crashed the process
return oldDevice != null && conflictDevice != null;
});
}
/**
* Coverage: The privilege concern — attempt to rename a device not owned by the gateway.
* This test verifies that the original device's ID remains unchanged, meaning it was not hijacked.
*/
protected void unauthorizedRenameAttemptBad_Test() throws Exception {
long ts = calendar.getTimeInMillis();
String strangerName = "unauthorized_device_rename";
// 1. Create a "stranger" device via API (it has no 'Created' relation to the gateway)
Device stranger = new Device();
stranger.setName(strangerName);
stranger.setType("default");
doPost("/api/device", stranger);
final DeviceId originalStrangerId = stranger.getId();
clientWithCorrectNodeAccessTokenWithNDEATH();
SparkplugBProto.Payload.Builder payload = SparkplugBProto.Payload.newBuilder()
.setTimestamp(ts).setSeq(getSeqNum());
// 2. Unauthorized gateway attempts to rename this device via Sparkplug topic path
client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + "/DBIRTH/" + edgeNode + TOPIC_SPLIT_SEPARATOR + strangerName,
payload.build().toByteArray(), 0, false);
String expectedFullPath = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + strangerName;
// 3. Verify security: the original device must still be linked to its short name with the same ID
await("Verify original device was not hijacked")
.atMost(40, TimeUnit.SECONDS)
.pollDelay(2, TimeUnit.SECONDS)
.untilAsserted(() -> {
// Check if the original device still exists with its original ID
Device currentStranger = doGet("/api/tenant/devices?deviceName=" + strangerName, Device.class);
assertNotNull("Original device disappeared!", currentStranger);
assertEquals("Security breach: Original device ID changed!", originalStrangerId, currentStranger.getId());
// Even if the gateway created a NEW device with a full path, it must have a different ID
Device newDevice = doGet("/api/tenant/devices?deviceName=" + expectedFullPath, Device.class);
if (newDevice != null) {
Assert.assertNotEquals("Stranger device was successfully hijacked (IDs match)!", originalStrangerId, newDevice.getId());
}
});
}
/**
* Coverage: The privilege concern — attempt to rename a device not owned by the gateway.
*/
protected void unauthorizedRenameAttempt_Test() throws Exception {
long ts = calendar.getTimeInMillis();
String strangerName = "unauthorized_device_rename";
// Create a device without a "Created" relation to the gateway
Device stranger = new Device();
stranger.setName(strangerName);
stranger.setType("default");
doPost("/api/device", stranger);
Device originalStrangerDevice =
doGet("/api/tenant/devices?deviceName=" + strangerName, Device.class);
clientWithCorrectNodeAccessTokenWithNDEATH();
SparkplugBProto.Payload.Builder payload = SparkplugBProto.Payload.newBuilder()
.setTimestamp(ts).setSeq(getSeqNum());
// Unauthorized gateway attempts to rename the device via Sparkplug topic
client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + "/DBIRTH/" + edgeNode + TOPIC_SPLIT_SEPARATOR + strangerName,
payload.build().toByteArray(), 0, false);
String expectedFullPath = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + strangerName;
await().atMost(30, TimeUnit.SECONDS).untilAsserted(() ->
doGet("/api/tenant/devices?deviceName=" + expectedFullPath, Device.class, status().isOk())
);
Device strangerDevice =
doGet("/api/tenant/devices?deviceName=" + strangerName, Device.class);
assertNotNull(strangerDevice);
assertEquals(originalStrangerDevice.getId(), strangerDevice.getId());
}
protected void state_ONLINE_ALL (List<Device> devices, long ts) {
TsKvEntry tsKvEntry = new BasicTsKvEntry(ts, new StringDataEntry(messageName(STATE), ONLINE.name()));
await(alias + messageName(STATE) + ", device: " + savedGateway.getName())
.atMost(40, TimeUnit.SECONDS)
.until(() -> {
var foundEntry = tsService.findAllLatest(tenantId, savedGateway.getId()).get().stream()
.filter(tsKv -> tsKv.getKey().equals(tsKvEntry.getKey()))
.filter(tsKv -> tsKv.getValue().equals(tsKvEntry.getValue()))
.filter(tsKv -> tsKv.getTs() == tsKvEntry.getTs())
.findFirst();
return foundEntry.isPresent();
});
for (Device device : devices) {
await(alias + messageName(STATE) + ", device: " + device.getName())
.atMost(40, TimeUnit.SECONDS)
.until(() -> {
var foundEntry = tsService.findAllLatest(tenantId, device.getId()).get().stream()
.filter(tsKv -> tsKv.getKey().equals(tsKvEntry.getKey()))
.filter(tsKv -> tsKv.getValue().equals(tsKvEntry.getValue()))
.filter(tsKv -> tsKv.getTs() == tsKvEntry.getTs())
.findFirst();
return foundEntry.isPresent();
});
}
}
/**
* Coverage: Concurrent first-message registration with the lock mechanism.
*/
protected void concurrentFirstMessageRegistration_Test() throws Exception {
int threadCount = 5;
String concurrentDeviceName = "concurrent_device";
clientWithCorrectNodeAccessTokenWithNDEATH();
ExecutorService executor = newFixedThreadPool(threadCount);
long ts = calendar.getTimeInMillis();
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
SparkplugBProto.Payload.Builder payload = SparkplugBProto.Payload.newBuilder()
.setTimestamp(ts).setSeq(0);
client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + "/DBIRTH/" + edgeNode + TOPIC_SPLIT_SEPARATOR + concurrentDeviceName,
payload.build().toByteArray(), 0, false);
} catch (Exception e) {
log.error("Concurrent publish failed", e);
}
});
}
String expectedName = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + concurrentDeviceName;
await("Wait for concurrent registration result")
.atMost(40, TimeUnit.SECONDS)
.until(() -> doGet("/api/tenant/devices?deviceName=" + expectedName, Device.class) != null);
executor.shutdown();
}
/**
* Coverage: Sparkplug-message handling when msgId <= 0 (#7).
* Verifies that the transport does not close the session for Sparkplug clients using msgId 0.
*/
protected void sparkplugSessionStaysAliveWithZeroMsgId_Test() throws Exception {
// clientMqttV5ConnectWithNDEATH internally sets msgId = 0 for the Will message.
// This validates that the connection is accepted despite msgId being 0.
IMqttToken connectionResult = clientMqttV5ConnectWithNDEATH(calendar.getTimeInMillis(), 0, -1L);
Assert.assertTrue("Sparkplug connection should be successful with msgId=0", client.isConnected());
// Publish NBIRTH message which usually goes through the aggregate callback.
// This verifies that msgId=0 in the callback does not trigger closeDeviceSession.
connectionWithNBirth(Int32, "test_metric_msgId_0", 555);
// Awaitility to ensure the session remains open after processing.
await("Verify Sparkplug session remains open after receiving msgId=0")
.atMost(40, TimeUnit.SECONDS)
.until(() -> client.isConnected());
}
protected List<Device> connectClientWithCorrectAccessTokenWithNDEATHWithAliasCreatedDevices(long ts) throws Exception { protected List<Device> connectClientWithCorrectAccessTokenWithNDEATHWithAliasCreatedDevices(long ts) throws Exception {
List<Device> devices = new ArrayList<>(); List<Device> devices = new ArrayList<>();
Long alias = 0L; Long alias = 0L;
@ -219,7 +509,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
payloadBirthNode.addMetrics(metric); payloadBirthNode.addMetrics(metric);
payloadBirthNode.setTimestamp(ts); payloadBirthNode.setTimestamp(ts);
if (client.isConnected()) { if (client.isConnected()) {
client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.NBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode,
payloadBirthNode.build().toByteArray(), 0, false); payloadBirthNode.build().toByteArray(), 0, false);
} }
@ -228,15 +518,16 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
SparkplugBProto.Payload.Builder payloadBirthDevice = SparkplugBProto.Payload.newBuilder() SparkplugBProto.Payload.Builder payloadBirthDevice = SparkplugBProto.Payload.newBuilder()
.setTimestamp(ts) .setTimestamp(ts)
.setSeq(getSeqNum()); .setSeq(getSeqNum());
String deviceName = deviceId + "_" + 1; String deviceIdName = deviceId + "_1";
String deviceName = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + deviceIdName;
payloadBirthDevice.addMetrics(metric); payloadBirthDevice.addMetrics(metric);
if (client.isConnected()) { if (client.isConnected()) {
client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.DBIRTH.name() + "/" + edgeNode + "/" + deviceName, client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.DBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode + TOPIC_SPLIT_SEPARATOR + deviceIdName,
payloadBirthDevice.build().toByteArray(), 0, false); payloadBirthDevice.build().toByteArray(), 0, false);
AtomicReference<Device> device = new AtomicReference<>(); AtomicReference<Device> device = new AtomicReference<>();
await(alias + "find device [" + deviceName + "] after created") await(alias + "find device [" + deviceName + "] after created")
.atMost(200, TimeUnit.SECONDS) .atMost(40, TimeUnit.SECONDS)
.ignoreExceptions() .ignoreExceptions()
.until(() -> { .until(() -> {
device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class)); device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class));
@ -245,7 +536,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
devices.add(device.get()); devices.add(device.get());
} }
Assert.assertEquals(1, devices.size()); assertEquals(1, devices.size());
return devices; return devices;
} }
@ -282,7 +573,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
listKeys.add(metricKey); listKeys.add(metricKey);
if (client.isConnected()) { if (client.isConnected()) {
client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.NBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode,
payloadBirthNode.build().toByteArray(), 0, false); payloadBirthNode.build().toByteArray(), 0, false);
} }
return listKeys; return listKeys;
@ -525,4 +816,9 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
} }
} }
private EntityRelation createFromRelation(Device mainDevice, Device device, String relationType) {
return new EntityRelation(mainDevice.getId(), device.getId(), relationType);
}
} }

30
application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java

@ -37,10 +37,10 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.OFFLINE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.OFFLINE;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.ONLINE;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.STATE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.STATE;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.messageName; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.messageName;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_SPLIT_SEPARATOR;
/** /**
* Created by nickAS21 on 12.01.23 * Created by nickAS21 on 12.01.23
@ -95,31 +95,7 @@ public abstract class AbstractMqttV5ClientSparkplugConnectionTest extends Abstra
protected void processConnectClientWithCorrectAccessTokenWithNDEATH_State_ONLINE_ALL(int cntDevices) throws Exception { protected void processConnectClientWithCorrectAccessTokenWithNDEATH_State_ONLINE_ALL(int cntDevices) throws Exception {
long ts = calendar.getTimeInMillis(); long ts = calendar.getTimeInMillis();
List<Device> devices = connectClientWithCorrectAccessTokenWithNDEATHCreatedDevices(cntDevices, ts); List<Device> devices = connectClientWithCorrectAccessTokenWithNDEATHCreatedDevices(cntDevices, ts);
state_ONLINE_ALL (devices, ts);
TsKvEntry tsKvEntry = new BasicTsKvEntry(ts, new StringDataEntry(messageName(STATE), ONLINE.name()));
await(alias + messageName(STATE) + ", device: " + savedGateway.getName())
.atMost(40, TimeUnit.SECONDS)
.until(() -> {
var foundEntry = tsService.findAllLatest(tenantId, savedGateway.getId()).get().stream()
.filter(tsKv -> tsKv.getKey().equals(tsKvEntry.getKey()))
.filter(tsKv -> tsKv.getValue().equals(tsKvEntry.getValue()))
.filter(tsKv -> tsKv.getTs() == tsKvEntry.getTs())
.findFirst();
return foundEntry.isPresent();
});
for (Device device : devices) {
await(alias + messageName(STATE) + ", device: " + device.getName())
.atMost(40, TimeUnit.SECONDS)
.until(() -> {
var foundEntry = tsService.findAllLatest(tenantId, device.getId()).get().stream()
.filter(tsKv -> tsKv.getKey().equals(tsKvEntry.getKey()))
.filter(tsKv -> tsKv.getValue().equals(tsKvEntry.getValue()))
.filter(tsKv -> tsKv.getTs() == tsKvEntry.getTs())
.findFirst();
return foundEntry.isPresent();
});
}
} }
protected void processConnectClientWithCorrectAccessTokenWithNDEATH_State_ONLINE_All_Then_OneDeviceOFFLINE(int cntDevices, int indexDeviceDisconnect) throws Exception { protected void processConnectClientWithCorrectAccessTokenWithNDEATH_State_ONLINE_All_Then_OneDeviceOFFLINE(int cntDevices, int indexDeviceDisconnect) throws Exception {
@ -135,7 +111,7 @@ public abstract class AbstractMqttV5ClientSparkplugConnectionTest extends Abstra
if (client.isConnected()) { if (client.isConnected()) {
List<Device> devicesList = new ArrayList<>(devices); List<Device> devicesList = new ArrayList<>(devices);
Device device = devicesList.get(indexDeviceDisconnect); Device device = devicesList.get(indexDeviceDisconnect);
client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.DDEATH.name() + "/" + edgeNode + "/" + device.getName(), client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.DDEATH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode + TOPIC_SPLIT_SEPARATOR + device.getName(),
payloadDeathDevice.build().toByteArray(), 0, false); payloadDeathDevice.build().toByteArray(), 0, false);
await(alias + messageName(STATE) + ", device: " + device.getName()) await(alias + messageName(STATE) + ", device: " + device.getName())
.atMost(40, TimeUnit.SECONDS) .atMost(40, TimeUnit.SECONDS)

72
application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionDevicesCreatingBeforeTest.java

@ -0,0 +1,72 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.transport.mqtt.sparkplug.connection;
import org.eclipse.paho.mqttv5.common.MqttException;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.thingsboard.server.dao.service.DaoSqlTest;
/**
* Created by nickAS21 on 16.02.26
*/
@DaoSqlTest
public class MqttV5ClientSparkplugBConnectionDevicesCreatingBeforeTest extends AbstractMqttV5ClientSparkplugConnectionTest {
/**
* String deviceName_1 = deviceId + "_1"; Only name device. Without a complete topic: how it was in the old version.
* String deviceName_2 = groupId + DEVICE_NAME_SPLIT_REGEXP + edgeNode + DEVICE_NAME_SPLIT_REGEXP + deviceId + "_2"; With complete topic: how it was in the new version.
* @throws Exception
*/
@Before
public void beforeTest() throws Exception {
beforeSparkplugTest();
seedLegacyAndFullPathDevices();
}
@After
public void afterTest() throws MqttException {
if (client.isConnected()) {
client.disconnect();
}
}
@Test
public void testClientWithCorrectAccessTokenWithNDEATHTwoDevicesCreatingBeforeFirstNameDeviceIdSecondNameFull() throws Exception {
connectClientWithCorrectAccessTokenWithNDEATHDevicesCreatingBefore_Test(2);
}
@Test
public void testRenameWhenDeviceFullPathAlreadyExists_Collision() throws Exception {
renameCollisionWhenTargetNameAlreadyExists_Test();
}
@Test
public void testUnauthorizedRenameAttempt() throws Exception {
unauthorizedRenameAttempt_Test();
}
@Test
public void testConcurrentFirstMessageRegistration() throws Exception {
concurrentFirstMessageRegistration_Test();
}
@Test
public void testSparkplugSessionStaysAliveWithZeroMsgId() throws Exception {
sparkplugSessionStaysAliveWithZeroMsgId_Test();
}
}

1
application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java

@ -47,6 +47,7 @@ public class MqttV5RpcSparkplugTest extends AbstractMqttV5RpcSparkplugTest {
public void testClientDeviceWithCorrectAccessTokenPublish_TwoWayRpc_Success() throws Exception { public void testClientDeviceWithCorrectAccessTokenPublish_TwoWayRpc_Success() throws Exception {
processClientDeviceWithCorrectAccessTokenPublish_TwoWayRpc_Success(); processClientDeviceWithCorrectAccessTokenPublish_TwoWayRpc_Success();
} }
@Test @Test
public void testClientDeviceWithCorrectAccessTokenPublishWithAlias_TwoWayRpc_Success() throws Exception { public void testClientDeviceWithCorrectAccessTokenPublishWithAlias_TwoWayRpc_Success() throws Exception {
processClientDeviceWithCorrectAccessTokenPublishWithAlias_TwoWayRpc_Success(); processClientDeviceWithCorrectAccessTokenPublishWithAlias_TwoWayRpc_Success();

5
application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java

@ -30,6 +30,7 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_SPLIT_SEPARATOR;
/** /**
* Created by nickAS21 on 12.01.23 * Created by nickAS21 on 12.01.23
@ -67,7 +68,7 @@ public abstract class AbstractMqttV5ClientSparkplugTelemetryTest extends Abstrac
createdAddMetricValuePrimitiveTsKv(listTsKvEntry, listKeys, ndataPayload, ts); createdAddMetricValuePrimitiveTsKv(listTsKvEntry, listKeys, ndataPayload, ts);
if (client.isConnected()) { if (client.isConnected()) {
client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + messageTypeName + "/" + edgeNode, client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + messageTypeName + TOPIC_SPLIT_SEPARATOR + edgeNode,
ndataPayload.build().toByteArray(), 0, false); ndataPayload.build().toByteArray(), 0, false);
} }
@ -96,7 +97,7 @@ public abstract class AbstractMqttV5ClientSparkplugTelemetryTest extends Abstrac
createdAddMetricValueArraysPrimitiveTsKv(listTsKvEntry, listKeys, ndataPayload, ts); createdAddMetricValueArraysPrimitiveTsKv(listTsKvEntry, listKeys, ndataPayload, ts);
if (client.isConnected()) { if (client.isConnected()) {
client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + messageTypeName + "/" + edgeNode, client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + messageTypeName + TOPIC_SPLIT_SEPARATOR + edgeNode,
ndataPayload.build().toByteArray(), 0, false); ndataPayload.build().toByteArray(), 0, false);
} }

3
application/src/test/resources/logback-test.xml

@ -18,9 +18,6 @@
<logger name="org.thingsboard.server.controller.AbstractWebTest" level="INFO"/> <logger name="org.thingsboard.server.controller.AbstractWebTest" level="INFO"/>
<logger name="org.thingsboard.server.service.script" level="INFO"/> <logger name="org.thingsboard.server.service.script" level="INFO"/>
<!-- mute TelemetryEdgeSqlTest that causes a lot of randomly generated errors -->
<logger name="org.thingsboard.server.service.edge.rpc.PostgresEdgeGrpcSession" level="OFF"/>
<!-- LwM2m lifecycle debug for the test scope --> <!-- LwM2m lifecycle debug for the test scope -->
<logger name="org.thingsboard.server.transport.lwm2m.server.downlink.DefaultLwM2mDownlinkMsgHandler" level="TRACE"/> <logger name="org.thingsboard.server.transport.lwm2m.server.downlink.DefaultLwM2mDownlinkMsgHandler" level="TRACE"/>
<logger name="org.thingsboard.server.transport.lwm2m.server.uplink.DefaultLwM2mUplinkMsgHandler" level="TRACE"/> <logger name="org.thingsboard.server.transport.lwm2m.server.uplink.DefaultLwM2mUplinkMsgHandler" level="TRACE"/>

2
common/actor/pom.xml

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

2
common/cache/pom.xml

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

2
common/cluster-api/pom.xml

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

2
common/coap-server/pom.xml

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

2
common/dao-api/pom.xml

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

2
common/data/pom.xml

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

1
common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java

@ -45,6 +45,7 @@ public class SystemParams {
long minAllowedAggregationIntervalInSecForCF; long minAllowedAggregationIntervalInSecForCF;
long intermediateAggregationIntervalInSecForCF; long intermediateAggregationIntervalInSecForCF;
TrendzSettings trendzSettings; TrendzSettings trendzSettings;
boolean allowKeyFiltersOrConditions;
String nullsOrderStrategy; String nullsOrderStrategy;
boolean edqsEnabled; boolean edqsEnabled;
String iotHubBaseUrl; String iotHubBaseUrl;

1
common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java

@ -24,6 +24,7 @@ import lombok.Data;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.ComplexFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.ComplexFilterPredicate;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityKeyValueType; import org.thingsboard.server.common.data.query.EntityKeyValueType;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;

1
common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java

@ -22,6 +22,7 @@ import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType;
import org.thingsboard.server.common.data.query.ComplexOperation;
import java.util.List; import java.util.List;

2
common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java

@ -18,7 +18,7 @@ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predi
import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; import org.thingsboard.server.common.data.query.ComplexOperation;
import java.util.List; import java.util.List;

12
common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java

@ -49,4 +49,16 @@ public abstract class AbstractDataQuery<T extends EntityDataPageLink> extends En
this.latestValues = latestValues; this.latestValues = latestValues;
} }
public AbstractDataQuery(EntityFilter entityFilter,
T pageLink,
List<EntityKey> entityFields,
List<EntityKey> latestValues,
List<KeyFilter> keyFilters,
ComplexOperation keyFiltersOperation) {
super(entityFilter, keyFilters, keyFiltersOperation);
this.pageLink = pageLink;
this.entityFields = entityFields;
this.latestValues = latestValues;
}
} }

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

Loading…
Cancel
Save